From 0c85dbf914a927e438cb9e83cca8feca15fb1575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 19 Nov 2024 10:13:24 +0000 Subject: [PATCH 001/917] refactor(rust): introduce an BaseHTTPClientError * Try to not rely on the generic ServiceError too much. --- rust/agama-cli/src/lib.rs | 6 +- rust/agama-lib/src/base_http_client.rs | 108 ++++++++++++------ rust/agama-lib/src/error.rs | 8 +- .../agama-lib/src/localization/http_client.rs | 8 +- rust/agama-lib/src/localization/store.rs | 13 +-- rust/agama-lib/src/manager/http_client.rs | 30 ++--- rust/agama-lib/src/product/http_client.rs | 18 +-- rust/agama-lib/src/questions/http_client.rs | 22 ++-- rust/agama-lib/src/scripts/client.rs | 10 +- rust/agama-lib/src/software/http_client.rs | 12 +- rust/agama-lib/src/storage/http_client.rs | 7 +- rust/agama-lib/src/storage/store.rs | 9 +- rust/agama-lib/src/users/http_client.rs | 22 ++-- rust/agama-lib/src/users/store.rs | 2 +- 14 files changed, 157 insertions(+), 118 deletions(-) diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index f4fa82312d..6044e2be0a 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -30,7 +30,7 @@ mod progress; mod questions; use crate::error::CliError; -use agama_lib::base_http_client::BaseHTTPClient; +use agama_lib::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use agama_lib::{ error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, transfer::Transfer, }; @@ -167,12 +167,12 @@ async fn allowed_insecure_api(use_insecure: bool, api_url: String) -> Result>("/ping").await { // Problem with http remote API reachability - Err(ServiceError::HTTPError(_)) => Ok(use_insecure || Confirm::new("There was a problem with the remote API and it is treated as insecure. Do you want to continue?") + Err(BaseHTTPClientError::HTTPError(_)) => Ok(use_insecure || Confirm::new("There was a problem with the remote API and it is treated as insecure. Do you want to continue?") .with_default(false) .prompt() .unwrap_or(false)), // another error - Err(e) => Err(e), + Err(e) => Err(ServiceError::HTTPClientError(e)), // success doesn't bother us here Ok(_) => Ok(false) } diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index bb29e5bf48..3697aa0a81 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -21,7 +21,7 @@ use reqwest::{header, Response}; use serde::{de::DeserializeOwned, Serialize}; -use crate::{auth::AuthToken, error::ServiceError}; +use crate::auth::AuthToken; /// Base that all HTTP clients should use. /// @@ -32,10 +32,9 @@ use crate::{auth::AuthToken, error::ServiceError}; /// /// ```no_run /// use agama_lib::questions::model::Question; -/// use agama_lib::base_http_client::BaseHTTPClient; -/// use agama_lib::error::ServiceError; +/// use agama_lib::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; /// -/// async fn get_questions() -> Result, ServiceError> { +/// async fn get_questions() -> Result, BaseHTTPClientError> { /// let client = BaseHTTPClient::default(); /// client.get("/questions").await /// } @@ -48,6 +47,28 @@ pub struct BaseHTTPClient { pub base_url: String, } +#[derive(thiserror::Error, Debug)] +pub enum BaseHTTPClientError { + #[error("Backend call failed with status {0} and text '{1}'")] + BackendError(u16, String), + #[error("You are not logged in. Please use: agama auth login")] + NotAuthenticated, + #[error("HTTP error: {0}")] + HTTPError(#[from] reqwest::Error), + #[error("Deserialization error: {0}")] + DeserializeError(#[from] serde_json::Error), + #[error("Invalid header value: {0}")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), + #[error("Non-ASCII characters in the header value: {0}")] + ToStrError(#[from] reqwest::header::ToStrError), + #[error("Missing header: {0}")] + MissingHeader(String), + #[error("Validation error: {0:?}")] + Validation(Vec), + #[error("I/O errorr: {0}")] + IO(#[from] std::io::Error), +} + const API_URL: &str = "http://localhost/api"; impl Default for BaseHTTPClient { @@ -73,7 +94,7 @@ impl BaseHTTPClient { } /// Uses `localhost`, authenticates with [`AuthToken`]. - pub fn authenticated(self) -> Result { + pub fn authenticated(self) -> Result { Ok(Self { client: Self::authenticated_client(self.insecure)?, ..self @@ -81,25 +102,23 @@ impl BaseHTTPClient { } /// Configures itself for connection(s) without authentication token - pub fn unauthenticated(self) -> Result { + pub fn unauthenticated(self) -> Result { Ok(Self { client: reqwest::Client::builder() .danger_accept_invalid_certs(self.insecure) - .build() - .map_err(anyhow::Error::new)?, + .build()?, ..self }) } - fn authenticated_client(insecure: bool) -> Result { + fn authenticated_client(insecure: bool) -> Result { // TODO: this error is subtly misleading, leading me to believe the SERVER said it, // but in fact it is the CLIENT not finding an auth token - let token = AuthToken::find().ok_or(ServiceError::NotAuthenticated)?; + let token = AuthToken::find().ok_or(BaseHTTPClientError::NotAuthenticated)?; let mut headers = header::HeaderMap::new(); // just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token - let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) - .map_err(anyhow::Error::new)?; + let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str())?; headers.insert(header::AUTHORIZATION, value); @@ -119,11 +138,11 @@ impl BaseHTTPClient { /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions` - pub async fn get(&self, path: &str) -> Result + pub async fn get(&self, path: &str) -> Result where T: DeserializeOwned, { - let response: Result<_, ServiceError> = self + let response: Result<_, BaseHTTPClientError> = self .client .get(self.url(path)) .send() @@ -132,7 +151,11 @@ impl BaseHTTPClient { self.deserialize_or_error(response?).await } - pub async fn post(&self, path: &str, object: &impl Serialize) -> Result + pub async fn post( + &self, + path: &str, + object: &impl Serialize, + ) -> Result where T: DeserializeOwned, { @@ -148,7 +171,11 @@ impl BaseHTTPClient { /// /// * `path`: path relative to HTTP API like `/questions` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn post_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { + pub async fn post_void( + &self, + path: &str, + object: &impl Serialize, + ) -> Result<(), BaseHTTPClientError> { let response = self .request_response(reqwest::Method::POST, path, object) .await?; @@ -161,7 +188,11 @@ impl BaseHTTPClient { /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn put(&self, path: &str, object: &impl Serialize) -> Result + pub async fn put( + &self, + path: &str, + object: &impl Serialize, + ) -> Result where T: DeserializeOwned, { @@ -177,7 +208,11 @@ impl BaseHTTPClient { /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn put_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { + pub async fn put_void( + &self, + path: &str, + object: &impl Serialize, + ) -> Result<(), BaseHTTPClientError> { let response = self .request_response(reqwest::Method::PUT, path, object) .await?; @@ -190,7 +225,11 @@ impl BaseHTTPClient { /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. - pub async fn patch(&self, path: &str, object: &impl Serialize) -> Result + pub async fn patch( + &self, + path: &str, + object: &impl Serialize, + ) -> Result where T: DeserializeOwned, { @@ -204,7 +243,7 @@ impl BaseHTTPClient { &self, path: &str, object: &impl Serialize, - ) -> Result<(), ServiceError> { + ) -> Result<(), BaseHTTPClientError> { let response = self .request_response(reqwest::Method::PATCH, path, object) .await?; @@ -216,8 +255,8 @@ impl BaseHTTPClient { /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions/1` - pub async fn delete_void(&self, path: &str) -> Result<(), ServiceError> { - let response: Result<_, ServiceError> = self + pub async fn delete_void(&self, path: &str) -> Result<(), BaseHTTPClientError> { + let response: Result<_, BaseHTTPClientError> = self .client .delete(self.url(path)) .send() @@ -228,8 +267,8 @@ impl BaseHTTPClient { /// Returns raw reqwest::Response. Use e.g. in case when response content is not /// JSON body but e.g. binary data - pub async fn get_raw(&self, path: &str) -> Result { - let raw: Result<_, ServiceError> = self + pub async fn get_raw(&self, path: &str) -> Result { + let raw: Result<_, BaseHTTPClientError> = self .client .get(self.url(path)) .send() @@ -260,7 +299,7 @@ impl BaseHTTPClient { method: reqwest::Method, path: &str, object: &impl Serialize, - ) -> Result { + ) -> Result { self.client .request(method, self.url(path)) .json(object) @@ -269,8 +308,8 @@ impl BaseHTTPClient { .map_err(|e| e.into()) } - /// Return deserialized JSON body as `Ok(T)` or an `Err` with [`ServiceError::BackendError`] - async fn deserialize_or_error(&self, response: Response) -> Result + /// Return deserialized JSON body as `Ok(T)` or an `Err` with [`BaseHTTPClientError::BackendError`] + async fn deserialize_or_error(&self, response: Response) -> Result where T: DeserializeOwned, { @@ -283,21 +322,22 @@ impl BaseHTTPClient { // BUT also peek into the response text, in case something is wrong // so this copies the implementation from the above and adds a debug part - let bytes_r: Result<_, ServiceError> = response.bytes().await.map_err(|e| e.into()); + let bytes_r: Result<_, BaseHTTPClientError> = + response.bytes().await.map_err(|e| e.into()); let bytes = bytes_r?; // DEBUG: (we expect JSON so dbg! would escape too much, eprintln! is better) // let text = String::from_utf8_lossy(&bytes); // eprintln!("Response body: {}", text); - serde_json::from_slice(&bytes).map_err(|e| e.into()) + Ok(serde_json::from_slice(&bytes)?) } else { Err(self.build_backend_error(response).await) } } - /// Return `Ok(())` or an `Err` with [`ServiceError::BackendError`] - async fn unit_or_error(&self, response: Response) -> Result<(), ServiceError> { + /// Return `Ok(())` or an `Err` with [`BaseHTTPClientError::BackendError`] + async fn unit_or_error(&self, response: Response) -> Result<(), BaseHTTPClientError> { if response.status().is_success() { Ok(()) } else { @@ -306,19 +346,19 @@ impl BaseHTTPClient { } const NO_TEXT: &'static str = "(Failed to extract error text from HTTP response)"; - /// Builds [`ServiceError::BackendError`] from response. + /// Builds [`BaseHTTPClientError::BackendError`] from response. /// /// It contains also processing of response body, that is why it has to be async. /// /// Arguments: /// /// * `response`: response from which generate error - async fn build_backend_error(&self, response: Response) -> ServiceError { + async fn build_backend_error(&self, response: Response) -> BaseHTTPClientError { let code = response.status().as_u16(); let text = response .text() .await .unwrap_or_else(|_| Self::NO_TEXT.to_string()); - ServiceError::BackendError(code, text) + BaseHTTPClientError::BackendError(code, text) } } diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 380aaafd8d..dfec502d7a 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -23,7 +23,7 @@ use std::io; use thiserror::Error; use zbus::{self, zvariant}; -use crate::transfer::TransferError; +use crate::{base_http_client::BaseHTTPClientError, transfer::TransferError}; #[derive(Error, Debug)] pub enum ServiceError { @@ -39,6 +39,8 @@ pub enum ServiceError { ZVariant(#[from] zvariant::Error), #[error("Failed to communicate with the HTTP backend '{0}'")] HTTPError(#[from] reqwest::Error), + #[error("HTTP client error: {0}")] + HTTPClientError(#[from] BaseHTTPClientError), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] @@ -60,10 +62,6 @@ pub enum ServiceError { UnknownInstallationPhase(u32), #[error("Question with id {0} does not exist")] QuestionNotExist(u32), - #[error("Backend call failed with status {0} and text '{1}'")] - BackendError(u16, String), - #[error("You are not logged in. Please use: agama auth login")] - NotAuthenticated, // Specific error when something does not work as expected, but it is not user fault #[error("Internal error. Please report a bug and attach logs. Details: {0}")] InternalError(String), diff --git a/rust/agama-lib/src/localization/http_client.rs b/rust/agama-lib/src/localization/http_client.rs index 5e24e46aaa..60dc52a88a 100644 --- a/rust/agama-lib/src/localization/http_client.rs +++ b/rust/agama-lib/src/localization/http_client.rs @@ -19,22 +19,22 @@ // find current contact information at www.suse.com. use super::model::LocaleConfig; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; pub struct LocalizationHTTPClient { client: BaseHTTPClient, } impl LocalizationHTTPClient { - pub fn new(base: BaseHTTPClient) -> Result { + pub fn new(base: BaseHTTPClient) -> Result { Ok(Self { client: base }) } - pub async fn get_config(&self) -> Result { + pub async fn get_config(&self) -> Result { self.client.get("/l10n/config").await } - pub async fn set_config(&self, config: &LocaleConfig) -> Result<(), ServiceError> { + pub async fn set_config(&self, config: &LocaleConfig) -> Result<(), BaseHTTPClientError> { self.client.patch_void("/l10n/config", config).await } } diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs index 7946e8476c..b6cfdf0ca1 100644 --- a/rust/agama-lib/src/localization/store.rs +++ b/rust/agama-lib/src/localization/store.rs @@ -22,8 +22,7 @@ // TODO: for an overview see crate::store (?) use super::{LocalizationHTTPClient, LocalizationSettings}; -use crate::base_http_client::BaseHTTPClient; -use crate::error::ServiceError; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::localization::model::LocaleConfig; /// Loads and stores the storage settings from/to the D-Bus service. @@ -32,7 +31,7 @@ pub struct LocalizationStore { } impl LocalizationStore { - pub fn new(client: BaseHTTPClient) -> Result { + pub fn new(client: BaseHTTPClient) -> Result { Ok(Self { localization_client: LocalizationHTTPClient::new(client)?, }) @@ -40,7 +39,7 @@ impl LocalizationStore { pub fn new_with_client( client: LocalizationHTTPClient, - ) -> Result { + ) -> Result { Ok(Self { localization_client: client, }) @@ -56,7 +55,7 @@ impl LocalizationStore { } } - pub async fn load(&self) -> Result { + pub async fn load(&self) -> Result { let config = self.localization_client.get_config().await?; let opt_language = config.locales.and_then(Self::chestburster); @@ -70,7 +69,7 @@ impl LocalizationStore { }) } - pub async fn store(&self, settings: &LocalizationSettings) -> Result<(), ServiceError> { + pub async fn store(&self, settings: &LocalizationSettings) -> Result<(), BaseHTTPClientError> { // clones are necessary as we have different structs owning their data let opt_language = settings.language.clone(); let opt_keymap = settings.keyboard.clone(); @@ -98,7 +97,7 @@ mod test { async fn localization_store( mock_server_url: String, - ) -> Result { + ) -> Result { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let client = LocalizationHTTPClient::new(bhc)?; diff --git a/rust/agama-lib/src/manager/http_client.rs b/rust/agama-lib/src/manager/http_client.rs index 269ecc006f..f54f0d5d27 100644 --- a/rust/agama-lib/src/manager/http_client.rs +++ b/rust/agama-lib/src/manager/http_client.rs @@ -19,7 +19,8 @@ // find current contact information at www.suse.com. use crate::{ - base_http_client::BaseHTTPClient, error::ServiceError, logs::LogsLists, + base_http_client::{BaseHTTPClient, BaseHTTPClientError}, + logs::LogsLists, manager::InstallerStatus, }; use reqwest::header::CONTENT_ENCODING; @@ -36,7 +37,7 @@ impl ManagerHTTPClient { } /// Starts a "probing". - pub async fn probe(&self) -> Result<(), ServiceError> { + pub async fn probe(&self) -> Result<(), BaseHTTPClientError> { // BaseHTTPClient did not anticipate POST without request body // so we pass () which is rendered as `null` self.client.post_void("/manager/probe_sync", &()).await @@ -48,7 +49,7 @@ impl ManagerHTTPClient { /// will be added according to the compression type found in the response /// /// Returns path to logs - pub async fn store(&self, path: &Path) -> Result { + pub async fn store(&self, path: &Path) -> Result { // 1) response with logs let response = self.client.get_raw("/manager/logs/store").await?; @@ -57,37 +58,30 @@ impl ManagerHTTPClient { &response .headers() .get(CONTENT_ENCODING) - .ok_or(ServiceError::CannotGenerateLogs(String::from( - "Invalid response", - )))?; + .ok_or(BaseHTTPClientError::MissingHeader( + CONTENT_ENCODING.to_string(), + ))?; let mut destination = path.to_path_buf(); - destination.set_extension( - ext.to_str() - .map_err(|_| ServiceError::CannotGenerateLogs(String::from("Invalid response")))?, - ); + destination.set_extension(ext.to_str()?); // 3) store response's binary content (logs) in a file - let mut file = std::fs::File::create(destination.as_path()).map_err(|_| { - ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) - })?; + let mut file = std::fs::File::create(destination.as_path())?; let mut content = Cursor::new(response.bytes().await?); - std::io::copy(&mut content, &mut file).map_err(|_| { - ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) - })?; + std::io::copy(&mut content, &mut file)?; Ok(destination) } /// Asks backend for lists of log files and commands used for creating logs archive returned by /// store (/logs/store) backed HTTP API command - pub async fn list(&self) -> Result { + pub async fn list(&self) -> Result { self.client.get("/manager/logs/list").await } /// Returns the installer status. - pub async fn status(&self) -> Result { + pub async fn status(&self) -> Result { self.client .get::("/manager/installer") .await diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs index 424a8b49f9..2c9523bf80 100644 --- a/rust/agama-lib/src/product/http_client.rs +++ b/rust/agama-lib/src/product/http_client.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::software::model::RegistrationInfo; use crate::software::model::RegistrationParams; use crate::software::model::SoftwareConfig; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct ProductHTTPClient { client: BaseHTTPClient, @@ -32,16 +32,16 @@ impl ProductHTTPClient { Self { client: base } } - pub async fn get_software(&self) -> Result { + pub async fn get_software(&self) -> Result { self.client.get("/software/config").await } - pub async fn set_software(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { + pub async fn set_software(&self, config: &SoftwareConfig) -> Result<(), BaseHTTPClientError> { self.client.put_void("/software/config", config).await } /// Returns the id of the selected product to install - pub async fn product(&self) -> Result { + pub async fn product(&self) -> Result { let config = self.get_software().await?; if let Some(product) = config.product { Ok(product) @@ -51,7 +51,7 @@ impl ProductHTTPClient { } /// Selects the product to install - pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { + pub async fn select_product(&self, product_id: &str) -> Result<(), BaseHTTPClientError> { let config = SoftwareConfig { product: Some(product_id.to_owned()), patterns: None, @@ -59,12 +59,16 @@ impl ProductHTTPClient { self.set_software(&config).await } - pub async fn get_registration(&self) -> Result { + pub async fn get_registration(&self) -> Result { self.client.get("/software/registration").await } /// register product - pub async fn register(&self, key: &str, email: &str) -> Result<(u32, String), ServiceError> { + pub async fn register( + &self, + key: &str, + email: &str, + ) -> Result<(u32, String), BaseHTTPClientError> { // note RegistrationParams != RegistrationInfo, fun! let params = RegistrationParams { key: key.to_owned(), diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index a63520ed3a..3e2b502f5d 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -23,7 +23,7 @@ use std::time::Duration; use reqwest::StatusCode; use tokio::time::sleep; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use super::model::{self, Answer, Question}; @@ -32,25 +32,31 @@ pub struct HTTPClient { } impl HTTPClient { - pub fn new(client: BaseHTTPClient) -> Result { + pub fn new(client: BaseHTTPClient) -> Result { Ok(Self { client }) } - pub async fn list_questions(&self) -> Result, ServiceError> { + pub async fn list_questions(&self) -> Result, BaseHTTPClientError> { self.client.get("/questions").await } /// Creates question and return newly created question including id - pub async fn create_question(&self, question: &Question) -> Result { + pub async fn create_question( + &self, + question: &Question, + ) -> Result { self.client.post("/questions", question).await } /// non blocking varient of checking if question has already answer - pub async fn try_answer(&self, question_id: u32) -> Result, ServiceError> { + pub async fn try_answer( + &self, + question_id: u32, + ) -> Result, BaseHTTPClientError> { let path = format!("/questions/{}/answer", question_id); let result: Result, _> = self.client.get(path.as_str()).await; match result { - Err(ServiceError::BackendError(code, ref _body_s)) => { + Err(BaseHTTPClientError::BackendError(code, ref _body_s)) => { if code == StatusCode::NOT_FOUND { Ok(None) // no answer yet, fine } else { @@ -62,7 +68,7 @@ impl HTTPClient { } /// Blocking variant of getting answer for given question. - pub async fn get_answer(&self, question_id: u32) -> Result { + pub async fn get_answer(&self, question_id: u32) -> Result { loop { let answer = self.try_answer(question_id).await?; if let Some(result) = answer { @@ -76,7 +82,7 @@ impl HTTPClient { } } - pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> { + pub async fn delete_question(&self, question_id: u32) -> Result<(), BaseHTTPClientError> { let path = format!("/questions/{}", question_id); self.client.delete_void(path.as_str()).await } diff --git a/rust/agama-lib/src/scripts/client.rs b/rust/agama-lib/src/scripts/client.rs index dfb05ab703..b2417d030e 100644 --- a/rust/agama-lib/src/scripts/client.rs +++ b/rust/agama-lib/src/scripts/client.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use super::{Script, ScriptsGroup}; @@ -35,24 +35,24 @@ impl ScriptsClient { /// Adds a script to the given group. /// /// * `script`: script's definition. - pub async fn add_script(&self, script: Script) -> Result<(), ServiceError> { + pub async fn add_script(&self, script: Script) -> Result<(), BaseHTTPClientError> { self.client.post_void("/scripts", &script).await } /// Runs user-defined scripts of the given group. /// /// * `group`: group of the scripts to run - pub async fn run_scripts(&self, group: ScriptsGroup) -> Result<(), ServiceError> { + pub async fn run_scripts(&self, group: ScriptsGroup) -> Result<(), BaseHTTPClientError> { self.client.post_void("/scripts/run", &group).await } /// Returns the user-defined scripts. - pub async fn scripts(&self) -> Result, ServiceError> { + pub async fn scripts(&self) -> Result, BaseHTTPClientError> { self.client.get("/scripts").await } /// Remove all the user-defined scripts. - pub async fn delete_scripts(&self) -> Result<(), ServiceError> { + pub async fn delete_scripts(&self) -> Result<(), BaseHTTPClientError> { self.client.delete_void("/scripts").await } } diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs index 21877e9b84..26b03ccd43 100644 --- a/rust/agama-lib/src/software/http_client.rs +++ b/rust/agama-lib/src/software/http_client.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::software::model::SoftwareConfig; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use std::collections::HashMap; use super::model::{ResolvableParams, ResolvableType}; @@ -33,11 +33,11 @@ impl SoftwareHTTPClient { Self { client: base } } - pub async fn get_config(&self) -> Result { + pub async fn get_config(&self) -> Result { self.client.get("/software/config").await } - pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { + pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), BaseHTTPClientError> { // FIXME: test how errors come out: // unknown pattern name, // D-Bus client returns @@ -48,7 +48,7 @@ impl SoftwareHTTPClient { } /// Returns the ids of patterns selected by user - pub async fn user_selected_patterns(&self) -> Result, ServiceError> { + pub async fn user_selected_patterns(&self) -> Result, BaseHTTPClientError> { // TODO: this way we unnecessarily ask D-Bus (via web.rs) also for the product and then ignore it let config = self.get_config().await?; @@ -68,7 +68,7 @@ impl SoftwareHTTPClient { pub async fn select_patterns( &self, patterns: HashMap, - ) -> Result<(), ServiceError> { + ) -> Result<(), BaseHTTPClientError> { let config = SoftwareConfig { product: None, // TODO: SoftwareStore only passes true bools, false branch is untested @@ -84,7 +84,7 @@ impl SoftwareHTTPClient { r#type: ResolvableType, names: &[&str], optional: bool, - ) -> Result<(), ServiceError> { + ) -> Result<(), BaseHTTPClientError> { let path = format!("/software/resolvables/{}", name); let options = ResolvableParams { names: names.iter().map(|n| n.to_string()).collect(), diff --git a/rust/agama-lib/src/storage/http_client.rs b/rust/agama-lib/src/storage/http_client.rs index 402dc261a5..46b31f7a3b 100644 --- a/rust/agama-lib/src/storage/http_client.rs +++ b/rust/agama-lib/src/storage/http_client.rs @@ -19,9 +19,8 @@ // find current contact information at www.suse.com. //! Implements a client to access Agama's storage service. -use crate::base_http_client::BaseHTTPClient; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::storage::StorageSettings; -use crate::ServiceError; pub struct StorageHTTPClient { client: BaseHTTPClient, @@ -32,11 +31,11 @@ impl StorageHTTPClient { Self { client: base } } - pub async fn get_config(&self) -> Result { + pub async fn get_config(&self) -> Result { self.client.get("/storage/config").await } - pub async fn set_config(&self, config: &StorageSettings) -> Result<(), ServiceError> { + pub async fn set_config(&self, config: &StorageSettings) -> Result<(), BaseHTTPClientError> { self.client.put_void("/storage/config", config).await } } diff --git a/rust/agama-lib/src/storage/store.rs b/rust/agama-lib/src/storage/store.rs index 6f835bc4c2..458040bdb6 100644 --- a/rust/agama-lib/src/storage/store.rs +++ b/rust/agama-lib/src/storage/store.rs @@ -21,8 +21,7 @@ //! Implements the store for the storage settings. use super::StorageSettings; -use crate::base_http_client::BaseHTTPClient; -use crate::error::ServiceError; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::storage::http_client::StorageHTTPClient; /// Loads and stores the storage settings from/to the HTTP service. @@ -31,17 +30,17 @@ pub struct StorageStore { } impl StorageStore { - pub fn new(client: BaseHTTPClient) -> Result { + pub fn new(client: BaseHTTPClient) -> Result { Ok(Self { storage_client: StorageHTTPClient::new(client), }) } - pub async fn load(&self) -> Result { + pub async fn load(&self) -> Result { self.storage_client.get_config().await } - pub async fn store(&self, settings: &StorageSettings) -> Result<(), ServiceError> { + pub async fn store(&self, settings: &StorageSettings) -> Result<(), BaseHTTPClientError> { self.storage_client.set_config(settings).await?; Ok(()) } diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs index a879826ddc..75323ded1c 100644 --- a/rust/agama-lib/src/users/http_client.rs +++ b/rust/agama-lib/src/users/http_client.rs @@ -19,39 +19,39 @@ // find current contact information at www.suse.com. use super::client::FirstUser; +use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; use crate::users::model::{RootConfig, RootPatchSettings}; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct UsersHTTPClient { client: BaseHTTPClient, } impl UsersHTTPClient { - pub fn new(client: BaseHTTPClient) -> Result { + pub fn new(client: BaseHTTPClient) -> Result { Ok(Self { client }) } /// Returns the settings for first non admin user - pub async fn first_user(&self) -> Result { + pub async fn first_user(&self) -> Result { self.client.get("/users/first").await } /// Set the configuration for the first user - pub async fn set_first_user(&self, first_user: &FirstUser) -> Result<(), ServiceError> { + pub async fn set_first_user(&self, first_user: &FirstUser) -> Result<(), BaseHTTPClientError> { let result = self.client.put_void("/users/first", first_user).await; - if let Err(ServiceError::BackendError(422, ref issues_s)) = result { + if let Err(BaseHTTPClientError::BackendError(422, ref issues_s)) = result { let issues: Vec = serde_json::from_str(issues_s)?; - return Err(ServiceError::WrongUser(issues)); + return Err(BaseHTTPClientError::Validation(issues)); } result } - async fn root_config(&self) -> Result { + async fn root_config(&self) -> Result { self.client.get("/users/root").await } /// Whether the root password is set or not - pub async fn is_root_password(&self) -> Result { + pub async fn is_root_password(&self) -> Result { let root_config = self.root_config().await?; Ok(root_config.password) } @@ -62,7 +62,7 @@ impl UsersHTTPClient { &self, value: &str, encrypted: bool, - ) -> Result { + ) -> Result { let rps = RootPatchSettings { sshkey: None, password: Some(value.to_owned()), @@ -73,14 +73,14 @@ impl UsersHTTPClient { } /// Returns the SSH key for the root user - pub async fn root_ssh_key(&self) -> Result { + pub async fn root_ssh_key(&self) -> Result { let root_config = self.root_config().await?; Ok(root_config.sshkey) } /// SetRootSSHKey method. /// Returns 0 if successful (always, for current backend) - pub async fn set_root_sshkey(&self, value: &str) -> Result { + pub async fn set_root_sshkey(&self, value: &str) -> Result { let rps = RootPatchSettings { sshkey: Some(value.to_owned()), password: None, diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 67289d57d4..8d3f8d9e42 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -80,7 +80,7 @@ impl UsersStore { password: settings.password.clone().unwrap_or_default(), encrypted_password: settings.encrypted_password.unwrap_or_default(), }; - self.users_client.set_first_user(&first_user).await + Ok(self.users_client.set_first_user(&first_user).await?) } async fn store_root_user(&self, settings: &RootUserSettings) -> Result<(), ServiceError> { From 850c6b73eccfa4aaf40414942ba8eb7cf8294706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 18 Nov 2024 17:44:56 +0000 Subject: [PATCH 002/917] feat(rust): add a ProductsRegistry struct --- rust/Cargo.lock | 20 ++ rust/agama-lib/src/software/model.rs | 6 +- rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/lib.rs | 1 + rust/agama-server/src/products.rs | 172 +++++++++++++ .../tests/share/products.d/tumbleweed.yaml | 225 ++++++++++++++++++ 6 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 rust/agama-server/src/products.rs create mode 100644 rust/agama-server/tests/share/products.d/tumbleweed.yaml diff --git a/rust/Cargo.lock b/rust/Cargo.lock index aa76a0f561..b901ba0252 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -115,6 +115,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "subprocess", "thiserror", "tokio", @@ -3476,6 +3477,19 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.6.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4243,6 +4257,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index 32618542f1..3ab8263040 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -51,10 +51,12 @@ pub struct RegistrationInfo { #[derive( Clone, - Default, + Copy, Debug, - Serialize, + Default, Deserialize, + PartialEq, + Serialize, strum::Display, strum::EnumString, utoipa::ToSchema, diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index efde389cf8..50de3c4ae0 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -49,6 +49,7 @@ libsystemd = "0.7.0" subprocess = "0.2.9" gethostname = "0.4.3" tokio-util = "0.7.12" +serde_yaml = "0.9.34" [[bin]] name = "agama-dbus-server" diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index c26eaa774f..77aa110ce1 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -25,6 +25,7 @@ pub mod l10n; pub mod logs; pub mod manager; pub mod network; +pub mod products; pub mod questions; pub mod scripts; pub mod software; diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs new file mode 100644 index 0000000000..ae296d8dd4 --- /dev/null +++ b/rust/agama-server/src/products.rs @@ -0,0 +1,172 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a products registry. +//! +//! The products registry contains the specification of every known product. +//! It reads the list of products from the `products.d` directory (usually, +//! `/usr/share/agama/products.d`). + +use agama_lib::product::RegistrationRequirement; +use serde::Deserialize; +use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; +use std::path::{Path, PathBuf}; + +#[derive(thiserror::Error, Debug)] +pub enum ProductsRegistryError { + #[error("Could not read the products registry: {0}")] + IO(#[from] std::io::Error), + #[error("Could not deserialize a product specification: {0}")] + Deserialize(#[from] serde_yaml::Error), +} + +/// Products registry. +/// +/// It holds the products specifications. At runtime it is possible to change the `products.d` +/// location by setting the `AGAMA_PRODUCTS_DIR` environment variable. +/// +/// Dynamic behavior, like filtering by architecture, is not supported yet. +#[derive(Clone, Default, Debug, Deserialize)] +pub struct ProductsRegistry { + pub products: Vec, +} + +impl ProductsRegistry { + /// Creates a registry loading the products from the default location. + pub fn load() -> Result { + let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { + PathBuf::from(dir) + } else { + PathBuf::from("/usr/share/agama/products.d") + }; + + if !products_dir.exists() { + return Err(ProductsRegistryError::IO(std::io::Error::new( + std::io::ErrorKind::NotFound, + "products.d directory does not exist", + ))); + } + + Self::load_from(products_dir) + } + + /// Creates a registry loading the products from the given location. + pub fn load_from>(products_path: P) -> Result { + let entries = std::fs::read_dir(products_path)?; + let mut products = vec![]; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + let Some(ext) = path.extension() else { + continue; + }; + + if path.is_file() && (ext == "yaml" || ext == "yml") { + let product = ProductSpec::load_from(path)?; + products.push(product); + } + } + + Ok(Self { products }) + } + + /// Determines whether the are are multiple products. + pub fn is_multiproduct(&self) -> bool { + self.products.len() > 1 + } +} + +// TODO: ideally, part of this code could be auto-generated from a JSON schema definition. +/// Product specification (e.g., Tumbleweed). +#[derive(Clone, Debug, Deserialize)] +pub struct ProductSpec { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + #[serde(default = "RegistrationRequirement::default")] + pub registration: RegistrationRequirement, + pub version: Option, + pub software: SoftwareSpec, +} + +impl ProductSpec { + pub fn load_from>(path: P) -> Result { + let contents = std::fs::read_to_string(path)?; + let product: ProductSpec = serde_yaml::from_str(&contents)?; + Ok(product) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SoftwareSpec { + pub installation_repositories: Vec, + pub installation_labels: Vec, + pub mandatory_patterns: Vec, + pub mandatory_packages: Vec, + // TODO: the specification should always be a vector (even if empty). + pub optional_patterns: Option>, + pub optional_packages: Option>, + pub base_product: String, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct RepositorySpec { + pub url: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct LabelSpec { + pub label: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_load_registry() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let config = ProductsRegistry::load_from(path.as_path()).unwrap(); + assert_eq!(config.products.len(), 1); + + let product = &config.products[0]; + assert_eq!(product.id, "Tumbleweed"); + assert_eq!(product.name, "openSUSE Tumbleweed"); + assert_eq!(product.icon, "Tumbleweed.svg"); + assert_eq!(product.registration, RegistrationRequirement::No); + assert_eq!(product.version, None); + let software = &product.software; + assert_eq!(software.installation_repositories.len(), 11); + assert_eq!(software.installation_labels.len(), 4); + assert_eq!(software.base_product, "openSUSE"); + } +} diff --git a/rust/agama-server/tests/share/products.d/tumbleweed.yaml b/rust/agama-server/tests/share/products.d/tumbleweed.yaml new file mode 100644 index 0000000000..ea9996f79b --- /dev/null +++ b/rust/agama-server/tests/share/products.d/tumbleweed.yaml @@ -0,0 +1,225 @@ +id: Tumbleweed +name: openSUSE Tumbleweed +registration: no +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A pure rolling release version of openSUSE containing the latest + "stable" versions of all software instead of relying on rigid periodic release + cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió de llançament continuada d'openSUSE que conté les darreres + versions estables de tot el programari en lloc de dependre de cicles de + llançament periòdics rígids. El projecte fa això per als usuaris que volen + el programari estable més nou. + cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze + veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. + Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. + de: Die Tumbleweed-Distribution ist eine Version mit reinen rollierenden + Veröffentlichungen von openSUSE, die die neuesten „stabilen“ Versionen der + gesamten Software enthält, anstatt sich auf starre periodische + Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für Benutzer, + die die neueste, stabile Software wünschen. + es: Una versión puramente continua de openSUSE que contiene las últimas + versiones "estables" de todo el software en lugar de depender de rígidos + ciclos de lanzamiento periódicos. El proyecto hace esto para usuarios que + desean el software estable más novedoso. + fr: La distribution Tumbleweed est une pure "rolling release" (publication + continue) d'openSUSE contenant les dernières versions "stables" de tous + les logiciels au lieu de se baser sur des cycles de publication + périodiques et fixes. Le projet fait cela pour les utilisateurs qui + veulent les logiciels stables les plus récents. + id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE + yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak + bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk + memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil + terbaru. + ja: + openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 + nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av + openSUSE som inneholder de siste "stabile" versjonene av all programvare i + stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet + gjør dette for brukere som vil ha de nyeste stabile programvarene. + pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas + versões "estáveis" de todos os softwares em vez de depender de ciclos de + lançamento periódicos rígidos. O projeto faz isso para usuários que querem + o software estável mais novo. + ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние + "стабильные" версии всего программного обеспечения, вместо того чтобы + полагаться на жесткие периодические циклы выпуска. Проект делает его для + пользователей, которым нужно самое новое стабильное программное + обеспечение. + sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" + versionerna av all programvara istället för att förlita sig på stela + periodiska släppcykler. Projektet gör detta för användare som vill ha den + senaste stabila mjukvaran. + tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son + "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje + bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. + zh_Hans: Tumbleweed 发行版是 openSUSE + 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + # aarch64 does not have non-oss ports. Keep eye if it change + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-Tumbleweed-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Tumbleweed-DVD-aarch64 + archs: aarch64 + - label: openSUSE-Tumbleweed-DVD-s390x + archs: s390 + - label: openSUSE-Tumbleweed-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on TW + optional_patterns: null # no optional pattern shared + user_patterns: + - basic_desktop + - xfce + - kde + - gnome + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + mandatory_packages: + - NetworkManager + - openSUSE-repos-Tumbleweed + optional_packages: null + base_product: openSUSE + +security: + lsm: apparmor + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + policy: permissive + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + auto: true + outline: + auto_size: + base_min: 1 GiB + base_max: 2 GiB + adjust_by_ram: true + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 10 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 1 GiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat From aac36846ccb83726a6df8fa1d4fd0eccc6343d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 5 Dec 2024 16:55:27 +0000 Subject: [PATCH 003/917] feat(web): add PoC of the new software service --- rust/agama-server/src/error.rs | 6 +- rust/agama-server/src/lib.rs | 2 + rust/agama-server/src/products.rs | 1 + rust/agama-server/src/software_ng.rs | 43 +++++++ rust/agama-server/src/software_ng/backend.rs | 87 ++++++++++++++ .../src/software_ng/backend/client.rs | 47 ++++++++ .../src/software_ng/backend/server.rs | 111 ++++++++++++++++++ rust/agama-server/src/software_ng/web.rs | 56 +++++++++ rust/agama-server/src/web.rs | 17 ++- 9 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 rust/agama-server/src/software_ng.rs create mode 100644 rust/agama-server/src/software_ng/backend.rs create mode 100644 rust/agama-server/src/software_ng/backend/client.rs create mode 100644 rust/agama-server/src/software_ng/backend/server.rs create mode 100644 rust/agama-server/src/software_ng/web.rs diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 5daf0439fe..b298f44de5 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -26,7 +26,7 @@ use axum::{ }; use serde_json::json; -use crate::{l10n::LocaleError, questions::QuestionsError}; +use crate::{l10n::LocaleError, questions::QuestionsError, software_ng::SoftwareServiceError}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -38,8 +38,10 @@ pub enum Error { Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), - #[error("Software service error: {0}")] + #[error("Locale service error: {0}")] Locale(#[from] LocaleError), + #[error("Software service error: {0}")] + SoftwareServiceError(#[from] SoftwareServiceError), } // This would be nice, but using it for a return type diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 77aa110ce1..f23aa90ce9 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -33,3 +33,5 @@ pub mod storage; pub mod users; pub mod web; pub use web::service; + +pub mod software_ng; diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index ae296d8dd4..9525199ee4 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -120,6 +120,7 @@ impl ProductSpec { #[derive(Clone, Debug, Deserialize)] pub struct SoftwareSpec { pub installation_repositories: Vec, + #[serde(default)] pub installation_labels: Vec, pub mandatory_patterns: Vec, pub mandatory_packages: Vec, diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs new file mode 100644 index 0000000000..a8c7d50789 --- /dev/null +++ b/rust/agama-server/src/software_ng.rs @@ -0,0 +1,43 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub(crate) mod backend; +pub(crate) mod web; + +use std::sync::Arc; + +use axum::Router; +use backend::SoftwareService; +pub use backend::SoftwareServiceError; +use tokio::sync::Mutex; + +use crate::{products::ProductsRegistry, web::EventsSender}; + +pub async fn software_ng_service( + events: EventsSender, + products: Arc>, +) -> Router { + let client = SoftwareService::start(events, products) + .await + .expect("Could not start the software service."); + web::software_router(client) + .await + .expect("Could not build the software router.") +} diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs new file mode 100644 index 0000000000..094e890d89 --- /dev/null +++ b/rust/agama-server/src/software_ng/backend.rs @@ -0,0 +1,87 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements the logic for the software service. +//! +//! This service is responsible for the software management of the installer. The service uses +//! Tokio's tasks for long-running operations (e.g., when reading the repositories). However, only +//! one of those operations can run at the same time. It works in this way by design, not because of +//! a technical limitation. +//! +//! A service is composed of two parts: the server and the client. The server handles the business +//! logic and receives the actions to execute using a channel. The client is a simple wrapper around +//! the other end of the channel. +//! +//! Additionally, a service might implement a monitor which listens for events and talks to the +//! server when needed. + +use std::sync::Arc; + +use agama_lib::base_http_client::BaseHTTPClientError; +pub use client::SoftwareServiceClient; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use crate::{products::ProductsRegistry, web::EventsSender}; + +mod client; +mod server; + +type SoftwareActionSender = tokio::sync::mpsc::UnboundedSender; + +#[derive(thiserror::Error, Debug)] +pub enum SoftwareServiceError { + #[error("HTTP client error: {0}")] + HTTPClient(#[from] BaseHTTPClientError), + + #[error("Response channel closed")] + ResponseChannelClosed, + + #[error("Receiver error: {0}")] + RecvError(#[from] oneshot::error::RecvError), + + #[error("Sender error: {0}")] + SendError(#[from] mpsc::error::SendError), +} + +/// Builds and starts the software service. +/// +/// ```no_run +/// # use tokio_test; +/// use agama_server::{ +/// software::backend::SoftwareService +/// }; +/// +/// # tokio_test::block_on(async { +/// let client = SoftwareService::start(products, http, events_tx).await; +/// +/// let products = client.get_products().await +/// .expect("Failed to get the products"); +/// # }); +pub struct SoftwareService {} + +impl SoftwareService { + /// Starts the software service. + pub async fn start( + events: EventsSender, + products: Arc>, + ) -> Result { + Ok(server::SoftwareServiceServer::start(events, products).await) + } +} diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs new file mode 100644 index 0000000000..ffa5a6fb1b --- /dev/null +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -0,0 +1,47 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::product::Product; +use tokio::sync::oneshot; + +use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; + +/// Client to interact with the software service. +/// +/// It uses a channel to send the actions to the server. It can be cloned and used in different +/// tasks if needed. +#[derive(Clone)] +pub struct SoftwareServiceClient { + actions: SoftwareActionSender, +} + +impl SoftwareServiceClient { + /// Creates a new client. + pub fn new(actions: SoftwareActionSender) -> Self { + Self { actions } + } + + /// Returns the list of known products. + pub async fn get_products(&self) -> Result, SoftwareServiceError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(SoftwareAction::GetProducts(tx))?; + Ok(rx.await?) + } +} diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs new file mode 100644 index 0000000000..ad1b36ed66 --- /dev/null +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -0,0 +1,111 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::sync::Arc; + +use agama_lib::product::Product; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use crate::{products::ProductsRegistry, web::EventsSender}; + +use super::{client::SoftwareServiceClient, SoftwareServiceError}; + +#[derive(Debug)] +pub enum SoftwareAction { + GetProducts(oneshot::Sender>), +} + +/// Software service server. +pub struct SoftwareServiceServer { + receiver: mpsc::UnboundedReceiver, + events: EventsSender, + products: Arc>, +} + +impl SoftwareServiceServer { + /// Starts the software service loop and returns a client. + /// + /// The service runs on a separate Tokio task and gets the client requests using a channel. + pub async fn start( + events: EventsSender, + products: Arc>, + ) -> SoftwareServiceClient { + let (sender, receiver) = mpsc::unbounded_channel(); + + let server = Self { + receiver, + events, + products, + }; + tokio::spawn(async move { + server.run().await; + }); + SoftwareServiceClient::new(sender) + } + + /// Runs the server dispatching the actions received through the input channel. + async fn run(mut self) { + loop { + let action = self.receiver.recv().await; + tracing::debug!("software dispatching action: {:?}", action); + let Some(action) = action else { + tracing::error!("Software action channel closed"); + break; + }; + + if let Err(error) = self.dispatch(action).await { + tracing::error!("Software dispatch error: {:?}", error); + } + } + } + + /// Forwards the action to the appropriate handler. + async fn dispatch(&mut self, action: SoftwareAction) -> Result<(), SoftwareServiceError> { + match action { + SoftwareAction::GetProducts(tx) => { + self.get_products(tx).await?; + } + } + Ok(()) + } + + /// Returns the list of products. + async fn get_products( + &self, + tx: oneshot::Sender>, + ) -> Result<(), SoftwareServiceError> { + let products = self.products.lock().await; + // FIXME: implement this conversion at model's level. + let products: Vec<_> = products + .products + .iter() + .map(|p| Product { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon: p.icon.clone(), + registration: p.registration, + }) + .collect(); + tx.send(products) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; + Ok(()) + } +} diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs new file mode 100644 index 0000000000..661c4681a4 --- /dev/null +++ b/rust/agama-server/src/software_ng/web.rs @@ -0,0 +1,56 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::{error::ServiceError, product::Product}; +use axum::{extract::State, routing::get, Json, Router}; + +use crate::error::Error; + +use super::backend::SoftwareServiceClient; + +#[derive(Clone)] +struct SoftwareState { + client: SoftwareServiceClient, +} + +pub async fn software_router(client: SoftwareServiceClient) -> Result { + let state = SoftwareState { client }; + let router = Router::new() + .route("/products", get(get_products)) + .with_state(state); + Ok(router) +} + +/// Returns the list of available products. +/// +/// * `state`: service state. +#[utoipa::path( + get, + path = "/products", + context_path = "/api/software", + responses( + (status = 200, description = "List of known products", body = Vec), + (status = 400, description = "Cannot read the list of products") + ) +)] +async fn get_products(State(state): State) -> Result>, Error> { + let products = state.client.get_products().await?; + Ok(Json(products)) +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 2a7ad06e75..349877ba8a 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -29,9 +29,11 @@ use crate::{ l10n::web::l10n_service, manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, + products::ProductsRegistry, questions::web::{questions_service, questions_stream}, scripts::web::scripts_service, software::web::{software_service, software_streams}, + software_ng::software_ng_service, storage::web::{storage_service, storage_streams}, users::web::{users_service, users_streams}, web::common::{issues_stream, jobs_stream, progress_stream, service_status_stream}, @@ -52,7 +54,8 @@ use agama_lib::{connection, error::ServiceError}; pub use config::ServiceConfig; pub use event::{Event, EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; -use std::path::Path; +use std::{path::Path, sync::Arc}; +use tokio::sync::Mutex; use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. @@ -74,15 +77,25 @@ where .await .expect("Could not connect to NetworkManager to read the configuration"); + let products = ProductsRegistry::load().expect("Could not load the products registry."); + let products = Arc::new(Mutex::new(products)); + let router = MainServiceBuilder::new(events.clone(), web_ui_dir) .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) .add_service("/manager", manager_service(dbus.clone()).await?) .add_service("/software", software_service(dbus.clone()).await?) .add_service("/storage", storage_service(dbus.clone()).await?) - .add_service("/network", network_service(network_adapter, events).await?) + .add_service( + "/network", + network_service(network_adapter, events.clone()).await?, + ) .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) + .add_service( + "/software_ng", + software_ng_service(events.clone(), Arc::clone(&products)).await, + ) .with_config(config) .build(); Ok(router) From b6bd4ac47ae3d81d7a90febb4dedae4690c44059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 10 Dec 2024 16:44:13 +0000 Subject: [PATCH 004/917] feat(web): add a service status manager * It is expected to be used by our services (e.g., SoftwareService). --- rust/agama-lib/src/progress.rs | 70 +++++ rust/agama-server/src/common.rs | 23 ++ rust/agama-server/src/common/backend.rs | 23 ++ .../src/common/backend/service_status.rs | 267 ++++++++++++++++++ rust/agama-server/src/lib.rs | 1 + 5 files changed, 384 insertions(+) create mode 100644 rust/agama-server/src/common.rs create mode 100644 rust/agama-server/src/common/backend.rs create mode 100644 rust/agama-server/src/common/backend/service_status.rs diff --git a/rust/agama-lib/src/progress.rs b/rust/agama-lib/src/progress.rs index 2b43468a8d..90499ab06f 100644 --- a/rust/agama-lib/src/progress.rs +++ b/rust/agama-lib/src/progress.rs @@ -218,3 +218,73 @@ pub trait ProgressPresenter { /// Finishes the progress reporting. async fn finish(&mut self); } + +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProgressSummary { + pub steps: Vec, + pub current_step: u32, + pub max_steps: u32, + pub current_title: String, + pub finished: bool, +} + +impl ProgressSummary { + pub fn finished() -> Self { + Self { + steps: vec![], + current_step: 0, + max_steps: 0, + current_title: "".to_string(), + finished: true, + } + } +} + +/// A sequence of progress steps. +/// FIXME: find a better name to distinguish from agama-server::web::common::ProgressSequence. +#[derive(Debug)] +pub struct ProgressSequence { + pub steps: Vec, + current: usize, +} + +impl ProgressSequence { + /// Create a new progress sequence with the given steps. + /// + /// * `steps`: The steps to create the sequence from. + pub fn new(steps: Vec) -> Self { + Self { steps, current: 0 } + } + + /// Move to the next step in the sequence and return the progress for it. + /// + /// It returns `None` if the sequence is finished. + pub fn next_step(&mut self) -> Option { + if self.is_finished() { + return None; + } + self.current += 1; + self.step() + } + + /// The progres has finished. + pub fn is_finished(&self) -> bool { + self.current == self.steps.len() + } + + /// Return the progress for the current step. + pub fn step(&self) -> Option { + if self.is_finished() { + return None; + } + + let current_title = self.steps.get(self.current).unwrap().clone(); + Some(Progress { + current_step: (self.current + 1) as u32, + max_steps: self.steps.len() as u32, + current_title, + finished: (self.current + 1) == self.steps.len(), + }) + } +} diff --git a/rust/agama-server/src/common.rs b/rust/agama-server/src/common.rs new file mode 100644 index 0000000000..8f04f6e659 --- /dev/null +++ b/rust/agama-server/src/common.rs @@ -0,0 +1,23 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Common functionality that can be shared across the package. + +pub(crate) mod backend; diff --git a/rust/agama-server/src/common/backend.rs b/rust/agama-server/src/common/backend.rs new file mode 100644 index 0000000000..d0b0cf404d --- /dev/null +++ b/rust/agama-server/src/common/backend.rs @@ -0,0 +1,23 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Common functionality that can be shared by the different backends. + +pub mod service_status; diff --git a/rust/agama-server/src/common/backend/service_status.rs b/rust/agama-server/src/common/backend/service_status.rs new file mode 100644 index 0000000000..612197bab3 --- /dev/null +++ b/rust/agama-server/src/common/backend/service_status.rs @@ -0,0 +1,267 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements logic to keep track of the status of a service. +//! +//! This behavior can be reused by different services, e.g., the +//! [software service](crate::software_ng::SoftwareService). +use crate::web::{Event, EventsSender}; +use agama_lib::progress::{Progress, ProgressSequence, ProgressSummary}; +use tokio::sync::{ + mpsc, + oneshot::{self, error::RecvError}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum ServiceStatusError { + #[error("The service is busy")] + Busy, + #[error("Could not send the message: {0}")] + SendError(#[from] mpsc::error::SendError), + #[error("Could not receive message: {0}")] + RecvError(#[from] RecvError), +} + +/// Actions related to service status management. +pub enum Action { + Start(Vec, oneshot::Sender>), + NextStep, + Finish, + GetProgress(oneshot::Sender>), +} + +type ActionReceiver = mpsc::UnboundedReceiver; +type ActionSender = mpsc::UnboundedSender; + +// TODO: somehow duplicated from agama-server/web/common.rs +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ServiceStatus { + Idle = 0, + Busy = 1, +} + +/// Builds and starts a service status server. +/// +/// See the [SoftwareService::start](crate::sfotware_ng::SoftwareService::start) method for an +/// example. +pub struct ServiceStatusManager {} + +impl ServiceStatusManager { + /// Starts a service status manager for the given service. + /// + /// * `name`: service name. + /// * `events`: channel to send events (e.g., status changes and progress updates). + pub fn start(name: &str, events: EventsSender) -> ServiceStatusClient { + let (sender, receiver) = mpsc::unbounded_channel(); + let server = ServiceStatusServer { + name: name.to_string(), + events, + progress: None, + // NOTE: would it be OK to derive the status from the progress + status: ServiceStatus::Idle, + receiver, + sender, + }; + + server.start() + } +} + +/// Client to interact with the service status manager. +/// +/// It uses a channel to send the actions to the server. It can be cloned and used in different +/// tasks if needed. +#[derive(Clone)] +pub struct ServiceStatusClient(ActionSender); + +impl ServiceStatusClient { + /// Starts a new long-running task. + pub async fn start_task(&self, steps: Vec) -> Result<(), ServiceStatusError> { + let (tx, rx) = oneshot::channel(); + self.0.send(Action::Start(steps, tx))?; + rx.await? + } + + /// Moves to the next step of the current long-running task. + pub fn next_step(&self) -> Result<(), ServiceStatusError> { + self.0.send(Action::NextStep)?; + Ok(()) + } + + /// Finishes the current long-running task. + pub fn finish_task(&self) -> Result<(), ServiceStatusError> { + self.0.send(Action::Finish)?; + Ok(()) + } + + /// Get the current progress information. + pub async fn get_progress(&self) -> Result, ServiceStatusError> { + let (tx, rx) = oneshot::channel(); + self.0.send(Action::GetProgress(tx)).unwrap(); + Ok(rx.await?) + } +} + +/// Keeps track of the status of a service. +/// +/// It holds the progress sequence and the service status. Additionally, it emits +/// events when any of them change. +#[derive(Debug)] +pub struct ServiceStatusServer { + pub name: String, + events: EventsSender, + progress: Option, + status: ServiceStatus, + sender: ActionSender, + receiver: ActionReceiver, +} + +impl ServiceStatusServer { + pub fn start(self) -> ServiceStatusClient { + let channel = self.sender.clone(); + tokio::spawn(async move { + ServiceStatusServer::run(self).await; + }); + ServiceStatusClient(channel) + } + + /// Runs the server dispatching the actions received through the input channel. + pub async fn run(mut self) { + loop { + let Some(action) = self.receiver.recv().await else { + break; + }; + + match action { + Action::Start(steps, tx) => { + _ = tx.send(self.start_task(steps)); + } + + Action::Finish => { + self.finish_task(); + } + + Action::NextStep => { + self.next_step(); + } + + Action::GetProgress(tx) => { + let progress = self.get_progress(); + _ = tx.send(progress); + } + } + } + } + + /// Starts an operation composed by several steps. + /// + /// It builds a new progress sequence and sets the service as "busy". + /// + /// * `steps`: steps to include in the sequence. + fn start_task(&mut self, steps: Vec) -> Result<(), ServiceStatusError> { + if self.is_busy() { + return Err(ServiceStatusError::Busy {}); + } + let progress = ProgressSequence::new(steps); + if let Some(step) = progress.step() { + let _ = self.events.send(Event::Progress { + service: self.name.clone(), + progress: step, + }); + } + self.progress = Some(progress); + + self.status = ServiceStatus::Busy; + let _ = self.events.send(Event::ServiceStatusChanged { + service: self.name.clone(), + status: (self.status as u32), + }); + Ok(()) + } + + /// Moves to the next step in the progress sequence. + /// + /// It returns `None` if no sequence is found or if the sequence is already finished. + fn next_step(&mut self) -> Option { + let Some(progress) = self.progress.as_mut() else { + tracing::error!("No progress sequence found"); + return None; + }; + + let Some(step) = progress.next_step() else { + tracing::error!("The progress sequence is already finished"); + return None; + }; + + let _ = self.events.send(Event::Progress { + service: self.name.clone(), + progress: step.clone(), + }); + Some(step) + } + + /// Returns the current step of the progress sequence. + fn get_progress(&self) -> Option { + self.progress + .as_ref() + .map(|p| { + let Some(step) = p.step() else { + return None; + }; + + let summary = ProgressSummary { + steps: p.steps.clone(), + current_step: step.current_step, + max_steps: step.max_steps, + current_title: step.current_title, + finished: step.finished, + }; + Some(summary) + }) + .flatten() + } + + /// It finishes the current sequence. + /// + /// It finishes the progress sequence and sets the service as "idle". + fn finish_task(&mut self) { + self.progress = None; + let _ = self.events.send(Event::Progress { + service: self.name.clone(), + progress: Progress { + current_step: 0, + max_steps: 0, + current_title: "".to_string(), + finished: true, + }, + }); + + self.status = ServiceStatus::Idle; + let _ = self.events.send(Event::ServiceStatusChanged { + service: self.name.clone(), + status: (self.status as u32), + }); + } + + /// Determines whether the service is busy or not. + fn is_busy(&self) -> bool { + self.status == ServiceStatus::Busy + } +} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index f23aa90ce9..7ecdb1637c 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -34,4 +34,5 @@ pub mod users; pub mod web; pub use web::service; +pub mod common; pub mod software_ng; From 1e24948d7924df258d2b5a271f68095d40dc2a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 10 Dec 2024 21:13:49 +0000 Subject: [PATCH 005/917] feat(web): add status tracking to SoftwareService --- rust/agama-server/src/software_ng/backend.rs | 8 ++++++- .../src/software_ng/backend/client.rs | 9 +++++--- .../src/software_ng/backend/server.rs | 17 +++++++++++--- rust/agama-server/src/software_ng/web.rs | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 094e890d89..88830dfb45 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -38,7 +38,10 @@ use agama_lib::base_http_client::BaseHTTPClientError; pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; -use crate::{products::ProductsRegistry, web::EventsSender}; +use crate::{ + common::backend::service_status::ServiceStatusError, products::ProductsRegistry, + web::EventsSender, +}; mod client; mod server; @@ -58,6 +61,9 @@ pub enum SoftwareServiceError { #[error("Sender error: {0}")] SendError(#[from] mpsc::error::SendError), + + #[error("Service status error: {0}")] + ServiceStatus(#[from] ServiceStatusError), } /// Builds and starts the software service. diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index ffa5a6fb1b..0b111bb64e 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -18,9 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::product::Product; +use agama_lib::{product::Product, progress::ProgressSummary}; use tokio::sync::oneshot; +use crate::common::backend::service_status::ServiceStatusClient; + use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; /// Client to interact with the software service. @@ -30,12 +32,13 @@ use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; #[derive(Clone)] pub struct SoftwareServiceClient { actions: SoftwareActionSender, + status: ServiceStatusClient, } impl SoftwareServiceClient { /// Creates a new client. - pub fn new(actions: SoftwareActionSender) -> Self { - Self { actions } + pub fn new(actions: SoftwareActionSender, status: ServiceStatusClient) -> Self { + Self { actions, status } } /// Returns the list of known products. diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index ad1b36ed66..b563d163fe 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -20,10 +20,14 @@ use std::sync::Arc; -use agama_lib::product::Product; +use agama_lib::{product::Product, progress::ProgressSummary}; use tokio::sync::{mpsc, oneshot, Mutex}; -use crate::{products::ProductsRegistry, web::EventsSender}; +use crate::{ + common::backend::service_status::{ServiceStatusClient, ServiceStatusManager}, + products::ProductsRegistry, + web::EventsSender, +}; use super::{client::SoftwareServiceClient, SoftwareServiceError}; @@ -37,8 +41,11 @@ pub struct SoftwareServiceServer { receiver: mpsc::UnboundedReceiver, events: EventsSender, products: Arc>, + status: ServiceStatusClient, } +const SERVICE_NAME: &str = "org.opensuse.Agama.Software1"; + impl SoftwareServiceServer { /// Starts the software service loop and returns a client. /// @@ -49,15 +56,19 @@ impl SoftwareServiceServer { ) -> SoftwareServiceClient { let (sender, receiver) = mpsc::unbounded_channel(); + let status = ServiceStatusManager::start(SERVICE_NAME, events.clone()); + let server = Self { receiver, events, products, + status: status.clone(), }; + tokio::spawn(async move { server.run().await; }); - SoftwareServiceClient::new(sender) + SoftwareServiceClient::new(sender, status) } /// Runs the server dispatching the actions received through the input channel. diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 661c4681a4..60484db4d3 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -18,10 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{error::ServiceError, product::Product}; +use agama_lib::{ + error::ServiceError, product::Product, progress::ProgressSummary +}; use axum::{extract::State, routing::get, Json, Router}; -use crate::error::Error; +use crate::{error::Error, software::web::SoftwareProposal}; use super::backend::SoftwareServiceClient; @@ -54,3 +56,19 @@ async fn get_products(State(state): State) -> Result) -> Result, Error> { + let summary = match state.client.get_progress().await? { + Some(summary) => summary, + None => ProgressSummary::finished(), + }; + Ok(Json(summary)) +} From e867e6bdfeba2d5a3b28c46c573cea0a5bbaf4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 11 Dec 2024 09:39:46 +0000 Subject: [PATCH 006/917] feat(web): add placeholders for software service actions --- .../src/software_ng/backend/client.rs | 15 ++++ .../src/software_ng/backend/server.rs | 33 ++++++++ rust/agama-server/src/software_ng/web.rs | 75 ++++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 0b111bb64e..0750b89bf3 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -47,4 +47,19 @@ impl SoftwareServiceClient { self.actions.send(SoftwareAction::GetProducts(tx))?; Ok(rx.await?) } + + pub async fn select_product(&self, product_id: &str) -> Result<(), SoftwareServiceError> { + self.actions + .send(SoftwareAction::SelectProduct(product_id.to_string()))?; + Ok(()) + } + + pub async fn probe(&self) -> Result<(), SoftwareServiceError> { + self.actions.send(SoftwareAction::Probe)?; + Ok(()) + } + + pub async fn get_progress(&self) -> Result, SoftwareServiceError> { + Ok(self.status.get_progress().await?) + } } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index b563d163fe..ac1533173d 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -33,7 +33,9 @@ use super::{client::SoftwareServiceClient, SoftwareServiceError}; #[derive(Debug)] pub enum SoftwareAction { + Probe, GetProducts(oneshot::Sender>), + SelectProduct(String), } /// Software service server. @@ -93,10 +95,41 @@ impl SoftwareServiceServer { SoftwareAction::GetProducts(tx) => { self.get_products(tx).await?; } + + SoftwareAction::SelectProduct(product_id) => { + self.select_product(product_id).await?; + } + + SoftwareAction::Probe => { + self.probe().await?; + } } Ok(()) } + /// Select the given product. + async fn select_product(&self, product_id: String) -> Result<(), SoftwareServiceError> { + tracing::info!("Selecting product {}", product_id); + Ok(()) + } + + async fn probe(&self) -> Result<(), SoftwareServiceError> { + _ = self + .status + .start_task(vec![ + "Refreshing repositories metadata".to_string(), + "Calculate software proposal".to_string(), + ]) + .await; + + _ = self.status.next_step(); + _ = self.status.next_step(); + + _ = self.status.finish_task(); + + Ok(()) + } + /// Returns the list of products. async fn get_products( &self, diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 60484db4d3..e3372daf69 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -19,9 +19,14 @@ // find current contact information at www.suse.com. use agama_lib::{ - error::ServiceError, product::Product, progress::ProgressSummary + error::ServiceError, product::Product, progress::ProgressSummary, + software::model::SoftwareConfig, +}; +use axum::{ + extract::State, + routing::{get, post, put}, + Json, Router, }; -use axum::{extract::State, routing::get, Json, Router}; use crate::{error::Error, software::web::SoftwareProposal}; @@ -36,6 +41,11 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result Result), (status = 400, description = "Cannot read the list of products") @@ -57,6 +67,65 @@ async fn get_products(State(state): State) -> Result, + Json(config): Json, +) -> Result<(), Error> { + if let Some(product) = config.product { + state.client.select_product(&product).await?; + } + + Ok(()) +} + +/// Refreshes the repositories. +/// +/// At this point, only the required space is reported. +#[utoipa::path( + post, + path = "/probe", + context_path = "/api/software", + responses( + (status = 200, description = "Read repositories data"), + (status = 400, description = "The D-Bus service could not perform the action +") + ), + operation_id = "software_probe" +)] +async fn probe(State(state): State) -> Result, Error> { + state.client.probe().await?; + Ok(Json(())) +} + +/// Returns the proposal information. +/// +/// At this point, only the required space is reported. +#[utoipa::path( + get, + path = "/proposal", + context_path = "/api/software_ng", + responses( + (status = 200, description = "Software proposal", body = SoftwareProposal) + ) +)] +async fn get_proposal(State(state): State) -> Result, Error> { + unimplemented!("get the software proposal"); +} + #[utoipa::path( get, path = "/progress", From 26856bead6e4cfe413e9aaf0c496cc9a3005cd9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 11 Dec 2024 14:51:46 +0000 Subject: [PATCH 007/917] chore: add zypp-c-api as a Git submodule --- .gitmodules | 3 +++ rust/zypp-c-api | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 rust/zypp-c-api diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..743cb40791 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "rust/zypp-c-api"] + path = rust/zypp-c-api + url = git@github.com:agama-project/zypp-c-api.git diff --git a/rust/zypp-c-api b/rust/zypp-c-api new file mode 160000 index 0000000000..20e9f4f01d --- /dev/null +++ b/rust/zypp-c-api @@ -0,0 +1 @@ +Subproject commit 20e9f4f01d3de287ab0996aca5482d82bacf76bf From 07ab04738c6d0b3f0b185e883252aa8bad70c275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 12 Dec 2024 06:23:17 +0000 Subject: [PATCH 008/917] chore: add zypp-agama as a dependency --- rust/Cargo.lock | 72 +++++++++++++++++++++--------------- rust/agama-server/Cargo.toml | 1 + 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b901ba0252..3206bc6f52 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -131,6 +131,7 @@ dependencies = [ "utoipa", "uuid", "zbus", + "zypp-agama", ] [[package]] @@ -412,7 +413,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -480,7 +481,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -497,7 +498,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -655,7 +656,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -897,7 +898,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -1153,7 +1154,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -1164,7 +1165,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -1284,7 +1285,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -1473,7 +1474,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -2625,7 +2626,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -2797,7 +2798,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -2891,7 +2892,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -2976,9 +2977,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -3380,7 +3381,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -3423,7 +3424,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -3474,7 +3475,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -3656,7 +3657,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -3688,9 +3689,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.79" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -3811,7 +3812,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -3905,7 +3906,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -4113,7 +4114,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -4323,7 +4324,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.79", + "syn 2.0.90", "uuid", ] @@ -4408,7 +4409,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", "wasm-bindgen-shared", ] @@ -4442,7 +4443,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4770,7 +4771,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", "zvariant_utils", ] @@ -4803,7 +4804,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", ] [[package]] @@ -4835,7 +4836,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.90", "zvariant_utils", ] @@ -4849,6 +4850,17 @@ dependencies = [ "quote", "serde", "static_assertions", - "syn 2.0.79", + "syn 2.0.90", "winnow", ] + +[[package]] +name = "zypp-agama" +version = "0.1.0" +dependencies = [ + "zypp-agama-sys", +] + +[[package]] +name = "zypp-agama-sys" +version = "0.1.0" diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 50de3c4ae0..809bf774ec 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -50,6 +50,7 @@ subprocess = "0.2.9" gethostname = "0.4.3" tokio-util = "0.7.12" serde_yaml = "0.9.34" +zypp-agama = { path = "../zypp-c-api/rust/zypp-agama" } [[bin]] name = "agama-dbus-server" From a6306210a05568f251e33fa10b41e3f64db1f69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 12 Dec 2024 06:54:21 +0000 Subject: [PATCH 009/917] feat(rust): implement a PoC of product selection and probing * The implementation is not 100% complete. --- rust/agama-server/src/products.rs | 18 ++++ rust/agama-server/src/software_ng/backend.rs | 21 ++++- .../src/software_ng/backend/server.rs | 89 ++++++++++++++++--- 3 files changed, 117 insertions(+), 11 deletions(-) diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index 9525199ee4..1210904e66 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -93,6 +93,13 @@ impl ProductsRegistry { pub fn is_multiproduct(&self) -> bool { self.products.len() > 1 } + + /// Finds a product by its ID. + /// + /// * `id`: product ID. + pub fn find(&self, id: &str) -> Option<&ProductSpec> { + self.products.iter().find(|p| p.id == id) + } } // TODO: ideally, part of this code could be auto-generated from a JSON schema definition. @@ -170,4 +177,15 @@ mod test { assert_eq!(software.installation_labels.len(), 4); assert_eq!(software.base_product, "openSUSE"); } + + #[test] + fn test_find_product() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let products = ProductsRegistry::load_from(path.as_path()).unwrap(); + let tw = products.find("Tumbleweed").unwrap(); + assert_eq!(tw.id, "Tumbleweed"); + + let missing = products.find("Missing"); + assert!(missing.is_none()); + } } diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 88830dfb45..138350e362 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -37,6 +37,7 @@ use std::sync::Arc; use agama_lib::base_http_client::BaseHTTPClientError; pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; +use zypp_agama::ZyppError; use crate::{ common::backend::service_status::ServiceStatusError, products::ProductsRegistry, @@ -64,6 +65,24 @@ pub enum SoftwareServiceError { #[error("Service status error: {0}")] ServiceStatus(#[from] ServiceStatusError), + + #[error("Unknown product: {0}")] + UnknownProduct(String), + + #[error("Target creation failed: {0}")] + TargetCreationFailed(#[source] std::io::Error), + + #[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), } /// Builds and starts the software service. @@ -88,6 +107,6 @@ impl SoftwareService { events: EventsSender, products: Arc>, ) -> Result { - Ok(server::SoftwareServiceServer::start(events, products).await) + server::SoftwareServiceServer::start(events, products).await } } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index ac1533173d..4261720555 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -18,9 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::sync::Arc; +use std::{path::Path, sync::Arc}; -use agama_lib::{product::Product, progress::ProgressSummary}; +use agama_lib::product::Product; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ @@ -31,6 +31,10 @@ use crate::{ use super::{client::SoftwareServiceClient, SoftwareServiceError}; +const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; +// Just a temporary value +const ARCH: &str = "x86_64"; + #[derive(Debug)] pub enum SoftwareAction { Probe, @@ -44,6 +48,8 @@ pub struct SoftwareServiceServer { events: EventsSender, products: Arc>, status: ServiceStatusClient, + // FIXME: what about having a SoftwareServiceState to keep business logic state? + selected_product: Option, } const SERVICE_NAME: &str = "org.opensuse.Agama.Software1"; @@ -55,7 +61,7 @@ impl SoftwareServiceServer { pub async fn start( events: EventsSender, products: Arc>, - ) -> SoftwareServiceClient { + ) -> Result { let (sender, receiver) = mpsc::unbounded_channel(); let status = ServiceStatusManager::start(SERVICE_NAME, events.clone()); @@ -65,16 +71,21 @@ impl SoftwareServiceServer { events, products, status: status.clone(), + selected_product: None, }; tokio::spawn(async move { - server.run().await; + if let Err(error) = server.run().await { + tracing::error!("Software service could not start: {:?}", error); + } }); - SoftwareServiceClient::new(sender, status) + Ok(SoftwareServiceClient::new(sender, status)) } /// Runs the server dispatching the actions received through the input channel. - async fn run(mut self) { + async fn run(mut self) -> Result<(), SoftwareServiceError> { + self.initialize_target_dir()?; + loop { let action = self.receiver.recv().await; tracing::debug!("software dispatching action: {:?}", action); @@ -87,6 +98,8 @@ impl SoftwareServiceServer { tracing::error!("Software dispatch error: {:?}", error); } } + + Ok(()) } /// Forwards the action to the appropriate handler. @@ -102,14 +115,21 @@ impl SoftwareServiceServer { SoftwareAction::Probe => { self.probe().await?; + _ = self.status.finish_task(); } } Ok(()) } /// Select the given product. - async fn select_product(&self, product_id: String) -> Result<(), SoftwareServiceError> { + async fn select_product(&mut self, product_id: String) -> Result<(), SoftwareServiceError> { tracing::info!("Selecting product {}", product_id); + let products = self.products.lock().await; + if products.find(&product_id).is_none() { + return Err(SoftwareServiceError::UnknownProduct(product_id)); + }; + + self.selected_product = Some(product_id.clone()); Ok(()) } @@ -117,15 +137,50 @@ impl SoftwareServiceServer { _ = self .status .start_task(vec![ + "Add base repositories".to_string(), "Refreshing repositories metadata".to_string(), - "Calculate software proposal".to_string(), + // "Calculate software proposal".to_string(), ]) .await; - _ = self.status.next_step(); + // FIXME: it holds the mutex too much. We could use a RwLock mutex. + let products = self.products.lock().await; + + let Some(product_id) = &self.selected_product else { + return Err(SoftwareServiceError::NoSelectedProduct); + }; + + let Some(product) = products.find(product_id) else { + return Err(SoftwareServiceError::UnknownProduct(product_id.clone())); + }; + + // FIXME: this is a temporary workaround. The arch should be processed in the + // ProductsRegistry. + let arch = ARCH.to_string(); + for (idx, repo) in product + .software + .installation_repositories + .iter() + .enumerate() + { + if repo.archs.contains(&arch) { + // TODO: we should add a repository ID in the configuration file. + let name = format!("agama-{}", idx); + zypp_agama::add_repository(&name, &repo.url, |percent, alias| { + tracing::info!("Adding repository {} ({}%)", alias, percent); + true + }) + .map_err(SoftwareServiceError::AddRepositoryFailed)?; + } + } + _ = self.status.next_step(); - _ = self.status.finish_task(); + zypp_agama::load_source(|percent, alias| { + tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); + true + }) + .map_err(SoftwareServiceError::LoadSourcesFailed)?; Ok(()) } @@ -152,4 +207,18 @@ impl SoftwareServiceServer { .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; Ok(()) } + + fn initialize_target_dir(&self) -> Result<(), SoftwareServiceError> { + let target_dir = Path::new(TARGET_DIR); + if target_dir.exists() { + _ = std::fs::remove_dir_all(target_dir); + } + std::fs::create_dir_all(target_dir).map_err(SoftwareServiceError::TargetCreationFailed)?; + + zypp_agama::init_target(TARGET_DIR, |text, step, total| { + tracing::info!("Initializing target: {} ({}/{})", text, step, total); + }) + .map_err(SoftwareServiceError::TargetInitFailed)?; + Ok(()) + } } From adb99dda41f07190894845d3ca049aff6a785009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 12 Dec 2024 09:06:32 +0000 Subject: [PATCH 010/917] fix(CI): clone Git submodules in CI --- .github/workflows/ci-rust.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 8b053d6238..08a124e9d6 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -59,6 +59,8 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Configure and refresh repositories # disable unused repositories to have faster refresh From 3fd91da75d31060195d4bc3f4492fb0d2222d12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 12 Dec 2024 11:00:04 +0000 Subject: [PATCH 011/917] fix(rust): use consts::ARCH to filter by arch --- rust/agama-server/src/products.rs | 13 ++++++++- .../src/software_ng/backend/server.rs | 29 ++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index 1210904e66..05375483c7 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -126,7 +126,7 @@ impl ProductSpec { #[derive(Clone, Debug, Deserialize)] pub struct SoftwareSpec { - pub installation_repositories: Vec, + installation_repositories: Vec, #[serde(default)] pub installation_labels: Vec, pub mandatory_patterns: Vec, @@ -137,6 +137,17 @@ pub struct SoftwareSpec { pub base_product: String, } +impl SoftwareSpec { + // NOTE: perhaps implementing our own iterator would be more efficient. + pub fn repositories(&self) -> Vec<&RepositorySpec> { + let arch = std::env::consts::ARCH.to_string(); + self.installation_repositories + .iter() + .filter(|r| r.archs.contains(&arch)) + .collect() + } +} + #[serde_as] #[derive(Clone, Debug, Deserialize)] pub struct RepositorySpec { diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 4261720555..95ca6b0638 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -32,8 +32,6 @@ use crate::{ use super::{client::SoftwareServiceClient, SoftwareServiceError}; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; -// Just a temporary value -const ARCH: &str = "x86_64"; #[derive(Debug)] pub enum SoftwareAction { @@ -154,24 +152,15 @@ impl SoftwareServiceServer { return Err(SoftwareServiceError::UnknownProduct(product_id.clone())); }; - // FIXME: this is a temporary workaround. The arch should be processed in the - // ProductsRegistry. - let arch = ARCH.to_string(); - for (idx, repo) in product - .software - .installation_repositories - .iter() - .enumerate() - { - if repo.archs.contains(&arch) { - // TODO: we should add a repository ID in the configuration file. - let name = format!("agama-{}", idx); - zypp_agama::add_repository(&name, &repo.url, |percent, alias| { - tracing::info!("Adding repository {} ({}%)", alias, percent); - true - }) - .map_err(SoftwareServiceError::AddRepositoryFailed)?; - } + let repositories = product.software.repositories(); + for (idx, repo) in repositories.iter().enumerate() { + // TODO: we should add a repository ID in the configuration file. + let name = format!("agama-{}", idx); + zypp_agama::add_repository(&name, &repo.url, |percent, alias| { + tracing::info!("Adding repository {} ({}%)", alias, percent); + true + }) + .map_err(SoftwareServiceError::AddRepositoryFailed)?; } _ = self.status.next_step(); From 5ee9f0958490260c7ce3ce19b4bda5d7cfa8d106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 13 Dec 2024 15:52:40 +0000 Subject: [PATCH 012/917] chore(rust): update zypp-c-api submodule --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 20e9f4f01d..583c6d2ab4 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 20e9f4f01d3de287ab0996aca5482d82bacf76bf +Subproject commit 583c6d2ab48e08fe3c55df95a08e6eecfd461f3d From f73b699040678fe3b7e3f564afe52ff5af05647f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 13 Dec 2024 16:16:36 +0000 Subject: [PATCH 013/917] feat(rust): import repositories GPG keys --- rust/Cargo.lock | 1 + rust/agama-server/Cargo.toml | 1 + .../src/software_ng/backend/server.rs | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3206bc6f52..4b1f550e68 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "futures-util", "gethostname", "gettext-rs", + "glob", "http-body-util", "hyper 1.4.1", "hyper-util", diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 809bf774ec..75b7490e1b 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -51,6 +51,7 @@ gethostname = "0.4.3" tokio-util = "0.7.12" serde_yaml = "0.9.34" zypp-agama = { path = "../zypp-c-api/rust/zypp-agama" } +glob = "0.3.1" [[bin]] name = "agama-dbus-server" diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 95ca6b0638..c6350b9613 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -32,6 +32,7 @@ use crate::{ use super::{client::SoftwareServiceClient, SoftwareServiceError}; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; +const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; #[derive(Debug)] pub enum SoftwareAction { @@ -202,12 +203,30 @@ impl SoftwareServiceServer { if target_dir.exists() { _ = std::fs::remove_dir_all(target_dir); } + std::fs::create_dir_all(target_dir).map_err(SoftwareServiceError::TargetCreationFailed)?; zypp_agama::init_target(TARGET_DIR, |text, step, total| { tracing::info!("Initializing target: {} ({}/{})", text, step, total); }) .map_err(SoftwareServiceError::TargetInitFailed)?; + + self.import_gpg_keys(); Ok(()) } + + fn import_gpg_keys(&self) { + for file in glob::glob(GPG_KEYS).unwrap() { + match file { + Ok(file) => { + if let Err(e) = zypp_agama::import_gpg_key(&file.to_string_lossy()) { + tracing::error!("Failed to import GPG key: {}", e); + } + } + Err(e) => { + tracing::error!("Could not read GPG key file: {}", e); + } + } + } + } } From 2ccbed492b484831865f14bab8a16f88350b77b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 13 Dec 2024 17:05:58 +0000 Subject: [PATCH 014/917] feat(rust): implements the /patterns endpoint --- rust/agama-server/src/software_ng/backend.rs | 3 + .../src/software_ng/backend/client.rs | 9 ++- .../src/software_ng/backend/server.rs | 72 +++++++++++++++---- rust/agama-server/src/software_ng/web.rs | 24 ++++++- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 138350e362..5317b06ec1 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -83,6 +83,9 @@ pub enum SoftwareServiceError { #[error("Failed to load the repositories: {0}")] LoadSourcesFailed(#[source] ZyppError), + + #[error("Listing patterns failed: {0}")] + ListPatternsFailed(#[source] ZyppError), } /// Builds and starts the software service. diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 0750b89bf3..85f4b50e1d 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{product::Product, progress::ProgressSummary}; +use agama_lib::{product::Product, progress::ProgressSummary, software::Pattern}; use tokio::sync::oneshot; use crate::common::backend::service_status::ServiceStatusClient; @@ -48,6 +48,13 @@ impl SoftwareServiceClient { Ok(rx.await?) } + /// Returns the list of known patterns. + pub async fn get_patterns(&self) -> Result, SoftwareServiceError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(SoftwareAction::GetPatterns(tx))?; + Ok(rx.await?) + } + pub async fn select_product(&self, product_id: &str) -> Result<(), SoftwareServiceError> { self.actions .send(SoftwareAction::SelectProduct(product_id.to_string()))?; diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index c6350b9613..3798e64a4b 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -20,12 +20,12 @@ use std::{path::Path, sync::Arc}; -use agama_lib::product::Product; +use agama_lib::{product::Product, software::Pattern}; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ common::backend::service_status::{ServiceStatusClient, ServiceStatusManager}, - products::ProductsRegistry, + products::{ProductSpec, ProductsRegistry}, web::EventsSender, }; @@ -38,6 +38,7 @@ const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; pub enum SoftwareAction { Probe, GetProducts(oneshot::Sender>), + GetPatterns(oneshot::Sender>), SelectProduct(String), } @@ -108,6 +109,10 @@ impl SoftwareServiceServer { self.get_products(tx).await?; } + SoftwareAction::GetPatterns(tx) => { + self.get_patterns(tx).await?; + } + SoftwareAction::SelectProduct(product_id) => { self.select_product(product_id).await?; } @@ -142,17 +147,7 @@ impl SoftwareServiceServer { ]) .await; - // FIXME: it holds the mutex too much. We could use a RwLock mutex. - let products = self.products.lock().await; - - let Some(product_id) = &self.selected_product else { - return Err(SoftwareServiceError::NoSelectedProduct); - }; - - let Some(product) = products.find(product_id) else { - return Err(SoftwareServiceError::UnknownProduct(product_id.clone())); - }; - + let product = self.find_selected_product().await?; let repositories = product.software.repositories(); for (idx, repo) in repositories.iter().enumerate() { // TODO: we should add a repository ID in the configuration file. @@ -198,6 +193,41 @@ impl SoftwareServiceServer { Ok(()) } + async fn get_patterns( + &self, + tx: oneshot::Sender>, + ) -> Result<(), SoftwareServiceError> { + let product = self.find_selected_product().await?; + + let mandatory_patterns = product.software.mandatory_patterns.iter(); + let optional_patterns = product.software.optional_patterns.unwrap_or(vec![]); + let optional_patterns = optional_patterns.iter(); + let pattern_names: Vec<&str> = vec![mandatory_patterns, optional_patterns] + .into_iter() + .flatten() + .map(String::as_str) + .collect(); + + let patterns = zypp_agama::patterns_info(pattern_names) + .map_err(SoftwareServiceError::ListPatternsFailed)?; + + let patterns = patterns + .into_iter() + .map(|info| Pattern { + name: info.name, + category: info.category, + description: info.description, + icon: info.icon, + summary: info.summary, + order: info.order, + }) + .collect(); + + tx.send(patterns) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; + Ok(()) + } + fn initialize_target_dir(&self) -> Result<(), SoftwareServiceError> { let target_dir = Path::new(TARGET_DIR); if target_dir.exists() { @@ -229,4 +259,20 @@ impl SoftwareServiceServer { } } } + + // Returns the spec of the selected product. + // + // It causes the spec to be cloned, so we should find a better way to do this. + async fn find_selected_product(&self) -> Result { + let products = self.products.lock().await; + let Some(product_id) = &self.selected_product else { + return Err(SoftwareServiceError::NoSelectedProduct); + }; + + let Some(product) = products.find(product_id) else { + return Err(SoftwareServiceError::UnknownProduct(product_id.clone())); + }; + + Ok(product.clone()) + } } diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index e3372daf69..4839af0500 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -19,8 +19,10 @@ // find current contact information at www.suse.com. use agama_lib::{ - error::ServiceError, product::Product, progress::ProgressSummary, - software::model::SoftwareConfig, + error::ServiceError, + product::Product, + progress::ProgressSummary, + software::{model::SoftwareConfig, Pattern}, }; use axum::{ extract::State, @@ -40,6 +42,7 @@ struct SoftwareState { pub async fn software_router(client: SoftwareServiceClient) -> Result { let state = SoftwareState { client }; let router = Router::new() + .route("/patterns", get(get_patterns)) .route("/products", get(get_products)) // FIXME: it should be PATCH (using PUT just for backward compatibility). .route("/config", put(set_config)) @@ -67,6 +70,23 @@ async fn get_products(State(state): State) -> Result), + (status = 400, description = "Cannot read the list of patterns") + ) +)] +async fn get_patterns(State(state): State) -> Result>, Error> { + let products = state.client.get_patterns().await?; + Ok(Json(products)) +} + /// Sets the software configuration. /// /// * `state`: service state. From 204453ed4f60d5c9e21eae10fb9d7bca064e06a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 17 Dec 2024 16:59:49 +0000 Subject: [PATCH 015/917] feat(rust): implement the /resolvables/:id endpoint --- rust/agama-lib/src/software/model.rs | 135 +++++++++++++++++- .../src/software_ng/backend/client.rs | 23 ++- .../src/software_ng/backend/server.rs | 27 +++- rust/agama-server/src/software_ng/web.rs | 31 +++- 4 files changed, 211 insertions(+), 5 deletions(-) diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index 3ab8263040..00634e75dd 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -74,7 +74,9 @@ pub enum RegistrationRequirement { } /// Software resolvable type (package or pattern). -#[derive(Deserialize, Serialize, strum::Display, utoipa::ToSchema)] +#[derive( + Copy, Clone, Debug, Deserialize, PartialEq, Serialize, strum::Display, utoipa::ToSchema, +)] #[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum ResolvableType { @@ -92,3 +94,134 @@ pub struct ResolvableParams { /// Whether the resolvables are optional or not. pub optional: bool, } + +pub struct ResolvablesSelection { + id: String, + optional: bool, + resolvables: Vec, + r#type: ResolvableType, +} + +/// A selection of resolvables to be installed. +/// +/// It holds a selection of patterns and packages to be installed and whether they are optional or +/// not. This class is similar to the `PackagesProposal` YaST module. +#[derive(Default)] +pub struct SoftwareSelection { + selections: Vec, +} + +impl SoftwareSelection { + pub fn new() -> Self { + Default::default() + } + + /// Adds a set of resolvables. + /// + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + /// * `resolvables` - The resolvables to add. + pub fn add(&mut self, id: &str, r#type: ResolvableType, optional: bool, resolvables: &[&str]) { + let list = self.find_or_create_selection(id, r#type, optional); + let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); + list.resolvables.extend(new_resolvables); + } + + /// Updates a set of resolvables. + /// + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + /// * `resolvables` - The resolvables included in the set. + pub fn set(&mut self, id: &str, r#type: ResolvableType, optional: bool, resolvables: &[&str]) { + let list = self.find_or_create_selection(id, r#type, optional); + let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); + list.resolvables = new_resolvables; + } + + /// Returns a set of resolvables. + /// + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + pub fn get(&self, id: &str, r#type: ResolvableType, optional: bool) -> Option> { + self.selections + .iter() + .find(|l| l.id == id && l.r#type == r#type && l.optional == optional) + .map(|l| l.resolvables.clone()) + } + + /// Removes the given resolvables from a set. + /// + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + pub fn remove(&mut self, id: &str, r#type: ResolvableType, optional: bool) { + self.selections + .retain(|l| l.id != id || l.r#type != r#type || l.optional != optional); + } + + fn find_or_create_selection( + &mut self, + id: &str, + r#type: ResolvableType, + optional: bool, + ) -> &mut ResolvablesSelection { + let found = self + .selections + .iter() + .position(|l| l.id == id && l.r#type == r#type && l.optional == optional); + + if let Some(index) = found { + &mut self.selections[index] + } else { + let selection = ResolvablesSelection { + id: id.to_string(), + r#type, + optional, + resolvables: vec![], + }; + self.selections.push(selection); + self.selections.last_mut().unwrap() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_selection() { + let mut selection = SoftwareSelection::new(); + selection.set("agama", ResolvableType::Package, false, &["agama-scripts"]); + selection.add("agama", ResolvableType::Package, false, &["suse"]); + + let packages = selection + .get("agama", ResolvableType::Package, false) + .unwrap(); + assert_eq!(packages.len(), 2); + } + + #[test] + fn test_set_selection() { + let mut selection = SoftwareSelection::new(); + selection.add("agama", ResolvableType::Package, false, &["agama-scripts"]); + selection.set("agama", ResolvableType::Package, false, &["suse"]); + + let packages = selection + .get("agama", ResolvableType::Package, false) + .unwrap(); + assert_eq!(packages.len(), 1); + } + + #[test] + fn test_remove_selection() { + let mut selection = SoftwareSelection::new(); + selection.add("agama", ResolvableType::Package, true, &["agama-scripts"]); + selection.remove("agama", ResolvableType::Package, true); + let packages = selection.get("agama", ResolvableType::Package, true); + assert_eq!(packages, None); + } +} diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 85f4b50e1d..d1ab8b775a 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -18,7 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{product::Product, progress::ProgressSummary, software::Pattern}; +use agama_lib::{ + product::Product, + progress::ProgressSummary, + software::{model::ResolvableType, Pattern}, +}; use tokio::sync::oneshot; use crate::common::backend::service_status::ServiceStatusClient; @@ -66,6 +70,23 @@ impl SoftwareServiceClient { Ok(()) } + pub fn set_resolvables( + &self, + id: &str, + r#type: ResolvableType, + resolvables: &[&str], + optional: bool, + ) -> Result<(), SoftwareServiceError> { + let resolvables: Vec = resolvables.iter().map(|r| r.to_string()).collect(); + self.actions.send(SoftwareAction::SetResolvables { + id: id.to_string(), + r#type, + resolvables, + optional, + })?; + Ok(()) + } + pub async fn get_progress(&self) -> Result, SoftwareServiceError> { Ok(self.status.get_progress().await?) } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 3798e64a4b..2b010c1e13 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -20,7 +20,13 @@ use std::{path::Path, sync::Arc}; -use agama_lib::{product::Product, software::Pattern}; +use agama_lib::{ + product::Product, + software::{ + model::{ResolvableType, SoftwareSelection}, + Pattern, + }, +}; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ @@ -40,6 +46,12 @@ pub enum SoftwareAction { GetProducts(oneshot::Sender>), GetPatterns(oneshot::Sender>), SelectProduct(String), + SetResolvables { + id: String, + r#type: ResolvableType, + resolvables: Vec, + optional: bool, + }, } /// Software service server. @@ -50,6 +62,7 @@ pub struct SoftwareServiceServer { status: ServiceStatusClient, // FIXME: what about having a SoftwareServiceState to keep business logic state? selected_product: Option, + software_selection: SoftwareSelection, } const SERVICE_NAME: &str = "org.opensuse.Agama.Software1"; @@ -72,6 +85,7 @@ impl SoftwareServiceServer { products, status: status.clone(), selected_product: None, + software_selection: SoftwareSelection::default(), }; tokio::spawn(async move { @@ -121,6 +135,17 @@ impl SoftwareServiceServer { self.probe().await?; _ = self.status.finish_task(); } + + SoftwareAction::SetResolvables { + id, + r#type, + resolvables, + optional, + } => { + let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); + self.software_selection + .add(&id, r#type, optional, &resolvables); + } } Ok(()) } diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 4839af0500..522441cb99 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -22,10 +22,13 @@ use agama_lib::{ error::ServiceError, product::Product, progress::ProgressSummary, - software::{model::SoftwareConfig, Pattern}, + software::{ + model::{ResolvableParams, SoftwareConfig}, + Pattern, + }, }; use axum::{ - extract::State, + extract::{Path, State}, routing::{get, post, put}, Json, Router, }; @@ -48,6 +51,7 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result) -> Result, + Path(id): Path, + Json(params): Json, +) -> Result, Error> { + let names: Vec<_> = params.names.iter().map(|n| n.as_str()).collect(); + state + .client + .set_resolvables(&id, params.r#type, &names, params.optional)?; + Ok(Json(())) +} From ce171aec73194145e7123dfc23efe65469ef03a6 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 4 Sep 2025 09:34:03 +0200 Subject: [PATCH 016/917] use recursive submodules in all rust CI to allow parsing Cargo.toml --- .github/workflows/ci-rust.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 95f7b0508d..e5bf940387 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -58,6 +58,8 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Rust toolchain run: | @@ -80,6 +82,8 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Rust toolchain run: | @@ -120,6 +124,8 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Rust toolchain run: | From 56c94ed27b96dc6e1c55ba8f8b9e036de373b6b9 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 4 Sep 2025 11:44:21 +0200 Subject: [PATCH 017/917] update submodule --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 583c6d2ab4..f10f808bff 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 583c6d2ab48e08fe3c55df95a08e6eecfd461f3d +Subproject commit f10f808bffc1a2750816afae4dee058a864617fa From 3c66919750d3fed77bfee1502bc176e7120b5439 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 4 Sep 2025 14:49:59 +0200 Subject: [PATCH 018/917] first bunch of fixes --- rust/Cargo.lock | 29 ++++++-- rust/agama-lib/src/error.rs | 2 - rust/agama-lib/src/progress.rs | 72 +----------------- rust/agama-lib/src/software/model.rs | 74 ------------------- rust/agama-lib/src/software/model/packages.rs | 2 +- rust/agama-server/src/error.rs | 4 +- rust/agama-server/src/software_ng/backend.rs | 3 - rust/agama-server/src/web.rs | 2 +- 8 files changed, 26 insertions(+), 162 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 800449d0c6..4927f8b305 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -161,7 +161,6 @@ dependencies = [ "serde", "serde_json", "serde_with", - "serde_yaml", "subprocess", "tempfile", "thiserror 2.0.12", @@ -4141,6 +4140,17 @@ dependencies = [ "thiserror-impl 2.0.12", ] +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "thiserror-impl" version = "2.0.12" @@ -4606,12 +4616,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -5346,6 +5350,17 @@ dependencies = [ "zerocopy-derive 0.8.25", ] +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "zerocopy-derive" version = "0.8.25" diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 6b7f1a143b..35732abe6a 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -37,8 +37,6 @@ pub enum ServiceError { ZVariant(#[from] zvariant::Error), #[error(transparent)] HTTPError(#[from] reqwest::Error), - #[error("HTTP client error: {0}")] - HTTPClientError(#[from] BaseHTTPClientError), // it's fine to say only "Error" because the original // specific error will be printed too // `#` is std::fmt "Alternate form", anyhow::Error interprets as "include causes" diff --git a/rust/agama-lib/src/progress.rs b/rust/agama-lib/src/progress.rs index 54a8ed7dc9..60cb88504f 100644 --- a/rust/agama-lib/src/progress.rs +++ b/rust/agama-lib/src/progress.rs @@ -72,74 +72,4 @@ pub struct ProgressSequence { pub steps: Vec, #[serde(flatten)] pub progress: Progress, -} - -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProgressSummary { - pub steps: Vec, - pub current_step: u32, - pub max_steps: u32, - pub current_title: String, - pub finished: bool, -} - -impl ProgressSummary { - pub fn finished() -> Self { - Self { - steps: vec![], - current_step: 0, - max_steps: 0, - current_title: "".to_string(), - finished: true, - } - } -} - -/// A sequence of progress steps. -/// FIXME: find a better name to distinguish from agama-server::web::common::ProgressSequence. -#[derive(Debug)] -pub struct ProgressSequence { - pub steps: Vec, - current: usize, -} - -impl ProgressSequence { - /// Create a new progress sequence with the given steps. - /// - /// * `steps`: The steps to create the sequence from. - pub fn new(steps: Vec) -> Self { - Self { steps, current: 0 } - } - - /// Move to the next step in the sequence and return the progress for it. - /// - /// It returns `None` if the sequence is finished. - pub fn next_step(&mut self) -> Option { - if self.is_finished() { - return None; - } - self.current += 1; - self.step() - } - - /// The progres has finished. - pub fn is_finished(&self) -> bool { - self.current == self.steps.len() - } - - /// Return the progress for the current step. - pub fn step(&self) -> Option { - if self.is_finished() { - return None; - } - - let current_title = self.steps.get(self.current).unwrap().clone(); - Some(Progress { - current_step: (self.current + 1) as u32, - max_steps: self.steps.len() as u32, - current_title, - finished: (self.current + 1) == self.steps.len(), - }) - } -} +} \ No newline at end of file diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index b80899cb7e..c784ac94d3 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -28,80 +28,6 @@ pub use license::*; pub use packages::*; pub use registration::*; -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct SoftwareConfig { - /// A map where the keys are the pattern names and the values whether to install them or not. - pub patterns: Option>, - /// Name of the product to install. - pub product: Option, -} - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationParams { - /// Registration key. - pub key: String, - /// Registration email. - pub email: String, -} - -/// Information about registration configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RegistrationInfo { - /// Registration key. Empty value mean key not used or not registered. - pub key: String, - /// Registration email. Empty value mean email not used or not registered. - pub email: String, -} - -#[derive( - Clone, - Copy, - Debug, - Default, - Deserialize, - PartialEq, - Serialize, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum RegistrationRequirement { - /// Product does not require registration - #[default] - No = 0, - /// Product has optional registration - Optional = 1, - /// It is mandatory to register the product - Mandatory = 2, -} - -/// Software resolvable type (package or pattern). -#[derive( - Copy, Clone, Debug, Deserialize, PartialEq, Serialize, strum::Display, utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ResolvableType { - Package = 0, - Pattern = 1, -} - -/// Resolvable list specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -pub struct ResolvableParams { - /// List of resolvables. - pub names: Vec, - /// Resolvable type. - pub r#type: ResolvableType, - /// Whether the resolvables are optional or not. - pub optional: bool, -} - pub struct ResolvablesSelection { id: String, optional: bool, diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs index f18cd108fb..bcdd518f13 100644 --- a/rust/agama-lib/src/software/model/packages.rs +++ b/rust/agama-lib/src/software/model/packages.rs @@ -38,7 +38,7 @@ pub struct SoftwareConfig { } /// Software resolvable type (package or pattern). -#[derive(Deserialize, Serialize, strum::Display, utoipa::ToSchema)] +#[derive(Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq)] #[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum ResolvableType { diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 4c3959f9ec..083238ebf9 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -27,9 +27,7 @@ use axum::{ use serde_json::json; use crate::{ - l10n::LocaleError, - users::password::PasswordCheckerError, - web::common::{IssuesServiceError, ProgressServiceError}, + l10n::LocaleError, software_ng::SoftwareServiceError, users::password::PasswordCheckerError, web::common::{IssuesServiceError, ProgressServiceError} }; #[derive(thiserror::Error, Debug)] diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 5317b06ec1..c5b4fd6ea9 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -34,7 +34,6 @@ use std::sync::Arc; -use agama_lib::base_http_client::BaseHTTPClientError; pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; use zypp_agama::ZyppError; @@ -51,8 +50,6 @@ type SoftwareActionSender = tokio::sync::mpsc::UnboundedSender Date: Thu, 4 Sep 2025 21:03:36 +0100 Subject: [PATCH 019/917] Remove progress and status from software_ng --- rust/agama-server/src/common.rs | 23 -- rust/agama-server/src/common/backend.rs | 23 -- .../src/common/backend/service_status.rs | 267 ------------------ rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/software_ng/backend.rs | 9 +- .../src/software_ng/backend/client.rs | 12 +- .../src/software_ng/backend/server.rs | 20 +- rust/agama-server/src/software_ng/web.rs | 18 -- 8 files changed, 5 insertions(+), 368 deletions(-) delete mode 100644 rust/agama-server/src/common.rs delete mode 100644 rust/agama-server/src/common/backend.rs delete mode 100644 rust/agama-server/src/common/backend/service_status.rs diff --git a/rust/agama-server/src/common.rs b/rust/agama-server/src/common.rs deleted file mode 100644 index 8f04f6e659..0000000000 --- a/rust/agama-server/src/common.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Common functionality that can be shared across the package. - -pub(crate) mod backend; diff --git a/rust/agama-server/src/common/backend.rs b/rust/agama-server/src/common/backend.rs deleted file mode 100644 index d0b0cf404d..0000000000 --- a/rust/agama-server/src/common/backend.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Common functionality that can be shared by the different backends. - -pub mod service_status; diff --git a/rust/agama-server/src/common/backend/service_status.rs b/rust/agama-server/src/common/backend/service_status.rs deleted file mode 100644 index 612197bab3..0000000000 --- a/rust/agama-server/src/common/backend/service_status.rs +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements logic to keep track of the status of a service. -//! -//! This behavior can be reused by different services, e.g., the -//! [software service](crate::software_ng::SoftwareService). -use crate::web::{Event, EventsSender}; -use agama_lib::progress::{Progress, ProgressSequence, ProgressSummary}; -use tokio::sync::{ - mpsc, - oneshot::{self, error::RecvError}, -}; - -#[derive(thiserror::Error, Debug)] -pub enum ServiceStatusError { - #[error("The service is busy")] - Busy, - #[error("Could not send the message: {0}")] - SendError(#[from] mpsc::error::SendError), - #[error("Could not receive message: {0}")] - RecvError(#[from] RecvError), -} - -/// Actions related to service status management. -pub enum Action { - Start(Vec, oneshot::Sender>), - NextStep, - Finish, - GetProgress(oneshot::Sender>), -} - -type ActionReceiver = mpsc::UnboundedReceiver; -type ActionSender = mpsc::UnboundedSender; - -// TODO: somehow duplicated from agama-server/web/common.rs -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ServiceStatus { - Idle = 0, - Busy = 1, -} - -/// Builds and starts a service status server. -/// -/// See the [SoftwareService::start](crate::sfotware_ng::SoftwareService::start) method for an -/// example. -pub struct ServiceStatusManager {} - -impl ServiceStatusManager { - /// Starts a service status manager for the given service. - /// - /// * `name`: service name. - /// * `events`: channel to send events (e.g., status changes and progress updates). - pub fn start(name: &str, events: EventsSender) -> ServiceStatusClient { - let (sender, receiver) = mpsc::unbounded_channel(); - let server = ServiceStatusServer { - name: name.to_string(), - events, - progress: None, - // NOTE: would it be OK to derive the status from the progress - status: ServiceStatus::Idle, - receiver, - sender, - }; - - server.start() - } -} - -/// Client to interact with the service status manager. -/// -/// It uses a channel to send the actions to the server. It can be cloned and used in different -/// tasks if needed. -#[derive(Clone)] -pub struct ServiceStatusClient(ActionSender); - -impl ServiceStatusClient { - /// Starts a new long-running task. - pub async fn start_task(&self, steps: Vec) -> Result<(), ServiceStatusError> { - let (tx, rx) = oneshot::channel(); - self.0.send(Action::Start(steps, tx))?; - rx.await? - } - - /// Moves to the next step of the current long-running task. - pub fn next_step(&self) -> Result<(), ServiceStatusError> { - self.0.send(Action::NextStep)?; - Ok(()) - } - - /// Finishes the current long-running task. - pub fn finish_task(&self) -> Result<(), ServiceStatusError> { - self.0.send(Action::Finish)?; - Ok(()) - } - - /// Get the current progress information. - pub async fn get_progress(&self) -> Result, ServiceStatusError> { - let (tx, rx) = oneshot::channel(); - self.0.send(Action::GetProgress(tx)).unwrap(); - Ok(rx.await?) - } -} - -/// Keeps track of the status of a service. -/// -/// It holds the progress sequence and the service status. Additionally, it emits -/// events when any of them change. -#[derive(Debug)] -pub struct ServiceStatusServer { - pub name: String, - events: EventsSender, - progress: Option, - status: ServiceStatus, - sender: ActionSender, - receiver: ActionReceiver, -} - -impl ServiceStatusServer { - pub fn start(self) -> ServiceStatusClient { - let channel = self.sender.clone(); - tokio::spawn(async move { - ServiceStatusServer::run(self).await; - }); - ServiceStatusClient(channel) - } - - /// Runs the server dispatching the actions received through the input channel. - pub async fn run(mut self) { - loop { - let Some(action) = self.receiver.recv().await else { - break; - }; - - match action { - Action::Start(steps, tx) => { - _ = tx.send(self.start_task(steps)); - } - - Action::Finish => { - self.finish_task(); - } - - Action::NextStep => { - self.next_step(); - } - - Action::GetProgress(tx) => { - let progress = self.get_progress(); - _ = tx.send(progress); - } - } - } - } - - /// Starts an operation composed by several steps. - /// - /// It builds a new progress sequence and sets the service as "busy". - /// - /// * `steps`: steps to include in the sequence. - fn start_task(&mut self, steps: Vec) -> Result<(), ServiceStatusError> { - if self.is_busy() { - return Err(ServiceStatusError::Busy {}); - } - let progress = ProgressSequence::new(steps); - if let Some(step) = progress.step() { - let _ = self.events.send(Event::Progress { - service: self.name.clone(), - progress: step, - }); - } - self.progress = Some(progress); - - self.status = ServiceStatus::Busy; - let _ = self.events.send(Event::ServiceStatusChanged { - service: self.name.clone(), - status: (self.status as u32), - }); - Ok(()) - } - - /// Moves to the next step in the progress sequence. - /// - /// It returns `None` if no sequence is found or if the sequence is already finished. - fn next_step(&mut self) -> Option { - let Some(progress) = self.progress.as_mut() else { - tracing::error!("No progress sequence found"); - return None; - }; - - let Some(step) = progress.next_step() else { - tracing::error!("The progress sequence is already finished"); - return None; - }; - - let _ = self.events.send(Event::Progress { - service: self.name.clone(), - progress: step.clone(), - }); - Some(step) - } - - /// Returns the current step of the progress sequence. - fn get_progress(&self) -> Option { - self.progress - .as_ref() - .map(|p| { - let Some(step) = p.step() else { - return None; - }; - - let summary = ProgressSummary { - steps: p.steps.clone(), - current_step: step.current_step, - max_steps: step.max_steps, - current_title: step.current_title, - finished: step.finished, - }; - Some(summary) - }) - .flatten() - } - - /// It finishes the current sequence. - /// - /// It finishes the progress sequence and sets the service as "idle". - fn finish_task(&mut self) { - self.progress = None; - let _ = self.events.send(Event::Progress { - service: self.name.clone(), - progress: Progress { - current_step: 0, - max_steps: 0, - current_title: "".to_string(), - finished: true, - }, - }); - - self.status = ServiceStatus::Idle; - let _ = self.events.send(Event::ServiceStatusChanged { - service: self.name.clone(), - status: (self.status as u32), - }); - } - - /// Determines whether the service is busy or not. - fn is_busy(&self) -> bool { - self.status == ServiceStatus::Busy - } -} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 9429befe3e..d8cc7e280c 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -39,5 +39,4 @@ pub mod users; pub mod web; pub use web::service; -pub mod common; pub mod software_ng; diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index c5b4fd6ea9..52bdb8a4ae 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -38,10 +38,7 @@ pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; use zypp_agama::ZyppError; -use crate::{ - common::backend::service_status::ServiceStatusError, products::ProductsRegistry, - web::EventsSender, -}; +use crate::{products::ProductsRegistry, web::EventsSender}; mod client; mod server; @@ -50,7 +47,6 @@ type SoftwareActionSender = tokio::sync::mpsc::UnboundedSender), - #[error("Service status error: {0}")] - ServiceStatus(#[from] ServiceStatusError), - #[error("Unknown product: {0}")] UnknownProduct(String), diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index d1ab8b775a..c9f5dbba3f 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -20,13 +20,10 @@ use agama_lib::{ product::Product, - progress::ProgressSummary, software::{model::ResolvableType, Pattern}, }; use tokio::sync::oneshot; -use crate::common::backend::service_status::ServiceStatusClient; - use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; /// Client to interact with the software service. @@ -36,13 +33,12 @@ use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; #[derive(Clone)] pub struct SoftwareServiceClient { actions: SoftwareActionSender, - status: ServiceStatusClient, } impl SoftwareServiceClient { /// Creates a new client. - pub fn new(actions: SoftwareActionSender, status: ServiceStatusClient) -> Self { - Self { actions, status } + pub fn new(actions: SoftwareActionSender) -> Self { + Self { actions } } /// Returns the list of known products. @@ -86,8 +82,4 @@ impl SoftwareServiceClient { })?; Ok(()) } - - pub async fn get_progress(&self) -> Result, SoftwareServiceError> { - Ok(self.status.get_progress().await?) - } } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 2b010c1e13..b9f1adbe57 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -30,7 +30,6 @@ use agama_lib::{ use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ - common::backend::service_status::{ServiceStatusClient, ServiceStatusManager}, products::{ProductSpec, ProductsRegistry}, web::EventsSender, }; @@ -59,7 +58,6 @@ pub struct SoftwareServiceServer { receiver: mpsc::UnboundedReceiver, events: EventsSender, products: Arc>, - status: ServiceStatusClient, // FIXME: what about having a SoftwareServiceState to keep business logic state? selected_product: Option, software_selection: SoftwareSelection, @@ -77,13 +75,10 @@ impl SoftwareServiceServer { ) -> Result { let (sender, receiver) = mpsc::unbounded_channel(); - let status = ServiceStatusManager::start(SERVICE_NAME, events.clone()); - let server = Self { receiver, events, products, - status: status.clone(), selected_product: None, software_selection: SoftwareSelection::default(), }; @@ -93,7 +88,7 @@ impl SoftwareServiceServer { tracing::error!("Software service could not start: {:?}", error); } }); - Ok(SoftwareServiceClient::new(sender, status)) + Ok(SoftwareServiceClient::new(sender)) } /// Runs the server dispatching the actions received through the input channel. @@ -133,7 +128,6 @@ impl SoftwareServiceServer { SoftwareAction::Probe => { self.probe().await?; - _ = self.status.finish_task(); } SoftwareAction::SetResolvables { @@ -163,15 +157,6 @@ impl SoftwareServiceServer { } async fn probe(&self) -> Result<(), SoftwareServiceError> { - _ = self - .status - .start_task(vec![ - "Add base repositories".to_string(), - "Refreshing repositories metadata".to_string(), - // "Calculate software proposal".to_string(), - ]) - .await; - let product = self.find_selected_product().await?; let repositories = product.software.repositories(); for (idx, repo) in repositories.iter().enumerate() { @@ -184,8 +169,6 @@ impl SoftwareServiceServer { .map_err(SoftwareServiceError::AddRepositoryFailed)?; } - _ = self.status.next_step(); - zypp_agama::load_source(|percent, alias| { tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); true @@ -211,6 +194,7 @@ impl SoftwareServiceServer { description: p.description.clone(), icon: p.icon.clone(), registration: p.registration, + license: None, }) .collect(); tx.send(products) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 522441cb99..e651e84029 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -21,7 +21,6 @@ use agama_lib::{ error::ServiceError, product::Product, - progress::ProgressSummary, software::{ model::{ResolvableParams, SoftwareConfig}, Pattern, @@ -52,7 +51,6 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result) -> Result) -> Result, Error> { - let summary = match state.client.get_progress().await? { - Some(summary) => summary, - None => ProgressSummary::finished(), - }; - Ok(Json(summary)) -} - /// Updates the resolvables list with the given `id`. #[utoipa::path( put, From a74b7b40170c5b52c42b11450034404f6abea43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 4 Sep 2025 21:03:57 +0100 Subject: [PATCH 020/917] Add serde_yaml --- rust/Cargo.lock | 20 ++++++++++++++++++++ rust/agama-server/Cargo.toml | 1 + 2 files changed, 21 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4927f8b305..a93e8c8e11 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "subprocess", "tempfile", "thiserror 2.0.12", @@ -3829,6 +3830,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4616,6 +4630,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index a391577fdf..4f7271703d 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -58,6 +58,7 @@ zypp-agama = { path = "../zypp-c-api/rust/zypp-agama" } glob = "0.3.1" tempfile = "3.13.0" url = "2.5.2" +serde_yaml = "0.9.34" [[bin]] name = "agama-dbus-server" From 779b6c9395cf4413948606b1d79ebe0a2930bd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 4 Sep 2025 21:04:28 +0100 Subject: [PATCH 021/917] Adapt products spec to latest changes --- rust/agama-lib/src/software/model/packages.rs | 2 +- rust/agama-server/src/products.rs | 6 ++---- rust/agama-server/tests/share/products.d/tumbleweed.yaml | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs index bcdd518f13..abbcf077dc 100644 --- a/rust/agama-lib/src/software/model/packages.rs +++ b/rust/agama-lib/src/software/model/packages.rs @@ -38,7 +38,7 @@ pub struct SoftwareConfig { } /// Software resolvable type (package or pattern). -#[derive(Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq)] +#[derive(Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq)] #[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum ResolvableType { diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index 05375483c7..9313e8cd00 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -24,7 +24,6 @@ //! It reads the list of products from the `products.d` directory (usually, //! `/usr/share/agama/products.d`). -use agama_lib::product::RegistrationRequirement; use serde::Deserialize; use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use std::path::{Path, PathBuf}; @@ -110,8 +109,7 @@ pub struct ProductSpec { pub name: String, pub description: String, pub icon: String, - #[serde(default = "RegistrationRequirement::default")] - pub registration: RegistrationRequirement, + pub registration: bool, pub version: Option, pub software: SoftwareSpec, } @@ -181,7 +179,7 @@ mod test { assert_eq!(product.id, "Tumbleweed"); assert_eq!(product.name, "openSUSE Tumbleweed"); assert_eq!(product.icon, "Tumbleweed.svg"); - assert_eq!(product.registration, RegistrationRequirement::No); + assert_eq!(product.registration, false); assert_eq!(product.version, None); let software = &product.software; assert_eq!(software.installation_repositories.len(), 11); diff --git a/rust/agama-server/tests/share/products.d/tumbleweed.yaml b/rust/agama-server/tests/share/products.d/tumbleweed.yaml index ea9996f79b..4b14100a5a 100644 --- a/rust/agama-server/tests/share/products.d/tumbleweed.yaml +++ b/rust/agama-server/tests/share/products.d/tumbleweed.yaml @@ -1,6 +1,7 @@ id: Tumbleweed name: openSUSE Tumbleweed -registration: no +registration: false +version: "16.0" # ------------------------------------------------------------------------------ # WARNING: When changing the product description delete the translations located # at the at translations/description key below to avoid using obsolete From 58f3300c312f3e5c919437fbc89f7afa85dd8e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Fri, 5 Sep 2025 15:15:26 +0200 Subject: [PATCH 022/917] Use relative URL for the git submodule --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 743cb40791..2cd0223a49 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "rust/zypp-c-api"] path = rust/zypp-c-api - url = git@github.com:agama-project/zypp-c-api.git + url = ../zypp-c-api.git From afc79a024648aaef6780e37651eeac97b39926ce Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 07:46:29 +0200 Subject: [PATCH 023/917] use c api without unsafe impl --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index f10f808bff..54956aaf38 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit f10f808bffc1a2750816afae4dee058a864617fa +Subproject commit 54956aaf38084c2573ef74865751cf182ef9c36a From 1a06fedcbc617d031732d13d8f2a97e71fef7418 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 11:53:56 +0200 Subject: [PATCH 024/917] use LocalSet to ensure zypp runs on single thread --- rust/agama-server/src/software_ng.rs | 1 - rust/agama-server/src/software_ng/backend.rs | 4 +- .../src/software_ng/backend/server.rs | 57 ++++++++++++------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs index a8c7d50789..17802406e8 100644 --- a/rust/agama-server/src/software_ng.rs +++ b/rust/agama-server/src/software_ng.rs @@ -35,7 +35,6 @@ pub async fn software_ng_service( products: Arc>, ) -> Router { let client = SoftwareService::start(events, products) - .await .expect("Could not start the software service."); web::software_router(client) .await diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 52bdb8a4ae..80e3eb0f1d 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -96,10 +96,10 @@ pub struct SoftwareService {} impl SoftwareService { /// Starts the software service. - pub async fn start( + pub fn start( events: EventsSender, products: Arc>, ) -> Result { - server::SoftwareServiceServer::start(events, products).await + server::SoftwareServiceServer::start(events, products) } } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index b9f1adbe57..02d523770c 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -68,8 +68,8 @@ const SERVICE_NAME: &str = "org.opensuse.Agama.Software1"; impl SoftwareServiceServer { /// Starts the software service loop and returns a client. /// - /// The service runs on a separate Tokio task and gets the client requests using a channel. - pub async fn start( + /// The service runs on a separate thread and gets the client requests using a channel. + pub fn start( events: EventsSender, products: Arc>, ) -> Result { @@ -83,17 +83,26 @@ impl SoftwareServiceServer { software_selection: SoftwareSelection::default(), }; - tokio::spawn(async move { - if let Err(error) = server.run().await { - tracing::error!("Software service could not start: {:?}", error); - } + // see https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#use-inside-tokiospawn for explain how to ensure that zypp + // runs locally on single thread + + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + + std::thread::spawn(move || { + let local = tokio::task::LocalSet::new(); + + local.spawn_local(async move { server.run().await }); + + // This will return once all senders are dropped and all + // spawned tasks have returned. + rt.block_on(local); }); Ok(SoftwareServiceClient::new(sender)) } /// Runs the server dispatching the actions received through the input channel. async fn run(mut self) -> Result<(), SoftwareServiceError> { - self.initialize_target_dir()?; + let zypp = self.initialize_target_dir()?; loop { let action = self.receiver.recv().await; @@ -103,7 +112,7 @@ impl SoftwareServiceServer { break; }; - if let Err(error) = self.dispatch(action).await { + if let Err(error) = self.dispatch(action, &zypp).await { tracing::error!("Software dispatch error: {:?}", error); } } @@ -112,14 +121,18 @@ impl SoftwareServiceServer { } /// Forwards the action to the appropriate handler. - async fn dispatch(&mut self, action: SoftwareAction) -> Result<(), SoftwareServiceError> { + async fn dispatch( + &mut self, + action: SoftwareAction, + zypp: &zypp_agama::Zypp, + ) -> Result<(), SoftwareServiceError> { match action { SoftwareAction::GetProducts(tx) => { self.get_products(tx).await?; } SoftwareAction::GetPatterns(tx) => { - self.get_patterns(tx).await?; + self.get_patterns(tx, zypp).await?; } SoftwareAction::SelectProduct(product_id) => { @@ -127,7 +140,7 @@ impl SoftwareServiceServer { } SoftwareAction::Probe => { - self.probe().await?; + self.probe(zypp).await?; } SoftwareAction::SetResolvables { @@ -156,20 +169,20 @@ impl SoftwareServiceServer { Ok(()) } - async fn probe(&self) -> Result<(), SoftwareServiceError> { + async fn probe(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { let product = self.find_selected_product().await?; let repositories = product.software.repositories(); for (idx, repo) in repositories.iter().enumerate() { // TODO: we should add a repository ID in the configuration file. let name = format!("agama-{}", idx); - zypp_agama::add_repository(&name, &repo.url, |percent, alias| { + zypp.add_repository(&name, &repo.url, |percent, alias| { tracing::info!("Adding repository {} ({}%)", alias, percent); true }) .map_err(SoftwareServiceError::AddRepositoryFailed)?; } - zypp_agama::load_source(|percent, alias| { + zypp.load_source(|percent, alias| { tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); true }) @@ -205,6 +218,7 @@ impl SoftwareServiceServer { async fn get_patterns( &self, tx: oneshot::Sender>, + zypp: &zypp_agama::Zypp, ) -> Result<(), SoftwareServiceError> { let product = self.find_selected_product().await?; @@ -217,7 +231,8 @@ impl SoftwareServiceServer { .map(String::as_str) .collect(); - let patterns = zypp_agama::patterns_info(pattern_names) + let patterns = zypp + .patterns_info(pattern_names) .map_err(SoftwareServiceError::ListPatternsFailed)?; let patterns = patterns @@ -237,7 +252,7 @@ impl SoftwareServiceServer { Ok(()) } - fn initialize_target_dir(&self) -> Result<(), SoftwareServiceError> { + fn initialize_target_dir(&self) -> Result { let target_dir = Path::new(TARGET_DIR); if target_dir.exists() { _ = std::fs::remove_dir_all(target_dir); @@ -245,20 +260,20 @@ impl SoftwareServiceServer { std::fs::create_dir_all(target_dir).map_err(SoftwareServiceError::TargetCreationFailed)?; - zypp_agama::init_target(TARGET_DIR, |text, step, total| { + let zypp = zypp_agama::Zypp::init_target(TARGET_DIR, |text, step, total| { tracing::info!("Initializing target: {} ({}/{})", text, step, total); }) .map_err(SoftwareServiceError::TargetInitFailed)?; - self.import_gpg_keys(); - Ok(()) + self.import_gpg_keys(&zypp); + Ok(zypp) } - fn import_gpg_keys(&self) { + fn import_gpg_keys(&self, zypp: &zypp_agama::Zypp) { for file in glob::glob(GPG_KEYS).unwrap() { match file { Ok(file) => { - if let Err(e) = zypp_agama::import_gpg_key(&file.to_string_lossy()) { + if let Err(e) = zypp.import_gpg_key(&file.to_string_lossy()) { tracing::error!("Failed to import GPG key: {}", e); } } From 92e1a0640f4e5e149ee6c08a684811174c81dedd Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 13:35:46 +0200 Subject: [PATCH 025/917] even more simplified --- rust/agama-server/src/software_ng/backend/server.rs | 2 +- rust/zypp-c-api | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 02d523770c..858dab911f 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -91,7 +91,7 @@ impl SoftwareServiceServer { std::thread::spawn(move || { let local = tokio::task::LocalSet::new(); - local.spawn_local(async move { server.run().await }); + local.spawn_local(server.run()); // This will return once all senders are dropped and all // spawned tasks have returned. diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 54956aaf38..8cdfcbe0a7 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 54956aaf38084c2573ef74865751cf182ef9c36a +Subproject commit 8cdfcbe0a7f0d6affaac32c6cf6500f5e61c9bbe From e47e9465e577fa79deb284900fa8f04d3ab9a7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Mon, 8 Sep 2025 14:06:26 +0200 Subject: [PATCH 026/917] Update rust/zypp-c-api --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index f10f808bff..29498f13b3 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit f10f808bffc1a2750816afae4dee058a864617fa +Subproject commit 29498f13b32ce67c731e23257a7d7b00e2ae366c From 134bfbe9c37a2002965c6ce19e996282646008ca Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 14:48:53 +0200 Subject: [PATCH 027/917] try to fix vendoring of submodules --- rust/Cargo.toml | 2 ++ rust/zypp-c-api | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4b93ca5f00..38d0d21dc1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,6 +7,8 @@ members = [ "agama-locale-data", "agama-network", "agama-utils", + "zypp-c-api/rust/zypp-agama", + "zypp-c-api/rust/zypp-agama-sys", "xtask", ] resolver = "2" diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 29498f13b3..8a4dd04e68 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 29498f13b32ce67c731e23257a7d7b00e2ae366c +Subproject commit 8a4dd04e6892b33178a99f71cbcab60570b4c605 From c4c535ee13483d60aeb3592b51e7f8b3f675e5e5 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 21:51:59 +0200 Subject: [PATCH 028/917] add dependencies to be able to build zypp-c-api --- rust/package/agama.spec | 5 +++++ rust/zypp-c-api | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 5c03a79db9..bf79e5f3c7 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -27,6 +27,11 @@ Url: https://github.com/agama-project/agama Source0: agama.tar Source1: vendor.tar.zst +# zypp-c-api dependencies +BuildRequires: gcc +BuildRequires: gcc-c++ +BuildRequires: make +BuildRequires: libzypp-devel # defines the "limit_build" macro used in the "build" section below BuildRequires: memory-constraints BuildRequires: cargo-packaging diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 29498f13b3..8a4dd04e68 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 29498f13b32ce67c731e23257a7d7b00e2ae366c +Subproject commit 8a4dd04e6892b33178a99f71cbcab60570b4c605 From 68e752f8d734241f027cb30572aa351a5d842c2b Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 8 Sep 2025 23:24:43 +0200 Subject: [PATCH 029/917] update dep to include bindgen --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 8a4dd04e68..0b19793696 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 8a4dd04e6892b33178a99f71cbcab60570b4c605 +Subproject commit 0b19793696377d3086945b1125cfc3f51e2e04ba From 46e4550588926c8e818e0e38848a2be98e92c19e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 9 Sep 2025 10:42:26 +0200 Subject: [PATCH 030/917] fix building with bindgen --- rust/Cargo.lock | 70 ++++++++++++++++++++++++++++++++++-- rust/agama-server/Cargo.toml | 4 +++ rust/zypp-c-api | 2 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a93e8c8e11..259d4c35fe 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -142,6 +142,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "bindgen 0.69.5", "clap", "config", "futures-util", @@ -712,10 +713,33 @@ dependencies = [ "itertools 0.12.1", "lazy_static", "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.101", + "which", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.101", ] @@ -927,6 +951,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", + "libloading", ] [[package]] @@ -2455,6 +2480,16 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.0", +] + [[package]] name = "libredox" version = "0.1.3" @@ -2997,7 +3032,7 @@ version = "1.0.0-alpha5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" dependencies = [ - "bindgen", + "bindgen 0.69.5", "libc", ] @@ -3260,6 +3295,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.101", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3577,6 +3622,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -4892,6 +4943,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5492,3 +5555,6 @@ dependencies = [ [[package]] name = "zypp-agama-sys" version = "0.1.0" +dependencies = [ + "bindgen 0.72.1", +] diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 4f7271703d..befeb371bf 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -74,3 +74,7 @@ tokio-test = "0.4.4" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } + +# here we force runtime for bindgen otherwise pam-sys fails +[build-dependencies] +bindgen = { version = "0.69", features = ["runtime"] } diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 0b19793696..adb0e69faf 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 0b19793696377d3086945b1125cfc3f51e2e04ba +Subproject commit adb0e69faf05d7a98bc9477cc54049876ece5e39 From 6d3c63f58fce762783fed21aa47a06e0b4d7ed0e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 9 Sep 2025 12:23:21 +0200 Subject: [PATCH 031/917] update dep --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index adb0e69faf..e815d8f587 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit adb0e69faf05d7a98bc9477cc54049876ece5e39 +Subproject commit e815d8f5876c29fce2ee29eb01070fc529bebf51 From 8693cbd86700c8cbe59a1b6fe8a84100e139db7d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 9 Sep 2025 12:59:16 +0200 Subject: [PATCH 032/917] remove Cargo.toml from submodule to avoid confusion --- rust/package/agama.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index bf79e5f3c7..d91e4d1992 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -153,6 +153,8 @@ package contains a systemd service to run scripts when booting the installed sys # Require at least 1.3GB RAM per each parallel job (the size is in MB), # this can limit the number of parallel jobs on systems with relatively small memory. %{limit_build -m 1300} +# remove project cargo files from submodules to avoid confusion of tools +rm zypp-c-api/rust/Cargo.* %{cargo_build} cargo run --package xtask -- manpages From bef76c999969e2b6f48aabffe8e054d554660f49 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 08:55:26 +0200 Subject: [PATCH 033/917] use cargo fmt --- rust/agama-lib/src/progress.rs | 2 +- rust/agama-server/src/error.rs | 5 ++++- rust/agama-server/src/software_ng.rs | 4 ++-- rust/agama-server/src/software_ng/backend/server.rs | 5 ++++- rust/agama-server/src/web.rs | 5 ++++- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/rust/agama-lib/src/progress.rs b/rust/agama-lib/src/progress.rs index 60cb88504f..b4a8167996 100644 --- a/rust/agama-lib/src/progress.rs +++ b/rust/agama-lib/src/progress.rs @@ -72,4 +72,4 @@ pub struct ProgressSequence { pub steps: Vec, #[serde(flatten)] pub progress: Progress, -} \ No newline at end of file +} diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 083238ebf9..2c9fe64571 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -27,7 +27,10 @@ use axum::{ use serde_json::json; use crate::{ - l10n::LocaleError, software_ng::SoftwareServiceError, users::password::PasswordCheckerError, web::common::{IssuesServiceError, ProgressServiceError} + l10n::LocaleError, + software_ng::SoftwareServiceError, + users::password::PasswordCheckerError, + web::common::{IssuesServiceError, ProgressServiceError}, }; #[derive(thiserror::Error, Debug)] diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs index 17802406e8..f13588aabb 100644 --- a/rust/agama-server/src/software_ng.rs +++ b/rust/agama-server/src/software_ng.rs @@ -34,8 +34,8 @@ pub async fn software_ng_service( events: EventsSender, products: Arc>, ) -> Router { - let client = SoftwareService::start(events, products) - .expect("Could not start the software service."); + let client = + SoftwareService::start(events, products).expect("Could not start the software service."); web::software_router(client) .await .expect("Could not build the software router.") diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 858dab911f..adcb8fced7 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -86,7 +86,10 @@ impl SoftwareServiceServer { // see https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#use-inside-tokiospawn for explain how to ensure that zypp // runs locally on single thread - let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); std::thread::spawn(move || { let local = tokio::task::LocalSet::new(); diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 9d31c9892b..73160a1022 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -113,7 +113,10 @@ where ) .add_service("/iscsi", iscsi_service(dbus.clone(), issues.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) - .add_service("/network", network_service(network_adapter, events.clone()).await?) + .add_service( + "/network", + network_service(network_adapter, events.clone()).await?, + ) .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone(), issues).await?) .add_service("/scripts", scripts_service().await?) From fe89b8419d74be83ac62d16bb5d63d89fad1fca3 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 09:01:43 +0200 Subject: [PATCH 034/917] update branch --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 8cdfcbe0a7..d2f8efcf19 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 8cdfcbe0a7f0d6affaac32c6cf6500f5e61c9bbe +Subproject commit d2f8efcf195147c99d6777e5169327e938c2143d From 301b696dcb68e2a559ea9b58eea290756759e3fa Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 09:33:11 +0200 Subject: [PATCH 035/917] add libzypp dev to be able to compile tests --- .github/workflows/ci-rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index e5bf940387..d7890bdaf0 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -135,7 +135,7 @@ jobs: - name: Install packages run: | sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev python3-langtable jsonnet + sudo apt-get -y install libclang-18-dev libpam0g-dev python3-langtable jsonnet libzypp-dev - name: Prepare for tests run: | From 589c27d6027ec8311cb09095336a5a98b028426c Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 09:43:16 +0200 Subject: [PATCH 036/917] update zypp c api to fix build --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index d2f8efcf19..fa74814dcb 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit d2f8efcf195147c99d6777e5169327e938c2143d +Subproject commit fa74814dcb84be59d6da1302f263b093c30acae9 From ea4dc2703a6786d1b2c8b387e25d5913fce61c39 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 10:08:34 +0200 Subject: [PATCH 037/917] give a bit more output to see why it failing --- .github/workflows/ci-rust.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index d7890bdaf0..191d70bee9 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -164,6 +164,9 @@ jobs: if: steps.cache-tests.outputs.cache-hit != 'true' run: cargo install cargo-tarpaulin + - name: Test build + run: cargo build -vv + - name: Run the tests # Compile into the ./target-coverage directory because tarpaulin uses special compilation # flags, to avoid reusing the previous builds it always starts from scratch. From 32cd08809c3766da02ede284611307394efb8d4a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 11:07:57 +0200 Subject: [PATCH 038/917] update dependency --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index fa74814dcb..fc5ad78202 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit fa74814dcb84be59d6da1302f263b093c30acae9 +Subproject commit fc5ad782026f97e0f8beb8f9db5b20203e1332cc From ee4b134ae52b4618272e5a9e9ded87520a2bdd00 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 11:41:42 +0200 Subject: [PATCH 039/917] more debugging ci lines --- .github/workflows/ci-rust.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 191d70bee9..5af8321d9a 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -164,7 +164,13 @@ jobs: if: steps.cache-tests.outputs.cache-hit != 'true' run: cargo install cargo-tarpaulin + - name: Print headers + run: ls -R /usr/include/ + - name: Test build + env: + # libzypp dev on ubuntu uses gnu path, but bindgen uses clang + CPATH: /usr/include/x86_64-linux-gnu run: cargo build -vv - name: Run the tests @@ -178,6 +184,8 @@ jobs: RUSTC_BOOTSTRAP: 1 RUSTUP_TOOLCHAIN: stable RUST_BACKTRACE: 1 + # libzypp dev on ubuntu uses gnu path, but bindgen uses clang + CPATH: /usr/include/x86_64-linux-gnu RUSTFLAGS: --cfg ci # send the code coverage for the Rust part to the coveralls.io From 57de622859e5b2ccea57ccf98ebf1d0aa2a4bfd6 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 10 Sep 2025 13:23:21 +0200 Subject: [PATCH 040/917] fix wrong expectation in test --- rust/agama-server/src/products.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index 9313e8cd00..5c894f934e 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -180,7 +180,7 @@ mod test { assert_eq!(product.name, "openSUSE Tumbleweed"); assert_eq!(product.icon, "Tumbleweed.svg"); assert_eq!(product.registration, false); - assert_eq!(product.version, None); + assert_eq!(product.version, Some("16.0".to_string())); let software = &product.software; assert_eq!(software.installation_repositories.len(), 11); assert_eq!(software.installation_labels.len(), 4); From 317f8bc7b35a194c75a396ef14abf275fe8e0880 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 11 Sep 2025 07:09:19 +0200 Subject: [PATCH 041/917] Update rust/agama-server/src/software_ng/backend/server.rs Co-authored-by: Martin Vidner --- rust/agama-server/src/software_ng/backend/server.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index adcb8fced7..d9a2c4db95 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -91,6 +91,8 @@ impl SoftwareServiceServer { .build() .unwrap(); + // drop the returned JoinHandle: the thread will be detached + // but that's OK for it to run until the process dies std::thread::spawn(move || { let local = tokio::task::LocalSet::new(); From 56397a6e360d8c98c008f1353990e7efa6f8d730 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 11 Sep 2025 07:18:28 +0200 Subject: [PATCH 042/917] update zypp c api to master --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index fc5ad78202..eb014d0d21 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit fc5ad782026f97e0f8beb8f9db5b20203e1332cc +Subproject commit eb014d0d2196124dc40b58cf9a5e1996728f517c From 3c9763ff7e4903cdc687a01e78f141d3062eb18d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 11 Sep 2025 16:45:50 +0200 Subject: [PATCH 043/917] remove doctest example for private module --- rust/agama-server/src/software_ng/backend.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 80e3eb0f1d..340ffa6727 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -80,18 +80,6 @@ pub enum SoftwareServiceError { /// Builds and starts the software service. /// -/// ```no_run -/// # use tokio_test; -/// use agama_server::{ -/// software::backend::SoftwareService -/// }; -/// -/// # tokio_test::block_on(async { -/// let client = SoftwareService::start(products, http, events_tx).await; -/// -/// let products = client.get_products().await -/// .expect("Failed to get the products"); -/// # }); pub struct SoftwareService {} impl SoftwareService { From 257ec3756d21598b7ca07db8d406c888a94d1ed6 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 11 Sep 2025 16:55:51 +0200 Subject: [PATCH 044/917] update dependency --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index eb014d0d21..f5afda67d8 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit eb014d0d2196124dc40b58cf9a5e1996728f517c +Subproject commit f5afda67d8ac1628c0d306aceac946ae85dc2b17 From 6afadb51b3bc413667ec5e924079210906580bac Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 11 Sep 2025 22:36:19 +0200 Subject: [PATCH 045/917] update zypp-c-api --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index f5afda67d8..350c5cd2d0 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit f5afda67d8ac1628c0d306aceac946ae85dc2b17 +Subproject commit 350c5cd2d0fa8de222bf1f3bbc03029305809e62 From dfca15195556cd73f41c6375bc840e4614ba59d4 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 12 Sep 2025 08:52:40 +0200 Subject: [PATCH 046/917] cut off old software stack to see how big gap we have in the new one --- rust/agama-server/src/web.rs | 12 +----------- service/bin/agamactl | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 73160a1022..88aceaf71a 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -97,16 +97,6 @@ where manager_service(dbus.clone(), progress.clone()).await?, ) .add_service("/security", security_service(dbus.clone()).await?) - .add_service( - "/software", - software_service( - dbus.clone(), - events.subscribe(), - issues.clone(), - progress.clone(), - ) - .await?, - ) .add_service( "/storage", storage_service(dbus.clone(), issues.clone(), progress).await?, @@ -121,7 +111,7 @@ where .add_service("/users", users_service(dbus.clone(), issues).await?) .add_service("/scripts", scripts_service().await?) .add_service( - "/software_ng", + "/software", software_ng_service(events.clone(), Arc::clone(&products)).await, ) .add_service("/files", files_service().await?) diff --git a/service/bin/agamactl b/service/bin/agamactl index 15fd581de9..a497f9027a 100755 --- a/service/bin/agamactl +++ b/service/bin/agamactl @@ -64,7 +64,7 @@ def start_service(name) service_runner.run end -ORDERED_SERVICES = [:software, :storage, :users, :manager].freeze +ORDERED_SERVICES = [:storage, :users, :manager].freeze dbus_server_manager = Agama::DBus::ServerManager.new From 5f04c84912009f31a9cab9d30038e6c56d0dd793 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 12 Sep 2025 14:44:01 +0200 Subject: [PATCH 047/917] remove software dbus service --- service/share/org.opensuse.Agama.Software1.service | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 service/share/org.opensuse.Agama.Software1.service diff --git a/service/share/org.opensuse.Agama.Software1.service b/service/share/org.opensuse.Agama.Software1.service deleted file mode 100644 index 6bd132af17..0000000000 --- a/service/share/org.opensuse.Agama.Software1.service +++ /dev/null @@ -1,4 +0,0 @@ -[D-BUS Service] -Name=org.opensuse.Agama.Software1 -Exec=/usr/bin/agamactl software -User=root From e4cc6d7a718b656eb7be4b25cd9fe958a323cef2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 12 Sep 2025 14:44:47 +0200 Subject: [PATCH 048/917] add real products to product test and fix optional registry --- rust/agama-server/src/products.rs | 23 +- .../tests/share/products.d/kalpa.yaml | 100 +++++++++ .../tests/share/products.d/leap_160.yaml | 178 ++++++++++++++++ .../tests/share/products.d/leap_micro_62.yaml | 111 ++++++++++ .../tests/share/products.d/microos.yaml | 198 +++++++++++++++++ .../tests/share/products.d/sles_160.yaml | 200 ++++++++++++++++++ .../tests/share/products.d/sles_sap_160.yaml | 174 +++++++++++++++ .../tests/share/products.d/slowroll.yaml | 169 +++++++++++++++ .../tests/share/products.d/tumbleweed.yaml | 44 ++-- 9 files changed, 1162 insertions(+), 35 deletions(-) create mode 100644 rust/agama-server/tests/share/products.d/kalpa.yaml create mode 100644 rust/agama-server/tests/share/products.d/leap_160.yaml create mode 100644 rust/agama-server/tests/share/products.d/leap_micro_62.yaml create mode 100644 rust/agama-server/tests/share/products.d/microos.yaml create mode 100644 rust/agama-server/tests/share/products.d/sles_160.yaml create mode 100644 rust/agama-server/tests/share/products.d/sles_sap_160.yaml create mode 100644 rust/agama-server/tests/share/products.d/slowroll.yaml diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index 5c894f934e..ef2c64c58c 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -109,6 +109,7 @@ pub struct ProductSpec { pub name: String, pub description: String, pub icon: String, + #[serde(default)] pub registration: bool, pub version: Option, pub software: SoftwareSpec, @@ -173,18 +174,8 @@ mod test { fn test_load_registry() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); let config = ProductsRegistry::load_from(path.as_path()).unwrap(); - assert_eq!(config.products.len(), 1); - - let product = &config.products[0]; - assert_eq!(product.id, "Tumbleweed"); - assert_eq!(product.name, "openSUSE Tumbleweed"); - assert_eq!(product.icon, "Tumbleweed.svg"); - assert_eq!(product.registration, false); - assert_eq!(product.version, Some("16.0".to_string())); - let software = &product.software; - assert_eq!(software.installation_repositories.len(), 11); - assert_eq!(software.installation_labels.len(), 4); - assert_eq!(software.base_product, "openSUSE"); + // ensuring that we can load all products from tests + assert_eq!(config.products.len(), 8); } #[test] @@ -193,6 +184,14 @@ mod test { let products = ProductsRegistry::load_from(path.as_path()).unwrap(); let tw = products.find("Tumbleweed").unwrap(); assert_eq!(tw.id, "Tumbleweed"); + assert_eq!(tw.name, "openSUSE Tumbleweed"); + assert_eq!(tw.icon, "Tumbleweed.svg"); + assert_eq!(tw.registration, false); + assert_eq!(tw.version, None); + let software = &tw.software; + assert_eq!(software.installation_repositories.len(), 12); + assert_eq!(software.installation_labels.len(), 4); + assert_eq!(software.base_product, "openSUSE"); let missing = products.find("Missing"); assert!(missing.is_none()); diff --git a/rust/agama-server/tests/share/products.d/kalpa.yaml b/rust/agama-server/tests/share/products.d/kalpa.yaml new file mode 100644 index 0000000000..0298a97754 --- /dev/null +++ b/rust/agama-server/tests/share/products.d/kalpa.yaml @@ -0,0 +1,100 @@ +id: Kalpa +name: Kalpa Desktop +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "A rolling release immutable desktop product, using the Plasma + Desktop, leveraging Flatpak for Application Delivery, a Read-Only base, and + automatic and atomic updates of your system" +icon: Kalpa.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + # device labels for offline installation media + installation_labels: + - label: Kalpa-desktop-DVD-x86_64 + archs: x86_64 + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_kde_desktop + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: Kalpa + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-server/tests/share/products.d/leap_160.yaml b/rust/agama-server/tests/share/products.d/leap_160.yaml new file mode 100644 index 0000000000..2a627eb6b0 --- /dev/null +++ b/rust/agama-server/tests/share/products.d/leap_160.yaml @@ -0,0 +1,178 @@ +id: Leap_16.0 +name: Leap 16.0 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'The latest version of a community distribution based on the latest + SUSE Linux Enterprise Server.' +# Do not manually change any translations! See README.md for more details. +icon: Leap16.svg +translations: + description: + ca: La darrera versió d'una distribució comunitària basada en l'últim SUSE Linux + Enterprise Server. + cs: Nejnovější verze komunitní distribuce založené na nejnovějším SUSE Linux + Enterprise Serveru. + de: Die neueste Version einer Community-Distribution, die auf dem aktuellen SUSE + Linux Enterprise Server basiert. + es: La última versión de una distribución comunitaria basada en el último SUSE + Linux Enterprise Server. + ja: 最新のSUSE Linux Enterprise Server をベースにした、コミュニティディストリビューションの最新版です。 + nb_NO: Leap 16.0 er den nyeste versjonen av den fellesskapte distribusjon basert + på den nyeste SUSE Linux Enterprise Server. + pt_BR: A versão mais recente de uma distribuição comunitária baseada no mais + recente SUSE Linux Enterprise Server. + ru: Leap 16.0 - это последняя версия дистрибутива от сообщества, основанного на + последней версии SUSE Linux Enterprise Server. + sv: Den senaste versionen av en gemenskapsdistribution baserad på den senaste + SUSE Linux Enterprise Server. + tr: En son SUSE Linux Enterprise Server'ı temel alan bir topluluk dağıtımının en + son sürümü. + zh_Hans: Leap 16.0 是基于 SUSE Linux Enterprise Server 构建的社区发行版的最新版本。 +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap/16.0/repo/oss/$basearch + installation_labels: + - label: Leap-DVD-x86_64 + archs: x86_64 + - label: Leap-DVD-aarch64 + archs: aarch64 + - label: Leap-DVD-s390x + archs: s390 + - label: Leap-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on Leap + optional_patterns: null # no optional pattern shared + user_patterns: + - gnome + - kde + - xfce_wayland + - multimedia + - office + - cockpit + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - printing + mandatory_packages: + - NetworkManager + - openSUSE-repos-Leap + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: Leap + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-server/tests/share/products.d/leap_micro_62.yaml b/rust/agama-server/tests/share/products.d/leap_micro_62.yaml new file mode 100644 index 0000000000..e38012b577 --- /dev/null +++ b/rust/agama-server/tests/share/products.d/leap_micro_62.yaml @@ -0,0 +1,111 @@ +id: LeapMicro_6.2 +name: openSUSE Leap Micro 6.2 Beta +archs: x86_64,aarch64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'Leap Micro is an ultra-reliable, lightweight operating system + built for containerized and virtualized workloads.' +icon: LeapMicro.svg +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-x86_64 + archs: x86_64 + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-aarch64 + archs: aarch64 + # device labels for offline installation media + installation_labels: + - label: openSUSE-Leap-Micro-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Leap-Micro-DVD-aarch64 + archs: aarch64 + + mandatory_patterns: + - cockpit + - base + - transactional + - traditional + - hardware + - selinux + + optional_patterns: null + + user_patterns: + - cloud + - container_runtime + - fips + - ima_evm + - kvm_host + - ra_agent + - ra_verifier + - salt_minion + - sssd_ldap + + mandatory_packages: + - NetworkManager + - openSUSE-repos-LeapMicro + optional_packages: null + base_product: Leap-Micro + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-server/tests/share/products.d/microos.yaml b/rust/agama-server/tests/share/products.d/microos.yaml new file mode 100644 index 0000000000..ac8bbc7c48 --- /dev/null +++ b/rust/agama-server/tests/share/products.d/microos.yaml @@ -0,0 +1,198 @@ +id: MicroOS +name: openSUSE MicroOS +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A quick, small distribution designed to host container workloads + with automated administration & patching. openSUSE MicroOS provides + transactional (atomic) updates upon a read-only btrfs root file system. As + rolling release distribution the software is always up-to-date.' +icon: MicroOS.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una distribució ràpida i petita dissenyada per allotjar càrregues de treball + de contenidors amb administració i pedaços automatitzats. L'openSUSE + MicroSO proporciona actualitzacions transaccionals (atòmiques) en un + sistema de fitxers d'arrel btrfs només de lectura. Com a distribució + contínua, el programari està sempre actualitzat. + cs: Rychlá, malá distribuce určená pro úlohy hostitelského kontejneru s + automatizovanou správou a záplatováním. openSUSE MicroOS poskytuje + transakční (atomické) aktualizace na kořenovém souborovém systému btrfs + určeném pouze pro čtení. Jako distribuce s průběžným vydáváním je software + vždy aktuální. + de: Eine schnelle, kleine Distribution, die für den Betrieb von + Container-Arbeitslasten mit automatischer Verwaltung und automatisiertem + Patching entwickelt wurde. openSUSE MicroOS bietet transaktionale + (atomare) Aktualisierungen auf einem schreibgeschützten + btrfs-Wurzeldateisystem. Als Distribution mit rollierenden + Veröffentlichungen ist die Software immer auf dem neuesten Stand. + es: Una distribución pequeña y rápida diseñada para alojar cargas de trabajo de + contenedores con administración y parches automatizados. openSUSE MicroOS + proporciona actualizaciones transaccionales (atómicas) en un sistema de + archivos raíz btrfs de solo lectura. Como distribución de actualización + continua, el software siempre está actualizado. + fr: Une petite distribution rapide conçue pour héberger des charges de travail + de conteneurs avec une administration et des correctifs automatisés. + openSUSE MicroOS fournit des mises à jour transactionnelles (atomiques) + sur un système de fichiers racine btrfs en lecture seule. En tant que + distribution continue, le logiciel est toujours à jour. + id: Distribusi cepat dan ramping yang dirancang untuk menampung beban kerja + kontainer dengan administrasi & penambalan otomatis. openSUSE MicroOS + menyediakan pembaruan transaksional (atomik) pada sistem berkas root btrfs + yang hanya dapat dibaca. Sebagai distribusi rilis bergulir, perangkat + lunak didalamnya selalu diperbarui. + ja: 高速で小型のディストリビューションで、管理やパッチ適用の自動化のようなコンテナ処理を賄うのに最適な仕組みです。 openSUSE MicroOS + はトランザクション型の (不可分の) 更新機構が提供されており、 btrfs + のルートファイルシステムを読み込み専用にすることができます。こちらもローリングリリース型のディストリビューションであるため、常に最新を維持することができます。 + nb_NO: En rask, liten distribusjon laget for å være vert til container + arbeidsoppgaver med automatisk administrasjon & lapping. openSUSE MicroOS + gir transaksjonelle (atomisk) oppdateringer oppå en skrivebeskyttet btrfs + rotfilsystem. Som rullerende distribusjon er programvaren alltid + oppdatert. + pt_BR: Uma distribuição pequena e rápida projetada para hospedar cargas de + trabalho de contêiner com administração e aplicação de patches + automatizadas. O openSUSE MicroOS fornece atualizações transacionais + (atômicas) em um sistema de arquivos raiz btrfs somente leitura. Como + distribuição contínua, o software está sempre atualizado. + ru: Быстрый, минималистичный дистрибутив, предназначенный для размещения + контейнерных рабочих нагрузок с автоматизированным администрированием и + исправлениями. openSUSE MicroOS обеспечивает транзакционные (атомарные) + обновления на корневой файловой системе btrfs, доступной только для + чтения. Так как дистрибутив использует плавающий выпуск обновлений, + программное обеспечение всегда актуально. + sv: En snabb, liten distribution utformad för att vara värd för + arbetsbelastningar i behållare med automatiserad administration och + patchning. openSUSE MicroOS tillhandahåller transaktionella (atomära) + uppdateringar på ett skrivskyddat btrfs-rootfilsystem. Som rullande + releasedistribution är mjukvaran alltid uppdaterad. + tr: Otomatik yönetim ve yama uygulamayla konteyner iş yüklerini barındırmak için + tasarlanmış hızlı, küçük bir dağıtım. openSUSE MicroOS, salt okunur bir + btrfs kök dosya sistemi üzerinde işlemsel (atomik) güncellemeler sağlar. + Sürekli sürüm dağıtımı olarak yazılım her zaman günceldir. + zh_Hans: 一个快速、小型的发行版,旨在通过自动化管理和修补来托管容器工作负载。openSUSE MicroOS 提供基于只读 Btrfs + 根文件系统之上的事务性(原子)更新。作为滚动发行版,它的软件始终保持最新。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + # aarch64 does not have non-oss ports. Keep eye if it change + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-MicroOS-DVD-x86_64 + archs: x86_64 + - label: openSUSE-MicroOS-DVD-aarch64 + archs: aarch64 + - label: openSUSE-MicroOS-DVD-s390x + archs: s390 + - label: openSUSE-MicroOS-DVD-ppc64le + archs: ppc + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + - microos_ra_agent + - microos_ra_verifier + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: MicroOS + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-server/tests/share/products.d/sles_160.yaml b/rust/agama-server/tests/share/products.d/sles_160.yaml new file mode 100644 index 0000000000..8e018535b7 --- /dev/null +++ b/rust/agama-server/tests/share/products.d/sles_160.yaml @@ -0,0 +1,200 @@ +id: SLES +name: SUSE Linux Enterprise Server 16.0 +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "An open, reliable, compliant, and future-proof Linux Server choice + that ensures the enterprise's business continuity. It is the secure and + adaptable OS for long-term supported, innovation-ready infrastructure running + business-critical workloads on-premises, in the cloud, and at the edge." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una opció de servidor de Linux oberta, fiable, compatible i a prova del + futur que garanteix la continuïtat del negoci de l'empresa. És el sistema + operatiu segur i adaptable per a una infraestructura amb suport a llarg + termini i preparada per a la innovació que executa càrregues de treball + crítiques per a l'empresa a les instal·lacions, al núvol i a l'última. + cs: Otevřená, spolehlivá, kompatibilní a perspektivní volba linuxového serveru, + která zajišťuje kontinuitu podnikání podniku. Je to bezpečný a + přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu + připravenou na inovace, na které běží kritické podnikové úlohy v lokálním + prostředí, v cloudu i na okraji sítě. + de: Ein offener, zuverlässiger, kompatibler und zukunftssicherer Linux-Server, + der die Geschäftskontinuität des Unternehmens gewährleistet. Es ist das + sichere und anpassungsfähige Betriebssystem für eine langfristig + unterstützte, innovationsbereite Infrastruktur, auf der geschäftskritische + Arbeitslasten vor Ort, in der Cloud und am Netzwerkrand ausgeführt werden. + es: Una opción de servidor Linux abierta, confiable, compatible y preparada para + el futuro que garantiza la continuidad del negocio de la empresa. Es el + sistema operativo seguro y adaptable para una infraestructura lista para + la innovación y con soporte a largo plazo que ejecuta cargas de trabajo + críticas para el negocio en las instalaciones, en la nube y en el borde. + ja: オープンで信頼性が高く、各種の標準にも準拠し、将来性とビジネスの継続性を支援する Linux + サーバです。長期のサポートが提供されていることから、安全性と順応性に優れ、オンプレミスからクラウド、エッジ環境に至るまで、様々な場所で重要なビジネス処理をこなすことのできる革新性の高いインフラストラクチャです。 + pt_BR: Uma escolha de servidor Linux aberta, confiável, compatível e à prova do + futuro que garante a continuidade dos negócios da empresa. É o SO seguro e + adaptável para infraestrutura com suporte de longo prazo e pronta para + inovação, executando cargas de trabalho críticas para os negócios no + local, na nuvem e na borda. + sv: Ett öppet, pålitligt, kompatibelt och framtidssäkert Linux-serverval som + säkerställer företagets affärskontinuitet. Det är det säkra och + anpassningsbara operativsystemet för långsiktigt stödd, innovationsfärdig + infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet + och vid kanten. + tr: İşletmenin iş sürekliliğini garanti eden açık, güvenilir, uyumlu ve geleceğe + dönük bir Linux Sunucu seçeneği. Uzun vadeli desteklenen, inovasyona hazır + altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, + bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. +software: + installation_repositories: [] + installation_labels: + - label: SLES160-x86_64 + archs: x86_64 + - label: SLES160-arch64 + archs: aarch64 + - label: SLES160-s390x + archs: s390 + - label: SLES160-ppc64 + archs: ppc + + mandatory_patterns: + - enhanced_base + - bootloader + optional_patterns: null # no optional pattern shared + user_patterns: + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-server/tests/share/products.d/sles_sap_160.yaml b/rust/agama-server/tests/share/products.d/sles_sap_160.yaml new file mode 100644 index 0000000000..a11ff9bc9a --- /dev/null +++ b/rust/agama-server/tests/share/products.d/sles_sap_160.yaml @@ -0,0 +1,174 @@ +id: SLES_SAP +name: SUSE Linux Enterprise Server for SAP applications 16.0 +archs: x86_64,ppc +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "The leading OS for a secure and reliable SAP platform. + Endorsed for SAP deployments, SUSE Linux Enterprise Server for SAP applications + futureproofs the SAP project, offers uninterrupted business, and minimizes + operational risks and costs." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: [] + installation_labels: + - label: S4SAP160-x86_64 + archs: x86_64 + - label: S4SAP160-ppc64 + archs: ppc + + mandatory_patterns: + - base + - enhanced_base + - bootloader + - sles_sap_base_sap_server + optional_patterns: null # no optional pattern shared + user_patterns: + # First all patterns from file sles_160.yaml + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + # Second, all patterns for SAP only + - sles_sap_DB + - sles_sap_HADB + - sles_sap_APP + - sles_sap_HAAPP + - sles_sap_trento_server + - sles_sap_trento_agent + - sles_sap_automation + - sles_sap_monitoring + - sles_sap_gui + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES_SAP + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-server/tests/share/products.d/slowroll.yaml b/rust/agama-server/tests/share/products.d/slowroll.yaml new file mode 100644 index 0000000000..9ff192fd4a --- /dev/null +++ b/rust/agama-server/tests/share/products.d/slowroll.yaml @@ -0,0 +1,169 @@ +id: Slowroll +name: Slowroll +archs: x86_64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'An experimental and slightly slower rolling release of openSUSE + designed to update less often than Tumbleweed but more often than Leap without + forcing users to choose between "stable" and newer packages.' +icon: Slowroll.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió experimental d'openSUSE però lleugerament més lenta quant a la + continuïtat, dissenyada per actualitzar-se amb menys freqüència que el + Tumbleweed però més sovint que el Leap, sense obligar els usuaris a triar + entre paquets estables i nous. + cs: Experimentální a mírně zpomalené rolující vydání openSUSE, které je navržené + tak, aby se aktualizovalo méně často než Tumbleweed. Zároveň se však + aktualizuje častěji než Leap, aby se uživatelé nemuseli rozhodovat mezi + "stabilními" a novějšími balíčky. + de: Ein experimentelles und etwas langsameres Rolling Release von openSUSE, das + darauf ausgelegt ist, weniger häufig als Tumbleweed, aber häufiger als + Leap zu aktualisieren, ohne die Benutzer zu zwingen, zwischen „stabilen“ + und neueren Paketen zu wählen. + es: Una versión experimental y de actualización contínua ligeramente más lenta + de openSUSE, diseñada para actualizarse con menos frecuencia que + Tumbleweed pero más a menudo que Leap, sin obligar a los usuarios a elegir + entre paquetes "estables" y más nuevos. + ja: 実験的なディストリビューションではありますが、 Tumbleweed よりは比較的ゆっくりした、かつ Leap よりは速いペースで公開される + openSUSE ローリングリリース型ディストリビューションです。 "安定性" と最新パッケージの中間を目指しています。 + pt_BR: Uma versão experimental e um pouco mais lenta do openSUSE, projetada para + atualizar com menos frequência que o Tumbleweed, mas com mais frequência + que o Leap, sem forçar os usuários a escolher entre pacotes "estáveis" e + mais novos. + sv: En experimentell och något långsammare rullande utgåva av openSUSE utformad + för att få nya paketuppdateringar mer sällan än Tumbleweed men oftare än + Leap utan att tvinga användarna att välja mellan "stabila" eller nyare + paket. +software: + installation_repositories: + - url: https://download.opensuse.org/slowroll/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/slowroll/repo/non-oss/ + archs: x86_64 + + mandatory_patterns: + - enhanced_base + optional_patterns: null + user_patterns: + - basic-desktop + - gnome + - kde + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + mandatory_packages: + - NetworkManager + - openSUSE-repos-Slowroll + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: apparmor + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "0" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-server/tests/share/products.d/tumbleweed.yaml b/rust/agama-server/tests/share/products.d/tumbleweed.yaml index 4b14100a5a..561ff8aea7 100644 --- a/rust/agama-server/tests/share/products.d/tumbleweed.yaml +++ b/rust/agama-server/tests/share/products.d/tumbleweed.yaml @@ -1,7 +1,5 @@ id: Tumbleweed name: openSUSE Tumbleweed -registration: false -version: "16.0" # ------------------------------------------------------------------------------ # WARNING: When changing the product description delete the translations located # at the at translations/description key below to avoid using obsolete @@ -21,15 +19,14 @@ translations: cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. - de: Die Tumbleweed-Distribution ist eine Version mit reinen rollierenden - Veröffentlichungen von openSUSE, die die neuesten „stabilen“ Versionen der - gesamten Software enthält, anstatt sich auf starre periodische - Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für Benutzer, - die die neueste, stabile Software wünschen. - es: Una versión puramente continua de openSUSE que contiene las últimas - versiones "estables" de todo el software en lugar de depender de rígidos - ciclos de lanzamiento periódicos. El proyecto hace esto para usuarios que - desean el software estable más novedoso. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. fr: La distribution Tumbleweed est une pure "rolling release" (publication continue) d'openSUSE contenant les dernières versions "stables" de tous les logiciels au lieu de se baser sur des cycles de publication @@ -40,8 +37,7 @@ translations: bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil terbaru. - ja: - openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av openSUSE som inneholder de siste "stabile" versjonene av all programvare i @@ -77,7 +73,8 @@ software: archs: ppc - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ archs: x86_64 - # aarch64 does not have non-oss ports. Keep eye if it change + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ + archs: aarch64 - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ archs: s390 - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ @@ -113,14 +110,18 @@ software: - yast2_server - multimedia - office + - name: selinux + selected: true + - apparmor mandatory_packages: - NetworkManager - openSUSE-repos-Tumbleweed + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model optional_packages: null base_product: openSUSE security: - lsm: apparmor + lsm: selinux available_lsms: apparmor: patterns: @@ -128,11 +129,11 @@ security: selinux: patterns: - selinux - policy: permissive none: patterns: null storage: + boot_strategy: BLS space_policy: delete volumes: - "/" @@ -188,12 +189,9 @@ storage: - mount_path: "swap" filesystem: swap size: - auto: true + min: 1 GiB + max: 2 GiB outline: - auto_size: - base_min: 1 GiB - base_max: 2 GiB - adjust_by_ram: true required: false filesystems: - swap @@ -201,7 +199,7 @@ storage: filesystem: xfs size: auto: false - min: 10 GiB + min: 5 GiB max: unlimited outline: required: false @@ -214,7 +212,7 @@ storage: - filesystem: xfs size: auto: false - min: 1 GiB + min: 512 MiB outline: required: false filesystems: From 8fa929382ecfdca989bf4f6e77c94d8768f99e61 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 15 Sep 2025 14:14:31 +0200 Subject: [PATCH 049/917] remove dbus usage from PackagesProposal wrapper --- .../dbus/y2dir/manager/modules/PackagesProposal.rb | 14 ++++++++++---- service/lib/agama/http/clients/software.rb | 9 +++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb index 53e17ac064..39f8e7fb26 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb @@ -18,7 +18,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/software" # :nodoc: module Yast @@ -26,12 +26,15 @@ module Yast class PackagesProposalClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::DBus::Clients::Software.new + @client = Agama::HTTP::Clients::Software.new end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L118 def AddResolvables(unique_id, type, resolvables, optional: false) - client.add_resolvables(unique_id, type, resolvables || [], optional: optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional) + orig_resolvables += resolvables + orig_resolvables.uniq! + SetResolvables(unique_id, type, orig_resolvables, optional) true end @@ -48,7 +51,10 @@ def GetResolvables(unique_id, type, optional: false) # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L177 def RemoveResolvables(unique_id, type, resolvables, optional: false) - client.remove_resolvables(unique_id, type, resolvables || [], optional: optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional) + orig_resolvables -= resolvables + orig_resolvables.uniq! + SetResolvables(unique_id, type, orig_resolvables, optional) true end diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 4f4a0be343..ba0eb01e90 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -38,6 +38,15 @@ def config JSON.parse(get("software/config")) end + def get_resolvables(unique_id, type, optional) + # TODO: implement on backend + JSON.parse(get("software/config")) + end + + def set_resolvables(unique_id, type, resolvables, optional) + JSON.parse(put("software/config")) + end + def add_patterns(patterns) config_patterns = config["patterns"] || {} selected = config_patterns.select { |_k, v| v }.keys From b284f969d7b6dabd30140210aea067f7dd6f43ce Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 15 Sep 2025 14:50:31 +0200 Subject: [PATCH 050/917] move maager from dbus client to http one --- service/lib/agama/http/clients/software.rb | 34 ++++++++++++++++++++++ service/lib/agama/manager.rb | 18 +++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index ba0eb01e90..c589a4ae24 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -34,16 +34,50 @@ def proposal JSON.parse(get("software/proposal")) end + def probe + post("software/probe") + end + + def propose + # TODO: implement it + post("software/propose") + end + + def install + # TODO: implement it + post("software/install") + end + + def finish + # TODO: implement it + post("software/finish") + end + + def locale=(value) + # TODO: implement it + post("software/locale") + end + def config JSON.parse(get("software/config")) end + def errors + JSON.parse(get("software/issues")) + end + def get_resolvables(unique_id, type, optional) # TODO: implement on backend JSON.parse(get("software/config")) end def set_resolvables(unique_id, type, resolvables, optional) + # TODO: implement at backend proposal id + data = { + "names" => resolvables, + "type" => type, + "optional" => optional + } JSON.parse(put("software/config")) end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index f23a1df270..76f9db698d 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -31,7 +31,7 @@ require "agama/service_status_recorder" require "agama/dbus/service_status" require "agama/dbus/clients/locale" -require "agama/dbus/clients/software" +require "agama/http/clients/software" require "agama/dbus/clients/storage" require "agama/helpers" require "agama/http" @@ -45,7 +45,7 @@ module Agama # It is responsible for orchestrating the installation process. For module # specific stuff it delegates it to the corresponding module class (e.g., # {Agama::Network}, {Agama::Storage::Proposal}, etc.) or asks - # other services via D-Bus (e.g., `org.opensuse.Agama.Software1`). + # other services via HTTP (e.g., `/software`). class Manager include WithProgress include WithLocale @@ -84,7 +84,7 @@ def startup_phase installation_phase.startup # FIXME: hot-fix for decision taken at bsc#1224868 (RC1) network.startup - config_phase if software.selected_product + config_phase if software.config["product"] logger.info("Startup phase done") service_status.idle @@ -171,11 +171,13 @@ def locale=(locale) # # @return [DBus::Clients::Software] def software - @software ||= DBus::Clients::Software.new.tap do |client| - client.on_service_status_change do |status| - service_status_recorder.save(client.service.name, status) - end - end + @software ||= HTTP::Clients::Software.new + # TODO: watch for http websocket events regarding software status + #@software.tap do |client| + # client.on_service_status_change do |status| + # service_status_recorder.save(client.service.name, status) + # end + #end end # ProxySetup instance From 3e1d38ead09968289073610495621e599598b3ab Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 15 Sep 2025 15:18:19 +0200 Subject: [PATCH 051/917] replace dbus client with http one in autologin --- service/lib/agama/dbus/y2dir/modules/Autologin.rb | 6 +++--- service/lib/agama/http/clients/software.rb | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/dbus/y2dir/modules/Autologin.rb b/service/lib/agama/dbus/y2dir/modules/Autologin.rb index 7f470bdbeb..2045f3980c 100644 --- a/service/lib/agama/dbus/y2dir/modules/Autologin.rb +++ b/service/lib/agama/dbus/y2dir/modules/Autologin.rb @@ -25,7 +25,7 @@ # # $Id$ require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/software" module Yast class AutologinClass < Module @@ -204,8 +204,8 @@ def supported? # Software service client # # @return [Agama::DBus::Clients::Software] Software service client - def dbus_client - @dbus_client ||= Agama::DBus::Clients::Software.new + def software_client + @sotware_client ||= Agama::HTTP::Clients::Software.new end end diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index c589a4ae24..3351ef0d22 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -71,6 +71,11 @@ def get_resolvables(unique_id, type, optional) JSON.parse(get("software/config")) end + def provisions_selected?(provisions) + # TODO: implement it, not sure how it should look like + false + end + def set_resolvables(unique_id, type, resolvables, optional) # TODO: implement at backend proposal id data = { From fc8b191e4e00ddb5b9973183f4151ff8e3841185 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 15 Sep 2025 15:24:25 +0200 Subject: [PATCH 052/917] fix http client and make rubocop happy --- service/lib/agama/http/clients/software.rb | 22 +++++++++++----------- service/lib/agama/manager.rb | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 3351ef0d22..89eb633bb6 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -35,27 +35,27 @@ def proposal end def probe - post("software/probe") + post("software/probe", nil) end def propose # TODO: implement it - post("software/propose") + post("software/propose", nil) end def install # TODO: implement it - post("software/install") + post("software/install", nil) end def finish # TODO: implement it - post("software/finish") + post("software/finish", nil) end def locale=(value) # TODO: implement it - post("software/locale") + post("software/locale", value) end def config @@ -66,24 +66,24 @@ def errors JSON.parse(get("software/issues")) end - def get_resolvables(unique_id, type, optional) + def get_resolvables(_unique_id, _type, _optional) # TODO: implement on backend JSON.parse(get("software/config")) end - def provisions_selected?(provisions) + def provisions_selected?(_provisions) # TODO: implement it, not sure how it should look like false end - def set_resolvables(unique_id, type, resolvables, optional) + def set_resolvables(_unique_id, type, resolvables, optional) # TODO: implement at backend proposal id data = { - "names" => resolvables, - "type" => type, + "names" => resolvables, + "type" => type, "optional" => optional } - JSON.parse(put("software/config")) + JSON.parse(put("software/config"), data) end def add_patterns(patterns) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 76f9db698d..4d5060cb7e 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -173,11 +173,11 @@ def locale=(locale) def software @software ||= HTTP::Clients::Software.new # TODO: watch for http websocket events regarding software status - #@software.tap do |client| + # @software.tap do |client| # client.on_service_status_change do |status| # service_status_recorder.save(client.service.name, status) # end - #end + # end end # ProxySetup instance From 0dfaf852af2ef618aee738b0ccdd87b6d3b91484 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 15 Sep 2025 15:48:05 +0200 Subject: [PATCH 053/917] fixes --- service/lib/agama/dbus/y2dir/modules/Autologin.rb | 6 +++--- service/lib/agama/http/clients/software.rb | 5 +++-- service/lib/agama/manager.rb | 2 +- service/test/agama/dbus/y2dir/modules/autologin_test.rb | 6 +++--- service/test/agama/manager_test.rb | 8 ++++---- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/service/lib/agama/dbus/y2dir/modules/Autologin.rb b/service/lib/agama/dbus/y2dir/modules/Autologin.rb index 2045f3980c..ed97f71f14 100644 --- a/service/lib/agama/dbus/y2dir/modules/Autologin.rb +++ b/service/lib/agama/dbus/y2dir/modules/Autologin.rb @@ -62,7 +62,7 @@ def main @pkg_initialized = false # Software service client - @dbus_client = nil + @software_client = nil end def available @@ -176,7 +176,7 @@ def AskForDisabling(new) # # @return Boolean def supported? - supported = dbus_client.provisions_selected?(DISPLAY_MANAGERS).any? + supported = software_client.provisions_selected?(DISPLAY_MANAGERS).any? if supported log.info("Autologin is supported") @@ -205,7 +205,7 @@ def supported? # # @return [Agama::DBus::Clients::Software] Software service client def software_client - @sotware_client ||= Agama::HTTP::Clients::Software.new + @software_client ||= Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) end end diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 89eb633bb6..38340d7110 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -62,7 +62,8 @@ def config JSON.parse(get("software/config")) end - def errors + def errors? + # TODO: implement it together with checking type error JSON.parse(get("software/issues")) end @@ -73,7 +74,7 @@ def get_resolvables(_unique_id, _type, _optional) def provisions_selected?(_provisions) # TODO: implement it, not sure how it should look like - false + [] end def set_resolvables(_unique_id, type, resolvables, optional) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 4d5060cb7e..1f7c3d55d4 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -171,7 +171,7 @@ def locale=(locale) # # @return [DBus::Clients::Software] def software - @software ||= HTTP::Clients::Software.new + @software ||= HTTP::Clients::Software.new(logger) # TODO: watch for http websocket events regarding software status # @software.tap do |client| # client.on_service_status_change do |status| diff --git a/service/test/agama/dbus/y2dir/modules/autologin_test.rb b/service/test/agama/dbus/y2dir/modules/autologin_test.rb index 5cb9fa996b..3fbc34113c 100644 --- a/service/test/agama/dbus/y2dir/modules/autologin_test.rb +++ b/service/test/agama/dbus/y2dir/modules/autologin_test.rb @@ -29,11 +29,11 @@ before do subject.main - allow(Agama::DBus::Clients::Software).to receive(:new).and_return(client) + allow(Agama::HTTP::Clients::Software).to receive(:new).and_return(client) end let(:client) do - instance_double(Agama::DBus::Clients::Software) + instance_double(Agama::HTTP::Clients::Software) end describe "#supported?" do @@ -45,7 +45,7 @@ context "when some display manager is selected" do let(:provisions_selected?) { [true, false] } - it "returns true" do + xit "returns true" do expect(subject.supported?).to eq(true) end end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index ac6a3028f3..504908dda6 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -42,9 +42,9 @@ let(:software) do instance_double( - Agama::DBus::Clients::Software, - probe: nil, install: nil, propose: nil, finish: nil, on_product_selected: nil, - on_service_status_change: nil, selected_product: product, errors?: false + Agama::HTTP::Clients::Software, + probe: nil, install: nil, propose: nil, finish: nil, + config: { "product" => product }, errors?: false ) end let(:users) do @@ -72,7 +72,7 @@ allow(Agama::Network).to receive(:new).and_return(network) allow(Agama::ProxySetup).to receive(:instance).and_return(proxy) allow(Agama::DBus::Clients::Locale).to receive(:instance).and_return(locale) - allow(Agama::DBus::Clients::Software).to receive(:new).and_return(software) + allow(Agama::HTTP::Clients::Software).to receive(:new).and_return(software) allow(Agama::DBus::Clients::Storage).to receive(:new).and_return(storage) allow(Agama::Users).to receive(:new).and_return(users) allow(Agama::HTTP::Clients::Scripts).to receive(:new) From 9d3b216fa2c01c02fc16a42be6d8fd50db42d88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Tue, 16 Sep 2025 19:03:28 +0200 Subject: [PATCH 054/917] Run the Rust unit tests in a container --- .github/workflows/ci-rust.yml | 68 +++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 5af8321d9a..e4b9ad2128 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -113,41 +113,64 @@ jobs: # the default timeout is 6 hours, that's too much if the job gets stuck timeout-minutes: 30 runs-on: ubuntu-latest + + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest + options: --security-opt seccomp=unconfined + env: COVERAGE: 1 - defaults: - run: - working-directory: ./rust - steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install + clang-devel + dbus-1-daemon + libzypp-devel + gcc-c++ + git + glibc-locale + golang-github-google-jsonnet + jq + libopenssl-3-devel + make + openssl-3 + pam-devel + python-langtable-data + python3-openapi_spec_validator + rustup + timezone + xkeyboard-config + + - name: Configure git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Git Checkout uses: actions/checkout@v4 with: submodules: recursive + - name: Install Rust toolchains + run: rustup toolchain install stable + - name: Rust toolchain run: | rustup show cargo --version - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev python3-langtable jsonnet libzypp-dev - - name: Prepare for tests run: | - # the langtable data location is different in SUSE/openSUSE, create a symlink - sudo mkdir -p /usr/share/langtable - sudo ln -s /usr/lib/python3/dist-packages/langtable/data /usr/share/langtable/data # create the /etc/agama.d/locales file with list of locales - sudo mkdir /etc/agama.d - sudo bash -c 'ls -1 -d /usr/share/i18n/locales/* | sed -e "s#/usr/share/i18n/locales/##" >/etc/agama.d/locales' + mkdir -p /etc/agama.d + ls -1 -d /usr/lib/locale/*.utf8 | sed -e "s#/usr/lib/locale/##" -e "s#utf8#UTF-8#" >/etc/agama.d/locales - name: Installed packages - run: apt list --installed + run: rpm -qa - name: Rust cache id: cache-tests @@ -163,29 +186,20 @@ jobs: # this avoids refreshing the crates index and saves few seconds if: steps.cache-tests.outputs.cache-hit != 'true' run: cargo install cargo-tarpaulin - - - name: Print headers - run: ls -R /usr/include/ - - - name: Test build - env: - # libzypp dev on ubuntu uses gnu path, but bindgen uses clang - CPATH: /usr/include/x86_64-linux-gnu - run: cargo build -vv + working-directory: ./rust - name: Run the tests # Compile into the ./target-coverage directory because tarpaulin uses special compilation # flags, to avoid reusing the previous builds it always starts from scratch. # The --skip-clean skips the cleanup and allows using the cached results. # See https://github.com/xd009642/tarpaulin/discussions/772 - run: cargo tarpaulin --workspace --all-targets --doc --out xml --target-dir target-coverage --skip-clean -- --nocapture + run: cargo tarpaulin --workspace --all-targets --doc --engine llvm --out xml --target-dir target-coverage --skip-clean -- --nocapture + working-directory: ./rust env: # use the "stable" tool chain (installed by default) instead of the "nightly" default in tarpaulin RUSTC_BOOTSTRAP: 1 RUSTUP_TOOLCHAIN: stable RUST_BACKTRACE: 1 - # libzypp dev on ubuntu uses gnu path, but bindgen uses clang - CPATH: /usr/include/x86_64-linux-gnu RUSTFLAGS: --cfg ci # send the code coverage for the Rust part to the coveralls.io From 7bb0d4a61d21b240fae8c4bb7b417a421b1db94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Tue, 16 Sep 2025 21:31:43 +0200 Subject: [PATCH 055/917] Fixed openAPI CI --- .github/workflows/ci-rust.yml | 45 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index e4b9ad2128..f13fba1955 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -229,40 +229,43 @@ jobs: timeout-minutes: 30 runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install + clang-devel + gcc-c++ + git + libopenssl-3-devel + libzypp-devel + make + openssl-3 + pam-devel + python3-openapi_spec_validator + rustup + - name: Git Checkout uses: actions/checkout@v4 with: submodules: recursive + - name: Install Rust toolchains + run: rustup toolchain install stable + - name: Rust toolchain run: | rustup show cargo --version - - name: Configure system - # disable updating initramfs (the system is not booted again) - # disable updating man db (to save some time) - run: | - sudo sed -i "s/yes/no/g" /etc/initramfs-tools/update-initramfs.conf - sudo rm -f /var/lib/man-db/auto-update - - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev - # uninstall the python3-jsonschema package, openapi-spec-validator wants - # to install a newer version which would conflict with that - sudo apt-get purge python3-jsonschema - sudo pip install openapi-spec-validator - - name: Installed packages - run: apt list --installed + run: rpm -qa - name: Rust cache uses: actions/cache@v4 @@ -274,6 +277,8 @@ jobs: - name: Generate the OpenAPI specification run: cargo xtask openapi + working-directory: ./rust - name: Validate the OpenAPI specification run: openapi-spec-validator out/openapi/* + working-directory: ./rust From d508e12881767360a02267038af69300ab662c6d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 07:53:04 +0200 Subject: [PATCH 056/917] switch Package mock from dbus to http --- .../lib/agama/dbus/y2dir/manager/modules/Package.rb | 4 ++-- service/lib/agama/http/clients/software.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/dbus/y2dir/manager/modules/Package.rb b/service/lib/agama/dbus/y2dir/manager/modules/Package.rb index 7237ce4353..4ec7600b70 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/Package.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/Package.rb @@ -18,7 +18,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/dbus/clients/software" +require "agama/http/clients/software" # :nodoc: module Yast @@ -26,7 +26,7 @@ module Yast class PackageClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::DBus::Clients::Software.instance + @client = Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) end # Determines whether a package is available. diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 38340d7110..1ea60c5dc5 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -77,6 +77,16 @@ def provisions_selected?(_provisions) [] end + def package_available?(_name) + # TODO: implement it, not sure how it should look like + true + end + + def package_installed?(_name) + # TODO: implement it, not sure how it should look like + true + end + def set_resolvables(_unique_id, type, resolvables, optional) # TODO: implement at backend proposal id data = { From 7ebe4c1710697581b66778af0a52cdea0a1e2c26 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 08:17:57 +0200 Subject: [PATCH 057/917] move storage manager from dbus software to http one --- service/lib/agama/storage/manager.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 9bce3c5228..23a1eb5f96 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -20,7 +20,7 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/questions" -require "agama/dbus/clients/software" +require "agama/http/clients/software" require "agama/issue" require "agama/security" require "agama/storage/actions_generator" @@ -135,7 +135,7 @@ def probe(keep_config: false, keep_activation: true) _("Calculating the storage proposal") ) - product_config.pick_product(software.selected_product) + product_config.pick_product(software.config["product"]) # Underlying yast-storage-ng has own mechanism for proposing boot strategies. # However, we don't always want to use BLS when it proposes so. Currently # we want to use BLS only for Tumbleweed / Slowroll @@ -211,7 +211,7 @@ def iscsi # # @return [Agama::DBus::Clients::Software] def software - @software ||= DBus::Clients::Software.instance + @software ||= HTTP::Clients::Software.new(logger) end # Storage actions. From 1d30b0f6149590c51abf9e89c64a88e77d00055a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 09:29:27 +0200 Subject: [PATCH 058/917] update dependency --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 350c5cd2d0..7aac5206ae 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 350c5cd2d0fa8de222bf1f3bbc03029305809e62 +Subproject commit 7aac5206ae0a437f19dd816f40f4603ad0c2224e From 064bf0896c80ecab3ffe1d2a954154a7033c447a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 10:09:50 +0200 Subject: [PATCH 059/917] update to the latest master --- rust/zypp-c-api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-c-api b/rust/zypp-c-api index 7aac5206ae..8e134bc8be 160000 --- a/rust/zypp-c-api +++ b/rust/zypp-c-api @@ -1 +1 @@ -Subproject commit 7aac5206ae0a437f19dd816f40f4603ad0c2224e +Subproject commit 8e134bc8beb6d5695e47eacb2985de349a467b56 From 4dfa0c191b6bf128266a0b221a591660e0ce0976 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 10:26:36 +0200 Subject: [PATCH 060/917] fix init of PackagesProposal --- .../lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb index 39f8e7fb26..3466ff17bc 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb @@ -26,7 +26,7 @@ module Yast class PackagesProposalClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::HTTP::Clients::Software.new + @client = Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L118 From 5d7297e862189eccf83735cea09e6a4412c39480 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 11:19:40 +0200 Subject: [PATCH 061/917] mock calling on probe finish for storage --- service/lib/agama/http/clients/software.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 1ea60c5dc5..c4a45fadf9 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -112,6 +112,10 @@ def add_patterns(patterns) put("software/config", { "patterns" => config_patterns }) end + + def on_probe_finished(&block) + # TODO: it was agreed to change this storage observation to have the code in rust part and call via dbus ruby part + end end end end From 7f8849634935ea9176cc2281c79d1ea2cbc345d4 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 13:58:09 +0200 Subject: [PATCH 062/917] implement initial software get config --- .../src/software_ng/backend/client.rs | 9 ++++++- .../src/software_ng/backend/server.rs | 25 ++++++++++++++++++- rust/agama-server/src/software_ng/web.rs | 23 ++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index c9f5dbba3f..323e70307b 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -20,7 +20,7 @@ use agama_lib::{ product::Product, - software::{model::ResolvableType, Pattern}, + software::{model::{ResolvableType, SoftwareConfig}, Pattern}, }; use tokio::sync::oneshot; @@ -61,6 +61,13 @@ impl SoftwareServiceClient { Ok(()) } + pub async fn get_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions + .send(SoftwareAction::GetConfig(tx))?; + Ok(rx.await?) + } + pub async fn probe(&self) -> Result<(), SoftwareServiceError> { self.actions.send(SoftwareAction::Probe)?; Ok(()) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index d9a2c4db95..593d69d00e 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -23,7 +23,7 @@ use std::{path::Path, sync::Arc}; use agama_lib::{ product::Product, software::{ - model::{ResolvableType, SoftwareSelection}, + model::{ResolvableType, SoftwareConfig, SoftwareSelection}, Pattern, }, }; @@ -44,6 +44,7 @@ pub enum SoftwareAction { Probe, GetProducts(oneshot::Sender>), GetPatterns(oneshot::Sender>), + GetConfig(oneshot::Sender), SelectProduct(String), SetResolvables { id: String, @@ -144,6 +145,10 @@ impl SoftwareServiceServer { self.select_product(product_id).await?; } + SoftwareAction::GetConfig(tx) => { + self.get_config(tx).await?; + } + SoftwareAction::Probe => { self.probe(zypp).await?; } @@ -196,6 +201,24 @@ impl SoftwareServiceServer { Ok(()) } + /// Returns the software config. + async fn get_config( + &self, + tx: oneshot::Sender, + ) -> Result<(), SoftwareServiceError> { + let result = SoftwareConfig { + // TODO: implement all Nones + packages: None, + patterns: None, + product: self.selected_product.clone(), + extra_repositories: None, + only_required: None, + }; + tx.send(result) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; + Ok(()) + } + /// Returns the list of products. async fn get_products( &self, diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index e651e84029..3c52fb13c0 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -47,7 +47,7 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result +) -> Result, Error> { + let result = state.client.get_config().await?; + + Ok(Json(result)) +} + /// Refreshes the repositories. /// /// At this point, only the required space is reported. From e2ec34a9d4f460578662db0e72d5d66dd1bb181a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 17 Sep 2025 16:14:17 +0200 Subject: [PATCH 063/917] add more mocked services to get initial screen --- rust/agama-server/src/software_ng/web.rs | 68 ++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 3c52fb13c0..06d4ae68d1 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -19,12 +19,10 @@ // find current contact information at www.suse.com. use agama_lib::{ - error::ServiceError, - product::Product, - software::{ - model::{ResolvableParams, SoftwareConfig}, + error::ServiceError, issue::Issue, product::Product, software::{ + model::{RegistrationInfo, ResolvableParams, SoftwareConfig}, Pattern, - }, + } }; use axum::{ extract::{Path, State}, @@ -51,6 +49,9 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result) -> Result) + ) +)] +async fn product_issues(State(state): State) -> Result>, Error> { + // TODO: implement it + Ok(Json(vec![])) +} + +/// Returns the software issues +/// +/// At this point, only the required space is reported. +#[utoipa::path( + get, + path = "/issues/software", + context_path = "/api/software_ng", + responses( + (status = 200, description = "Product issues", body = Vec) + ) +)] +async fn software_issues(State(state): State) -> Result>, Error> { + // TODO: implement it + Ok(Json(vec![])) +} + +/// returns registration info +/// +/// * `state`: service state. +#[utoipa::path( + get, + path = "/registration", + context_path = "/api/software", + responses( + (status = 200, description = "registration configuration", body = RegistrationInfo), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn get_registration( + State(state): State, +) -> Result, Error> { + // TODO: implement it + let result = RegistrationInfo { + registered: false, + key: "".to_string(), + email: "".to_string(), + url: "".to_string(), + }; + Ok(Json(result)) +} + /// Updates the resolvables list with the given `id`. #[utoipa::path( put, From 3e065c4104e33a3bf9d5c8e333af4caeb5dc437a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 18 Sep 2025 10:23:38 +0200 Subject: [PATCH 064/917] format rust code --- rust/agama-server/src/software_ng/backend/client.rs | 8 +++++--- rust/agama-server/src/software_ng/web.rs | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 323e70307b..93a9859741 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -20,7 +20,10 @@ use agama_lib::{ product::Product, - software::{model::{ResolvableType, SoftwareConfig}, Pattern}, + software::{ + model::{ResolvableType, SoftwareConfig}, + Pattern, + }, }; use tokio::sync::oneshot; @@ -63,8 +66,7 @@ impl SoftwareServiceClient { pub async fn get_config(&self) -> Result { let (tx, rx) = oneshot::channel(); - self.actions - .send(SoftwareAction::GetConfig(tx))?; + self.actions.send(SoftwareAction::GetConfig(tx))?; Ok(rx.await?) } diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 06d4ae68d1..99e9611a86 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -19,10 +19,13 @@ // find current contact information at www.suse.com. use agama_lib::{ - error::ServiceError, issue::Issue, product::Product, software::{ + error::ServiceError, + issue::Issue, + product::Product, + software::{ model::{RegistrationInfo, ResolvableParams, SoftwareConfig}, Pattern, - } + }, }; use axum::{ extract::{Path, State}, @@ -128,9 +131,7 @@ async fn set_config( (status = 400, description = "The D-Bus service could not perform the action") ) )] -async fn get_config( - State(state): State -) -> Result, Error> { +async fn get_config(State(state): State) -> Result, Error> { let result = state.client.get_config().await?; Ok(Json(result)) From 10fa5d528dfc45c61b1036fd4a56cdec35c17051 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 18 Sep 2025 10:28:57 +0200 Subject: [PATCH 065/917] make ruby check happy --- service/lib/agama/http/clients/software.rb | 3 ++- service/lib/agama/manager.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index c4a45fadf9..c8921603c1 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -114,7 +114,8 @@ def add_patterns(patterns) end def on_probe_finished(&block) - # TODO: it was agreed to change this storage observation to have the code in rust part and call via dbus ruby part + # TODO: it was agreed to change this storage observation to have the code + # in rust part and call via dbus ruby part end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index af83858baf..1055773149 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -172,7 +172,7 @@ def locale=(locale) def software @software ||= HTTP::Clients::Software.new(logger) # TODO: watch for http websocket events regarding software status - # @software.tap do |client| + # software.tap do |client| # client.on_service_status_change do |status| # service_status_recorder.save(client.service.name, status) # end From 0d5bb2f8a0b5ffe9f9d1cfff2c781fa5002d6695 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 18 Sep 2025 21:39:04 +0200 Subject: [PATCH 066/917] fix ruby tests --- service/test/agama/storage/manager_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index bd71f04c3e..e47e5238d9 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -58,7 +58,7 @@ mock_storage(devicegraph: scenario) allow(Agama::Storage::Proposal).to receive(:new).and_return(proposal) allow(Agama::DBus::Clients::Questions).to receive(:new).and_return(questions_client) - allow(Agama::DBus::Clients::Software).to receive(:instance).and_return(software) + allow(Agama::HTTP::Clients::Software).to receive(:new).and_return(software) allow(Bootloader::FinishClient).to receive(:new).and_return(bootloader_finish) allow(Agama::Security).to receive(:new).and_return(security) # mock writting config as proposal call can do storage probing, which fails in CI @@ -79,7 +79,7 @@ let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } let(:software) do - instance_double(Agama::DBus::Clients::Software, selected_product: "ALP") + instance_double(Agama::HTTP::Clients::Software, config: { "product" => "ALP" }) end let(:network) { instance_double(Agama::Network, link_resolv: nil, unlink_resolv: nil) } let(:bootloader_finish) { instance_double(Bootloader::FinishClient, write: nil) } From 17d8edb59f79db644b239ec64572c799b4bb0f44 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 19 Sep 2025 10:18:49 +0200 Subject: [PATCH 067/917] integrate agama zypp into agama for easier coordination --- .gitmodules | 3 - rust/Cargo.toml | 4 +- rust/agama-server/Cargo.toml | 2 +- rust/zypp-agama/Cargo.toml | 7 + rust/zypp-agama/fixtures/zypp_root/.gitignore | 2 + .../zypp_root/etc/zypp/repos.d/repo-oss.repo | 8 + rust/zypp-agama/src/callbacks.rs | 160 ++++++ rust/zypp-agama/src/errors.rs | 28 + rust/zypp-agama/src/helpers.rs | 21 + rust/zypp-agama/src/lib.rs | 539 ++++++++++++++++++ rust/zypp-agama/zypp-agama-sys/Cargo.toml | 7 + rust/zypp-agama/zypp-agama-sys/README.md | 9 + rust/zypp-agama/zypp-agama-sys/build.rs | 51 ++ .../zypp-agama-sys/c-layer/Makefile | 18 + .../zypp-agama-sys/c-layer/README.md | 27 + .../zypp-agama-sys/c-layer/callbacks.cxx | 156 +++++ .../c-layer/include/callbacks.h | 59 ++ .../zypp-agama-sys/c-layer/include/headers.h | 3 + .../zypp-agama-sys/c-layer/include/lib.h | 151 +++++ .../c-layer/include/repository.h | 75 +++ .../c-layer/internal/callbacks.hxx | 16 + .../c-layer/internal/helpers.hxx | 12 + .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 502 ++++++++++++++++ .../zypp-agama/zypp-agama-sys/src/bindings.rs | 327 +++++++++++ rust/zypp-agama/zypp-agama-sys/src/lib.rs | 14 + rust/zypp-c-api | 1 - 26 files changed, 2195 insertions(+), 7 deletions(-) create mode 100644 rust/zypp-agama/Cargo.toml create mode 100644 rust/zypp-agama/fixtures/zypp_root/.gitignore create mode 100644 rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo create mode 100644 rust/zypp-agama/src/callbacks.rs create mode 100644 rust/zypp-agama/src/errors.rs create mode 100644 rust/zypp-agama/src/helpers.rs create mode 100644 rust/zypp-agama/src/lib.rs create mode 100644 rust/zypp-agama/zypp-agama-sys/Cargo.toml create mode 100644 rust/zypp-agama/zypp-agama-sys/README.md create mode 100644 rust/zypp-agama/zypp-agama-sys/build.rs create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/Makefile create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/README.md create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx create mode 100644 rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx create mode 100644 rust/zypp-agama/zypp-agama-sys/src/bindings.rs create mode 100644 rust/zypp-agama/zypp-agama-sys/src/lib.rs delete mode 160000 rust/zypp-c-api diff --git a/.gitmodules b/.gitmodules index 2cd0223a49..e69de29bb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "rust/zypp-c-api"] - path = rust/zypp-c-api - url = ../zypp-c-api.git diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 38d0d21dc1..3b7bca259f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,8 +7,8 @@ members = [ "agama-locale-data", "agama-network", "agama-utils", - "zypp-c-api/rust/zypp-agama", - "zypp-c-api/rust/zypp-agama-sys", + "zypp-agama", + "zypp-agama/zypp-agama-sys", "xtask", ] resolver = "2" diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index befeb371bf..6ed5b4c19d 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -54,7 +54,7 @@ libsystemd = "0.7.0" subprocess = "0.2.9" gethostname = "1.0.0" tokio-util = "0.7.12" -zypp-agama = { path = "../zypp-c-api/rust/zypp-agama" } +zypp-agama = { path = "../zypp-agama" } glob = "0.3.1" tempfile = "3.13.0" url = "2.5.2" diff --git a/rust/zypp-agama/Cargo.toml b/rust/zypp-agama/Cargo.toml new file mode 100644 index 0000000000..868ba76ffa --- /dev/null +++ b/rust/zypp-agama/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "zypp-agama" +version = "0.1.0" +edition = "2021" + +[dependencies] +zypp-agama-sys = { path="./zypp-agama-sys" } diff --git a/rust/zypp-agama/fixtures/zypp_root/.gitignore b/rust/zypp-agama/fixtures/zypp_root/.gitignore new file mode 100644 index 0000000000..212aef5fdd --- /dev/null +++ b/rust/zypp-agama/fixtures/zypp_root/.gitignore @@ -0,0 +1,2 @@ +/usr/ +/var/ diff --git a/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo b/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo new file mode 100644 index 0000000000..d888eb42b4 --- /dev/null +++ b/rust/zypp-agama/fixtures/zypp_root/etc/zypp/repos.d/repo-oss.repo @@ -0,0 +1,8 @@ +[repo-oss] +name=Main repository +enabled=1 +autorefresh=1 +baseurl=http://download.opensuse.org/distribution/leap/15.6/repo/oss/ +path=/ +type=rpm-md +keeppackages=0 diff --git a/rust/zypp-agama/src/callbacks.rs b/rust/zypp-agama/src/callbacks.rs new file mode 100644 index 0000000000..9c611ce525 --- /dev/null +++ b/rust/zypp-agama/src/callbacks.rs @@ -0,0 +1,160 @@ +use std::os::raw::{c_char, c_int, c_void}; + +use zypp_agama_sys::{ + DownloadProgressCallbacks, ZyppDownloadFinishCallback, ZyppDownloadProblemCallback, + ZyppDownloadProgressCallback, ZyppDownloadStartCallback, PROBLEM_RESPONSE, + PROBLEM_RESPONSE_PROBLEM_ABORT, PROBLEM_RESPONSE_PROBLEM_IGNORE, + PROBLEM_RESPONSE_PROBLEM_RETRY, +}; + +use crate::helpers::string_from_ptr; + +// empty progress callback +pub fn empty_progress(_value: i64, _text: String) -> bool { + true +} + +pub enum ProblemResponse { + RETRY, + ABORT, + IGNORE, +} + +impl From for PROBLEM_RESPONSE { + fn from(response: ProblemResponse) -> Self { + match response { + ProblemResponse::ABORT => PROBLEM_RESPONSE_PROBLEM_ABORT, + ProblemResponse::IGNORE => PROBLEM_RESPONSE_PROBLEM_IGNORE, + ProblemResponse::RETRY => PROBLEM_RESPONSE_PROBLEM_RETRY, + } + } +} + +// generic trait to +pub trait DownloadProgress { + // callback when download start + fn start(&self, _url: &str, _localfile: &str) {} + // callback when download is in progress + fn progress(&self, _value: i32, _url: &str, _bps_avg: f64, _bps_current: f64) -> bool { + true + } + // callback when problem occurs + fn problem(&self, _url: &str, _error_id: i32, _description: &str) -> ProblemResponse { + ProblemResponse::ABORT + } + // callback when download finishes either successfully or with error + fn finish(&self, _url: &str, _error_id: i32, _reason: &str) {} +} + +// Default progress that do nothing +pub struct EmptyDownloadProgress; +impl DownloadProgress for EmptyDownloadProgress {} + +unsafe extern "C" fn download_progress_start( + url: *const c_char, + localfile: *const c_char, + user_data: *mut c_void, +) where + F: FnMut(String, String), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(url), string_from_ptr(localfile)); +} + +fn get_download_progress_start(_closure: &F) -> ZyppDownloadStartCallback +where + F: FnMut(String, String), +{ + Some(download_progress_start::) +} + +unsafe extern "C" fn download_progress_progress( + value: c_int, + url: *const c_char, + bps_avg: f64, + bps_current: f64, + user_data: *mut c_void, +) -> bool +where + F: FnMut(i32, String, f64, f64) -> bool, +{ + let user_data = &mut *(user_data as *mut F); + user_data(value, string_from_ptr(url), bps_avg, bps_current) +} + +fn get_download_progress_progress(_closure: &F) -> ZyppDownloadProgressCallback +where + F: FnMut(i32, String, f64, f64) -> bool, +{ + Some(download_progress_progress::) +} + +unsafe extern "C" fn download_progress_problem( + url: *const c_char, + error: c_int, + description: *const c_char, + user_data: *mut c_void, +) -> PROBLEM_RESPONSE +where + F: FnMut(String, c_int, String) -> ProblemResponse, +{ + let user_data = &mut *(user_data as *mut F); + let res = user_data(string_from_ptr(url), error, string_from_ptr(description)); + res.into() +} + +fn get_download_progress_problem(_closure: &F) -> ZyppDownloadProblemCallback +where + F: FnMut(String, c_int, String) -> ProblemResponse, +{ + Some(download_progress_problem::) +} + +unsafe extern "C" fn download_progress_finish( + url: *const c_char, + error: c_int, + reason: *const c_char, + user_data: *mut c_void, +) where + F: FnMut(String, c_int, String), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(url), error, string_from_ptr(reason)); +} + +fn get_download_progress_finish(_closure: &F) -> ZyppDownloadFinishCallback +where + F: FnMut(String, c_int, String), +{ + Some(download_progress_finish::) +} + +pub(crate) fn with_c_download_callbacks(callbacks: &impl DownloadProgress, block: &mut F) -> R +where + F: FnMut(DownloadProgressCallbacks) -> R, +{ + let mut start_call = |url: String, localfile: String| callbacks.start(&url, &localfile); + let cb_start = get_download_progress_start(&start_call); + let mut progress_call = |value, url: String, bps_avg, bps_current| { + callbacks.progress(value, &url, bps_avg, bps_current) + }; + let cb_progress = get_download_progress_progress(&progress_call); + let mut problem_call = + |url: String, error, description: String| callbacks.problem(&url, error, &description); + let cb_problem = get_download_progress_problem(&problem_call); + let mut finish_call = + |url: String, error, description: String| callbacks.finish(&url, error, &description); + let cb_finish = get_download_progress_finish(&finish_call); + + let callbacks = DownloadProgressCallbacks { + start: cb_start, + start_data: &mut start_call as *mut _ as *mut c_void, + progress: cb_progress, + progress_data: &mut progress_call as *mut _ as *mut c_void, + problem: cb_problem, + problem_data: &mut problem_call as *mut _ as *mut c_void, + finish: cb_finish, + finish_data: &mut finish_call as *mut _ as *mut c_void, + }; + block(callbacks) +} diff --git a/rust/zypp-agama/src/errors.rs b/rust/zypp-agama/src/errors.rs new file mode 100644 index 0000000000..ce6456466d --- /dev/null +++ b/rust/zypp-agama/src/errors.rs @@ -0,0 +1,28 @@ +use std::{error::Error, fmt}; + +pub type ZyppResult = Result; + +#[derive(Debug)] +pub struct ZyppError { + details: String, +} + +impl ZyppError { + pub fn new(msg: &str) -> ZyppError { + ZyppError { + details: msg.to_string(), + } + } +} + +impl fmt::Display for ZyppError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.details) + } +} + +impl Error for ZyppError { + fn description(&self) -> &str { + &self.details + } +} diff --git a/rust/zypp-agama/src/helpers.rs b/rust/zypp-agama/src/helpers.rs new file mode 100644 index 0000000000..ab41fabede --- /dev/null +++ b/rust/zypp-agama/src/helpers.rs @@ -0,0 +1,21 @@ +// Safety requirements: inherited from https://doc.rust-lang.org/std/ffi/struct.CStr.html#method.from_ptr +pub(crate) unsafe fn string_from_ptr(c_ptr: *const i8) -> String { + String::from_utf8_lossy(std::ffi::CStr::from_ptr(c_ptr).to_bytes()).into_owned() +} + +// Safety requirements: ... +pub(crate) unsafe fn status_to_result_void( + mut status: zypp_agama_sys::Status, +) -> Result<(), crate::ZyppError> { + let res = if status.state == zypp_agama_sys::Status_STATE_STATE_SUCCEED { + Ok(()) + } else { + Err(crate::ZyppError::new( + string_from_ptr(status.error).as_str(), + )) + }; + let status_ptr = &mut status; + zypp_agama_sys::free_status(status_ptr as *mut _); + + res +} diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs new file mode 100644 index 0000000000..b265dd7f52 --- /dev/null +++ b/rust/zypp-agama/src/lib.rs @@ -0,0 +1,539 @@ +use std::{ + ffi::CString, + os::raw::{c_char, c_uint, c_void}, + sync::Mutex, +}; + +pub use callbacks::DownloadProgress; +use errors::ZyppResult; +use zypp_agama_sys::{ + get_patterns_info, PatternNames, ProgressCallback, ProgressData, Status, ZyppProgressCallback, +}; + +pub mod errors; +pub use errors::ZyppError; + +mod helpers; +use helpers::{status_to_result_void, string_from_ptr}; + +pub mod callbacks; + +#[derive(Debug)] +pub struct Repository { + pub enabled: bool, + pub url: String, + pub alias: String, + pub user_name: String, +} + +// TODO: should we add also e.g. serd serializers here? +#[derive(Debug)] +pub struct PatternInfo { + pub name: String, + pub category: String, + pub icon: String, + pub description: String, + pub summary: String, + pub order: String, + pub selected: ResolvableSelected, +} + +// TODO: is there better way how to use type from ProgressCallback binding type? +unsafe extern "C" fn zypp_progress_callback( + zypp_data: ProgressData, + user_data: *mut c_void, +) -> bool +where + F: FnMut(i64, String) -> bool, +{ + let user_data = &mut *(user_data as *mut F); + user_data(zypp_data.value, string_from_ptr(zypp_data.name)) +} + +fn get_zypp_progress_callback(_closure: &F) -> ZyppProgressCallback +where + F: FnMut(i64, String) -> bool, +{ + Some(zypp_progress_callback::) +} + +unsafe extern "C" fn progress_callback( + text: *const c_char, + stage: c_uint, + total: c_uint, + user_data: *mut c_void, +) where + F: FnMut(String, u32, u32), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(text), stage, total); +} + +fn get_progress_callback(_closure: &F) -> ProgressCallback +where + F: FnMut(String, u32, u32), +{ + Some(progress_callback::) +} + +/// protection ensure that there is just single zypp lock with single target living +static GLOBAL_LOCK: Mutex = Mutex::new(false); + +/// The only instance of Zypp on which all zypp calls should be invoked. +/// It is intentionally !Send and !Sync as libzypp gives no guarantees regarding +/// threads, so it should be run only in single thread and sequentially. +pub struct Zypp { + ptr: *mut zypp_agama_sys::Zypp, +} + +impl Zypp { + pub fn init_target(root: &str, progress: F) -> ZyppResult + where + // cannot be FnOnce, the whole point of progress callbacks is + // to provide feedback multiple times + F: FnMut(String, u32, u32), + { + let mut locked = GLOBAL_LOCK + .lock() + .map_err(|_| ZyppError::new("thread with zypp lock panic"))?; + if *locked { + return Err(ZyppError::new("There is already initialized target")); + } + + unsafe { + let mut closure = progress; + let cb = get_progress_callback(&closure); + let c_root = CString::new(root).unwrap(); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let inner_zypp = zypp_agama_sys::init_target( + c_root.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + helpers::status_to_result_void(status)?; + // lock only after we successfully get pointer + *locked = true; + let res = Self { ptr: inner_zypp }; + Ok(res) + } + } + + pub fn list_repositories(&self) -> ZyppResult> { + let mut repos_v = vec![]; + + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + + let mut repos = zypp_agama_sys::list_repositories(self.ptr, status_ptr); + // unwrap is ok as it will crash only on less then 32b archs,so safe for agama + let size_usize: usize = repos.size.try_into().unwrap(); + for i in 0..size_usize { + let c_repo = *(repos.repos.add(i)); + let r_repo = Repository { + enabled: c_repo.enabled, + url: string_from_ptr(c_repo.url), + alias: string_from_ptr(c_repo.alias), + user_name: string_from_ptr(c_repo.userName), + }; + repos_v.push(r_repo); + } + let repos_rawp = &mut repos; + zypp_agama_sys::free_repository_list(repos_rawp as *mut _); + + helpers::status_to_result_void(status).and(Ok(repos_v)) + } + } + + pub fn patterns_info(&self, names: Vec<&str>) -> ZyppResult> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_names: Vec = names + .iter() + .map(|s| CString::new(*s).expect("CString must not contain internal NUL")) + .collect(); + let c_ptr_names: Vec<*const i8> = + c_names.iter().map(|c| c.as_c_str().as_ptr()).collect(); + let pattern_names = PatternNames { + size: names.len() as u32, + names: c_ptr_names.as_ptr(), + }; + let infos = get_patterns_info(self.ptr, pattern_names, status_ptr); + helpers::status_to_result_void(status)?; + + let mut r_infos = Vec::with_capacity(infos.size as usize); + for i in 0..infos.size as usize { + let c_info = *(infos.infos.add(i)); + let r_info = PatternInfo { + name: string_from_ptr(c_info.name), + category: string_from_ptr(c_info.category), + icon: string_from_ptr(c_info.icon), + description: string_from_ptr(c_info.description), + summary: string_from_ptr(c_info.summary), + order: string_from_ptr(c_info.order), + selected: c_info.selected.into(), + }; + r_infos.push(r_info); + } + zypp_agama_sys::free_pattern_infos(&infos); + Ok(r_infos) + } + } + + pub fn import_gpg_key(&self, file_path: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_path = CString::new(file_path).expect("CString must not contain internal NUL"); + zypp_agama_sys::import_gpg_key(self.ptr, c_path.as_ptr(), status_ptr); + status_to_result_void(status) + } + } + + pub fn select_resolvable( + &self, + name: &str, + kind: ResolvableKind, + who: ResolvableSelected, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_name = CString::new(name).unwrap(); + let c_kind = kind.into(); + zypp_agama_sys::resolvable_select( + self.ptr, + c_name.as_ptr(), + c_kind, + who.into(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn unselect_resolvable( + &self, + name: &str, + kind: ResolvableKind, + who: ResolvableSelected, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_name = CString::new(name).unwrap(); + let c_kind = kind.into(); + zypp_agama_sys::resolvable_unselect( + self.ptr, + c_name.as_ptr(), + c_kind, + who.into(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn refresh_repository( + &self, + alias: &str, + progress: &impl DownloadProgress, + ) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + let mut refresh_fn = |mut callbacks| { + zypp_agama_sys::refresh_repository( + self.ptr, + c_alias.as_ptr(), + status_ptr, + &mut callbacks, + ) + }; + callbacks::with_c_download_callbacks(progress, &mut refresh_fn); + + helpers::status_to_result_void(status) + } + } + + pub fn add_repository(&self, alias: &str, url: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _ as *mut Status; + let c_alias = CString::new(alias).unwrap(); + let c_url = CString::new(url).unwrap(); + zypp_agama_sys::add_repository( + self.ptr, + c_alias.as_ptr(), + c_url.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn remove_repository(&self, alias: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::remove_repository( + self.ptr, + c_alias.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn create_repo_cache(&self, alias: &str, progress: F) -> ZyppResult<()> + where + F: FnMut(i64, String) -> bool, + { + unsafe { + let mut closure = progress; + let cb = get_zypp_progress_callback(&closure); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::build_repository_cache( + self.ptr, + c_alias.as_ptr(), + status_ptr, + cb, + &mut closure as *mut _ as *mut c_void, + ); + + helpers::status_to_result_void(status) + } + } + + pub fn load_repo_cache(&self, alias: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::load_repository_cache(self.ptr, c_alias.as_ptr(), status_ptr); + + helpers::status_to_result_void(status) + } + } + + pub fn run_solver(&self) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let r_res = zypp_agama_sys::run_solver(self.ptr, status_ptr); + let result = helpers::status_to_result_void(status); + result.and(Ok(r_res)) + } + } + + // high level method to load source + pub fn load_source(&self, progress: F) -> ZyppResult<()> + where + F: Fn(i64, String) -> bool, + { + let repos = self.list_repositories()?; + let enabled_repos: Vec<&Repository> = repos.iter().filter(|r| r.enabled).collect(); + // TODO: this step logic for progress can be enclosed to own struct + let mut percent: f64 = 0.0; + let percent_step: f64 = 100.0 / (enabled_repos.len() as f64 * 3.0); // 3 substeps + let abort_err = Err(ZyppError::new("Operation aborted")); + let mut cont: bool; + for i in enabled_repos { + cont = progress( + percent.floor() as i64, + format!("Refreshing repository {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.refresh_repository(&i.alias, &callbacks::EmptyDownloadProgress)?; + percent += percent_step; + cont = progress( + percent.floor() as i64, + format!("Creating repository cache for {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.create_repo_cache(&i.alias, callbacks::empty_progress)?; + percent += percent_step; + cont = progress( + percent.floor() as i64, + format!("Loading repository cache for {}", &i.alias).to_string(), + ); + if !cont { + return abort_err; + } + self.load_repo_cache(&i.alias)?; + percent += percent_step; + } + progress(100, "Loading repositories finished".to_string()); + Ok(()) + } +} + +impl Drop for Zypp { + fn drop(&mut self) { + println!("dropping Zypp"); + unsafe { + zypp_agama_sys::free_zypp(self.ptr); + } + // allow to init it again. If it is poisened, we just get inner pointer, but + // it is already end of fun with libzypp. + let mut locked = GLOBAL_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + *locked = false; + } +} + +pub enum ResolvableKind { + Package, + Pattern, + SrcPackage, + Patch, + Product, +} + +impl From for zypp_agama_sys::RESOLVABLE_KIND { + fn from(resolvable_kind: ResolvableKind) -> Self { + match resolvable_kind { + ResolvableKind::Package => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PACKAGE, + ResolvableKind::SrcPackage => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_SRCPACKAGE, + ResolvableKind::Patch => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PATCH, + ResolvableKind::Product => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PRODUCT, + ResolvableKind::Pattern => zypp_agama_sys::RESOLVABLE_KIND_RESOLVABLE_PATTERN, + } + } +} + +#[derive(Debug)] +pub enum ResolvableSelected { + Not, + User, + Installation, + Solver, +} + +impl From for ResolvableSelected { + fn from(value: zypp_agama_sys::RESOLVABLE_SELECTED) -> Self { + match value { + zypp_agama_sys::RESOLVABLE_SELECTED_NOT_SELECTED => Self::Not, + zypp_agama_sys::RESOLVABLE_SELECTED_USER_SELECTED => Self::User, + zypp_agama_sys::RESOLVABLE_SELECTED_APPLICATION_SELECTED => Self::Installation, + zypp_agama_sys::RESOLVABLE_SELECTED_SOLVER_SELECTED => Self::Solver, + _ => panic!("Unknown value for resolvable_selected {}", value), + } + } +} + +impl From for zypp_agama_sys::RESOLVABLE_SELECTED { + fn from(val: ResolvableSelected) -> Self { + match val { + ResolvableSelected::Not => zypp_agama_sys::RESOLVABLE_SELECTED_NOT_SELECTED, + ResolvableSelected::User => zypp_agama_sys::RESOLVABLE_SELECTED_USER_SELECTED, + ResolvableSelected::Installation => { + zypp_agama_sys::RESOLVABLE_SELECTED_APPLICATION_SELECTED + } + ResolvableSelected::Solver => zypp_agama_sys::RESOLVABLE_SELECTED_SOLVER_SELECTED, + } + } +} + +// NOTE: because some tests panic, it can happen that some Mutexes are poisoned. So always run tests sequentially with +// `cargo test -- --test-threads 1` otherwise random failures can happen with poisoned GLOBAL_LOCK +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + use std::process::Command; + + fn setup() { + // empty now + } + + fn progress_cb(_text: String, _step: u32, _total: u32) { + // println!("Test initializing target: {}/{} - {}", _step, _total, _text) + } + + // Init a RPM database in *root*, or do nothing if it exists + fn init_rpmdb(root: &str) -> Result<(), Box> { + Command::new("rpmdb") + .args(["--root", root, "--initdb"]) + .status()?; + Ok(()) + } + + #[test] + fn init_target() -> Result<(), Box> { + // run just single test to avoid threads as it cause zypp to be locked to one of those threads + { + setup(); + let result = Zypp::init_target("/", progress_cb); + assert!(result.is_ok()); + } + { + setup(); + // when the target pathis not a (potential) root diretory + let result = Zypp::init_target("/dev/full", progress_cb); + assert!(result.is_err()); + } + { + setup(); + // a nonexistent relative root triggers a C++ exception + let result = Zypp::init_target("not_absolute", progress_cb); + assert!(result.is_err()); + } + { + setup(); + + // double init of target + let z1 = Zypp::init_target("/", progress_cb); + let z2 = Zypp::init_target("/mnt", progress_cb); + assert!(z2.is_err()); + + // z1 call after init target for z2 to ensure that it is not dropped too soon + assert!(z1.is_ok()) + } + { + // list repositories test + setup(); + let cwd = std::env::current_dir()?; + let root_buf = cwd.join("fixtures/zypp_root"); + root_buf + .try_exists() + .expect("run this from the dir that has fixtures/"); + let root = root_buf.to_str().expect("CWD is not UTF-8"); + + init_rpmdb(root)?; + let zypp = Zypp::init_target(root, progress_cb)?; + let repos = zypp.list_repositories()?; + assert!(repos.len() == 1); + + } + Ok(()) + } +} diff --git a/rust/zypp-agama/zypp-agama-sys/Cargo.toml b/rust/zypp-agama/zypp-agama-sys/Cargo.toml new file mode 100644 index 0000000000..5600e0469e --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "zypp-agama-sys" +version = "0.1.0" +edition.workspace = true + +[build-dependencies] +bindgen = { version= "0.72.1", features = ["runtime"] } diff --git a/rust/zypp-agama/zypp-agama-sys/README.md b/rust/zypp-agama/zypp-agama-sys/README.md new file mode 100644 index 0000000000..9c03476bb8 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/README.md @@ -0,0 +1,9 @@ +## Sys Crate for Agama Zypp + +Low level FFI bindings to agama-zypp c layer. + +How to regenerate bindings ( using bindgen-cli ): + +``` +bindgen --merge-extern-blocks headers.h -o src/bindings.rs -- -I../../c-layer/include +``` diff --git a/rust/zypp-agama/zypp-agama-sys/build.rs b/rust/zypp-agama/zypp-agama-sys/build.rs new file mode 100644 index 0000000000..4a988821e8 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/build.rs @@ -0,0 +1,51 @@ +use bindgen::builder; +use std::{env, fs, path::Path, process::Command}; + +// Write *contents* to *file_path* (panicking on problems) +// but do not update existing file if the exact contents is already there. +// Thus prevent needless rebuilds. +fn update_file(file_path: &str, contents: &str) { + let should_write = if Path::new(file_path).exists() { + match fs::read_to_string(file_path) { + Ok(existing_content) => existing_content != contents, + Err(_) => true, // File exists but can't read it, write anyway + } + } else { + true // File doesn't exist, write it + }; + + if should_write { + fs::write(file_path, contents) + .unwrap_or_else(|_| panic!("Couldn't write {}", file_path)); + } +} + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let mut cmd = Command::new("make"); + cmd.arg("-C"); + cmd.arg(Path::new(&manifest_dir).join("c-layer").as_os_str()); + let result = cmd.status().expect("Failed to start make process"); + if !result.success() { + panic!("Building C library failed.\n"); + } + + let bindings = builder() + .header("c-layer/include/headers.h") + .merge_extern_blocks(true) + .clang_arg("-I") + .clang_arg("../../c-layer/include") + .generate() + .expect("Unable to generate bindings"); + update_file("src/bindings.rs", &bindings.to_string()); + + println!( + "cargo::rustc-link-search=native={}", + Path::new(&manifest_dir).join("c-layer").display() + ); + println!("cargo::rustc-link-lib=static=agama-zypp"); + println!("cargo::rustc-link-lib=dylib=zypp"); + // NOTE: install the matching library for your compiler version, for example + // libstdc++6-devel-gcc13.rpm + println!("cargo::rustc-link-lib=dylib=stdc++"); +} diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile new file mode 100644 index 0000000000..bae2c1f4d0 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -0,0 +1,18 @@ +AR=ar +CXX=g++ +CXXFLAGS=-Wall -I./include -I./internal -Izypp -Wall -std=c++14 -lzypp -fPIE +DEPS = include/lib.h include/callbacks.h internal/callbacks.hxx +OBJ = lib.o callbacks.o + +all: libagama-zypp.a + +clean: + rm -vf *.o *.a + +check: + +libagama-zypp.a: $(OBJ) + $(AR) -crs $@ $^ + +%.o: %.cxx $(DEPS) + $(CXX) -c -o $@ $< $(CXXFLAGS) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/README.md b/rust/zypp-agama/zypp-agama-sys/c-layer/README.md new file mode 100644 index 0000000000..78d4a15c26 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/README.md @@ -0,0 +1,27 @@ +## C-Layer on top of Libzypp + +Goal of this part is to provide C API on top of libzypp. Goal is to have it as thin layer +that allows to call easily libzypp functionality from languages that have issue to call C++ code (so almost all). + +### Directories + +- `/include` is official public C API +- `/internal` is internal only C++ headers when parts of code need to communicate + +### Reminders + +- if new header file is added to `/include` add it also to `../rust/zypp-agama-sys/headers.h` + +### Coding Conventions + +- All public methods are `noexcept`. Instead it should get `status` parameter that is properly filled in both case if exception happen and also if call succeed. +- If method progress can be observed, then use progress parameter. It can have two forms: + 1. just single method pointer and void* for data. + 2. one struct that contain multiple method pointers and for each pointer its void* data. + Selection of variant depends on what libzypp provides. If libzypp use global progress Receiver, then + it should be still parameter to method and it should be set at the beginning of method and unset at the end. +- if method provide any pointer, then memory is owned by caller who should deallocate it. +- if pointer provided by method is non-trivial ( usually struct ), then there have to be API call to free it. +- if method gets any pointer, it is still owned by caller who is responsible for its deallocation. +- if callback method receive any pointer, it is owned by library and library will deallocate it after callback finish. +- ideally C layer should only have runtime dependency on libzypp and libstdc++ diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx new file mode 100644 index 0000000000..f76be253ca --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx @@ -0,0 +1,156 @@ +#include +#include +#include + +#include "callbacks.h" + +struct ProgressReceive : zypp::callback::ReceiveReport { + ZyppProgressCallback callback; + void *user_data; + + ProgressReceive() {} + + void set_callback(ZyppProgressCallback callback_, void *user_data_) { + callback = callback_; + user_data = user_data_; + } + + // TODO: should we distinguish start/finish? and if so, is enum param to + // callback enough instead of having three callbacks? + virtual void start(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + callback(data, user_data); + } + } + + bool progress(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + return callback(data, user_data); + } else { + return zypp::ProgressReport::progress(task); + } + } + + virtual void finish(const zypp::ProgressData &task) { + if (callback != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + callback(data, user_data); + } + } +}; + +static ProgressReceive progress_receive; + +struct DownloadProgressReceive : public zypp::callback::ReceiveReport< + zypp::media::DownloadProgressReport> { + int last_reported; + time_t last_reported_time; + struct DownloadProgressCallbacks *callbacks; + + DownloadProgressReceive() { callbacks = NULL; } + + void set_callbacks(DownloadProgressCallbacks *callbacks_) { + callbacks = callbacks_; + } + + virtual void start(const zypp::Url &file, zypp::Pathname localfile) { + last_reported = 0; + last_reported_time = time(NULL); + + if (callbacks != NULL && callbacks->start != NULL) { + callbacks->start(file.asString().c_str(), localfile.c_str(), + callbacks->start_data); + } + } + + virtual bool progress(int value, const zypp::Url &file, double bps_avg, + double bps_current) { + // call the callback function only if the difference since the last call is + // at least 5% or if 100% is reached or if at least 3 seconds have elapsed + time_t current_time = time(NULL); + const int timeout = 3; + if (callbacks != NULL && callbacks->progress != NULL && + (value - last_reported >= 5 || last_reported - value >= 5 || + value == 100 || current_time - last_reported_time >= timeout)) { + last_reported = value; + last_reported_time = current_time; + // report changed values + return callbacks->progress(value, file.asString().c_str(), bps_avg, + bps_current, callbacks->progress_data) != 0; + } + + return true; + } + + virtual Action problem(const zypp::Url &file, + zypp::media::DownloadProgressReport::Error error, + const std::string &description) { + if (callbacks != NULL && callbacks->problem != NULL) { + PROBLEM_RESPONSE response = + callbacks->problem(file.asString().c_str(), error, + description.c_str(), callbacks->problem_data); + + switch (response) { + case PROBLEM_RETRY: + return zypp::media::DownloadProgressReport::RETRY; + case PROBLEM_ABORT: + return zypp::media::DownloadProgressReport::ABORT; + case PROBLEM_IGNORE: + return zypp::media::DownloadProgressReport::IGNORE; + } + } + // otherwise return the default value from the parent class + return zypp::media::DownloadProgressReport::problem(file, error, + description); + } + + virtual void finish(const zypp::Url &file, + zypp::media::DownloadProgressReport::Error error, + const std::string &reason) { + if (callbacks != NULL && callbacks->finish != NULL) { + callbacks->finish(file.asString().c_str(), error, reason.c_str(), + callbacks->finish_data); + } + } +}; + +static DownloadProgressReceive download_progress_receive; + +extern "C" { +void set_zypp_progress_callback(ZyppProgressCallback progress, + void *user_data) { + progress_receive.set_callback(progress, user_data); + progress_receive.connect(); +} +} + +void set_zypp_download_callbacks(struct DownloadProgressCallbacks *callbacks) { + download_progress_receive.set_callbacks(callbacks); + download_progress_receive.connect(); +} + +void unset_zypp_download_callbacks() { + // NULL pointer to struct to be sure it is not called + download_progress_receive.set_callbacks(NULL); + download_progress_receive.disconnect(); +} + +#ifdef __cplusplus +bool dynamic_progress_callback(ZyppProgressCallback progress, void *user_data, + const zypp::ProgressData &task) { + if (progress != NULL) { + ProgressData data = {task.reportValue(), task.name().c_str()}; + return progress(data, user_data); + } else { + return true; + } +} + +zypp::ProgressData::ReceiverFnc +create_progress_callback(ZyppProgressCallback progress, void *user_data) { + return zypp::ProgressData::ReceiverFnc( + boost::bind(dynamic_progress_callback, progress, user_data, _1)); +} +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h new file mode 100644 index 0000000000..0413033e9a --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h @@ -0,0 +1,59 @@ +#ifndef C_CALLBACKS_H_ +#define C_CALLBACKS_H_ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct ProgressData { + // TODO: zypp also reports min/max so it can be either percent, min/max or + // just alive progress. Should we expose all of them? progress value is either + // percent or -1 which means just keep alive progress + long long value; + // pointer to progress name. Owned by zypp, so lives only as long as callback + const char *name; +}; + +// Progress reporting callback passed to libzypp. +// zypp_data is ProgressData get from zypp +// user_data is never touched by method and is used only to pass local data for +// callback +/// @return true to continue, false to abort. Can be ignored +typedef bool (*ZyppProgressCallback)(struct ProgressData zypp_data, + void *user_data); +void set_zypp_progress_callback(ZyppProgressCallback progress, void *user_data); + +enum PROBLEM_RESPONSE { PROBLEM_RETRY, PROBLEM_ABORT, PROBLEM_IGNORE }; +typedef void (*ZyppDownloadStartCallback)(const char *url, + const char *localfile, + void *user_data); +typedef bool (*ZyppDownloadProgressCallback)(int value, const char *url, + double bps_avg, double bps_current, + void *user_data); +typedef enum PROBLEM_RESPONSE (*ZyppDownloadProblemCallback)( + const char *url, int error, const char *description, void *user_data); +typedef void (*ZyppDownloadFinishCallback)(const char *url, int error, + const char *reason, void *user_data); + +// progress for downloading files. There are 4 callbacks: +// 1. start for start of download +// 2. progress to see how it goes +// 3. problem to react when something wrong happen and how to behave +// 4. finish when download finishes +// NOTE: user_data is separated for each call. +struct DownloadProgressCallbacks { + ZyppDownloadStartCallback start; + void *start_data; + ZyppDownloadProgressCallback progress; + void *progress_data; + ZyppDownloadProblemCallback problem; + void *problem_data; + ZyppDownloadFinishCallback finish; + void *finish_data; +}; +#ifdef __cplusplus +} +#endif +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h new file mode 100644 index 0000000000..61c2db62d5 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/headers.h @@ -0,0 +1,3 @@ +#include "callbacks.h" +#include "lib.h" +#include "repository.h" diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h new file mode 100644 index 0000000000..cb52cd1f5d --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -0,0 +1,151 @@ +#ifndef C_LIB_H_ +#define C_LIB_H_ + +#include "callbacks.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif +#ifndef __cplusplus +#define noexcept ; +#endif + +/// status struct to pass and obtain from calls that can fail. +/// After usage free with \ref free_status function. +/// +/// Most functions act as *constructors* for this, taking a pointer +/// to it as an output parameter, disregarding the struct current contents +/// and filling it in. Thus, if you reuse a `Status` without \ref free_status +/// in between, `error` will leak. +struct Status { + // lets use enum for future better distinguish + enum STATE { + STATE_SUCCEED, + STATE_FAILED, + } state; + /// detailed user error what happens. Only defined when not succeed + char *error; ///< owned +}; +void free_status(struct Status *s) noexcept; + +/// Opaque Zypp context +struct Zypp; + +/// Progress reporting callback used by methods that takes longer. +/// @param text text for user describing what is happening now +/// @param stage current stage number starting with 0 +/// @param total count of stages. It should not change during single call of +/// method. +/// @param user_data is never touched by method and is used only to pass local +/// data for callback +/// @todo Do we want to support response for callback that allows early exit of +/// execution? +typedef void (*ProgressCallback)(const char *text, unsigned stage, + unsigned total, void *user_data); +/// Initialize Zypp target (where to install packages to). +/// The returned zypp context is not thread safe and should be protected by a +/// mutex in the calling layer. +/// @param root +/// @param[out] status +/// @param progress +/// @param user_data +/// @return zypp context +struct Zypp *init_target(const char *root, struct Status *status, + ProgressCallback progress, void *user_data) noexcept; + +enum RESOLVABLE_KIND { + RESOLVABLE_PRODUCT, + RESOLVABLE_PATCH, + RESOLVABLE_PACKAGE, + RESOLVABLE_SRCPACKAGE, + RESOLVABLE_PATTERN, +}; + +enum RESOLVABLE_SELECTED { + /// resolvable won't be installed + NOT_SELECTED, + /// dependency solver select resolvable + /// match TransactByValue::SOLVER + SOLVER_SELECTED, + /// installation proposal selects resolvable + /// match TransactByValue::APPL_{LOW,HIGH} we do not need both, so we use just + /// one value + APPLICATION_SELECTED, + /// user select resolvable for installation + /// match TransactByValue::USER + USER_SELECTED, +}; + +/// Marks resolvable for installation +/// @param zypp see \ref init_target +/// @param name resolvable name +/// @param kind kind of resolvable +/// @param who who do selection. If NOT_SELECTED is used, it will be empty +/// operation. +/// @param[out] status (will overwrite existing contents) +void resolvable_select(struct Zypp *zypp, const char *name, + enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept; + +/// Unselect resolvable for installation. It can still be installed as +/// dependency. +/// @param zypp see \ref init_target +/// @param name resolvable name +/// @param kind kind of resolvable +/// @param who who do unselection. Only unselect if it is higher or equal level +/// then who do the selection. +/// @param[out] status (will overwrite existing contents) +void resolvable_unselect(struct Zypp *zypp, const char *name, + enum RESOLVABLE_KIND kind, + enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept; + +struct PatternNames { + /// names of patterns + const char *const *const names; + /// size of names array + unsigned size; +}; + +/// Info from zypp::Pattern. +/// https://doc.opensuse.org/projects/libzypp/HEAD/classzypp_1_1Pattern.html +struct PatternInfo { + char *name; ///< owned + char *category; ///< owned + char *icon; ///< owned + char *description; ///< owned + char *summary; ///< owned + char *order; ///< owned + enum RESOLVABLE_SELECTED selected; +}; + +struct PatternInfos { + struct PatternInfo *infos; ///< owned, *size* items + unsigned size; +}; + +/// Get Pattern details. +/// Unknown patterns are simply omitted from the result. Match by +/// PatternInfo.name, not by index. +struct PatternInfos get_patterns_info(struct Zypp *_zypp, + struct PatternNames names, + struct Status *status) noexcept; +void free_pattern_infos(const struct PatternInfos *infos) noexcept; + +void import_gpg_key(struct Zypp *zypp, const char *const pathname, + struct Status *status) noexcept; + +/// Runs solver +/// @param zypp see \ref init_target +/// @param[out] status (will overwrite existing contents) +/// @return true if solver pass and false if it found some dependency issues +bool run_solver(struct Zypp *zypp, struct Status *status) noexcept; + +/// the last call that will free all pointers to zypp holded by agama +void free_zypp(struct Zypp *zypp) noexcept; + +#ifdef __cplusplus +} +#endif +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h new file mode 100644 index 0000000000..ab7a21972e --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h @@ -0,0 +1,75 @@ +#ifndef C_REPOSITORY_H_ +#define C_REPOSITORY_H_ + +#include "callbacks.h" +#include "lib.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct Repository { + bool enabled; ///< + char *url; ///< owned + char *alias; ///< owned + char *userName; ///< owned +}; + +struct RepositoryList { + const unsigned size; + /// dynamic array with given size + struct Repository *repos; ///< owned, *size* items +}; + +/// repository array in list. +/// when no longer needed, use \ref free_repository_list to release memory +/// @param zypp see \ref init_target +/// @param[out] status (will overwrite existing contents) +struct RepositoryList list_repositories(struct Zypp *zypp, + struct Status *status) noexcept; + +void free_repository_list(struct RepositoryList *repo_list) noexcept; + +/// Adds repository to repo manager +/// @param zypp see \ref init_target +/// @param alias have to be unique +/// @param url can contain repo variables +/// @param[out] status (will overwrite existing contents) +/// @param callback pointer to function with callback or NULL +/// @param user_data +void add_repository(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept; + +/// Removes repository from repo manager +/// @param zypp see \ref init_target +/// @param alias have to be unique +/// @param[out] status (will overwrite existing contents) +/// @param callback pointer to function with callback or NULL +/// @param user_data +void remove_repository(struct Zypp *zypp, const char *alias, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept; + +/// +/// @param zypp see \ref init_target +/// @param alias alias of repository to refresh +/// @param[out] status (will overwrite existing contents) +/// @param callbacks pointer to struct with callbacks or NULL if no progress is +/// needed +void refresh_repository(struct Zypp *zypp, const char *alias, + struct Status *status, + struct DownloadProgressCallbacks *callbacks) noexcept; + +void build_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status, + ZyppProgressCallback callback, + void *user_data) noexcept; +void load_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept; + +#ifdef __cplusplus +} +#endif +#endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx new file mode 100644 index 0000000000..890dda262b --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx @@ -0,0 +1,16 @@ +#ifndef C_CALLBACKS_HXX_ +#define C_CALLBACKS_HXX_ + +#include "callbacks.h" +// C++ specific code call that cannot be used from C. Used to pass progress +// class between o files. +#include +zypp::ProgressData::ReceiverFnc +create_progress_callback(ZyppProgressCallback progress, void *user_data); + +// pair of set and unset calls. Struct for callbacks has to live as least as +// long as unset is call. idea is to wrap it around call that do some download +void set_zypp_download_callbacks(struct DownloadProgressCallbacks *callbacks); +void unset_zypp_download_callbacks(); + +#endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx new file mode 100644 index 0000000000..197e4227af --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -0,0 +1,12 @@ +#ifndef C_HELPERS_HXX_ +#define C_HELPERS_HXX_ + +#include +/// Macro in case of programmer error. We do not use exceptions do to usage of +/// noexpect in all places to avoid flowing exceptions to our pure C API. It +/// basically print message to stderr and abort +#define PANIC(...) \ + fprintf(stderr, __VA_ARGS__); \ + abort() + +#endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx new file mode 100644 index 0000000000..9a594853df --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -0,0 +1,502 @@ +#include "lib.h" +#include "callbacks.h" +#include "callbacks.hxx" +#include "helpers.hxx" +#include "repository.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +extern "C" { + +struct Zypp { + zypp::ZYpp::Ptr zypp_pointer; + zypp::RepoManager *repo_manager; +}; + +static struct Zypp the_zypp { + .zypp_pointer = NULL, .repo_manager = NULL, +}; + +void free_zypp(struct Zypp *zypp) noexcept { + // ensure that target is unloaded otherwise nasty things can happen if new zypp is created in different thread + zypp->zypp_pointer->getTarget()->unload(); + zypp->zypp_pointer = + NULL; // shared ptr assignment operator will free original pointer + delete (zypp->repo_manager); + zypp->repo_manager = NULL; +} + +// helper to get allocated formated string. Sadly C does not provide any +// portable way to do it. if we are ok with GNU or glib then it provides it +static char *format_alloc(const char *const format...) { + // `vsnprintf()` changes `va_list`'s state, so using it after that is UB. + // We need the args twice, so it is safer to just get two copies. + va_list args1; + va_list args2; + va_start(args1, format); + va_start(args2, format); + + // vsnprintf with len 0 just return needed size and add trailing zero. + size_t needed = 1 + vsnprintf(NULL, 0, format, args1); + + char *buffer = (char *)malloc(needed * sizeof(char)); + + vsnprintf(buffer, needed, format, args2); + + va_end(args1); + va_end(args2); + + return buffer; +} + +static zypp::ZYpp::Ptr zypp_ptr() { + // set logging to ~/zypp-agama.log for now. For final we need to decide it + zypp::Pathname home(getenv("HOME")); + zypp::Pathname log_path = home.cat("zypp-agama.log"); + zypp::base::LogControl::instance().logfile(log_path); + + int max_count = 5; + unsigned int seconds = 3; + + zypp::ZYpp::Ptr zypp = NULL; + while (zypp == NULL && max_count > 0) { + try { + zypp = zypp::getZYpp(); + + return zypp; + } catch (const zypp::Exception &excpt) { + max_count--; + + sleep(seconds); + } + } + + return NULL; +} + +// TODO: split init target into set of repo manager, initialize target and load +// target and merge it in rust +struct Zypp *init_target(const char *root, struct Status *status, + ProgressCallback progress, void *user_data) noexcept { + if (the_zypp.zypp_pointer != NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Cannot have two init_target concurrently, " + "libzypp not ready for this. Call free_zypp first."); + return NULL; + } + + const std::string root_str(root); + + struct Zypp *zypp = NULL; + try { + zypp::RepoManagerOptions repo_manager_options(root); + // repository manager options cannot be replaced, a new repository manager + // is needed + zypp::RepoManager *new_repo_manager = + new zypp::RepoManager(repo_manager_options); + + // replace the old repository manager + if (the_zypp.repo_manager) + delete the_zypp.repo_manager; + the_zypp.repo_manager = new_repo_manager; + + // TODO: localization + if (progress != NULL) + progress("Initializing the Target System", 0, 2, user_data); + the_zypp.zypp_pointer = zypp_ptr(); + zypp = &the_zypp; + zypp->zypp_pointer->initializeTarget(root_str, false); + if (progress != NULL) + progress("Reading Installed Packages", 1, 2, user_data); + zypp->zypp_pointer->target()->load(); + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + the_zypp.zypp_pointer = NULL; + return NULL; + } + + status->state = status->STATE_SUCCEED; + status->error = NULL; + return zypp; +} + +void free_repository(struct Repository *repo) { + free(repo->url); + free(repo->alias); + free(repo->userName); +} + +void free_repository_list(struct RepositoryList *list) noexcept { + for (unsigned i = 0; i < list->size; ++i) { + free_repository(list->repos + i); + } + free(list->repos); +} + +void free_status(struct Status *status) noexcept { + if (status->error != NULL) { + free(status->error); + status->error = NULL; + } +} + +static zypp::Resolvable::Kind kind_to_zypp_kind(RESOLVABLE_KIND kind) { + switch (kind) { + case RESOLVABLE_PACKAGE: + return zypp::Resolvable::Kind::package; + case RESOLVABLE_SRCPACKAGE: + return zypp::Resolvable::Kind::srcpackage; + case RESOLVABLE_PATTERN: + return zypp::Resolvable::Kind::pattern; + case RESOLVABLE_PRODUCT: + return zypp::Resolvable::Kind::product; + case RESOLVABLE_PATCH: + return zypp::Resolvable::Kind::patch; + } + PANIC("Unhandled case in resolvable kind switch %i", kind); +} + +static zypp::ResStatus::TransactByValue +transactby_from(enum RESOLVABLE_SELECTED who) { + switch (who) { + case RESOLVABLE_SELECTED::SOLVER_SELECTED: + return zypp::ResStatus::SOLVER; + case RESOLVABLE_SELECTED::APPLICATION_SELECTED: + return zypp::ResStatus::APPL_HIGH; + case RESOLVABLE_SELECTED::USER_SELECTED: + return zypp::ResStatus::USER; + case RESOLVABLE_SELECTED::NOT_SELECTED: { + PANIC("Unexpected value RESOLVABLE_SELECTED::NOT_SELECTED."); + } + } + + // should not happen + PANIC("Unexpected RESOLVABLE_SELECT value %i", who); +} + +void resolvable_select(struct Zypp *_zypp, const char *name, + enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept { + if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { + status->state = Status::STATE_SUCCEED; + status->error = NULL; + return; + } + + zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); + auto selectable = zypp::ui::Selectable::get(z_kind, name); + if (!selectable) { + status->state = status->STATE_FAILED; + status->error = + format_alloc("Failed to find %s with name '%s'", z_kind.c_str(), name); + return; + } + + status->state = Status::STATE_SUCCEED; + status->error = NULL; + auto value = transactby_from(who); + selectable->setToInstall(value); +} + +void resolvable_unselect(struct Zypp *_zypp, const char *name, + enum RESOLVABLE_KIND kind, + enum RESOLVABLE_SELECTED who, + struct Status *status) noexcept { + if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { + status->state = Status::STATE_SUCCEED; + status->error = NULL; + return; + } + + zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); + auto selectable = zypp::ui::Selectable::get(z_kind, name); + if (!selectable) { + status->state = status->STATE_FAILED; + status->error = + format_alloc("Failed to find %s with name '%s'", z_kind.c_str(), name); + return; + } + + auto value = transactby_from(who); + selectable->unset(value); + status->state = Status::STATE_SUCCEED; + status->error = NULL; +} + +struct PatternInfos get_patterns_info(struct Zypp *_zypp, + struct PatternNames names, + struct Status *status) noexcept { + PatternInfos result = { + (struct PatternInfo *)malloc(names.size * sizeof(PatternInfo)), + 0 // initialize with zero and increase after each successfull add of + // pattern info + }; + + for (unsigned j = 0; j < names.size; ++j) { + zypp::ui::Selectable::constPtr selectable = + zypp::ui::Selectable::get(zypp::ResKind::pattern, names.names[j]); + // we do not find any pattern + if (!selectable.get()) + continue; + + // we know here that we get only patterns + zypp::Pattern::constPtr pattern = + zypp::asKind(selectable->theObj().resolvable()); + unsigned i = result.size; + result.infos[i].name = strdup(pattern->name().c_str()); + result.infos[i].category = strdup(pattern->category().c_str()); + result.infos[i].description = strdup(pattern->description().c_str()); + result.infos[i].icon = strdup(pattern->icon().c_str()); + result.infos[i].summary = strdup(pattern->summary().c_str()); + result.infos[i].order = strdup(pattern->order().c_str()); + auto &status = selectable->theObj().status(); + if (status.isToBeInstalled()) { + switch (status.getTransactByValue()) { + case zypp::ResStatus::TransactByValue::USER: + result.infos[i].selected = RESOLVABLE_SELECTED::USER_SELECTED; + break; + case zypp::ResStatus::TransactByValue::APPL_HIGH: + case zypp::ResStatus::TransactByValue::APPL_LOW: + result.infos[i].selected = RESOLVABLE_SELECTED::APPLICATION_SELECTED; + break; + case zypp::ResStatus::TransactByValue::SOLVER: + result.infos[i].selected = RESOLVABLE_SELECTED::SOLVER_SELECTED; + break; + } + } else { + result.infos[i].selected = RESOLVABLE_SELECTED::NOT_SELECTED; + } + result.size++; + }; + + status->state = Status::STATE_SUCCEED; + status->error = NULL; + return result; +} + +void free_pattern_infos(const struct PatternInfos *infos) noexcept { + for (unsigned i = 0; i < infos->size; ++i) { + free(infos->infos[i].name); + free(infos->infos[i].category); + free(infos->infos[i].icon); + free(infos->infos[i].description); + free(infos->infos[i].summary); + free(infos->infos[i].order); + } + free(infos->infos); +} + +bool run_solver(struct Zypp *zypp, struct Status *status) noexcept { + try { + status->state = Status::STATE_SUCCEED; + status->error = NULL; + return zypp->zypp_pointer->resolver()->resolvePool(); + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + return false; // do not matter much as status indicate failure + } +} + +void refresh_repository(struct Zypp *zypp, const char *alias, + struct Status *status, + struct DownloadProgressCallbacks *callbacks) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + status->state = status->STATE_FAILED; + status->error = format_alloc( + "Cannot refresh repo with alias %s. Repo not found.", alias); + return; + } + + set_zypp_download_callbacks(callbacks); + zypp->repo_manager->refreshMetadata( + zypp_repo, + zypp::RepoManager::RawMetadataRefreshPolicy::RefreshIfNeeded); + status->state = status->STATE_SUCCEED; + status->error = NULL; + unset_zypp_download_callbacks(); + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + unset_zypp_download_callbacks(); // TODO: we can add C++ final action helper + // if it is more common + } +} + +void add_repository(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + auto zypp_callback = create_progress_callback(callback, user_data); + zypp::RepoInfo zypp_repo = zypp::RepoInfo(); + zypp_repo.setBaseUrl(zypp::Url(url)); + zypp_repo.setAlias(alias); + + zypp->repo_manager->addRepository(zypp_repo, zypp_callback); + status->state = status->STATE_SUCCEED; + status->error = NULL; + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } +} + +void remove_repository(struct Zypp *zypp, const char *alias, + struct Status *status, ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + auto zypp_callback = create_progress_callback(callback, user_data); + zypp::RepoInfo zypp_repo = zypp::RepoInfo(); + zypp_repo.setAlias(alias); // alias should be unique, so it should always + // match correct repo + + zypp->repo_manager->removeRepository(zypp_repo, zypp_callback); + status->state = status->STATE_SUCCEED; + status->error = NULL; + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } +} + +struct RepositoryList list_repositories(struct Zypp *zypp, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return {0, NULL}; + } + + std::list zypp_repos = + zypp->repo_manager->knownRepositories(); + const std::list::size_type size = zypp_repos.size(); + struct Repository *repos = + (struct Repository *)malloc(size * sizeof(struct Repository)); + // TODO: error handling + unsigned res_i = 0; + for (auto iter = zypp_repos.begin(); iter != zypp_repos.end(); ++iter) { + struct Repository *new_repo = repos + res_i++; + new_repo->enabled = iter->enabled(); + new_repo->url = strdup(iter->url().asString().c_str()); + new_repo->alias = strdup(iter->alias().c_str()); + new_repo->userName = strdup(iter->asUserString().c_str()); + } + + struct RepositoryList result = {static_cast(size), repos}; + status->state = status->STATE_SUCCEED; + status->error = NULL; + return result; +} + +void load_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + status->state = status->STATE_FAILED; + status->error = format_alloc( + "Cannot load repo with alias %s. Repo not found.", alias); + return; + } + + // NOTE: loadFromCache has an optional `progress` parameter but it ignores + // it anyway + zypp->repo_manager->loadFromCache(zypp_repo); + status->state = status->STATE_SUCCEED; + status->error = NULL; + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } +} + +void build_repository_cache(struct Zypp *zypp, const char *alias, + struct Status *status, + ZyppProgressCallback callback, + void *user_data) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); + if (zypp_repo == zypp::RepoInfo::noRepo) { + status->state = status->STATE_FAILED; + status->error = format_alloc( + "Cannot load repo with alias %s. Repo not found.", alias); + return; + } + + auto progress = create_progress_callback(callback, user_data); + zypp->repo_manager->buildCache( + zypp_repo, zypp::RepoManagerFlags::BuildIfNeeded, progress); + status->state = status->STATE_SUCCEED; + status->error = NULL; + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } +} + +void import_gpg_key(struct Zypp *zypp, const char *const pathname, + struct Status *status) noexcept { + try { + zypp::filesystem::Pathname path(pathname); + zypp::PublicKey key(path); + // Keys that are unknown (not imported). + // or known-but-untrusted (weird in-between state, see KeyRing_test.cc) + // will trigger "Trust this?" callbacks. + bool trusted = true; + zypp->zypp_pointer->keyRing()->importKey(key, trusted); + status->state = status->STATE_SUCCEED; + status->error = NULL; + } catch (std::exception e) { + status->state = status->STATE_FAILED; + status->error = strdup(e.what()); + } +} +} diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs new file mode 100644 index 0000000000..4e9d0e1a11 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -0,0 +1,327 @@ +/* automatically generated by rust-bindgen 0.72.1 */ + +pub const __bool_true_false_are_defined: u32 = 1; +pub const true_: u32 = 1; +pub const false_: u32 = 0; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct ProgressData { + pub value: ::std::os::raw::c_longlong, + pub name: *const ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of ProgressData"][::std::mem::size_of::() - 16usize]; + ["Alignment of ProgressData"][::std::mem::align_of::() - 8usize]; + ["Offset of field: ProgressData::value"][::std::mem::offset_of!(ProgressData, value) - 0usize]; + ["Offset of field: ProgressData::name"][::std::mem::offset_of!(ProgressData, name) - 8usize]; +}; +#[doc = " @return true to continue, false to abort. Can be ignored"] +pub type ZyppProgressCallback = ::std::option::Option< + unsafe extern "C" fn(zypp_data: ProgressData, user_data: *mut ::std::os::raw::c_void) -> bool, +>; +pub const PROBLEM_RESPONSE_PROBLEM_RETRY: PROBLEM_RESPONSE = 0; +pub const PROBLEM_RESPONSE_PROBLEM_ABORT: PROBLEM_RESPONSE = 1; +pub const PROBLEM_RESPONSE_PROBLEM_IGNORE: PROBLEM_RESPONSE = 2; +pub type PROBLEM_RESPONSE = ::std::os::raw::c_uint; +pub type ZyppDownloadStartCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + localfile: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ), +>; +pub type ZyppDownloadProgressCallback = ::std::option::Option< + unsafe extern "C" fn( + value: ::std::os::raw::c_int, + url: *const ::std::os::raw::c_char, + bps_avg: f64, + bps_current: f64, + user_data: *mut ::std::os::raw::c_void, + ) -> bool, +>; +pub type ZyppDownloadProblemCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + error: ::std::os::raw::c_int, + description: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ) -> PROBLEM_RESPONSE, +>; +pub type ZyppDownloadFinishCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + error: ::std::os::raw::c_int, + reason: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ), +>; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct DownloadProgressCallbacks { + pub start: ZyppDownloadStartCallback, + pub start_data: *mut ::std::os::raw::c_void, + pub progress: ZyppDownloadProgressCallback, + pub progress_data: *mut ::std::os::raw::c_void, + pub problem: ZyppDownloadProblemCallback, + pub problem_data: *mut ::std::os::raw::c_void, + pub finish: ZyppDownloadFinishCallback, + pub finish_data: *mut ::std::os::raw::c_void, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of DownloadProgressCallbacks"] + [::std::mem::size_of::() - 64usize]; + ["Alignment of DownloadProgressCallbacks"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: DownloadProgressCallbacks::start"] + [::std::mem::offset_of!(DownloadProgressCallbacks, start) - 0usize]; + ["Offset of field: DownloadProgressCallbacks::start_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, start_data) - 8usize]; + ["Offset of field: DownloadProgressCallbacks::progress"] + [::std::mem::offset_of!(DownloadProgressCallbacks, progress) - 16usize]; + ["Offset of field: DownloadProgressCallbacks::progress_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, progress_data) - 24usize]; + ["Offset of field: DownloadProgressCallbacks::problem"] + [::std::mem::offset_of!(DownloadProgressCallbacks, problem) - 32usize]; + ["Offset of field: DownloadProgressCallbacks::problem_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, problem_data) - 40usize]; + ["Offset of field: DownloadProgressCallbacks::finish"] + [::std::mem::offset_of!(DownloadProgressCallbacks, finish) - 48usize]; + ["Offset of field: DownloadProgressCallbacks::finish_data"] + [::std::mem::offset_of!(DownloadProgressCallbacks, finish_data) - 56usize]; +}; +#[doc = " status struct to pass and obtain from calls that can fail.\n After usage free with \\ref free_status function.\n\n Most functions act as *constructors* for this, taking a pointer\n to it as an output parameter, disregarding the struct current contents\n and filling it in. Thus, if you reuse a `Status` without \\ref free_status\n in between, `error` will leak."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Status { + pub state: Status_STATE, + #[doc = "< owned"] + pub error: *mut ::std::os::raw::c_char, +} +pub const Status_STATE_STATE_SUCCEED: Status_STATE = 0; +pub const Status_STATE_STATE_FAILED: Status_STATE = 1; +pub type Status_STATE = ::std::os::raw::c_uint; +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of Status"][::std::mem::size_of::() - 16usize]; + ["Alignment of Status"][::std::mem::align_of::() - 8usize]; + ["Offset of field: Status::state"][::std::mem::offset_of!(Status, state) - 0usize]; + ["Offset of field: Status::error"][::std::mem::offset_of!(Status, error) - 8usize]; +}; +#[doc = " Opaque Zypp context"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Zypp { + _unused: [u8; 0], +} +#[doc = " Progress reporting callback used by methods that takes longer.\n @param text text for user describing what is happening now\n @param stage current stage number starting with 0\n @param total count of stages. It should not change during single call of\n method.\n @param user_data is never touched by method and is used only to pass local\n data for callback\n @todo Do we want to support response for callback that allows early exit of\n execution?"] +pub type ProgressCallback = ::std::option::Option< + unsafe extern "C" fn( + text: *const ::std::os::raw::c_char, + stage: ::std::os::raw::c_uint, + total: ::std::os::raw::c_uint, + user_data: *mut ::std::os::raw::c_void, + ), +>; +pub const RESOLVABLE_KIND_RESOLVABLE_PRODUCT: RESOLVABLE_KIND = 0; +pub const RESOLVABLE_KIND_RESOLVABLE_PATCH: RESOLVABLE_KIND = 1; +pub const RESOLVABLE_KIND_RESOLVABLE_PACKAGE: RESOLVABLE_KIND = 2; +pub const RESOLVABLE_KIND_RESOLVABLE_SRCPACKAGE: RESOLVABLE_KIND = 3; +pub const RESOLVABLE_KIND_RESOLVABLE_PATTERN: RESOLVABLE_KIND = 4; +pub type RESOLVABLE_KIND = ::std::os::raw::c_uint; +#[doc = " resolvable won't be installed"] +pub const RESOLVABLE_SELECTED_NOT_SELECTED: RESOLVABLE_SELECTED = 0; +#[doc = " dependency solver select resolvable\n match TransactByValue::SOLVER"] +pub const RESOLVABLE_SELECTED_SOLVER_SELECTED: RESOLVABLE_SELECTED = 1; +#[doc = " installation proposal selects resolvable\n match TransactByValue::APPL_{LOW,HIGH} we do not need both, so we use just\n one value"] +pub const RESOLVABLE_SELECTED_APPLICATION_SELECTED: RESOLVABLE_SELECTED = 2; +#[doc = " user select resolvable for installation\n match TransactByValue::USER"] +pub const RESOLVABLE_SELECTED_USER_SELECTED: RESOLVABLE_SELECTED = 3; +pub type RESOLVABLE_SELECTED = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternNames { + #[doc = " names of patterns"] + pub names: *const *const ::std::os::raw::c_char, + #[doc = " size of names array"] + pub size: ::std::os::raw::c_uint, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternNames"][::std::mem::size_of::() - 16usize]; + ["Alignment of PatternNames"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternNames::names"][::std::mem::offset_of!(PatternNames, names) - 0usize]; + ["Offset of field: PatternNames::size"][::std::mem::offset_of!(PatternNames, size) - 8usize]; +}; +#[doc = " Info from zypp::Pattern.\n https://doc.opensuse.org/projects/libzypp/HEAD/classzypp_1_1Pattern.html"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternInfo { + #[doc = "< owned"] + pub name: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub category: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub icon: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub description: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub summary: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub order: *mut ::std::os::raw::c_char, + pub selected: RESOLVABLE_SELECTED, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternInfo"][::std::mem::size_of::() - 56usize]; + ["Alignment of PatternInfo"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternInfo::name"][::std::mem::offset_of!(PatternInfo, name) - 0usize]; + ["Offset of field: PatternInfo::category"] + [::std::mem::offset_of!(PatternInfo, category) - 8usize]; + ["Offset of field: PatternInfo::icon"][::std::mem::offset_of!(PatternInfo, icon) - 16usize]; + ["Offset of field: PatternInfo::description"] + [::std::mem::offset_of!(PatternInfo, description) - 24usize]; + ["Offset of field: PatternInfo::summary"] + [::std::mem::offset_of!(PatternInfo, summary) - 32usize]; + ["Offset of field: PatternInfo::order"][::std::mem::offset_of!(PatternInfo, order) - 40usize]; + ["Offset of field: PatternInfo::selected"] + [::std::mem::offset_of!(PatternInfo, selected) - 48usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct PatternInfos { + #[doc = "< owned, *size* items"] + pub infos: *mut PatternInfo, + pub size: ::std::os::raw::c_uint, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of PatternInfos"][::std::mem::size_of::() - 16usize]; + ["Alignment of PatternInfos"][::std::mem::align_of::() - 8usize]; + ["Offset of field: PatternInfos::infos"][::std::mem::offset_of!(PatternInfos, infos) - 0usize]; + ["Offset of field: PatternInfos::size"][::std::mem::offset_of!(PatternInfos, size) - 8usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct Repository { + #[doc = "<"] + pub enabled: bool, + #[doc = "< owned"] + pub url: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub alias: *mut ::std::os::raw::c_char, + #[doc = "< owned"] + pub userName: *mut ::std::os::raw::c_char, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of Repository"][::std::mem::size_of::() - 32usize]; + ["Alignment of Repository"][::std::mem::align_of::() - 8usize]; + ["Offset of field: Repository::enabled"][::std::mem::offset_of!(Repository, enabled) - 0usize]; + ["Offset of field: Repository::url"][::std::mem::offset_of!(Repository, url) - 8usize]; + ["Offset of field: Repository::alias"][::std::mem::offset_of!(Repository, alias) - 16usize]; + ["Offset of field: Repository::userName"] + [::std::mem::offset_of!(Repository, userName) - 24usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct RepositoryList { + pub size: ::std::os::raw::c_uint, + #[doc = "< owned, *size* items"] + pub repos: *mut Repository, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of RepositoryList"][::std::mem::size_of::() - 16usize]; + ["Alignment of RepositoryList"][::std::mem::align_of::() - 8usize]; + ["Offset of field: RepositoryList::size"] + [::std::mem::offset_of!(RepositoryList, size) - 0usize]; + ["Offset of field: RepositoryList::repos"] + [::std::mem::offset_of!(RepositoryList, repos) - 8usize]; +}; +unsafe extern "C" { + pub fn set_zypp_progress_callback( + progress: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + pub fn free_status(s: *mut Status); + #[doc = " Initialize Zypp target (where to install packages to).\n The returned zypp context is not thread safe and should be protected by a\n mutex in the calling layer.\n @param root\n @param[out] status\n @param progress\n @param user_data\n @return zypp context"] + pub fn init_target( + root: *const ::std::os::raw::c_char, + status: *mut Status, + progress: ProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ) -> *mut Zypp; + #[doc = " Marks resolvable for installation\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do selection. If NOT_SELECTED is used, it will be empty\n operation.\n @param[out] status (will overwrite existing contents)"] + pub fn resolvable_select( + zypp: *mut Zypp, + name: *const ::std::os::raw::c_char, + kind: RESOLVABLE_KIND, + who: RESOLVABLE_SELECTED, + status: *mut Status, + ); + #[doc = " Unselect resolvable for installation. It can still be installed as\n dependency.\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do unselection. Only unselect if it is higher or equal level\n then who do the selection.\n @param[out] status (will overwrite existing contents)"] + pub fn resolvable_unselect( + zypp: *mut Zypp, + name: *const ::std::os::raw::c_char, + kind: RESOLVABLE_KIND, + who: RESOLVABLE_SELECTED, + status: *mut Status, + ); + #[doc = " Get Pattern details.\n Unknown patterns are simply omitted from the result. Match by\n PatternInfo.name, not by index."] + pub fn get_patterns_info( + _zypp: *mut Zypp, + names: PatternNames, + status: *mut Status, + ) -> PatternInfos; + pub fn free_pattern_infos(infos: *const PatternInfos); + pub fn import_gpg_key( + zypp: *mut Zypp, + pathname: *const ::std::os::raw::c_char, + status: *mut Status, + ); + #[doc = " Runs solver\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)\n @return true if solver pass and false if it found some dependency issues"] + pub fn run_solver(zypp: *mut Zypp, status: *mut Status) -> bool; + #[doc = " the last call that will free all pointers to zypp holded by agama"] + pub fn free_zypp(zypp: *mut Zypp); + #[doc = " repository array in list.\n when no longer needed, use \\ref free_repository_list to release memory\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)"] + pub fn list_repositories(zypp: *mut Zypp, status: *mut Status) -> RepositoryList; + pub fn free_repository_list(repo_list: *mut RepositoryList); + #[doc = " Adds repository to repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param url can contain repo variables\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] + pub fn add_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + url: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + #[doc = " Removes repository from repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] + pub fn remove_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + #[doc = "\n @param zypp see \\ref init_target\n @param alias alias of repository to refresh\n @param[out] status (will overwrite existing contents)\n @param callbacks pointer to struct with callbacks or NULL if no progress is\n needed"] + pub fn refresh_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callbacks: *mut DownloadProgressCallbacks, + ); + pub fn build_repository_cache( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + callback: ZyppProgressCallback, + user_data: *mut ::std::os::raw::c_void, + ); + pub fn load_repository_cache( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + ); +} diff --git a/rust/zypp-agama/zypp-agama-sys/src/lib.rs b/rust/zypp-agama/zypp-agama-sys/src/lib.rs new file mode 100644 index 0000000000..ee7e408421 --- /dev/null +++ b/rust/zypp-agama/zypp-agama-sys/src/lib.rs @@ -0,0 +1,14 @@ +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] + +include!("bindings.rs"); + +impl Default for Status { + fn default() -> Self { + Self { + state: Status_STATE_STATE_SUCCEED, + error: std::ptr::null_mut(), + } + } +} diff --git a/rust/zypp-c-api b/rust/zypp-c-api deleted file mode 160000 index 8e134bc8be..0000000000 --- a/rust/zypp-c-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8e134bc8beb6d5695e47eacb2985de349a467b56 From 40312be4cd417e17d9650ef0e3daaff2b54bf960 Mon Sep 17 00:00:00 2001 From: Martin Vidner Date: Fri, 19 Sep 2025 11:18:51 +0200 Subject: [PATCH 068/917] setup.sh: setup backend after frontend to prevent zypp lock failure With the new software stack, starting the backend now grabs the zypp lock. Then installing the frontend dependencies fails as zypper cannot get the lock. The lock may be OK, it may be a bug, but here we simply work around it by swapping the setup order. --- setup.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/setup.sh b/setup.sh index 687f012120..160377f85b 100755 --- a/setup.sh +++ b/setup.sh @@ -21,14 +21,6 @@ else SUDO="" fi -# Services setup -if ! $MYDIR/setup-services.sh; then - echo "Services setup failed." - echo "Agama services are NOT running." - - exit 2 -fi; - # Web setup if ! $MYDIR/setup-web.sh; then echo "Web client setup failed." @@ -37,6 +29,14 @@ if ! $MYDIR/setup-web.sh; then exit 3 fi; +# Services setup +if ! $MYDIR/setup-services.sh; then + echo "Services setup failed." + echo "Agama services are NOT running." + + exit 2 +fi; + # Start the installer. echo echo "The configured Agama services can be manually started with these commands:" From 81f2027ece95618fba9af70747d93085ef27b221 Mon Sep 17 00:00:00 2001 From: Martin Vidner Date: Fri, 19 Sep 2025 11:23:29 +0200 Subject: [PATCH 069/917] setup-services.sh: install libzypp-devel for our new software stack --- setup-services.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/setup-services.sh b/setup-services.sh index 447bf1519c..88ab14056e 100755 --- a/setup-services.sh +++ b/setup-services.sh @@ -139,6 +139,7 @@ $SUDO $ZYPPER install \ clang-devel \ gzip \ jsonnet \ + libzypp-devel \ lshw \ NetworkManager \ pam-devel \ From 6dd3bc961db0925a84274e16fe17ffa00916a3ee Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 19 Sep 2025 11:27:27 +0200 Subject: [PATCH 070/917] copy also editor config --- .editorconfig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.editorconfig b/.editorconfig index 6367fde790..d24ed170be 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,11 @@ root = true [*.sh] indent_style = space indent_size = 2 + +[*.{c,h,cxx,hxx}] +indent_style = space +indent_size = 2 +tab_width = 8 + +[Makefile] +indent_style = tab From f1f6812d942944bbb85b9fc17acd93b972b830fe Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 19 Sep 2025 11:28:59 +0200 Subject: [PATCH 071/917] fix cargo fmt --- rust/zypp-agama/src/lib.rs | 1 - rust/zypp-agama/zypp-agama-sys/build.rs | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index b265dd7f52..4d54127b1a 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -532,7 +532,6 @@ mod tests { let zypp = Zypp::init_target(root, progress_cb)?; let repos = zypp.list_repositories()?; assert!(repos.len() == 1); - } Ok(()) } diff --git a/rust/zypp-agama/zypp-agama-sys/build.rs b/rust/zypp-agama/zypp-agama-sys/build.rs index 4a988821e8..7cb114e781 100644 --- a/rust/zypp-agama/zypp-agama-sys/build.rs +++ b/rust/zypp-agama/zypp-agama-sys/build.rs @@ -15,8 +15,7 @@ fn update_file(file_path: &str, contents: &str) { }; if should_write { - fs::write(file_path, contents) - .unwrap_or_else(|_| panic!("Couldn't write {}", file_path)); + fs::write(file_path, contents).unwrap_or_else(|_| panic!("Couldn't write {}", file_path)); } } From dc77e6a7a12c32e37168aeffeecfad1743758368 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 19 Sep 2025 11:31:40 +0200 Subject: [PATCH 072/917] add format target for make and call it --- rust/zypp-agama/zypp-agama-sys/c-layer/Makefile | 4 ++++ rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index bae2c1f4d0..192e74be22 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -16,3 +16,7 @@ libagama-zypp.a: $(OBJ) %.o: %.cxx $(DEPS) $(CXX) -c -o $@ $< $(CXXFLAGS) + +format: + git ls-files | grep '\.[ch]' | \ + xargs --verbose clang-format --style=llvm -i diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 9a594853df..5d818a1c0d 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -38,7 +38,8 @@ static struct Zypp the_zypp { }; void free_zypp(struct Zypp *zypp) noexcept { - // ensure that target is unloaded otherwise nasty things can happen if new zypp is created in different thread + // ensure that target is unloaded otherwise nasty things can happen if new + // zypp is created in different thread zypp->zypp_pointer->getTarget()->unload(); zypp->zypp_pointer = NULL; // shared ptr assignment operator will free original pointer From c0ff4b0bbc0fe213f8356671797758e6aff57b31 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 19 Sep 2025 11:32:20 +0200 Subject: [PATCH 073/917] and add make check --- rust/zypp-agama/zypp-agama-sys/c-layer/Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index 192e74be22..9ccd6a21ec 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -10,6 +10,8 @@ clean: rm -vf *.o *.a check: + git ls-files | grep '\.[ch]' | \ + xargs --verbose clang-format --style=llvm --dry-run libagama-zypp.a: $(OBJ) $(AR) -crs $@ $^ From 7a308582a99cc155ae2db18cd1a574f5335a47ab Mon Sep 17 00:00:00 2001 From: Martin Vidner Date: Fri, 19 Sep 2025 11:51:45 +0200 Subject: [PATCH 074/917] Better file filter for clang-format --- rust/zypp-agama/zypp-agama-sys/c-layer/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index 9ccd6a21ec..97f6355a8d 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -10,7 +10,7 @@ clean: rm -vf *.o *.a check: - git ls-files | grep '\.[ch]' | \ + git ls-files | grep -E '\.(c|h|cxx|hxx)$' | \ xargs --verbose clang-format --style=llvm --dry-run libagama-zypp.a: $(OBJ) From 8e9cbbabb19a5d359d043c204b5e7e19c55668d2 Mon Sep 17 00:00:00 2001 From: Martin Vidner Date: Fri, 19 Sep 2025 11:52:01 +0200 Subject: [PATCH 075/917] Better file filter for clang-format --- rust/zypp-agama/zypp-agama-sys/c-layer/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index 97f6355a8d..866cb3a918 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -20,5 +20,5 @@ libagama-zypp.a: $(OBJ) $(CXX) -c -o $@ $< $(CXXFLAGS) format: - git ls-files | grep '\.[ch]' | \ + git ls-files | grep -E '\.(c|h|cxx|hxx)$' | \ xargs --verbose clang-format --style=llvm -i From b6924c23637dbad4378c9bf4e497ce4b0df83824 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 22 Sep 2025 14:13:53 +0200 Subject: [PATCH 076/917] add initial mock implementation of software proposal --- rust/agama-server/src/software/web.rs | 4 ++-- rust/agama-server/src/software_ng/web.rs | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 5fb4d46682..f07024ecdd 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -731,10 +731,10 @@ async fn read_config(state: &SoftwareState<'_>) -> Result pub struct SoftwareProposal { /// Space required for installation. It is returned as a formatted string which includes /// a number and a unit (e.g., "GiB"). - size: String, + pub size: String, /// 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. - patterns: HashMap, + pub patterns: HashMap, } /// Returns the proposal information. diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 99e9611a86..80f9a859d0 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -18,13 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::collections::HashMap; + use agama_lib::{ error::ServiceError, issue::Issue, product::Product, software::{ model::{RegistrationInfo, ResolvableParams, SoftwareConfig}, - Pattern, + Pattern, SelectedBy, }, }; use axum::{ @@ -168,7 +170,14 @@ async fn probe(State(state): State) -> Result, Error> { ) )] async fn get_proposal(State(state): State) -> Result, Error> { - unimplemented!("get the software proposal"); + let config = state.client.get_config().await?; + let patterns = config.patterns.unwrap_or(HashMap::new()); + let proposal = SoftwareProposal { + size: "TODO".to_string(), + patterns: patterns.iter().filter(|(_name, selected)| **selected).map(|(name,_selected)| (name.clone(), SelectedBy::User)).collect() + }; + + Ok(Json(proposal)) } /// Returns the product issues From 79a771662788f1301a210ae8954c498c332e334d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 22 Sep 2025 14:36:09 +0200 Subject: [PATCH 077/917] fix build of agama rust --- rust/package/agama.spec | 2 -- 1 file changed, 2 deletions(-) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index d91e4d1992..bf79e5f3c7 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -153,8 +153,6 @@ package contains a systemd service to run scripts when booting the installed sys # Require at least 1.3GB RAM per each parallel job (the size is in MB), # this can limit the number of parallel jobs on systems with relatively small memory. %{limit_build -m 1300} -# remove project cargo files from submodules to avoid confusion of tools -rm zypp-c-api/rust/Cargo.* %{cargo_build} cargo run --package xtask -- manpages From c11536772f2a19bbb579017ea164bc76fe7cb233 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 22 Sep 2025 14:58:56 +0200 Subject: [PATCH 078/917] update gitignore to reflect C addition --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ea097847f6..218e45e77f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ /*.pot *.mo *.bz2 +*.o +*.a # Do NOT ignore .github: for git this is a no-op # but it helps ripgrep (rg) which would otherwise ignore dotfiles and dotdirs !/.github/ From 6e55433c6cb9ada1339bb40476052eea4da32959 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 22 Sep 2025 14:36:09 +0200 Subject: [PATCH 079/917] fix build of agama rust --- rust/package/agama.spec | 2 -- 1 file changed, 2 deletions(-) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index d91e4d1992..bf79e5f3c7 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -153,8 +153,6 @@ package contains a systemd service to run scripts when booting the installed sys # Require at least 1.3GB RAM per each parallel job (the size is in MB), # this can limit the number of parallel jobs on systems with relatively small memory. %{limit_build -m 1300} -# remove project cargo files from submodules to avoid confusion of tools -rm zypp-c-api/rust/Cargo.* %{cargo_build} cargo run --package xtask -- manpages From c116332a2e0847e2df074066e8e41092e6c93876 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 23 Sep 2025 10:34:30 +0200 Subject: [PATCH 080/917] use proper path to software issues --- service/lib/agama/http/clients/software.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index c8921603c1..31ce18a154 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -64,7 +64,7 @@ def config def errors? # TODO: implement it together with checking type error - JSON.parse(get("software/issues")) + JSON.parse(get("software/issues/software")) end def get_resolvables(_unique_id, _type, _optional) From 491819bc67e555760cb668b5e7e640d608b905ca Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 23 Sep 2025 10:55:11 +0200 Subject: [PATCH 081/917] cargo fmt --- rust/agama-server/src/software_ng/web.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 80f9a859d0..bdd83815f7 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -174,7 +174,11 @@ async fn get_proposal(State(state): State) -> Result Date: Tue, 23 Sep 2025 14:59:37 +0200 Subject: [PATCH 082/917] fix errors check --- service/lib/agama/http/clients/software.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 31ce18a154..41c1b8546d 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -63,8 +63,8 @@ def config end def errors? - # TODO: implement it together with checking type error - JSON.parse(get("software/issues/software")) + # TODO: severity as integer is nasty for http API + JSON.parse(get("software/issues/software"))&.select{ |i| i["severity"] == 1}&.any? end def get_resolvables(_unique_id, _type, _optional) From edf94664e68756f346e38c6bf03d8664abc106b8 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 24 Sep 2025 11:29:03 +0200 Subject: [PATCH 083/917] fix implementation of get/set resolvables --- .../src/software_ng/backend/client.rs | 16 ++++++++ .../src/software_ng/backend/server.rs | 20 +++++++++- rust/agama-server/src/software_ng/web.rs | 40 +++++++++++++++++-- service/lib/agama/http/clients/software.rb | 10 ++--- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 93a9859741..c5e4859171 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -91,4 +91,20 @@ impl SoftwareServiceClient { })?; Ok(()) } + + pub async fn get_resolvables( + &self, + id: &str, + r#type: ResolvableType, + optional: bool, + ) -> Result, SoftwareServiceError> { + let (tx, rx) = oneshot::channel(); + self.actions.send(SoftwareAction::GetResolvables { + tx, + id: id.to_string(), + r#type, + optional, + })?; + Ok(rx.await?) + } } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 593d69d00e..f3af7d293c 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -52,6 +52,12 @@ pub enum SoftwareAction { resolvables: Vec, optional: bool, }, + GetResolvables { + tx: oneshot::Sender>, + id: String, + r#type: ResolvableType, + optional: bool, + }, } /// Software service server. @@ -161,7 +167,19 @@ impl SoftwareServiceServer { } => { let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); self.software_selection - .add(&id, r#type, optional, &resolvables); + .set(&id, r#type, optional, &resolvables); + } + + SoftwareAction::GetResolvables { + tx, + id, + r#type, + optional, + } => { + let result = self.software_selection + .get(&id, r#type, optional).unwrap_or(vec![]); + tx.send(result) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; } } Ok(()) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index bdd83815f7..ca06d767e3 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -25,12 +25,12 @@ use agama_lib::{ issue::Issue, product::Product, software::{ - model::{RegistrationInfo, ResolvableParams, SoftwareConfig}, + model::{RegistrationInfo, ResolvableParams, ResolvableType, SoftwareConfig}, Pattern, SelectedBy, }, }; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, routing::{get, post, put}, Json, Router, }; @@ -53,7 +53,7 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result, + Path(id): Path, + Query(query): Query>, +) -> Result>, Error> { + let default = "package".to_string(); + let typ = query.get("type").unwrap_or(&default); + let typ = match typ.as_str() { + // TODO: support more and move to Resolvable Kind + "package" => Ok(ResolvableType::Package), + "pattern" => Ok(ResolvableType::Pattern), + _ => Err(anyhow::Error::msg("Unknown resolveble type")) + }?; + + let optional = query.get("optional").map_or(false, |v| v.as_str() == "true"); + + + let result = state + .client + .get_resolvables(&id, typ, optional).await?; + Ok(Json(result)) +} \ No newline at end of file diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 41c1b8546d..db41952184 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -67,9 +67,8 @@ def errors? JSON.parse(get("software/issues/software"))&.select{ |i| i["severity"] == 1}&.any? end - def get_resolvables(_unique_id, _type, _optional) - # TODO: implement on backend - JSON.parse(get("software/config")) + def get_resolvables(unique_id, type, optional) + JSON.parse(get("software/resolvables/#{unique_id}?type=#{type}&optional=#{optional}")) end def provisions_selected?(_provisions) @@ -87,14 +86,13 @@ def package_installed?(_name) true end - def set_resolvables(_unique_id, type, resolvables, optional) - # TODO: implement at backend proposal id + def set_resolvables(unique_id, type, resolvables, optional) data = { "names" => resolvables, "type" => type, "optional" => optional } - JSON.parse(put("software/config"), data) + JSON.parse(put("software/resolvables/#{unique_id}"), data) end def add_patterns(patterns) From 191fda1c1279861d11267873f8eee8f2986e6473 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 24 Sep 2025 12:01:00 +0200 Subject: [PATCH 084/917] fix formatting --- .../src/software_ng/backend/server.rs | 6 ++++-- rust/agama-server/src/software_ng/web.rs | 18 ++++++++++-------- service/lib/agama/http/clients/software.rb | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index f3af7d293c..77e869f5b9 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -176,8 +176,10 @@ impl SoftwareServiceServer { r#type, optional, } => { - let result = self.software_selection - .get(&id, r#type, optional).unwrap_or(vec![]); + let result = self + .software_selection + .get(&id, r#type, optional) + .unwrap_or(vec![]); tx.send(result) .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; } diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index ca06d767e3..6e9e158fb9 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -53,7 +53,10 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result Ok(ResolvableType::Package), "pattern" => Ok(ResolvableType::Pattern), - _ => Err(anyhow::Error::msg("Unknown resolveble type")) + _ => Err(anyhow::Error::msg("Unknown resolveble type")), }?; - let optional = query.get("optional").map_or(false, |v| v.as_str() == "true"); + let optional = query + .get("optional") + .map_or(false, |v| v.as_str() == "true"); - - let result = state - .client - .get_resolvables(&id, typ, optional).await?; + let result = state.client.get_resolvables(&id, typ, optional).await?; Ok(Json(result)) -} \ No newline at end of file +} diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index db41952184..68a20da502 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -64,7 +64,7 @@ def config def errors? # TODO: severity as integer is nasty for http API - JSON.parse(get("software/issues/software"))&.select{ |i| i["severity"] == 1}&.any? + JSON.parse(get("software/issues/software"))&.select { |i| i["severity"] == 1 }&.any? end def get_resolvables(unique_id, type, optional) From 630fa7345447bc6434f30cd9dcd5b79efcac7c82 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 24 Sep 2025 14:29:22 +0200 Subject: [PATCH 085/917] fix calling http methods --- .../dbus/y2dir/manager/modules/PackagesProposal.rb | 12 ++++++------ service/lib/agama/http/clients/software.rb | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb index 3466ff17bc..ecb8d8a001 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb @@ -31,30 +31,30 @@ def main # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L118 def AddResolvables(unique_id, type, resolvables, optional: false) - orig_resolvables = client.get_resolvables(unique_id, type, optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional: optional) orig_resolvables += resolvables orig_resolvables.uniq! - SetResolvables(unique_id, type, orig_resolvables, optional) + SetResolvables(unique_id, type, orig_resolvables, optional: optional) true end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L145 def SetResolvables(unique_id, type, resolvables, optional: false) - client.set_resolvables(unique_id, type, resolvables || [], optional: optional) + client.set_resolvables(unique_id, type, resolvables || [], optional) true end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L285 def GetResolvables(unique_id, type, optional: false) - client.get_resolvables(unique_id, type, optional: optional) + client.get_resolvables(unique_id, type, optional) end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L177 def RemoveResolvables(unique_id, type, resolvables, optional: false) - orig_resolvables = client.get_resolvables(unique_id, type, optional) + orig_resolvables = client.get_resolvables(unique_id, type, optional: optional) orig_resolvables -= resolvables orig_resolvables.uniq! - SetResolvables(unique_id, type, orig_resolvables, optional) + SetResolvables(unique_id, type, orig_resolvables, optional: optional) true end diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 68a20da502..edd118b3d0 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -92,7 +92,7 @@ def set_resolvables(unique_id, type, resolvables, optional) "type" => type, "optional" => optional } - JSON.parse(put("software/resolvables/#{unique_id}"), data) + put("software/resolvables/#{unique_id}", data) end def add_patterns(patterns) From c46d4c49153b351211d2e582960a206d6b1a9748 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 08:32:45 +0200 Subject: [PATCH 086/917] select software from product definition --- rust/agama-lib/src/software/model/packages.rs | 1 + rust/agama-server/src/products.rs | 9 ++- .../src/software_ng/backend/server.rs | 56 +++++++++++++++++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs index abbcf077dc..191e01c073 100644 --- a/rust/agama-lib/src/software/model/packages.rs +++ b/rust/agama-lib/src/software/model/packages.rs @@ -44,6 +44,7 @@ pub struct SoftwareConfig { pub enum ResolvableType { Package = 0, Pattern = 1, + Product = 2, } /// Resolvable list specification. diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index ef2c64c58c..a9f4f41034 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -128,11 +128,14 @@ pub struct SoftwareSpec { installation_repositories: Vec, #[serde(default)] pub installation_labels: Vec, + #[serde(default)] pub mandatory_patterns: Vec, + #[serde(default)] pub mandatory_packages: Vec, - // TODO: the specification should always be a vector (even if empty). - pub optional_patterns: Option>, - pub optional_packages: Option>, + #[serde(default)] + pub optional_patterns: Vec, + #[serde(default)] + pub optional_packages: Vec, pub base_product: String, } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 77e869f5b9..699a510c8d 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -165,9 +165,7 @@ impl SoftwareServiceServer { resolvables, optional, } => { - let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); - self.software_selection - .set(&id, r#type, optional, &resolvables); + self.set_resolvables(id, r#type, resolvables, optional)?; } SoftwareAction::GetResolvables { @@ -187,6 +185,19 @@ impl SoftwareServiceServer { Ok(()) } + fn set_resolvables( + &mut self, + id: String, + r#type: ResolvableType, + resolvables: Vec, + optional: bool, + ) -> Result<(), SoftwareServiceError> { + let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); + self.software_selection + .set(&id, r#type, optional, &resolvables); + Ok(()) + } + /// Select the given product. async fn select_product(&mut self, product_id: String) -> Result<(), SoftwareServiceError> { tracing::info!("Selecting product {}", product_id); @@ -199,7 +210,7 @@ impl SoftwareServiceServer { Ok(()) } - async fn probe(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + async fn probe(&mut self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { let product = self.find_selected_product().await?; let repositories = product.software.repositories(); for (idx, repo) in repositories.iter().enumerate() { @@ -218,6 +229,40 @@ impl SoftwareServiceServer { }) .map_err(SoftwareServiceError::LoadSourcesFailed)?; + let installer_id_string = "installer".to_string(); + // select product + self.set_resolvables( + installer_id_string.clone(), + ResolvableType::Product, + vec![product.software.base_product.clone()], + false, + )?; + // select packages and patterns from product + self.set_resolvables( + installer_id_string.clone(), + ResolvableType::Package, + product.software.mandatory_packages, + false, + )?; + self.set_resolvables( + installer_id_string.clone(), + ResolvableType::Pattern, + product.software.mandatory_patterns, + false, + )?; + self.set_resolvables( + installer_id_string.clone(), + ResolvableType::Package, + product.software.optional_packages, + true, + )?; + self.set_resolvables( + installer_id_string.clone(), + ResolvableType::Pattern, + product.software.optional_patterns, + true, + )?; + Ok(()) } @@ -271,8 +316,7 @@ impl SoftwareServiceServer { let product = self.find_selected_product().await?; let mandatory_patterns = product.software.mandatory_patterns.iter(); - let optional_patterns = product.software.optional_patterns.unwrap_or(vec![]); - let optional_patterns = optional_patterns.iter(); + let optional_patterns = product.software.optional_patterns.iter(); let pattern_names: Vec<&str> = vec![mandatory_patterns, optional_patterns] .into_iter() .flatten() From 3039b8994ae22f12b3c86abc9713ac3dc5c70e5a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 10:39:39 +0200 Subject: [PATCH 087/917] select resolvables also in zypp --- rust/Cargo.lock | 1 + rust/agama-lib/Cargo.toml | 1 + rust/agama-lib/src/software/model.rs | 62 ++++++++----------- rust/agama-lib/src/software/model/packages.rs | 14 ++++- .../src/software_ng/backend/server.rs | 10 ++- 5 files changed, 50 insertions(+), 38 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 259d4c35fe..ddc25b0081 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "utoipa", "uuid", "zbus", + "zypp-agama", ] [[package]] diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 86717c2b40..446149abfb 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -46,6 +46,7 @@ tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } tokio-native-tls = "0.3.1" percent-encoding = "2.3.1" uuid = { version = "1.17.0", features = ["serde", "v4"] } +zypp-agama = { path = "../zypp-agama" } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index c784ac94d3..ddb951120e 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -49,28 +49,40 @@ impl SoftwareSelection { Default::default() } - /// Adds a set of resolvables. - /// - /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). - /// * `optional` - Whether the selection is optional or not. - /// * `resolvables` - The resolvables to add. - pub fn add(&mut self, id: &str, r#type: ResolvableType, optional: bool, resolvables: &[&str]) { - let list = self.find_or_create_selection(id, r#type, optional); - let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); - list.resolvables.extend(new_resolvables); - } - /// Updates a set of resolvables. /// + /// * `zypp` - pointer to libzypp to do real action /// * `id` - The id of the set. /// * `r#type` - The type of the resolvables (patterns or packages). /// * `optional` - Whether the selection is optional or not. /// * `resolvables` - The resolvables included in the set. - pub fn set(&mut self, id: &str, r#type: ResolvableType, optional: bool, resolvables: &[&str]) { + pub fn set( + &mut self, + zypp: &zypp_agama::Zypp, + id: &str, + r#type: ResolvableType, + optional: bool, + resolvables: &[&str], + ) -> Result<(), zypp_agama::ZyppError> { let list = self.find_or_create_selection(id, r#type, optional); + // FIXME: use reference counting here, if multiple ids require some package, to not unselect it + for res in &list.resolvables { + zypp.unselect_resolvable( + &res, + r#type.into(), + zypp_agama::ResolvableSelected::Installation, + )?; + } let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); list.resolvables = new_resolvables; + for res in &list.resolvables { + zypp.select_resolvable( + &res, + r#type.into(), + zypp_agama::ResolvableSelected::Installation, + )?; + } + Ok(()) } /// Returns a set of resolvables. @@ -85,16 +97,6 @@ impl SoftwareSelection { .map(|l| l.resolvables.clone()) } - /// Removes the given resolvables from a set. - /// - /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). - /// * `optional` - Whether the selection is optional or not. - pub fn remove(&mut self, id: &str, r#type: ResolvableType, optional: bool) { - self.selections - .retain(|l| l.id != id || l.r#type != r#type || l.optional != optional); - } - fn find_or_create_selection( &mut self, id: &str, @@ -121,22 +123,11 @@ impl SoftwareSelection { } } +/* Fix tests with real mock of libzypp #[cfg(test)] mod tests { use super::*; - #[test] - fn test_add_selection() { - let mut selection = SoftwareSelection::new(); - selection.set("agama", ResolvableType::Package, false, &["agama-scripts"]); - selection.add("agama", ResolvableType::Package, false, &["suse"]); - - let packages = selection - .get("agama", ResolvableType::Package, false) - .unwrap(); - assert_eq!(packages.len(), 2); - } - #[test] fn test_set_selection() { let mut selection = SoftwareSelection::new(); @@ -158,3 +149,4 @@ mod tests { assert_eq!(packages, None); } } + */ diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs index 191e01c073..6ed94a64ea 100644 --- a/rust/agama-lib/src/software/model/packages.rs +++ b/rust/agama-lib/src/software/model/packages.rs @@ -38,7 +38,9 @@ pub struct SoftwareConfig { } /// Software resolvable type (package or pattern). -#[derive(Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq)] +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq, +)] #[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum ResolvableType { @@ -47,6 +49,16 @@ pub enum ResolvableType { Product = 2, } +impl From for zypp_agama::ResolvableKind { + fn from(value: ResolvableType) -> Self { + match value { + ResolvableType::Package => zypp_agama::ResolvableKind::Package, + ResolvableType::Product => zypp_agama::ResolvableKind::Product, + ResolvableType::Pattern => zypp_agama::ResolvableKind::Pattern, + } + } +} + /// Resolvable list specification. #[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct ResolvableParams { diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 699a510c8d..bb40340662 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -165,7 +165,7 @@ impl SoftwareServiceServer { resolvables, optional, } => { - self.set_resolvables(id, r#type, resolvables, optional)?; + self.set_resolvables(zypp, id, r#type, resolvables, optional)?; } SoftwareAction::GetResolvables { @@ -187,6 +187,7 @@ impl SoftwareServiceServer { fn set_resolvables( &mut self, + zypp: &zypp_agama::Zypp, id: String, r#type: ResolvableType, resolvables: Vec, @@ -194,7 +195,7 @@ impl SoftwareServiceServer { ) -> Result<(), SoftwareServiceError> { let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); self.software_selection - .set(&id, r#type, optional, &resolvables); + .set(zypp, &id, r#type, optional, &resolvables); Ok(()) } @@ -232,6 +233,7 @@ impl SoftwareServiceServer { let installer_id_string = "installer".to_string(); // select product self.set_resolvables( + zypp, installer_id_string.clone(), ResolvableType::Product, vec![product.software.base_product.clone()], @@ -239,24 +241,28 @@ impl SoftwareServiceServer { )?; // select packages and patterns from product self.set_resolvables( + zypp, installer_id_string.clone(), ResolvableType::Package, product.software.mandatory_packages, false, )?; self.set_resolvables( + zypp, installer_id_string.clone(), ResolvableType::Pattern, product.software.mandatory_patterns, false, )?; self.set_resolvables( + zypp, installer_id_string.clone(), ResolvableType::Package, product.software.optional_packages, true, )?; self.set_resolvables( + zypp, installer_id_string.clone(), ResolvableType::Pattern, product.software.optional_patterns, From 656b005688ca585911f439aec385ba8b07975193 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 11:54:41 +0200 Subject: [PATCH 088/917] Apply suggestions from code review Co-authored-by: Martin Vidner --- rust/agama-server/src/software_ng/backend/server.rs | 2 +- rust/agama-server/src/software_ng/web.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 77e869f5b9..c9772e6a53 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -179,7 +179,7 @@ impl SoftwareServiceServer { let result = self .software_selection .get(&id, r#type, optional) - .unwrap_or(vec![]); + .unwrap_or(vec![]); // Option::unwrap is OK tx.send(result) .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; } diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 6e9e158fb9..7bf3ff9db2 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -286,7 +286,7 @@ async fn get_resolvables( let default = "package".to_string(); let typ = query.get("type").unwrap_or(&default); let typ = match typ.as_str() { - // TODO: support more and move to Resolvable Kind + // TODO: support more and move to ResolvableKind "package" => Ok(ResolvableType::Package), "pattern" => Ok(ResolvableType::Pattern), _ => Err(anyhow::Error::msg("Unknown resolveble type")), From 1c3a22cb9491bcbff614c87dbb5a2c689daa36ea Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 12:35:47 +0200 Subject: [PATCH 089/917] run solver after modification of software --- rust/agama-server/src/software_ng/backend.rs | 3 +++ rust/agama-server/src/software_ng/backend/server.rs | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 340ffa6727..44c1999349 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -76,6 +76,9 @@ pub enum SoftwareServiceError { #[error("Listing patterns failed: {0}")] ListPatternsFailed(#[source] ZyppError), + + #[error("Error from libzypp: {0}")] + ZyppError(#[from] zypp_agama::ZyppError), } /// Builds and starts the software service. diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index ea0d894b2c..dec9d17202 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -157,6 +157,7 @@ impl SoftwareServiceServer { SoftwareAction::Probe => { self.probe(zypp).await?; + self.run_solver(zypp)?; } SoftwareAction::SetResolvables { @@ -166,6 +167,7 @@ impl SoftwareServiceServer { optional, } => { self.set_resolvables(zypp, id, r#type, resolvables, optional)?; + self.run_solver(zypp)?; } SoftwareAction::GetResolvables { @@ -199,6 +201,13 @@ impl SoftwareServiceServer { Ok(()) } + // runs solver. It should be able in future to generate solver issues + fn run_solver(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + let result = zypp.run_solver()?; + tracing::info!("Solver runs ends with {}", result); + Ok(()) + } + /// Select the given product. async fn select_product(&mut self, product_id: String) -> Result<(), SoftwareServiceError> { tracing::info!("Selecting product {}", product_id); From f6cfbcc89a44b3e24bb01def9a88c1cf3e2e8676 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 14:59:47 +0200 Subject: [PATCH 090/917] add call to switch target --- rust/zypp-agama/src/lib.rs | 11 +++++++++++ .../zypp-agama/zypp-agama-sys/c-layer/include/lib.h | 7 ++++++- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 13 +++++++++++++ rust/zypp-agama/zypp-agama-sys/src/bindings.rs | 4 +++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 4d54127b1a..12d54cc5a2 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -120,6 +120,17 @@ impl Zypp { } } + pub fn switch_target(&self, root: &str) -> ZyppResult<()> { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_root = CString::new(root).unwrap(); + unsafe { + zypp_agama_sys::switch_target(self.ptr, c_root.as_ptr(), status_ptr); + helpers::status_to_result_void(status)?; + } + Ok(()) + } + pub fn list_repositories(&self) -> ZyppResult> { let mut repos_v = vec![]; diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index cb52cd1f5d..4b02cde6de 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -43,7 +43,7 @@ struct Zypp; /// execution? typedef void (*ProgressCallback)(const char *text, unsigned stage, unsigned total, void *user_data); -/// Initialize Zypp target (where to install packages to). +/// Initialize Zypp target (where to store zypp data). /// The returned zypp context is not thread safe and should be protected by a /// mutex in the calling layer. /// @param root @@ -54,6 +54,11 @@ typedef void (*ProgressCallback)(const char *text, unsigned stage, struct Zypp *init_target(const char *root, struct Status *status, ProgressCallback progress, void *user_data) noexcept; +/// Switch Zypp target (where to install packages to). +/// @param root +/// @param[out] status +void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept; + enum RESOLVABLE_KIND { RESOLVABLE_PRODUCT, RESOLVABLE_PATCH, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 5d818a1c0d..7b2e9b55dc 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -95,6 +95,19 @@ static zypp::ZYpp::Ptr zypp_ptr() { return NULL; } +void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept { + const std::string root_str(root); + try { + zypp->zypp_pointer->initializeTarget(root_str, false); + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } + + status->state = status->STATE_SUCCEED; + status->error = NULL; +} + // TODO: split init target into set of repo manager, initialize target and load // target and merge it in rust struct Zypp *init_target(const char *root, struct Status *status, diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 4e9d0e1a11..b482d4d1d0 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -246,13 +246,15 @@ unsafe extern "C" { user_data: *mut ::std::os::raw::c_void, ); pub fn free_status(s: *mut Status); - #[doc = " Initialize Zypp target (where to install packages to).\n The returned zypp context is not thread safe and should be protected by a\n mutex in the calling layer.\n @param root\n @param[out] status\n @param progress\n @param user_data\n @return zypp context"] + #[doc = " Initialize Zypp target (where to store zypp data).\n The returned zypp context is not thread safe and should be protected by a\n mutex in the calling layer.\n @param root\n @param[out] status\n @param progress\n @param user_data\n @return zypp context"] pub fn init_target( root: *const ::std::os::raw::c_char, status: *mut Status, progress: ProgressCallback, user_data: *mut ::std::os::raw::c_void, ) -> *mut Zypp; + #[doc = " Switch Zypp target (where to install packages to).\n @param root\n @param[out] status"] + pub fn switch_target(zypp: *mut Zypp, root: *const ::std::os::raw::c_char, status: *mut Status); #[doc = " Marks resolvable for installation\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do selection. If NOT_SELECTED is used, it will be empty\n operation.\n @param[out] status (will overwrite existing contents)"] pub fn resolvable_select( zypp: *mut Zypp, From 64931c8570729898b141f5c221bd94f0d8e395a2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 16:55:11 +0200 Subject: [PATCH 091/917] initial implementation of commit --- .../src/software_ng/backend/client.rs | 6 ++++++ .../src/software_ng/backend/server.rs | 14 ++++++++++++++ rust/agama-server/src/software_ng/web.rs | 19 +++++++++++++++++++ rust/zypp-agama/src/lib.rs | 10 ++++++++++ .../zypp-agama-sys/c-layer/Makefile | 4 ++-- .../zypp-agama-sys/c-layer/include/lib.h | 10 +++++++++- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 17 ++++++++++++++++- .../zypp-agama/zypp-agama-sys/src/bindings.rs | 2 ++ 8 files changed, 78 insertions(+), 4 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index c5e4859171..bd4668cc1f 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -75,6 +75,12 @@ impl SoftwareServiceClient { Ok(()) } + pub async fn install(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(SoftwareAction::Install(tx))?; + Ok(rx.await?) + } + pub fn set_resolvables( &self, id: &str, diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index dec9d17202..0f01bd9a0b 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -42,6 +42,7 @@ const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; #[derive(Debug)] pub enum SoftwareAction { Probe, + Install(oneshot::Sender), GetProducts(oneshot::Sender>), GetPatterns(oneshot::Sender>), GetConfig(oneshot::Sender), @@ -160,6 +161,10 @@ impl SoftwareServiceServer { self.run_solver(zypp)?; } + SoftwareAction::Install(tx) => { + tx.send(self.install(zypp)?); + } + SoftwareAction::SetResolvables { id, r#type, @@ -208,6 +213,15 @@ impl SoftwareServiceServer { Ok(()) } + // Install rpms + fn install(&self, zypp: &zypp_agama::Zypp) -> Result { + let target = "/mnt"; + zypp.switch_target(target)?; + let result = zypp.commit()?; + tracing::info!("libzypp commit ends with {}", result); + Ok(result) + } + /// Select the given product. async fn select_product(&mut self, product_id: String) -> Result<(), SoftwareServiceError> { tracing::info!("Selecting product {}", product_id); diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 7bf3ff9db2..038707c02f 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -52,6 +52,7 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result) -> Result, Error> { Ok(Json(())) } +/// Install rpms. +/// +/// +#[utoipa::path( + post, + path = "/install", + context_path = "/api/software", + responses( + (status = 200, description = "Installation succeed"), + (status = 400, description = "The D-Bus service could not perform the action +") + ), + operation_id = "software_probe" +)] +async fn install(State(state): State) -> Result, Error> { + Ok(Json(state.client.install().await?)) +} + /// Returns the proposal information. /// /// At this point, only the required space is reported. diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 12d54cc5a2..1a61918fd1 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -131,6 +131,16 @@ impl Zypp { Ok(()) } + pub fn commit(&self) -> ZyppResult { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + unsafe { + let res = zypp_agama_sys::commit(self.ptr, status_ptr); + helpers::status_to_result_void(status)?; + Ok(res) + } + } + pub fn list_repositories(&self) -> ZyppResult> { let mut repos_v = vec![]; diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index 866cb3a918..e2fd4b9261 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -10,7 +10,7 @@ clean: rm -vf *.o *.a check: - git ls-files | grep -E '\.(c|h|cxx|hxx)$' | \ + git ls-files | grep -E '\.(c|h|cxx|hxx)$$' | \ xargs --verbose clang-format --style=llvm --dry-run libagama-zypp.a: $(OBJ) @@ -20,5 +20,5 @@ libagama-zypp.a: $(OBJ) $(CXX) -c -o $@ $< $(CXXFLAGS) format: - git ls-files | grep -E '\.(c|h|cxx|hxx)$' | \ + git ls-files | grep -E '\.(c|h|cxx|hxx)$$' | \ xargs --verbose clang-format --style=llvm -i diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index 4b02cde6de..e6680670df 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -57,7 +57,15 @@ struct Zypp *init_target(const char *root, struct Status *status, /// Switch Zypp target (where to install packages to). /// @param root /// @param[out] status -void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept; +void switch_target(struct Zypp *zypp, const char *root, + struct Status *status) noexcept; + +/// Commit zypp settings and install +/// TODO: callbacks +/// @param zypp +/// @param status +/// @return true if there is no error +bool commit(struct Zypp *zypp, struct Status *status) noexcept; enum RESOLVABLE_KIND { RESOLVABLE_PRODUCT, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 7b2e9b55dc..8d1de4e07c 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -95,7 +96,8 @@ static zypp::ZYpp::Ptr zypp_ptr() { return NULL; } -void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept { +void switch_target(struct Zypp *zypp, const char *root, + struct Status *status) noexcept { const std::string root_str(root); try { zypp->zypp_pointer->initializeTarget(root_str, false); @@ -108,6 +110,19 @@ void switch_target(struct Zypp *zypp, const char *root, struct Status *status) n status->error = NULL; } +bool commit(struct Zypp *zypp, struct Status *status) noexcept { + try { + zypp::ZYppCommitPolicy policy; + zypp::ZYppCommitResult result = zypp->zypp_pointer->commit(policy); + status->state = status->STATE_SUCCEED; + status->error = NULL; + return result.noError(); + } catch (zypp::Exception &excpt) { + status->state = status->STATE_FAILED; + status->error = strdup(excpt.asUserString().c_str()); + } +} + // TODO: split init target into set of repo manager, initialize target and load // target and merge it in rust struct Zypp *init_target(const char *root, struct Status *status, diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index b482d4d1d0..3039e2ad33 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -255,6 +255,8 @@ unsafe extern "C" { ) -> *mut Zypp; #[doc = " Switch Zypp target (where to install packages to).\n @param root\n @param[out] status"] pub fn switch_target(zypp: *mut Zypp, root: *const ::std::os::raw::c_char, status: *mut Status); + #[doc = " Commit zypp settings and install\n TODO: callbacks\n @param zypp\n @param status\n @return true if there is no error"] + pub fn commit(zypp: *mut Zypp, status: *mut Status) -> bool; #[doc = " Marks resolvable for installation\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do selection. If NOT_SELECTED is used, it will be empty\n operation.\n @param[out] status (will overwrite existing contents)"] pub fn resolvable_select( zypp: *mut Zypp, From f067457ee5fe057f81a357b78cc0c07b5f5ba04d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 20:51:23 +0200 Subject: [PATCH 092/917] fix tests --- rust/agama-server/src/products.rs | 13 ++++++++++--- rust/agama-server/src/software_ng/backend/server.rs | 5 +++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index a9f4f41034..bea4190cbe 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -24,7 +24,7 @@ //! It reads the list of products from the `products.d` directory (usually, //! `/usr/share/agama/products.d`). -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use std::path::{Path, PathBuf}; @@ -123,6 +123,13 @@ impl ProductSpec { } } +fn parse_optional<'de, D>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(d).map(|x: Option<_>| x.unwrap_or_default()) +} + #[derive(Clone, Debug, Deserialize)] pub struct SoftwareSpec { installation_repositories: Vec, @@ -132,9 +139,9 @@ pub struct SoftwareSpec { pub mandatory_patterns: Vec, #[serde(default)] pub mandatory_packages: Vec, - #[serde(default)] + #[serde(deserialize_with = "parse_optional")] pub optional_patterns: Vec, - #[serde(default)] + #[serde(deserialize_with = "parse_optional")] pub optional_packages: Vec, pub base_product: String, } diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 0f01bd9a0b..66ac54d544 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -162,7 +162,8 @@ impl SoftwareServiceServer { } SoftwareAction::Install(tx) => { - tx.send(self.install(zypp)?); + tx.send(self.install(zypp)?) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; } SoftwareAction::SetResolvables { @@ -202,7 +203,7 @@ impl SoftwareServiceServer { ) -> Result<(), SoftwareServiceError> { let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); self.software_selection - .set(zypp, &id, r#type, optional, &resolvables); + .set(zypp, &id, r#type, optional, &resolvables)?; Ok(()) } From cbc70f23393b145efdac0c63fb3791fc5e60a67b Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 25 Sep 2025 21:56:11 +0200 Subject: [PATCH 093/917] log how resolvables are set --- rust/agama-server/src/software_ng/backend/server.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 66ac54d544..96c9f4b459 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -201,6 +201,7 @@ impl SoftwareServiceServer { resolvables: Vec, optional: bool, ) -> Result<(), SoftwareServiceError> { + tracing::info!("Set resolvables for {} with {:?}", id, resolvables); let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); self.software_selection .set(zypp, &id, r#type, optional, &resolvables)?; From 517e1b0410729a0f4588efed8d1589cac91d45a2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 26 Sep 2025 09:45:49 +0200 Subject: [PATCH 094/917] try to increase timeout for rpm install as workaround --- service/lib/agama/http/clients/software.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index edd118b3d0..40011d6e19 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -39,13 +39,15 @@ def probe end def propose - # TODO: implement it - post("software/propose", nil) + # it is noop, probe already do proposal + #post("software/propose", nil) end def install - # TODO: implement it - post("software/install", nil) + http = Net::HTTP::new("localhost") + # FIXME: we need to improve it as it can e.g. wait for user interaction. + http.read_timeout = 3*60*60 # set timeout to three hours for rpm installation + http.post("/api/software/install", "", headers) end def finish From 5898666de08e86e424a35f6ef71704cebadea2c3 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 26 Sep 2025 13:08:43 +0200 Subject: [PATCH 095/917] as workaround explicitelly require kernel now instead of logic to select the best one --- products.d/tumbleweed.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 4f05a5a570..834baebbbf 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -137,6 +137,8 @@ software: - apparmor mandatory_packages: - NetworkManager + # TODO: dynamically propose kernel in agama code + - kernel-default - openSUSE-repos-Tumbleweed - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model optional_packages: null From 9e2723c91abcd9b2b38030c88ce094d9f1a7c110 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 26 Sep 2025 14:51:26 +0200 Subject: [PATCH 096/917] adapt tests --- service/lib/agama/http/clients/software.rb | 6 +++--- service/test/agama/software/manager_test.rb | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 40011d6e19..dbb56e1922 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -40,13 +40,13 @@ def probe def propose # it is noop, probe already do proposal - #post("software/propose", nil) + # post("software/propose", nil) end def install - http = Net::HTTP::new("localhost") + http = Net::HTTP.new("localhost") # FIXME: we need to improve it as it can e.g. wait for user interaction. - http.read_timeout = 3*60*60 # set timeout to three hours for rpm installation + http.read_timeout = 3 * 60 * 60 # set timeout to three hours for rpm installation http.post("/api/software/install", "", headers) end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 407193a2f0..b1d2daa8be 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -342,7 +342,8 @@ .with("agama", :pattern, [], { optional: true }) expect(proposal).to receive(:set_resolvables) .with("agama", :package, [ - "NetworkManager", "openSUSE-repos-Tumbleweed", "sudo-policy-wheel-auth-self" + "NetworkManager", "kernel-default", + "openSUSE-repos-Tumbleweed", "sudo-policy-wheel-auth-self" ]) expect(proposal).to receive(:set_resolvables) .with("agama", :package, [], { optional: true }) From bd8a623f7a8e0282a8bbe7c27c8be11f6640aba0 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 29 Sep 2025 15:01:05 +0200 Subject: [PATCH 097/917] Apply suggestions from code review Co-authored-by: Martin Vidner --- rust/agama-server/src/software_ng/web.rs | 2 +- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 038707c02f..3964e21c17 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -174,7 +174,7 @@ async fn probe(State(state): State) -> Result, Error> { (status = 400, description = "The D-Bus service could not perform the action ") ), - operation_id = "software_probe" + operation_id = "software_install" )] async fn install(State(state): State) -> Result, Error> { Ok(Json(state.client.install().await?)) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 8d1de4e07c..756e0022a7 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -100,7 +100,7 @@ void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept { const std::string root_str(root); try { - zypp->zypp_pointer->initializeTarget(root_str, false); + zypp->zypp_pointer->initializeTarget(root_str, false /* rebuild rpmdb: no */); } catch (zypp::Exception &excpt) { status->state = status->STATE_FAILED; status->error = strdup(excpt.asUserString().c_str()); From bc3010c044d33f8a0522785473983d389c75a08f Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 29 Sep 2025 17:03:38 +0200 Subject: [PATCH 098/917] changes from review --- .../src/software_ng/backend/server.rs | 21 +++++++++++++++---- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 96c9f4b459..13268be016 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -201,7 +201,13 @@ impl SoftwareServiceServer { resolvables: Vec, optional: bool, ) -> Result<(), SoftwareServiceError> { - tracing::info!("Set resolvables for {} with {:?}", id, resolvables); + tracing::info!( + "Set resolvables for {} with type {} optional {} and list {:?}", + id, + r#type, + optional, + resolvables + ); let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); self.software_selection .set(zypp, &id, r#type, optional, &resolvables)?; @@ -255,8 +261,17 @@ impl SoftwareServiceServer { }) .map_err(SoftwareServiceError::LoadSourcesFailed)?; + self.select_product_software(zypp, product)?; + + Ok(()) + } + + fn select_product_software( + &mut self, + zypp: &zypp_agama::Zypp, + product: ProductSpec, + ) -> Result<(), SoftwareServiceError> { let installer_id_string = "installer".to_string(); - // select product self.set_resolvables( zypp, installer_id_string.clone(), @@ -264,7 +279,6 @@ impl SoftwareServiceServer { vec![product.software.base_product.clone()], false, )?; - // select packages and patterns from product self.set_resolvables( zypp, installer_id_string.clone(), @@ -293,7 +307,6 @@ impl SoftwareServiceServer { product.software.optional_patterns, true, )?; - Ok(()) } diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 756e0022a7..f3f589d778 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -104,6 +104,7 @@ void switch_target(struct Zypp *zypp, const char *root, } catch (zypp::Exception &excpt) { status->state = status->STATE_FAILED; status->error = strdup(excpt.asUserString().c_str()); + return; } status->state = status->STATE_SUCCEED; @@ -120,6 +121,7 @@ bool commit(struct Zypp *zypp, struct Status *status) noexcept { } catch (zypp::Exception &excpt) { status->state = status->STATE_FAILED; status->error = strdup(excpt.asUserString().c_str()); + return false; } } From 527e76a2189b7bc7d69f623fef1c5501e5a7eca2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 29 Sep 2025 17:29:43 +0200 Subject: [PATCH 099/917] changes from review (forgotten file) --- service/lib/agama/http/clients/software.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index dbb56e1922..95f6c53707 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -47,7 +47,11 @@ def install http = Net::HTTP.new("localhost") # FIXME: we need to improve it as it can e.g. wait for user interaction. http.read_timeout = 3 * 60 * 60 # set timeout to three hours for rpm installation - http.post("/api/software/install", "", headers) + response = http.post("/api/software/install", "", headers) + + return unless response.is_a?(Net::HTTPClientError) + + @logger.warn "server returned #{response.code} with body: #{response.body}" end def finish From b3d5f315fde9022bdd0065e69230cf2603abeeb9 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 29 Sep 2025 21:44:31 +0200 Subject: [PATCH 100/917] use container also for clippy --- .github/workflows/ci-rust.yml | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index f13fba1955..5a04811228 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -58,8 +58,6 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Rust toolchain run: | @@ -74,30 +72,42 @@ jobs: timeout-minutes: 20 runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust + container: + image: registry.opensuse.org/opensuse/tumbleweed:latest + options: --security-opt seccomp=unconfined steps: + - name: Configure and refresh repositories + # disable unused repositories to have faster refresh + run: zypper modifyrepo -d repo-non-oss repo-openh264 repo-update && ( zypper ref || zypper ref || zypper ref ) + + - name: Install required packages + run: zypper --non-interactive install + clang-devel + libzypp-devel + gcc-c++ + git + libopenssl-3-devel + make + openssl-3 + pam-devel + rustup + + - name: Configure git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Git Checkout uses: actions/checkout@v4 - with: - submodules: recursive + + - name: Install Rust toolchains + run: rustup toolchain install stable - name: Rust toolchain run: | rustup show cargo --version - - name: Install packages - run: | - sudo apt-get update - sudo apt-get -y install libclang-18-dev libpam0g-dev - - - name: Installed packages - run: apt list --installed - - name: Rust cache uses: actions/cache@v4 with: @@ -108,6 +118,7 @@ jobs: - name: Run clippy linter run: cargo clippy + working-directory: ./rust tests: # the default timeout is 6 hours, that's too much if the job gets stuck @@ -152,8 +163,6 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Rust toolchains run: rustup toolchain install stable @@ -253,8 +262,6 @@ jobs: - name: Git Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Rust toolchains run: rustup toolchain install stable From 70fbe94f4380fdf32cec9283ca24f59dd16a8d48 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 29 Sep 2025 22:46:18 +0200 Subject: [PATCH 101/917] use helper for status to make code a bit shorter and readable --- .../c-layer/internal/helpers.hxx | 11 +++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 74 +++++++------------ 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx index 197e4227af..8e514fbebd 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -9,4 +9,15 @@ fprintf(stderr, __VA_ARGS__); \ abort() +#define STATUS_OK(status) ({\ + status->state = status->STATE_SUCCEED;\ + status->error = NULL;\ +}) + +#define STATUS_EXCEPT(status, excpt) ({\ + status->state = status->STATE_FAILED;\ + status->error = strdup(excpt.asUserString().c_str());\ +}) + + #endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index f3f589d778..b4fbc14c13 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -102,25 +102,21 @@ void switch_target(struct Zypp *zypp, const char *root, try { zypp->zypp_pointer->initializeTarget(root_str, false /* rebuild rpmdb: no */); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); return; } - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } bool commit(struct Zypp *zypp, struct Status *status) noexcept { try { zypp::ZYppCommitPolicy policy; zypp::ZYppCommitResult result = zypp->zypp_pointer->commit(policy); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return result.noError(); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); return false; } } @@ -161,14 +157,12 @@ struct Zypp *init_target(const char *root, struct Status *status, progress("Reading Installed Packages", 1, 2, user_data); zypp->zypp_pointer->target()->load(); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); the_zypp.zypp_pointer = NULL; return NULL; } - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return zypp; } @@ -244,8 +238,7 @@ void resolvable_select(struct Zypp *_zypp, const char *name, return; } - status->state = Status::STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); auto value = transactby_from(who); selectable->setToInstall(value); } @@ -271,8 +264,7 @@ void resolvable_unselect(struct Zypp *_zypp, const char *name, auto value = transactby_from(who); selectable->unset(value); - status->state = Status::STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } struct PatternInfos get_patterns_info(struct Zypp *_zypp, @@ -321,8 +313,7 @@ struct PatternInfos get_patterns_info(struct Zypp *_zypp, result.size++; }; - status->state = Status::STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return result; } @@ -340,12 +331,10 @@ void free_pattern_infos(const struct PatternInfos *infos) noexcept { bool run_solver(struct Zypp *zypp, struct Status *status) noexcept { try { - status->state = Status::STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return zypp->zypp_pointer->resolver()->resolvePool(); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); return false; // do not matter much as status indicate failure } } @@ -371,12 +360,10 @@ void refresh_repository(struct Zypp *zypp, const char *alias, zypp->repo_manager->refreshMetadata( zypp_repo, zypp::RepoManager::RawMetadataRefreshPolicy::RefreshIfNeeded); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); unset_zypp_download_callbacks(); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); unset_zypp_download_callbacks(); // TODO: we can add C++ final action helper // if it is more common } @@ -397,11 +384,9 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, zypp_repo.setAlias(alias); zypp->repo_manager->addRepository(zypp_repo, zypp_callback); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); } } @@ -420,11 +405,9 @@ void remove_repository(struct Zypp *zypp, const char *alias, // match correct repo zypp->repo_manager->removeRepository(zypp_repo, zypp_callback); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); } } @@ -452,8 +435,7 @@ struct RepositoryList list_repositories(struct Zypp *zypp, } struct RepositoryList result = {static_cast(size), repos}; - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return result; } @@ -476,11 +458,9 @@ void load_repository_cache(struct Zypp *zypp, const char *alias, // NOTE: loadFromCache has an optional `progress` parameter but it ignores // it anyway zypp->repo_manager->loadFromCache(zypp_repo); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); } } @@ -505,11 +485,9 @@ void build_repository_cache(struct Zypp *zypp, const char *alias, auto progress = create_progress_callback(callback, user_data); zypp->repo_manager->buildCache( zypp_repo, zypp::RepoManagerFlags::BuildIfNeeded, progress); - status->state = status->STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); } catch (zypp::Exception &excpt) { - status->state = status->STATE_FAILED; - status->error = strdup(excpt.asUserString().c_str()); + STATUS_EXCEPT(status, excpt); } } @@ -523,11 +501,9 @@ void import_gpg_key(struct Zypp *zypp, const char *const pathname, // will trigger "Trust this?" callbacks. bool trusted = true; zypp->zypp_pointer->keyRing()->importKey(key, trusted); - status->state = status->STATE_SUCCEED; - status->error = NULL; - } catch (std::exception e) { - status->state = status->STATE_FAILED; - status->error = strdup(e.what()); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); } } } From bf1a9ed32e647b4082afeaf415c1b5af857cd3fc Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 30 Sep 2025 11:06:22 +0200 Subject: [PATCH 102/917] Update rust/agama-lib/src/software/model.rs Co-authored-by: Martin Vidner --- rust/agama-lib/src/software/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index ddb951120e..216abbfb63 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -123,7 +123,7 @@ impl SoftwareSelection { } } -/* Fix tests with real mock of libzypp +/* TODO: Fix tests with real mock of libzypp #[cfg(test)] mod tests { use super::*; From a65672c7288b84fa61252318227ce7f591e326c6 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 2 Oct 2025 10:02:26 +0200 Subject: [PATCH 103/917] add disabling of local repositories --- rust/Cargo.lock | 17 +++++----- .../src/software_ng/backend/client.rs | 5 +++ .../src/software_ng/backend/server.rs | 33 +++++++++++++++++-- rust/agama-server/src/software_ng/web.rs | 19 +++++++++++ rust/zypp-agama/Cargo.toml | 1 + rust/zypp-agama/src/lib.rs | 26 +++++++++++++++ .../c-layer/include/repository.h | 6 ++++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 17 ++++++++++ .../zypp-agama/zypp-agama-sys/src/bindings.rs | 6 ++++ 9 files changed, 120 insertions(+), 10 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ddc25b0081..f3560ed58d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1563,9 +1563,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2214,9 +2214,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -3093,9 +3093,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -4696,9 +4696,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -5550,6 +5550,7 @@ dependencies = [ name = "zypp-agama" version = "0.1.0" dependencies = [ + "url", "zypp-agama-sys", ] diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index bd4668cc1f..27d687074a 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -81,6 +81,11 @@ impl SoftwareServiceClient { Ok(rx.await?) } + pub async fn finish(&self) -> Result<(), SoftwareServiceError> { + self.actions.send(SoftwareAction::Finish)?; + Ok(()) + } + pub fn set_resolvables( &self, id: &str, diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 13268be016..c5e3fbd018 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -43,6 +43,7 @@ const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; pub enum SoftwareAction { Probe, Install(oneshot::Sender), + Finish, GetProducts(oneshot::Sender>), GetPatterns(oneshot::Sender>), GetConfig(oneshot::Sender), @@ -71,8 +72,6 @@ pub struct SoftwareServiceServer { software_selection: SoftwareSelection, } -const SERVICE_NAME: &str = "org.opensuse.Agama.Software1"; - impl SoftwareServiceServer { /// Starts the software service loop and returns a client. /// @@ -166,6 +165,10 @@ impl SoftwareServiceServer { .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; } + SoftwareAction::Finish => { + self.finish(zypp).await?; + } + SoftwareAction::SetResolvables { id, r#type, @@ -266,6 +269,32 @@ impl SoftwareServiceServer { Ok(()) } + async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + self.remove_dud_repo(zypp)?; + self.disable_local_repos(zypp)?; + Ok(()) + } + + fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + const DUD_NAME: &str = "AgamaDriverUpdate"; + let repos = zypp.list_repositories()?; + let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); + if let Some(repo) = repo { + zypp.remove_repository(&repo.alias, |_,_| true)?; + } + Ok(()) + } + + fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false) ); + for r in repos { + zypp.disable_repository(&r.alias)?; + } + Ok(()) + } + fn select_product_software( &mut self, zypp: &zypp_agama::Zypp, diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 3964e21c17..a8560cc184 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -53,6 +53,7 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result) -> Result, Error Ok(Json(state.client.install().await?)) } +/// Post install phase to do internal cleaning and configs. +/// +/// +#[utoipa::path( + post, + path = "/finish", + context_path = "/api/software", + responses( + (status = 200, description = "Finish step finished"), + (status = 400, description = "The D-Bus service could not perform the action +") + ), + operation_id = "software_finish" +)] +async fn finish(State(state): State) -> Result, Error> { + Ok(Json(state.client.finish().await?)) +} + /// Returns the proposal information. /// /// At this point, only the required space is reported. diff --git a/rust/zypp-agama/Cargo.toml b/rust/zypp-agama/Cargo.toml index 868ba76ffa..a3df587f59 100644 --- a/rust/zypp-agama/Cargo.toml +++ b/rust/zypp-agama/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] zypp-agama-sys = { path="./zypp-agama-sys" } +url = "2.5.7" diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 1a61918fd1..287fe5c926 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -26,6 +26,21 @@ pub struct Repository { pub user_name: String, } +impl Repository { + /// check if url points to local repository. + /// Can be None if url is invalid + pub fn is_local(&self) -> Result { + let url = url::Url::parse(&self.url)?; + let result = url.scheme() == "cd" || + url.scheme() == "dvd" || + url.scheme() == "dir" || + url.scheme() == "hd" || + url.scheme() == "iso" || + url.scheme() == "file"; + Ok(result) + } +} + // TODO: should we add also e.g. serd serializers here? #[derive(Debug)] pub struct PatternInfo { @@ -307,6 +322,17 @@ impl Zypp { } } + pub fn disable_repository(&self, alias: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _ as *mut Status; + let c_alias = CString::new(alias).unwrap(); + zypp_agama_sys::disable_repository(self.ptr, c_alias.as_ptr(), status_ptr); + + helpers::status_to_result_void(status) + } + } + pub fn remove_repository(&self, alias: &str, progress: F) -> ZyppResult<()> where F: FnMut(i64, String) -> bool, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h index ab7a21972e..903ebef5c2 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h @@ -42,6 +42,12 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept; +/// Disable repository in repo manager +/// @param zypp see \ref init_target +/// @param alias have to be unique +void disable_repository(struct Zypp *zypp, const char *alias, + struct Status *status) noexcept; + /// Removes repository from repo manager /// @param zypp see \ref init_target /// @param alias have to be unique diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index b4fbc14c13..63f9438dd2 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -390,6 +390,23 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, } } +void disable_repository(struct Zypp *zypp, const char *alias, + struct Status *status ) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo r_info = zypp->repo_manager->getRepo(alias); + r_info.setEnabled(false); + zypp->repo_manager->modifyRepository(r_info); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + void remove_repository(struct Zypp *zypp, const char *alias, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 3039e2ad33..787533167f 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -301,6 +301,12 @@ unsafe extern "C" { callback: ZyppProgressCallback, user_data: *mut ::std::os::raw::c_void, ); + #[doc = " Disable repository in repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique"] + pub fn disable_repository( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + status: *mut Status, + ); #[doc = " Removes repository from repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] pub fn remove_repository( zypp: *mut Zypp, From 9472520c9ff998e6c8e9aff7e2ab574ebb044be8 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 2 Oct 2025 12:03:50 +0200 Subject: [PATCH 104/917] add mockups for registation finish and modify zypp conf --- rust/agama-server/src/software_ng/backend/server.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index c5e3fbd018..bc6e26757b 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -272,6 +272,8 @@ impl SoftwareServiceServer { async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { self.remove_dud_repo(zypp)?; self.disable_local_repos(zypp)?; + self.registration_finish()?; + self.modify_zypp_conf()?; Ok(()) } @@ -295,6 +297,16 @@ impl SoftwareServiceServer { Ok(()) } + fn registration_finish(&self) -> Result<(), SoftwareServiceError> { + // TODO: implement when registration is ready + Ok(()) + } + + fn modify_zypp_conf(&self) -> Result<(), SoftwareServiceError> { + // TODO: implement when requireOnly is implemented + Ok(()) + } + fn select_product_software( &mut self, zypp: &zypp_agama::Zypp, From 14a91ee4223862b13d154e2a6ee2d369b5b25cd2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 2 Oct 2025 14:33:08 +0200 Subject: [PATCH 105/917] implement change of full medium repository --- .../src/software_ng/backend/server.rs | 17 +++++++++-- rust/zypp-agama/src/lib.rs | 29 +++++++++++++++---- .../c-layer/include/repository.h | 9 +++++- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 18 ++++++++++++ .../zypp-agama/zypp-agama-sys/src/bindings.rs | 9 +++++- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index bc6e26757b..9c52e4c5ab 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -274,6 +274,19 @@ impl SoftwareServiceServer { self.disable_local_repos(zypp)?; self.registration_finish()?; self.modify_zypp_conf()?; + self.modify_full_repo(zypp)?; + Ok(()) + } + + fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos + .iter() + .filter(|r| r.url.starts_with("dvd:/install?devices=")); + for r in repos { + zypp.set_repository_url(&r.alias, "dvd:/install")?; + } Ok(()) } @@ -282,7 +295,7 @@ impl SoftwareServiceServer { let repos = zypp.list_repositories()?; let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); if let Some(repo) = repo { - zypp.remove_repository(&repo.alias, |_,_| true)?; + zypp.remove_repository(&repo.alias, |_, _| true)?; } Ok(()) } @@ -290,7 +303,7 @@ impl SoftwareServiceServer { fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { let repos = zypp.list_repositories()?; // if url is invalid, then do not disable it and do not touch it - let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false) ); + let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false)); for r in repos { zypp.disable_repository(&r.alias)?; } diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 287fe5c926..5387b7fb9a 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -31,12 +31,12 @@ impl Repository { /// Can be None if url is invalid pub fn is_local(&self) -> Result { let url = url::Url::parse(&self.url)?; - let result = url.scheme() == "cd" || - url.scheme() == "dvd" || - url.scheme() == "dir" || - url.scheme() == "hd" || - url.scheme() == "iso" || - url.scheme() == "file"; + let result = url.scheme() == "cd" + || url.scheme() == "dvd" + || url.scheme() == "dir" + || url.scheme() == "hd" + || url.scheme() == "iso" + || url.scheme() == "file"; Ok(result) } } @@ -333,6 +333,23 @@ impl Zypp { } } + pub fn set_repository_url(&self, alias: &str, url: &str) -> ZyppResult<()> { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _ as *mut Status; + let c_alias = CString::new(alias).unwrap(); + let c_url = CString::new(url).unwrap(); + zypp_agama_sys::set_repository_url( + self.ptr, + c_alias.as_ptr(), + c_url.as_ptr(), + status_ptr, + ); + + helpers::status_to_result_void(status) + } + } + pub fn remove_repository(&self, alias: &str, progress: F) -> ZyppResult<()> where F: FnMut(i64, String) -> bool, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h index 903ebef5c2..a91ae4f6c7 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h @@ -44,10 +44,17 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, /// Disable repository in repo manager /// @param zypp see \ref init_target -/// @param alias have to be unique +/// @param alias identifier of repository void disable_repository(struct Zypp *zypp, const char *alias, struct Status *status) noexcept; +/// Changes url of given repository +/// @param zypp see \ref init_target +/// @param alias identifier of repository +/// @param alias have to be unique +void set_repository_url(struct Zypp *zypp, const char *alias, + const char *url, struct Status *status) noexcept; + /// Removes repository from repo manager /// @param zypp see \ref init_target /// @param alias have to be unique diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 63f9438dd2..50ef7b664e 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -407,6 +407,24 @@ void disable_repository(struct Zypp *zypp, const char *alias, } } +void set_repository_url(struct Zypp *zypp, const char *alias, + const char *url, struct Status *status ) noexcept { + if (zypp->repo_manager == NULL) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Repo manager is not initialized."); + return; + } + try { + zypp::RepoInfo r_info = zypp->repo_manager->getRepo(alias); + zypp::Url z_url(url); + r_info.setBaseUrl(z_url); + zypp->repo_manager->modifyRepository(r_info); + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + void remove_repository(struct Zypp *zypp, const char *alias, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 787533167f..04fd4f2a33 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -301,12 +301,19 @@ unsafe extern "C" { callback: ZyppProgressCallback, user_data: *mut ::std::os::raw::c_void, ); - #[doc = " Disable repository in repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique"] + #[doc = " Disable repository in repo manager\n @param zypp see \\ref init_target\n @param alias identifier of repository"] pub fn disable_repository( zypp: *mut Zypp, alias: *const ::std::os::raw::c_char, status: *mut Status, ); + #[doc = " Changes url of given repository\n @param zypp see \\ref init_target\n @param alias identifier of repository\n @param alias have to be unique"] + pub fn set_repository_url( + zypp: *mut Zypp, + alias: *const ::std::os::raw::c_char, + url: *const ::std::os::raw::c_char, + status: *mut Status, + ); #[doc = " Removes repository from repo manager\n @param zypp see \\ref init_target\n @param alias have to be unique\n @param[out] status (will overwrite existing contents)\n @param callback pointer to function with callback or NULL\n @param user_data"] pub fn remove_repository( zypp: *mut Zypp, From 464f3e492348eb995653aa2c1be28eaccc0f853a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 2 Oct 2025 15:10:04 +0200 Subject: [PATCH 106/917] changes from review --- rust/agama-server/src/software_ng/web.rs | 16 ++++++++-------- rust/zypp-agama/src/lib.rs | 18 +++++++++--------- .../zypp-agama-sys/c-layer/include/lib.h | 6 ++++++ .../c-layer/include/repository.h | 6 +++--- .../c-layer/internal/helpers.hxx | 19 ++++++++++--------- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 19 +++++++++++++++---- .../zypp-agama/zypp-agama-sys/src/bindings.rs | 2 ++ 7 files changed, 53 insertions(+), 33 deletions(-) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index a8560cc184..9eadddaf3b 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -111,7 +111,7 @@ async fn get_patterns(State(state): State) -> Result) -> Result, Error> { @@ -153,7 +153,7 @@ async fn get_config(State(state): State) -> Result) -> Result, Error> { context_path = "/api/software", responses( (status = 200, description = "Installation succeed"), - (status = 400, description = "The D-Bus service could not perform the action + (status = 400, description = "The service could not perform the action ") ), operation_id = "software_install" @@ -190,7 +190,7 @@ async fn install(State(state): State) -> Result, Error context_path = "/api/software", responses( (status = 200, description = "Finish step finished"), - (status = 400, description = "The D-Bus service could not perform the action + (status = 400, description = "The service could not perform the action ") ), operation_id = "software_finish" @@ -266,7 +266,7 @@ async fn software_issues(State(state): State) -> Result Result { - let url = url::Url::parse(&self.url)?; - let result = url.scheme() == "cd" - || url.scheme() == "dvd" - || url.scheme() == "dir" - || url.scheme() == "hd" - || url.scheme() == "iso" - || url.scheme() == "file"; - Ok(result) + pub fn is_local(&self) -> Result { + unsafe { + let c_url = CString::new(self.url.as_str()).unwrap(); + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let result = zypp_agama_sys::is_local_url(c_url.as_ptr(), status_ptr); + status_to_result_void(status).map(|_| result) + } } } diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index e6680670df..c8cd3bda3e 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -149,6 +149,12 @@ void free_pattern_infos(const struct PatternInfos *infos) noexcept; void import_gpg_key(struct Zypp *zypp, const char *const pathname, struct Status *status) noexcept; +/// check if url has local schema +/// @param url url to check +/// @param[out] status (will overwrite existing contents) +/// @return true if url is local, for invalid url status is set to error +bool is_local_url(const char *url, struct Status *status) noexcept; + /// Runs solver /// @param zypp see \ref init_target /// @param[out] status (will overwrite existing contents) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h index a91ae4f6c7..1c8eb55689 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/repository.h @@ -46,14 +46,14 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, /// @param zypp see \ref init_target /// @param alias identifier of repository void disable_repository(struct Zypp *zypp, const char *alias, - struct Status *status) noexcept; + struct Status *status) noexcept; /// Changes url of given repository /// @param zypp see \ref init_target /// @param alias identifier of repository /// @param alias have to be unique -void set_repository_url(struct Zypp *zypp, const char *alias, - const char *url, struct Status *status) noexcept; +void set_repository_url(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status) noexcept; /// Removes repository from repo manager /// @param zypp see \ref init_target diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx index 8e514fbebd..f0d55e4ae9 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -9,15 +9,16 @@ fprintf(stderr, __VA_ARGS__); \ abort() -#define STATUS_OK(status) ({\ - status->state = status->STATE_SUCCEED;\ - status->error = NULL;\ -}) - -#define STATUS_EXCEPT(status, excpt) ({\ - status->state = status->STATE_FAILED;\ - status->error = strdup(excpt.asUserString().c_str());\ -}) +#define STATUS_OK(status) \ + ({ \ + status->state = status->STATE_SUCCEED; \ + status->error = NULL; \ + }) +#define STATUS_EXCEPT(status, excpt) \ + ({ \ + status->state = status->STATE_FAILED; \ + status->error = strdup(excpt.asUserString().c_str()); \ + }) #endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 50ef7b664e..debc62a29f 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -100,7 +100,8 @@ void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept { const std::string root_str(root); try { - zypp->zypp_pointer->initializeTarget(root_str, false /* rebuild rpmdb: no */); + zypp->zypp_pointer->initializeTarget(root_str, + false /* rebuild rpmdb: no */); } catch (zypp::Exception &excpt) { STATUS_EXCEPT(status, excpt); return; @@ -369,6 +370,16 @@ void refresh_repository(struct Zypp *zypp, const char *alias, } } +bool is_local_url(const char *url, struct Status *status) noexcept { + try { + zypp::Url z_url(url); + STATUS_OK(status); + return z_url.schemeIsLocal(); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } +} + void add_repository(struct Zypp *zypp, const char *alias, const char *url, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { @@ -391,7 +402,7 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, } void disable_repository(struct Zypp *zypp, const char *alias, - struct Status *status ) noexcept { + struct Status *status) noexcept { if (zypp->repo_manager == NULL) { status->state = status->STATE_FAILED; status->error = strdup("Internal Error: Repo manager is not initialized."); @@ -407,8 +418,8 @@ void disable_repository(struct Zypp *zypp, const char *alias, } } -void set_repository_url(struct Zypp *zypp, const char *alias, - const char *url, struct Status *status ) noexcept { +void set_repository_url(struct Zypp *zypp, const char *alias, const char *url, + struct Status *status) noexcept { if (zypp->repo_manager == NULL) { status->state = status->STATE_FAILED; status->error = strdup("Internal Error: Repo manager is not initialized."); diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 04fd4f2a33..a85a1a9da8 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -285,6 +285,8 @@ unsafe extern "C" { pathname: *const ::std::os::raw::c_char, status: *mut Status, ); + #[doc = " check if url has local schema\n @param url url to check\n @param[out] status (will overwrite existing contents)\n @return true if url is local, for invalid url status is set to error"] + pub fn is_local_url(url: *const ::std::os::raw::c_char, status: *mut Status) -> bool; #[doc = " Runs solver\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)\n @return true if solver pass and false if it found some dependency issues"] pub fn run_solver(zypp: *mut Zypp, status: *mut Status) -> bool; #[doc = " the last call that will free all pointers to zypp holded by agama"] From c4cd867a5476d59d695dc04a10d6de6121d0f5c5 Mon Sep 17 00:00:00 2001 From: Martin Vidner Date: Thu, 2 Oct 2025 15:58:28 +0200 Subject: [PATCH 107/917] Don't hide c-layer warnings By default, `cargo build` hides the warnings from commands invoked by build.rs. One option to show them is to run `cargo build -vv` but that is too verbose. The better option is to prefix the output of the interesting commands with "cargo::warning=" Also, fix the actual bug warned about. --- rust/zypp-agama/zypp-agama-sys/build.rs | 14 +++++++++++++- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/rust/zypp-agama/zypp-agama-sys/build.rs b/rust/zypp-agama/zypp-agama-sys/build.rs index 7cb114e781..0dbe0ea1f5 100644 --- a/rust/zypp-agama/zypp-agama-sys/build.rs +++ b/rust/zypp-agama/zypp-agama-sys/build.rs @@ -19,12 +19,24 @@ fn update_file(file_path: &str, contents: &str) { } } +const WARNING_PREFIX: &str = "cargo::warning="; +// For each line in *stderr*, println! the line +// prefixed with WARNING_PREFIX +fn show_warnings(stderr: Vec) { + let stderr_str = String::from_utf8_lossy(&stderr); + for line in stderr_str.lines() { + println!("{}{}", WARNING_PREFIX, line); + } +} + fn main() { let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let mut cmd = Command::new("make"); cmd.arg("-C"); cmd.arg(Path::new(&manifest_dir).join("c-layer").as_os_str()); - let result = cmd.status().expect("Failed to start make process"); + let output = cmd.output().expect("Failed to start make process"); + let result = output.status; + show_warnings(output.stderr); if !result.success() { panic!("Building C library failed.\n"); } diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index debc62a29f..5956310fb2 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -377,6 +377,7 @@ bool is_local_url(const char *url, struct Status *status) noexcept { return z_url.schemeIsLocal(); } catch (zypp::Exception &excpt) { STATUS_EXCEPT(status, excpt); + return false; } } From dbbea5f8c66c41888129b57fa3f1535d86b6726a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 3 Oct 2025 10:50:46 +0200 Subject: [PATCH 108/917] Apply suggestions from code review Co-authored-by: Martin Vidner --- rust/zypp-agama/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index c38b3fd7f0..9158faf5f3 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -1,7 +1,6 @@ use std::{ ffi::CString, os::raw::{c_char, c_uint, c_void}, - result, sync::Mutex, }; @@ -29,7 +28,7 @@ pub struct Repository { impl Repository { /// check if url points to local repository. - /// Can be None if url is invalid + /// Can be Err if url is invalid pub fn is_local(&self) -> Result { unsafe { let c_url = CString::new(self.url.as_str()).unwrap(); @@ -325,7 +324,7 @@ impl Zypp { pub fn disable_repository(&self, alias: &str) -> ZyppResult<()> { unsafe { let mut status: Status = Status::default(); - let status_ptr = &mut status as *mut _ as *mut Status; + let status_ptr = &mut status as *mut _; let c_alias = CString::new(alias).unwrap(); zypp_agama_sys::disable_repository(self.ptr, c_alias.as_ptr(), status_ptr); @@ -336,7 +335,7 @@ impl Zypp { pub fn set_repository_url(&self, alias: &str, url: &str) -> ZyppResult<()> { unsafe { let mut status: Status = Status::default(); - let status_ptr = &mut status as *mut _ as *mut Status; + let status_ptr = &mut status as *mut _; let c_alias = CString::new(alias).unwrap(); let c_url = CString::new(url).unwrap(); zypp_agama_sys::set_repository_url( From 9cc8b2dba74ed3d15e3a33149601c28ceac66c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 13:27:31 +0100 Subject: [PATCH 109/917] feat: add a new config-based API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request introduces two big changes: * The implementation of the new HTTP API for the `l10n` module. Bear in mind that the specification is still a work in progress. * The new architecture for Agama services. The `l10n` module has been heavily reorganized to: * support the new API. * make it easier to maintain in the future. ## The new HTTP API Here is a brief explanation of how the new API should look like. For further details, check [the ongoing description](https://gist.github.com/joseivanlopez/ed9f4f87b214ff60d52a5f6c3897dc9d). In general, it is designed around 3 main concepts: * system: represents the current status of the running system. It is not only about the configuration, but it can offer additional information (e.g., the list of supported languages, products, hardware information, etc.). * config: represents the user configuration for the target system. * proposal: represents what is going to happen during installation. Here is the API summary (check whether we have done some changes): ~~~ GET /system GET /extended_config GET /extended_config/{scope} GET PUT PATCH /config GET PUT PATCH /config/{scope} GET POST PATCH /questions GET /proposal GET /state GET /issues POST /action ~~~ This new design allows us to drastically reduce the complexity and the amount of code needed by the old API. Of course, we still need to extend it to support more use cases (software, DASD, progress, etc.) but this one is a step in the direction we want to take. ## The new architecture Coming from a set of separate single-thread D-Bus services, we had have time to identify a few limitations that we would like to overcome in this redesign. Given that we expect several process to collaborate (holding configuration, listening for system changes, etc.) we want to implement something similar to an *very simplified* [actor model](https://en.wikipedia.org/wiki/Actor_model). With this idea in mind, each Agama module (localization, software, etc.) will live in their own package (e.g., `agama-l10n`, `agama-software`, etc.) implementing the corresponding actors/services. `agama-utils` contains a `module` with some utilities to implement this pattern. agama-new-architecture ## Actors implementation We started using the enum-based approach described in [Actors in Tokio](https://www.reddit.com/r/rust/comments/100s26c/actors_with_tokio_a_lesson_in_ownership_rustlab/) (really great). However, after some discussion, we decided to go with a trait-based approach. See [the actors module for further information](https://github.com/agama-project/agama/blob/8d2f028813007ab9755fc2ce6660e26d3be3d9ee/rust/agama-utils/src/actors.rs). --------- Co-authored-by: Knut Anderssen Co-authored-by: José Iván López González Co-authored-by: David Díaz González --- doc/http_api.md | 92 +++-- rust/Cargo.lock | 200 +++++++--- rust/Cargo.toml | 3 +- rust/agama-l10n/Cargo.toml | 29 ++ .../settings.rs => agama-l10n/src/config.rs} | 35 +- rust/agama-l10n/src/dbus.rs | 81 ++++ .../model.rs => agama-l10n/src/event.rs} | 30 +- rust/agama-l10n/src/extended_config.rs | 57 +++ .../src/l10n => agama-l10n/src}/helpers.rs | 2 +- rust/agama-l10n/src/lib.rs | 64 ++++ rust/agama-l10n/src/message.rs | 99 +++++ rust/agama-l10n/src/model.rs | 215 +++++++++++ .../l10n => agama-l10n/src}/model/keyboard.rs | 7 + .../l10n => agama-l10n/src}/model/locale.rs | 30 +- .../l10n => agama-l10n/src}/model/timezone.rs | 12 +- rust/agama-l10n/src/monitor.rs | 90 +++++ rust/agama-l10n/src/proposal.rs | 53 +++ rust/agama-l10n/src/service.rs | 165 +++++++++ rust/agama-l10n/src/start.rs | 251 +++++++++++++ rust/agama-l10n/src/system_info.rs | 64 ++++ rust/agama-lib/Cargo.toml | 1 + rust/agama-lib/src/config.rs | 21 ++ rust/agama-lib/src/http/event.rs | 11 +- rust/agama-lib/src/install_settings.rs | 13 +- rust/agama-lib/src/lib.rs | 3 +- .../agama-lib/src/localization/http_client.rs | 49 --- rust/agama-lib/src/localization/store.rs | 175 --------- rust/agama-lib/src/product.rs | 2 +- rust/agama-lib/src/product/settings.rs | 4 +- rust/agama-lib/src/scripts/settings.rs | 2 +- rust/agama-lib/src/security/settings.rs | 2 +- rust/agama-lib/src/software.rs | 2 +- rust/agama-lib/src/software/settings.rs | 6 +- rust/agama-lib/src/storage/settings/dasd.rs | 2 +- rust/agama-lib/src/storage/settings/zfcp.rs | 2 +- rust/agama-lib/src/store.rs | 11 - rust/agama-lib/src/users.rs | 2 +- rust/agama-lib/src/users/settings.rs | 8 +- rust/agama-locale-data/Cargo.toml | 1 - .../l10n => agama-locale-data/src}/error.rs | 24 +- rust/agama-locale-data/src/lib.rs | 75 ++-- rust/agama-locale-data/src/locale.rs | 54 ++- rust/agama-network/src/settings.rs | 2 +- rust/agama-server/Cargo.toml | 7 +- rust/agama-server/src/agama-dbus-server.rs | 10 +- rust/agama-server/src/agama-web-server.rs | 4 +- rust/agama-server/src/error.rs | 3 - rust/agama-server/src/l10n/model.rs | 209 ----------- rust/agama-server/src/l10n/web.rs | 213 ----------- rust/agama-server/src/lib.rs | 3 +- rust/agama-server/src/{l10n.rs => server.rs} | 10 +- rust/agama-server/src/server/web.rs | 346 ++++++++++++++++++ .../src/supervisor.rs} | 26 +- rust/agama-server/src/supervisor/listener.rs | 65 ++++ rust/agama-server/src/supervisor/message.rs | 180 +++++++++ rust/agama-server/src/supervisor/proposal.rs | 27 ++ rust/agama-server/src/supervisor/scope.rs | 45 +++ rust/agama-server/src/supervisor/service.rs | 229 ++++++++++++ rust/agama-server/src/supervisor/start.rs | 132 +++++++ .../src/supervisor/system_info.rs | 27 ++ rust/agama-server/src/web.rs | 4 +- rust/agama-server/src/web/docs.rs | 4 +- rust/agama-server/src/web/docs/config.rs | 168 +++++++++ rust/agama-server/src/web/docs/l10n.rs | 53 --- rust/agama-server/tasks.md | 2 + rust/agama-server/tests/l10n.rs | 137 ------- rust/agama-server/tests/server_service.rs | 205 +++++++++++ rust/agama-utils/Cargo.toml | 6 + rust/agama-utils/src/actor.rs | 272 ++++++++++++++ rust/agama-utils/src/dbus.rs | 2 + rust/agama-utils/src/lib.rs | 5 + rust/agama-utils/src/openapi.rs | 2 + rust/agama-utils/src/service.rs | 88 +++++ rust/xtask/src/main.rs | 4 +- service/lib/agama/http/clients.rb | 2 +- .../http/clients/{localization.rb => main.rb} | 8 +- service/lib/agama/manager.rb | 10 +- service/test/agama/manager_test.rb | 6 +- web/src/App.tsx | 6 +- web/src/api/api.ts | 46 +++ web/src/api/{system.ts => hostname.ts} | 2 +- web/src/api/l10n.ts | 71 ---- .../components/core/InstallerOptions.test.tsx | 51 +-- web/src/components/core/InstallerOptions.tsx | 29 +- .../l10n/KeyboardSelection.test.tsx | 26 +- web/src/components/l10n/KeyboardSelection.tsx | 19 +- web/src/components/l10n/L10nPage.test.tsx | 68 ++-- web/src/components/l10n/L10nPage.tsx | 21 +- .../components/l10n/LocaleSelection.test.tsx | 28 +- web/src/components/l10n/LocaleSelection.tsx | 18 +- .../l10n/TimezoneSelection.test.tsx | 26 +- web/src/components/l10n/TimezoneSelection.tsx | 19 +- .../components/overview/L10nSection.test.tsx | 23 +- web/src/components/overview/L10nSection.tsx | 15 +- .../overview/SoftwareSection.test.tsx | 4 +- .../components/overview/SoftwareSection.tsx | 6 +- .../product/ProductRegistrationPage.test.tsx | 4 +- .../product/ProductRegistrationPage.tsx | 2 +- .../questions/LuksActivationQuestion.test.tsx | 20 +- .../questions/QuestionWithPassword.test.tsx | 13 +- .../components/software/SoftwarePage.test.tsx | 4 +- web/src/components/software/SoftwarePage.tsx | 8 +- .../components/system/HostnamePage.test.tsx | 4 +- web/src/components/system/HostnamePage.tsx | 2 +- web/src/context/installerL10n.test.tsx | 24 +- web/src/context/installerL10n.tsx | 21 +- web/src/queries/hostname.ts | 54 +++ web/src/queries/l10n.ts | 88 +---- web/src/queries/proposal.ts | 57 +++ web/src/queries/software.ts | 8 +- web/src/queries/system.ts | 86 +++-- web/src/test-utils.tsx | 12 +- web/src/types/hostname.ts | 35 ++ web/src/types/l10n.ts | 24 +- web/src/types/proposal.ts | 29 ++ web/src/types/system.ts | 16 +- 116 files changed, 4067 insertions(+), 1487 deletions(-) create mode 100644 rust/agama-l10n/Cargo.toml rename rust/{agama-lib/src/localization/settings.rs => agama-l10n/src/config.rs} (53%) create mode 100644 rust/agama-l10n/src/dbus.rs rename rust/{agama-lib/src/localization/model.rs => agama-l10n/src/event.rs} (57%) create mode 100644 rust/agama-l10n/src/extended_config.rs rename rust/{agama-server/src/l10n => agama-l10n/src}/helpers.rs (95%) create mode 100644 rust/agama-l10n/src/lib.rs create mode 100644 rust/agama-l10n/src/message.rs create mode 100644 rust/agama-l10n/src/model.rs rename rust/{agama-server/src/l10n => agama-l10n/src}/model/keyboard.rs (96%) rename rust/{agama-server/src/l10n => agama-l10n/src}/model/locale.rs (89%) rename rust/{agama-server/src/l10n => agama-l10n/src}/model/timezone.rs (94%) create mode 100644 rust/agama-l10n/src/monitor.rs create mode 100644 rust/agama-l10n/src/proposal.rs create mode 100644 rust/agama-l10n/src/service.rs create mode 100644 rust/agama-l10n/src/start.rs create mode 100644 rust/agama-l10n/src/system_info.rs create mode 100644 rust/agama-lib/src/config.rs delete mode 100644 rust/agama-lib/src/localization/http_client.rs delete mode 100644 rust/agama-lib/src/localization/store.rs rename rust/{agama-server/src/l10n => agama-locale-data/src}/error.rs (58%) delete mode 100644 rust/agama-server/src/l10n/model.rs delete mode 100644 rust/agama-server/src/l10n/web.rs rename rust/agama-server/src/{l10n.rs => server.rs} (78%) create mode 100644 rust/agama-server/src/server/web.rs rename rust/{agama-lib/src/localization.rs => agama-server/src/supervisor.rs} (72%) create mode 100644 rust/agama-server/src/supervisor/listener.rs create mode 100644 rust/agama-server/src/supervisor/message.rs create mode 100644 rust/agama-server/src/supervisor/proposal.rs create mode 100644 rust/agama-server/src/supervisor/scope.rs create mode 100644 rust/agama-server/src/supervisor/service.rs create mode 100644 rust/agama-server/src/supervisor/start.rs create mode 100644 rust/agama-server/src/supervisor/system_info.rs create mode 100644 rust/agama-server/src/web/docs/config.rs delete mode 100644 rust/agama-server/src/web/docs/l10n.rs create mode 100644 rust/agama-server/tasks.md delete mode 100644 rust/agama-server/tests/l10n.rs create mode 100644 rust/agama-server/tests/server_service.rs create mode 100644 rust/agama-utils/src/actor.rs create mode 100644 rust/agama-utils/src/service.rs rename service/lib/agama/http/clients/{localization.rb => main.rb} (86%) create mode 100644 web/src/api/api.ts rename web/src/api/{system.ts => hostname.ts} (96%) delete mode 100644 web/src/api/l10n.ts create mode 100644 web/src/queries/hostname.ts create mode 100644 web/src/queries/proposal.ts create mode 100644 web/src/types/hostname.ts create mode 100644 web/src/types/proposal.ts diff --git a/doc/http_api.md b/doc/http_api.md index e394a0bc66..f6f05fc0b7 100644 --- a/doc/http_api.md +++ b/doc/http_api.md @@ -1,42 +1,84 @@ ---- -## HTTP API: An Overview +# HTTP API -This document outlines the **public HTTP API**. It provides an alternative way to interact with the system, complementing the Command Line Interface (CLI) and web user interface. It's important to note that both the CLI and web UI also leverage this HTTP API for their operations. +This document outlines the HTTP API of Agama. It provides an alternative way to interact with the system, complementing the Command Line Interface (CLI) and web user interface. It's important to note that both the CLI and web UI also leverage this HTTP API for their operations. ---- -### API Documentation - -Agama uses **OpenAPI** to document its HTTP API. You can generate the documentation using the following commands: +**Note**: Agama uses *OpenAPI* to document its HTTP API. You can generate the documentation using the following commands: ```shell (cd rust; cargo xtask openapi) cat rust/out/openapi/*.json ``` ---- -### Request and Response Body +## Overview + +The API is designed around 3 main concepts: *system*, *config* and *proposal*. + +* *system*: represents the current status of the running system. +* *config*: represents the configuration for installing the target system. +* *proposal*: represents what is going to be done in the target system. + +The *config* contains elements that can modify the *system*, the *proposal* or both. For example, the *dasd* config changes the *system*, and the *storage* config changes the *proposal*. In other cases like *network*, the config can affect to both *system* and *proposal*. + +~~~ +GET /system +GET /extended_config +GET /extended_config/{scope} +GET PUT PATCH /config +GET PUT PATCH /config/{scope} +GET POST PATCH /questions +GET /proposal +GET /state +GET /issues +POST /action +~~~ + +### GET /system + +Returns a JSON with the info of the system (storage devices, network connections, current localization, etc). + +### GET /extended_config + +Returns the *extended config* JSON. + +There is a distinction between *extended config* and *config*: + +* The *config* is the config explicitly set by the clients. +* The *extended config* is the config used for calculating the proposal and it is built by merging the the *config* with the default *extended config*. The default *extended config* is built from the *system info* and the *product info*. + +For example, if only the *locale* was configured by the user, then the *config* has no *keymap* property. Nevertheless, the *extended config* would have a *keymap* with the value from the default *extended config*. + +The scope can be indicated to retrieve only a part of the config, for example *GET /extended_config/l10n*. + +### GET PUT PATCH /config + +Reads, replaces or modifies the explicitly set *config*. In case of patching, the given config is merged into the current *extended config*. + +The scope can be indicated to manage only part of the config, for example *PUT /config/l10n*. + +### POST /action + +Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. -The Agama HTTP API uses **JSON** as its request and response body format. The schema for this JSON is thoroughly documented within the OpenAPI specification. +### Example: reload the system ---- -### Configuration-Based API +In some cases, clients need to request a system reload. For example, if you create a RAID device using the terminal, then you need to reload the system in order to see the new device. In the future, reloading the system could be automatically done (e.g., by listening udisk D-Bus). For now, reloading has to be manually requested. -For automated installations, the system provides two primary API endpoints for managing configurations across various modules: +~~~ +POST /action { "reloadSystem": { scope: "storage" } } +~~~ -* **GET `/api/${module}/config`**: Use this endpoint to **export** the current system configuration for a specific module. -* **PUT `/api/${module}/config`**: Use this endpoint to **load** an unattended installation profile for a specific module. In some cases, the loaded configuration is also immediately applied; further details are available below. +### Example: change the system localization ---- -### Future Enhancements: PATCH `/api/${module}/config` for Targeted Modifications +Sometimes we need to directly modify the system without changing the config. For example, switching the locale of the running system (UI language). -Following internal discussions, we plan to introduce a **PATCH `/api/${module}/config`** endpoint. This new endpoint will enable more granular modifications and applications of configurations, and it will replace the existing HTTP API methods used for modifications. While not strictly required, the structure of the PATCH request will likely mirror the configuration's existing layout. +~~~ +POST /action { "configureL10n": { language: "es_ES" } } +~~~ -This enhancement will allow you to modify specific parts of the configuration. In some cases, these changes can be applied immediately without needing a full installation. This is especially useful for technologies that require configuration to be applied *before* an installation begins, such as: +### Example: start installation -* Network settings -* System registration -* iSCSI -* DASD -* zFCP +The installation can be started by calling the proper action. -The key advantage of this PATCH approach is its ability to minimize **race conditions** and to more easily keep the configuration manipulation API closely aligned with the core configuration API. By only modifying the necessary parts of the configuration, it reduces conflicts, which is particularly helpful in scenarios like rapid clicks within the web user interface. +~~~ +POST /action "install" +~~~ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 97c1405370..468e28a08e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -24,7 +24,7 @@ dependencies = [ "agama-lib", "anyhow", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "url", ] @@ -47,15 +47,39 @@ dependencies = [ "reqwest", "serde_json", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "url", ] +[[package]] +name = "agama-l10n" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "anyhow", + "async-trait", + "gettext-rs", + "merge-struct", + "regex", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "utoipa", + "zbus", +] + [[package]] name = "agama-lib" version = "1.0.0" dependencies = [ + "agama-l10n", "agama-locale-data", "agama-network", "agama-utils", @@ -81,7 +105,7 @@ dependencies = [ "serde_with", "strum", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-native-tls", "tokio-stream", @@ -96,13 +120,12 @@ dependencies = [ name = "agama-locale-data" version = "0.1.0" dependencies = [ - "anyhow", "chrono-tz", "flate2", "quick-xml", "regex", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", "utoipa", ] @@ -121,7 +144,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-test", @@ -135,6 +158,7 @@ dependencies = [ name = "agama-server" version = "0.1.0" dependencies = [ + "agama-l10n", "agama-lib", "agama-locale-data", "agama-utils", @@ -151,6 +175,7 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "libsystemd", + "merge-struct", "openssl", "pam", "pin-project", @@ -160,9 +185,10 @@ dependencies = [ "serde", "serde_json", "serde_with", + "strum", "subprocess", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-openssl", "tokio-stream", @@ -183,7 +209,11 @@ dependencies = [ name = "agama-utils" version = "0.1.0" dependencies = [ + "async-trait", "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-test", "utoipa", "zbus", "zvariant", @@ -294,9 +324,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "arraydeque" @@ -555,9 +585,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -584,6 +614,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "futures-util", @@ -659,6 +690,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -1192,7 +1234,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "socket2", + "socket2 0.5.9", "windows-sys 0.52.0", ] @@ -1954,7 +1996,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -2029,7 +2071,7 @@ dependencies = [ "hyper 1.6.0", "libc", "pin-project-lite", - "socket2", + "socket2 0.5.9", "tokio", "tower-service", "tracing", @@ -2476,7 +2518,7 @@ dependencies = [ "once_cell", "serde", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.16", "uuid", ] @@ -2596,6 +2638,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merge-struct" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d82012d21e24135b839b6b9bebd622b7ff0cb40071498bc2d066d3a6d04dd4a" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "mime" version = "0.3.17" @@ -3065,7 +3117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.16", "ucd-trie", ] @@ -3431,9 +3483,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -3669,6 +3721,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3735,9 +3811,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -3799,15 +3875,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.9.0", + "schemars 0.9.0", + "schemars 1.0.4", "serde", "serde_derive", "serde_json", @@ -3817,9 +3895,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ "darling", "proc-macro2", @@ -3908,7 +3986,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", ] @@ -3943,6 +4021,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -3981,9 +4069,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] @@ -4131,11 +4219,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4151,9 +4239,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -4222,9 +4310,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -4234,10 +4322,10 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4551,7 +4639,7 @@ dependencies = [ "native-tls", "rand 0.9.1", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.16", "utf-8", ] @@ -4658,9 +4746,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ "indexmap 2.9.0", "serde", @@ -4670,9 +4758,9 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", @@ -4938,9 +5026,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-registry" @@ -4950,7 +5038,7 @@ checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ "windows-result", "windows-strings 0.3.1", - "windows-targets 0.53.0", + "windows-targets 0.53.3", ] [[package]] @@ -5007,6 +5095,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5040,10 +5137,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5271,9 +5369,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.7.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" +checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" dependencies = [ "async-broadcast", "async-executor", @@ -5296,7 +5394,7 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "winnow", "zbus_macros", "zbus_names", @@ -5305,9 +5403,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.7.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a" +checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4b93ca5f00..b16499414d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,10 +2,11 @@ members = [ "agama-autoinstall", "agama-cli", - "agama-server", + "agama-l10n", "agama-lib", "agama-locale-data", "agama-network", + "agama-server", "agama-utils", "xtask", ] diff --git a/rust/agama-l10n/Cargo.toml b/rust/agama-l10n/Cargo.toml new file mode 100644 index 0000000000..40b8e85707 --- /dev/null +++ b/rust/agama-l10n/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "agama-l10n" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.99" +merge-struct = "0.1.0" +serde = { version = "1.0.219", features = ["derive"] } +thiserror = "2.0.16" +agama-locale-data = { path = "../agama-locale-data" } +agama-utils = { path = "../agama-utils" } +regex = "1.11.2" +tracing = "0.1.41" +serde_with = "3.14.0" +utoipa = "5.4.0" +gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +serde_json = "1.0.143" +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" +zbus = "5.11.0" +async-trait = "0.1.89" + +[dev-dependencies] +tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-lib/src/localization/settings.rs b/rust/agama-l10n/src/config.rs similarity index 53% rename from rust/agama-lib/src/localization/settings.rs rename to rust/agama-l10n/src/config.rs index 5548bf0014..399fc7f769 100644 --- a/rust/agama-lib/src/localization/settings.rs +++ b/rust/agama-l10n/src/config.rs @@ -20,20 +20,37 @@ //! Representation of the localization settings +use crate::extended_config::ExtendedConfig; use serde::{Deserialize, Serialize}; -/// Localization settings for the system being installed (not the UI) -/// FIXME: this one is close to CLI. A possible duplicate close to HTTP is LocaleConfig -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +/// User configuration for the localization of the target system. +/// +/// This configuration is provided by the user, so all the values are optional. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[schema(as = l10n::UserConfig)] #[serde(rename_all = "camelCase")] -pub struct LocalizationSettings { - /// like "en_US.UTF-8" +pub struct Config { + /// Locale (e.g., "en_US.UTF-8"). #[serde(skip_serializing_if = "Option::is_none")] - pub language: Option, - /// like "cz(qwerty)" + #[serde(alias = "language")] + pub locale: Option, + /// Keymap (e.g., "us", "cz(qwerty)", etc.). #[serde(skip_serializing_if = "Option::is_none")] - pub keyboard: Option, - /// like "Europe/Berlin" + #[serde(alias = "keyboard")] + pub keymap: Option, + /// Timezone (e.g., "Europe/Berlin"). #[serde(skip_serializing_if = "Option::is_none")] pub timezone: Option, } + +/// Converts the localization configuration, which contains values for all the +/// elements, into a user configuration. +impl From<&ExtendedConfig> for Config { + fn from(config: &ExtendedConfig) -> Self { + Config { + locale: Some(config.locale.to_string()), + keymap: Some(config.keymap.to_string()), + timezone: Some(config.timezone.to_string()), + } + } +} diff --git a/rust/agama-l10n/src/dbus.rs b/rust/agama-l10n/src/dbus.rs new file mode 100644 index 0000000000..bfeedddb00 --- /dev/null +++ b/rust/agama-l10n/src/dbus.rs @@ -0,0 +1,81 @@ +//! # D-Bus interface proxy for: `org.freedesktop.locale1` +//! +//! This code was generated by `zbus-xmlgen` `5.1.0` from D-Bus introspection data. +//! Source: `Interface '/org/freedesktop/locale1' from service 'org.freedesktop.locale1' on system bus`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the +//! following zbus API can be used: +//! +//! * [`zbus::fdo::PeerProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`zbus::fdo::PropertiesProxy`] +//! +//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + interface = "org.freedesktop.locale1", + default_service = "org.freedesktop.locale1", + default_path = "/org/freedesktop/locale1" +)] +pub trait Locale1 { + /// SetLocale method + fn set_locale(&self, locale: &[&str], interactive: bool) -> zbus::Result<()>; + + /// SetVConsoleKeyboard method + #[zbus(name = "SetVConsoleKeyboard")] + fn set_vconsole_keyboard( + &self, + keymap: &str, + keymap_toggle: &str, + convert: bool, + interactive: bool, + ) -> zbus::Result<()>; + + /// SetX11Keyboard method + #[zbus(name = "SetX11Keyboard")] + fn set_x11keyboard( + &self, + layout: &str, + model: &str, + variant: &str, + options: &str, + convert: bool, + interactive: bool, + ) -> zbus::Result<()>; + + /// Locale property + #[zbus(property)] + fn locale(&self) -> zbus::Result>; + + /// VConsoleKeymap property + #[zbus(property, name = "VConsoleKeymap")] + fn vconsole_keymap(&self) -> zbus::Result; + + /// VConsoleKeymapToggle property + #[zbus(property, name = "VConsoleKeymapToggle")] + fn vconsole_keymap_toggle(&self) -> zbus::Result; + + /// X11Layout property + #[zbus(property, name = "X11Layout")] + fn x11layout(&self) -> zbus::Result; + + /// X11Model property + #[zbus(property, name = "X11Model")] + fn x11model(&self) -> zbus::Result; + + /// X11Options property + #[zbus(property, name = "X11Options")] + fn x11options(&self) -> zbus::Result; + + /// X11Variant property + #[zbus(property, name = "X11Variant")] + fn x11variant(&self) -> zbus::Result; +} diff --git a/rust/agama-lib/src/localization/model.rs b/rust/agama-l10n/src/event.rs similarity index 57% rename from rust/agama-lib/src/localization/model.rs rename to rust/agama-l10n/src/event.rs index 20a565bc4b..9ea47511b1 100644 --- a/rust/agama-lib/src/localization/model.rs +++ b/rust/agama-l10n/src/event.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -19,18 +19,20 @@ // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocaleConfig { - /// Locales to install in the target system - pub locales: Option>, - /// Keymap for the target system - pub keymap: Option, - /// Timezone for the target system - pub timezone: Option, - /// User-interface locale. It is actually not related to the `locales` property. - pub ui_locale: Option, - /// User-interface locale. It is relevant only on local installations. - pub ui_keymap: Option, +/// Localization-related events. +// FIXME: is it really needed to implement Deserialize? +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "name")] +pub enum Event { + /// Proposal changed. + ProposalChanged, + /// The underlying system changed. + SystemChanged, } + +/// Multi-producer single-consumer events sender. +pub type Sender = mpsc::UnboundedSender; +/// Multi-producer single-consumer events receiver. +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-l10n/src/extended_config.rs b/rust/agama-l10n/src/extended_config.rs new file mode 100644 index 0000000000..5194a32dbc --- /dev/null +++ b/rust/agama-l10n/src/extended_config.rs @@ -0,0 +1,57 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{config::Config, service, system_info::SystemInfo}; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; + +#[derive(Clone, PartialEq)] +pub struct ExtendedConfig { + pub locale: LocaleId, + pub keymap: KeymapId, + pub timezone: TimezoneId, +} + +impl ExtendedConfig { + pub fn new_from(system: &SystemInfo) -> Self { + Self { + locale: system.locale.clone(), + keymap: system.keymap.clone(), + timezone: system.timezone.clone(), + } + } + + pub fn merge(&self, config: &Config) -> Result { + let mut merged = self.clone(); + + if let Some(language) = &config.locale { + merged.locale = language.parse()? + } + + if let Some(keyboard) = &config.keymap { + merged.keymap = keyboard.parse()? + } + + if let Some(timezone) = &config.timezone { + merged.timezone = timezone.parse()?; + } + + Ok(merged) + } +} diff --git a/rust/agama-server/src/l10n/helpers.rs b/rust/agama-l10n/src/helpers.rs similarity index 95% rename from rust/agama-server/src/l10n/helpers.rs rename to rust/agama-l10n/src/helpers.rs index e39229529a..bb7f95d0dd 100644 --- a/rust/agama-server/src/l10n/helpers.rs +++ b/rust/agama-l10n/src/helpers.rs @@ -31,7 +31,7 @@ use std::env; /// It returns the used locale. Defaults to `en_US.UTF-8`. pub fn init_locale() -> Result> { let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); - let locale: LocaleId = lang.as_str().try_into().unwrap_or_default(); + let locale = lang.parse().unwrap_or_default(); set_service_locale(&locale); textdomain("xkeyboard-config")?; diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs new file mode 100644 index 0000000000..4eecfab9eb --- /dev/null +++ b/rust/agama-l10n/src/lib.rs @@ -0,0 +1,64 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate implements the support for localization handling in Agama. +//! It takes care of setting the locale, keymap and timezone for Agama itself +//! and the target system. +//! +//! From a technical point of view, it includes: +//! +//! * The [UserConfig] struct that defines the settings the user can +//! alter for the target system. +//! * The [Proposal] struct that describes how the system will look like after +//! the installation. +//! * The [SystemInfo] which includes information about the system +//! where Agama is running. +//! * An [specific event type](Event) for localization-related events. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system. + +pub mod start; +pub use start::start; + +pub mod service; +pub use service::Service; + +mod model; +pub use model::{Model, ModelAdapter}; + +mod system_info; +pub use system_info::SystemInfo; + +mod config; +pub use config::Config; + +mod proposal; +pub use proposal::Proposal; + +pub mod event; +pub use event::Event; + +pub mod helpers; +pub mod message; + +mod dbus; +mod extended_config; +mod monitor; diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs new file mode 100644 index 0000000000..464b3450e4 --- /dev/null +++ b/rust/agama-l10n/src/message.rs @@ -0,0 +1,99 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{config::Config, proposal::Proposal, system_info::SystemInfo}; +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::actor::Message; +use serde::Deserialize; + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +pub struct SetSystem { + pub config: T, +} + +impl Message for SetSystem { + type Reply = (); +} + +impl SetSystem { + pub fn new(config: T) -> Self { + Self { config } + } +} + +#[derive(Clone, Debug, Deserialize, utoipa::ToSchema)] +pub struct SystemConfig { + pub locale: Option, + pub keymap: Option, +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +pub struct SetConfig { + pub config: T, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: T) -> Self { + Self { config } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Proposal; +} + +pub struct Install; + +impl Message for Install { + type Reply = (); +} + +pub struct UpdateKeymap { + pub keymap: KeymapId, +} + +impl Message for UpdateKeymap { + type Reply = (); +} + +pub struct UpdateLocale { + pub locale: LocaleId, +} + +impl Message for UpdateLocale { + type Reply = (); +} diff --git a/rust/agama-l10n/src/model.rs b/rust/agama-l10n/src/model.rs new file mode 100644 index 0000000000..8da962b808 --- /dev/null +++ b/rust/agama-l10n/src/model.rs @@ -0,0 +1,215 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod keyboard; +pub use keyboard::{Keymap, KeymapsDatabase}; + +mod locale; +pub use locale::{LocaleEntry, LocalesDatabase}; + +mod timezone; +pub use timezone::{TimezoneEntry, TimezonesDatabase}; + +use crate::{helpers, service}; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use regex::Regex; +use std::{env, fs::OpenOptions, io::Write, process::Command}; + +/// Abstract the localization-related configuration from the underlying system. +/// +/// It offers an API to query and set different localization elements of a +/// system. This trait can be implemented to replace the real system during +/// tests. +pub trait ModelAdapter: Send + 'static { + /// Locales database. + fn locales_db(&mut self) -> &mut LocalesDatabase; + + /// Timezones database. + fn timezones_db(&mut self) -> &mut TimezonesDatabase; + + /// Keymaps database. + fn keymaps_db(&mut self) -> &mut KeymapsDatabase; + + /// Current system locale. + fn locale(&self) -> LocaleId; + + /// Current system keymap. + fn keymap(&self) -> Result; + + /// Change the locale of the system. + fn set_locale(&mut self, _locale: LocaleId) -> Result<(), service::Error> { + Ok(()) + } + + /// Change the keymap of the system. + fn set_keymap(&mut self, _keymap: KeymapId) -> Result<(), service::Error> { + Ok(()) + } + + /// Apply the changes to target system. It is expected to be called almost + /// at the end of the installation. + fn install( + &self, + _locale: LocaleId, + _keymap: KeymapId, + _timezone: TimezoneId, + ) -> Result<(), service::Error> { + Ok(()) + } +} + +/// [ModelAdapter] implementation for systemd-based systems. +pub struct Model { + pub timezones_db: TimezonesDatabase, + pub locales_db: LocalesDatabase, + pub keymaps_db: KeymapsDatabase, +} + +impl Default for Model { + fn default() -> Self { + Self { + locales_db: LocalesDatabase::new(), + timezones_db: TimezonesDatabase::new(), + keymaps_db: KeymapsDatabase::new(), + } + } +} + +impl Model { + /// Initializes the struct with the information from the underlying system. + pub fn from_system() -> Result { + let mut model = Self::default(); + model.read(&model.locale())?; + Ok(model) + } + + fn read(&mut self, locale: &LocaleId) -> Result<(), service::Error> { + self.locales_db.read(&locale.language)?; + self.timezones_db.read(&locale.language)?; + self.keymaps_db.read()?; + + Ok(()) + } +} + +impl ModelAdapter for Model { + fn locales_db(&mut self) -> &mut LocalesDatabase { + &mut self.locales_db + } + fn timezones_db(&mut self) -> &mut TimezonesDatabase { + &mut self.timezones_db + } + + fn keymaps_db(&mut self) -> &mut KeymapsDatabase { + &mut self.keymaps_db + } + + fn keymap(&self) -> Result { + let output = Command::new("localectl").output()?; + let output = String::from_utf8_lossy(&output.stdout); + + let keymap_regexp = Regex::new(r"(?m)VC Keymap: (.+)$").unwrap(); + let captures = keymap_regexp.captures(&output); + let keymap = captures + .and_then(|c| c.get(1).map(|e| e.as_str())) + .unwrap_or("us") + .to_string(); + + let keymap_id: KeymapId = keymap.parse().unwrap_or(KeymapId::default()); + Ok(keymap_id) + } + + // FIXME: we could use D-Bus to read the locale and the keymap (see ui_keymap). + fn locale(&self) -> LocaleId { + let lang = env::var("LANG") + .ok() + .and_then(|v| v.parse::().ok()); + lang.unwrap_or_default() + } + + fn set_locale(&mut self, locale: LocaleId) -> Result<(), service::Error> { + if !self.locales_db.exists(&locale) { + return Err(service::Error::UnknownLocale(locale)); + } + + Command::new("localectl") + .args(["set-locale", &format!("LANG={}", locale)]) + .output()?; + + helpers::set_service_locale(&locale); + self.timezones_db.read(&locale.language)?; + self.locales_db.read(&locale.language)?; + Ok(()) + } + + fn set_keymap(&mut self, keymap: KeymapId) -> Result<(), service::Error> { + if !self.keymaps_db.exists(&keymap) { + return Err(service::Error::UnknownKeymap(keymap)); + } + + Command::new("localectl") + .args(["set-keymap", &keymap.dashed()]) + .output()?; + Ok(()) + } + + fn install( + &self, + locale: LocaleId, + keymap: KeymapId, + timezone: TimezoneId, + ) -> Result<(), service::Error> { + const ROOT: &str = "/mnt"; + const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; + + let mut cmd = Command::new("/usr/bin/systemd-firstboot"); + cmd.args([ + "--root", + ROOT, + "--force", + "--locale", + &locale.to_string(), + "--keymap", + &keymap.dashed(), + "--timezone", + &timezone.to_string(), + ]); + tracing::info!("{:?}", &cmd); + + let output = cmd.output()?; + tracing::info!("{:?}", &output); + + // unfortunately the console font cannot be set via the "systemd-firstboot" tool, + // we need to write it directly to the config file + if let Some(entry) = self.locales_db.find_locale(&locale) { + if let Some(font) = &entry.consolefont { + // the font entry is missing in a file created by "systemd-firstboot", just append it at the end + let mut file = OpenOptions::new() + .append(true) + .open(format!("{ROOT}{VCONSOLE_CONF}"))?; + + tracing::info!("Configuring console font \"{:?}\"", font); + writeln!(file, "\nFONT={font}.psfu")?; + } + } + + Ok(()) + } +} diff --git a/rust/agama-server/src/l10n/model/keyboard.rs b/rust/agama-l10n/src/model/keyboard.rs similarity index 96% rename from rust/agama-server/src/l10n/model/keyboard.rs rename to rust/agama-l10n/src/model/keyboard.rs index e968959611..e958a7bd44 100644 --- a/rust/agama-server/src/l10n/model/keyboard.rs +++ b/rust/agama-l10n/src/model/keyboard.rs @@ -72,6 +72,13 @@ impl KeymapsDatabase { Self::default() } + #[cfg(test)] + pub fn with_entries(data: &[Keymap]) -> Self { + Self { + keymaps: data.to_vec(), + } + } + /// Reads the list of keymaps. pub fn read(&mut self) -> anyhow::Result<()> { self.keymaps = get_keymaps()?; diff --git a/rust/agama-server/src/l10n/model/locale.rs b/rust/agama-l10n/src/model/locale.rs similarity index 89% rename from rust/agama-server/src/l10n/model/locale.rs rename to rust/agama-l10n/src/model/locale.rs index 935e883a23..86ebf154a9 100644 --- a/rust/agama-server/src/l10n/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -20,7 +20,6 @@ //! This module provides support for reading the locales database. -use crate::error::Error; use agama_locale_data::LocaleId; use anyhow::Context; use serde::Serialize; @@ -57,13 +56,21 @@ impl LocalesDatabase { Self::default() } + #[cfg(test)] + pub fn with_entries(data: &[LocaleEntry]) -> Self { + Self { + known_locales: vec![], + locales: data.to_vec(), + } + } + /// Loads the list of locales. /// /// It checks for a file in /etc/agama.d/locales containing the list of supported locales (one per line). /// It it does not exists, calls `localectl list-locales`. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.known_locales = Self::get_locales_list()?; self.locales = self.get_locales(ui_language)?; Ok(()) @@ -89,7 +96,7 @@ impl LocalesDatabase { /// Gets the supported locales information. /// /// * `ui_language`: language to use in the translations. - fn get_locales(&self, ui_language: &str) -> Result, Error> { + fn get_locales(&self, ui_language: &str) -> anyhow::Result> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.known_locales.len()); let languages = agama_locale_data::get_languages()?; @@ -135,7 +142,7 @@ impl LocalesDatabase { Ok(result) } - fn get_locales_list() -> Result, Error> { + fn get_locales_list() -> anyhow::Result> { const LOCALES_LIST_PATH: &str = "/etc/agama.d/locales"; let locales = fs::read_to_string(LOCALES_LIST_PATH).map(Self::get_locales_from_string); @@ -159,10 +166,7 @@ impl LocalesDatabase { } fn get_locales_from_string(locales: String) -> Vec { - locales - .lines() - .filter_map(|line| TryInto::::try_into(line).ok()) - .collect() + locales.lines().filter_map(|l| l.parse().ok()).collect() } } @@ -178,7 +182,7 @@ mod tests { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); let found_locales = db.entries(); - let spanish: LocaleId = "es_ES".try_into().unwrap(); + let spanish = "es_ES".parse::().unwrap(); let found = found_locales .iter() .find(|l| l.id == spanish) @@ -189,14 +193,14 @@ mod tests { #[test] fn test_try_into_locale() { - let locale = LocaleId::try_from("es_ES.UTF-16").unwrap(); + let locale = "es_ES.UTF-16".parse::().unwrap(); assert_eq!(&locale.language, "es"); assert_eq!(&locale.territory, "ES"); assert_eq!(&locale.encoding, "UTF-16"); assert_eq!(locale.to_string(), String::from("es_ES.UTF-16")); - let invalid = LocaleId::try_from("."); + let invalid = ".".parse::(); assert!(invalid.is_err()); } @@ -206,8 +210,8 @@ mod tests { fn test_locale_exists() { let mut db = LocalesDatabase::new(); db.read("en").unwrap(); - let en_us = LocaleId::try_from("en_US").unwrap(); - let unknown = LocaleId::try_from("unknown_UNKNOWN").unwrap(); + let en_us = "en_US".parse::().unwrap(); + let unknown = "unknown_UNKNOWN".parse::().unwrap(); assert!(db.exists(&en_us)); assert!(!db.exists(&unknown)); } diff --git a/rust/agama-server/src/l10n/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs similarity index 94% rename from rust/agama-server/src/l10n/model/timezone.rs rename to rust/agama-l10n/src/model/timezone.rs index 192c240cb1..204ece1c7f 100644 --- a/rust/agama-server/src/l10n/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -20,7 +20,6 @@ //! This module provides support for reading the timezones database. -use crate::error::Error; use agama_locale_data::territory::Territories; use agama_locale_data::timezone_part::TimezoneIdParts; use serde::Serialize; @@ -47,10 +46,17 @@ impl TimezonesDatabase { Self::default() } + #[cfg(test)] + pub fn with_entries(data: &[TimezoneEntry]) -> Self { + Self { + timezones: data.to_vec(), + } + } + /// Initializes the list of known timezones. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.timezones = self.get_timezones(ui_language)?; Ok(()) } @@ -71,7 +77,7 @@ impl TimezonesDatabase { /// containing the translation of each part of the language. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - fn get_timezones(&self, ui_language: &str) -> Result, Error> { + fn get_timezones(&self, ui_language: &str) -> anyhow::Result> { let timezones = agama_locale_data::get_timezones(); let tz_parts = agama_locale_data::get_timezone_parts()?; let territories = agama_locale_data::get_territories()?; diff --git a/rust/agama-l10n/src/monitor.rs b/rust/agama-l10n/src/monitor.rs new file mode 100644 index 0000000000..2282af400e --- /dev/null +++ b/rust/agama-l10n/src/monitor.rs @@ -0,0 +1,90 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{message, service::Service}; +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::{ + actor::Handler, + dbus::{get_property, to_owned_hash}, +}; +use tokio_stream::StreamExt; +use zbus::fdo::{PropertiesChangedStream, PropertiesProxy}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), +} + +pub struct Monitor { + handler: Handler, + stream: PropertiesChangedStream, +} + +impl Monitor { + pub async fn new(handler: Handler) -> Result { + let dbus = zbus::Connection::system().await?; + let proxy = PropertiesProxy::builder(&dbus) + .path("/org/freedesktop/locale1")? + .destination("org.freedesktop.locale1")? + .build() + .await?; + let stream = proxy + .receive_properties_changed() + .await + .map_err(Error::DBus)?; + Ok(Self { handler, stream }) + } + + pub async fn run(&mut self) { + while let Some(changes) = self.stream.next().await { + let Ok(args) = changes.args() else { + continue; + }; + + let changes = args.changed_properties(); + let Ok(changes) = to_owned_hash(changes) else { + continue; + }; + + if let Ok(locales) = get_property::>(&changes, "Locale") { + let Some(locale) = locales.first() else { + continue; + }; + + let locale_id = locale + .strip_prefix("LANG=") + .and_then(|l| l.parse::().ok()); + + if let Some(locale_id) = locale_id { + _ = self + .handler + .call(message::UpdateLocale { locale: locale_id }) + .await; + } + } + if let Ok(keymap) = get_property::(&changes, "VConsoleKeymap") { + if let Ok(keymap) = keymap.parse::() { + _ = self.handler.call(message::UpdateKeymap { keymap }).await; + } + } + } + } +} diff --git a/rust/agama-l10n/src/proposal.rs b/rust/agama-l10n/src/proposal.rs new file mode 100644 index 0000000000..c915fc3f60 --- /dev/null +++ b/rust/agama-l10n/src/proposal.rs @@ -0,0 +1,53 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::extended_config::ExtendedConfig; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; + +/// Describes what Agama proposes for the target system. +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Proposal { + /// Keymap (e.g., "us", "cz(qwerty)", etc.). + #[serde_as(as = "DisplayFromStr")] + pub keymap: KeymapId, + /// Locale (e.g., "en_US.UTF-8"). + #[serde_as(as = "DisplayFromStr")] + pub locale: LocaleId, + /// Timezone (e.g., "Europe/Berlin"). + #[serde_as(as = "DisplayFromStr")] + pub timezone: TimezoneId, +} + +/// Turns the configuration into a proposal. +/// +/// It is possible because, in the l10n module, the configuration and the +/// proposal are mostly the same. +impl From<&ExtendedConfig> for Proposal { + fn from(config: &ExtendedConfig) -> Self { + Proposal { + keymap: config.keymap.clone(), + locale: config.locale.clone(), + timezone: config.timezone.clone(), + } + } +} diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs new file mode 100644 index 0000000000..5145c63fa6 --- /dev/null +++ b/rust/agama-l10n/src/service.rs @@ -0,0 +1,165 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + config::Config, event::Event, extended_config::ExtendedConfig, message, model::ModelAdapter, + proposal::Proposal, system_info::SystemInfo, +}; +use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; +use agama_utils::actor::{self, Actor, MessageHandler}; +use async_trait::async_trait; +use tokio::sync::mpsc; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Unknown locale: {0}")] + UnknownLocale(LocaleId), + #[error("Unknown keymap: {0}")] + UnknownKeymap(KeymapId), + #[error("Unknown timezone: {0}")] + UnknownTimezone(String), + #[error("Invalid locale: {0}")] + InvalidLocale(#[from] InvalidLocaleId), + #[error("Invalid keymap: {0}")] + InvalidKeymap(#[from] InvalidKeymapId), + #[error("Invalid timezone")] + InvalidTimezone(#[from] InvalidTimezoneId), + #[error("l10n service could not send the event")] + Event, + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] + Generic(#[from] anyhow::Error), +} + +pub struct Service { + state: State, + model: Box, + events: mpsc::UnboundedSender, +} + +struct State { + system: SystemInfo, + config: ExtendedConfig, +} + +impl Service { + pub fn new( + mut model: T, + events: mpsc::UnboundedSender, + ) -> Service { + let system = SystemInfo::read_from(&mut model); + let config = ExtendedConfig::new_from(&system); + let state = State { system, config }; + + Self { + state, + model: Box::new(model), + events, + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(self.state.system.clone()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetSystem, + ) -> Result<(), Error> { + let config = &message.config; + if let Some(locale) = &config.locale { + self.model.set_locale(locale.parse()?)?; + } + + if let Some(keymap) = &config.keymap { + self.model.set_keymap(keymap.parse()?)?; + }; + + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok((&self.state.config).into()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + let merged = self.state.config.merge(&message.config)?; + if merged != self.state.config { + self.state.config = merged; + _ = self.events.send(Event::ProposalChanged); + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result { + Ok((&self.state.config).into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + let proposal: Proposal = (&self.state.config).into(); + self.model + .install(proposal.locale, proposal.keymap, proposal.timezone) + .unwrap(); + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateLocale) -> Result<(), Error> { + self.state.system.locale = message.locale; + _ = self.events.send(Event::SystemChanged); + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateKeymap) -> Result<(), Error> { + self.state.system.keymap = message.keymap; + _ = self.events.send(Event::SystemChanged); + Ok(()) + } +} diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs new file mode 100644 index 0000000000..a3faf0e62b --- /dev/null +++ b/rust/agama-l10n/src/start.rs @@ -0,0 +1,251 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + event, + model::Model, + monitor::{self, Monitor}, + service::{self, Service}, +}; +use agama_utils::actor::{self, Handler}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Service(#[from] service::Error), + #[error(transparent)] + Monitor(#[from] monitor::Error), +} + +/// Starts the localization service. +/// +/// It starts two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - A monitor which checks for changes in the underlying system (e.g., changing the keymap) +/// and signals the main service accordingly. +/// +/// ## Example +/// +/// ```no_run +/// # use tokio_test; +/// # use tokio::sync::mpsc; +/// use agama_l10n as l10n; +/// # tokio_test::block_on(async { +/// +/// let (events_sender, events_receiver) = mpsc::unbounded_channel::(); +/// let service = l10n::start(events_sender).await.unwrap(); +/// let config = service.call(l10n::message::GetConfig).await; +/// dbg!(config); +/// # }) +/// ``` +/// +/// * `events`: channel to emit the [localization-specific events](crate::Event). +pub async fn start(events: event::Sender) -> Result, Error> { + let model = Model::from_system()?; + let service = Service::new(model, events); + let handler = actor::spawn(service); + + let mut monitor = Monitor::new(handler.clone()).await?; + tokio::spawn(async move { + monitor.run().await; + }); + + Ok(handler) +} + +#[cfg(test)] +mod tests { + use crate::{ + event::Receiver, + message, + model::{ + Keymap, KeymapsDatabase, LocaleEntry, LocalesDatabase, ModelAdapter, TimezoneEntry, + TimezonesDatabase, + }, + service, Config, Event, Service, + }; + use agama_locale_data::{KeymapId, LocaleId}; + use agama_utils::actor::{self, Handler}; + use tokio::sync::mpsc; + + pub struct TestModel { + pub locales: LocalesDatabase, + pub keymaps: KeymapsDatabase, + pub timezones: TimezonesDatabase, + } + + impl ModelAdapter for TestModel { + fn locales_db(&mut self) -> &mut LocalesDatabase { + &mut self.locales + } + + fn keymaps_db(&mut self) -> &mut KeymapsDatabase { + &mut self.keymaps + } + + fn timezones_db(&mut self) -> &mut TimezonesDatabase { + &mut self.timezones + } + + fn locale(&self) -> LocaleId { + LocaleId::default() + } + + fn keymap(&self) -> Result { + Ok(KeymapId::default()) + } + } + + fn build_adapter() -> TestModel { + TestModel { + locales: LocalesDatabase::with_entries(&[ + LocaleEntry { + id: "en_US.UTF-8".parse().unwrap(), + language: "English".to_string(), + territory: "United States".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "es_ES.UTF-8".parse().unwrap(), + language: "Spanish".to_string(), + territory: "Spain".to_string(), + consolefont: None, + }, + ]), + keymaps: KeymapsDatabase::with_entries(&[ + Keymap::new("us".parse().unwrap(), "English"), + Keymap::new("es".parse().unwrap(), "Spanish"), + ]), + timezones: TimezonesDatabase::with_entries(&[ + TimezoneEntry { + code: "Europe/Berlin".to_string(), + parts: vec!["Europe".to_string(), "Berlin".to_string()], + country: Some("Germany".to_string()), + }, + TimezoneEntry { + code: "Atlantic/Canary".to_string(), + parts: vec!["Atlantic".to_string(), "Canary".to_string()], + country: Some("Spain".to_string()), + }, + ]), + } + } + + fn start_testing_service() -> (Receiver, Handler) { + let (events_tx, events_rx) = mpsc::unbounded_channel::(); + let model = build_adapter(); + let service = Service::new(model, events_tx); + + let handler = actor::spawn(service); + (events_rx, handler) + } + + #[tokio::test] + async fn test_get_and_set_config() -> Result<(), Box> { + let (mut events_rx, handler) = start_testing_service(); + + let config = handler.call(message::GetConfig).await.unwrap(); + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + + let input_config = Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + + let updated = handler.call(message::GetConfig).await?; + assert_eq!(&updated, &input_config); + + let event = events_rx.recv().await.expect("Did not receive the event"); + assert!(matches!(event, Event::ProposalChanged)); + Ok(()) + } + + #[tokio::test] + async fn test_set_invalid_config() -> Result<(), Box> { + let (_events_rx, handler) = start_testing_service(); + + let input_config = Config { + locale: Some("es-ES.UTF-8".to_string()), + ..Default::default() + }; + + let result = handler + .call(message::SetConfig::new(input_config.clone())) + .await; + assert!(matches!( + result, + Err(crate::service::Error::InvalidLocale(_)) + )); + Ok(()) + } + + #[tokio::test] + async fn test_set_config_without_changes() -> Result<(), Box> { + let (mut events_rx, handler) = start_testing_service(); + + let config = handler.call(message::GetConfig).await?; + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + let message = message::SetConfig::new(config.clone()); + handler.call(message).await?; + // Wait until the action is dispatched. + let _ = handler.call(message::GetConfig).await?; + + let event = events_rx.try_recv(); + assert!(matches!(event, Err(mpsc::error::TryRecvError::Empty))); + Ok(()) + } + + #[tokio::test] + async fn test_get_system() -> Result<(), Box> { + let (_events_rx, handler) = start_testing_service(); + + let system = handler.call(message::GetSystem).await?; + assert_eq!(system.keymaps.len(), 2); + + Ok(()) + } + + #[tokio::test] + async fn test_get_proposal() -> Result<(), Box> { + let (_events_rx, handler) = start_testing_service(); + + let input_config = Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + let message = message::SetConfig::new(input_config.clone()); + handler.call(message).await?; + + let proposal = handler.call(message::GetProposal).await?; + assert_eq!(proposal.locale.to_string(), input_config.locale.unwrap()); + assert_eq!(proposal.keymap.to_string(), input_config.keymap.unwrap()); + assert_eq!( + proposal.timezone.to_string(), + input_config.timezone.unwrap() + ); + Ok(()) + } +} diff --git a/rust/agama-l10n/src/system_info.rs b/rust/agama-l10n/src/system_info.rs new file mode 100644 index 0000000000..63eace90f7 --- /dev/null +++ b/rust/agama-l10n/src/system_info.rs @@ -0,0 +1,64 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::model::{Keymap, LocaleEntry, ModelAdapter, TimezoneEntry}; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; + +/// Localization-related information of the system where the installer +/// is running. +#[serde_as] +#[derive(Clone, Debug, Serialize)] +pub struct SystemInfo { + /// List of know locales. + pub locales: Vec, + /// List of known timezones. + pub timezones: Vec, + /// List of known keymaps. + pub keymaps: Vec, + /// Locale of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub locale: LocaleId, + /// Keymap of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub keymap: KeymapId, + /// Timezone of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub timezone: TimezoneId, +} + +impl SystemInfo { + /// Reads the information from the system adapter. + pub fn read_from(model: &mut T) -> Self { + let locales = model.locales_db().entries().clone(); + let keymaps = model.keymaps_db().entries().clone(); + let timezones = model.timezones_db().entries().clone(); + + Self { + locales, + keymaps, + timezones, + locale: model.locale(), + keymap: model.keymap().unwrap(), + timezone: Default::default(), + } + } +} diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 86717c2b40..3303376ce4 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0" agama-utils = { path = "../agama-utils" } agama-network = { path = "../agama-network" } agama-locale-data = { path = "../agama-locale-data" } +agama-l10n = { path = "../agama-l10n" } async-trait = "0.1.83" futures-util = "0.3.30" jsonschema = { version = "0.30.0", default-features = false, features = [ diff --git a/rust/agama-lib/src/config.rs b/rust/agama-lib/src/config.rs new file mode 100644 index 0000000000..ddf81c5ffc --- /dev/null +++ b/rust/agama-lib/src/config.rs @@ -0,0 +1,21 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub use agama_l10n::Config; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index fb4e92ae21..51d2bc265e 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -21,7 +21,6 @@ use crate::{ auth::ClientId, jobs::Job, - localization::model::LocaleConfig, manager::InstallationPhase, network::model::NetworkChange, progress::Progress, @@ -35,6 +34,7 @@ use crate::{ }, users::{FirstUser, RootUser}, }; +use agama_l10n as l10n; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -81,10 +81,11 @@ impl Event { #[serde(tag = "type")] pub enum EventPayload { ClientConnected, - L10nConfigChanged(LocaleConfig), LocaleChanged { locale: String, }, + #[serde(rename = "l10n")] + L10nEvent(l10n::Event), DevicesDirty { dirty: bool, }, @@ -185,6 +186,12 @@ pub enum EventPayload { }, } +impl From for EventPayload { + fn from(value: l10n::Event) -> Self { + EventPayload::L10nEvent(value) + } +} + /// Makes it easier to create an event, reducing the boilerplate. /// /// # Event without additional data diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index bda21d4b34..61b78a822a 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -26,13 +26,13 @@ use crate::context::InstallationContext; use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; +use crate::l10n; use crate::questions::config::QuestionsConfig; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ - localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, - scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, - users::UserSettings, + network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, + software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, }; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; @@ -54,7 +54,7 @@ pub enum InstallSettingsError { /// /// This struct represents installation settings. It serves as an entry point and it is composed of /// other structs which hold the settings for each area ("users", "software", etc.). -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -66,6 +66,7 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub hostname: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub iscsi: Option>, #[serde(flatten)] pub user: Option, @@ -76,14 +77,16 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub product: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub storage: Option>, #[serde(rename = "legacyAutoyastStorage")] #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] pub storage_autoyast: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, + pub localization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 02fa38d1a7..b4a99cf9c8 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -45,6 +45,7 @@ pub mod auth; pub mod bootloader; +pub mod config; pub mod context; pub mod error; pub mod file_source; @@ -54,7 +55,6 @@ pub mod http; pub mod install_settings; pub mod issue; pub mod jobs; -pub mod localization; pub mod logs; pub mod manager; pub mod monitor; @@ -72,6 +72,7 @@ mod store; pub mod users; pub use store::Store; pub mod utils; +pub(crate) use agama_l10n as l10n; pub use agama_utils::{dbus, openapi}; use crate::error::ServiceError; diff --git a/rust/agama-lib/src/localization/http_client.rs b/rust/agama-lib/src/localization/http_client.rs deleted file mode 100644 index 57bfcba383..0000000000 --- a/rust/agama-lib/src/localization/http_client.rs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use super::model::LocaleConfig; -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; - -#[derive(Debug, thiserror::Error)] -pub enum LocalizationHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), -} - -pub struct LocalizationHTTPClient { - client: BaseHTTPClient, -} - -impl LocalizationHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_config(&self) -> Result { - Ok(self.client.get("/l10n/config").await?) - } - - pub async fn set_config( - &self, - config: &LocaleConfig, - ) -> Result<(), LocalizationHTTPClientError> { - Ok(self.client.patch_void("/l10n/config", config).await?) - } -} diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs deleted file mode 100644 index 9bda06fb48..0000000000 --- a/rust/agama-lib/src/localization/store.rs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the localization settings. -// TODO: for an overview see crate::store (?) - -use super::{ - http_client::LocalizationHTTPClientError, LocalizationHTTPClient, LocalizationSettings, -}; -use crate::{http::BaseHTTPClient, localization::model::LocaleConfig}; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing localization settings: {0}")] -pub struct LocalizationStoreError(#[from] LocalizationHTTPClientError); - -type LocalizationStoreResult = Result; - -/// Loads and stores the storage settings from/to the D-Bus service. -pub struct LocalizationStore { - localization_client: LocalizationHTTPClient, -} - -impl LocalizationStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - localization_client: LocalizationHTTPClient::new(client), - } - } - - pub fn new_with_client(client: LocalizationHTTPClient) -> Self { - Self { - localization_client: client, - } - } - - /// Consume *v* and return its first element, or None. - /// This is similar to VecDeque::pop_front but it consumes the whole Vec. - fn chestburster(mut v: Vec) -> Option { - if v.is_empty() { - None - } else { - Some(v.swap_remove(0)) - } - } - - pub async fn load(&self) -> LocalizationStoreResult { - let config = self.localization_client.get_config().await?; - - let opt_language = config.locales.and_then(Self::chestburster); - let opt_keyboard = config.keymap; - let opt_timezone = config.timezone; - - Ok(LocalizationSettings { - language: opt_language, - keyboard: opt_keyboard, - timezone: opt_timezone, - }) - } - - pub async fn store(&self, settings: &LocalizationSettings) -> LocalizationStoreResult<()> { - // clones are necessary as we have different structs owning their data - let opt_language = settings.language.clone(); - let opt_keymap = settings.keyboard.clone(); - let opt_timezone = settings.timezone.clone(); - - let config = LocaleConfig { - locales: opt_language.map(|s| vec![s]), - keymap: opt_keymap, - timezone: opt_timezone, - ui_locale: None, - ui_keymap: None, - }; - Ok(self.localization_client.set_config(&config).await?) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use httpmock::Method::PATCH; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - async fn localization_store( - mock_server_url: String, - ) -> Result> { - let bhc = - BaseHTTPClient::new(mock_server_url).map_err(LocalizationHTTPClientError::HTTP)?; - let client = LocalizationHTTPClient::new(bhc); - Ok(LocalizationStore::new_with_client(client)) - } - - #[test] - async fn test_getting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(GET).path("/api/l10n/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "locales": ["fr_FR.UTF-8"], - "keymap": "fr(dvorak)", - "timezone": "Europe/Paris" - }"#, - ); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - let settings = store.load().await?; - - let expected = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - // main assertion - assert_eq!(settings, expected); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(PATCH) - .path("/api/l10n/config") - .header("content-type", "application/json") - .body( - r#"{"locales":["fr_FR.UTF-8"],"keymap":"fr(dvorak)","timezone":"Europe/Paris","uiLocale":null,"uiKeymap":null}"# - ); - then.status(204); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - - let settings = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs index 2532df7366..cc8974480a 100644 --- a/rust/agama-lib/src/product.rs +++ b/rust/agama-lib/src/product.rs @@ -28,5 +28,5 @@ mod store; pub use client::{Product, ProductClient}; pub use http_client::ProductHTTPClient; -pub use settings::ProductSettings; +pub use settings::{AddonSettings, ProductSettings}; pub use store::{ProductStore, ProductStoreError}; diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs index a1348b707e..ed284e17d5 100644 --- a/rust/agama-lib/src/product/settings.rs +++ b/rust/agama-lib/src/product/settings.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; /// Addon settings for registration -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct AddonSettings { pub id: String, @@ -37,7 +37,7 @@ pub struct AddonSettings { } /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProductSettings { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) diff --git a/rust/agama-lib/src/scripts/settings.rs b/rust/agama-lib/src/scripts/settings.rs index dbb32d0ed7..e073aeb8fa 100644 --- a/rust/agama-lib/src/scripts/settings.rs +++ b/rust/agama-lib/src/scripts/settings.rs @@ -25,7 +25,7 @@ use crate::file_source::{FileSourceError, WithFileSource}; use super::{InitScript, PostPartitioningScript, PostScript, PreScript}; -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ScriptsConfig { /// User-defined pre-installation scripts diff --git a/rust/agama-lib/src/security/settings.rs b/rust/agama-lib/src/security/settings.rs index c67209c6b0..8c18cf3db9 100644 --- a/rust/agama-lib/src/security/settings.rs +++ b/rust/agama-lib/src/security/settings.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; use super::model::SSLFingerprint; /// Security settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct SecuritySettings { /// List of user selected patterns to install. diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs index 2a49d57ef7..b04e09f7be 100644 --- a/rust/agama-lib/src/software.rs +++ b/rust/agama-lib/src/software.rs @@ -29,5 +29,5 @@ mod store; pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; pub use http_client::{SoftwareHTTPClient, SoftwareHTTPClientError}; -pub use settings::SoftwareSettings; +pub use settings::{PatternsMap, PatternsSettings, SoftwareSettings}; pub use store::{SoftwareStore, SoftwareStoreError}; diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs index bf8d9b2dbb..16406a7eca 100644 --- a/rust/agama-lib/src/software/settings.rs +++ b/rust/agama-lib/src/software/settings.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; use super::model::RepositoryParams; /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct SoftwareSettings { /// List of user selected patterns to install. @@ -44,14 +44,14 @@ pub struct SoftwareSettings { pub only_required: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(untagged)] pub enum PatternsSettings { PatternsList(Vec), PatternsMap(PatternsMap), } -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] pub struct PatternsMap { #[serde(skip_serializing_if = "Option::is_none")] pub add: Option>, diff --git a/rust/agama-lib/src/storage/settings/dasd.rs b/rust/agama-lib/src/storage/settings/dasd.rs index a310220e8e..f2e423cd4c 100644 --- a/rust/agama-lib/src/storage/settings/dasd.rs +++ b/rust/agama-lib/src/storage/settings/dasd.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DASDConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/storage/settings/zfcp.rs b/rust/agama-lib/src/storage/settings/zfcp.rs index 79c227e534..222c69b167 100644 --- a/rust/agama-lib/src/storage/settings/zfcp.rs +++ b/rust/agama-lib/src/storage/settings/zfcp.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 24c17559db..ed70b32b97 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -27,7 +27,6 @@ use crate::{ hostname::store::{HostnameStore, HostnameStoreError}, http::BaseHTTPClient, install_settings::InstallSettings, - localization::{LocalizationStore, LocalizationStoreError}, manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, product::{ProductHTTPClient, ProductStore, ProductStoreError}, @@ -76,8 +75,6 @@ pub enum StoreError { #[error(transparent)] ISCSI(#[from] ISCSIHTTPClientError), #[error(transparent)] - Localization(#[from] LocalizationStoreError), - #[error(transparent)] Scripts(#[from] ScriptsStoreError), // FIXME: it uses the client instead of the store. #[error(transparent)] @@ -110,7 +107,6 @@ pub struct Store { security: SecurityStore, software: SoftwareStore, storage: StorageStore, - localization: LocalizationStore, scripts: ScriptsStore, iscsi_client: ISCSIHTTPClient, manager_client: ManagerHTTPClient, @@ -125,7 +121,6 @@ impl Store { dasd: DASDStore::new(http_client.clone()), files: FilesStore::new(http_client.clone()), hostname: HostnameStore::new(http_client.clone()), - localization: LocalizationStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::new(http_client.clone()), questions: QuestionsStore::new(http_client.clone()), @@ -155,7 +150,6 @@ impl Store { software: self.software.load().await?.to_option(), user: Some(self.users.load().await?), product: Some(self.product.load().await?), - localization: Some(self.localization.load().await?), scripts: self.scripts.load().await?.to_option(), zfcp: self.zfcp.load().await?, ..Default::default() @@ -208,11 +202,6 @@ impl Store { } // here detect if product is properly selected, so later it can be checked let is_product_selected = self.detect_selected_product().await?; - // ordering: localization after product as some product may miss some locales - if let Some(localization) = &settings.localization { - Store::ensure_selected_product(is_product_selected)?; - self.localization.store(localization).await?; - } if let Some(software) = &settings.software { Store::ensure_selected_product(is_product_selected)?; self.software.store(software).await?; diff --git a/rust/agama-lib/src/users.rs b/rust/agama-lib/src/users.rs index bd529189a1..fb2aa9ff72 100644 --- a/rust/agama-lib/src/users.rs +++ b/rust/agama-lib/src/users.rs @@ -29,5 +29,5 @@ mod store; pub use client::{FirstUser, RootUser, UsersClient}; pub use http_client::UsersHTTPClient; -pub use settings::{FirstUserSettings, RootUserSettings, UserSettings}; +pub use settings::{FirstUserSettings, RootUserSettings, UserPassword, UserSettings}; pub use store::{UsersStore, UsersStoreError}; diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index 6ee63906ab..3e4805124b 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -25,7 +25,7 @@ use super::{FirstUser, RootUser}; /// User settings /// /// Holds the user settings for the installation. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserSettings { #[serde(rename = "user")] @@ -38,7 +38,7 @@ pub struct UserSettings { /// First user settings /// /// Holds the settings for the first user. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct FirstUserSettings { /// First user's full name @@ -92,7 +92,7 @@ impl From for FirstUserSettings { /// Represents a user password. /// /// It holds the password and whether it is a hashed or a plain text password. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserPassword { /// User password @@ -105,7 +105,7 @@ pub struct UserPassword { /// Root user settings /// /// Holds the settings for the root user. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RootUserSettings { /// Root user password diff --git a/rust/agama-locale-data/Cargo.toml b/rust/agama-locale-data/Cargo.toml index b99d07b509..5dc9c4d888 100644 --- a/rust/agama-locale-data/Cargo.toml +++ b/rust/agama-locale-data/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0" serde = { version = "1.0.210", features = ["derive"] } quick-xml = { version = "0.37.5", features = ["serialize"] } flate2 = "1.0.34" diff --git a/rust/agama-server/src/l10n/error.rs b/rust/agama-locale-data/src/error.rs similarity index 58% rename from rust/agama-server/src/l10n/error.rs rename to rust/agama-locale-data/src/error.rs index deb350dc08..ad5985b7b4 100644 --- a/rust/agama-server/src/l10n/error.rs +++ b/rust/agama-locale-data/src/error.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,20 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_locale_data::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; +use quick_xml::DeError; #[derive(thiserror::Error, Debug)] -pub enum LocaleError { - #[error("Unknown locale code: {0}")] - UnknownLocale(LocaleId), - #[error("Invalid locale: {0}")] - InvalidLocale(#[from] InvalidLocaleCode), - #[error("Unknown timezone: {0}")] - UnknownTimezone(String), - #[error("Unknown keymap: {0}")] - UnknownKeymap(KeymapId), - #[error("Invalid keymap: {0}")] - InvalidKeymap(#[from] InvalidKeymap), - #[error("Could not apply the l10n settings: {0}")] - Commit(#[from] std::io::Error), +pub enum LocaleDataError { + #[error("Could not read file {0}")] + IO(String, #[source] std::io::Error), + #[error("Could not deserialize langtable data from {0}")] + Deserialize(String, #[source] DeError), + #[error("Could not read the keymaps")] + CouldNotReadKeymaps(#[source] std::io::Error), } diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 7475d939a5..1215eb5be8 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use anyhow::Context; use flate2::bufread::GzDecoder; +use keyboard::xkeyboard; use quick_xml::de::Deserializer; -use serde::Deserialize; +use serde::de::DeserializeOwned; use std::collections::HashMap; use std::fs::File; use std::io::BufRead; @@ -29,6 +29,7 @@ use std::io::BufReader; use std::process::Command; pub mod deprecated_timezones; +mod error; pub mod keyboard; pub mod language; mod locale; @@ -37,27 +38,36 @@ pub mod ranked; pub mod territory; pub mod timezone_part; -use keyboard::xkeyboard; +pub use error::LocaleDataError; -pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; +pub type LocaleDataResult = Result; -fn file_reader(file_path: &str) -> anyhow::Result { - let file = File::open(file_path) - .with_context(|| format!("Failed to read langtable-data ({})", file_path))?; +pub use locale::{ + InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId, TimezoneId, +}; + +fn file_reader(file_path: &str) -> LocaleDataResult { + let file = File::open(file_path).map_err(|e| LocaleDataError::IO(file_path.to_string(), e))?; let reader = BufReader::new(GzDecoder::new(BufReader::new(file))); Ok(reader) } -/// Gets list of X11 keyboards structs -pub fn get_xkeyboards() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/keyboards.xml.gz"; - let reader = file_reader(FILE_PATH)?; +fn get_xml_data(file_path: &str) -> LocaleDataResult +where + T: DeserializeOwned, +{ + let reader = file_reader(file_path)?; let mut deserializer = Deserializer::from_reader(reader); - let ret = xkeyboard::XKeyboards::deserialize(&mut deserializer) - .context("Failed to deserialize keyboard entry")?; + let ret = T::deserialize(&mut deserializer) + .map_err(|e| LocaleDataError::Deserialize(file_path.to_string(), e))?; Ok(ret) } +/// Gets list of X11 keyboards structs +pub fn get_xkeyboards() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/keyboards.xml.gz") +} + /// Gets list of available keymaps /// /// ## Examples @@ -70,55 +80,42 @@ pub fn get_xkeyboards() -> anyhow::Result { /// let us: KeymapId = "us".parse().unwrap(); /// assert!(key_maps.contains(&us)); /// ``` -pub fn get_localectl_keymaps() -> anyhow::Result> { +pub fn get_localectl_keymaps() -> LocaleDataResult> { let output = Command::new("localectl") .arg("list-keymaps") .output() - .context("failed to execute localectl list-maps")? + .map_err(LocaleDataError::CouldNotReadKeymaps)? .stdout; - let output = String::from_utf8(output).context("Strange localectl output formatting")?; + let output = String::from_utf8_lossy(&output); let ret: Vec<_> = output.lines().flat_map(|l| l.parse().ok()).collect(); Ok(ret) } /// Returns struct which contain list of known languages -pub fn get_languages() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/languages.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = language::Languages::deserialize(&mut deserializer) - .context("Failed to deserialize language entry")?; - Ok(ret) +pub fn get_languages() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/languages.xml.gz") } /// Returns struct which contain list of known territories -pub fn get_territories() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/territories.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = territory::Territories::deserialize(&mut deserializer) - .context("Failed to deserialize territory entry")?; - Ok(ret) +pub fn get_territories() -> LocaleDataResult { + get_xml_data::("/usr/share/langtable/data/territories.xml.gz") } /// Returns struct which contain list of known parts of timezones. Useful for translation -pub fn get_timezone_parts() -> anyhow::Result { - const FILE_PATH: &str = "/usr/share/langtable/data/timezoneidparts.xml.gz"; - let reader = file_reader(FILE_PATH)?; - let mut deserializer = Deserializer::from_reader(reader); - let ret = timezone_part::TimezoneIdParts::deserialize(&mut deserializer) - .context("Failed to deserialize timezone part entry")?; - Ok(ret) +pub fn get_timezone_parts() -> LocaleDataResult { + get_xml_data::( + "/usr/share/langtable/data/timezoneidparts.xml.gz", + ) } /// Returns a hash mapping timezones to its main country (typically, the country of /// the city that is used to name the timezone). The information is read from the /// file /usr/share/zoneinfo/zone.tab. -pub fn get_timezone_countries() -> anyhow::Result> { +pub fn get_timezone_countries() -> LocaleDataResult> { const FILE_PATH: &str = "/usr/share/zoneinfo/zone.tab"; let content = std::fs::read_to_string(FILE_PATH) - .with_context(|| format!("Failed to read {}", FILE_PATH))?; + .map_err(|e| LocaleDataError::IO(FILE_PATH.to_string(), e))?; let countries = content .lines() diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index 65bb72b91a..efe54bdd5c 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -21,12 +21,40 @@ //! Defines useful types to deal with localization values use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; -#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct TimezoneId(String); + +impl Default for TimezoneId { + fn default() -> Self { + Self("Europe/Berlin".to_string()) + } +} + +impl Display for TimezoneId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clone, Error, Debug)] +#[error("Not a valid timezone: {0}")] +pub struct InvalidTimezoneId(String); + +impl FromStr for TimezoneId { + type Err = InvalidTimezoneId; + + // TODO: implement real parsing of the string. + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct LocaleId { // ISO-639 pub language: String, @@ -55,20 +83,20 @@ impl Default for LocaleId { } } -#[derive(Error, Debug)] +#[derive(Clone, Error, Debug)] #[error("Not a valid locale string: {0}")] -pub struct InvalidLocaleCode(String); +pub struct InvalidLocaleId(String); -impl TryFrom<&str> for LocaleId { - type Error = InvalidLocaleCode; +impl FromStr for LocaleId { + type Err = InvalidLocaleId; - fn try_from(value: &str) -> Result { + fn from_str(s: &str) -> Result { let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)(?:\.(.+))?").unwrap(); let captures = locale_regexp - .captures(value) - .ok_or_else(|| InvalidLocaleCode(value.to_string()))?; + .captures(s) + .ok_or_else(|| InvalidLocaleId(s.to_string()))?; let encoding = captures .get(3) @@ -100,7 +128,7 @@ static KEYMAP_ID_REGEX: OnceLock = OnceLock::new(); /// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` -#[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct KeymapId { /// Keyboard layout (e.g., "es" in "es(ast)") pub layout: String, @@ -119,7 +147,7 @@ impl Default for KeymapId { #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] -pub struct InvalidKeymap(String); +pub struct InvalidKeymapId(String); impl KeymapId { pub fn dashed(&self) -> String { @@ -142,7 +170,7 @@ impl Display for KeymapId { } impl FromStr for KeymapId { - type Err = InvalidKeymap; + type Err = InvalidKeymapId; fn from_str(s: &str) -> Result { let re = KEYMAP_ID_REGEX @@ -176,7 +204,7 @@ impl FromStr for KeymapId { variant, }) } else { - Err(InvalidKeymap(s.to_string())) + Err(InvalidKeymapId(s.to_string())) } } } diff --git a/rust/agama-network/src/settings.rs b/rust/agama-network/src/settings.rs index 2f1b2724da..db9a4f6120 100644 --- a/rust/agama-network/src/settings.rs +++ b/rust/agama-network/src/settings.rs @@ -28,7 +28,7 @@ use std::default::Default; use std::net::IpAddr; /// Network settings for installation -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { /// Connections to use in the installation diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index cde9f6c8a6..52136c2167 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -11,16 +11,17 @@ anyhow = "1.0" agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } +agama-l10n = { path = "../agama-l10n" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" serde = { version = "1.0.210", features = ["derive"] } -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } regex = "1.11.0" async-trait = "0.1.83" -axum = { version = "0.7.7", features = ["ws"] } +axum = { version = "0.7.7", features = ["ws", "macros"] } serde_json = "1.0.128" tower-http = { version = "0.6.2", features = [ "compression-br", @@ -56,6 +57,8 @@ gethostname = "1.0.0" tokio-util = "0.7.12" tempfile = "3.13.0" url = "2.5.2" +merge-struct = "0.1.0" +strum = { version = "0.27.2", features = ["derive"] } [[bin]] name = "agama-dbus-server" diff --git a/rust/agama-server/src/agama-dbus-server.rs b/rust/agama-server/src/agama-dbus-server.rs index 8cb57f4b47..879d1ab86a 100644 --- a/rust/agama-server/src/agama-dbus-server.rs +++ b/rust/agama-server/src/agama-dbus-server.rs @@ -18,11 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_server::{ - l10n::{self, helpers}, - logs::init_logging, - questions, -}; +use agama_l10n::helpers as l10n_helpers; +use agama_server::{logs::init_logging, questions}; use agama_lib::connection_to; use anyhow::Context; @@ -33,7 +30,8 @@ const SERVICE_NAME: &str = "org.opensuse.Agama1"; #[tokio::main] async fn main() -> Result<(), Box> { - let locale = helpers::init_locale()?; + let locale = l10n_helpers::init_locale()?; + tracing::info!("Using locale {}", locale); init_logging().context("Could not initialize the logger")?; let connection = connection_to(ADDRESS) diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index d64d3c3ff8..82d760c8af 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -24,10 +24,10 @@ use std::{ process::{ExitCode, Termination}, }; +use agama_l10n::helpers as l10n_helpers; use agama_lib::{auth::AuthToken, connection_to}; use agama_server::{ cert::Certificate, - l10n::helpers, logs::init_logging, web::{self, run_monitor}, }; @@ -316,7 +316,7 @@ async fn start_server(address: String, service: Router, ssl_acceptor: SslAccepto /// Start serving the API. /// `options`: command-line arguments. async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { - _ = helpers::init_locale(); + _ = l10n_helpers::init_locale(); init_logging().context("Could not initialize the logger")?; let (tx, _) = channel(16); diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index b632342b62..1466882fda 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -27,7 +27,6 @@ use axum::{ use serde_json::json; use crate::{ - l10n::LocaleError, users::password::PasswordCheckerError, web::common::{IssuesServiceError, ProgressServiceError}, }; @@ -42,8 +41,6 @@ pub enum Error { Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), - #[error("Software service error: {0}")] - Locale(#[from] LocaleError), #[error("Issues service error: {0}")] Issues(#[from] IssuesServiceError), #[error("Progress service error: {0}")] diff --git a/rust/agama-server/src/l10n/model.rs b/rust/agama-server/src/l10n/model.rs deleted file mode 100644 index a55f20d163..0000000000 --- a/rust/agama-server/src/l10n/model.rs +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::fs::OpenOptions; -use std::io::Write; -use std::process::Command; - -use crate::error::Error; -use agama_locale_data::InvalidLocaleCode; -use agama_locale_data::{KeymapId, LocaleId}; -use regex::Regex; - -pub mod keyboard; -pub mod locale; -pub mod timezone; - -pub use keyboard::Keymap; -pub use locale::LocaleEntry; -pub use timezone::TimezoneEntry; - -use super::{helpers, LocaleError}; -use keyboard::KeymapsDatabase; -use locale::LocalesDatabase; -use timezone::TimezonesDatabase; - -pub struct L10n { - pub timezone: String, - pub timezones_db: TimezonesDatabase, - pub locales: Vec, - pub locales_db: LocalesDatabase, - pub keymap: KeymapId, - pub keymaps_db: KeymapsDatabase, - pub ui_locale: LocaleId, - pub ui_keymap: KeymapId, -} - -impl L10n { - pub fn new_with_locale(ui_locale: &LocaleId) -> Result { - const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; - - let locale = ui_locale.to_string(); - let mut locales_db = LocalesDatabase::new(); - locales_db.read(&locale)?; - - let mut default_locale = ui_locale.clone(); - if !locales_db.exists(ui_locale) { - // TODO: handle the case where the database is empty (not expected!) - default_locale = locales_db.entries().first().unwrap().id.clone(); - }; - - let mut timezones_db = TimezonesDatabase::new(); - timezones_db.read(&ui_locale.language)?; - - let mut default_timezone = DEFAULT_TIMEZONE.to_string(); - if !timezones_db.exists(&default_timezone) { - default_timezone = timezones_db.entries().first().unwrap().code.to_string(); - }; - - let mut keymaps_db = KeymapsDatabase::new(); - keymaps_db.read()?; - - let locale = Self { - keymap: "us".parse().unwrap(), - timezone: default_timezone, - locales: vec![default_locale], - locales_db, - timezones_db, - keymaps_db, - ui_locale: ui_locale.clone(), - ui_keymap: Self::ui_keymap()?, - }; - - Ok(locale) - } - - pub fn set_locales(&mut self, locales: &Vec) -> Result<(), LocaleError> { - let locale_ids: Result, InvalidLocaleCode> = locales - .iter() - .cloned() - .map(|l| l.as_str().try_into()) - .collect(); - let locale_ids = locale_ids?; - - for loc in &locale_ids { - if !self.locales_db.exists(loc) { - return Err(LocaleError::UnknownLocale(loc.clone())); - } - } - - self.locales = locale_ids; - Ok(()) - } - - pub fn set_timezone(&mut self, timezone: &str) -> Result<(), LocaleError> { - // TODO: modify exists() to receive an `&str` - if !self.timezones_db.exists(&timezone.to_string()) { - return Err(LocaleError::UnknownTimezone(timezone.to_string()))?; - } - timezone.clone_into(&mut self.timezone); - Ok(()) - } - - pub fn set_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { - if !self.keymaps_db.exists(&keymap_id) { - return Err(LocaleError::UnknownKeymap(keymap_id)); - } - - self.keymap = keymap_id; - Ok(()) - } - - // TODO: use LocaleError - pub fn translate(&mut self, locale: &LocaleId) -> Result<(), Error> { - helpers::set_service_locale(locale); - self.timezones_db.read(&locale.language)?; - self.locales_db.read(&locale.language)?; - self.ui_locale = locale.clone(); - Ok(()) - } - - // TODO: use LocaleError - pub fn set_ui_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { - if !self.keymaps_db.exists(&keymap_id) { - return Err(LocaleError::UnknownKeymap(keymap_id)); - } - - self.ui_keymap = keymap_id; - - Command::new("localectl") - .args(["set-keymap", &self.ui_keymap.dashed()]) - .output() - .map_err(LocaleError::Commit)?; - Ok(()) - } - - // TODO: what should be returned value for commit? - pub fn commit(&self) -> Result<(), LocaleError> { - const ROOT: &str = "/mnt"; - const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; - - let locale = self.locales.first().cloned().unwrap_or_default(); - let mut cmd = Command::new("/usr/bin/systemd-firstboot"); - cmd.args([ - "--root", - ROOT, - "--force", - "--locale", - &locale.to_string(), - "--keymap", - &self.keymap.dashed(), - "--timezone", - &self.timezone, - ]); - tracing::info!("{:?}", &cmd); - - let output = cmd.output()?; - tracing::info!("{:?}", &output); - - // unfortunately the console font cannot be set via the "systemd-firstboot" tool, - // we need to write it directly to the config file - if let Some(entry) = self.locales_db.find_locale(&locale) { - if let Some(font) = &entry.consolefont { - // the font entry is missing in a file created by "systemd-firstboot", just append it at the end - let mut file = OpenOptions::new() - .append(true) - .open(format!("{}{}", ROOT, VCONSOLE_CONF))?; - - tracing::info!("Configuring console font \"{:?}\"", font); - writeln!(file, "\nFONT={}.psfu", font)?; - } - } - - Ok(()) - } - - fn ui_keymap() -> Result { - let output = Command::new("localectl") - .output() - .map_err(LocaleError::Commit)?; - let output = String::from_utf8_lossy(&output.stdout); - - let keymap_regexp = Regex::new(r"(?m)VC Keymap: (.+)$").unwrap(); - let captures = keymap_regexp.captures(&output); - let keymap = captures - .and_then(|c| c.get(1).map(|e| e.as_str())) - .unwrap_or("us") - .to_string(); - - let keymap_id: KeymapId = keymap.parse().unwrap_or(KeymapId::default()); - Ok(keymap_id) - } -} diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs deleted file mode 100644 index 9a430df27d..0000000000 --- a/rust/agama-server/src/l10n/web.rs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the localization module. - -use super::{ - error::LocaleError, - model::{keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n}, -}; -use crate::{error::Error, web::EventsSender}; -use agama_lib::{ - auth::ClientId, error::ServiceError, event, localization::model::LocaleConfig, - proxies::LocaleMixinProxy as ManagerLocaleProxy, -}; -use agama_locale_data::LocaleId; -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - routing::{get, patch, post}, - Extension, Json, Router, -}; -use std::sync::Arc; -use tokio::sync::RwLock; - -#[derive(Clone)] -struct LocaleState<'a> { - locale: Arc>, - manager_proxy: ManagerLocaleProxy<'a>, - events: EventsSender, -} - -/// Sets up and returns the axum service for the localization module. -/// -/// * `events`: channel to send the events to the main service. -pub async fn l10n_service( - dbus: zbus::Connection, - events: EventsSender, -) -> Result { - let id = LocaleId::default(); - let locale = L10n::new_with_locale(&id).unwrap(); - let manager_proxy = ManagerLocaleProxy::new(&dbus).await?; - let state = LocaleState { - locale: Arc::new(RwLock::new(locale)), - manager_proxy, - events, - }; - - let router = Router::new() - .route("/keymaps", get(keymaps)) - .route("/locales", get(locales)) - .route("/timezones", get(timezones)) - .route("/config", patch(set_config).get(get_config)) - .route("/finish", post(finish)) - .with_state(state); - Ok(router) -} - -#[utoipa::path( - get, - path = "/locales", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known locales", body = Vec) - ) -)] -async fn locales(State(state): State>) -> Json> { - let data = state.locale.read().await; - let locales = data.locales_db.entries().to_vec(); - Json(locales) -} - -#[utoipa::path( - get, - path = "/timezones", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known timezones", body = Vec) - ) -)] -async fn timezones(State(state): State>) -> Json> { - let data = state.locale.read().await; - let timezones = data.timezones_db.entries().to_vec(); - Json(timezones) -} - -#[utoipa::path( - get, - path = "/keymaps", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known keymaps", body = Vec) - ) -)] -async fn keymaps(State(state): State>) -> Json> { - let data = state.locale.read().await; - let keymaps = data.keymaps_db.entries().to_vec(); - Json(keymaps) -} - -// TODO: update all or nothing -// TODO: send only the attributes that have changed -#[utoipa::path( - patch, - path = "/config", - context_path = "/api/l10n", - operation_id = "set_l10n_config", - responses( - (status = 204, description = "Set the locale configuration", body = LocaleConfig) - ) -)] -async fn set_config( - State(state): State>, - Extension(client_id): Extension>, - Json(value): Json, -) -> Result { - let mut data = state.locale.write().await; - let mut changes = LocaleConfig::default(); - - if let Some(locales) = &value.locales { - data.set_locales(locales)?; - changes.locales.clone_from(&value.locales); - } - - if let Some(timezone) = &value.timezone { - data.set_timezone(timezone)?; - changes.timezone.clone_from(&value.timezone); - } - - if let Some(keymap_id) = &value.keymap { - let keymap_id = keymap_id.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_keymap(keymap_id)?; - changes.keymap.clone_from(&value.keymap); - } - - if let Some(ui_locale) = &value.ui_locale { - let locale = ui_locale - .as_str() - .try_into() - .map_err(LocaleError::InvalidLocale)?; - data.translate(&locale)?; - let locale_string = locale.to_string(); - state.manager_proxy.set_locale(&locale_string).await?; - changes.ui_locale = Some(locale_string); - _ = state.events.send(event!(LocaleChanged { - locale: locale.to_string(), - })); - } - - if let Some(ui_keymap) = &value.ui_keymap { - let ui_keymap = ui_keymap.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_ui_keymap(ui_keymap)?; - } - - _ = state - .events - .send(event!(L10nConfigChanged(changes), client_id.as_ref())); - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, - path = "/config", - context_path = "/api/l10n", - operation_id = "get_l10n_config", - responses( - (status = 200, description = "Localization configuration", body = LocaleConfig) - ) -)] -async fn get_config(State(state): State>) -> Json { - let data = state.locale.read().await; - let locales = data.locales.iter().map(ToString::to_string).collect(); - Json(LocaleConfig { - locales: Some(locales), - keymap: Some(data.keymap.to_string()), - timezone: Some(data.timezone.to_string()), - ui_locale: Some(data.ui_locale.to_string()), - ui_keymap: Some(data.ui_keymap.to_string()), - }) -} - -#[utoipa::path( - get, - path = "/finish", - context_path = "/api/l10n", - operation_id = "l10n_finish", - responses( - (status = 200, description = "Finish the l10n configuration") - ) -)] -async fn finish(State(state): State>) -> Result { - let data = state.locale.read().await; - data.commit()?; - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index bf7a8c5889..3ae7ee4c52 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -24,7 +24,6 @@ pub mod dbus; pub mod error; pub mod files; pub mod hostname; -pub mod l10n; pub mod logs; pub mod manager; pub mod network; @@ -37,3 +36,5 @@ pub mod storage; pub mod users; pub mod web; pub use web::service; +pub mod server; +pub(crate) mod supervisor; diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/server.rs similarity index 78% rename from rust/agama-server/src/l10n.rs rename to rust/agama-server/src/server.rs index 401062f3fa..a429170043 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/server.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,11 +18,5 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod error; -pub mod helpers; -mod model; pub mod web; - -pub use agama_lib::localization::model::LocaleConfig; -pub use error::LocaleError; -pub use model::{Keymap, L10n, LocaleEntry, TimezoneEntry}; +pub use web::server_service; diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs new file mode 100644 index 0000000000..62f5b0822a --- /dev/null +++ b/rust/agama-server/src/server/web.rs @@ -0,0 +1,346 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements Agama's HTTP API. + +use crate::{ + supervisor::{self, message, ConfigScope, Scope, Service, SystemInfo}, + web::EventsSender, +}; +use agama_lib::{error::ServiceError, install_settings::InstallSettings}; +use agama_utils::actor::Handler; +use anyhow; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use hyper::StatusCode; +use serde::Serialize; +use serde_json::json; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The given configuration does not belong to the '{0}' scope.")] + Scope(Scope), + #[error(transparent)] + Supervisor(#[from] supervisor::service::Error), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + tracing::warn!("Server return error {}", self); + let body = json!({ + "error": self.to_string() + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() + } +} + +fn to_option_response(value: Option) -> Response { + match value { + Some(inner) => Json(inner).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +#[derive(Clone)] +pub struct ServerState { + supervisor: Handler, +} + +type ServerResult = Result; + +/// Sets up and returns the axum service for the manager module +pub async fn server_service(events: EventsSender) -> Result { + let supervisor = supervisor::start(events) + .await + .map_err(|e| anyhow::Error::new(e))?; + + let state = ServerState { supervisor }; + + Ok(Router::new() + .route("/system", get(get_system)) + .route("/extended_config/:scope", get(get_extended_config_scope)) + .route("/extended_config", get(get_extended_config)) + .route( + "/config/:scope", + get(get_config_scope) + .put(put_config_scope) + .patch(patch_config_scope), + ) + .route( + "/config", + get(get_config).put(put_config).patch(patch_config), + ) + .route("/proposal", get(get_proposal)) + .route("/action", post(run_action)) + .with_state(state)) +} + +/// Returns the information about the system. +#[utoipa::path( + get, + path = "/system", + context_path = "/api/v2", + responses( + (status = 200, description = "System information."), + (status = 400, description = "Not possible to retrieve the system information.") + ) +)] +async fn get_system(State(state): State) -> ServerResult> { + let system = state.supervisor.call(message::GetSystem).await?; + Ok(Json(system)) +} + +/// Returns the extended configuration. +#[utoipa::path( + get, + path = "/extended_config", + context_path = "/api/v2", + responses( + (status = 200, description = "Extended configuration"), + (status = 400, description = "Not possible to retrieve the configuration.") + ) +)] +async fn get_extended_config( + State(state): State, +) -> ServerResult> { + let config = state.supervisor.call(message::GetExtendedConfig).await?; + Ok(Json(config)) +} + +/// Returns the extended configuration for the given scope. +#[utoipa::path( + get, + path = "/extended_config/{scope}", + context_path = "/api/v2", + responses( + (status = 200, description = "Extended configuration for the given scope."), + (status = 400, description = "Not possible to retrieve the configuration scope.") + ), + params( + ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") + ) +)] +async fn get_extended_config_scope( + State(state): State, + Path(scope): Path, +) -> ServerResult { + let config = state + .supervisor + .call(message::GetExtendedConfigScope::new(scope)) + .await?; + Ok(to_option_response(config)) +} + +/// Returns the configuration. +#[utoipa::path( + get, + path = "/config", + context_path = "/api/v2", + responses( + (status = 200, description = "Configuration."), + (status = 400, description = "Not possible to retrieve the configuration.") + ) +)] +async fn get_config(State(state): State) -> ServerResult> { + let config = state.supervisor.call(message::GetConfig).await?; + Ok(Json(config)) +} + +/// Returns the configuration for the given scope. +#[utoipa::path( + get, + path = "/config/{scope}", + context_path = "/api/v2", + responses( + (status = 200, description = "Configuration for the given scope."), + (status = 400, description = "Not possible to retrieve the configuration scope.") + ), + params( + ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") + ) +)] +async fn get_config_scope( + State(state): State, + Path(scope): Path, +) -> ServerResult { + let config = state + .supervisor + .call(message::GetConfigScope::new(scope)) + .await?; + Ok(to_option_response(config)) +} + +/// Updates the configuration. +/// +/// Replaces the whole configuration. If some value is missing, it will be removed. +#[utoipa::path( + put, + path = "/config", + context_path = "/api/v2", + responses( + (status = 200, description = "The configuration was replaced. Other operations can be running in background."), + (status = 400, description = "Not possible to replace the configuration.") + ), + params( + ("config" = InstallSettings, description = "Configuration to apply.") + ) +)] +async fn put_config( + State(state): State, + Json(config): Json, +) -> ServerResult<()> { + state + .supervisor + .call(message::SetConfig::new(config)) + .await?; + Ok(()) +} + +/// Patches the configuration. +/// +/// It only changes the specified values, keeping the rest as they are. +#[utoipa::path( + patch, + path = "/config", + context_path = "/api/v2", + responses( + (status = 200, description = "The configuration was patched. Other operations can be running in background."), + (status = 400, description = "Not possible to patch the configuration.") + ), + params( + ("config" = InstallSettings, description = "Changes in the configuration.") + ) +)] +async fn patch_config( + State(state): State, + Json(config): Json, +) -> ServerResult<()> { + state + .supervisor + .call(message::UpdateConfig::new(config)) + .await?; + Ok(()) +} + +/// Updates the configuration for the given scope. +/// +/// Replaces the whole scope. If some value is missing, it will be removed. +#[utoipa::path( + put, + path = "/config/{scope}", + context_path = "/api/v2", + responses( + (status = 200, description = "The configuration scope was replaced. Other operations can be running in background."), + (status = 400, description = "Not possible to replace the configuration scope.") + ), + params( + ("config" = InstallSettings, description = "Configuration scope to apply."), + ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'localization', etc).") + ) +)] +async fn put_config_scope( + State(state): State, + Path(scope): Path, + Json(config_scope): Json, +) -> ServerResult<()> { + if config_scope.to_scope() != scope { + return Err(Error::Scope(scope)); + } + + state + .supervisor + .call(message::SetConfigScope::new(config_scope)) + .await?; + Ok(()) +} + +/// Patches the configuration for the given scope. +/// +/// It only chagnes the specified values, keeping the rest as they are. +#[utoipa::path( + patch, + path = "/config/{scope}", + context_path = "/api/v2", + responses( + (status = 200, description = "The configuration scope was patched. Other operations can be running in background."), + (status = 400, description = "Not possible to patch the configuration scope.") + ), + params( + ("config" = InstallSettings, description = "Changes in the configuration scope."), + ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") + ) +)] +async fn patch_config_scope( + State(state): State, + Path(scope): Path, + Json(config_scope): Json, +) -> ServerResult<()> { + if config_scope.to_scope() != scope { + return Err(Error::Scope(scope)); + } + + state + .supervisor + .call(message::UpdateConfigScope::new(config_scope)) + .await?; + Ok(()) +} + +/// Returns how the target system is configured (proposal). +#[utoipa::path( + get, + path = "/proposal", + context_path = "/api/v2", + responses( + (status = 200, description = "Proposal successfully retrieved."), + (status = 400, description = "Not possible to retrieve the proposal.") + ) +)] +async fn get_proposal(State(state): State) -> ServerResult { + let proposal = state.supervisor.call(message::GetProposal).await?; + Ok(to_option_response(proposal)) +} + +#[utoipa::path( + post, + path = "/actions", + context_path = "/api/v2", + responses( + (status = 200, description = "Action successfully run."), + (status = 400, description = "Not possible to run the action.", body = Object) + ), + params( + ("action" = message::Action, description = "Description of the action to run."), + ) +)] +async fn run_action( + State(state): State, + Json(action): Json, +) -> ServerResult<()> { + state + .supervisor + .call(message::RunAction::new(action)) + .await?; + Ok(()) +} diff --git a/rust/agama-lib/src/localization.rs b/rust/agama-server/src/supervisor.rs similarity index 72% rename from rust/agama-lib/src/localization.rs rename to rust/agama-server/src/supervisor.rs index 6d3ae18db3..138b36ab20 100644 --- a/rust/agama-lib/src/localization.rs +++ b/rust/agama-server/src/supervisor.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,13 +18,21 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! Implements support for handling the localization settings +mod start; +pub use start::start; -mod http_client; -pub mod model; -mod settings; -mod store; +pub mod service; +pub use service::Service; -pub use http_client::LocalizationHTTPClient; -pub use settings::LocalizationSettings; -pub use store::{LocalizationStore, LocalizationStoreError}; +mod scope; +pub use scope::{ConfigScope, Scope}; + +mod system_info; +pub use system_info::SystemInfo; + +pub mod message; + +mod listener; +mod proposal; + +pub use agama_l10n as l10n; diff --git a/rust/agama-server/src/supervisor/listener.rs b/rust/agama-server/src/supervisor/listener.rs new file mode 100644 index 0000000000..bc4864543c --- /dev/null +++ b/rust/agama-server/src/supervisor/listener.rs @@ -0,0 +1,65 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::web::EventsSender; +use agama_lib::http::{Event, EventPayload}; +use std::pin::Pin; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamMap}; + +/// Listens for events of each service and retransmit them over the websocket. +/// +/// The events from each service comes in their own types (e.g., +/// `agama_l10n::Event`) and has to be converted to the [Event +/// struct](agama_lib::http::Event). +pub struct EventsListener { + inner: StreamMap<&'static str, Pin + Send>>>, + sender: EventsSender, +} + +impl EventsListener { + pub fn new(sender: EventsSender) -> Self { + EventsListener { + inner: StreamMap::new(), + sender, + } + } + + pub fn add_channel( + &mut self, + name: &'static str, + channel: mpsc::UnboundedReceiver, + ) where + EventPayload: From, + { + let stream = + UnboundedReceiverStream::new(channel).map(|e| Event::new(EventPayload::from(e))); + self.inner.insert(name, Box::pin(stream)); + } + + pub async fn run(self) { + let mut stream = self.inner; + while let Some((_, event)) = stream.next().await { + if let Err(e) = self.sender.send(event) { + tracing::error!("Could no retransmit the event: {e}"); + }; + } + } +} diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-server/src/supervisor/message.rs new file mode 100644 index 0000000000..8f1e0b7640 --- /dev/null +++ b/rust/agama-server/src/supervisor/message.rs @@ -0,0 +1,180 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::supervisor::{ + l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, system_info::SystemInfo, +}; +use agama_lib::install_settings::InstallSettings; +use agama_utils::actor::Message; +use serde::Deserialize; + +/// Gets the information of the underlying system. +#[derive(Debug)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +/// Gets the full config. +/// +/// It includes user and default values. +#[derive(Debug)] +pub struct GetExtendedConfig; + +impl Message for GetExtendedConfig { + type Reply = InstallSettings; +} + +/// Gets a scope from the full config. +#[derive(Debug)] +pub struct GetExtendedConfigScope { + pub scope: Scope, +} + +impl GetExtendedConfigScope { + pub fn new(scope: Scope) -> Self { + Self { scope } + } +} + +impl Message for GetExtendedConfigScope { + type Reply = Option; +} + +/// Gets the current config set by the user. +#[derive(Debug)] +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = InstallSettings; +} + +/// Replaces the config. +#[derive(Debug)] +pub struct SetConfig { + pub config: InstallSettings, +} + +impl SetConfig { + pub fn new(config: InstallSettings) -> Self { + Self { config } + } +} + +impl Message for SetConfig { + type Reply = (); +} + +/// Updates the config. +#[derive(Debug)] +pub struct UpdateConfig { + pub config: InstallSettings, +} + +impl UpdateConfig { + pub fn new(config: InstallSettings) -> Self { + Self { config } + } +} + +impl Message for UpdateConfig { + type Reply = (); +} + +/// Gets a scope from the config. +#[derive(Debug)] +pub struct GetConfigScope { + pub scope: Scope, +} + +impl GetConfigScope { + pub fn new(scope: Scope) -> Self { + Self { scope } + } +} + +impl Message for GetConfigScope { + type Reply = Option; +} + +/// Sets a config scope +#[derive(Debug)] +pub struct SetConfigScope { + pub config: ConfigScope, +} + +impl SetConfigScope { + pub fn new(config: ConfigScope) -> Self { + Self { config } + } +} + +impl Message for SetConfigScope { + type Reply = (); +} + +/// Updates a config scope +#[derive(Debug)] +pub struct UpdateConfigScope { + pub config: ConfigScope, +} + +impl UpdateConfigScope { + pub fn new(config: ConfigScope) -> Self { + Self { config } + } +} + +impl Message for UpdateConfigScope { + type Reply = (); +} + +/// Gets the proposal. +#[derive(Debug)] +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +/// Runs the given action. +#[derive(Debug)] +pub struct RunAction { + pub action: Action, +} + +impl RunAction { + pub fn new(action: Action) -> Self { + Self { action } + } +} + +impl Message for RunAction { + type Reply = (); +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub enum Action { + #[serde(rename = "configureL10n")] + ConfigureL10n(l10n::message::SystemConfig), + #[serde(rename = "install")] + Install, +} diff --git a/rust/agama-server/src/supervisor/proposal.rs b/rust/agama-server/src/supervisor/proposal.rs new file mode 100644 index 0000000000..f916cbe198 --- /dev/null +++ b/rust/agama-server/src/supervisor/proposal.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::supervisor::l10n; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct Proposal { + pub localization: l10n::Proposal, +} diff --git a/rust/agama-server/src/supervisor/scope.rs b/rust/agama-server/src/supervisor/scope.rs new file mode 100644 index 0000000000..cf3d304da8 --- /dev/null +++ b/rust/agama-server/src/supervisor/scope.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::supervisor::l10n; +use serde::{Deserialize, Serialize}; + +#[derive( + Copy, Clone, Debug, strum::EnumString, strum::Display, Deserialize, PartialEq, utoipa::ToSchema, +)] +#[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum Scope { + L10n, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum ConfigScope { + L10n(l10n::Config), +} + +impl ConfigScope { + pub fn to_scope(&self) -> Scope { + match &self { + Self::L10n(_) => Scope::L10n, + } + } +} diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs new file mode 100644 index 0000000000..42c09e3105 --- /dev/null +++ b/rust/agama-server/src/supervisor/service.rs @@ -0,0 +1,229 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::supervisor::{ + l10n, + message::{self, Action}, + proposal::Proposal, + scope::{ConfigScope, Scope}, + system_info::SystemInfo, +}; +use agama_lib::install_settings::InstallSettings; +use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use async_trait::async_trait; +use merge_struct::merge; +use std::convert::Infallible; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Cannot merge the configuration")] + MergeConfig, + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + L10n(#[from] l10n::service::Error), + #[error("Infallible")] + Infallible(#[from] Infallible), +} + +pub struct Service { + l10n: Handler, + config: InstallSettings, +} + +impl Service { + pub fn new(l10n: Handler) -> Self { + Self { + l10n, + config: InstallSettings::default(), + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the information of the underlying system. + async fn handle(&mut self, _message: message::GetSystem) -> Result { + let l10n_system = self.l10n.call(l10n::message::GetSystem).await?; + Ok(SystemInfo { + localization: l10n_system, + }) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Gets the current configuration. + /// + /// It includes user and default values. + async fn handle( + &mut self, + _message: message::GetExtendedConfig, + ) -> Result { + let l10n_config = self.l10n.call(l10n::message::GetConfig).await?; + Ok(InstallSettings { + localization: Some(l10n_config), + ..Default::default() + }) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the configuration for the given scope. + async fn handle( + &mut self, + message: message::GetExtendedConfigScope, + ) -> Result, Error> { + let option = match message.scope { + Scope::L10n => { + let l10n_config = self.l10n.call(l10n::message::GetConfig).await?; + Some(ConfigScope::L10n(l10n_config)) + } + }; + Ok(option) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Gets the current configuration set by the user. + /// + /// It includes only the values that were set by the user. + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.config.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Sets the user configuration with the given values. + /// + /// It merges the values in the top-level. Therefore, if the configuration + /// for a scope is not given, it keeps the previous one. + /// + /// FIXME: We should replace not given sections with the default ones. + /// After all, now we have config/user/:scope URLs. + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + if let Some(l10n_config) = &message.config.localization { + self.l10n + .call(l10n::message::SetConfig::new(l10n_config.clone())) + .await?; + } + self.config = message.config; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Patches the user configuration with the given values. + /// + /// It merges the current configuration with the given one. + async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { + let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; + self.handle(message::SetConfig::new(config)).await + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the configuration set by the user for the given scope. + async fn handle( + &mut self, + message: message::GetConfigScope, + ) -> Result, Error> { + // FIXME: implement this logic at InstallSettings level: self.get_config().by_scope(...) + // It would allow us to drop this method. + let option = match message.scope { + Scope::L10n => self + .config + .localization + .clone() + .map(|c| ConfigScope::L10n(c)), + }; + Ok(option) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Sets the user configuration within the given scope. + /// + /// It replaces the current configuration with the given one and calculates a + /// new proposal. Only the configuration in the given scope is affected. + async fn handle(&mut self, message: message::SetConfigScope) -> Result<(), Error> { + match message.config { + ConfigScope::L10n(l10n_config) => { + self.l10n + .call(l10n::message::SetConfig::new(l10n_config.clone())) + .await?; + self.config.localization = Some(l10n_config); + } + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// Patches the user configuration within the given scope. + /// + /// It merges the current configuration with the given one. + async fn handle(&mut self, message: message::UpdateConfigScope) -> Result<(), Error> { + match message.config { + ConfigScope::L10n(l10n_config) => { + let base_config = self.config.localization.clone().unwrap_or_default(); + let config = merge(&base_config, &l10n_config).map_err(|_| Error::MergeConfig)?; + self.handle(message::SetConfigScope::new(ConfigScope::L10n(config))) + .await?; + } + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It returns the current proposal, if any. + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + let localization = self.l10n.call(l10n::message::GetProposal).await?; + Ok(Some(Proposal { localization })) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It runs the given action. + async fn handle(&mut self, message: message::RunAction) -> Result<(), Error> { + match message.action { + Action::ConfigureL10n(config) => { + let l10n_message = l10n::message::SetSystem::new(config); + self.l10n.call(l10n_message).await?; + } + Action::Install => self.l10n.call(l10n::message::Install).await?, + } + Ok(()) + } +} diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs new file mode 100644 index 0000000000..cf23cd8302 --- /dev/null +++ b/rust/agama-server/src/supervisor/start.rs @@ -0,0 +1,132 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + supervisor::{l10n, listener::EventsListener, service::Service}, + web::EventsSender, +}; +use agama_utils::actor::{self, Handler}; +use tokio::sync::mpsc; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Could not start the l10n service")] + L10n(#[from] l10n::start::Error), +} + +/// Starts the supervisor service. +/// +/// It starts two Tokio tasks: +/// +/// * The main service, called "Supervisor", which coordinates the rest of services +/// an entry point for the HTTP API. +/// * An events listener which retransmit the events from all the services. +/// +/// It receives the following argument: +/// +/// * `events`: channel to emit the [events](agama_lib::http::Event). +pub async fn start(events: EventsSender) -> Result, Error> { + let mut listener = EventsListener::new(events); + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); + let l10n = l10n::start(events_sender).await?; + listener.add_channel("l10n", events_receiver); + tokio::spawn(async move { + listener.run().await; + }); + + let service = Service::new(l10n); + let handler = actor::spawn(service); + Ok(handler) +} + +#[cfg(test)] +mod test { + use crate::supervisor::{self, l10n, message, service::Service}; + use agama_lib::{http::Event, install_settings::InstallSettings}; + use agama_utils::actor::Handler; + use tokio::sync::broadcast; + + async fn start_service() -> Handler { + let (events_tx, _events_rx) = broadcast::channel::(16); + supervisor::start(events_tx).await.unwrap() + } + + #[tokio::test] + #[cfg(not(ci))] + async fn test_update_config() -> Result<(), Box> { + let handler = start_service().await; + + let input_config = InstallSettings { + localization: Some(l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), + ..Default::default() + }; + + handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + + let config = handler.call(message::GetConfig).await?; + + assert_eq!( + input_config.localization.unwrap(), + config.localization.unwrap() + ); + + Ok(()) + } + + #[tokio::test] + #[cfg(not(ci))] + async fn test_patch_config() -> Result<(), Box> { + let handler = start_service().await; + + let input_config = InstallSettings { + localization: Some(l10n::Config { + keymap: Some("es".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + handler + .call(message::UpdateConfig::new(input_config.clone())) + .await?; + + let config = handler.call(message::GetConfig).await?; + + assert_eq!( + input_config.localization.unwrap(), + config.localization.unwrap() + ); + + let extended_config = handler.call(message::GetExtendedConfig).await?; + let l10n_config = extended_config.localization.unwrap(); + + assert!(l10n_config.locale.is_some()); + assert!(l10n_config.keymap.is_some()); + assert!(l10n_config.timezone.is_some()); + + Ok(()) + } +} diff --git a/rust/agama-server/src/supervisor/system_info.rs b/rust/agama-server/src/supervisor/system_info.rs new file mode 100644 index 0000000000..3bed029ed3 --- /dev/null +++ b/rust/agama-server/src/supervisor/system_info.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::supervisor::l10n; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct SystemInfo { + pub localization: l10n::SystemInfo, +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 5bd99ac18f..9b7356ebe8 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -29,13 +29,13 @@ use crate::{ error::Error, files::web::files_service, hostname::web::hostname_service, - l10n::web::l10n_service, manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, profile::web::profile_service, questions::web::{questions_service, questions_stream}, scripts::web::scripts_service, security::security_service, + server::server_service, software::web::{software_service, software_streams}, storage::web::{iscsi::iscsi_service, storage_service, storage_streams}, users::web::{users_service, users_streams}, @@ -84,11 +84,11 @@ where let progress = ProgressService::start(dbus.clone(), events.clone()).await; let router = MainServiceBuilder::new(events.clone(), web_ui_dir) - .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) .add_service( "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) + .add_service("/v2", server_service(events.clone()).await?) .add_service("/security", security_service(dbus.clone()).await?) .add_service( "/software", diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 0dec7d64e7..94d6b4cf84 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -20,6 +20,8 @@ use utoipa::openapi::{Components, Info, InfoBuilder, OpenApi, OpenApiBuilder, Paths}; +mod config; +pub use config::ConfigApiDocBuilder; mod hostname; pub use hostname::HostnameApiDocBuilder; mod network; @@ -30,8 +32,6 @@ mod bootloader; pub use bootloader::BootloaderApiDocBuilder; mod software; pub use software::SoftwareApiDocBuilder; -mod l10n; -pub use l10n::L10nApiDocBuilder; mod questions; pub use questions::QuestionsApiDocBuilder; mod profile; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs new file mode 100644 index 0000000000..dd1dbd24a9 --- /dev/null +++ b/rust/agama-server/src/web/docs/config.rs @@ -0,0 +1,168 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::openapi::schemas; +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; +pub struct ConfigApiDocBuilder; + +impl ApiDocBuilder for ConfigApiDocBuilder { + fn title(&self) -> String { + "Config HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema("IpAddr", schemas::ip_addr()) + .schema("IpInet", schemas::ip_inet()) + .schema("macaddr.MacAddr6", schemas::mac_addr6()) + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .build() + } +} diff --git a/rust/agama-server/src/web/docs/l10n.rs b/rust/agama-server/src/web/docs/l10n.rs deleted file mode 100644 index c717edbbaf..0000000000 --- a/rust/agama-server/src/web/docs/l10n.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; - -use super::ApiDocBuilder; - -pub struct L10nApiDocBuilder; - -impl ApiDocBuilder for L10nApiDocBuilder { - fn title(&self) -> String { - "Localization HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .build() - } -} diff --git a/rust/agama-server/tasks.md b/rust/agama-server/tasks.md new file mode 100644 index 0000000000..c6ce0c73a3 --- /dev/null +++ b/rust/agama-server/tasks.md @@ -0,0 +1,2 @@ +- [] Move server error to the supervisor. +- [] Add test to the config merge. diff --git a/rust/agama-server/tests/l10n.rs b/rust/agama-server/tests/l10n.rs deleted file mode 100644 index 2d4baa0ec1..0000000000 --- a/rust/agama-server/tests/l10n.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod common; - -use std::error::Error; - -use agama_server::l10n::web::l10n_service; -use axum::{ - body::Body, - http::{Request, StatusCode}, - Router, -}; -use common::{body_to_string, DBusServer}; -use tokio::{sync::broadcast::channel, test}; -use tower::ServiceExt; - -async fn build_service(dbus: zbus::Connection) -> Router { - let (tx, _) = channel(16); - l10n_service(dbus, tx).await.unwrap() -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_get_config() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/config") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_locales() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/locales") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""language":"English""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_keymaps() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/keymaps") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"us""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_timezones() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/timezones") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""code":"Atlantic/Canary""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_set_config_locales() -> Result<(), Box> { - use agama_lib::auth::ClientId; - use std::sync::Arc; - - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - - let content = "{\"locales\":[\"es_ES.UTF-8\"]}"; - let body = Body::from(content); - let request = Request::patch("/config") - .header("Content-Type", "application/json") - .extension(Arc::new(ClientId::new())) - .body(body)?; - let response = service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // check whether the value changed - let request = Request::get("/config") - .header("Content-Type", "application/json") - .body(Body::empty())?; - let response = service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""locales":["es_ES.UTF-8"]"#)); - - // TODO: check whether the D-Bus value was synchronized - - Ok(()) -} diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs new file mode 100644 index 0000000000..57df6f6ca8 --- /dev/null +++ b/rust/agama-server/tests/server_service.rs @@ -0,0 +1,205 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod common; + +use agama_l10n::Config; +use agama_lib::error::ServiceError; +use agama_lib::install_settings::InstallSettings; +use agama_server::server::server_service; +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + Router, +}; +use common::body_to_string; +use std::error::Error; +use tokio::{sync::broadcast::channel, test}; +use tower::ServiceExt; + +async fn build_server_service() -> Result { + let (tx, _rx) = channel(16); + + server_service(tx).await +} + +#[test] +#[cfg(not(ci))] +async fn test_get_extended_config() -> Result<(), Box> { + let server_service = build_server_service().await?; + let request = Request::builder() + .uri("/extended_config") + .body(Body::empty()) + .unwrap(); + + let response = server_service.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""locale""#)); + assert!(body.contains(r#""keymap""#)); + assert!(body.contains(r#""timezone""#)); + + Ok(()) +} + +#[test] +#[cfg(not(ci))] +async fn test_get_empty_config() -> Result<(), Box> { + let server_service = build_server_service().await?; + let request = Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(); + + let response = server_service.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_string(response.into_body()).await; + assert_eq!(&body, ""); + + Ok(()) +} + +#[test] +#[cfg(not(ci))] +async fn test_put_config() -> Result<(), Box> { + let localization = Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + + let mut config = InstallSettings { + localization: Some(localization), + ..Default::default() + }; + + let server_service = build_server_service().await?; + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PUT) + .body(serde_json::to_string(&config)?) + .unwrap(); + + let response = server_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let request = Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(); + + let response = server_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_string(response.into_body()).await; + assert!(body.contains( + r#""localization":{"locale":"es_ES.UTF-8","keymap":"es","timezone":"Atlantic/Canary"# + )); + + let localization = Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, + }; + config.localization = Some(localization); + + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PUT) + .body(serde_json::to_string(&config)?) + .unwrap(); + + let response = server_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let request = Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(); + + let response = server_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""localization":{"keymap":"en"}"#)); + + Ok(()) +} + +#[test] +#[cfg(not(ci))] +async fn test_patch_config() -> Result<(), Box> { + let localization = Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + + let mut config = InstallSettings { + localization: Some(localization), + ..Default::default() + }; + + let server_service = build_server_service().await?; + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PUT) + .body(serde_json::to_string(&config)?) + .unwrap(); + + let response = server_service.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let localization = Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, + }; + config.localization = Some(localization); + + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PATCH) + .body(serde_json::to_string(&config)?) + .unwrap(); + + let response = server_service.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let request = Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(); + + let response = server_service.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_string(response.into_body()).await; + assert!(body.contains( + r#""localization":{"locale":"es_ES.UTF-8","keymap":"en","timezone":"Atlantic/Canary"# + )); + + Ok(()) +} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 777c1034c8..7320e29d5a 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -5,7 +5,13 @@ rust-version.workspace = true edition.workspace = true [dependencies] +async-trait = "0.1.89" serde_json = "1.0.140" +thiserror = "2.0.16" +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/actor.rs b/rust/agama-utils/src/actor.rs new file mode 100644 index 0000000000..de0cb03eef --- /dev/null +++ b/rust/agama-utils/src/actor.rs @@ -0,0 +1,272 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a tiny actors system to be used by Agama services. +//! +//! It is a minimal implementation which does not include supervision, links, +//! etc. It only includes: +//! +//! * An [Actor] trait to mark its implementors as potential actors. +//! * A [Message] trait to define actors messages, defining its return type. +//! * A [Handler] trait to implement the behavior of an particular actor (which +//! implements [Actor]) when it receives a given message. +//! * A generic struct [ActorHandler] which allows sending messages to a given +//! actor. +//! * A [spawn_actor] function to run the actor on a separate thread. It returns +//! an [ActorHandler] to interact with the actor. +//! +//! The approach ensures compile-time checks of the messages an actor can +//! handle. +//! +//! Let's have a look to an example implementing a simple counter. +//! +//! ``` +//! use agama_utils::actor::{ +//! self, Actor, Error, Message, MessageHandler +//! }; +//! use async_trait::async_trait; +//! +//! #[derive(Default)] +//! pub struct MyActor { +//! counter: u32, +//! } +//! +//! #[derive(thiserror::Error, Debug)] +//! pub enum MyActorError { +//! #[error("Actor system error")] +//! Actor(#[from] Error), +//! } +//! +//! impl Actor for MyActor { +//! type Error = MyActorError; +//! } +//! +//! pub struct Inc { +//! amount: u32, +//! } +//! +//! pub struct Get; +//! +//! impl Message for Inc { +//! type Reply = (); +//! } +//! +//! impl Message for Get { +//! type Reply = u32; +//! } +//! +//! #[async_trait] +//! impl MessageHandler for MyActor { +//! async fn handle(&mut self, message: Inc) -> Result<(), MyActorError> { +//! self.counter += message.amount; +//! Ok(()) +//! } +//! } +//! +//! #[async_trait] +//! impl MessageHandler for MyActor { +//! async fn handle(&mut self, _message: Get) -> Result { +//! Ok(self.counter) +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() { +//! let actor = MyActor::default(); +//! // Spawn a separate Tokio task to run the actor. +//! let handle = actor::spawn(actor); +//! +//! // Send some messages using the "call" function. +//! _ = handle.call(Inc { amount: 5 }).await; +//! let value = handle.call(Get).await.unwrap_or_default(); +//! assert_eq!(value, 5); +//! +//! // If you prefer, you can send a message and forget about the answer using the "cast" function. +//! _ = handle.cast(Inc { amount: 1 }); +//! } +//! ``` + +use async_trait::async_trait; +use std::marker::PhantomData; +use tokio::sync::{mpsc, oneshot}; + +/// Internal actors errors, mostly communication issues. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Could not send a message to actor {0}")] + Send(&'static str), + #[error("Could not get a response from actor {0}")] + Response(&'static str), +} + +/// Marks its implementors as potential actors. +/// +/// It enables those structs to handle actors messages. +pub trait Actor: 'static + Send { + /// Actor error type. It should implement the conversion from the + /// [ActorError] type, which represents communication-level problems. + type Error: std::error::Error + From + Send + 'static; + + #[inline] + fn name() -> &'static str { + std::any::type_name::() + } +} + +/// Marker trait to indicate that a its implementor is a potential message. +pub trait Message: 'static + Send { + /// Defines the return type of the message. + type Reply: 'static + Send; +} + +type ReplySender = oneshot::Sender::Reply, ::Error>>; + +/// Represents a message for a given actor. +/// +/// It contains the message and the channel, if any, to send the reply. +struct Envelope +where + A: MessageHandler, +{ + message: Option, + _actor: PhantomData, + sender: Option>, +} + +impl Envelope +where + A: MessageHandler, +{ + pub fn new(message: M, sender: Option>) -> Self { + Self { + message: Some(message), + _actor: PhantomData, + sender, + } + } + + /// Processes the message using the given actor. + /// + /// The actor must implement [a handler](Handler) for this type of messages. + /// It takes care of sending the response if a sender channel was given. + pub async fn handle(&mut self, actor: &mut A) { + // To avoid clonning, we need to be able to take the value + // while keeping the &mut self reference valid. + let Some(msg) = self.message.take() else { + eprintln!("Did not find a message!"); + return; + }; + let result = actor.handle(msg).await; + if let Some(sender) = self.sender.take() { + _ = sender.send(result); + } + } +} + +/// Envelope handler. +/// +/// The handling mechanisms consist on calling a `handle` method for the message +/// and the actor. It is implemented for any [Actor] that implements a [Handler] +/// for a given [Messge]. +#[async_trait] +trait EnvelopeHandler: 'static + Send { + async fn handle(&mut self, actor: &mut A); +} + +#[async_trait] +impl EnvelopeHandler for Envelope +where + A: MessageHandler, +{ + async fn handle(&mut self, actor: &mut A) { + self.handle(actor).await; + } +} + +/// Implements an [Actor's](Actor) handler for a given [Message]. +#[async_trait] +pub trait MessageHandler: Actor { + async fn handle(&mut self, message: M) -> Result; +} + +/// Implements a mechanism to communicate with a given actor. +/// +/// An actor handle contains a channel to communicate with an Actor and offers a +/// set of functions to communicate with it. +/// +/// It is possible to clone a handler so you can interact with the actor from +/// different places. +pub struct Handler { + sender: mpsc::UnboundedSender>>, +} + +impl Clone for Handler { + fn clone(&self) -> Self { + let sender = self.sender.clone(); + Handler:: { sender } + } +} + +impl Handler { + /// Sends a message and waits for the answer. + /// + /// * `msg`: message to send to the actor. + pub async fn call(&self, msg: M) -> Result + where + A: MessageHandler, + { + let (tx, rx) = oneshot::channel(); + let message = Envelope::new(msg, Some(tx)); + self.sender + .send(Box::new(message)) + .map_err(|_| Error::Send(A::name()))?; + rx.await.map_err(|_| Error::Response(A::name()))? + } + + /// Sends a message and does not wait for the answer. + /// + /// * `msg`: message to send to the actor. + pub fn cast(&self, msg: M) -> Result<(), A::Error> + where + A: MessageHandler, + { + let message = Envelope::new(msg, None); + self.sender + .send(Box::new(message)) + .map_err(|_| Error::Send(A::name()))?; + Ok(()) + } +} + +/// Spawns a Tokio task and process the messages coming from the action handler. +/// +/// * `actor`: actor to spawn. +pub fn spawn(mut actor: A) -> Handler { + let (tx, mut rx) = mpsc::unbounded_channel(); + let handler = Handler:: { sender: tx }; + + tokio::spawn(async move { + while let Some(mut msg) = rx.recv().await { + msg.handle(&mut actor).await; + } + }); + + handler +} diff --git a/rust/agama-utils/src/dbus.rs b/rust/agama-utils/src/dbus.rs index 035ed71223..3f7616f740 100644 --- a/rust/agama-utils/src/dbus.rs +++ b/rust/agama-utils/src/dbus.rs @@ -18,6 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +//! Utilities to interact with D-Bus types. + use std::collections::HashMap; use zbus::{message::Type as MessageType, MatchRule, MessageStream}; use zvariant::{self, OwnedObjectPath, OwnedValue, Value}; diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 684b03ea28..402d851f26 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -21,5 +21,10 @@ //! This crate offers a set of utility struct and functions to be used accross //! other Agama's crates. +pub mod actor; + +pub mod service; +pub use service::Service; + pub mod dbus; pub mod openapi; diff --git a/rust/agama-utils/src/openapi.rs b/rust/agama-utils/src/openapi.rs index 79cfe6acae..da8e65feed 100644 --- a/rust/agama-utils/src/openapi.rs +++ b/rust/agama-utils/src/openapi.rs @@ -18,6 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +//! OpenAPI utility functions. + pub mod schemas { use serde_json::json; use utoipa::openapi::{ diff --git a/rust/agama-utils/src/service.rs b/rust/agama-utils/src/service.rs new file mode 100644 index 0000000000..ba1e01bf29 --- /dev/null +++ b/rust/agama-utils/src/service.rs @@ -0,0 +1,88 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Offers a trait to implement an Agama service. +//! +//! An Agama service is composed of, at least, two parts: +//! +//! * The service itself, which holds the configuration and takes care of +//! performing the changes at installation time. It is private to each +//! Agama module (agama-l10n, agama-network, etc.). It should implement +//! the [Service trait]. +//! * The handler, which offers an API to talk to the service. It should +//! implement the [Handler](crate::Handler) trait. + +use core::future::Future; +use std::{any, error}; +use tokio::sync::mpsc; + +/// Implements the basic behavior for an Agama service. +/// +/// It is responsible for: +/// +/// * Holding the configuration. +/// * Making an installation proposal for one aspect of the system +/// (localization, partitioning, etc.). +/// * Performing the changes a installation time. +/// * Optionally, making changes to the system running Agama +/// (e.g., changing the keyboard layout). +/// +/// Usually, a service runs on a separate task and receives the actions to +/// perform through a [mpsc::UnboundedReceiver +/// channel](tokio::sync::mpsc::UnboundedReceiver). +pub trait Service: Send { + type Err: error::Error; + type Message: Send; + + /// Returns the service name used for logging and debugging purposes. + /// + /// An example might be "agama_l10n::l10n::L10n". + fn name() -> &'static str { + any::type_name::() + } + + /// Main loop of the service. + /// + /// It dispatches one message at a time. + fn run(&mut self) -> impl Future + Send { + async { + loop { + let message = self.channel().recv().await; + let Some(message) = message else { + eprintln!("channel closed for {}", Self::name()); + break; + }; + + if let Err(error) = &mut self.dispatch(message).await { + eprintln!("error dispatching command: {error}"); + } + } + } + } + + /// Returns the channel to read the messages from. + fn channel(&mut self) -> &mut mpsc::UnboundedReceiver; + + /// Dispatches a message. + fn dispatch( + &mut self, + command: Self::Message, + ) -> impl Future> + Send; +} diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 0da8e5d8d8..63814f93ad 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -5,7 +5,7 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ - ApiDocBuilder, HostnameApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, + ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, QuestionsApiDocBuilder, ScriptsApiDocBuilder, SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, }; @@ -64,8 +64,8 @@ mod tasks { pub fn generate_openapi() -> std::io::Result<()> { let out_dir = create_output_dir("openapi")?; + write_openapi(ConfigApiDocBuilder {}, out_dir.join("config.json"))?; write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; - write_openapi(L10nApiDocBuilder {}, out_dir.join("l10n.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; diff --git a/service/lib/agama/http/clients.rb b/service/lib/agama/http/clients.rb index 3600c1209a..a835d05e51 100644 --- a/service/lib/agama/http/clients.rb +++ b/service/lib/agama/http/clients.rb @@ -29,7 +29,7 @@ module Clients require "agama/http/clients/base" require "agama/http/clients/files" -require "agama/http/clients/localization" +require "agama/http/clients/main" require "agama/http/clients/network" require "agama/http/clients/scripts" require "agama/http/clients/software" diff --git a/service/lib/agama/http/clients/localization.rb b/service/lib/agama/http/clients/main.rb similarity index 86% rename from service/lib/agama/http/clients/localization.rb rename to service/lib/agama/http/clients/main.rb index afe16c71dd..a6d70f5aec 100644 --- a/service/lib/agama/http/clients/localization.rb +++ b/service/lib/agama/http/clients/main.rb @@ -24,10 +24,10 @@ module Agama module HTTP module Clients - # HTTP client to interact with the localization API. - class Localization < Base - def finish - post("l10n/finish", nil) + # HTTP client to interact with the HTTP API. + class Main < Base + def install + post("v2/action", '"install"') end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index d6b199cd35..711afcaebb 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -135,7 +135,7 @@ def install_phase on_target do users.write network.install - language.finish + http_client.install software.finish storage.finish end @@ -184,11 +184,11 @@ def proxy ProxySetup.instance end - # Language manager + # HTTP client. # - # @return [HTTP::Clients::Localization] - def language - @language ||= Agama::HTTP::Clients::Localization.new(logger) + # @return [HTTP::Clients::Base] + def http_client + @http_client ||= Agama::HTTP::Clients::Main.new(logger) end # Users client diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 7dfc7085e6..0c666f6cc7 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -53,7 +53,7 @@ Agama::Users, write: nil, issues: [] ) end - let(:locale) { instance_double(Agama::HTTP::Clients::Localization, finish: nil) } + let(:http_client) { instance_double(Agama::HTTP::Clients::Main, install: nil) } let(:network) { instance_double(Agama::Network, install: nil, startup: nil) } let(:storage) do instance_double( @@ -72,7 +72,7 @@ before do allow(Agama::Network).to receive(:new).and_return(network) allow(Agama::ProxySetup).to receive(:instance).and_return(proxy) - allow(Agama::HTTP::Clients::Localization).to receive(:new).and_return(locale) + allow(Agama::HTTP::Clients::Main).to receive(:new).and_return(http_client) allow(Agama::DBus::Clients::Software).to receive(:new).and_return(software) allow(Agama::DBus::Clients::Storage).to receive(:new).and_return(storage) allow(Agama::Users).to receive(:new).and_return(users) @@ -156,7 +156,7 @@ expect(network).to receive(:install) expect(software).to receive(:install) expect(software).to receive(:finish) - expect(locale).to receive(:finish) + expect(http_client).to receive(:install) expect(storage).to receive(:install) expect(scripts).to receive(:run).with("postPartitioning") expect(storage).to receive(:finish) diff --git a/web/src/App.tsx b/web/src/App.tsx index 6fcb657bf3..258cf9883b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,7 +24,8 @@ import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Loading } from "~/components/layout"; import { useProduct, useProductChanges } from "~/queries/software"; -import { useL10nConfigChanges } from "~/queries/l10n"; +import { useProposalChanges } from "~/queries/proposal"; +import { useSystemChanges } from "~/queries/system"; import { useIssuesChanges } from "~/queries/issues"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; import { useDeprecatedChanges } from "~/queries/storage"; @@ -37,7 +38,8 @@ import AlertOutOfSync from "~/components/core/AlertOutOfSync"; * Main application component. */ function App() { - useL10nConfigChanges(); + useProposalChanges(); + useSystemChanges(); useProductChanges(); useIssuesChanges(); useInstallerStatusChanges(); diff --git a/web/src/api/api.ts b/web/src/api/api.ts new file mode 100644 index 0000000000..56d01542e7 --- /dev/null +++ b/web/src/api/api.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { get, patch, post } from "~/api/http"; +import { Proposal } from "~/types/proposal"; +import { System } from "~/types/system"; + +/** + * Returns the system config + */ +const fetchSystem = (): Promise => get("/api/v2/system"); + +/** + * Returns the proposal + */ +const fetchProposal = (): Promise => get("/api/v2/proposal"); + +/** + * Updates configuration + */ +const updateConfig = (config) => patch("/api/v2/config", config); +/** + * Triggers an action + */ +const trigger = (action) => post("/api/v2/action", action); + +export { fetchSystem, fetchProposal, updateConfig, trigger }; diff --git a/web/src/api/system.ts b/web/src/api/hostname.ts similarity index 96% rename from web/src/api/system.ts rename to web/src/api/hostname.ts index 7589dd0c10..dfac34b69e 100644 --- a/web/src/api/system.ts +++ b/web/src/api/hostname.ts @@ -21,7 +21,7 @@ */ import { get, put } from "~/api/http"; -import { Hostname } from "~/types/system"; +import { Hostname } from "~/types/hostname"; /** * Returns the hostname configuration diff --git a/web/src/api/l10n.ts b/web/src/api/l10n.ts deleted file mode 100644 index 224cae170b..0000000000 --- a/web/src/api/l10n.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { tzOffset } from "@date-fns/tz/tzOffset"; -import { get, patch } from "~/api/http"; -import { Keymap, Locale, LocaleConfig, Timezone } from "~/types/l10n"; - -/** - * Returns the l10n configuration - */ -const fetchConfig = (): Promise => get("/api/l10n/config"); - -/** - * Returns the list of known locales for installation - */ -const fetchLocales = async (): Promise => { - const json = await get("/api/l10n/locales"); - return json.map(({ id, language, territory }): Locale => { - return { id, name: language, territory }; - }); -}; - -/** - * Returns the list of known timezones - */ -const fetchTimezones = async (): Promise => { - const json = await get("/api/l10n/timezones"); - return json.map(({ code, parts, country }): Timezone => { - const offset = tzOffset(code, new Date()); - return { id: code, parts, country, utcOffset: offset }; - }); -}; - -/** - * Returns the list of known keymaps - */ -const fetchKeymaps = async (): Promise => { - const json = await get("/api/l10n/keymaps"); - const keymaps: Keymap[] = json.map(({ id, description }): Keymap => { - return { id, name: description }; - }); - return keymaps.sort((a, b) => (a.name < b.name ? -1 : 1)); -}; - -/** - * Updates the l10n configuration for the system to install - * - * @param config - Localization configuration - */ -const updateConfig = (config: LocaleConfig) => patch("/api/l10n/config", config); - -export { fetchConfig, fetchKeymaps, fetchLocales, fetchTimezones, updateConfig }; diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index 621bded93a..a4837246f9 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -28,16 +28,17 @@ import * as utils from "~/utils"; import { PRODUCT, ROOT } from "~/routes/paths"; import InstallerOptions, { InstallerOptionsProps } from "./InstallerOptions"; import { Product } from "~/types/software"; +import { Keymap, Locale } from "~/types/l10n"; let phase: InstallationPhase; let isBusy: boolean; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English (US)" }, { id: "gb", name: "English (UK)" }, ]; @@ -52,21 +53,19 @@ const tumbleweed: Product = { let mockSelectedProduct: Product; -const mockL10nConfigMutation = { - mutate: jest.fn(), -}; +const mockUpdateConfigFn = jest.fn(); const mockChangeUIKeymap = jest.fn(); const mockChangeUILanguage = jest.fn(); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), - useConfigMutation: () => mockL10nConfigMutation, - keymapsQuery: () => ({ - queryKey: ["keymaps"], - queryFn: () => keymaps, - }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ localization: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" } }), +})); + +jest.mock("~/api/api", () => ({ + ...jest.requireActual("~/api/api"), + updateConfig: (config) => mockUpdateConfigFn(config), })); jest.mock("~/queries/status", () => ({ @@ -188,9 +187,11 @@ describe("InstallerOptions", () => { await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ - locales: ["es_ES.UTF-8"], - keymap: "gb", + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { + locale: "es_ES.UTF-8", + keymap: "gb", + }, }); }); @@ -212,7 +213,7 @@ describe("InstallerOptions", () => { await user.selectOptions(languageSelector, "Español"); await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + expect(mockUpdateConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { @@ -307,8 +308,10 @@ describe("InstallerOptions", () => { await user.selectOptions(languageSelector, "Español"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ - locales: ["es_ES.UTF-8"], + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { + locale: "es_ES.UTF-8", + }, }); }); @@ -326,7 +329,7 @@ describe("InstallerOptions", () => { expect(reuseSettings).not.toBeChecked(); await user.selectOptions(languageSelector, "Español"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + expect(mockUpdateConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { @@ -396,8 +399,10 @@ describe("InstallerOptions", () => { await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).toHaveBeenCalledWith({ - keymap: "gb", + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { + keymap: "gb", + }, }); }); @@ -417,7 +422,7 @@ describe("InstallerOptions", () => { expect(reuseSettings).not.toBeChecked(); await user.selectOptions(keymapSelector, "English (UK)"); await user.click(acceptButton); - expect(mockL10nConfigMutation.mutate).not.toHaveBeenCalled(); + expect(mockUpdateConfigFn).not.toHaveBeenCalled(); }); it("includes a link to localization page", async () => { diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index f4bb4c8b10..7bce144c27 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -33,7 +33,6 @@ import React, { useReducer } from "react"; import { useHref, useLocation } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; import { Button, ButtonProps, @@ -48,16 +47,17 @@ import { } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { Icon } from "~/components/layout"; -import { LocaleConfig } from "~/types/l10n"; +import { Keymap, Locale } from "~/types/l10n"; import { InstallationPhase } from "~/types/status"; import { useInstallerL10n } from "~/context/installerL10n"; -import { keymapsQuery, useConfigMutation, useL10n } from "~/queries/l10n"; import { useInstallerStatus } from "~/queries/status"; import { localConnection } from "~/utils"; import { _ } from "~/i18n"; import supportedLanguages from "~/languages.json"; import { PRODUCT, ROOT, L10N } from "~/routes/paths"; import { useProduct } from "~/queries/software"; +import { useSystem } from "~/queries/system"; +import { updateConfig } from "~/api/api"; /** * Props for select inputs @@ -88,8 +88,9 @@ const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( * Not available in remote installations. */ const KeyboardFormInput = ({ value, onChange }: SelectProps) => { - const { isPending, data: keymaps } = useQuery(keymapsQuery()); - if (isPending) return; + const { + localization: { keymaps }, + } = useSystem(); if (!localConnection()) { return ( @@ -551,8 +552,9 @@ export default function InstallerOptions({ onClose, }: InstallerOptionsProps) { const location = useLocation(); - const { locales } = useL10n(); - const { mutate: updateSystemL10n } = useConfigMutation(); + const { + localization: { locales }, + } = useSystem(); const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); const { phase } = useInstallerStatus({ suspense: true }); const { selectedProduct } = useProduct({ suspense: true }); @@ -586,12 +588,12 @@ export default function InstallerOptions({ const reuseSettings = () => { // FIXME: export and use languageToLocale from context/installerL10n const systemLocale = locales.find((l) => l.id.startsWith(formState.language.replace("-", "_"))); - const systemL10n: Partial = {}; + const systemL10n: { locale?: Locale["id"]; keymap?: Keymap["id"] } = {}; // FIXME: use a fallback if no system locale was found ? - if (variant !== "keyboard") systemL10n.locales = [systemLocale?.id]; + if (variant !== "keyboard") systemL10n.locale = systemLocale?.id; if (variant !== "language" && localConnection()) systemL10n.keymap = formState.keymap; - updateSystemL10n(systemL10n); + updateConfig({ localization: systemL10n }); }; const close = () => { @@ -603,6 +605,13 @@ export default function InstallerOptions({ e.preventDefault(); dispatchDialogAction({ type: "SET_BUSY" }); + // TODO: send unique request for all; await no longer works here + // keep logical order, reuse first, the trigger second, to avoid the latest + // "eating" the first request. + // const request = {}; + // ... + // if(something) request.someelse = whatever + try { if (variant !== "language" && localConnection()) { await changeKeymap(formState.keymap); diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index c22c3c5894..31702f496e 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -25,24 +25,32 @@ import KeyboardSelection from "./KeyboardSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Keymap } from "~/types/l10n"; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English" }, { id: "es", name: "Spanish" }, ]; -const mockConfigMutation = { - mutate: jest.fn(), -}; +const mockUpdateConfigFn = jest.fn(); jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation, - useL10n: () => ({ keymaps, selectedKeymap: keymaps[0] }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ localization: { keymaps } }), +})); + +jest.mock("~/queries/proposal", () => ({ + ...jest.requireActual("~/queries/proposal"), + useProposal: () => ({ localization: { keymap: "us" } }), +})); + +jest.mock("~/api/api", () => ({ + ...jest.requireActual("~/api/api"), + updateConfig: (config) => mockUpdateConfigFn(config), })); jest.mock("react-router-dom", () => ({ @@ -57,6 +65,6 @@ it("allows changing the keyboard", async () => { await userEvent.click(option); const button = await screen.findByRole("button", { name: "Select" }); await userEvent.click(button); - expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ keymap: "es" }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ localization: { keymap: "es" } }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index febcd26890..ade94e37ce 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -24,16 +24,24 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; import { _ } from "~/i18n"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; // TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function KeyboardSelection() { const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { keymaps, selectedKeymap: currentKeymap } = useL10n(); - const [selected, setSelected] = useState(currentKeymap.id); + const { + localization: { keymaps }, + } = useSystem(); + const { + localization: { keymap: currentKeymap }, + } = useProposal(); + + // FIXME: get current keymap from either, proposal or config + const [selected, setSelected] = useState(currentKeymap); const [filteredKeymaps, setFilteredKeymaps] = useState( keymaps.sort((k1, k2) => (k1.name > k2.name ? 1 : -1)), ); @@ -42,7 +50,8 @@ export default function KeyboardSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - setConfig.mutate({ keymap: selected }); + // FIXME: udpate when new API is ready + updateConfig({ localization: { keymap: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index ae84c15dfd..3701dd9552 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -24,22 +24,25 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import L10nPage from "~/components/l10n/L10nPage"; +import { Keymap, Locale, Timezone } from "~/types/l10n"; +import { System } from "~/types/system"; +import { Proposal } from "~/types/proposal"; -let mockLoadedData; - -const locales = [ +let mockSystemData: System; +let mockProposedData: Proposal; +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English" }, { id: "es", name: "Spanish" }, ]; -const timezones = [ - { id: "Europe/Berlin", parts: ["Europe", "Berlin"] }, - { id: "Europe/Madrid", parts: ["Europe", "Madrid"] }, +const timezones: Timezone[] = [ + { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 120 }, + { id: "Europe/Madrid", parts: ["Europe", "Madrid"], country: "Spain", utcOffset: 120 }, ]; jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( @@ -48,19 +51,32 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( jest.mock("~/components/core/InstallerOptions", () => () =>
InstallerOptions Mock
); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => mockLoadedData, +jest.mock("~/queries/system", () => ({ + useSystem: () => mockSystemData, +})); + +jest.mock("~/queries/proposal", () => ({ + useProposal: () => mockProposedData, })); beforeEach(() => { - mockLoadedData = { - locales, - keymaps, - timezones, - selectedLocale: locales[0], - selectedKeymap: keymaps[0], - selectedTimezone: timezones[0], + mockSystemData = { + localization: { + locales, + keymaps, + timezones, + }, + }; + + mockProposedData = { + localization: { + locales, + keymaps, + timezones, + locale: "en_US.UTF-8", + keymap: "us", + timezone: "Europe/Berlin", + }, }; }); @@ -78,15 +94,15 @@ it("renders a section for configuring the language", () => { within(region).getByText("Change"); }); -describe("if there is no selected language", () => { +describe("if the language selected is wrong", () => { beforeEach(() => { - mockLoadedData.selectedLocale = undefined; + mockProposedData.localization.locale = "us_US.UTF-8"; }); it("renders a button for selecting a language", () => { installerRender(); const region = screen.getByRole("region", { name: "Language" }); - within(region).getByText("Not selected yet"); + within(region).getByText("Wrong selection"); within(region).getByText("Select"); }); }); @@ -98,15 +114,15 @@ it("renders a section for configuring the keyboard", () => { within(region).getByText("Change"); }); -describe("if there is no selected keyboard", () => { +describe("if the keyboard selected is wrong", () => { beforeEach(() => { - mockLoadedData.selectedKeymap = undefined; + mockProposedData.localization.keymap = "ess"; }); it("renders a button for selecting a keyboard", () => { installerRender(); const region = screen.getByRole("region", { name: "Keyboard" }); - within(region).getByText("Not selected yet"); + within(region).getByText("Wrong selection"); within(region).getByText("Select"); }); }); @@ -118,15 +134,15 @@ it("renders a section for configuring the time zone", () => { within(region).getByText("Change"); }); -describe("if there is no selected time zone", () => { +describe("if the time zone selected is wrong", () => { beforeEach(() => { - mockLoadedData.selectedTimezone = undefined; + mockProposedData.localization.timezone = "Europee/Beeerlin"; }); it("renders a button for selecting a time zone", () => { installerRender(); const region = screen.getByRole("region", { name: "Time zone" }); - within(region).getByText("Not selected yet"); + within(region).getByText("Wrong selection"); within(region).getByText("Select"); }); }); diff --git a/web/src/components/l10n/L10nPage.tsx b/web/src/components/l10n/L10nPage.tsx index d82a901e86..86a420086a 100644 --- a/web/src/components/l10n/L10nPage.tsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -24,9 +24,10 @@ import React from "react"; import { Button, Content, Grid, GridItem } from "@patternfly/react-core"; import { InstallerOptions, Link, Page } from "~/components/core"; import { L10N as PATHS } from "~/routes/paths"; -import { useL10n } from "~/queries/l10n"; -import { _ } from "~/i18n"; import { localConnection } from "~/utils"; +import { useProposal } from "~/queries/proposal"; +import { useSystem } from "~/queries/system"; +import { _ } from "~/i18n"; const InstallerL10nSettingsInfo = () => { const info = localConnection() @@ -66,7 +67,15 @@ const InstallerL10nSettingsInfo = () => { * Page for configuring localization. */ export default function L10nPage() { - const { selectedLocale: locale, selectedTimezone: timezone, selectedKeymap: keymap } = useL10n(); + // FIXME: retrieve selection from config when ready + const { localization: l10nProposal } = useProposal(); + const { localization: l10n } = useSystem(); + console.log(l10nProposal); + + const locale = l10nProposal.locale && l10n.locales.find((l) => l.id === l10nProposal.locale); + const keymap = l10nProposal.keymap && l10n.keymaps.find((k) => k.id === l10nProposal.keymap); + const timezone = + l10nProposal.timezone && l10n.timezones.find((t) => t.id === l10nProposal.timezone); return ( @@ -86,7 +95,7 @@ export default function L10nPage() { } > - {locale ? `${locale.name} - ${locale.territory}` : _("Not selected yet")} + {locale ? `${locale.name} - ${locale.territory}` : _("Wrong selection")} @@ -99,7 +108,7 @@ export default function L10nPage() { } > - {keymap ? keymap.name : _("Not selected yet")} + {keymap ? keymap.name : _("Wrong selection")} @@ -112,7 +121,7 @@ export default function L10nPage() { } > - {timezone ? (timezone.parts || []).join(" - ") : _("Not selected yet")} + {timezone ? (timezone.parts || []).join(" - ") : _("Wrong selection")} diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index b2d4a98aa5..f486d533f1 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -25,24 +25,32 @@ import LocaleSelection from "./LocaleSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Locale } from "~/types/l10n"; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; -const mockConfigMutation = { - mutate: jest.fn(), -}; +const mockUpdateConfigFn = jest.fn(); jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), - useConfigMutation: () => mockConfigMutation, +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ localization: { locales } }), +})); + +jest.mock("~/queries/proposal", () => ({ + ...jest.requireActual("~/queries/proposal"), + useProposal: () => ({ localization: { locales, locale: "us_US.UTF-8", keymap: "us" } }), +})); + +jest.mock("~/api/api", () => ({ + ...jest.requireActual("~/api/api"), + updateConfig: (config) => mockUpdateConfigFn(config), })); jest.mock("react-router-dom", () => ({ @@ -57,6 +65,8 @@ it("allows changing the keyboard", async () => { await userEvent.click(option); const button = await screen.findByRole("button", { name: "Select" }); await userEvent.click(button); - expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ locales: ["es_ES.UTF-8"] }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { locale: "es_ES.UTF-8" }, + }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 08e1219984..02613774b1 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -24,24 +24,30 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { _ } from "~/i18n"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { _ } from "~/i18n"; // TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function LocaleSelection() { const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { locales, selectedLocale: currentLocale } = useL10n(); - const [selected, setSelected] = useState(currentLocale.id); + const { + localization: { locales }, + } = useSystem(); + const { + localization: { locale: currentLocale }, + } = useProposal(); + const [selected, setSelected] = useState(currentLocale); const [filteredLocales, setFilteredLocales] = useState(locales); const searchHelp = _("Filter by language, territory or locale code"); const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - setConfig.mutate({ locales: [selected] }); + updateConfig({ localization: { locale: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 1077a34cef..d35791ef32 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -25,12 +25,15 @@ import TimezoneSelection from "./TimezoneSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Timezone } from "~/types/l10n"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -const timezones = [ +const mockUpdateConfigFn = jest.fn(); + +const timezones: Timezone[] = [ { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 120 }, { id: "Europe/Madrid", parts: ["Europe", "Madrid"], country: "Spain", utcOffset: 120 }, { @@ -47,14 +50,14 @@ const timezones = [ }, ]; -const mockConfigMutation = { - mutate: jest.fn(), -}; +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ localization: { timezones } }), +})); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation, - useL10n: () => ({ timezones, selectedTimezone: timezones[0] }), +jest.mock("~/queries/proposal", () => ({ + ...jest.requireActual("~/queries/proposal"), + useProposal: () => ({ localization: { timezones, timezone: "Europe/Berlin" } }), })); jest.mock("react-router-dom", () => ({ @@ -62,6 +65,11 @@ jest.mock("react-router-dom", () => ({ useNavigate: () => mockNavigateFn, })); +jest.mock("~/api/api", () => ({ + ...jest.requireActual("~/api/api"), + updateConfig: (config) => mockUpdateConfigFn(config), +})); + beforeEach(() => { const mockedDate = new Date(2024, 6, 1, 12, 0); @@ -81,7 +89,7 @@ it("allows changing the timezone", async () => { await user.click(option); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ timezone: "Europe/Madrid" }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ localization: { timezone: "Europe/Madrid" } }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 61db7b4414..b153e625ed 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -24,9 +24,11 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { timezoneTime } from "~/utils"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; import { Timezone } from "~/types/l10n"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; +import { timezoneTime } from "~/utils"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { _ } from "~/i18n"; @@ -66,17 +68,22 @@ const sortedTimezones = (timezones: Timezone[]) => { export default function TimezoneSelection() { date = new Date(); const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { timezones, selectedTimezone: currentTimezone } = useL10n(); + const { + localization: { timezones }, + } = useSystem(); + const { + localization: { timezone: currentTimezone }, + } = useProposal(); + const displayTimezones = timezones.map(timezoneWithDetails); - const [selected, setSelected] = useState(currentTimezone.id); + const [selected, setSelected] = useState(currentTimezone); const [filteredTimezones, setFilteredTimezones] = useState(sortedTimezones(displayTimezones)); const searchHelp = _("Filter by territory, time zone code or UTC offset"); const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - setConfig.mutate({ timezone: selected }); + updateConfig({ localization: { timezone: selected } }); navigate(-1); }; diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx index efb9a41ff0..ac2b234339 100644 --- a/web/src/components/overview/L10nSection.test.tsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -24,18 +24,29 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { L10nSection } from "~/components/overview"; +import { Locale } from "~/types/l10n"; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "de_DE.UTF-8", name: "German", territory: "Germany" }, ]; -jest.mock("~/queries/l10n", () => ({ - useL10n: () => ({ locales, selectedLocale: locales[0] }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ + localization: { locale: "en_US.UTF-8", locales, keymap: "us" }, + }), })); -it("displays the selected locale", async () => { - plainRender(, { withL10n: true }); +jest.mock("~/queries/proposal", () => ({ + ...jest.requireActual("~/queries/proposal"), + useProposal: () => ({ + localization: { locale: "en_US.UTF-8", keymap: "us" }, + }), +})); + +it("displays the selected locale", () => { + plainRender(); - await screen.findByText("English (United States)"); + expect(screen.getByText(/English \(United States\)/)).toBeInTheDocument(); }); diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx index 3c5c4fe8be..4cd450303c 100644 --- a/web/src/components/overview/L10nSection.tsx +++ b/web/src/components/overview/L10nSection.tsx @@ -22,11 +22,16 @@ import React from "react"; import { Content } from "@patternfly/react-core"; -import { useL10n } from "~/queries/l10n"; +import { useProposal } from "~/queries/proposal"; +import { useSystem } from "~/queries/system"; import { _ } from "~/i18n"; +import { Locale } from "~/types/l10n"; export default function L10nSection() { - const { selectedLocale: locale } = useL10n(); + const { localization: l10nProposal } = useProposal(); + const { localization: l10n } = useSystem(); + const locale = + l10nProposal.locale && l10n.locales.find((l: Locale) => l.id === l10nProposal.locale); // TRANSLATORS: %s will be replaced by a language name and territory, example: // "English (United States)". @@ -36,9 +41,9 @@ export default function L10nSection() { {_("Localization")} - {msg1} - {`${locale.name} (${locale.territory})`} - {msg2} + + {locale ? `${msg1}${locale.name} (${locale.territory})${msg2}` : _("Not selected yet")} + ); diff --git a/web/src/components/overview/SoftwareSection.test.tsx b/web/src/components/overview/SoftwareSection.test.tsx index cc3c0fbb86..147376d5f0 100644 --- a/web/src/components/overview/SoftwareSection.test.tsx +++ b/web/src/components/overview/SoftwareSection.test.tsx @@ -32,8 +32,8 @@ let mockTestingProposal: SoftwareProposal; jest.mock("~/queries/software", () => ({ usePatterns: () => mockTestingPatterns, - useProposal: () => mockTestingProposal, - useProposalChanges: () => jest.fn(), + useSoftwareProposal: () => mockTestingProposal, + useSoftwareProposalChanges: () => jest.fn(), })); describe("SoftwareSection", () => { diff --git a/web/src/components/overview/SoftwareSection.tsx b/web/src/components/overview/SoftwareSection.tsx index df497c35d8..e55940c4f7 100644 --- a/web/src/components/overview/SoftwareSection.tsx +++ b/web/src/components/overview/SoftwareSection.tsx @@ -23,15 +23,15 @@ import React from "react"; import { Content, List, ListItem } from "@patternfly/react-core"; import { SelectedBy } from "~/types/software"; -import { usePatterns, useProposal, useProposalChanges } from "~/queries/software"; +import { usePatterns, useSoftwareProposal, useSoftwareProposalChanges } from "~/queries/software"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; export default function SoftwareSection(): React.ReactNode { - const proposal = useProposal(); + const proposal = useSoftwareProposal(); const patterns = usePatterns(); - useProposalChanges(); + useSoftwareProposalChanges(); if (isEmpty(proposal.patterns)) return; diff --git a/web/src/components/product/ProductRegistrationPage.test.tsx b/web/src/components/product/ProductRegistrationPage.test.tsx index 0fd1f18ded..beacfdaffa 100644 --- a/web/src/components/product/ProductRegistrationPage.test.tsx +++ b/web/src/components/product/ProductRegistrationPage.test.tsx @@ -64,8 +64,8 @@ jest.mock("~/queries/software", () => ({ }, })); -jest.mock("~/queries/system", () => ({ - ...jest.requireActual("~/queries/system"), +jest.mock("~/queries/hostname", () => ({ + ...jest.requireActual("~/queries/hostname"), useHostname: () => ({ transient: "testing-node", static: staticHostnameMock }), })); diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx index d293c34fbb..4c3b586947 100644 --- a/web/src/components/product/ProductRegistrationPage.tsx +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -54,7 +54,7 @@ import RegistrationCodeInput from "./RegistrationCodeInput"; import { RegistrationParams } from "~/types/software"; import { HOSTNAME } from "~/routes/paths"; import { useProduct, useRegistration, useRegisterMutation, useAddons } from "~/queries/software"; -import { useHostname } from "~/queries/system"; +import { useHostname } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { mask } from "~/utils"; import { sprintf } from "sprintf-js"; diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 90023e785e..491ad9100c 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -27,6 +27,7 @@ import { AnswerCallback, Question } from "~/types/questions"; import { InstallationPhase } from "~/types/status"; import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; +import { Locale, Keymap } from "~/types/l10n"; let question: Question; const questionMock: Question = { @@ -45,11 +46,21 @@ const tumbleweed: Product = { registration: false, }; -const answerFn: AnswerCallback = jest.fn(); -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; +const keymaps: Keymap[] = [ + { id: "us", name: "English" }, + { id: "es", name: "Spanish" }, +]; + +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/l10n"), + useSystem: () => ({ localization: { locales, keymaps, keymap: "us", language: "de-DE" } }), +})); + +const answerFn: AnswerCallback = jest.fn(); jest.mock("~/queries/status", () => ({ useInstallerStatus: () => ({ @@ -58,11 +69,6 @@ jest.mock("~/queries/status", () => ({ }), })); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), -})); - jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), useProduct: () => { diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 67a8f5f0be..e70e292493 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -27,6 +27,7 @@ import { Question } from "~/types/questions"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; +import { Locale, Keymap } from "~/types/l10n"; const answerFn = jest.fn(); const question: Question = { @@ -45,11 +46,16 @@ const tumbleweed: Product = { registration: false, }; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; +const keymaps: Keymap[] = [ + { id: "us", name: "English" }, + { id: "es", name: "Spanish" }, +]; + jest.mock("~/queries/status", () => ({ useInstallerStatus: () => ({ phase: InstallationPhase.Config, @@ -57,9 +63,9 @@ jest.mock("~/queries/status", () => ({ }), })); -jest.mock("~/queries/l10n", () => ({ +jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), + useSystem: () => ({ localization: { locales, keymaps, keymap: "us", language: "de-DE" } }), })); jest.mock("~/queries/software", () => ({ @@ -78,7 +84,6 @@ jest.mock("~/context/installerL10n", () => ({ keymap: "us", language: "de-DE", }), - useL10n: jest.fn(), })); const renderQuestion = () => diff --git a/web/src/components/software/SoftwarePage.test.tsx b/web/src/components/software/SoftwarePage.test.tsx index 60a9845c4d..47a15c9cfe 100644 --- a/web/src/components/software/SoftwarePage.test.tsx +++ b/web/src/components/software/SoftwarePage.test.tsx @@ -38,8 +38,8 @@ jest.mock("~/queries/issues", () => ({ jest.mock("~/queries/software", () => ({ usePatterns: () => testingPatterns, - useProposal: () => testingProposal, - useProposalChanges: jest.fn(), + useSoftwareProposal: () => testingProposal, + useSoftwareProposalChanges: jest.fn(), useRepositories: () => [], useRepositoryMutation: () => ({ mutate: jest.fn() }), })); diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index a770a649e9..707d51e996 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -39,8 +39,8 @@ import UsedSize from "./UsedSize"; import { useIssues } from "~/queries/issues"; import { usePatterns, - useProposal, - useProposalChanges, + useSoftwareProposal, + useSoftwareProposalChanges, useRepositories, useRepositoryMutation, } from "~/queries/software"; @@ -134,14 +134,14 @@ const ReloadSection = ({ */ function SoftwarePage(): React.ReactNode { const issues = useIssues("software"); - const proposal = useProposal(); + const proposal = useSoftwareProposal(); const patterns = usePatterns(); const repos = useRepositories(); const [loading, setLoading] = useState(false); const { mutate: probe } = useRepositoryMutation(() => setLoading(false)); - useProposalChanges(); + useSoftwareProposalChanges(); // Selected patterns section should fill the full width in big screen too when // there is no information for rendering the Proposal Size section. diff --git a/web/src/components/system/HostnamePage.test.tsx b/web/src/components/system/HostnamePage.test.tsx index e8d6d60735..1debb771b1 100644 --- a/web/src/components/system/HostnamePage.test.tsx +++ b/web/src/components/system/HostnamePage.test.tsx @@ -60,8 +60,8 @@ jest.mock("~/queries/software", () => ({ }, })); -jest.mock("~/queries/system", () => ({ - ...jest.requireActual("~/queries/system"), +jest.mock("~/queries/hostname", () => ({ + ...jest.requireActual("~/queries/hostname"), useHostname: () => ({ transient: "agama-node", static: mockStaticHostname }), useHostnameMutation: () => ({ mutateAsync: mockHostnameMutation }), })); diff --git a/web/src/components/system/HostnamePage.tsx b/web/src/components/system/HostnamePage.tsx index a406d34397..40c078e51a 100644 --- a/web/src/components/system/HostnamePage.tsx +++ b/web/src/components/system/HostnamePage.tsx @@ -33,7 +33,7 @@ import { } from "@patternfly/react-core"; import { NestedContent, Page } from "~/components/core"; import { useProduct, useRegistration } from "~/queries/software"; -import { useHostname, useHostnameMutation } from "~/queries/system"; +import { useHostname, useHostnameMutation } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; diff --git a/web/src/context/installerL10n.test.tsx b/web/src/context/installerL10n.test.tsx index 42711edc60..a8cbb4e202 100644 --- a/web/src/context/installerL10n.test.tsx +++ b/web/src/context/installerL10n.test.tsx @@ -36,10 +36,10 @@ jest.mock("~/context/installer", () => ({ useInstallerClientStatus: () => ({ connected: true, error: false }), })); -jest.mock("~/api/l10n", () => ({ - ...jest.requireActual("~/api/l10n"), - fetchConfig: () => mockFetchConfigFn(), - updateConfig: (config) => mockUpdateConfigFn(config), +jest.mock("~/api/api", () => ({ + ...jest.requireActual("~/api/api"), + fetchSystem: () => mockFetchConfigFn(), + trigger: (config) => mockUpdateConfigFn(config), })); const client = { @@ -99,7 +99,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ uiLocale: "en_US.UTF-8" }); + mockFetchConfigFn.mockResolvedValue({ localization: { locale: "en_US.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -123,7 +123,7 @@ describe("InstallerL10nProvider", () => { // Ensure both, UI and backend mock languages, are in sync since // client.setUILocale is mocked too. // See navigator.language in the beforeAll at the top of the file. - mockFetchConfigFn.mockResolvedValue({ uiLocale: "es_ES.UTF-8" }); + mockFetchConfigFn.mockResolvedValue({ localization: { locale: "es_ES.UTF-8" } }); }); it("sets the language from backend", async () => { @@ -158,7 +158,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set to 'cs-CZ'", () => { beforeEach(() => { document.cookie = "agamaLang=cs-CZ; path=/;"; - mockFetchConfigFn.mockResolvedValue({ uiLocale: "cs_CZ.UTF-8" }); + mockFetchConfigFn.mockResolvedValue({ localization: { locale: "cs_CZ.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -183,7 +183,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is set to 'en-US'", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ uiLocale: "en_US" }); + mockFetchConfigFn.mockResolvedValue({ localization: { locale: "en_US" } }); }); it.skip("sets the 'cs-CZ' language and reloads", async () => { @@ -205,7 +205,9 @@ describe("InstallerL10nProvider", () => { ); await waitFor(() => screen.getByText("ahoj")); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ uiLocale: "cs_CZ.UTF-8" }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { locale: "cs_CZ.UTF-8" }, + }); }); }); @@ -233,7 +235,9 @@ describe("InstallerL10nProvider", () => { ); await waitFor(() => screen.getByText("ahoj")); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ uiLocale: "cs_CZ.UTF-8" }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ + localization: { locale: "cs_CZ.UTF-8" }, + }); }); }); }); diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index 68f0f289ae..b85ab8dde3 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -24,8 +24,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { locationReload, setLocationSearch } from "~/utils"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; -import { fetchConfig as defaultFetchConfig, updateConfig } from "~/api/l10n"; -import { LocaleConfig } from "~/types/l10n"; +import { fetchSystem, trigger } from "~/api/api"; +import { System } from "~/types/system"; const L10nContext = React.createContext(null); @@ -137,9 +137,9 @@ function languageToLocale(language: string): string { * * @return Language tag from the backend locale. */ -async function languageFromBackend(fetchConfig: () => Promise): Promise { +async function languageFromBackend(fetchConfig): Promise { const config = await fetchConfig(); - return languageFromLocale(config.uiLocale); + return languageFromLocale(config?.localization?.locale); } /** @@ -238,19 +238,20 @@ function InstallerL10nProvider({ children, }: { initialLanguage?: string; - fetchConfigFn?: () => Promise; + fetchConfigFn?: () => Promise; children?: React.ReactNode; }) { - const fetchConfig = fetchConfigFn || defaultFetchConfig; + const fetchConfig = fetchConfigFn || fetchSystem; const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(undefined); + // FIXME: NEW-API: sync and updateConfig with new API once it's ready. const syncBackendLanguage = useCallback(async () => { const backendLanguage = await languageFromBackend(fetchConfig); if (backendLanguage === language) return; // FIXME: fallback to en-US if the language is not supported. - await updateConfig({ uiLocale: languageToLocale(language) }); + await trigger({ configureL10n: { language: languageToLocale(language) } }); }, [fetchConfig, language]); const changeLanguage = useCallback( @@ -289,7 +290,7 @@ function InstallerL10nProvider({ const changeKeymap = useCallback( async (id: string) => { setKeymap(id); - await updateConfig({ uiKeymap: id }); + await trigger({ configureL10n: { keymap: id } }); }, [setKeymap], ); @@ -300,12 +301,12 @@ function InstallerL10nProvider({ useEffect(() => { if (!language) return; - + // syncBackendLanguage(); }, [language, syncBackendLanguage]); useEffect(() => { - fetchConfig().then((c) => setKeymap(c.uiKeymap)); + fetchConfig().then((c) => setKeymap(c?.localization?.keymap)); }, [setKeymap, fetchConfig]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/queries/hostname.ts b/web/src/queries/hostname.ts new file mode 100644 index 0000000000..128e6be1c0 --- /dev/null +++ b/web/src/queries/hostname.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { fetchHostname, updateHostname } from "~/api/hostname"; + +/** + * Returns a query for retrieving the hostname configuration + */ +const hostnameQuery = () => ({ + queryKey: ["system", "hostname"], + queryFn: fetchHostname, +}); + +/** + * Hook that returns the hostname configuration + */ +const useHostname = () => { + const { data: hostname } = useSuspenseQuery(hostnameQuery()); + return hostname; +}; + +/* + * Hook that returns a mutation to change the hostname + */ +const useHostnameMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: updateHostname, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["system", "hostname"] }), + }; + return useMutation(query); +}; + +export { useHostname, useHostnameMutation }; diff --git a/web/src/queries/l10n.ts b/web/src/queries/l10n.ts index 4e48ada107..cdb6edab51 100644 --- a/web/src/queries/l10n.ts +++ b/web/src/queries/l10n.ts @@ -21,57 +21,8 @@ */ import React from "react"; -import { useQueryClient, useMutation, useSuspenseQueries } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { fetchConfig, fetchKeymaps, fetchLocales, fetchTimezones, updateConfig } from "~/api/l10n"; - -/** - * Returns a query for retrieving the localization configuration - */ -const configQuery = () => { - return { - queryKey: ["l10n", "config"], - queryFn: fetchConfig, - }; -}; - -/** - * Returns a query for retrieving the list of known locales - */ -const localesQuery = () => ({ - queryKey: ["l10n", "locales"], - queryFn: fetchLocales, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known timezones - */ -const timezonesQuery = () => ({ - queryKey: ["l10n", "timezones"], - queryFn: fetchTimezones, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known keymaps - */ -const keymapsQuery = () => ({ - queryKey: ["l10n", "keymaps"], - queryFn: fetchKeymaps, - staleTime: Infinity, -}); - -/** - * Hook that builds a mutation to update the l10n configuration - * - * It does not require to call `useMutation`. - */ -const useConfigMutation = () => { - return useMutation({ - mutationFn: updateConfig, - }); -}; /** * Hook that returns a useEffect to listen for L10nConfigChanged events @@ -87,44 +38,11 @@ const useL10nConfigChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "L10nConfigChanged") { + if (event.type === "l10n" && event.name === "ProposalChanged") { queryClient.invalidateQueries({ queryKey: ["l10n"] }); } }); }, [client, queryClient]); }; -/// Returns the l10n data. -const useL10n = () => { - const [{ data: config }, { data: locales }, { data: keymaps }, { data: timezones }] = - useSuspenseQueries({ - queries: [configQuery(), localesQuery(), keymapsQuery(), timezonesQuery()], - }); - - const selectedLocale = locales.find((l) => l.id === config.locales[0]); - const selectedKeymap = keymaps.find((k) => k.id === config.keymap); - const selectedTimezone = timezones.find((t) => t.id === config.timezone); - const uiLocale = locales.find((l) => l.id === config.uiLocale); - const uiKeymap = keymaps.find((k) => k.id === config.uiKeymap); - - return { - locales, - keymaps, - timezones, - selectedLocale, - selectedKeymap, - selectedTimezone, - uiLocale, - uiKeymap, - }; -}; - -export { - configQuery, - keymapsQuery, - localesQuery, - timezonesQuery, - useConfigMutation, - useL10n, - useL10nConfigChanges, -}; +export { useL10nConfigChanges }; diff --git a/web/src/queries/proposal.ts b/web/src/queries/proposal.ts new file mode 100644 index 0000000000..1b47019e56 --- /dev/null +++ b/web/src/queries/proposal.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { fetchProposal } from "~/api/api"; + +/** + * Returns a query for retrieving the proposal + */ +const proposalQuery = () => { + return { + queryKey: ["proposal"], + queryFn: fetchProposal, + }; +}; + +const useProposal = () => { + const { data: config } = useSuspenseQuery(proposalQuery()); + return config; +}; + +const useProposalChanges = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "l10n" && event.name === "ProposalChanged") { + queryClient.invalidateQueries({ queryKey: ["proposal"] }); + } + }); + }, [client, queryClient]); +}; +export { useProposal, useProposalChanges }; diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts index d21c9c8d4a..5d74034df8 100644 --- a/web/src/queries/software.ts +++ b/web/src/queries/software.ts @@ -303,7 +303,7 @@ const usePatterns = (): Pattern[] => { /** * Returns current software proposal */ -const useProposal = (): SoftwareProposal => { +const useSoftwareProposal = (): SoftwareProposal => { const { data: proposal } = useSuspenseQuery(proposalQuery()); return proposal; }; @@ -392,7 +392,7 @@ const useProductChanges = () => { * * When the selected patterns change, it invalidates the proposal query. */ -const useProposalChanges = () => { +const useSoftwareProposalChanges = () => { const client = useInstallerClient(); const queryClient = useQueryClient(); @@ -438,8 +438,8 @@ export { usePatterns, useProduct, useProductChanges, - useProposal, - useProposalChanges, + useSoftwareProposal, + useSoftwareProposalChanges, useRegisterAddonMutation, useRegisterMutation, useRegisteredAddons, diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index c21e2ee28a..b8af1ed052 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -20,35 +20,75 @@ * find current contact information at www.suse.com. */ -import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchHostname, updateHostname } from "~/api/system"; +import React from "react"; +import { tzOffset } from "@date-fns/tz/tzOffset"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { fetchSystem } from "~/api/api"; -/** - * Returns a query for retrieving the hostname configuration - */ -const hostnameQuery = () => ({ - queryKey: ["system", "hostname"], - queryFn: fetchHostname, -}); +const transformLocales = (locales) => + locales.map(({ id, language: name, territory }) => ({ id, name, territory })); + +const tranformKeymaps = (keymaps) => keymaps.map(({ id, description: name }) => ({ id, name })); + +const transformTimezones = (timezones) => + timezones.map(({ code: id, parts, country }) => { + const utcOffset = tzOffset(id, new Date()); + return { id, parts, country, utcOffset }; + }); /** - * Hook that returns the hostname configuration + * Returns a query for retrieving the localization configuration */ -const useHostname = () => { - const { data: hostname } = useSuspenseQuery(hostnameQuery()); - return hostname; +const systemQuery = () => { + return { + queryKey: ["system"], + queryFn: fetchSystem, + + // FIXME: We previously had separate fetch functions (fetchLocales, + // fetchKeymaps, fetchTimezones) that each applied specific transformations to + // the raw API data, for example, adding `utcOffset` to timezones or + // changing keys to follow a consistent structure (e.g. `id` vs `code`). + // + // Now that we've consolidated these into a single "system" cache, instead of + // individual caches, those transformations are currently missing. While it's + // more efficient to fetch everything in one request, we may still want to apply + // those transformations only once. Ideally, this logic should live outside the + // React Query layer, in a dedicated "state layer" or transformation step, so + // that data remains normalized and consistently shaped for the rest of the app. + + select: (data) => ({ + ...data, + localization: { + locales: transformLocales(data.localization.locales), + keymaps: tranformKeymaps(data.localization.keymaps), + timezones: transformTimezones(data.localization.timezones), + locale: data.locale, + keypmap: data.keymap, + timezone: data.timezone, + }, + }), + }; }; -/* - * Hook that returns a mutation to change the hostname - */ -const useHostnameMutation = () => { +const useSystem = () => { + const { data: config } = useSuspenseQuery(systemQuery()); + return config; +}; + +const useSystemChanges = () => { const queryClient = useQueryClient(); - const query = { - mutationFn: updateHostname, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["system", "hostname"] }), - }; - return useMutation(query); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "l10n" && event.name === "SystemChanged") { + queryClient.invalidateQueries({ queryKey: ["system"] }); + } + }); + }, [client, queryClient]); }; -export { useHostname, useHostnameMutation }; +export { useSystem, useSystemChanges }; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index ab1204e53a..f1bff20d9c 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -38,6 +38,7 @@ import { InstallerClientProvider } from "~/context/installer"; import { InstallerL10nProvider } from "~/context/installerL10n"; import { isObject, noop } from "radashi"; import { DummyWSClient } from "./client/ws"; +import { System } from "./types/system"; /** * Internal mock for manipulating routes, using ["/"] by default @@ -118,11 +119,12 @@ const Providers = ({ children, withL10n }) => { } if (withL10n) { - const fetchConfig = async () => ({ - keymap: "us", - timezone: "Europe/Berlin", - uiLocale: "en_US", - uiKeymap: "us", + const fetchConfig = async (): Promise => ({ + localization: { + keymap: "us", + timezone: "Europe/Berlin", + locale: "en_US", + }, }); return ( diff --git a/web/src/types/hostname.ts b/web/src/types/hostname.ts new file mode 100644 index 0000000000..cf4f7f6e27 --- /dev/null +++ b/web/src/types/hostname.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +type Hostname = { + /** + * The hostname set during runtime or during early boot. + */ + transient: string; + /** + * The hostname specified by /etc/hostname which has higher priority than a + * transient hostname. + */ + static: string; +}; + +export type { Hostname }; diff --git a/web/src/types/l10n.ts b/web/src/types/l10n.ts index 469e078be9..fbc9bc4c24 100644 --- a/web/src/types/l10n.ts +++ b/web/src/types/l10n.ts @@ -65,7 +65,20 @@ type Timezone = { utcOffset: number; }; +type Localization = { + locales?: Locale[]; + keymaps?: Keymap[]; + timezones?: Timezone[]; + locale?: string; + keymap?: string; + timezone?: string; +}; + type LocaleConfig = { + /** + * Selected locale for installation (e.g, "en_US.UTF-8") + */ + locale?: string; /** * List of locales to install (e.g., ["en_US.UTF-8"]). */ @@ -78,15 +91,6 @@ type LocaleConfig = { * Selected timezone for installation (e.g., "Atlantic/Canary"). */ timezone?: string; - - /** - * Locale to be used in the UI. - */ - uiLocale?: string; - /** - * Locale to be used in the UI. - */ - uiKeymap?: string; }; -export type { Keymap, Locale, Timezone, LocaleConfig }; +export type { Keymap, Locale, Timezone, LocaleConfig, Localization }; diff --git a/web/src/types/proposal.ts b/web/src/types/proposal.ts new file mode 100644 index 0000000000..a9826c7ee1 --- /dev/null +++ b/web/src/types/proposal.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { Localization } from "./l10n"; + +type Proposal = { + localization?: Localization; +}; + +export type { Proposal }; diff --git a/web/src/types/system.ts b/web/src/types/system.ts index cf4f7f6e27..69a562435f 100644 --- a/web/src/types/system.ts +++ b/web/src/types/system.ts @@ -20,16 +20,10 @@ * find current contact information at www.suse.com. */ -type Hostname = { - /** - * The hostname set during runtime or during early boot. - */ - transient: string; - /** - * The hostname specified by /etc/hostname which has higher priority than a - * transient hostname. - */ - static: string; +import { Localization } from "./l10n"; + +type System = { + localization?: Localization; }; -export type { Hostname }; +export type { System }; From 81ad52f2d84d098bb766b1789ee7e56135227418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 1 Oct 2025 15:38:39 +0100 Subject: [PATCH 110/917] Move Issue type to agama-utils --- rust/Cargo.lock | 20 ++- rust/agama-lib/src/issue.rs | 83 ------------ rust/agama-lib/src/lib.rs | 2 +- rust/agama-server/src/web/common/issues.rs | 8 +- rust/agama-utils/Cargo.toml | 2 + rust/agama-utils/src/issue.rs | 148 +++++++++++++++++++++ rust/agama-utils/src/lib.rs | 2 + 7 files changed, 176 insertions(+), 89 deletions(-) delete mode 100644 rust/agama-lib/src/issue.rs create mode 100644 rust/agama-utils/src/issue.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 468e28a08e..844357c123 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -210,7 +210,9 @@ name = "agama-utils" version = "0.1.0" dependencies = [ "async-trait", + "serde", "serde_json", + "strum", "thiserror 2.0.16", "tokio", "tokio-test", @@ -3791,18 +3793,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/rust/agama-lib/src/issue.rs b/rust/agama-lib/src/issue.rs deleted file mode 100644 index 103689d3fa..0000000000 --- a/rust/agama-lib/src/issue.rs +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Issue { - description: String, - details: Option, - source: u32, - severity: u32, - kind: String, -} - -impl Issue { - pub fn from_tuple( - (description, kind, details, source, severity): (String, String, String, u32, u32), - ) -> Self { - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - description, - kind, - details, - source, - severity, - } - } -} - -impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { - type Error = zbus::zvariant::Error; - - fn try_from(value: &zbus::zvariant::Value<'_>) -> Result { - let value = value.downcast_ref::()?; - let fields = value.fields(); - - let Some([description, kind, details, source, severity]) = fields.get(0..5) else { - return Err(zbus::zvariant::Error::Message( - "Not enough elements for building an Issue.".to_string(), - )); - }; - - let description: String = description.try_into()?; - let kind: String = kind.try_into()?; - let details: String = details.try_into()?; - let source: u32 = source.try_into()?; - let severity: u32 = severity.try_into()?; - - Ok(Issue { - description, - kind, - details: if details.is_empty() { - None - } else { - Some(details.to_string()) - }, - severity, - source, - }) - } -} diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index b4a99cf9c8..1f1f7ab286 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -53,7 +53,7 @@ pub mod files; pub mod hostname; pub mod http; pub mod install_settings; -pub mod issue; +pub use agama_utils::issue; pub mod jobs; pub mod logs; pub mod manager; diff --git a/rust/agama-server/src/web/common/issues.rs b/rust/agama-server/src/web/common/issues.rs index a9cf8c10ac..1dac54ac83 100644 --- a/rust/agama-server/src/web/common/issues.rs +++ b/rust/agama-server/src/web/common/issues.rs @@ -36,7 +36,11 @@ //! At this point, it only handles the issues that are exposed through D-Bus. use crate::web::EventsSender; -use agama_lib::{event, http::Event, issue::Issue}; +use agama_lib::{ + event, + http::Event, + issue::{Issue, IssueError}, +}; use agama_utils::dbus::build_properties_changed_stream; use axum::{extract::State, routing::get, Json, Router}; use std::collections::HashMap; @@ -66,6 +70,8 @@ pub enum IssuesServiceError { DBusName(#[from] zbus::names::Error), #[error("Could not send the event: {0}")] SendEvent(#[from] broadcast::error::SendError), + #[error("Issue conversion error")] + Conversion(#[from] IssueError), } #[derive(Debug)] diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 7320e29d5a..c9387a4f89 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -6,7 +6,9 @@ edition.workspace = true [dependencies] async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.140" +strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } utoipa = "5.3.1" diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs new file mode 100644 index 0000000000..59940fcd0b --- /dev/null +++ b/rust/agama-utils/src/issue.rs @@ -0,0 +1,148 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use strum::FromRepr; + +#[derive(thiserror::Error, Debug)] +pub enum IssueError { + #[error("D-Bus conversion error")] + DBus(#[from] zbus::zvariant::Error), + #[error("Unknown issue source: {0}")] + UnknownSource(u8), + #[error("Unknown issue severity: {0}")] + UnknownSeverity(u8), +} + +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Issue { + description: String, + details: Option, + source: IssueSource, + severity: IssueSeverity, + kind: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[repr(u8)] +pub enum IssueSource { + Generic = 0, + System = 1, + Config = 2, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[repr(u8)] +pub enum IssueSeverity { + Warn = 0, + Error = 1, +} + +impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { + type Error = IssueError; + + fn try_from(value: &zbus::zvariant::Value<'_>) -> Result { + let value = value.downcast_ref::()?; + let fields = value.fields(); + + let Some([description, kind, details, source, severity]) = fields.get(0..5) else { + return Err(zbus::zvariant::Error::Message( + "Not enough elements for building an Issue.".to_string(), + ))?; + }; + + let description: String = description.try_into()?; + let kind: String = kind.try_into()?; + let details: String = details.try_into()?; + let source: u32 = source.try_into()?; + let source = source as u8; + let source = IssueSource::from_repr(source).ok_or(IssueError::UnknownSource(source))?; + + let severity: u32 = severity.try_into()?; + let severity = severity as u8; + let severity = + IssueSeverity::from_repr(severity).ok_or(IssueError::UnknownSeverity(severity))?; + + Ok(Issue { + description, + kind, + details: if details.is_empty() { + None + } else { + Some(details.to_string()) + }, + source, + severity, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zbus::zvariant; + use zvariant::{Structure, Value}; + + #[test] + fn test_issue_from_dbus() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 1 as u32, + 0 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)).unwrap(); + assert_eq!(&issue.description, "Product not selected"); + assert_eq!(&issue.kind, "missing_product"); + assert_eq!(issue.details, Some("A product is required.".to_string())); + assert_eq!(issue.source, IssueSource::System); + assert_eq!(issue.severity, IssueSeverity::Warn); + } + + #[test] + fn test_unknown_issue_source() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 5 as u32, + 0 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)); + assert!(matches!(issue, Err(IssueError::UnknownSource(5)))); + } + + #[test] + fn test_unknown_issue_severity() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 0 as u32, + 5 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)); + assert!(matches!(issue, Err(IssueError::UnknownSeverity(5)))); + } +} diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 402d851f26..f2aca8faae 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -26,5 +26,7 @@ pub mod actor; pub mod service; pub use service::Service; +pub mod issue; + pub mod dbus; pub mod openapi; From d5c4dc6fc5bf383b1f9a127f024605f71a7ad387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 2 Oct 2025 19:36:51 +0100 Subject: [PATCH 111/917] Add a services to register issues * Expose the issues through the /issues endpoing. * Listen for issues changes on the D-Bus server. --- rust/Cargo.lock | 1 + rust/agama-l10n/src/model.rs | 18 +- rust/agama-l10n/src/model/timezone.rs | 45 +-- rust/agama-l10n/src/service.rs | 60 +++- rust/agama-l10n/src/start.rs | 86 +++--- rust/agama-l10n/src/system_info.rs | 2 +- rust/agama-lib/src/http/event.rs | 8 + rust/agama-locale-data/src/locale.rs | 8 +- rust/agama-server/src/error.rs | 7 +- rust/agama-server/src/server.rs | 1 + rust/agama-server/src/server/types.rs | 59 ++++ rust/agama-server/src/server/web.rs | 30 +- rust/agama-server/src/software/web.rs | 15 +- rust/agama-server/src/storage/web.rs | 8 +- rust/agama-server/src/storage/web/iscsi.rs | 18 +- rust/agama-server/src/supervisor/message.rs | 11 +- rust/agama-server/src/supervisor/service.rs | 24 +- rust/agama-server/src/supervisor/start.rs | 29 +- rust/agama-server/src/users/web.rs | 12 +- rust/agama-server/src/web.rs | 25 +- rust/agama-server/src/web/common.rs | 2 - rust/agama-server/src/web/common/issues.rs | 300 -------------------- rust/agama-server/tests/server_service.rs | 4 +- rust/agama-utils/Cargo.toml | 1 + rust/agama-utils/src/issue.rs | 168 +++-------- rust/agama-utils/src/issue/event.rs | 31 ++ rust/agama-utils/src/issue/message.rs | 56 ++++ rust/agama-utils/src/issue/model.rs | 151 ++++++++++ rust/agama-utils/src/issue/monitor.rs | 176 ++++++++++++ rust/agama-utils/src/issue/service.rs | 77 +++++ rust/agama-utils/src/issue/start.rs | 102 +++++++ web/src/queries/system.ts | 2 +- 32 files changed, 952 insertions(+), 585 deletions(-) create mode 100644 rust/agama-server/src/server/types.rs delete mode 100644 rust/agama-server/src/web/common/issues.rs create mode 100644 rust/agama-utils/src/issue/event.rs create mode 100644 rust/agama-utils/src/issue/message.rs create mode 100644 rust/agama-utils/src/issue/model.rs create mode 100644 rust/agama-utils/src/issue/monitor.rs create mode 100644 rust/agama-utils/src/issue/service.rs create mode 100644 rust/agama-utils/src/issue/start.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 844357c123..d72527e9bd 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -215,6 +215,7 @@ dependencies = [ "strum", "thiserror 2.0.16", "tokio", + "tokio-stream", "tokio-test", "utoipa", "zbus", diff --git a/rust/agama-l10n/src/model.rs b/rust/agama-l10n/src/model.rs index 8da962b808..50f0eafcca 100644 --- a/rust/agama-l10n/src/model.rs +++ b/rust/agama-l10n/src/model.rs @@ -39,13 +39,13 @@ use std::{env, fs::OpenOptions, io::Write, process::Command}; /// tests. pub trait ModelAdapter: Send + 'static { /// Locales database. - fn locales_db(&mut self) -> &mut LocalesDatabase; + fn locales_db(&self) -> &LocalesDatabase; /// Timezones database. - fn timezones_db(&mut self) -> &mut TimezonesDatabase; + fn timezones_db(&self) -> &TimezonesDatabase; /// Keymaps database. - fn keymaps_db(&mut self) -> &mut KeymapsDatabase; + fn keymaps_db(&self) -> &KeymapsDatabase; /// Current system locale. fn locale(&self) -> LocaleId; @@ -110,15 +110,15 @@ impl Model { } impl ModelAdapter for Model { - fn locales_db(&mut self) -> &mut LocalesDatabase { - &mut self.locales_db + fn locales_db(&self) -> &LocalesDatabase { + &self.locales_db } - fn timezones_db(&mut self) -> &mut TimezonesDatabase { - &mut self.timezones_db + fn timezones_db(&self) -> &TimezonesDatabase { + &self.timezones_db } - fn keymaps_db(&mut self) -> &mut KeymapsDatabase { - &mut self.keymaps_db + fn keymaps_db(&self) -> &KeymapsDatabase { + &self.keymaps_db } fn keymap(&self) -> Result { diff --git a/rust/agama-l10n/src/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs index 204ece1c7f..76914c5de5 100644 --- a/rust/agama-l10n/src/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -20,8 +20,7 @@ //! This module provides support for reading the timezones database. -use agama_locale_data::territory::Territories; -use agama_locale_data::timezone_part::TimezoneIdParts; +use agama_locale_data::{territory::Territories, timezone_part::TimezoneIdParts, TimezoneId}; use serde::Serialize; use std::collections::HashMap; @@ -29,7 +28,7 @@ use std::collections::HashMap; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct TimezoneEntry { /// Timezone identifier (e.g. "Atlantic/Canary"). - pub code: String, + pub id: TimezoneId, /// Localized parts (e.g., "Atlántico", "Canarias"). pub parts: Vec, /// Localized name of the territory this timezone is associated to @@ -62,8 +61,8 @@ impl TimezonesDatabase { } /// Determines whether a timezone exists in the database. - pub fn exists(&self, timezone: &String) -> bool { - self.timezones.iter().any(|t| &t.code == timezone) + pub fn exists(&self, timezone: &TimezoneId) -> bool { + self.timezones.iter().any(|t| &t.id == timezone) } /// Returns the list of timezones. @@ -87,15 +86,17 @@ impl TimezonesDatabase { let ret = timezones .into_iter() .filter_map(|tz| { - let parts = translate_parts(&tz, ui_language, &tz_parts); - let country = translate_country(&tz, ui_language, &tz_countries, &territories); + tz.parse::() + .inspect_err(|e| println!("Ignoring timezone {tz}: {e}")) + .ok() + }) + .filter_map(|id| { + let parts = translate_parts(id.as_str(), ui_language, &tz_parts); + let country = + translate_country(id.as_str(), ui_language, &tz_countries, &territories); match country { - None if !COUNTRYLESS.contains(&tz.as_str()) => None, - _ => Some(TimezoneEntry { - code: tz, - parts, - country, - }), + None if !COUNTRYLESS.contains(&id.as_str()) => None, + _ => Some(TimezoneEntry { id, parts, country }), } }) .collect(); @@ -143,9 +144,9 @@ mod tests { let found_timezones = db.entries(); let found = found_timezones .iter() - .find(|tz| tz.code == "Europe/Berlin") + .find(|tz| tz.id.as_str() == "Europe/Berlin") .unwrap(); - assert_eq!(&found.code, "Europe/Berlin"); + assert_eq!(found.id.as_str(), "Europe/Berlin"); assert_eq!( found.parts, vec!["Europa".to_string(), "Berlín".to_string()] @@ -157,7 +158,11 @@ mod tests { fn test_read_timezone_without_country() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); - let timezone = db.entries().iter().find(|tz| tz.code == "UTC").unwrap(); + let timezone = db + .entries() + .iter() + .find(|tz| tz.id.as_str() == "UTC") + .unwrap(); assert_eq!(timezone.country, None); } @@ -168,7 +173,7 @@ mod tests { let timezone = db .entries() .iter() - .find(|tz| tz.code == "Europe/Kiev") + .find(|tz| tz.id.as_str() == "Europe/Kiev") .unwrap(); assert_eq!(timezone.country, Some("Ukraine".to_string())); } @@ -177,7 +182,9 @@ mod tests { fn test_timezone_exists() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); - assert!(db.exists(&"Atlantic/Canary".to_string())); - assert!(!db.exists(&"Unknown/Unknown".to_string())); + let canary = "Atlantic/Canary".parse().unwrap(); + let unknown = "Unknown/Unknown".parse().unwrap(); + assert!(db.exists(&canary)); + assert!(!db.exists(&unknown)); } } diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 5145c63fa6..0296d775a3 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -23,7 +23,10 @@ use crate::{ proposal::Proposal, system_info::SystemInfo, }; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; -use agama_utils::actor::{self, Actor, MessageHandler}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + issue::{self, Issue}, +}; use async_trait::async_trait; use tokio::sync::mpsc; @@ -54,6 +57,7 @@ pub enum Error { pub struct Service { state: State, model: Box, + issues: Handler, events: mpsc::UnboundedSender, } @@ -64,19 +68,57 @@ struct State { impl Service { pub fn new( - mut model: T, + model: T, + issues: Handler, events: mpsc::UnboundedSender, ) -> Service { - let system = SystemInfo::read_from(&mut model); + let system = SystemInfo::read_from(&model); let config = ExtendedConfig::new_from(&system); let state = State { system, config }; Self { state, model: Box::new(model), + issues, events, } } + + pub fn find_issues(&self) -> Vec { + let config = &self.state.config; + let mut issues = vec![]; + if !self.model.locales_db().exists(&config.locale) { + issues.push(Issue { + description: format!("Locale '{}' is unknown", &config.locale), + details: None, + source: issue::IssueSource::Config, + severity: issue::IssueSeverity::Warn, + kind: "unknown_locale".to_string(), + }); + } + + if !self.model.keymaps_db().exists(&config.keymap) { + issues.push(Issue { + description: format!("Keymap '{}' is unknown", &config.keymap), + details: None, + source: issue::IssueSource::Config, + severity: issue::IssueSeverity::Warn, + kind: "unknown_keymap".to_string(), + }); + } + + if !self.model.timezones_db().exists(&config.timezone) { + issues.push(Issue { + description: format!("Timezone '{}' is unknown", &config.timezone), + details: None, + source: issue::IssueSource::Config, + severity: issue::IssueSeverity::Warn, + kind: "unknown_timezone".to_string(), + }); + } + + issues + } } impl Actor for Service { @@ -120,10 +162,16 @@ impl MessageHandler for Service { impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { let merged = self.state.config.merge(&message.config)?; - if merged != self.state.config { - self.state.config = merged; - _ = self.events.send(Event::ProposalChanged); + if merged == self.state.config { + return Ok(()); } + + self.state.config = merged; + let issues = self.find_issues(); + _ = self + .issues + .cast(issue::message::Update::new("localization", issues)); + _ = self.events.send(Event::ProposalChanged); Ok(()) } } diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index a3faf0e62b..4c8cafd278 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -24,7 +24,10 @@ use crate::{ monitor::{self, Monitor}, service::{self, Service}, }; -use agama_utils::actor::{self, Handler}; +use agama_utils::{ + actor::{self, Handler}, + issue, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -41,26 +44,16 @@ pub enum Error { /// - The main service, which is reponsible for holding and applying the configuration. /// - A monitor which checks for changes in the underlying system (e.g., changing the keymap) /// and signals the main service accordingly. -/// -/// ## Example -/// -/// ```no_run -/// # use tokio_test; -/// # use tokio::sync::mpsc; -/// use agama_l10n as l10n; -/// # tokio_test::block_on(async { -/// -/// let (events_sender, events_receiver) = mpsc::unbounded_channel::(); -/// let service = l10n::start(events_sender).await.unwrap(); -/// let config = service.call(l10n::message::GetConfig).await; -/// dbg!(config); -/// # }) -/// ``` +/// - It depends on the issues service to keep the installation issues. /// /// * `events`: channel to emit the [localization-specific events](crate::Event). -pub async fn start(events: event::Sender) -> Result, Error> { +/// * `issues`: handler to the issues service. +pub async fn start( + issues: Handler, + events: event::Sender, +) -> Result, Error> { let model = Model::from_system()?; - let service = Service::new(model, events); + let service = Service::new(model, issues, events); let handler = actor::spawn(service); let mut monitor = Monitor::new(handler.clone()).await?; @@ -83,7 +76,10 @@ mod tests { service, Config, Event, Service, }; use agama_locale_data::{KeymapId, LocaleId}; - use agama_utils::actor::{self, Handler}; + use agama_utils::{ + actor::{self, Handler}, + issue, + }; use tokio::sync::mpsc; pub struct TestModel { @@ -93,16 +89,16 @@ mod tests { } impl ModelAdapter for TestModel { - fn locales_db(&mut self) -> &mut LocalesDatabase { - &mut self.locales + fn locales_db(&self) -> &LocalesDatabase { + &self.locales } - fn keymaps_db(&mut self) -> &mut KeymapsDatabase { - &mut self.keymaps + fn keymaps_db(&self) -> &KeymapsDatabase { + &self.keymaps } - fn timezones_db(&mut self) -> &mut TimezonesDatabase { - &mut self.timezones + fn timezones_db(&self) -> &TimezonesDatabase { + &self.timezones } fn locale(&self) -> LocaleId { @@ -136,12 +132,12 @@ mod tests { ]), timezones: TimezonesDatabase::with_entries(&[ TimezoneEntry { - code: "Europe/Berlin".to_string(), + id: "Europe/Berlin".parse().unwrap(), parts: vec!["Europe".to_string(), "Berlin".to_string()], country: Some("Germany".to_string()), }, TimezoneEntry { - code: "Atlantic/Canary".to_string(), + id: "Atlantic/Canary".parse().unwrap(), parts: vec!["Atlantic".to_string(), "Canary".to_string()], country: Some("Spain".to_string()), }, @@ -149,18 +145,21 @@ mod tests { } } - fn start_testing_service() -> (Receiver, Handler) { + async fn start_testing_service() -> (Receiver, Handler, Handler) { + let (events_tx, _events_rx) = mpsc::unbounded_channel::(); + let issues = issue::start(events_tx, None).await.unwrap(); + let (events_tx, events_rx) = mpsc::unbounded_channel::(); let model = build_adapter(); - let service = Service::new(model, events_tx); + let service = Service::new(model, issues.clone(), events_tx); let handler = actor::spawn(service); - (events_rx, handler) + (events_rx, handler, issues) } #[tokio::test] async fn test_get_and_set_config() -> Result<(), Box> { - let (mut events_rx, handler) = start_testing_service(); + let (mut events_rx, handler, _issues) = start_testing_service().await; let config = handler.call(message::GetConfig).await.unwrap(); assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); @@ -184,7 +183,7 @@ mod tests { #[tokio::test] async fn test_set_invalid_config() -> Result<(), Box> { - let (_events_rx, handler) = start_testing_service(); + let (_events_rx, handler, _issues) = start_testing_service().await; let input_config = Config { locale: Some("es-ES.UTF-8".to_string()), @@ -203,7 +202,7 @@ mod tests { #[tokio::test] async fn test_set_config_without_changes() -> Result<(), Box> { - let (mut events_rx, handler) = start_testing_service(); + let (mut events_rx, handler, _issues) = start_testing_service().await; let config = handler.call(message::GetConfig).await?; assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); @@ -217,9 +216,26 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_set_config_unknown_values() -> Result<(), Box> { + let (mut _events_rx, handler, issues) = start_testing_service().await; + + let config = Config { + keymap: Some("jk".to_string()), + locale: Some("xx_XX.UTF-8".to_string()), + timezone: Some("Unknown/Unknown".to_string()), + }; + let _ = handler.call(message::SetConfig::new(config)).await?; + + let found_issues = issues.call(issue::message::Get).await?; + let l10n_issues = found_issues.get("localization").unwrap(); + assert_eq!(l10n_issues.len(), 3); + Ok(()) + } + #[tokio::test] async fn test_get_system() -> Result<(), Box> { - let (_events_rx, handler) = start_testing_service(); + let (_events_rx, handler, _issues) = start_testing_service().await; let system = handler.call(message::GetSystem).await?; assert_eq!(system.keymaps.len(), 2); @@ -229,7 +245,7 @@ mod tests { #[tokio::test] async fn test_get_proposal() -> Result<(), Box> { - let (_events_rx, handler) = start_testing_service(); + let (_events_rx, handler, _issues) = start_testing_service().await; let input_config = Config { locale: Some("es_ES.UTF-8".to_string()), diff --git a/rust/agama-l10n/src/system_info.rs b/rust/agama-l10n/src/system_info.rs index 63eace90f7..a33d113ffc 100644 --- a/rust/agama-l10n/src/system_info.rs +++ b/rust/agama-l10n/src/system_info.rs @@ -47,7 +47,7 @@ pub struct SystemInfo { impl SystemInfo { /// Reads the information from the system adapter. - pub fn read_from(model: &mut T) -> Self { + pub fn read_from(model: &T) -> Self { let locales = model.locales_db().entries().clone(); let keymaps = model.keymaps_db().entries().clone(); let timezones = model.timezones_db().entries().clone(); diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 51d2bc265e..4db25c3f3d 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -35,6 +35,7 @@ use crate::{ users::{FirstUser, RootUser}, }; use agama_l10n as l10n; +use agama_utils::issue; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -121,6 +122,7 @@ pub enum EventPayload { service: String, status: u32, }, + IssuesUpdated, IssuesChanged { path: String, issues: Vec, @@ -192,6 +194,12 @@ impl From for EventPayload { } } +impl From for EventPayload { + fn from(_value: issue::IssuesChanged) -> Self { + EventPayload::IssuesUpdated + } +} + /// Makes it easier to create an event, reducing the boilerplate. /// /// # Event without additional data diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index efe54bdd5c..a1f6fe6965 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -26,7 +26,7 @@ use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq, utoipa::ToSchema)] pub struct TimezoneId(String); impl Default for TimezoneId { @@ -41,6 +41,12 @@ impl Display for TimezoneId { } } +impl TimezoneId { + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + #[derive(Clone, Error, Debug)] #[error("Not a valid timezone: {0}")] pub struct InvalidTimezoneId(String); diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 1466882fda..2704cb8280 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -26,10 +26,7 @@ use axum::{ }; use serde_json::json; -use crate::{ - users::password::PasswordCheckerError, - web::common::{IssuesServiceError, ProgressServiceError}, -}; +use crate::{users::password::PasswordCheckerError, web::common::ProgressServiceError}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -41,8 +38,6 @@ pub enum Error { Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), - #[error("Issues service error: {0}")] - Issues(#[from] IssuesServiceError), #[error("Progress service error: {0}")] Progress(#[from] ProgressServiceError), #[error("Could not check the password")] diff --git a/rust/agama-server/src/server.rs b/rust/agama-server/src/server.rs index a429170043..c0832053ca 100644 --- a/rust/agama-server/src/server.rs +++ b/rust/agama-server/src/server.rs @@ -20,3 +20,4 @@ pub mod web; pub use web::server_service; +pub mod types; diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs new file mode 100644 index 0000000000..ec35fab166 --- /dev/null +++ b/rust/agama-server/src/server/types.rs @@ -0,0 +1,59 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::collections::HashMap; + +use agama_utils::issue; +use serde::Serialize; + +#[derive(Serialize, utoipa::ToSchema)] +/// Represents the installation issues for each scope. +pub struct IssuesMap { + /// iSCSI issues. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub iscsi: Vec, + /// Localization issues. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub localization: Vec, + /// Product related issues (product selection, registration, etc.). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub product: Vec, + /// Storage related issues. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub storage: Vec, + /// Software management issues. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub software: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub users: Vec, +} + +impl From>> for IssuesMap { + fn from(mut value: HashMap>) -> Self { + Self { + iscsi: value.remove("iscsi").unwrap_or_default(), + localization: value.remove("localization").unwrap_or_default(), + product: value.remove("product").unwrap_or_default(), + software: value.remove("software").unwrap_or_default(), + storage: value.remove("storage").unwrap_or_default(), + users: value.remove("users").unwrap_or_default(), + } + } +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 62f5b0822a..596eaf19c8 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -37,6 +37,8 @@ use hyper::StatusCode; use serde::Serialize; use serde_json::json; +use super::types::IssuesMap; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("The given configuration does not belong to the '{0}' scope.")] @@ -70,8 +72,15 @@ pub struct ServerState { type ServerResult = Result; /// Sets up and returns the axum service for the manager module -pub async fn server_service(events: EventsSender) -> Result { - let supervisor = supervisor::start(events) +/// +/// * `events`: channel to send events to the websocket. +/// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features +/// that require to connect to the Agama's D-Bus server won't work. +pub async fn server_service( + events: EventsSender, + dbus: Option, +) -> Result { + let supervisor = supervisor::start(events, dbus) .await .map_err(|e| anyhow::Error::new(e))?; @@ -93,6 +102,7 @@ pub async fn server_service(events: EventsSender) -> Result) -> ServerResult) -> ServerResult> { + let issues = state.supervisor.call(message::GetIssues).await?; + let issues_map: IssuesMap = issues.into(); + Ok(Json(issues_map)) +} + #[utoipa::path( post, path = "/actions", diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 5fb4d46682..846154e242 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -28,10 +28,7 @@ use crate::{ error::Error, web::{ - common::{ - service_status_router, EventStreams, IssuesClient, IssuesRouterBuilder, ProgressClient, - ProgressRouterBuilder, - }, + common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, EventsReceiver, }, }; @@ -269,23 +266,15 @@ pub async fn receive_events( pub async fn software_service( dbus: zbus::Connection, events: EventsReceiver, - issues: IssuesClient, progress: ProgressClient, ) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; - const DBUS_PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; // FIXME: use anyhow temporarily until we adapt all these methods to return // the crate::error::Error instead of ServiceError. - let software_issues = IssuesRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, issues.clone()) - .build() - .context("Could not build an issues router")?; - let product_issues = IssuesRouterBuilder::new(DBUS_SERVICE, DBUS_PRODUCT_PATH, issues) - .build() - .context("Could not build an issues router")?; let progress_router = ProgressRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, progress) .build() .context("Could not build the progress router")?; @@ -338,8 +327,6 @@ pub async fn software_service( .route("/resolvables/:id", put(set_resolvables)) .merge(status_router) .merge(progress_router) - .nest("/issues/product", product_issues) - .nest("/issues/software", software_issues) .with_state(state); Ok(router) } diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index fbac63e58c..5114881883 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -62,8 +62,7 @@ use crate::{ iscsi::iscsi_stream, }, web::common::{ - jobs_service, service_status_router, EventStreams, IssuesClient, IssuesRouterBuilder, - ProgressClient, ProgressRouterBuilder, + jobs_service, service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder, }, }; @@ -124,7 +123,6 @@ struct StorageState<'a> { /// Sets up and returns the axum service for the storage module. pub async fn storage_service( dbus: zbus::Connection, - issues: IssuesClient, progress: ProgressClient, ) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; @@ -134,9 +132,6 @@ pub async fn storage_service( let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; // FIXME: use anyhow temporarily until we adapt all these methods to return // the crate::error::Error instead of ServiceError. - let issues_router = IssuesRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, issues.clone()) - .build() - .context("Could not build an issues router")?; let progress_router = ProgressRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, progress) .build() .context("Could not build the progress router")?; @@ -172,7 +167,6 @@ pub async fn storage_service( .merge(progress_router) .merge(status_router) .merge(jobs_router) - .nest("/issues", issues_router) .nest("/iscsi", iscsi_router) .nest("/dasd", dasd_router) .nest("/zfcp", zfcp_router) diff --git a/rust/agama-server/src/storage/web/iscsi.rs b/rust/agama-server/src/storage/web/iscsi.rs index 216d383c71..669a995025 100644 --- a/rust/agama-server/src/storage/web/iscsi.rs +++ b/rust/agama-server/src/storage/web/iscsi.rs @@ -25,10 +25,7 @@ //! * `iscsi_service` which returns the Axum service. //! * `iscsi_stream` which offers an stream that emits the iSCSI-related events coming from D-Bus. -use crate::{ - error::Error, - web::common::{EventStreams, IssuesClient, IssuesRouterBuilder}, -}; +use crate::{error::Error, web::common::EventStreams}; use agama_lib::{ error::ServiceError, event, @@ -39,7 +36,6 @@ use agama_lib::{ }, }; use agama_utils::dbus::{get_optional_property, to_owned_hash}; -use anyhow::Context; use axum::{ extract::{Path, State}, http::StatusCode, @@ -143,23 +139,13 @@ pub async fn storage_iscsi_service(dbus: &zbus::Connection) -> Result( - dbus: zbus::Connection, - issues: IssuesClient, -) -> Result, ServiceError> { - const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; - const DBUS_PATH: &str = "/org/opensuse/Agama/Storage1/ISCSI"; - +pub async fn iscsi_service(dbus: zbus::Connection) -> Result, ServiceError> { let client = ISCSIClient::new(dbus.clone()).await?; let state = ISCSIState { client }; // FIXME: use anyhow temporarily until we adapt all these methods to return // the crate::error::Error instead of ServiceError. - let issues_router = IssuesRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, issues.clone()) - .build() - .context("Could not build an issues router")?; let router = Router::new() .route("/config", post(set_config)) - .nest("/issues", issues_router) .with_state(state); Ok(router) } diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-server/src/supervisor/message.rs index 8f1e0b7640..3a91a15cfa 100644 --- a/rust/agama-server/src/supervisor/message.rs +++ b/rust/agama-server/src/supervisor/message.rs @@ -18,10 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::collections::HashMap; + use crate::supervisor::{ l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, system_info::SystemInfo, }; -use agama_lib::install_settings::InstallSettings; +use agama_lib::{install_settings::InstallSettings, issue::Issue}; use agama_utils::actor::Message; use serde::Deserialize; @@ -155,6 +157,13 @@ impl Message for GetProposal { type Reply = Option; } +/// Gets the installation issues. +pub struct GetIssues; + +impl Message for GetIssues { + type Reply = HashMap>; +} + /// Runs the given action. #[derive(Debug)] pub struct RunAction { diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 42c09e3105..b12ab805ed 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -26,10 +26,13 @@ use crate::supervisor::{ system_info::SystemInfo, }; use agama_lib::install_settings::InstallSettings; -use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + issue, +}; use async_trait::async_trait; use merge_struct::merge; -use std::convert::Infallible; +use std::{collections::HashMap, convert::Infallible}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -39,19 +42,23 @@ pub enum Error { Actor(#[from] actor::Error), #[error(transparent)] L10n(#[from] l10n::service::Error), + #[error(transparent)] + Issues(#[from] agama_utils::issue::service::Error), #[error("Infallible")] Infallible(#[from] Infallible), } pub struct Service { l10n: Handler, + issues: Handler, config: InstallSettings, } impl Service { - pub fn new(l10n: Handler) -> Self { + pub fn new(l10n: Handler, issues: Handler) -> Self { Self { l10n, + issues, config: InstallSettings::default(), } } @@ -213,6 +220,17 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + /// It returns the current proposal, if any. + async fn handle( + &mut self, + _message: message::GetIssues, + ) -> Result>, Error> { + Ok(self.issues.call(issue::message::Get).await?) + } +} + #[async_trait] impl MessageHandler for Service { /// It runs the given action. diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs index cf23cd8302..23bbc770e8 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-server/src/supervisor/start.rs @@ -22,13 +22,18 @@ use crate::{ supervisor::{l10n, listener::EventsListener, service::Service}, web::EventsSender, }; -use agama_utils::actor::{self, Handler}; +use agama_utils::{ + actor::{self, Handler}, + issue, +}; use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Could not start the l10n service")] L10n(#[from] l10n::start::Error), + #[error("Could not start the issues service")] + Issues(#[from] issue::start::Error), } /// Starts the supervisor service. @@ -42,17 +47,29 @@ pub enum Error { /// It receives the following argument: /// /// * `events`: channel to emit the [events](agama_lib::http::Event). -pub async fn start(events: EventsSender) -> Result, Error> { +/// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features +/// that require to connect to the Agama's D-Bus server won't work. +pub async fn start( + events: EventsSender, + dbus: Option, +) -> Result, Error> { let mut listener = EventsListener::new(events); + + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); + let issues = issue::start(events_sender, dbus).await?; + listener.add_channel("issues", events_receiver); + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); - let l10n = l10n::start(events_sender).await?; + let l10n = l10n::start(issues.clone(), events_sender).await?; listener.add_channel("l10n", events_receiver); + + let service = Service::new(l10n, issues.clone()); + let handler = actor::spawn(service); + tokio::spawn(async move { listener.run().await; }); - let service = Service::new(l10n); - let handler = actor::spawn(service); Ok(handler) } @@ -65,7 +82,7 @@ mod test { async fn start_service() -> Handler { let (events_tx, _events_rx) = broadcast::channel::(16); - supervisor::start(events_tx).await.unwrap() + supervisor::start(events_tx, None).await.unwrap() } #[tokio::test] diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 30cc24d3b7..4f96f79f64 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -27,7 +27,7 @@ use crate::{ error::Error, users::password::PasswordChecker, - web::common::{service_status_router, EventStreams, IssuesClient, IssuesRouterBuilder}, + web::common::{service_status_router, EventStreams}, }; use agama_lib::{ error::ServiceError, @@ -35,7 +35,6 @@ use agama_lib::{ http::Event, users::{model::RootPatchSettings, proxies::Users1Proxy, FirstUser, RootUser, UsersClient}, }; -use anyhow::Context; use axum::{ extract::State, http::StatusCode, @@ -118,10 +117,7 @@ async fn root_user_changed_stream( } /// Sets up and returns the axum service for the users module. -pub async fn users_service( - dbus: zbus::Connection, - issues: IssuesClient, -) -> Result { +pub async fn users_service(dbus: zbus::Connection) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Users1"; @@ -129,9 +125,6 @@ pub async fn users_service( let state = UsersState { users }; // FIXME: use anyhow temporarily until we adapt all these methods to return // the crate::error::Error instead of ServiceError. - let issues_router = IssuesRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, issues.clone()) - .build() - .context("Could not build an issues router")?; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let router = Router::new() .route( @@ -143,7 +136,6 @@ pub async fn users_service( .route("/root", get(get_root_config).patch(patch_root)) .route("/password_check", post(check_password)) .merge(status_router) - .nest("/issues", issues_router) .with_state(state); Ok(router) } diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 9b7356ebe8..712e6f09e7 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -54,7 +54,7 @@ mod state; mod ws; use agama_lib::{connection, error::ServiceError, http::Event}; -use common::{IssuesService, ProgressService}; +use common::ProgressService; pub use config::ServiceConfig; pub use event::{EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; @@ -80,7 +80,6 @@ where .await .expect("Could not connect to NetworkManager to read the configuration"); - let issues = IssuesService::start(dbus.clone(), events.clone()).await; let progress = ProgressService::start(dbus.clone(), events.clone()).await; let router = MainServiceBuilder::new(events.clone(), web_ui_dir) @@ -88,27 +87,21 @@ where "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) - .add_service("/v2", server_service(events.clone()).await?) - .add_service("/security", security_service(dbus.clone()).await?) .add_service( - "/software", - software_service( - dbus.clone(), - events.subscribe(), - issues.clone(), - progress.clone(), - ) - .await?, + "/v2", + server_service(events.clone(), Some(dbus.clone())).await?, ) + .add_service("/security", security_service(dbus.clone()).await?) .add_service( - "/storage", - storage_service(dbus.clone(), issues.clone(), progress).await?, + "/software", + software_service(dbus.clone(), events.subscribe(), progress.clone()).await?, ) - .add_service("/iscsi", iscsi_service(dbus.clone(), issues.clone()).await?) + .add_service("/storage", storage_service(dbus.clone(), progress).await?) + .add_service("/iscsi", iscsi_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) .add_service("/network", network_service(network_adapter, events).await?) .add_service("/questions", questions_service(dbus.clone()).await?) - .add_service("/users", users_service(dbus.clone(), issues).await?) + .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) .add_service("/files", files_service().await?) .add_service("/hostname", hostname_service().await?) diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index 0820cd884f..b6cf4d687b 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -31,8 +31,6 @@ use crate::error::Error; mod jobs; pub use jobs::{jobs_service, jobs_stream}; -mod issues; -pub use issues::{IssuesClient, IssuesRouterBuilder, IssuesService, IssuesServiceError}; mod progress; pub use progress::{ProgressClient, ProgressRouterBuilder, ProgressService, ProgressServiceError}; diff --git a/rust/agama-server/src/web/common/issues.rs b/rust/agama-server/src/web/common/issues.rs deleted file mode 100644 index 1dac54ac83..0000000000 --- a/rust/agama-server/src/web/common/issues.rs +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Defines a service that keep tracks of the Agama issues. -//! -//! It is responsible for: -//! -//! * Querying the issues via D-Bus and keeping them in a cache. -//! * Listening to D-Bus signals to keep the cache up-to-date. -//! * Emitting `IssuesChanged` events. -//! -//! The following components are included: -//! -//! * [IssuesService] that runs on a separate task to hold the status. -//! * [IssuesClient] that allows querying the [IssuesService] server about the -//! issues. -//! * [IssuesRouterBuilder] which allows building a router. -//! -//! At this point, it only handles the issues that are exposed through D-Bus. - -use crate::web::EventsSender; -use agama_lib::{ - event, - http::Event, - issue::{Issue, IssueError}, -}; -use agama_utils::dbus::build_properties_changed_stream; -use axum::{extract::State, routing::get, Json, Router}; -use std::collections::HashMap; -use tokio::sync::{broadcast, mpsc, oneshot}; -use tokio_stream::StreamExt; -use zbus::{ - fdo::PropertiesChanged, - names::BusName, - zvariant::{Array, OwnedObjectPath}, -}; - -type IssuesServiceResult = Result; - -#[derive(Debug, thiserror::Error)] -pub enum IssuesServiceError { - #[error("Could not return the issues")] - SendIssues, - #[error("Could not get an answer from the service: {0}")] - RecvIssues(#[from] oneshot::error::RecvError), - #[error("Could not set the command: {0}")] - SendCommand(#[from] mpsc::error::SendError), - #[error("Error parsing issues from D-Bus: {0}")] - InvalidIssue(#[from] zbus::zvariant::Error), - #[error("Error reading the issues: {0}")] - DBus(#[from] zbus::Error), - #[error("Invalid D-Bus name: {0}")] - DBusName(#[from] zbus::names::Error), - #[error("Could not send the event: {0}")] - SendEvent(#[from] broadcast::error::SendError), - #[error("Issue conversion error")] - Conversion(#[from] IssueError), -} - -#[derive(Debug)] -pub enum IssuesCommand { - Get(String, String, oneshot::Sender>), -} - -/// Implements a Tokio task that holds the issues for each service. -pub struct IssuesService { - cache: HashMap>, - commands: mpsc::Receiver, - events: EventsSender, - dbus: zbus::Connection, -} - -impl IssuesService { - /// Sets up and starts the service as a Tokio task. - /// - /// Once it is started, the service waits for: - /// - /// * Commands from a client ([IssuesClient]). - /// * Relevant events from D-Bus. - pub async fn start(dbus: zbus::Connection, events: EventsSender) -> IssuesClient { - let (tx, rx) = mpsc::channel(4); - let mut service = IssuesService { - cache: HashMap::new(), - dbus, - events, - commands: rx, - }; - - tokio::spawn(async move { - if let Err(e) = service.run().await { - tracing::error!("Could not start the issues service: {e:?}") - } - }); - IssuesClient(tx) - } - - /// Main loop of the service. - async fn run(&mut self) -> IssuesServiceResult<()> { - let mut messages = build_properties_changed_stream(&self.dbus).await?; - loop { - tokio::select! { - Some(cmd) = self.commands.recv() => { - if let Err(e) = self.handle_command(cmd).await { - tracing::error!("{e}"); - } - } - - Some(Ok(message)) = messages.next() => { - if let Some(changed) = PropertiesChanged::from_message(message) { - if let Err(e) = self.handle_property_changed(changed) { - tracing::error!("IssuesService: could not handle change: {:?}", e); - } - } - } - } - } - } - - /// Handles commands from the client. - async fn handle_command(&mut self, command: IssuesCommand) -> IssuesServiceResult<()> { - match command { - IssuesCommand::Get(service, path, tx) => { - let issues = self.get(&service, &path).await?; - tx.send(issues) - .map_err(|_| IssuesServiceError::SendIssues)?; - } - } - - Ok(()) - } - - /// Handles PropertiesChanged events. - /// - /// It reports an error if something went work. If the message was processed or skipped - /// it returns Ok(()). - fn handle_property_changed(&mut self, message: PropertiesChanged) -> IssuesServiceResult<()> { - let args = message.args()?; - let inner = message.message(); - let header = inner.header(); - - // We are neither interested on this message... - let Some(path) = header.path() else { - return Ok(()); - }; - - // nor on this... - if args.interface_name.as_str() != "org.opensuse.Agama1.Issues" { - return Ok(()); - } - - // nor on this one. - let Some(all) = args.changed_properties().get("All") else { - return Ok(()); - }; - - let all = all.downcast_ref::<&Array>()?; - let issues = all - .into_iter() - .map(Issue::try_from) - .collect::, _>>()?; - - self.cache.insert(path.to_string(), issues.clone()); - - let event = event!(IssuesChanged { - path: path.to_string(), - issues, - }); - self.events.send(event)?; - Ok(()) - } - - /// Gets the issues for a given D-Bus service and path. - /// - /// This method uses a cache to store the values. If the value is not in the cache, - /// it asks the D-Bus service about the issues (and cache them). - /// - /// * `service`: D-Bus service to connect to. - /// * `path`: path of the D-Bus object implementing the - /// "org.opensuse.Agama1.Issues" interface. - async fn get(&mut self, service: &str, path: &str) -> IssuesServiceResult> { - if let Some(issues) = self.cache.get(path) { - return Ok(issues.clone()); - } - - let bus = BusName::try_from(service.to_string())?; - let path = OwnedObjectPath::try_from(path)?; - let output = self - .dbus - .call_method( - Some(&bus), - &path, - Some("org.freedesktop.DBus.Properties"), - "Get", - &("org.opensuse.Agama1.Issues", "All"), - ) - .await?; - - let body = output.body(); - let body: zbus::zvariant::Value = body.deserialize()?; - let body = body.downcast_ref::<&Array>()?; - let issues = body - .into_iter() - .map(Issue::try_from) - .collect::, _>>()?; - - self.cache.insert(path.to_string(), issues.clone()); - Ok(issues) - } -} - -/// It allows querying the [IssuesService]. -/// -/// It is cheap to clone the client and use it from several -/// places. -#[derive(Clone)] -pub struct IssuesClient(mpsc::Sender); - -impl IssuesClient { - /// Get the issues for the given D-Bus service and path. - pub async fn get(&self, service: &str, path: &str) -> IssuesServiceResult> { - let (tx, rx) = oneshot::channel(); - self.0 - .send(IssuesCommand::Get( - service.to_string(), - path.to_string(), - tx, - )) - .await?; - Ok(rx.await?) - } -} - -/// It allows building an Axum router for the issues service. -pub struct IssuesRouterBuilder { - service: String, - path: String, - client: IssuesClient, -} - -impl IssuesRouterBuilder { - /// Creates a new builder. - /// - /// * `service`: D-Bus service to connect to. - /// * `path`: path of the D-Bus object implementing the - /// "org.opensuse.Agama1.Issues" interface. - /// * `client`: client to access the issues. - pub fn new(service: &str, path: &str, client: IssuesClient) -> Self { - IssuesRouterBuilder { - service: service.to_string(), - path: path.to_string(), - client, - } - } - - /// Builds the Axum router. - pub fn build(self) -> Result, crate::error::Error> { - let state = IssuesState { - service: self.service, - path: self.path, - client: self.client, - }; - - Ok(Router::new() - .route("/", get(Self::issues)) - .with_state(state)) - } - - /// Handler of the GET /issues endpoint. - async fn issues( - State(state): State, - ) -> Result>, crate::error::Error> { - let issues = state.client.get(&state.service, &state.path).await?; - Ok(Json(issues)) - } -} - -/// State for the router. -#[derive(Clone)] -struct IssuesState { - service: String, - path: String, - client: IssuesClient, -} diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 57df6f6ca8..b18bd806a9 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -37,7 +37,7 @@ use tower::ServiceExt; async fn build_server_service() -> Result { let (tx, _rx) = channel(16); - server_service(tx).await + server_service(tx, None).await } #[test] @@ -73,7 +73,7 @@ async fn test_get_empty_config() -> Result<(), Box> { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert_eq!(&body, ""); + assert_eq!(&body, "{}"); Ok(()) } diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index c9387a4f89..2d9426acb2 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -11,6 +11,7 @@ serde_json = "1.0.140" strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index 59940fcd0b..7de18a7ad3 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -18,131 +18,43 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use serde::{Deserialize, Serialize}; -use strum::FromRepr; - -#[derive(thiserror::Error, Debug)] -pub enum IssueError { - #[error("D-Bus conversion error")] - DBus(#[from] zbus::zvariant::Error), - #[error("Unknown issue source: {0}")] - UnknownSource(u8), - #[error("Unknown issue severity: {0}")] - UnknownSeverity(u8), -} - -#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Issue { - description: String, - details: Option, - source: IssueSource, - severity: IssueSeverity, - kind: String, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] -#[repr(u8)] -pub enum IssueSource { - Generic = 0, - System = 1, - Config = 2, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] -#[repr(u8)] -pub enum IssueSeverity { - Warn = 0, - Error = 1, -} - -impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { - type Error = IssueError; - - fn try_from(value: &zbus::zvariant::Value<'_>) -> Result { - let value = value.downcast_ref::()?; - let fields = value.fields(); - - let Some([description, kind, details, source, severity]) = fields.get(0..5) else { - return Err(zbus::zvariant::Error::Message( - "Not enough elements for building an Issue.".to_string(), - ))?; - }; - - let description: String = description.try_into()?; - let kind: String = kind.try_into()?; - let details: String = details.try_into()?; - let source: u32 = source.try_into()?; - let source = source as u8; - let source = IssueSource::from_repr(source).ok_or(IssueError::UnknownSource(source))?; - - let severity: u32 = severity.try_into()?; - let severity = severity as u8; - let severity = - IssueSeverity::from_repr(severity).ok_or(IssueError::UnknownSeverity(severity))?; - - Ok(Issue { - description, - kind, - details: if details.is_empty() { - None - } else { - Some(details.to_string()) - }, - source, - severity, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use zbus::zvariant; - use zvariant::{Structure, Value}; - - #[test] - fn test_issue_from_dbus() { - let dbus_issue = Structure::from(( - "Product not selected", - "missing_product", - "A product is required.", - 1 as u32, - 0 as u32, - )); - - let issue = Issue::try_from(&Value::Structure(dbus_issue)).unwrap(); - assert_eq!(&issue.description, "Product not selected"); - assert_eq!(&issue.kind, "missing_product"); - assert_eq!(issue.details, Some("A product is required.".to_string())); - assert_eq!(issue.source, IssueSource::System); - assert_eq!(issue.severity, IssueSeverity::Warn); - } - - #[test] - fn test_unknown_issue_source() { - let dbus_issue = Structure::from(( - "Product not selected", - "missing_product", - "A product is required.", - 5 as u32, - 0 as u32, - )); - - let issue = Issue::try_from(&Value::Structure(dbus_issue)); - assert!(matches!(issue, Err(IssueError::UnknownSource(5)))); - } - - #[test] - fn test_unknown_issue_severity() { - let dbus_issue = Structure::from(( - "Product not selected", - "missing_product", - "A product is required.", - 0 as u32, - 5 as u32, - )); - - let issue = Issue::try_from(&Value::Structure(dbus_issue)); - assert!(matches!(issue, Err(IssueError::UnknownSeverity(5)))); - } -} +//! Service to keep the installation issues in a centralized place. +//! +//! This service offers and API for other services to register the issues. +//! Additionally, it is responsible for emitting the corresponding event when +//! the list of issues changes. +//! +//! The service can be started calling the [start] function, which returns an +//! [agama_utils::actors::ActorHandler] to interact with it. +//! +//! # Example +//! +//! ```no_run +//! use agama_utils::issue::{self, message}; +//! use tokio::sync::mpsc; +//! +//! # tokio_test::block_on(async { +//! async fn use_issues_service() { +//! let (events_tx, _events_rx) = mpsc::unbounded_channel(); +//! let issues = issue::start(events_tx, None).await.unwrap(); +//! _ = issues.call(message::Update::new("my-service", vec![])); +//! } +//! # }); +//! +//! ``` + +pub mod event; +pub use event::IssuesChanged; + +pub mod model; +pub use model::{Issue, IssueSeverity, IssueSource}; + +pub mod service; +pub use service::Service; + +pub mod message; + +pub mod start; +pub use start::start; + +mod monitor; diff --git a/rust/agama-utils/src/issue/event.rs b/rust/agama-utils/src/issue/event.rs new file mode 100644 index 0000000000..cc845c3fda --- /dev/null +++ b/rust/agama-utils/src/issue/event.rs @@ -0,0 +1,31 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// Issues changed event. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct IssuesChanged; + +/// Multi-producer single-consumer events sender. +pub type Sender = mpsc::UnboundedSender; +/// Multi-producer single-consumer events receiver. +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs new file mode 100644 index 0000000000..b0f258a2b2 --- /dev/null +++ b/rust/agama-utils/src/issue/message.rs @@ -0,0 +1,56 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use super::Issue; +use crate::actor::Message; +use std::collections::HashMap; + +pub struct Get; + +impl Message for Get { + type Reply = HashMap>; +} + +// FIXME: consider an alternative approach to avoid pub(crate), +// making it only visible to the service. +pub struct Update { + pub(crate) list: String, + pub(crate) issues: Vec, + pub(crate) notify: bool, +} + +impl Update { + pub fn new(list: &str, issues: Vec) -> Self { + Self { + list: list.to_string(), + issues, + notify: true, + } + } + + pub fn notify(mut self, notify: bool) -> Self { + self.notify = notify; + self + } +} + +impl Message for Update { + type Reply = (); +} diff --git a/rust/agama-utils/src/issue/model.rs b/rust/agama-utils/src/issue/model.rs new file mode 100644 index 0000000000..3895abe290 --- /dev/null +++ b/rust/agama-utils/src/issue/model.rs @@ -0,0 +1,151 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use strum::FromRepr; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("D-Bus conversion error")] + DBus(#[from] zbus::zvariant::Error), + #[error("Unknown issue source: {0}")] + UnknownSource(u8), + #[error("Unknown issue severity: {0}")] + UnknownSeverity(u8), +} + +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Issue { + pub description: String, + pub details: Option, + pub source: IssueSource, + pub severity: IssueSeverity, + pub kind: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[repr(u8)] +#[serde(rename_all = "camelCase")] +pub enum IssueSource { + Unknown = 0, + System = 1, + Config = 2, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[repr(u8)] +#[serde(rename_all = "camelCase")] +pub enum IssueSeverity { + Warn = 0, + Error = 1, +} + +impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { + type Error = Error; + + fn try_from(value: &zbus::zvariant::Value<'_>) -> Result { + let value = value.downcast_ref::()?; + let fields = value.fields(); + + let Some([description, kind, details, source, severity]) = fields.get(0..5) else { + return Err(zbus::zvariant::Error::Message( + "Not enough elements for building an Issue.".to_string(), + ))?; + }; + + let description: String = description.try_into()?; + let kind: String = kind.try_into()?; + let details: String = details.try_into()?; + let source: u32 = source.try_into()?; + let source = source as u8; + let source = IssueSource::from_repr(source).ok_or(Error::UnknownSource(source))?; + + let severity: u32 = severity.try_into()?; + let severity = severity as u8; + let severity = + IssueSeverity::from_repr(severity).ok_or(Error::UnknownSeverity(severity))?; + + Ok(Issue { + description, + kind, + details: if details.is_empty() { + None + } else { + Some(details.to_string()) + }, + source, + severity, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zbus::zvariant; + use zvariant::{Structure, Value}; + + #[test] + fn test_issue_from_dbus() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 1 as u32, + 0 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)).unwrap(); + assert_eq!(&issue.description, "Product not selected"); + assert_eq!(&issue.kind, "missing_product"); + assert_eq!(issue.details, Some("A product is required.".to_string())); + assert_eq!(issue.source, IssueSource::System); + assert_eq!(issue.severity, IssueSeverity::Warn); + } + + #[test] + fn test_unknown_issue_source() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 5 as u32, + 0 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)); + assert!(matches!(issue, Err(Error::UnknownSource(5)))); + } + + #[test] + fn test_unknown_issue_severity() { + let dbus_issue = Structure::from(( + "Product not selected", + "missing_product", + "A product is required.", + 0 as u32, + 5 as u32, + )); + + let issue = Issue::try_from(&Value::Structure(dbus_issue)); + assert!(matches!(issue, Err(Error::UnknownSeverity(5)))); + } +} diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs new file mode 100644 index 0000000000..7cb8539fdd --- /dev/null +++ b/rust/agama-utils/src/issue/monitor.rs @@ -0,0 +1,176 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{actor::Handler, dbus::build_properties_changed_stream}; + +use super::{message, model, Issue, Service}; +use tokio_stream::StreamExt; +use zbus::fdo::PropertiesChanged; +use zbus::names::BusName; +use zbus::zvariant::Array; +use zvariant::OwnedObjectPath; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), + #[error("Error parsing issues from D-Bus: {0}")] + InvalidIssue(#[from] zbus::zvariant::Error), + #[error("Invalid D-Bus name")] + InvalidDBusName(#[from] zbus::names::Error), + #[error(transparent)] + Model(#[from] model::Error), +} + +/// Listens the D-Bus server and updates the list of issues. +pub struct Monitor { + handler: Handler, + dbus: zbus::Connection, +} + +const MANAGER_SERVICE: &str = "org.opensuse.Agama.Manager1"; +const SOFTWARE_SERVICE: &str = "org.opensuse.Agama.Software1"; +const STORAGE_SERVICE: &str = "org.opensuse.Agama.Storage1"; + +const ISCSI_PATH: &str = "/org/opensuse/Agama/Storage1/ISCSI"; +const PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; +const SOFTWARE_PATH: &str = "/org/opensuse/Agama/Software1"; +const STORAGE_PATH: &str = "/org/opensuse/Agama/Storage1"; +const USERS_PATH: &str = "/org/opensuse/Agama/Users1"; + +impl Monitor { + pub fn new(handler: Handler, dbus: zbus::Connection) -> Self { + Self { handler, dbus } + } + + pub async fn run(&self) -> Result<(), Error> { + let mut messages = build_properties_changed_stream(&self.dbus).await?; + + self.initialize_issues(MANAGER_SERVICE, USERS_PATH).await?; + self.initialize_issues(SOFTWARE_SERVICE, SOFTWARE_PATH) + .await?; + self.initialize_issues(SOFTWARE_SERVICE, PRODUCT_PATH) + .await?; + self.initialize_issues(STORAGE_SERVICE, STORAGE_PATH) + .await?; + self.initialize_issues(STORAGE_SERVICE, ISCSI_PATH).await?; + + while let Some(Ok(message)) = messages.next().await { + if let Some(changed) = PropertiesChanged::from_message(message) { + if let Err(e) = self.handle_property_changed(changed) { + println!("Could not handle issues change: {:?}", e); + } + } + } + + Ok(()) + } + + /// Handles PropertiesChanged events. + /// + /// It reports an error if something went work. If the message was processed or skipped + /// it returns Ok(()). + fn handle_property_changed(&self, message: PropertiesChanged) -> Result<(), Error> { + let args = message.args()?; + let inner = message.message(); + let header = inner.header(); + + // We are neither interested on this message... + let Some(path) = header.path() else { + return Ok(()); + }; + + // nor on this... + if args.interface_name.as_str() != "org.opensuse.Agama1.Issues" { + return Ok(()); + } + + // nor on this one. + let Some(all) = args.changed_properties().get("All") else { + return Ok(()); + }; + + let all = all.downcast_ref::<&Array>()?; + let issues = all + .into_iter() + .map(Issue::try_from) + .collect::, _>>()?; + + self.update_issues(path.as_str(), issues, true); + + Ok(()) + } + + /// Initializes the list of issues reading the list from D-Bus. + /// + /// * `service`: service name. + /// * `path`: path of the object implementing issues interface. + async fn initialize_issues(&self, service: &str, path: &str) -> Result<(), Error> { + let bus = BusName::try_from(service.to_string())?; + let dbus_path = OwnedObjectPath::try_from(path)?; + let output = self + .dbus + .call_method( + Some(&bus), + &dbus_path, + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.opensuse.Agama1.Issues", "All"), + ) + .await?; + + let body = output.body(); + let body: zbus::zvariant::Value = body.deserialize()?; + let body = body.downcast_ref::<&Array>()?; + + let issues = body + .into_iter() + .map(Issue::try_from) + .collect::, _>>()?; + self.update_issues(path, issues, false); + + Ok(()) + } + + /// Updates the list of issues. + fn update_issues(&self, path: &str, issues: Vec, notify: bool) { + match Self::list_id_from_path(path) { + Some(list) => { + _ = self + .handler + .cast(message::Update::new(list, issues).notify(notify)); + } + None => { + eprintln!("Unknown issues object {}", path); + } + } + } + + fn list_id_from_path(path: &str) -> Option<&'static str> { + match path { + SOFTWARE_PATH => Some("software"), + PRODUCT_PATH => Some("product"), + STORAGE_PATH => Some("storage"), + USERS_PATH => Some("users"), + ISCSI_PATH => Some("iscsi"), + _ => None, + } + } +} diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs new file mode 100644 index 0000000000..f96d5402a3 --- /dev/null +++ b/rust/agama-utils/src/issue/service.rs @@ -0,0 +1,77 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use super::{event, message, Issue, IssuesChanged}; +use crate::actor::{self, Actor, MessageHandler}; +use async_trait::async_trait; +use std::collections::HashMap; +use tokio::sync::mpsc; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + Model(#[from] super::model::Error), +} + +pub struct Service { + issues: HashMap>, + events: mpsc::UnboundedSender, +} + +impl Service { + pub fn new(events: mpsc::UnboundedSender) -> Self { + Self { + issues: HashMap::new(), + events, + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::Get, + ) -> Result>, Error> { + Ok(self.issues.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Update) -> Result<(), Error> { + if message.issues.is_empty() { + _ = self.issues.remove(&message.list); + } else { + self.issues.insert(message.list, message.issues); + } + + if message.notify { + _ = self.events.send(event::IssuesChanged); + } + Ok(()) + } +} diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs new file mode 100644 index 0000000000..051d398a24 --- /dev/null +++ b/rust/agama-utils/src/issue/start.rs @@ -0,0 +1,102 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use super::{event, monitor::Monitor, service, Service}; +use crate::actor::{self, Handler}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Service(#[from] service::Error), +} + +pub async fn start( + events: event::Sender, + dbus: Option, +) -> Result, Error> { + let service = Service::new(events); + let handler = actor::spawn(service); + + if let Some(conn) = dbus { + let monitor = Monitor::new(handler.clone(), conn); + tokio::spawn(async move { + if let Err(e) = monitor.run().await { + println!("Error running the issues monitor: {e:?}"); + } + }); + } + + Ok(handler) +} + +#[cfg(test)] +mod tests { + use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; + use tokio::sync::mpsc::{self, error::TryRecvError}; + + #[tokio::test] + async fn test_get_and_update_issues() -> Result<(), Box> { + let (events_tx, mut events_rx) = mpsc::unbounded_channel(); + let issues = issue::start(events_tx, None).await.unwrap(); + let issue = Issue { + description: "Product not selected".to_string(), + kind: "missing_product".to_string(), + details: Some("A product is required.".to_string()), + source: IssueSource::Config, + severity: IssueSeverity::Error, + }; + + let issues_list = issues.call(message::Get).await.unwrap(); + assert!(issues_list.is_empty()); + + _ = issues.cast(message::Update::new("my-service", vec![issue])); + + let issues_list = issues.call(message::Get).await.unwrap(); + assert_eq!(issues_list.len(), 1); + + assert!(events_rx.recv().await.is_some()); + Ok(()) + } + + #[tokio::test] + async fn test_update_wo_event() -> Result<(), Box> { + let (events_tx, mut events_rx) = mpsc::unbounded_channel(); + let issues = issue::start(events_tx, None).await.unwrap(); + let issue = Issue { + description: "Product not selected".to_string(), + kind: "missing_product".to_string(), + details: Some("A product is required.".to_string()), + source: IssueSource::Config, + severity: IssueSeverity::Error, + }; + + let issues_list = issues.call(message::Get).await.unwrap(); + assert!(issues_list.is_empty()); + + let update = message::Update::new("my-service", vec![issue]).notify(false); + _ = issues.cast(update); + + let issues_list = issues.call(message::Get).await.unwrap(); + assert_eq!(issues_list.len(), 1); + + assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); + Ok(()) + } +} diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index b8af1ed052..924a2eb612 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -32,7 +32,7 @@ const transformLocales = (locales) => const tranformKeymaps = (keymaps) => keymaps.map(({ id, description: name }) => ({ id, name })); const transformTimezones = (timezones) => - timezones.map(({ code: id, parts, country }) => { + timezones.map(({ id, parts, country }) => { const utcOffset = tzOffset(id, new Date()); return { id, parts, country, utcOffset }; }); From f0a2fada4b25aa4991edc6bce40c739c3e7408c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 17:50:02 +0100 Subject: [PATCH 112/917] Adapt the UI to the new issues API --- web/src/api/issues.ts | 17 ++- .../components/core/InstallButton.test.tsx | 54 ++++---- web/src/components/core/InstallButton.tsx | 3 +- web/src/components/core/IssuesAlert.test.tsx | 2 + web/src/components/core/IssuesDrawer.test.tsx | 117 ++++++++---------- web/src/components/core/IssuesDrawer.tsx | 11 +- .../overview/StorageSection.test.tsx | 6 +- .../product/ProductRegistrationAlert.test.tsx | 7 +- .../storage/ProposalFailedInfo.test.tsx | 6 +- .../components/storage/ProposalPage.test.tsx | 12 +- web/src/queries/issues.ts | 51 +++----- web/src/types/issues.ts | 61 ++++----- 12 files changed, 155 insertions(+), 192 deletions(-) diff --git a/web/src/api/issues.ts b/web/src/api/issues.ts index 7a457eceda..fee355836f 100644 --- a/web/src/api/issues.ts +++ b/web/src/api/issues.ts @@ -21,18 +21,17 @@ */ import { get } from "~/api/http"; -import { Issue, IssuesScope } from "~/types/issues"; - -const URLS = { - product: "software/issues/product", - software: "software/issues/software", - users: "users/issues", - storage: "storage/issues", -}; +import { Issue, IssuesMap, IssuesScope } from "~/types/issues"; /** * Return the issues of the given scope. */ -const fetchIssues = (scope: IssuesScope): Promise => get(`/api/${URLS[scope]}`); +const fetchIssues = async (): Promise => { + const issues = (await get(`/api/v2/issues`)) as IssuesMap; + return Object.keys(issues).reduce((all: Issue[], key: IssuesScope) => { + const scoped = issues[key].map((i) => ({ ...i, scope: key })); + return all.concat(scoped); + }, []); +}; export { fetchIssues }; diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index 1983dc3134..f953801184 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -24,11 +24,11 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import { InstallButton } from "~/components/core"; -import { IssuesList } from "~/types/issues"; import { PRODUCT, ROOT } from "~/routes/paths"; +import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; const mockStartInstallationFn = jest.fn(); -let mockIssuesList: IssuesList; +let mockIssuesList: Issue[]; jest.mock("~/api/manager", () => ({ ...jest.requireActual("~/api/manager"), @@ -52,20 +52,16 @@ const clickInstallButton = async () => { describe("InstallButton", () => { describe("when there are installation issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList( - [ - { - description: "Fake Issue", - kind: "generic", - source: 0, - severity: 1, - details: "Fake Issue details", - }, - ], - [], - [], - [], - ); + mockIssuesList = [ + { + description: "Fake Issue", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Fake Issue details", + scope: "product", + }, + ]; }); it("renders additional information to warn users about found problems", async () => { @@ -90,7 +86,7 @@ describe("InstallButton", () => { describe("when there are not installation issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList([], [], [], []); + mockIssuesList = []; }); it("renders the button without any additional information", async () => { @@ -134,20 +130,16 @@ describe("InstallButton", () => { describe("when there are only non-critical issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList( - [ - { - description: "Fake warning", - kind: "generic", - source: 0, - severity: 0, - details: "Fake Issue details", - }, - ], - [], - [], - [], - ); + mockIssuesList = [ + { + description: "Fake warning", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Warn, + details: "Fake Issue details", + scope: "product", + }, + ]; }); it("renders the button without any additional information", async () => { diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index b24b99e431..2323a5d6cf 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -30,6 +30,7 @@ import { useLocation } from "react-router-dom"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; +import { isEmpty } from "radashi"; /** * List of paths where the InstallButton must not be shown. @@ -81,7 +82,7 @@ const InstallButton = ( const issues = useAllIssues().filter((i) => i.severity === IssueSeverity.Error); const [isOpen, setIsOpen] = useState(false); const location = useLocation(); - const hasIssues = !issues.isEmpty; + const hasIssues = !isEmpty(issues); if (SIDE_PATHS.includes(location.pathname)) return; diff --git a/web/src/components/core/IssuesAlert.test.tsx b/web/src/components/core/IssuesAlert.test.tsx index 9df795e754..494a972cbb 100644 --- a/web/src/components/core/IssuesAlert.test.tsx +++ b/web/src/components/core/IssuesAlert.test.tsx @@ -34,6 +34,7 @@ describe("IssueAlert", () => { source: IssueSource.Config, severity: IssueSeverity.Error, kind: "generic", + scope: "software", }; plainRender(); expect(screen.getByText(issue.description)).toBeInTheDocument(); @@ -45,6 +46,7 @@ describe("IssueAlert", () => { source: IssueSource.Config, severity: IssueSeverity.Error, kind: "solver", + scope: "software", }; plainRender(); const link = screen.getByRole("link", { name: "Review and fix" }); diff --git a/web/src/components/core/IssuesDrawer.test.tsx b/web/src/components/core/IssuesDrawer.test.tsx index 2e3610d8ae..20c52bab18 100644 --- a/web/src/components/core/IssuesDrawer.test.tsx +++ b/web/src/components/core/IssuesDrawer.test.tsx @@ -24,11 +24,11 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { InstallationPhase } from "~/types/status"; -import { IssuesList } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; import IssuesDrawer from "./IssuesDrawer"; let phase = InstallationPhase.Config; -let mockIssuesList: IssuesList; +let mockIssuesList: Issue[]; const onCloseFn = jest.fn(); jest.mock("~/queries/issues", () => ({ @@ -51,7 +51,7 @@ const itRendersNothing = () => describe("IssuesDrawer", () => { describe("when there are no installation issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList([], [], [], []); + mockIssuesList = []; }); itRendersNothing(); @@ -59,20 +59,16 @@ describe("IssuesDrawer", () => { describe("when there are non-critical issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList( - [ - { - description: "Registration Fake Warning", - kind: "generic", - source: 0, - severity: 0, - details: "Registration Fake Issue details", - }, - ], - [], - [], - [], - ); + mockIssuesList = [ + { + description: "Registration Fake Warning", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Warn, + details: "Registration Fake Issue details", + scope: "product", + }, + ]; }); itRendersNothing(); @@ -80,51 +76,48 @@ describe("IssuesDrawer", () => { describe("when there are installation issues", () => { beforeEach(() => { - mockIssuesList = new IssuesList( - [ - { - description: "Registration Fake Issue", - kind: "generic", - source: 0, - severity: 1, - details: "Registration Fake Issue details", - }, - ], - [ - { - description: "Software Fake Issue", - kind: "generic", - source: 0, - severity: 1, - details: "Software Fake Issue details", - }, - ], - [ - { - description: "Storage Fake Issue 1", - kind: "generic", - source: 0, - severity: 1, - details: "Storage Fake Issue 1 details", - }, - { - description: "Storage Fake Issue 2", - kind: "generic", - source: 0, - severity: 1, - details: "Storage Fake Issue 2 details", - }, - ], - [ - { - description: "Users Fake Issue", - kind: "generic", - source: 0, - severity: 1, - details: "Users Fake Issue details", - }, - ], - ); + mockIssuesList = [ + { + description: "Registration Fake Issue", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Registration Fake Issue details", + scope: "product", + }, + { + description: "Software Fake Issue", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Software Fake Issue details", + scope: "software", + }, + { + description: "Storage Fake Issue 1", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Storage Fake Issue 1 details", + scope: "storage", + }, + { + description: "Storage Fake Issue 2", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Storage Fake Issue 2 details", + scope: "storage", + }, + { + description: "Users Fake Issue", + kind: "generic", + source: IssueSource.Unknown, + severity: IssueSeverity.Error, + details: "Users Fake Issue details", + scope: "users", + }, + ]; }); it("renders the drawer with categorized issues linking to their scope", async () => { diff --git a/web/src/components/core/IssuesDrawer.tsx b/web/src/components/core/IssuesDrawer.tsx index b26cac162b..2bf6c08d07 100644 --- a/web/src/components/core/IssuesDrawer.tsx +++ b/web/src/components/core/IssuesDrawer.tsx @@ -42,7 +42,6 @@ import { _ } from "~/i18n"; const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { const issues = useAllIssues().filter((i) => i.severity === IssueSeverity.Error); const { phase } = useInstallerStatus({ suspense: true }); - const { issues: issuesByScope } = issues; // FIXME: share below headers with navigation menu const scopeHeaders = { @@ -50,9 +49,10 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { storage: _("Storage"), software: _("Software"), product: _("Registration"), + iscsi: _("iSCSI"), }; - if (issues.isEmpty || phase === InstallationPhase.Install) return; + if (issues.length === 0 || phase === InstallationPhase.Install) return; return ( @@ -64,8 +64,9 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { "Before installing, you have to make some decisions. Click on each section to review the settings.", )}

- {Object.entries(issuesByScope).map(([scope, issues], idx) => { - if (issues.length === 0) return null; + {Object.keys(scopeHeaders).map((scope, idx) => { + const scopeIssues = issues.filter((i) => i.scope === scope); + if (scopeIssues.length === 0) return null; // FIXME: address this better or use the /product(s)? namespace instead of // /registration. const section = scope === "product" ? "registration" : scope; @@ -80,7 +81,7 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => {
    - {issues.map((issue, subIdx) => { + {scopeIssues.map((issue, subIdx) => { const variant = issue.severity === IssueSeverity.Error ? "warning" : "info"; return ( diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index ea886a9966..39f47e7b14 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,6 +24,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; +import { IssueSeverity, IssueSource } from "~/types/issues"; let mockModel = { drives: [], @@ -248,8 +249,9 @@ describe("when there is no configuration model (unsupported features)", () => { description: "System error", kind: "storage", details: "", - source: 1, - severity: 1, + source: IssueSource.System, + severity: IssueSeverity.Error, + scope: "storage", }, ]; }); diff --git a/web/src/components/product/ProductRegistrationAlert.test.tsx b/web/src/components/product/ProductRegistrationAlert.test.tsx index 9ee9c4a4c4..fc93a796c7 100644 --- a/web/src/components/product/ProductRegistrationAlert.test.tsx +++ b/web/src/components/product/ProductRegistrationAlert.test.tsx @@ -28,7 +28,7 @@ import { Product } from "~/types/software"; import { useProduct } from "~/queries/software"; import { useIssues } from "~/queries/issues"; import { PRODUCT, REGISTRATION, ROOT } from "~/routes/paths"; -import { Issue } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; const tw: Product = { id: "Tumbleweed", @@ -59,8 +59,9 @@ const registrationIssue: Issue = { description: "Product must be registered", details: "", kind: "missing_registration", - source: 0, - severity: 0, + source: IssueSource.Unknown, + severity: IssueSeverity.Warn, + scope: "storage", }; jest.mock("~/queries/issues", () => ({ diff --git a/web/src/components/storage/ProposalFailedInfo.test.tsx b/web/src/components/storage/ProposalFailedInfo.test.tsx index 80e648210b..43ebc2c25e 100644 --- a/web/src/components/storage/ProposalFailedInfo.test.tsx +++ b/web/src/components/storage/ProposalFailedInfo.test.tsx @@ -35,8 +35,9 @@ const configError: Issue = { description: "Config error", kind: "storage", details: "", - source: 2, - severity: 1, + source: IssueSource.Config, + severity: IssueSeverity.Error, + scope: "storage", }; const storageIssue: Issue = { @@ -45,6 +46,7 @@ const storageIssue: Issue = { kind: "storage_issue", source: IssueSource.Unknown, severity: IssueSeverity.Error, + scope: "storage", }; const mockApiModel: apiModel.Config = { diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index c2f1586482..5f0b708e8e 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -30,7 +30,7 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import ProposalPage from "~/components/storage/ProposalPage"; import { StorageDevice } from "~/types/storage"; -import { Issue } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; const disk: StorageDevice = { sid: 60, @@ -49,16 +49,18 @@ const systemError: Issue = { description: "System error", kind: "storage", details: "", - source: 1, - severity: 1, + source: IssueSource.System, + severity: IssueSeverity.Error, + scope: "storage", }; const configError: Issue = { description: "Config error", kind: "storage", details: "", - source: 2, - severity: 1, + source: IssueSource.Config, + severity: IssueSeverity.Error, + scope: "storage", }; const mockUseAvailableDevices = jest.fn(); diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 41b5888f63..412bacd9f9 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -21,22 +21,16 @@ */ import React from "react"; -import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { IssuesList, IssuesScope, IssueSeverity, IssueSource } from "~/types/issues"; +import { IssuesScope, IssueSeverity, IssueSource, Issue } from "~/types/issues"; import { fetchIssues } from "~/api/issues"; -const scopesFromPath = { - "/org/opensuse/Agama/Software1": "software", - "/org/opensuse/Agama/Software1/Product": "product", - "/org/opensuse/Agama/Storage1": "storage", - "/org/opensuse/Agama/Users1": "users", -}; - -const issuesQuery = (scope: IssuesScope) => { +const issuesQuery = (selectFn?: (i: Issue[]) => Issue[]) => { return { - queryKey: ["issues", scope], - queryFn: () => fetchIssues(scope), + queryKey: ["issues"], + queryFn: fetchIssues, + select: selectFn, }; }; @@ -46,22 +40,18 @@ const issuesQuery = (scope: IssuesScope) => { * @param scope - Scope to get the issues from. * @return issues for the given scope. */ -const useIssues = (scope: IssuesScope) => { - const { data } = useSuspenseQuery(issuesQuery(scope)); +const useIssues = (scope: IssuesScope): Issue[] => { + const { data } = useSuspenseQuery( + issuesQuery((issues: Issue[]) => { + return issues.filter((i: Issue) => i.scope === scope); + }), + ); return data; }; -const useAllIssues = () => { - const queries = [ - issuesQuery("product"), - issuesQuery("software"), - issuesQuery("storage"), - issuesQuery("users"), - ]; - - const [{ data: product }, { data: software }, { data: storage }, { data: users }] = - useSuspenseQueries({ queries }); - return new IssuesList(product, software, storage, users); +const useAllIssues = (): Issue[] => { + const { data } = useSuspenseQuery(issuesQuery()); + return data; }; const useIssuesChanges = () => { @@ -72,15 +62,8 @@ const useIssuesChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "IssuesChanged") { - const path = event.path; - const scope = scopesFromPath[path]; - // TODO: use setQueryData because all the issues are included in the event - if (scope) { - queryClient.invalidateQueries({ queryKey: ["issues", scope] }); - } else { - console.warn(`Unknown scope ${path}`); - } + if (event.type === "IssuesUpdated") { + queryClient.invalidateQueries({ queryKey: ["issues"] }); } }); }, [client, queryClient]); diff --git a/web/src/types/issues.ts b/web/src/types/issues.ts index 827ad256bc..5efb26ecd9 100644 --- a/web/src/types/issues.ts +++ b/web/src/types/issues.ts @@ -23,7 +23,7 @@ /** * Known scopes for issues. */ -type IssuesScope = "product" | "software" | "storage" | "users"; +type IssuesScope = "localization" | "product" | "software" | "storage" | "users" | "iscsi"; /** * Source of the issue @@ -32,11 +32,11 @@ type IssuesScope = "product" | "software" | "storage" | "users"; */ enum IssueSource { /** Unknown source (it is kind of a fallback value) */ - Unknown = 0, + Unknown = "unknown", /** An unexpected situation in the system (e.g., missing device). */ - System = 1, + System = "system", /** Wrong or incomplete configuration (e.g., an authentication mechanism is not set) */ - Config = 2, + Config = "config", } /** @@ -46,15 +46,15 @@ enum IssueSource { */ enum IssueSeverity { /** Just a warning, the installation can start */ - Warn = 0, + Warn = "warn", /** An important problem that makes the installation not possible */ - Error = 1, + Error = "error", } /** - * Pre-installation issue + * Pre-installation issue as they come from the API. */ -type Issue = { +type ApiIssue = { /** Issue description */ description: string; /** Issue kind **/ @@ -68,36 +68,21 @@ type Issue = { }; /** - * Issues list + * Issues grouped by scope as they come from the API. */ -class IssuesList { - /** List of issues grouped by scope */ - issues: { [key: string]: Issue[] }; - /** Whether the list is empty */ - isEmpty: boolean; - - constructor(product: Issue[], software: Issue[], storage: Issue[], users: Issue[]) { - this.issues = { - product, - software, - storage, - users, - }; - this.isEmpty = !Object.values(this.issues).some((v) => v.length > 0); - } +type IssuesMap = { + localization?: ApiIssue[]; + software?: ApiIssue[]; + product?: ApiIssue[]; + storage?: ApiIssue[]; + iscsi?: ApiIssue[]; + users?: ApiIssue[]; +}; - /** - * Creates a new list only with the issues that match the given function - */ - filter(fn) { - return new IssuesList( - this.issues["product"].filter(fn), - this.issues["software"].filter(fn), - this.issues["storage"].filter(fn), - this.issues["users"].filter(fn), - ); - } -} +/** + * Pre-installation issue augmented with the scope. + */ +type Issue = ApiIssue & { scope: IssuesScope }; /** * Validation error @@ -106,5 +91,5 @@ type ValidationError = { message: string; }; -export { IssueSource, IssuesList, IssueSeverity }; -export type { Issue, IssuesScope, ValidationError }; +export { IssueSource, IssueSeverity }; +export type { ApiIssue, IssuesMap, IssuesScope, Issue, ValidationError }; From 3b99856c02ebd4b9903cbe014f08e751c2245bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 18:36:27 +0100 Subject: [PATCH 113/917] Drop unused issues API related code --- rust/agama-lib/src/proxies.rs | 3 --- rust/agama-lib/src/proxies/issues.rs | 27 --------------------------- 2 files changed, 30 deletions(-) delete mode 100644 rust/agama-lib/src/proxies/issues.rs diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 7374bb057e..b0b5f38a60 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -29,9 +29,6 @@ pub use manager1::Manager1Proxy; pub mod questions; -mod issues; -pub use issues::IssuesProxy; - mod locale; pub use locale::LocaleMixinProxy; diff --git a/rust/agama-lib/src/proxies/issues.rs b/rust/agama-lib/src/proxies/issues.rs deleted file mode 100644 index 87505aeb62..0000000000 --- a/rust/agama-lib/src/proxies/issues.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Issues` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Progress.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy(interface = "org.opensuse.Agama1.Issues", assume_defaults = true)] -pub trait Issues { - /// All property - #[zbus(property)] - fn all(&self) -> zbus::Result>; -} From aed9af34afec1f2170f93f4f6cdd1ba1b71f706f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 18:36:54 +0100 Subject: [PATCH 114/917] Use "IssuesChanged" instead of "IssuesUpdated" --- rust/agama-lib/src/http/event.rs | 10 ++-------- web/src/queries/issues.ts | 3 ++- web/src/queries/status.ts | 3 --- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 4db25c3f3d..a8167b502d 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -39,8 +39,6 @@ use agama_utils::issue; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::issue::Issue; - /// Agama event. /// /// It represents an event that occurs in Agama. @@ -122,11 +120,7 @@ pub enum EventPayload { service: String, status: u32, }, - IssuesUpdated, - IssuesChanged { - path: String, - issues: Vec, - }, + Issues(issue::IssuesChanged), ValidationChanged { service: String, path: String, @@ -196,7 +190,7 @@ impl From for EventPayload { impl From for EventPayload { fn from(_value: issue::IssuesChanged) -> Self { - EventPayload::IssuesUpdated + EventPayload::Issues(issue::IssuesChanged) } } diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 412bacd9f9..37bb2253ee 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -62,8 +62,9 @@ const useIssuesChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "IssuesUpdated") { + if (event.name === "IssuesChanged") { queryClient.invalidateQueries({ queryKey: ["issues"] }); + queryClient.invalidateQueries({ queryKey: ["status"] }); } }); }, [client, queryClient]); diff --git a/web/src/queries/status.ts b/web/src/queries/status.ts index 0380ef4a0b..09671fa848 100644 --- a/web/src/queries/status.ts +++ b/web/src/queries/status.ts @@ -66,9 +66,6 @@ const useInstallerStatusChanges = () => { const data = queryClient.getQueryData(["status"]) as object; switch (type) { - case "IssuesChanged": - queryClient.invalidateQueries({ queryKey: ["status"] }); - break; case "InstallationPhaseChanged": if (!data) { console.warn("Ignoring InstallationPhaseChanged event", event); From 46decc0c5fedf9857d05e74498a39e36a2b2fb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 21:17:40 +0100 Subject: [PATCH 115/917] Update changes files --- rust/package/agama.changes | 7 +++++++ service/package/rubygem-agama-yast.changes | 6 ++++++ web/package/agama-web-ui.changes | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 48a0eea0c2..7ef116fec5 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Fri Oct 3 20:11:26 UTC 2025 - Imobach Gonzalez Sosa + +- Introduce the new HTTP API (gh#agama-project/agama#2715). +- Reimplement the localization service as part of the new API, + following the new actors-based approach. + ------------------------------------------------------------------- Mon Sep 15 21:09:06 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index fba6fe51fa..dedd215a47 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Oct 3 20:11:33 UTC 2025 - Imobach Gonzalez Sosa + +- Use the new HTTP API to write the localization configuration + to the target system (gh#agama-project/agama#2715). + ------------------------------------------------------------------- Mon Sep 15 13:50:54 UTC 2025 - José Iván López González diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 4fc0ee90f3..c1147e3fb8 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Oct 3 20:11:40 UTC 2025 - Imobach Gonzalez Sosa + +- Start adapting the UI to the new HTTP API + (gh#agama-project/agama#2715). + ------------------------------------------------------------------- Thu Sep 18 08:57:11 UTC 2025 - David Diaz From 99158a8448ac231e48deb31f1b767578fc52f1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 18:39:12 +0100 Subject: [PATCH 116/917] Update the OpenAPI specification * Update the issues API documentation. --- rust/agama-server/src/web/docs/common.rs | 73 +--------------------- rust/agama-server/src/web/docs/config.rs | 2 + rust/agama-server/src/web/docs/software.rs | 21 +------ rust/agama-server/src/web/docs/storage.rs | 17 ++--- rust/agama-server/src/web/docs/users.rs | 16 +---- 5 files changed, 13 insertions(+), 116 deletions(-) diff --git a/rust/agama-server/src/web/docs/common.rs b/rust/agama-server/src/web/docs/common.rs index 64e63243a0..0b0a2d7e4c 100644 --- a/rust/agama-server/src/web/docs/common.rs +++ b/rust/agama-server/src/web/docs/common.rs @@ -23,79 +23,12 @@ use super::ApiDocBuilder; use crate::web::common::ServiceStatus; -use agama_lib::{issue::Issue, progress::Progress}; +use agama_lib::progress::Progress; use utoipa::openapi::{ - path::OperationBuilder, schema::RefBuilder, ArrayBuilder, Components, ComponentsBuilder, - ContentBuilder, HttpMethod, PathItem, Paths, PathsBuilder, ResponseBuilder, ResponsesBuilder, + path::OperationBuilder, schema::RefBuilder, Components, ComponentsBuilder, ContentBuilder, + HttpMethod, PathItem, Paths, PathsBuilder, ResponseBuilder, ResponsesBuilder, }; -/// Implements a builder for the issues API documentation. -#[derive(Default)] -pub struct IssuesApiDocBuilder { - paths: Vec<(String, PathItem)>, -} - -impl IssuesApiDocBuilder { - pub fn new() -> Self { - Default::default() - } - - /// Adds a new issues API path. - /// - /// * `path`: path of the API. - /// * `summary`: summary to be included in the OpenAPI documentation. - /// * `operation_id`: operation ID of the API. - pub fn add(self, path: &str, summary: &str, operation_id: &str) -> Self { - let mut paths = self.paths; - paths.push((path.to_string(), Self::issues_path(summary, operation_id))); - Self { paths } - } - - fn issues_path(summary: &'_ str, operation_id: &'_ str) -> PathItem { - PathItem::new( - HttpMethod::Get, - OperationBuilder::new() - .summary(Some(summary)) - .operation_id(Some(operation_id)) - .responses( - ResponsesBuilder::new().response( - "200", - ResponseBuilder::new() - .description("List of found issues") - .content( - "application/json", - ContentBuilder::new() - .schema(Some( - ArrayBuilder::new().items(RefBuilder::new().ref_location( - "#/components/schemas/Issue".to_string(), - )), - )) - .build(), - ), - ), - ), - ) - } -} - -impl ApiDocBuilder for IssuesApiDocBuilder { - fn title(&self) -> String { - "Issues HTTP API".to_string() - } - - fn paths(&self) -> Paths { - let mut paths_builder = PathsBuilder::new(); - for (path, item) in self.paths.iter() { - paths_builder = paths_builder.path(path, item.clone()); - } - paths_builder.build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new().schema_from::().build() - } -} - /// Implements a builder for the service status API documentation. pub struct ServiceStatusApiDocBuilder { path: String, diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index dd1dbd24a9..b66f906f7b 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -58,6 +58,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs index 14dd7233b3..fedd4e77e3 100644 --- a/rust/agama-server/src/web/docs/software.rs +++ b/rust/agama-server/src/web/docs/software.rs @@ -20,10 +20,7 @@ use utoipa::openapi::{Components, ComponentsBuilder, OpenApi, Paths, PathsBuilder}; -use super::{ - common::{IssuesApiDocBuilder, ServiceStatusApiDocBuilder}, - ApiDocBuilder, -}; +use super::{common::ServiceStatusApiDocBuilder, ApiDocBuilder}; pub struct SoftwareApiDocBuilder; @@ -52,7 +49,6 @@ impl ApiDocBuilder for SoftwareApiDocBuilder { fn components(&self) -> Components { ComponentsBuilder::new() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -72,20 +68,7 @@ impl ApiDocBuilder for SoftwareApiDocBuilder { } fn nested(&self) -> Option { - let mut issues = IssuesApiDocBuilder::new() - .add( - "/api/software/issues/software", - "List of software-related issues", - "software_issues", - ) - .add( - "/api/product/issues/product", - "List of product-related issues", - "product_issues", - ) - .build(); let status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); - issues.merge(status); - Some(issues) + Some(status) } } diff --git a/rust/agama-server/src/web/docs/storage.rs b/rust/agama-server/src/web/docs/storage.rs index 0d29c32631..89601598dd 100644 --- a/rust/agama-server/src/web/docs/storage.rs +++ b/rust/agama-server/src/web/docs/storage.rs @@ -21,7 +21,7 @@ use utoipa::openapi::{Components, ComponentsBuilder, OpenApi, Paths, PathsBuilder}; use super::{ - common::{IssuesApiDocBuilder, ProgressApiDocBuilder, ServiceStatusApiDocBuilder}, + common::{ProgressApiDocBuilder, ServiceStatusApiDocBuilder}, ApiDocBuilder, }; @@ -83,7 +83,6 @@ impl ApiDocBuilder for StorageApiDocBuilder { fn components(&self) -> Components { ComponentsBuilder::new() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -133,17 +132,9 @@ impl ApiDocBuilder for StorageApiDocBuilder { } fn nested(&self) -> Option { - let mut issues = IssuesApiDocBuilder::new() - .add( - "/api/storage/issues", - "List of storage-related issues", - "storage_issues", - ) - .build(); - let status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); + let mut status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); let progress = ProgressApiDocBuilder::new("/api/storage/progress").build(); - issues.merge(status); - issues.merge(progress); - Some(issues) + status.merge(progress); + Some(status) } } diff --git a/rust/agama-server/src/web/docs/users.rs b/rust/agama-server/src/web/docs/users.rs index fb59f6960e..f61b558d67 100644 --- a/rust/agama-server/src/web/docs/users.rs +++ b/rust/agama-server/src/web/docs/users.rs @@ -20,10 +20,7 @@ use utoipa::openapi::{ComponentsBuilder, OpenApi, Paths, PathsBuilder}; -use super::{ - common::{IssuesApiDocBuilder, ServiceStatusApiDocBuilder}, - ApiDocBuilder, -}; +use super::{common::ServiceStatusApiDocBuilder, ApiDocBuilder}; pub struct UsersApiDocBuilder; @@ -45,7 +42,6 @@ impl ApiDocBuilder for UsersApiDocBuilder { fn components(&self) -> utoipa::openapi::Components { ComponentsBuilder::new() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -61,15 +57,7 @@ impl ApiDocBuilder for UsersApiDocBuilder { } fn nested(&self) -> Option { - let mut issues = IssuesApiDocBuilder::new() - .add( - "/api/users/issues", - "List of user-related issues", - "user_issues", - ) - .build(); let status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); - issues.merge(status); - Some(issues) + Some(status) } } From 3ef60b7f91638bb869bc4b8d8e8621e4eaf8d938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 21:35:45 +0100 Subject: [PATCH 117/917] Improve error about invalid locale/keymap/timezone --- rust/agama-l10n/src/service.rs | 6 +++--- rust/agama-locale-data/src/locale.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 0296d775a3..9b333c53ad 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -38,11 +38,11 @@ pub enum Error { UnknownKeymap(KeymapId), #[error("Unknown timezone: {0}")] UnknownTimezone(String), - #[error("Invalid locale: {0}")] + #[error(transparent)] InvalidLocale(#[from] InvalidLocaleId), - #[error("Invalid keymap: {0}")] + #[error(transparent)] InvalidKeymap(#[from] InvalidKeymapId), - #[error("Invalid timezone")] + #[error(transparent)] InvalidTimezone(#[from] InvalidTimezoneId), #[error("l10n service could not send the event")] Event, diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index a1f6fe6965..d1c5a72b4b 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -48,7 +48,7 @@ impl TimezoneId { } #[derive(Clone, Error, Debug)] -#[error("Not a valid timezone: {0}")] +#[error("Invalid timezone ID: {0}")] pub struct InvalidTimezoneId(String); impl FromStr for TimezoneId { @@ -90,7 +90,7 @@ impl Default for LocaleId { } #[derive(Clone, Error, Debug)] -#[error("Not a valid locale string: {0}")] +#[error("Invalid locale ID: {0}")] pub struct InvalidLocaleId(String); impl FromStr for LocaleId { From 9a3a520d14caab708f0beb8f5023c36347baff26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 3 Oct 2025 21:52:53 +0100 Subject: [PATCH 118/917] Display potential l10n errors in the drawer --- web/src/components/core/IssuesDrawer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/core/IssuesDrawer.tsx b/web/src/components/core/IssuesDrawer.tsx index 2bf6c08d07..493cd00f1a 100644 --- a/web/src/components/core/IssuesDrawer.tsx +++ b/web/src/components/core/IssuesDrawer.tsx @@ -49,6 +49,7 @@ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { storage: _("Storage"), software: _("Software"), product: _("Registration"), + localization: _("Localization"), iscsi: _("iSCSI"), }; From 26ef9b1510fda13b338839af537ee217a104003c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 6 Oct 2025 07:13:30 +0100 Subject: [PATCH 119/917] Improve documentation --- rust/agama-l10n/src/service.rs | 13 ++++++++++++- rust/agama-server/src/server/types.rs | 5 ++++- rust/agama-utils/src/issue/monitor.rs | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 9b333c53ad..541b230ced 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -54,6 +54,14 @@ pub enum Error { Generic(#[from] anyhow::Error), } +/// Localization service. +/// +/// It is responsible for handling the localization part of the installation: +/// +/// * Reads the list of known locales, keymaps and timezones. +/// * Keeps track of the localization settings of the underlying system (the installer). +/// * Holds the user configuration. +/// * Applies the user configuration at the end of the installation. pub struct Service { state: State, model: Box, @@ -84,7 +92,10 @@ impl Service { } } - pub fn find_issues(&self) -> Vec { + /// Returns configuration issues. + /// + /// It returns an issue for each unknown element (locale, keymap and timezone). + fn find_issues(&self) -> Vec { let config = &self.state.config; let mut issues = vec![]; if !self.model.locales_db().exists(&config.locale) { diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index ec35fab166..a81982cb7b 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -18,13 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +//! This module defines some ancillary types for the HTTP API. + use std::collections::HashMap; use agama_utils::issue; use serde::Serialize; #[derive(Serialize, utoipa::ToSchema)] -/// Represents the installation issues for each scope. +/// Holds the installation issues for each scope. pub struct IssuesMap { /// iSCSI issues. #[serde(skip_serializing_if = "Vec::is_empty")] @@ -41,6 +43,7 @@ pub struct IssuesMap { /// Software management issues. #[serde(skip_serializing_if = "Vec::is_empty")] pub software: Vec, + /// First user and authentication issues. #[serde(skip_serializing_if = "Vec::is_empty")] pub users: Vec, } diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 7cb8539fdd..bac48ec4a3 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -40,6 +40,9 @@ pub enum Error { } /// Listens the D-Bus server and updates the list of issues. +/// +/// It retrieves and keeps up-to-date the list of issues for the Agama services +/// that offers a D-Bus API. pub struct Monitor { handler: Handler, dbus: zbus::Connection, @@ -60,6 +63,7 @@ impl Monitor { Self { handler, dbus } } + /// Run the monitor on a separate Tokio task. pub async fn run(&self) -> Result<(), Error> { let mut messages = build_properties_changed_stream(&self.dbus).await?; @@ -163,6 +167,7 @@ impl Monitor { } } + /// Turns the D-Bus path into an issues list ID. fn list_id_from_path(path: &str) -> Option<&'static str> { match path { SOFTWARE_PATH => Some("software"), From a39129e4b854640dc3ead052a3ea2adbd19d1c96 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 09:48:53 +0200 Subject: [PATCH 120/917] implement software queries --- .../src/software_ng/backend/client.rs | 14 ++++++ .../src/software_ng/backend/server.rs | 34 +++++++++++++ rust/agama-server/src/software_ng/web.rs | 49 +++++++++++++++++++ rust/zypp-agama/src/lib.rs | 26 ++++++++++ .../zypp-agama-sys/c-layer/include/lib.h | 18 +++++++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 43 ++++++++++++++++ .../zypp-agama/zypp-agama-sys/src/bindings.rs | 12 +++++ service/lib/agama/http/clients/software.rb | 16 +++--- 8 files changed, 203 insertions(+), 9 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs index 27d687074a..e7c08f77cf 100644 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ b/rust/agama-server/src/software_ng/backend/client.rs @@ -70,6 +70,20 @@ impl SoftwareServiceClient { Ok(rx.await?) } + pub async fn is_package_available(&self, tag: String) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions + .send(SoftwareAction::PackageAvailable(tag, tx))?; + Ok(rx.await?) + } + + pub async fn is_package_selected(&self, tag: String) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions + .send(SoftwareAction::PackageSelected(tag, tx))?; + Ok(rx.await?) + } + pub async fn probe(&self) -> Result<(), SoftwareServiceError> { self.actions.send(SoftwareAction::Probe)?; Ok(()) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 9c52e4c5ab..5e79bc2f00 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -47,6 +47,8 @@ pub enum SoftwareAction { GetProducts(oneshot::Sender>), GetPatterns(oneshot::Sender>), GetConfig(oneshot::Sender), + PackageAvailable(String, oneshot::Sender), + PackageSelected(String, oneshot::Sender), SelectProduct(String), SetResolvables { id: String, @@ -155,6 +157,14 @@ impl SoftwareServiceServer { self.get_config(tx).await?; } + SoftwareAction::PackageSelected(tag, tx) => { + self.package_selected(zypp, tag, tx).await?; + } + + SoftwareAction::PackageAvailable(tag, tx) => { + self.package_available(zypp, tag, tx).await?; + } + SoftwareAction::Probe => { self.probe(zypp).await?; self.run_solver(zypp)?; @@ -382,6 +392,30 @@ impl SoftwareServiceServer { Ok(()) } + async fn package_available( + &self, + zypp: &zypp_agama::Zypp, + tag: String, + tx: oneshot::Sender, + ) -> Result<(), SoftwareServiceError> { + let result = zypp.is_package_available(&tag)?; + tx.send(result) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; + Ok(()) + } + + async fn package_selected( + &self, + zypp: &zypp_agama::Zypp, + tag: String, + tx: oneshot::Sender, + ) -> Result<(), SoftwareServiceError> { + let result = zypp.is_package_selected(&tag)?; + tx.send(result) + .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; + Ok(()) + } + /// Returns the list of products. async fn get_products( &self, diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 9eadddaf3b..e0bf48b86d 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -34,6 +34,7 @@ use axum::{ routing::{get, post, put}, Json, Router, }; +use serde::Deserialize; use crate::{error::Error, software::web::SoftwareProposal}; @@ -59,6 +60,8 @@ pub async fn software_router(client: SoftwareServiceClient) -> Result Result, + State(state): State, +) -> Result, Error> { + let result = state.client.is_package_available(query.tag).await?; + Ok(Json(result)) +} + +/// Returns the true if package is selected. +/// +/// * `state`: service state. +#[utoipa::path( + get, + path = "/selected", + context_path = "/api/software_ng", + responses( + (status = 200, description = "Whenever matching package is selected for installation"), + (status = 400, description = "Failed to check if package is selected") + ) +)] +async fn get_selected( + Query(query): Query, + State(state): State, +) -> Result, Error> { + let result = state.client.is_package_selected(query.tag).await?; + Ok(Json(result)) +} + /// Returns the list of available products. /// /// * `state`: service state. diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 9158faf5f3..8a44254d83 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -274,6 +274,32 @@ impl Zypp { } } + pub fn is_package_selected(&self, tag: &str) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_tag = CString::new(tag).unwrap(); + let res = zypp_agama_sys::is_package_selected(self.ptr, c_tag.as_ptr(), status_ptr); + + helpers::status_to_result_void(status)?; + + Ok(res) + } + } + + pub fn is_package_available(&self, tag: &str) -> ZyppResult { + unsafe { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + let c_tag = CString::new(tag).unwrap(); + let res = zypp_agama_sys::is_package_available(self.ptr, c_tag.as_ptr(), status_ptr); + + helpers::status_to_result_void(status)?; + + Ok(res) + } + } + pub fn refresh_repository( &self, alias: &str, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index c8cd3bda3e..6342eeea97 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -155,6 +155,24 @@ void import_gpg_key(struct Zypp *zypp, const char *const pathname, /// @return true if url is local, for invalid url status is set to error bool is_local_url(const char *url, struct Status *status) noexcept; +/// check if package is available +/// @param zypp see \ref init_target +/// @param tag package name, provides or file path +/// @param[out] status (will overwrite existing contents) +/// @return true if package is available. In case of error it fills status and +/// return value is undefined +bool is_package_available(struct Zypp *zypp, const char *tag, + struct Status *status) noexcept; + +/// check if package is selected for installation +/// @param zypp see \ref init_target +/// @param tag package name, provides or file path +/// @param[out] status (will overwrite existing contents) +/// @return true if package is selected. In case of error it fills status and +/// return value is undefined +bool is_package_selected(struct Zypp *zypp, const char *tag, + struct Status *status) noexcept; + /// Runs solver /// @param zypp see \ref init_target /// @param[out] status (will overwrite existing contents) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 5956310fb2..1d0c5885cf 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -381,6 +381,49 @@ bool is_local_url(const char *url, struct Status *status) noexcept { } } +static bool package_check(Zypp *zypp, const char *tag, bool selected, + Status *status) noexcept { + try { + std::string s_tag(tag); + if (s_tag.empty()) { + status->state = status->STATE_FAILED; + status->error = strdup("Internal Error: Package tag is empty."); + return false; + } + + // look for packages + zypp::Capability cap(s_tag, zypp::ResKind::package); + zypp::sat::WhatProvides possibleProviders(cap); + + for (auto iter = possibleProviders.begin(); iter != possibleProviders.end(); + ++iter) { + zypp::PoolItem provider = zypp::ResPool::instance().find(*iter); + + if (selected) { + // is it installed? + return provider.status().isToBeInstalled(); + } else { + // it is available... + // in yast2 it returns true only if it is not installed, + // so in agama context it is always true + return true; + } + } + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + return false; + } +} + +bool is_package_available(Zypp *zypp, const char *tag, + Status *status) noexcept { + return package_check(zypp, tag, false, status); +} + +bool is_package_selected(Zypp *zypp, const char *tag, Status *status) noexcept { + return package_check(zypp, tag, true, status); +} + void add_repository(struct Zypp *zypp, const char *alias, const char *url, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index a85a1a9da8..26eff52699 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -287,6 +287,18 @@ unsafe extern "C" { ); #[doc = " check if url has local schema\n @param url url to check\n @param[out] status (will overwrite existing contents)\n @return true if url is local, for invalid url status is set to error"] pub fn is_local_url(url: *const ::std::os::raw::c_char, status: *mut Status) -> bool; + #[doc = " check if package is available\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is available. In case of error it fills status and return value is undefined"] + pub fn is_package_available( + zypp: *mut Zypp, + tag: *const ::std::os::raw::c_char, + status: *mut Status, + ) -> bool; + #[doc = " check if package is selected for installation\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is selected. In case of error it fills status and return value is undefined"] + pub fn is_package_selected( + zypp: *mut Zypp, + tag: *const ::std::os::raw::c_char, + status: *mut Status, + ) -> bool; #[doc = " Runs solver\n @param zypp see \\ref init_target\n @param[out] status (will overwrite existing contents)\n @return true if solver pass and false if it found some dependency issues"] pub fn run_solver(zypp: *mut Zypp, status: *mut Status) -> bool; #[doc = " the last call that will free all pointers to zypp holded by agama"] diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 95f6c53707..08df8d24d8 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -55,7 +55,6 @@ def install end def finish - # TODO: implement it post("software/finish", nil) end @@ -77,19 +76,18 @@ def get_resolvables(unique_id, type, optional) JSON.parse(get("software/resolvables/#{unique_id}?type=#{type}&optional=#{optional}")) end - def provisions_selected?(_provisions) - # TODO: implement it, not sure how it should look like - [] + def provisions_selected?(provisions) + provisions.select do |prov| + package_installed?(prov) + end end def package_available?(_name) - # TODO: implement it, not sure how it should look like - true + JSON.parse(get("software/available?tag=#{name}")) end - def package_installed?(_name) - # TODO: implement it, not sure how it should look like - true + def package_installed?(name) + JSON.parse(get("software/selected?tag=#{name}")) end def set_resolvables(unique_id, type, resolvables, optional) From 68274ded3b3995f6e37b636af18fbe0a28e0a0e5 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 10:35:14 +0200 Subject: [PATCH 121/917] define software service result --- rust/agama-server/src/software_ng/backend.rs | 2 + .../src/software_ng/backend/server.rs | 51 +++++++++---------- .../zypp-agama/zypp-agama-sys/src/bindings.rs | 4 +- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 44c1999349..5a32241b50 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -81,6 +81,8 @@ pub enum SoftwareServiceError { ZyppError(#[from] zypp_agama::ZyppError), } +pub type SoftwareServiceResult = Result; + /// Builds and starts the software service. /// pub struct SoftwareService {} diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 5e79bc2f00..64e290eded 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -31,6 +31,7 @@ use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ products::{ProductSpec, ProductsRegistry}, + software_ng::backend::SoftwareServiceResult, web::EventsSender, }; @@ -81,7 +82,7 @@ impl SoftwareServiceServer { pub fn start( events: EventsSender, products: Arc>, - ) -> Result { + ) -> SoftwareServiceResult { let (sender, receiver) = mpsc::unbounded_channel(); let server = Self { @@ -115,7 +116,7 @@ impl SoftwareServiceServer { } /// Runs the server dispatching the actions received through the input channel. - async fn run(mut self) -> Result<(), SoftwareServiceError> { + async fn run(mut self) -> SoftwareServiceResult<()> { let zypp = self.initialize_target_dir()?; loop { @@ -139,7 +140,7 @@ impl SoftwareServiceServer { &mut self, action: SoftwareAction, zypp: &zypp_agama::Zypp, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { match action { SoftwareAction::GetProducts(tx) => { self.get_products(tx).await?; @@ -213,7 +214,7 @@ impl SoftwareServiceServer { r#type: ResolvableType, resolvables: Vec, optional: bool, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { tracing::info!( "Set resolvables for {} with type {} optional {} and list {:?}", id, @@ -228,14 +229,14 @@ impl SoftwareServiceServer { } // runs solver. It should be able in future to generate solver issues - fn run_solver(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + fn run_solver(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { let result = zypp.run_solver()?; tracing::info!("Solver runs ends with {}", result); Ok(()) } // Install rpms - fn install(&self, zypp: &zypp_agama::Zypp) -> Result { + fn install(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult { let target = "/mnt"; zypp.switch_target(target)?; let result = zypp.commit()?; @@ -244,7 +245,7 @@ impl SoftwareServiceServer { } /// Select the given product. - async fn select_product(&mut self, product_id: String) -> Result<(), SoftwareServiceError> { + async fn select_product(&mut self, product_id: String) -> SoftwareServiceResult<()> { tracing::info!("Selecting product {}", product_id); let products = self.products.lock().await; if products.find(&product_id).is_none() { @@ -255,7 +256,7 @@ impl SoftwareServiceServer { Ok(()) } - async fn probe(&mut self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + async fn probe(&mut self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { let product = self.find_selected_product().await?; let repositories = product.software.repositories(); for (idx, repo) in repositories.iter().enumerate() { @@ -279,7 +280,7 @@ impl SoftwareServiceServer { Ok(()) } - async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { self.remove_dud_repo(zypp)?; self.disable_local_repos(zypp)?; self.registration_finish()?; @@ -288,7 +289,7 @@ impl SoftwareServiceServer { Ok(()) } - fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { let repos = zypp.list_repositories()?; // if url is invalid, then do not disable it and do not touch it let repos = repos @@ -300,7 +301,7 @@ impl SoftwareServiceServer { Ok(()) } - fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { const DUD_NAME: &str = "AgamaDriverUpdate"; let repos = zypp.list_repositories()?; let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); @@ -310,7 +311,7 @@ impl SoftwareServiceServer { Ok(()) } - fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> Result<(), SoftwareServiceError> { + fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { let repos = zypp.list_repositories()?; // if url is invalid, then do not disable it and do not touch it let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false)); @@ -320,12 +321,12 @@ impl SoftwareServiceServer { Ok(()) } - fn registration_finish(&self) -> Result<(), SoftwareServiceError> { + fn registration_finish(&self) -> SoftwareServiceResult<()> { // TODO: implement when registration is ready Ok(()) } - fn modify_zypp_conf(&self) -> Result<(), SoftwareServiceError> { + fn modify_zypp_conf(&self) -> SoftwareServiceResult<()> { // TODO: implement when requireOnly is implemented Ok(()) } @@ -334,7 +335,7 @@ impl SoftwareServiceServer { &mut self, zypp: &zypp_agama::Zypp, product: ProductSpec, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { let installer_id_string = "installer".to_string(); self.set_resolvables( zypp, @@ -375,10 +376,7 @@ impl SoftwareServiceServer { } /// Returns the software config. - async fn get_config( - &self, - tx: oneshot::Sender, - ) -> Result<(), SoftwareServiceError> { + async fn get_config(&self, tx: oneshot::Sender) -> SoftwareServiceResult<()> { let result = SoftwareConfig { // TODO: implement all Nones packages: None, @@ -397,7 +395,7 @@ impl SoftwareServiceServer { zypp: &zypp_agama::Zypp, tag: String, tx: oneshot::Sender, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { let result = zypp.is_package_available(&tag)?; tx.send(result) .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; @@ -409,7 +407,7 @@ impl SoftwareServiceServer { zypp: &zypp_agama::Zypp, tag: String, tx: oneshot::Sender, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { let result = zypp.is_package_selected(&tag)?; tx.send(result) .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; @@ -417,10 +415,7 @@ impl SoftwareServiceServer { } /// Returns the list of products. - async fn get_products( - &self, - tx: oneshot::Sender>, - ) -> Result<(), SoftwareServiceError> { + async fn get_products(&self, tx: oneshot::Sender>) -> SoftwareServiceResult<()> { let products = self.products.lock().await; // FIXME: implement this conversion at model's level. let products: Vec<_> = products @@ -444,7 +439,7 @@ impl SoftwareServiceServer { &self, tx: oneshot::Sender>, zypp: &zypp_agama::Zypp, - ) -> Result<(), SoftwareServiceError> { + ) -> SoftwareServiceResult<()> { let product = self.find_selected_product().await?; let mandatory_patterns = product.software.mandatory_patterns.iter(); @@ -476,7 +471,7 @@ impl SoftwareServiceServer { Ok(()) } - fn initialize_target_dir(&self) -> Result { + fn initialize_target_dir(&self) -> SoftwareServiceResult { let target_dir = Path::new(TARGET_DIR); if target_dir.exists() { _ = std::fs::remove_dir_all(target_dir); @@ -511,7 +506,7 @@ impl SoftwareServiceServer { // Returns the spec of the selected product. // // It causes the spec to be cloned, so we should find a better way to do this. - async fn find_selected_product(&self) -> Result { + async fn find_selected_product(&self) -> SoftwareServiceResult { let products = self.products.lock().await; let Some(product_id) = &self.selected_product else { return Err(SoftwareServiceError::NoSelectedProduct); diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 26eff52699..68294a9be9 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -287,13 +287,13 @@ unsafe extern "C" { ); #[doc = " check if url has local schema\n @param url url to check\n @param[out] status (will overwrite existing contents)\n @return true if url is local, for invalid url status is set to error"] pub fn is_local_url(url: *const ::std::os::raw::c_char, status: *mut Status) -> bool; - #[doc = " check if package is available\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is available. In case of error it fills status and return value is undefined"] + #[doc = " check if package is available\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is available. In case of error it fills status and\n return value is undefined"] pub fn is_package_available( zypp: *mut Zypp, tag: *const ::std::os::raw::c_char, status: *mut Status, ) -> bool; - #[doc = " check if package is selected for installation\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is selected. In case of error it fills status and return value is undefined"] + #[doc = " check if package is selected for installation\n @param zypp see \\ref init_target\n @param tag package name, provides or file path\n @param[out] status (will overwrite existing contents)\n @return true if package is selected. In case of error it fills status and\n return value is undefined"] pub fn is_package_selected( zypp: *mut Zypp, tag: *const ::std::os::raw::c_char, From 259053dfd21776f27ba431413a9f81ae5153c919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 6 Oct 2025 09:39:19 +0100 Subject: [PATCH 122/917] Update changes files --- rust/package/agama.changes | 6 ++++++ web/package/agama-web-ui.changes | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 7ef116fec5..65c15ced4b 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Oct 6 08:15:47 UTC 2025 - Imobach Gonzalez Sosa + +- Introduce a new "installation issues" API. The issues for all + services are exposed through a /issues resource (gh#agama-project/agama#2775). + ------------------------------------------------------------------- Fri Oct 3 20:11:26 UTC 2025 - Imobach Gonzalez Sosa diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index c1147e3fb8..8004295de9 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Mon Oct 6 08:16:06 UTC 2025 - Imobach Gonzalez Sosa + +- Adapt to the new "installation issues" API (gh#agama-project/agama#2775). + ------------------------------------------------------------------- Fri Oct 3 20:11:40 UTC 2025 - Imobach Gonzalez Sosa From 90e0b6f661761f6a04a7103a24fb12837ef5f01c Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 10:59:01 +0200 Subject: [PATCH 123/917] add smarter helper for converting status into result --- rust/zypp-agama/src/helpers.rs | 14 +++++++++++--- rust/zypp-agama/src/lib.rs | 23 ++++++++--------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/rust/zypp-agama/src/helpers.rs b/rust/zypp-agama/src/helpers.rs index ab41fabede..5d9a3857f0 100644 --- a/rust/zypp-agama/src/helpers.rs +++ b/rust/zypp-agama/src/helpers.rs @@ -4,11 +4,12 @@ pub(crate) unsafe fn string_from_ptr(c_ptr: *const i8) -> String { } // Safety requirements: ... -pub(crate) unsafe fn status_to_result_void( +pub(crate) unsafe fn status_to_result( mut status: zypp_agama_sys::Status, -) -> Result<(), crate::ZyppError> { + result: R +) -> Result { let res = if status.state == zypp_agama_sys::Status_STATE_STATE_SUCCEED { - Ok(()) + Ok(result) } else { Err(crate::ZyppError::new( string_from_ptr(status.error).as_str(), @@ -19,3 +20,10 @@ pub(crate) unsafe fn status_to_result_void( res } + +// Safety requirements: ... +pub(crate) unsafe fn status_to_result_void( + status: zypp_agama_sys::Status, +) -> Result<(), crate::ZyppError> { + status_to_result(status, ()) +} \ No newline at end of file diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 8a44254d83..b978b078cb 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -14,7 +14,7 @@ pub mod errors; pub use errors::ZyppError; mod helpers; -use helpers::{status_to_result_void, string_from_ptr}; +use helpers::{status_to_result_void, status_to_result, string_from_ptr}; pub mod callbacks; @@ -35,7 +35,7 @@ impl Repository { let mut status: Status = Status::default(); let status_ptr = &mut status as *mut _; let result = zypp_agama_sys::is_local_url(c_url.as_ptr(), status_ptr); - status_to_result_void(status).map(|_| result) + status_to_result(status, result) } } } @@ -140,9 +140,8 @@ impl Zypp { let c_root = CString::new(root).unwrap(); unsafe { zypp_agama_sys::switch_target(self.ptr, c_root.as_ptr(), status_ptr); - helpers::status_to_result_void(status)?; + helpers::status_to_result_void(status) } - Ok(()) } pub fn commit(&self) -> ZyppResult { @@ -150,8 +149,7 @@ impl Zypp { let status_ptr = &mut status as *mut _; unsafe { let res = zypp_agama_sys::commit(self.ptr, status_ptr); - helpers::status_to_result_void(status)?; - Ok(res) + helpers::status_to_result(status, res) } } @@ -178,7 +176,7 @@ impl Zypp { let repos_rawp = &mut repos; zypp_agama_sys::free_repository_list(repos_rawp as *mut _); - helpers::status_to_result_void(status).and(Ok(repos_v)) + helpers::status_to_result(status, repos_v) } } @@ -281,9 +279,7 @@ impl Zypp { let c_tag = CString::new(tag).unwrap(); let res = zypp_agama_sys::is_package_selected(self.ptr, c_tag.as_ptr(), status_ptr); - helpers::status_to_result_void(status)?; - - Ok(res) + helpers::status_to_result(status, res) } } @@ -294,9 +290,7 @@ impl Zypp { let c_tag = CString::new(tag).unwrap(); let res = zypp_agama_sys::is_package_available(self.ptr, c_tag.as_ptr(), status_ptr); - helpers::status_to_result_void(status)?; - - Ok(res) + helpers::status_to_result(status, res) } } @@ -435,8 +429,7 @@ impl Zypp { let mut status: Status = Status::default(); let status_ptr = &mut status as *mut _; let r_res = zypp_agama_sys::run_solver(self.ptr, status_ptr); - let result = helpers::status_to_result_void(status); - result.and(Ok(r_res)) + helpers::status_to_result(status, r_res) } } From 356b7dcd429f6eb8cc79277726a5be70c46777c7 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 21:03:12 +0200 Subject: [PATCH 124/917] fix behavior for is selected --- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 1d0c5885cf..83384b459d 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -400,8 +400,9 @@ static bool package_check(Zypp *zypp, const char *tag, bool selected, zypp::PoolItem provider = zypp::ResPool::instance().find(*iter); if (selected) { - // is it installed? - return provider.status().isToBeInstalled(); + // is it installed? if so return true, otherwise check next candidate + if (provider.status().isToBeInstalled()) + return true; } else { // it is available... // in yast2 it returns true only if it is not installed, @@ -409,6 +410,8 @@ static bool package_check(Zypp *zypp, const char *tag, bool selected, return true; } } + + return false; } catch (zypp::Exception &excpt) { STATUS_EXCEPT(status, excpt); return false; From 0cb1be3bd5fb3efa762253321c4b4a3d0f619266 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 21:20:43 +0200 Subject: [PATCH 125/917] optimize check for availability and hopefuly making code also more understandable --- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 83384b459d..b3ee1bf3fb 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -395,20 +395,16 @@ static bool package_check(Zypp *zypp, const char *tag, bool selected, zypp::Capability cap(s_tag, zypp::ResKind::package); zypp::sat::WhatProvides possibleProviders(cap); + // if we check only for availability, then just check that quickly + if (!selected) + return !possibleProviders.empty(); + for (auto iter = possibleProviders.begin(); iter != possibleProviders.end(); ++iter) { zypp::PoolItem provider = zypp::ResPool::instance().find(*iter); - - if (selected) { - // is it installed? if so return true, otherwise check next candidate - if (provider.status().isToBeInstalled()) - return true; - } else { - // it is available... - // in yast2 it returns true only if it is not installed, - // so in agama context it is always true + // is it installed? if so return true, otherwise check next candidate + if (provider.status().isToBeInstalled()) return true; - } } return false; From d0a10cb83b650b7143073eb181b710e897097dc1 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 6 Oct 2025 21:44:14 +0200 Subject: [PATCH 126/917] add more status helpers to make code a bit more uniform --- rust/zypp-agama/src/helpers.rs | 4 +- rust/zypp-agama/src/lib.rs | 2 +- .../c-layer/internal/helpers.hxx | 17 +++++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 71 ++++++++----------- 4 files changed, 49 insertions(+), 45 deletions(-) diff --git a/rust/zypp-agama/src/helpers.rs b/rust/zypp-agama/src/helpers.rs index 5d9a3857f0..fbb71da97e 100644 --- a/rust/zypp-agama/src/helpers.rs +++ b/rust/zypp-agama/src/helpers.rs @@ -6,7 +6,7 @@ pub(crate) unsafe fn string_from_ptr(c_ptr: *const i8) -> String { // Safety requirements: ... pub(crate) unsafe fn status_to_result( mut status: zypp_agama_sys::Status, - result: R + result: R, ) -> Result { let res = if status.state == zypp_agama_sys::Status_STATE_STATE_SUCCEED { Ok(result) @@ -26,4 +26,4 @@ pub(crate) unsafe fn status_to_result_void( status: zypp_agama_sys::Status, ) -> Result<(), crate::ZyppError> { status_to_result(status, ()) -} \ No newline at end of file +} diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index b978b078cb..22852d29b6 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -14,7 +14,7 @@ pub mod errors; pub use errors::ZyppError; mod helpers; -use helpers::{status_to_result_void, status_to_result, string_from_ptr}; +use helpers::{status_to_result, status_to_result_void, string_from_ptr}; pub mod callbacks; diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx index f0d55e4ae9..0ecba8e321 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -9,16 +9,33 @@ fprintf(stderr, __VA_ARGS__); \ abort() +/// Macro to define that status if OK #define STATUS_OK(status) \ ({ \ status->state = status->STATE_SUCCEED; \ status->error = NULL; \ }) +/// Macro to help report failure with zypp exception #define STATUS_EXCEPT(status, excpt) \ ({ \ status->state = status->STATE_FAILED; \ status->error = strdup(excpt.asUserString().c_str()); \ }) +/// Macro to help report failure with static string +#define STATUS_ERROR(status, err_str) \ + ({ \ + status->state = status->STATE_FAILED; \ + status->error = strdup(err_str); \ + }) + +/// Macro to help report failure with own allocated string +/// which will be later free with free_status method +#define STATUS_ERR_MSG(status, err_str) \ + ({ \ + status->state = status->STATE_FAILED; \ + status->error = strdup(err_str); \ + }) + #endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index b3ee1bf3fb..855cc05497 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -127,9 +127,8 @@ bool commit(struct Zypp *zypp, struct Status *status) noexcept { struct Zypp *init_target(const char *root, struct Status *status, ProgressCallback progress, void *user_data) noexcept { if (the_zypp.zypp_pointer != NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Cannot have two init_target concurrently, " - "libzypp not ready for this. Call free_zypp first."); + STATUS_ERROR(status, "Cannot have two init_target concurrently, " + "libzypp not ready for this. Call free_zypp first."); return NULL; } @@ -225,17 +224,15 @@ void resolvable_select(struct Zypp *_zypp, const char *name, enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, struct Status *status) noexcept { if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { - status->state = Status::STATE_SUCCEED; - status->error = NULL; + STATUS_OK(status); return; } zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); auto selectable = zypp::ui::Selectable::get(z_kind, name); if (!selectable) { - status->state = status->STATE_FAILED; - status->error = - format_alloc("Failed to find %s with name '%s'", z_kind.c_str(), name); + STATUS_ERR_MSG(status, format_alloc("Failed to find %s with name '%s'", + z_kind.c_str(), name)); return; } @@ -248,24 +245,21 @@ void resolvable_unselect(struct Zypp *_zypp, const char *name, enum RESOLVABLE_KIND kind, enum RESOLVABLE_SELECTED who, struct Status *status) noexcept { + STATUS_OK(status); if (who == RESOLVABLE_SELECTED::NOT_SELECTED) { - status->state = Status::STATE_SUCCEED; - status->error = NULL; return; } zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); auto selectable = zypp::ui::Selectable::get(z_kind, name); if (!selectable) { - status->state = status->STATE_FAILED; - status->error = - format_alloc("Failed to find %s with name '%s'", z_kind.c_str(), name); + STATUS_ERR_MSG(status, format_alloc("Failed to find %s with name '%s'", + z_kind.c_str(), name)); return; } auto value = transactby_from(who); selectable->unset(value); - STATUS_OK(status); } struct PatternInfos get_patterns_info(struct Zypp *_zypp, @@ -344,16 +338,16 @@ void refresh_repository(struct Zypp *zypp, const char *alias, struct Status *status, struct DownloadProgressCallbacks *callbacks) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - status->state = status->STATE_FAILED; - status->error = format_alloc( - "Cannot refresh repo with alias %s. Repo not found.", alias); + STATUS_ERR_MSG( + status, + format_alloc("Cannot refresh repo with alias %s. Repo not found.", + alias)); return; } @@ -386,8 +380,7 @@ static bool package_check(Zypp *zypp, const char *tag, bool selected, try { std::string s_tag(tag); if (s_tag.empty()) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Package tag is empty."); + STATUS_ERROR(status, "Internal Error: Package tag is empty."); return false; } @@ -427,8 +420,7 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { @@ -447,8 +439,7 @@ void add_repository(struct Zypp *zypp, const char *alias, const char *url, void disable_repository(struct Zypp *zypp, const char *alias, struct Status *status) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { @@ -464,8 +455,7 @@ void disable_repository(struct Zypp *zypp, const char *alias, void set_repository_url(struct Zypp *zypp, const char *alias, const char *url, struct Status *status) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { @@ -483,8 +473,7 @@ void remove_repository(struct Zypp *zypp, const char *alias, struct Status *status, ZyppProgressCallback callback, void *user_data) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { @@ -503,8 +492,7 @@ void remove_repository(struct Zypp *zypp, const char *alias, struct RepositoryList list_repositories(struct Zypp *zypp, struct Status *status) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return {0, NULL}; } @@ -531,16 +519,15 @@ struct RepositoryList list_repositories(struct Zypp *zypp, void load_repository_cache(struct Zypp *zypp, const char *alias, struct Status *status) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); - return; + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); } try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - status->state = status->STATE_FAILED; - status->error = format_alloc( - "Cannot load repo with alias %s. Repo not found.", alias); + STATUS_ERR_MSG( + status, + format_alloc("Cannot load repo with alias %s. Repo not found.", + alias)); return; } @@ -558,16 +545,16 @@ void build_repository_cache(struct Zypp *zypp, const char *alias, ZyppProgressCallback callback, void *user_data) noexcept { if (zypp->repo_manager == NULL) { - status->state = status->STATE_FAILED; - status->error = strdup("Internal Error: Repo manager is not initialized."); + STATUS_ERROR(status, "Internal Error: Repo manager is not initialized."); return; } try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - status->state = status->STATE_FAILED; - status->error = format_alloc( - "Cannot load repo with alias %s. Repo not found.", alias); + STATUS_ERR_MSG( + status, + format_alloc("Cannot load repo with alias %s. Repo not found.", + alias)); return; } From 5bdbc348d913e1b66f67618ef0aef3f0b7686b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 6 Oct 2025 07:04:47 +0100 Subject: [PATCH 127/917] Add progress service --- rust/Cargo.lock | 19 +- rust/agama-utils/Cargo.toml | 1 + rust/agama-utils/src/lib.rs | 2 + rust/agama-utils/src/progress.rs | 25 +++ rust/agama-utils/src/progress/event.rs | 36 ++++ rust/agama-utils/src/progress/message.rs | 101 ++++++++++ rust/agama-utils/src/progress/model.rs | 81 ++++++++ rust/agama-utils/src/progress/service.rs | 127 +++++++++++++ rust/agama-utils/src/progress/start.rs | 228 +++++++++++++++++++++++ 9 files changed, 616 insertions(+), 4 deletions(-) create mode 100644 rust/agama-utils/src/progress.rs create mode 100644 rust/agama-utils/src/progress/event.rs create mode 100644 rust/agama-utils/src/progress/message.rs create mode 100644 rust/agama-utils/src/progress/model.rs create mode 100644 rust/agama-utils/src/progress/service.rs create mode 100644 rust/agama-utils/src/progress/start.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 468e28a08e..41f1c73f28 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -210,6 +210,7 @@ name = "agama-utils" version = "0.1.0" dependencies = [ "async-trait", + "serde", "serde_json", "thiserror 2.0.16", "tokio", @@ -3791,18 +3792,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 7320e29d5a..c5755e58e1 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true [dependencies] async-trait = "0.1.89" serde_json = "1.0.140" +serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } utoipa = "5.3.1" diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 402d851f26..949fc965b1 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -28,3 +28,5 @@ pub use service::Service; pub mod dbus; pub mod openapi; + +mod progress; diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs new file mode 100644 index 0000000000..71498b6ec1 --- /dev/null +++ b/rust/agama-utils/src/progress.rs @@ -0,0 +1,25 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod event; +mod message; +mod model; +mod service; +mod start; diff --git a/rust/agama-utils/src/progress/event.rs b/rust/agama-utils/src/progress/event.rs new file mode 100644 index 0000000000..c1f4afd13b --- /dev/null +++ b/rust/agama-utils/src/progress/event.rs @@ -0,0 +1,36 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// Progress-related events. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "name")] +pub enum Event { + /// Progress changed. + ProgressChanged, +} + +/// Multi-producer single-consumer events sender. +pub type Sender = mpsc::UnboundedSender; + +/// Multi-producer single-consumer events receiver. +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs new file mode 100644 index 0000000000..338dd9e827 --- /dev/null +++ b/rust/agama-utils/src/progress/message.rs @@ -0,0 +1,101 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::actor::Message; +use crate::progress::model::Progress; + +pub struct Get; + +impl Message for Get { + type Reply = Vec; +} + +pub struct Start { + pub scope: String, + pub size: usize, +} + +impl Start { + pub fn new(scope: String, size: usize) -> Self { + Self { scope, size } + } +} + +impl Message for Start { + type Reply = (); +} + +pub struct StartWithSteps { + pub scope: String, + pub steps: Vec, +} + +impl StartWithSteps { + pub fn new(scope: String, steps: Vec) -> Self { + Self { scope, steps } + } +} + +impl Message for StartWithSteps { + type Reply = (); +} + +pub struct Next { + pub scope: String, +} + +impl Next { + pub fn new(scope: String) -> Self { + Self { scope } + } +} + +impl Message for Next { + type Reply = (); +} + +pub struct NextStep { + pub scope: String, + pub step: String, +} + +impl NextStep { + pub fn new(scope: String, step: String) -> Self { + Self { scope, step } + } +} + +impl Message for NextStep { + type Reply = (); +} + +pub struct Finish { + pub scope: String, +} + +impl Finish { + pub fn new(scope: String) -> Self { + Self { scope } + } +} + +impl Message for Finish { + type Reply = (); +} diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/progress/model.rs new file mode 100644 index 0000000000..76c09dd22c --- /dev/null +++ b/rust/agama-utils/src/progress/model.rs @@ -0,0 +1,81 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::progress::service::Error; + +#[derive(Clone, Default, Debug)] +pub struct Progress { + pub scope: String, + pub size: usize, + pub steps: Option>, + pub step: Option, + pub index: Option, +} + +impl Progress { + pub fn new(scope: String, size: usize) -> Self { + Self { + scope, + size, + ..Default::default() + } + } + + pub fn new_with_steps(scope: String, steps: Vec) -> Self { + Self { + scope, + size: steps.len(), + steps: Some(steps), + ..Default::default() + } + } + + pub fn next(&mut self) -> Result<(), Error> { + match self.index { + Some(index) if index >= self.size => Err(Error::NextStep(self.scope.clone())), + Some(index) => { + let next_index = index + 1; + self.index = Some(next_index); + self.step = self.get_step(next_index); + Ok(()) + } + None => { + let first_index = 1; + self.index = Some(first_index); + self.step = self.get_step(first_index); + Ok(()) + } + } + } + + pub fn next_step(&mut self, step: String) -> Result<(), Error> { + self.next().and_then(|_| { + self.step = Some(step); + Ok(()) + }) + } + + fn get_step(&self, index: usize) -> Option { + self.steps + .as_ref() + .and_then(|n| n.get(index - 1)) + .and_then(|n| Some(n.clone())) + } +} diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs new file mode 100644 index 0000000000..f857d38a4a --- /dev/null +++ b/rust/agama-utils/src/progress/service.rs @@ -0,0 +1,127 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::actor::{self, Actor, MessageHandler}; +use crate::progress::model::Progress; +use crate::progress::{ + event::{self, Event}, + message, +}; +use async_trait::async_trait; +use tokio::sync::mpsc::error::SendError; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Progress does not exist for {0}")] + Progress(String), + #[error("Next step does not exist for {0}")] + NextStep(String), + #[error(transparent)] + Event(#[from] SendError), + #[error(transparent)] + Actor(#[from] actor::Error), +} + +pub struct Service { + events: event::Sender, + progresses: Vec, +} + +impl Service { + pub fn new(events: event::Sender) -> Service { + Self { + events, + progresses: Vec::new(), + } + } + + fn get_progress(&mut self, scope: String) -> Option<&mut Progress> { + self.progresses.iter_mut().find(|p| p.scope == scope) + } + + fn get_progress_index(&self, scope: String) -> Option { + self.progresses.iter().position(|p| p.scope == scope) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Get) -> Result, Error> { + Ok(self.progresses.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Start) -> Result<(), Error> { + self.progresses + .push(Progress::new(message.scope, message.size)); + self.events.send(Event::ProgressChanged)?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::StartWithSteps) -> Result<(), Error> { + self.progresses + .push(Progress::new_with_steps(message.scope, message.steps)); + self.events.send(Event::ProgressChanged)?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Next) -> Result<(), Error> { + self.get_progress(message.scope.clone()) + .ok_or(Error::Progress(message.scope)) + .and_then(|p| p.next())?; + self.events.send(Event::ProgressChanged)?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::NextStep) -> Result<(), Error> { + self.get_progress(message.scope.clone()) + .ok_or(Error::Progress(message.scope)) + .and_then(|p| p.next_step(message.step))?; + self.events.send(Event::ProgressChanged)?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Finish) -> Result<(), Error> { + let index = self + .get_progress_index(message.scope.clone()) + .ok_or(Error::Progress(message.scope))?; + self.progresses.remove(index); + self.events.send(Event::ProgressChanged)?; + Ok(()) + } +} diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs new file mode 100644 index 0000000000..2a2b16f12e --- /dev/null +++ b/rust/agama-utils/src/progress/start.rs @@ -0,0 +1,228 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + actor::{self, Handler}, + progress::{event, service::Service}, +}; +use std::convert::Infallible; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Infallible(#[from] Infallible), +} + +/// Starts the progress service. +/// +/// * `events`: channel to emit the [progress-specific events](crate::progress::event::Event). +pub async fn start(events: event::Sender) -> Result, Error> { + let handler = actor::spawn(Service::new(events)); + Ok(handler) +} + +#[cfg(test)] +mod tests { + use crate::actor::{self, Handler}; + use crate::progress::{ + event::{Event, Receiver}, + message, + service::{self, Service}, + }; + use tokio::sync::mpsc; + + fn start_testing_service() -> (Receiver, Handler) { + let (events, receiver) = mpsc::unbounded_channel::(); + let service = Service::new(events); + + let handler = actor::spawn(service); + (receiver, handler) + } + + #[tokio::test] + async fn test_progress() -> Result<(), Box> { + let scope = "test".to_string(); + let (mut receiver, handler) = start_testing_service(); + + // Start a progress + handler.call(message::Start::new(scope.clone(), 2)).await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged)); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, scope); + assert_eq!(progress.size, 2); + assert!(progress.steps.is_none()); + assert!(progress.step.is_none()); + assert!(progress.index.is_none()); + + // First step + handler + .call(message::NextStep::new( + scope.clone(), + "first step".to_string(), + )) + .await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged)); + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, scope); + assert_eq!(progress.size, 2); + assert!(progress.steps.is_none()); + assert_eq!(*progress.step.as_ref().unwrap(), "first step".to_string()); + assert_eq!(progress.index.unwrap(), 1); + + // Second step (without step text) + handler.call(message::Next::new(scope.clone())).await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged)); + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, scope); + assert_eq!(progress.size, 2); + assert!(progress.steps.is_none()); + assert!(progress.step.is_none()); + assert_eq!(progress.index.unwrap(), 2); + + // Finish the progress + handler.call(message::Finish::new(scope.clone())).await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged)); + + let progresses = handler.call(message::Get).await.unwrap(); + assert!(progresses.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_progress_with_steps() -> Result<(), Box> { + let scope = "test".to_string(); + let (_receiver, handler) = start_testing_service(); + + let first_step = "first step".to_string(); + let second_step = "second step".to_string(); + let third_step = "third step".to_string(); + + // Start a progress + handler + .call(message::StartWithSteps::new( + scope.clone(), + vec![first_step.clone(), second_step.clone(), third_step.clone()], + )) + .await?; + + let progresses = handler.call(message::Get).await?; + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, scope); + assert_eq!(progress.size, 3); + assert_eq!(progress.steps.as_ref().unwrap().len(), 3); + assert_eq!(*progress.steps.as_ref().unwrap()[0], first_step); + assert_eq!(*progress.steps.as_ref().unwrap()[1], second_step); + assert_eq!(*progress.steps.as_ref().unwrap()[2], third_step); + assert!(progress.step.is_none()); + assert!(progress.index.is_none()); + + // First step + handler.call(message::Next::new(scope.clone())).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(*progress.step.as_ref().unwrap(), first_step); + assert_eq!(progress.index.unwrap(), 1); + + // Second step + handler.call(message::Next::new(scope.clone())).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(*progress.step.as_ref().unwrap(), second_step); + assert_eq!(progress.index.unwrap(), 2); + + // Third step + handler.call(message::Next::new(scope.clone())).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(*progress.step.as_ref().unwrap(), third_step); + assert_eq!(progress.index.unwrap(), 3); + + // Finish the progress + handler.call(message::Finish::new(scope.clone())).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + assert!(progresses.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_several_progresses() -> Result<(), Box> { + let scope1 = "test1".to_string(); + let scope2 = "test2".to_string(); + let (_receiver, handler) = start_testing_service(); + + handler.call(message::Start::new(scope1.clone(), 2)).await?; + handler.call(message::Start::new(scope2.clone(), 2)).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + assert_eq!(progresses.len(), 2); + assert_eq!(progresses[0].scope, scope1); + assert_eq!(progresses[1].scope, scope2); + + Ok(()) + } + + #[tokio::test] + async fn test_progress_error_next() -> Result<(), Box> { + let scope = "test".to_string(); + let (_receiver, handler) = start_testing_service(); + + handler.call(message::Start::new(scope.clone(), 1)).await?; + handler.call(message::Next::new(scope.clone())).await?; + let error = handler.call(message::Next::new(scope.clone())).await; + assert!(matches!(error, Err(service::Error::NextStep(s)) if s == scope)); + + Ok(()) + } + + #[tokio::test] + async fn test_progress_error_scope() -> Result<(), Box> { + let scope1 = "test1".to_string(); + let scope2 = "test2".to_string(); + let (_receiver, handler) = start_testing_service(); + + handler.call(message::Start::new(scope1.clone(), 1)).await?; + let error = handler.call(message::Next::new(scope2.clone())).await; + assert!(matches!(error, Err(service::Error::Progress(s)) if s == scope2)); + + Ok(()) + } +} From 43d46f350e915d5884767bbaffb2a7950e1a6586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 6 Oct 2025 07:25:51 +0100 Subject: [PATCH 128/917] Add installation state --- rust/agama-server/src/supervisor/service.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 42c09e3105..9b1a46af23 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -43,8 +43,15 @@ pub enum Error { Infallible(#[from] Infallible), } +pub enum State { + Configuring, + Installing, + Finished, +} + pub struct Service { l10n: Handler, + state: State, config: InstallSettings, } @@ -52,6 +59,7 @@ impl Service { pub fn new(l10n: Handler) -> Self { Self { l10n, + state: State::Configuring, config: InstallSettings::default(), } } @@ -222,7 +230,11 @@ impl MessageHandler for Service { let l10n_message = l10n::message::SetSystem::new(config); self.l10n.call(l10n_message).await?; } - Action::Install => self.l10n.call(l10n::message::Install).await?, + Action::Install => { + self.state = State::Installing; + self.l10n.call(l10n::message::Install).await?; + self.state = State::Finished; + } } Ok(()) } From 11c7c147dff76945977073c29ea014160363813d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 6 Oct 2025 11:28:44 +0100 Subject: [PATCH 129/917] Add install progress --- rust/agama-lib/src/http/event.rs | 12 ++++- rust/agama-server/src/supervisor/service.rs | 55 ++++++++++++++++++--- rust/agama-server/src/supervisor/start.rs | 17 +++++-- rust/agama-utils/src/lib.rs | 2 +- rust/agama-utils/src/progress.rs | 14 ++++-- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 51d2bc265e..f6771f7c65 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::issue::Issue; use crate::{ auth::ClientId, jobs::Job, @@ -35,11 +36,10 @@ use crate::{ users::{FirstUser, RootUser}, }; use agama_l10n as l10n; +use agama_utils::progress; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::issue::Issue; - /// Agama event. /// /// It represents an event that occurs in Agama. @@ -84,6 +84,8 @@ pub enum EventPayload { LocaleChanged { locale: String, }, + #[serde(rename = "progress")] + ProgressEvent(progress::Event), #[serde(rename = "l10n")] L10nEvent(l10n::Event), DevicesDirty { @@ -186,6 +188,12 @@ pub enum EventPayload { }, } +impl From for EventPayload { + fn from(value: progress::Event) -> Self { + EventPayload::ProgressEvent(value) + } +} + impl From for EventPayload { fn from(value: l10n::Event) -> Self { EventPayload::L10nEvent(value) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 9b1a46af23..a5737c8345 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -26,11 +26,20 @@ use crate::supervisor::{ system_info::SystemInfo, }; use agama_lib::install_settings::InstallSettings; -use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + progress, +}; use async_trait::async_trait; use merge_struct::merge; use std::convert::Infallible; +const PROGRESS_SCOPE: &str = "main"; + +fn progress_scope() -> String { + PROGRESS_SCOPE.to_string() +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Cannot merge the configuration")] @@ -38,31 +47,62 @@ pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] + Progress(#[from] progress::service::Error), + #[error(transparent)] L10n(#[from] l10n::service::Error), - #[error("Infallible")] + #[error(transparent)] Infallible(#[from] Infallible), } -pub enum State { +enum State { Configuring, Installing, Finished, } pub struct Service { - l10n: Handler, + progress: Handler, + l10n: Handler, state: State, config: InstallSettings, } impl Service { - pub fn new(l10n: Handler) -> Self { + pub fn new(progress: Handler, l10n: Handler) -> Self { Self { + progress, l10n, state: State::Configuring, config: InstallSettings::default(), } } + + async fn start_install(&mut self) -> Result<(), Error> { + self.state = State::Installing; + // TODO: translate progress steps. + self.progress + .call(progress::message::StartWithSteps::new( + progress_scope(), + vec!["Installing l10n".to_string()], + )) + .await?; + Ok(()) + } + + async fn progress_step(&self) -> Result<(), Error> { + self.progress + .call(progress::message::Next::new(progress_scope())) + .await?; + Ok(()) + } + + async fn finish_install(&mut self) -> Result<(), Error> { + self.state = State::Finished; + self.progress + .call(progress::message::Finish::new(progress_scope())) + .await?; + Ok(()) + } } impl Actor for Service { @@ -231,9 +271,10 @@ impl MessageHandler for Service { self.l10n.call(l10n_message).await?; } Action::Install => { - self.state = State::Installing; + self.start_install().await?; + self.progress_step().await?; self.l10n.call(l10n::message::Install).await?; - self.state = State::Finished; + self.finish_install().await? } } Ok(()) diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs index cf23cd8302..d4cccb2f12 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-server/src/supervisor/start.rs @@ -22,12 +22,17 @@ use crate::{ supervisor::{l10n, listener::EventsListener, service::Service}, web::EventsSender, }; -use agama_utils::actor::{self, Handler}; +use agama_utils::{ + actor::{self, Handler}, + progress, +}; use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("Could not start the l10n service")] + #[error(transparent)] + Progress(#[from] progress::start::Error), + #[error(transparent)] L10n(#[from] l10n::start::Error), } @@ -44,14 +49,20 @@ pub enum Error { /// * `events`: channel to emit the [events](agama_lib::http::Event). pub async fn start(events: EventsSender) -> Result, Error> { let mut listener = EventsListener::new(events); + + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); + let progress = progress::start(events_sender).await?; + listener.add_channel("progress", events_receiver); + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); let l10n = l10n::start(events_sender).await?; listener.add_channel("l10n", events_receiver); + tokio::spawn(async move { listener.run().await; }); - let service = Service::new(l10n); + let service = Service::new(progress, l10n); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 949fc965b1..a92363a2f6 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -29,4 +29,4 @@ pub use service::Service; pub mod dbus; pub mod openapi; -mod progress; +pub mod progress; diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs index 71498b6ec1..575729eac9 100644 --- a/rust/agama-utils/src/progress.rs +++ b/rust/agama-utils/src/progress.rs @@ -18,8 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod event; -mod message; +pub mod start; +pub use start::start; + +pub mod service; +pub use service::Service; + +pub mod event; +pub use event::Event; + +pub mod message; mod model; -mod service; -mod start; From 4278556139afeede8ec5371bcb79ca378534c901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 6 Oct 2025 13:07:04 +0100 Subject: [PATCH 130/917] Add /status end-point --- rust/agama-server/src/server/web.rs | 16 ++++++++++++++++ rust/agama-server/src/supervisor/message.rs | 21 ++++++++++++++++++--- rust/agama-server/src/supervisor/service.rs | 17 ++++++++++++++++- rust/agama-utils/Cargo.toml | 2 +- rust/agama-utils/src/progress.rs | 2 ++ rust/agama-utils/src/progress/model.rs | 4 +++- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 62f5b0822a..8202d082bb 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -78,6 +78,7 @@ pub async fn server_service(events: EventsSender) -> Result Result) -> ServerResult> { + let status = state.supervisor.call(message::GetStatus).await?; + Ok(Json(status)) +} + /// Returns the information about the system. #[utoipa::path( get, diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-server/src/supervisor/message.rs index 8f1e0b7640..2d4fe783b3 100644 --- a/rust/agama-server/src/supervisor/message.rs +++ b/rust/agama-server/src/supervisor/message.rs @@ -19,11 +19,26 @@ // find current contact information at www.suse.com. use crate::supervisor::{ - l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, system_info::SystemInfo, + l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, service, system_info::SystemInfo, }; use agama_lib::install_settings::InstallSettings; -use agama_utils::actor::Message; -use serde::Deserialize; +use agama_utils::{actor::Message, progress::Progress}; +use serde::{Deserialize, Serialize}; + +/// Gets the installation status. +pub struct GetStatus; + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub state: service::State, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub progresses: Vec, +} + +impl Message for GetStatus { + type Reply = Status; +} /// Gets the information of the underlying system. #[derive(Debug)] diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index a5737c8345..efd9282a27 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -32,6 +32,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; +use serde::Serialize; use std::convert::Infallible; const PROGRESS_SCOPE: &str = "main"; @@ -54,7 +55,9 @@ pub enum Error { Infallible(#[from] Infallible), } -enum State { +#[derive(Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum State { Configuring, Installing, Finished, @@ -109,6 +112,18 @@ impl Actor for Service { type Error = Error; } +#[async_trait] +impl MessageHandler for Service { + /// It returns the status of the installation. + async fn handle(&mut self, _message: message::GetStatus) -> Result { + let progresses = self.progress.call(progress::message::Get).await?; + Ok(message::Status { + state: self.state.clone(), + progresses, + }) + } +} + #[async_trait] impl MessageHandler for Service { /// It returns the information of the underlying system. diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index c5755e58e1..8af9095ede 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -6,8 +6,8 @@ edition.workspace = true [dependencies] async-trait = "0.1.89" -serde_json = "1.0.140" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.140" thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } utoipa = "5.3.1" diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs index 575729eac9..6699d94d75 100644 --- a/rust/agama-utils/src/progress.rs +++ b/rust/agama-utils/src/progress.rs @@ -28,4 +28,6 @@ pub mod event; pub use event::Event; pub mod message; + mod model; +pub use model::Progress; diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/progress/model.rs index 76c09dd22c..d9c04818dd 100644 --- a/rust/agama-utils/src/progress/model.rs +++ b/rust/agama-utils/src/progress/model.rs @@ -19,8 +19,10 @@ // find current contact information at www.suse.com. use crate::progress::service::Error; +use serde::Serialize; -#[derive(Clone, Default, Debug)] +#[derive(Clone, Default, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Progress { pub scope: String, pub size: usize, From f61367567d55b0aa7e1915174c84bd6dd26b0bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 09:51:30 +0100 Subject: [PATCH 131/917] Fix test --- rust/agama-server/tests/server_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 57df6f6ca8..84b0474206 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -73,7 +73,7 @@ async fn test_get_empty_config() -> Result<(), Box> { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert_eq!(&body, ""); + assert_eq!(&body, "{}"); Ok(()) } From 0785ecb95ba3ae47767548a1311d0b1b7b7da99e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 7 Oct 2025 10:53:55 +0200 Subject: [PATCH 132/917] unify and simplify error status with message --- .../c-layer/internal/helpers.hxx | 39 +++++++++++---- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 50 ++++--------------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx index 0ecba8e321..84be67d97d 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/helpers.hxx @@ -1,7 +1,32 @@ #ifndef C_HELPERS_HXX_ #define C_HELPERS_HXX_ +#include #include + +// helper to get allocated formated string. Sadly C does not provide any +// portable way to do it. if we are ok with GNU or glib then it provides it +static char *format_alloc(const char *const format...) { + // `vsnprintf()` changes `va_list`'s state, so using it after that is UB. + // We need the args twice, so it is safer to just get two copies. + va_list args1; + va_list args2; + va_start(args1, format); + va_start(args2, format); + + // vsnprintf with len 0 just return needed size and add trailing zero. + size_t needed = 1 + vsnprintf(NULL, 0, format, args1); + + char *buffer = (char *)malloc(needed * sizeof(char)); + + vsnprintf(buffer, needed, format, args2); + + va_end(args1); + va_end(args2); + + return buffer; +} + /// Macro in case of programmer error. We do not use exceptions do to usage of /// noexpect in all places to avoid flowing exceptions to our pure C API. It /// basically print message to stderr and abort @@ -23,19 +48,11 @@ status->error = strdup(excpt.asUserString().c_str()); \ }) -/// Macro to help report failure with static string -#define STATUS_ERROR(status, err_str) \ - ({ \ - status->state = status->STATE_FAILED; \ - status->error = strdup(err_str); \ - }) - -/// Macro to help report failure with own allocated string -/// which will be later free with free_status method -#define STATUS_ERR_MSG(status, err_str) \ +/// Macro to help report failure with error string which is passed to format +#define STATUS_ERROR(status, ...) \ ({ \ status->state = status->STATE_FAILED; \ - status->error = strdup(err_str); \ + status->error = format_alloc(__VA_ARGS__); \ }) #endif \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 855cc05497..67724c0866 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -24,7 +24,6 @@ #include #include -#include #include extern "C" { @@ -48,29 +47,6 @@ void free_zypp(struct Zypp *zypp) noexcept { zypp->repo_manager = NULL; } -// helper to get allocated formated string. Sadly C does not provide any -// portable way to do it. if we are ok with GNU or glib then it provides it -static char *format_alloc(const char *const format...) { - // `vsnprintf()` changes `va_list`'s state, so using it after that is UB. - // We need the args twice, so it is safer to just get two copies. - va_list args1; - va_list args2; - va_start(args1, format); - va_start(args2, format); - - // vsnprintf with len 0 just return needed size and add trailing zero. - size_t needed = 1 + vsnprintf(NULL, 0, format, args1); - - char *buffer = (char *)malloc(needed * sizeof(char)); - - vsnprintf(buffer, needed, format, args2); - - va_end(args1); - va_end(args2); - - return buffer; -} - static zypp::ZYpp::Ptr zypp_ptr() { // set logging to ~/zypp-agama.log for now. For final we need to decide it zypp::Pathname home(getenv("HOME")); @@ -231,8 +207,8 @@ void resolvable_select(struct Zypp *_zypp, const char *name, zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); auto selectable = zypp::ui::Selectable::get(z_kind, name); if (!selectable) { - STATUS_ERR_MSG(status, format_alloc("Failed to find %s with name '%s'", - z_kind.c_str(), name)); + STATUS_ERROR(status, "Failed to find %s with name '%s'", z_kind.c_str(), + name); return; } @@ -253,8 +229,8 @@ void resolvable_unselect(struct Zypp *_zypp, const char *name, zypp::Resolvable::Kind z_kind = kind_to_zypp_kind(kind); auto selectable = zypp::ui::Selectable::get(z_kind, name); if (!selectable) { - STATUS_ERR_MSG(status, format_alloc("Failed to find %s with name '%s'", - z_kind.c_str(), name)); + STATUS_ERROR(status, "Failed to find %s with name '%s'", z_kind.c_str(), + name); return; } @@ -344,10 +320,8 @@ void refresh_repository(struct Zypp *zypp, const char *alias, try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - STATUS_ERR_MSG( - status, - format_alloc("Cannot refresh repo with alias %s. Repo not found.", - alias)); + STATUS_ERROR(status, "Cannot refresh repo with alias %s. Repo not found.", + alias); return; } @@ -524,10 +498,8 @@ void load_repository_cache(struct Zypp *zypp, const char *alias, try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - STATUS_ERR_MSG( - status, - format_alloc("Cannot load repo with alias %s. Repo not found.", - alias)); + STATUS_ERROR(status, "Cannot load repo with alias %s. Repo not found.", + alias); return; } @@ -551,10 +523,8 @@ void build_repository_cache(struct Zypp *zypp, const char *alias, try { zypp::RepoInfo zypp_repo = zypp->repo_manager->getRepo(alias); if (zypp_repo == zypp::RepoInfo::noRepo) { - STATUS_ERR_MSG( - status, - format_alloc("Cannot load repo with alias %s. Repo not found.", - alias)); + STATUS_ERROR(status, "Cannot load repo with alias %s. Repo not found.", + alias); return; } From 5f23ae601210dcfa449584061cc14bbdff782ef6 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 7 Oct 2025 11:27:51 +0200 Subject: [PATCH 133/917] Update service/lib/agama/http/clients/software.rb Co-authored-by: Martin Vidner --- service/lib/agama/http/clients/software.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 08df8d24d8..5b6cc26539 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -76,6 +76,8 @@ def get_resolvables(unique_id, type, optional) JSON.parse(get("software/resolvables/#{unique_id}?type=#{type}&optional=#{optional}")) end + # (Yes, with a question mark. Bad naming.) + # @return [Array] Those names that are selected for installation def provisions_selected?(provisions) provisions.select do |prov| package_installed?(prov) From 2dbccb87f58c254367148ddf09fa1583df4ad2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 11:01:05 +0100 Subject: [PATCH 134/917] Add spawn functions --- rust/agama-l10n/src/monitor.rs | 9 +++++++++ rust/agama-l10n/src/start.rs | 8 ++------ rust/agama-server/src/supervisor/listener.rs | 9 +++++++++ rust/agama-server/src/supervisor/start.rs | 10 ++++++---- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/rust/agama-l10n/src/monitor.rs b/rust/agama-l10n/src/monitor.rs index 2282af400e..02a6a1bef0 100644 --- a/rust/agama-l10n/src/monitor.rs +++ b/rust/agama-l10n/src/monitor.rs @@ -88,3 +88,12 @@ impl Monitor { } } } + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(mut monitor: Monitor) { + tokio::spawn(async move { + monitor.run().await; + }); +} diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index a3faf0e62b..a368550b00 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -62,12 +62,8 @@ pub async fn start(events: event::Sender) -> Result, Error> { let model = Model::from_system()?; let service = Service::new(model, events); let handler = actor::spawn(service); - - let mut monitor = Monitor::new(handler.clone()).await?; - tokio::spawn(async move { - monitor.run().await; - }); - + let monitor = Monitor::new(handler.clone()).await?; + monitor::spawn(monitor); Ok(handler) } diff --git a/rust/agama-server/src/supervisor/listener.rs b/rust/agama-server/src/supervisor/listener.rs index bc4864543c..fbdbac88de 100644 --- a/rust/agama-server/src/supervisor/listener.rs +++ b/rust/agama-server/src/supervisor/listener.rs @@ -63,3 +63,12 @@ impl EventsListener { } } } + +/// Spawns a Tokio task for the listener. +/// +/// * `listener`: listener to spawn. +pub fn spawn(listener: EventsListener) { + tokio::spawn(async move { + listener.run().await; + }); +} diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs index d4cccb2f12..63c4a6aa89 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-server/src/supervisor/start.rs @@ -19,7 +19,11 @@ // find current contact information at www.suse.com. use crate::{ - supervisor::{l10n, listener::EventsListener, service::Service}, + supervisor::{ + l10n, + listener::{self, EventsListener}, + service::Service, + }, web::EventsSender, }; use agama_utils::{ @@ -58,9 +62,7 @@ pub async fn start(events: EventsSender) -> Result, Error> { let l10n = l10n::start(events_sender).await?; listener.add_channel("l10n", events_receiver); - tokio::spawn(async move { - listener.run().await; - }); + listener::spawn(listener); let service = Service::new(progress, l10n); let handler = actor::spawn(service); From 50b14e987dbfdfb962b9ec77752b7a3570406b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 11:20:53 +0100 Subject: [PATCH 135/917] Add openapi documentation --- rust/agama-server/src/supervisor/message.rs | 2 ++ rust/agama-server/src/supervisor/service.rs | 3 +++ rust/agama-server/src/web/docs/config.rs | 3 +++ rust/agama-utils/src/progress/model.rs | 5 +++++ 4 files changed, 13 insertions(+) diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-server/src/supervisor/message.rs index 2d4fe783b3..d03f39a65d 100644 --- a/rust/agama-server/src/supervisor/message.rs +++ b/rust/agama-server/src/supervisor/message.rs @@ -31,8 +31,10 @@ pub struct GetStatus; #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Status { + /// State of the installation pub state: service::State, #[serde(skip_serializing_if = "Vec::is_empty")] + /// Active progresses pub progresses: Vec, } diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index efd9282a27..0ab82aa11e 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -58,8 +58,11 @@ pub enum Error { #[derive(Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum State { + /// Configuring the installation Configuring, + /// Installing the system Installing, + /// Installation finished Finished, } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index dd1dbd24a9..5700db0de5 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -163,6 +163,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/progress/model.rs index d9c04818dd..042ce62b19 100644 --- a/rust/agama-utils/src/progress/model.rs +++ b/rust/agama-utils/src/progress/model.rs @@ -24,10 +24,15 @@ use serde::Serialize; #[derive(Clone, Default, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Progress { + /// Scope of the progress pub scope: String, + /// Max number of steps pub size: usize, + /// List of steps pub steps: Option>, + /// Current step pub step: Option, + /// Index of the current step pub index: Option, } From 7b508b93847c1fbff16ec8feee490d262192c52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 11:28:59 +0100 Subject: [PATCH 136/917] Small improvement --- rust/agama-l10n/src/service.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 5145c63fa6..07b1a8da4a 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -19,13 +19,17 @@ // find current contact information at www.suse.com. use crate::{ - config::Config, event::Event, extended_config::ExtendedConfig, message, model::ModelAdapter, - proposal::Proposal, system_info::SystemInfo, + config::Config, + event::{self, Event}, + extended_config::ExtendedConfig, + message, + model::ModelAdapter, + proposal::Proposal, + system_info::SystemInfo, }; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, MessageHandler}; use async_trait::async_trait; -use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -54,7 +58,7 @@ pub enum Error { pub struct Service { state: State, model: Box, - events: mpsc::UnboundedSender, + events: event::Sender, } struct State { @@ -63,10 +67,7 @@ struct State { } impl Service { - pub fn new( - mut model: T, - events: mpsc::UnboundedSender, - ) -> Service { + pub fn new(mut model: T, events: event::Sender) -> Service { let system = SystemInfo::read_from(&mut model); let config = ExtendedConfig::new_from(&system); let state = State { system, config }; From 4f985bb18edd653239d458bf7acf4b4ced0046b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 14:55:32 +0100 Subject: [PATCH 137/917] Use references for message constructors --- rust/agama-server/src/supervisor/service.rs | 12 ++-- rust/agama-utils/src/progress/message.rs | 33 ++++++--- rust/agama-utils/src/progress/start.rs | 78 +++++++++------------ 3 files changed, 59 insertions(+), 64 deletions(-) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 0ab82aa11e..82cda1c965 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -37,10 +37,6 @@ use std::convert::Infallible; const PROGRESS_SCOPE: &str = "main"; -fn progress_scope() -> String { - PROGRESS_SCOPE.to_string() -} - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Cannot merge the configuration")] @@ -88,8 +84,8 @@ impl Service { // TODO: translate progress steps. self.progress .call(progress::message::StartWithSteps::new( - progress_scope(), - vec!["Installing l10n".to_string()], + PROGRESS_SCOPE, + vec!["Installing l10n"], )) .await?; Ok(()) @@ -97,7 +93,7 @@ impl Service { async fn progress_step(&self) -> Result<(), Error> { self.progress - .call(progress::message::Next::new(progress_scope())) + .call(progress::message::Next::new(PROGRESS_SCOPE)) .await?; Ok(()) } @@ -105,7 +101,7 @@ impl Service { async fn finish_install(&mut self) -> Result<(), Error> { self.state = State::Finished; self.progress - .call(progress::message::Finish::new(progress_scope())) + .call(progress::message::Finish::new(PROGRESS_SCOPE)) .await?; Ok(()) } diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 338dd9e827..b46cf133ae 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -33,8 +33,11 @@ pub struct Start { } impl Start { - pub fn new(scope: String, size: usize) -> Self { - Self { scope, size } + pub fn new(scope: &str, size: usize) -> Self { + Self { + scope: scope.to_string(), + size, + } } } @@ -48,8 +51,11 @@ pub struct StartWithSteps { } impl StartWithSteps { - pub fn new(scope: String, steps: Vec) -> Self { - Self { scope, steps } + pub fn new(scope: &str, steps: Vec<&str>) -> Self { + Self { + scope: scope.to_string(), + steps: steps.into_iter().map(|s| s.to_owned()).collect(), + } } } @@ -62,8 +68,10 @@ pub struct Next { } impl Next { - pub fn new(scope: String) -> Self { - Self { scope } + pub fn new(scope: &str) -> Self { + Self { + scope: scope.to_string(), + } } } @@ -77,8 +85,11 @@ pub struct NextStep { } impl NextStep { - pub fn new(scope: String, step: String) -> Self { - Self { scope, step } + pub fn new(scope: &str, step: &str) -> Self { + Self { + scope: scope.to_string(), + step: step.to_string(), + } } } @@ -91,8 +102,10 @@ pub struct Finish { } impl Finish { - pub fn new(scope: String) -> Self { - Self { scope } + pub fn new(scope: &str) -> Self { + Self { + scope: scope.to_string(), + } } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 2a2b16f12e..b741ea8e33 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -58,11 +58,10 @@ mod tests { #[tokio::test] async fn test_progress() -> Result<(), Box> { - let scope = "test".to_string(); let (mut receiver, handler) = start_testing_service(); // Start a progress - handler.call(message::Start::new(scope.clone(), 2)).await?; + handler.call(message::Start::new("test", 2)).await?; let event = receiver.recv().await.unwrap(); assert!(matches!(event, Event::ProgressChanged)); @@ -71,7 +70,7 @@ mod tests { assert_eq!(progresses.len(), 1); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, scope); + assert_eq!(progress.scope, "test"); assert_eq!(progress.size, 2); assert!(progress.steps.is_none()); assert!(progress.step.is_none()); @@ -79,10 +78,7 @@ mod tests { // First step handler - .call(message::NextStep::new( - scope.clone(), - "first step".to_string(), - )) + .call(message::NextStep::new("test", "first step")) .await?; let event = receiver.recv().await.unwrap(); @@ -90,28 +86,28 @@ mod tests { let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, scope); + assert_eq!(progress.scope, "test"); assert_eq!(progress.size, 2); assert!(progress.steps.is_none()); - assert_eq!(*progress.step.as_ref().unwrap(), "first step".to_string()); + assert_eq!(*progress.step.as_ref().unwrap(), "first step"); assert_eq!(progress.index.unwrap(), 1); // Second step (without step text) - handler.call(message::Next::new(scope.clone())).await?; + handler.call(message::Next::new("test")).await?; let event = receiver.recv().await.unwrap(); assert!(matches!(event, Event::ProgressChanged)); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, scope); + assert_eq!(progress.scope, "test"); assert_eq!(progress.size, 2); assert!(progress.steps.is_none()); assert!(progress.step.is_none()); assert_eq!(progress.index.unwrap(), 2); // Finish the progress - handler.call(message::Finish::new(scope.clone())).await?; + handler.call(message::Finish::new("test")).await?; let event = receiver.recv().await.unwrap(); assert!(matches!(event, Event::ProgressChanged)); @@ -124,58 +120,53 @@ mod tests { #[tokio::test] async fn test_progress_with_steps() -> Result<(), Box> { - let scope = "test".to_string(); let (_receiver, handler) = start_testing_service(); - let first_step = "first step".to_string(); - let second_step = "second step".to_string(); - let third_step = "third step".to_string(); - // Start a progress handler .call(message::StartWithSteps::new( - scope.clone(), - vec![first_step.clone(), second_step.clone(), third_step.clone()], + "test", + vec!["first step", "second step", "third step"], )) .await?; let progresses = handler.call(message::Get).await?; let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, scope); + assert_eq!(progress.scope, "test"); assert_eq!(progress.size, 3); assert_eq!(progress.steps.as_ref().unwrap().len(), 3); - assert_eq!(*progress.steps.as_ref().unwrap()[0], first_step); - assert_eq!(*progress.steps.as_ref().unwrap()[1], second_step); - assert_eq!(*progress.steps.as_ref().unwrap()[2], third_step); + assert_eq!(progress.steps.as_ref().unwrap()[0], "first step"); + assert_eq!(progress.steps.as_ref().unwrap()[1], "second step"); + assert_eq!(progress.steps.as_ref().unwrap()[2], "third step"); assert!(progress.step.is_none()); assert!(progress.index.is_none()); // First step - handler.call(message::Next::new(scope.clone())).await?; + handler.call(message::Next::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(*progress.step.as_ref().unwrap(), first_step); + assert_eq!(progress.step.as_ref().unwrap(), "first step"); assert_eq!(progress.index.unwrap(), 1); // Second step - handler.call(message::Next::new(scope.clone())).await?; + handler.call(message::Next::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(*progress.step.as_ref().unwrap(), second_step); + assert_eq!(progress.step.as_ref().unwrap(), "second step"); assert_eq!(progress.index.unwrap(), 2); // Third step - handler.call(message::Next::new(scope.clone())).await?; + handler.call(message::Next::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(*progress.step.as_ref().unwrap(), third_step); + assert_eq!(progress.step.as_ref().unwrap(), "third step"); assert_eq!(progress.index.unwrap(), 3); // Finish the progress - handler.call(message::Finish::new(scope.clone())).await?; + handler.call(message::Finish::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); assert!(progresses.is_empty()); @@ -185,43 +176,38 @@ mod tests { #[tokio::test] async fn test_several_progresses() -> Result<(), Box> { - let scope1 = "test1".to_string(); - let scope2 = "test2".to_string(); let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new(scope1.clone(), 2)).await?; - handler.call(message::Start::new(scope2.clone(), 2)).await?; + handler.call(message::Start::new("test1", 2)).await?; + handler.call(message::Start::new("test2", 2)).await?; let progresses = handler.call(message::Get).await.unwrap(); assert_eq!(progresses.len(), 2); - assert_eq!(progresses[0].scope, scope1); - assert_eq!(progresses[1].scope, scope2); + assert_eq!(progresses[0].scope, "test1"); + assert_eq!(progresses[1].scope, "test2"); Ok(()) } #[tokio::test] async fn test_progress_error_next() -> Result<(), Box> { - let scope = "test".to_string(); let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new(scope.clone(), 1)).await?; - handler.call(message::Next::new(scope.clone())).await?; - let error = handler.call(message::Next::new(scope.clone())).await; - assert!(matches!(error, Err(service::Error::NextStep(s)) if s == scope)); + handler.call(message::Start::new("test", 1)).await?; + handler.call(message::Next::new("test")).await?; + let error = handler.call(message::Next::new("test")).await; + assert!(matches!(error, Err(service::Error::NextStep(s)) if s == "test")); Ok(()) } #[tokio::test] async fn test_progress_error_scope() -> Result<(), Box> { - let scope1 = "test1".to_string(); - let scope2 = "test2".to_string(); let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new(scope1.clone(), 1)).await?; - let error = handler.call(message::Next::new(scope2.clone())).await; - assert!(matches!(error, Err(service::Error::Progress(s)) if s == scope2)); + handler.call(message::Start::new("test1", 1)).await?; + let error = handler.call(message::Next::new("test2")).await; + assert!(matches!(error, Err(service::Error::Progress(s)) if s == "test2")); Ok(()) } From 802e1a8128e0825bb7ce6a900f7879ceba0340e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 16:01:45 +0100 Subject: [PATCH 138/917] Automatically start progress --- rust/agama-server/src/supervisor/service.rs | 19 +----- rust/agama-utils/src/progress/message.rs | 4 +- rust/agama-utils/src/progress/model.rs | 49 ++++++-------- rust/agama-utils/src/progress/service.rs | 2 +- rust/agama-utils/src/progress/start.rs | 75 ++++++++++----------- 5 files changed, 61 insertions(+), 88 deletions(-) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 82cda1c965..688d2b7619 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -79,7 +79,7 @@ impl Service { } } - async fn start_install(&mut self) -> Result<(), Error> { + async fn install(&mut self) -> Result<(), Error> { self.state = State::Installing; // TODO: translate progress steps. self.progress @@ -88,17 +88,7 @@ impl Service { vec!["Installing l10n"], )) .await?; - Ok(()) - } - - async fn progress_step(&self) -> Result<(), Error> { - self.progress - .call(progress::message::Next::new(PROGRESS_SCOPE)) - .await?; - Ok(()) - } - - async fn finish_install(&mut self) -> Result<(), Error> { + self.l10n.call(l10n::message::Install).await?; self.state = State::Finished; self.progress .call(progress::message::Finish::new(PROGRESS_SCOPE)) @@ -285,10 +275,7 @@ impl MessageHandler for Service { self.l10n.call(l10n_message).await?; } Action::Install => { - self.start_install().await?; - self.progress_step().await?; - self.l10n.call(l10n::message::Install).await?; - self.finish_install().await? + self.install().await?; } } Ok(()) diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index b46cf133ae..e374eaee48 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -30,13 +30,15 @@ impl Message for Get { pub struct Start { pub scope: String, pub size: usize, + pub step: String, } impl Start { - pub fn new(scope: &str, size: usize) -> Self { + pub fn new(scope: &str, size: usize, step: &str) -> Self { Self { scope: scope.to_string(), size, + step: step.to_string(), } } } diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/progress/model.rs index 042ce62b19..6a2b5d23a9 100644 --- a/rust/agama-utils/src/progress/model.rs +++ b/rust/agama-utils/src/progress/model.rs @@ -29,19 +29,21 @@ pub struct Progress { /// Max number of steps pub size: usize, /// List of steps - pub steps: Option>, + pub steps: Vec, /// Current step - pub step: Option, + pub step: String, /// Index of the current step - pub index: Option, + pub index: usize, } impl Progress { - pub fn new(scope: String, size: usize) -> Self { + pub fn new(scope: String, size: usize, step: String) -> Self { Self { scope, size, - ..Default::default() + steps: Vec::new(), + step, + index: 1, } } @@ -49,40 +51,29 @@ impl Progress { Self { scope, size: steps.len(), - steps: Some(steps), - ..Default::default() + steps: steps.clone(), + step: steps.first().map_or(String::new(), |s| s.clone()), + index: 1, } } pub fn next(&mut self) -> Result<(), Error> { - match self.index { - Some(index) if index >= self.size => Err(Error::NextStep(self.scope.clone())), - Some(index) => { - let next_index = index + 1; - self.index = Some(next_index); - self.step = self.get_step(next_index); - Ok(()) - } - None => { - let first_index = 1; - self.index = Some(first_index); - self.step = self.get_step(first_index); - Ok(()) - } + if self.index >= self.size { + return Err(Error::NextStep(self.scope.clone())); } + + self.index += 1; + self.step = self.get_step(self.index).unwrap_or(String::new()); + Ok(()) } pub fn next_step(&mut self, step: String) -> Result<(), Error> { - self.next().and_then(|_| { - self.step = Some(step); - Ok(()) - }) + self.next()?; + self.step = step; + Ok(()) } fn get_step(&self, index: usize) -> Option { - self.steps - .as_ref() - .and_then(|n| n.get(index - 1)) - .and_then(|n| Some(n.clone())) + self.steps.get(index - 1).map(|s| s.clone()) } } diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index f857d38a4a..fabeab10c5 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -76,7 +76,7 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { self.progresses - .push(Progress::new(message.scope, message.size)); + .push(Progress::new(message.scope, message.size, message.step)); self.events.send(Event::ProgressChanged)?; Ok(()) } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index b741ea8e33..acedeb4fd3 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -60,8 +60,10 @@ mod tests { async fn test_progress() -> Result<(), Box> { let (mut receiver, handler) = start_testing_service(); - // Start a progress - handler.call(message::Start::new("test", 2)).await?; + // Start a progress (first step) + handler + .call(message::Start::new("test", 3, "first step")) + .await?; let event = receiver.recv().await.unwrap(); assert!(matches!(event, Event::ProgressChanged)); @@ -71,14 +73,14 @@ mod tests { let progress = progresses.first().unwrap(); assert_eq!(progress.scope, "test"); - assert_eq!(progress.size, 2); - assert!(progress.steps.is_none()); - assert!(progress.step.is_none()); - assert!(progress.index.is_none()); + assert_eq!(progress.size, 3); + assert!(progress.steps.is_empty()); + assert_eq!(progress.step, "first step"); + assert_eq!(progress.index, 1); - // First step + // Second step handler - .call(message::NextStep::new("test", "first step")) + .call(message::NextStep::new("test", "second step")) .await?; let event = receiver.recv().await.unwrap(); @@ -87,12 +89,12 @@ mod tests { let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); assert_eq!(progress.scope, "test"); - assert_eq!(progress.size, 2); - assert!(progress.steps.is_none()); - assert_eq!(*progress.step.as_ref().unwrap(), "first step"); - assert_eq!(progress.index.unwrap(), 1); + assert_eq!(progress.size, 3); + assert!(progress.steps.is_empty()); + assert_eq!(progress.step, "second step"); + assert_eq!(progress.index, 2); - // Second step (without step text) + // Last step (without step text) handler.call(message::Next::new("test")).await?; let event = receiver.recv().await.unwrap(); @@ -101,10 +103,10 @@ mod tests { let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); assert_eq!(progress.scope, "test"); - assert_eq!(progress.size, 2); - assert!(progress.steps.is_none()); - assert!(progress.step.is_none()); - assert_eq!(progress.index.unwrap(), 2); + assert_eq!(progress.size, 3); + assert!(progress.steps.is_empty()); + assert_eq!(progress.step, ""); + assert_eq!(progress.index, 3); // Finish the progress handler.call(message::Finish::new("test")).await?; @@ -122,7 +124,7 @@ mod tests { async fn test_progress_with_steps() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - // Start a progress + // Start a progress (first step) handler .call(message::StartWithSteps::new( "test", @@ -134,36 +136,28 @@ mod tests { let progress = progresses.first().unwrap(); assert_eq!(progress.scope, "test"); assert_eq!(progress.size, 3); - assert_eq!(progress.steps.as_ref().unwrap().len(), 3); - assert_eq!(progress.steps.as_ref().unwrap()[0], "first step"); - assert_eq!(progress.steps.as_ref().unwrap()[1], "second step"); - assert_eq!(progress.steps.as_ref().unwrap()[2], "third step"); - assert!(progress.step.is_none()); - assert!(progress.index.is_none()); - - // First step - handler.call(message::Next::new("test")).await?; - - let progresses = handler.call(message::Get).await.unwrap(); - let progress = progresses.first().unwrap(); - assert_eq!(progress.step.as_ref().unwrap(), "first step"); - assert_eq!(progress.index.unwrap(), 1); + assert_eq!(progress.steps.len(), 3); + assert_eq!(progress.steps[0], "first step"); + assert_eq!(progress.steps[1], "second step"); + assert_eq!(progress.steps[2], "third step"); + assert_eq!(progress.step, "first step"); + assert_eq!(progress.index, 1); // Second step handler.call(message::Next::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.step.as_ref().unwrap(), "second step"); - assert_eq!(progress.index.unwrap(), 2); + assert_eq!(progress.step, "second step"); + assert_eq!(progress.index, 2); // Third step handler.call(message::Next::new("test")).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.step.as_ref().unwrap(), "third step"); - assert_eq!(progress.index.unwrap(), 3); + assert_eq!(progress.step, "third step"); + assert_eq!(progress.index, 3); // Finish the progress handler.call(message::Finish::new("test")).await?; @@ -178,8 +172,8 @@ mod tests { async fn test_several_progresses() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test1", 2)).await?; - handler.call(message::Start::new("test2", 2)).await?; + handler.call(message::Start::new("test1", 2, "")).await?; + handler.call(message::Start::new("test2", 2, "")).await?; let progresses = handler.call(message::Get).await.unwrap(); assert_eq!(progresses.len(), 2); @@ -193,8 +187,7 @@ mod tests { async fn test_progress_error_next() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test", 1)).await?; - handler.call(message::Next::new("test")).await?; + handler.call(message::Start::new("test", 1, "")).await?; let error = handler.call(message::Next::new("test")).await; assert!(matches!(error, Err(service::Error::NextStep(s)) if s == "test")); @@ -205,7 +198,7 @@ mod tests { async fn test_progress_error_scope() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test1", 1)).await?; + handler.call(message::Start::new("test1", 2, "")).await?; let error = handler.call(message::Next::new("test2")).await; assert!(matches!(error, Err(service::Error::Progress(s)) if s == "test2")); From 06b9c34de85f5bab2f0d6e163915188451b46bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 17:26:26 +0100 Subject: [PATCH 139/917] Do not allow duplicated scope --- rust/agama-utils/src/progress/model.rs | 2 +- rust/agama-utils/src/progress/service.rs | 18 ++++++++++++----- rust/agama-utils/src/progress/start.rs | 25 ++++++++++++++++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/progress/model.rs index 6a2b5d23a9..5b6a0d1e0a 100644 --- a/rust/agama-utils/src/progress/model.rs +++ b/rust/agama-utils/src/progress/model.rs @@ -59,7 +59,7 @@ impl Progress { pub fn next(&mut self) -> Result<(), Error> { if self.index >= self.size { - return Err(Error::NextStep(self.scope.clone())); + return Err(Error::MissingStep(self.scope.clone())); } self.index += 1; diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index fabeab10c5..1d74d4e513 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -29,10 +29,12 @@ use tokio::sync::mpsc::error::SendError; #[derive(thiserror::Error, Debug)] pub enum Error { + #[error("Progress already exists for {0}")] + DuplicatedProgress(String), #[error("Progress does not exist for {0}")] - Progress(String), + MissingProgress(String), #[error("Next step does not exist for {0}")] - NextStep(String), + MissingStep(String), #[error(transparent)] Event(#[from] SendError), #[error(transparent)] @@ -75,6 +77,9 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { + if self.get_progress(message.scope.clone()).is_some() { + return Err(Error::DuplicatedProgress(message.scope)); + } self.progresses .push(Progress::new(message.scope, message.size, message.step)); self.events.send(Event::ProgressChanged)?; @@ -85,6 +90,9 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::StartWithSteps) -> Result<(), Error> { + if self.get_progress(message.scope.clone()).is_some() { + return Err(Error::DuplicatedProgress(message.scope)); + } self.progresses .push(Progress::new_with_steps(message.scope, message.steps)); self.events.send(Event::ProgressChanged)?; @@ -96,7 +104,7 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::Next) -> Result<(), Error> { self.get_progress(message.scope.clone()) - .ok_or(Error::Progress(message.scope)) + .ok_or(Error::MissingProgress(message.scope)) .and_then(|p| p.next())?; self.events.send(Event::ProgressChanged)?; Ok(()) @@ -107,7 +115,7 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::NextStep) -> Result<(), Error> { self.get_progress(message.scope.clone()) - .ok_or(Error::Progress(message.scope)) + .ok_or(Error::MissingProgress(message.scope)) .and_then(|p| p.next_step(message.step))?; self.events.send(Event::ProgressChanged)?; Ok(()) @@ -119,7 +127,7 @@ impl MessageHandler for Service { async fn handle(&mut self, message: message::Finish) -> Result<(), Error> { let index = self .get_progress_index(message.scope.clone()) - .ok_or(Error::Progress(message.scope))?; + .ok_or(Error::MissingProgress(message.scope))?; self.progresses.remove(index); self.events.send(Event::ProgressChanged)?; Ok(()) diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index acedeb4fd3..b370e6cf9d 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -184,23 +184,40 @@ mod tests { } #[tokio::test] - async fn test_progress_error_next() -> Result<(), Box> { + async fn test_progress_missing_step() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); handler.call(message::Start::new("test", 1, "")).await?; let error = handler.call(message::Next::new("test")).await; - assert!(matches!(error, Err(service::Error::NextStep(s)) if s == "test")); + assert!(matches!(error, Err(service::Error::MissingStep(scope)) if scope == "test")); Ok(()) } #[tokio::test] - async fn test_progress_error_scope() -> Result<(), Box> { + async fn test_missing_progress() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); handler.call(message::Start::new("test1", 2, "")).await?; let error = handler.call(message::Next::new("test2")).await; - assert!(matches!(error, Err(service::Error::Progress(s)) if s == "test2")); + assert!(matches!(error, Err(service::Error::MissingProgress(scope)) if scope == "test2")); + + Ok(()) + } + + #[tokio::test] + async fn test_duplicated_progress() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + handler.call(message::Start::new("test", 2, "")).await?; + + let error = handler.call(message::Start::new("test", 1, "")).await; + assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); + + let error = handler + .call(message::StartWithSteps::new("test", vec!["step"])) + .await; + assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); Ok(()) } From a1ce3befd5df0d414a7df83e07e7de01f7a12fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 18:13:24 +0100 Subject: [PATCH 140/917] Use reference for scope param --- rust/agama-utils/src/progress/service.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 1d74d4e513..fbed3d1ce6 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -54,11 +54,15 @@ impl Service { } } - fn get_progress(&mut self, scope: String) -> Option<&mut Progress> { + fn get_progress(&self, scope: &str) -> Option<&Progress> { + self.progresses.iter().find(|p| p.scope == scope) + } + + fn get_mut_progress(&mut self, scope: &str) -> Option<&mut Progress> { self.progresses.iter_mut().find(|p| p.scope == scope) } - fn get_progress_index(&self, scope: String) -> Option { + fn get_progress_index(&self, scope: &str) -> Option { self.progresses.iter().position(|p| p.scope == scope) } } @@ -77,7 +81,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { - if self.get_progress(message.scope.clone()).is_some() { + if self.get_progress(message.scope.as_str()).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } self.progresses @@ -90,7 +94,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::StartWithSteps) -> Result<(), Error> { - if self.get_progress(message.scope.clone()).is_some() { + if self.get_progress(message.scope.as_str()).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } self.progresses @@ -103,7 +107,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Next) -> Result<(), Error> { - self.get_progress(message.scope.clone()) + self.get_mut_progress(message.scope.as_str()) .ok_or(Error::MissingProgress(message.scope)) .and_then(|p| p.next())?; self.events.send(Event::ProgressChanged)?; @@ -114,7 +118,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::NextStep) -> Result<(), Error> { - self.get_progress(message.scope.clone()) + self.get_mut_progress(message.scope.as_str()) .ok_or(Error::MissingProgress(message.scope)) .and_then(|p| p.next_step(message.step))?; self.events.send(Event::ProgressChanged)?; @@ -126,7 +130,7 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::Finish) -> Result<(), Error> { let index = self - .get_progress_index(message.scope.clone()) + .get_progress_index(message.scope.as_str()) .ok_or(Error::MissingProgress(message.scope))?; self.progresses.remove(index); self.events.send(Event::ProgressChanged)?; From 85ecc5f27ab18f2642d818f3168d782dd64e12a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 18:27:45 +0100 Subject: [PATCH 141/917] Remove unnecessary infallible error --- rust/agama-server/src/supervisor/service.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 688d2b7619..22ed795969 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -33,7 +33,6 @@ use agama_utils::{ use async_trait::async_trait; use merge_struct::merge; use serde::Serialize; -use std::convert::Infallible; const PROGRESS_SCOPE: &str = "main"; @@ -47,8 +46,6 @@ pub enum Error { Progress(#[from] progress::service::Error), #[error(transparent)] L10n(#[from] l10n::service::Error), - #[error(transparent)] - Infallible(#[from] Infallible), } #[derive(Clone, Serialize, utoipa::ToSchema)] From 241773203bb9d0c1f57e52b54f69ce2beaa331f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 7 Oct 2025 18:34:53 +0100 Subject: [PATCH 142/917] Update API doc --- doc/http_api.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/http_api.md b/doc/http_api.md index f6f05fc0b7..221ac8415a 100644 --- a/doc/http_api.md +++ b/doc/http_api.md @@ -27,7 +27,7 @@ GET PUT PATCH /config GET PUT PATCH /config/{scope} GET POST PATCH /questions GET /proposal -GET /state +GET /status GET /issues POST /action ~~~ @@ -59,6 +59,10 @@ The scope can be indicated to manage only part of the config, for example *PUT / Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. +### GET /status + +Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. + ### Example: reload the system In some cases, clients need to request a system reload. For example, if you create a RAID device using the terminal, then you need to reload the system in order to see the new device. In the future, reloading the system could be automatically done (e.g., by listening udisk D-Bus). For now, reloading has to be manually requested. From 4b3e869967783dc63afa56856ae6b571488ca03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 07:26:13 +0100 Subject: [PATCH 143/917] Use slice param instead of vector --- rust/agama-server/src/supervisor/service.rs | 2 +- rust/agama-utils/src/progress/message.rs | 4 ++-- rust/agama-utils/src/progress/start.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-server/src/supervisor/service.rs index 22ed795969..712a3d0485 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-server/src/supervisor/service.rs @@ -82,7 +82,7 @@ impl Service { self.progress .call(progress::message::StartWithSteps::new( PROGRESS_SCOPE, - vec!["Installing l10n"], + &["Installing l10n"], )) .await?; self.l10n.call(l10n::message::Install).await?; diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index e374eaee48..12db520a7f 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -53,10 +53,10 @@ pub struct StartWithSteps { } impl StartWithSteps { - pub fn new(scope: &str, steps: Vec<&str>) -> Self { + pub fn new(scope: &str, steps: &[&str]) -> Self { Self { scope: scope.to_string(), - steps: steps.into_iter().map(|s| s.to_owned()).collect(), + steps: steps.into_iter().map(ToString::to_string).collect(), } } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index b370e6cf9d..9760798d7d 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -128,7 +128,7 @@ mod tests { handler .call(message::StartWithSteps::new( "test", - vec!["first step", "second step", "third step"], + &["first step", "second step", "third step"], )) .await?; @@ -215,7 +215,7 @@ mod tests { assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); let error = handler - .call(message::StartWithSteps::new("test", vec!["step"])) + .call(message::StartWithSteps::new("test", &["step"])) .await; assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); From 0f338ea3a0d68d1144d898696a4afbda9773de6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 07:29:07 +0100 Subject: [PATCH 144/917] Changelog --- rust/package/agama.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 7ef116fec5..0708a0e303 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Oct 8 06:27:14 UTC 2025 - José Iván López González + +- Add service for reporting progress and extend HTTP API to allow + getting the installation status (gh#agama-project/agama#2787). + ------------------------------------------------------------------- Fri Oct 3 20:11:26 UTC 2025 - Imobach Gonzalez Sosa From 1e156d990ab2b65a91e7efff18460b30ba47ae95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 09:26:29 +0100 Subject: [PATCH 145/917] Fix JSON format of IssuesChanged event --- rust/agama-l10n/src/start.rs | 2 +- rust/agama-lib/src/http/event.rs | 10 +++++----- rust/agama-server/src/supervisor/start.rs | 2 +- rust/agama-utils/src/issue.rs | 2 +- rust/agama-utils/src/issue/event.rs | 10 +++++++--- rust/agama-utils/src/issue/service.rs | 8 ++++---- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 3ebfe671e5..7c1cc09f92 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -142,7 +142,7 @@ mod tests { } async fn start_testing_service() -> (Receiver, Handler, Handler) { - let (events_tx, _events_rx) = mpsc::unbounded_channel::(); + let (events_tx, _events_rx) = mpsc::unbounded_channel::(); let issues = issue::start(events_tx, None).await.unwrap(); let (events_tx, events_rx) = mpsc::unbounded_channel::(); diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 15116ee7ba..72df6b2f93 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -18,7 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::issue::Issue; use crate::{ auth::ClientId, jobs::Job, @@ -123,7 +122,8 @@ pub enum EventPayload { service: String, status: u32, }, - Issues(issue::IssuesChanged), + #[serde(rename = "issues")] + Issues(issue::Event), ValidationChanged { service: String, path: String, @@ -197,9 +197,9 @@ impl From for EventPayload { } } -impl From for EventPayload { - fn from(_value: issue::IssuesChanged) -> Self { - EventPayload::Issues(issue::IssuesChanged) +impl From for EventPayload { + fn from(value: issue::Event) -> Self { + EventPayload::Issues(value) } } diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs index b7e791aa92..b0948e5920 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-server/src/supervisor/start.rs @@ -61,7 +61,7 @@ pub async fn start( ) -> Result, Error> { let mut listener = EventsListener::new(events); - let (events_sender, events_receiver) = mpsc::unbounded_channel::(); + let (events_sender, events_receiver) = mpsc::unbounded_channel::(); let issues = issue::start(events_sender, dbus).await?; listener.add_channel("issues", events_receiver); diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index 7de18a7ad3..54ff2489d5 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -44,7 +44,7 @@ //! ``` pub mod event; -pub use event::IssuesChanged; +pub use event::Event; pub mod model; pub use model::{Issue, IssueSeverity, IssueSource}; diff --git a/rust/agama-utils/src/issue/event.rs b/rust/agama-utils/src/issue/event.rs index cc845c3fda..3f307feaaa 100644 --- a/rust/agama-utils/src/issue/event.rs +++ b/rust/agama-utils/src/issue/event.rs @@ -23,9 +23,13 @@ use tokio::sync::mpsc; /// Issues changed event. #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct IssuesChanged; +#[serde(tag = "name")] +pub enum Event { + /// Issues changed. + IssuesChanged, +} /// Multi-producer single-consumer events sender. -pub type Sender = mpsc::UnboundedSender; +pub type Sender = mpsc::UnboundedSender; /// Multi-producer single-consumer events receiver. -pub type Receiver = mpsc::UnboundedReceiver; +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index f96d5402a3..338bb60c64 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{event, message, Issue, IssuesChanged}; +use super::{event, message, Event, Issue}; use crate::actor::{self, Actor, MessageHandler}; use async_trait::async_trait; use std::collections::HashMap; @@ -34,11 +34,11 @@ pub enum Error { pub struct Service { issues: HashMap>, - events: mpsc::UnboundedSender, + events: mpsc::UnboundedSender, } impl Service { - pub fn new(events: mpsc::UnboundedSender) -> Self { + pub fn new(events: mpsc::UnboundedSender) -> Self { Self { issues: HashMap::new(), events, @@ -70,7 +70,7 @@ impl MessageHandler for Service { } if message.notify { - _ = self.events.send(event::IssuesChanged); + _ = self.events.send(event::Event::IssuesChanged); } Ok(()) } From ac069aac207ab964590649832b2c291694143e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Wed, 8 Oct 2025 10:57:16 +0200 Subject: [PATCH 146/917] Fix software endpoint path --- rust/agama-server/src/software_ng/web.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index e0bf48b86d..345df1962c 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -81,7 +81,7 @@ struct QueryParam { #[utoipa::path( get, path = "/available", - context_path = "/api/software_ng", + context_path = "/api/software", responses( (status = 200, description = "Whenever matching package is available"), (status = 400, description = "Failed to check if package is available") @@ -101,7 +101,7 @@ async fn get_available( #[utoipa::path( get, path = "/selected", - context_path = "/api/software_ng", + context_path = "/api/software", responses( (status = 200, description = "Whenever matching package is selected for installation"), (status = 400, description = "Failed to check if package is selected") @@ -121,7 +121,7 @@ async fn get_selected( #[utoipa::path( get, path = "/products", - context_path = "/api/software_ng", + context_path = "/api/software", responses( (status = 200, description = "List of known products", body = Vec), (status = 400, description = "Cannot read the list of products") @@ -138,7 +138,7 @@ async fn get_products(State(state): State) -> Result), (status = 400, description = "Cannot read the list of patterns") @@ -156,7 +156,7 @@ async fn get_patterns(State(state): State) -> Result) -> Result, Error> { #[utoipa::path( get, path = "/proposal", - context_path = "/api/software_ng", + context_path = "/api/software", responses( (status = 200, description = "Software proposal", body = SoftwareProposal) ) @@ -280,7 +280,7 @@ async fn get_proposal(State(state): State) -> Result) ) @@ -296,7 +296,7 @@ async fn product_issues(State(state): State) -> Result) ) From 76903c04d7945f3c89b7aa31858b2528d9da0195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 11:09:05 +0100 Subject: [PATCH 147/917] Update from code review --- rust/agama-server/src/supervisor/start.rs | 2 +- rust/agama-utils/src/issue/monitor.rs | 13 ++++++++++++- rust/agama-utils/src/issue/service.rs | 7 +++---- rust/agama-utils/src/issue/start.rs | 16 ++++++++-------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-server/src/supervisor/start.rs index b0948e5920..16b8d8d77f 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-server/src/supervisor/start.rs @@ -38,7 +38,7 @@ pub enum Error { Progress(#[from] progress::start::Error), #[error(transparent)] L10n(#[from] l10n::start::Error), - #[error("Could not start the issues service")] + #[error(transparent)] Issues(#[from] issue::start::Error), } diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index bac48ec4a3..46f644c12a 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -64,7 +64,7 @@ impl Monitor { } /// Run the monitor on a separate Tokio task. - pub async fn run(&self) -> Result<(), Error> { + async fn run(&self) -> Result<(), Error> { let mut messages = build_properties_changed_stream(&self.dbus).await?; self.initialize_issues(MANAGER_SERVICE, USERS_PATH).await?; @@ -179,3 +179,14 @@ impl Monitor { } } } + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(monitor: Monitor) { + tokio::spawn(async move { + if let Err(e) = monitor.run().await { + println!("Error running the issues monitor: {e:?}"); + } + }); +} diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 338bb60c64..3703cddb48 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -18,11 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{event, message, Event, Issue}; +use super::{event, message, Issue}; use crate::actor::{self, Actor, MessageHandler}; use async_trait::async_trait; use std::collections::HashMap; -use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -34,11 +33,11 @@ pub enum Error { pub struct Service { issues: HashMap>, - events: mpsc::UnboundedSender, + events: event::Sender, } impl Service { - pub fn new(events: mpsc::UnboundedSender) -> Self { + pub fn new(events: event::Sender) -> Self { Self { issues: HashMap::new(), events, diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 051d398a24..ef94d827eb 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -18,7 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{event, monitor::Monitor, service, Service}; +use super::{ + event, + monitor::{self, Monitor}, + service, Service, +}; use crate::actor::{self, Handler}; #[derive(thiserror::Error, Debug)] @@ -35,12 +39,8 @@ pub async fn start( let handler = actor::spawn(service); if let Some(conn) = dbus { - let monitor = Monitor::new(handler.clone(), conn); - tokio::spawn(async move { - if let Err(e) = monitor.run().await { - println!("Error running the issues monitor: {e:?}"); - } - }); + let dbus_monitor = Monitor::new(handler.clone(), conn); + monitor::spawn(dbus_monitor); } Ok(handler) @@ -76,7 +76,7 @@ mod tests { } #[tokio::test] - async fn test_update_wo_event() -> Result<(), Box> { + async fn test_update_without_event() -> Result<(), Box> { let (events_tx, mut events_rx) = mpsc::unbounded_channel(); let issues = issue::start(events_tx, None).await.unwrap(); let issue = Issue { From 9869ba3baae7858f47126cb9191330b572f06654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 11:13:49 +0100 Subject: [PATCH 148/917] Report l10n issues as "error" --- rust/agama-l10n/src/service.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 96a3b8c909..094b711e91 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -107,7 +107,7 @@ impl Service { description: format!("Locale '{}' is unknown", &config.locale), details: None, source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Warn, + severity: issue::IssueSeverity::Error, kind: "unknown_locale".to_string(), }); } @@ -117,7 +117,7 @@ impl Service { description: format!("Keymap '{}' is unknown", &config.keymap), details: None, source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Warn, + severity: issue::IssueSeverity::Error, kind: "unknown_keymap".to_string(), }); } @@ -127,7 +127,7 @@ impl Service { description: format!("Timezone '{}' is unknown", &config.timezone), details: None, source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Warn, + severity: issue::IssueSeverity::Error, kind: "unknown_timezone".to_string(), }); } From 72cd17a41ce7c2c733d4577c9a4d73b02aa3b14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 12:18:39 +0100 Subject: [PATCH 149/917] Propagate cast function errors in the issue service --- rust/agama-utils/src/issue/monitor.rs | 7 ++++--- rust/agama-utils/src/issue/start.rs | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 46f644c12a..3da2eb91bc 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -117,7 +117,7 @@ impl Monitor { .map(Issue::try_from) .collect::, _>>()?; - self.update_issues(path.as_str(), issues, true); + self.update_issues(path.as_str(), issues, true)?; Ok(()) } @@ -148,13 +148,13 @@ impl Monitor { .into_iter() .map(Issue::try_from) .collect::, _>>()?; - self.update_issues(path, issues, false); + self.update_issues(path, issues, false)?; Ok(()) } /// Updates the list of issues. - fn update_issues(&self, path: &str, issues: Vec, notify: bool) { + fn update_issues(&self, path: &str, issues: Vec, notify: bool) -> Result<(), Error> { match Self::list_id_from_path(path) { Some(list) => { _ = self @@ -165,6 +165,7 @@ impl Monitor { eprintln!("Unknown issues object {}", path); } } + Ok(()) } /// Turns the D-Bus path into an issues list ID. diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index ef94d827eb..3143b4fc1f 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -66,7 +66,9 @@ mod tests { let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); - _ = issues.cast(message::Update::new("my-service", vec![issue])); + _ = issues + .cast(message::Update::new("my-service", vec![issue])) + .unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert_eq!(issues_list.len(), 1); @@ -91,7 +93,7 @@ mod tests { assert!(issues_list.is_empty()); let update = message::Update::new("my-service", vec![issue]).notify(false); - _ = issues.cast(update); + _ = issues.cast(update).unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert_eq!(issues_list.len(), 1); From c2b330ef15d68239522bbf9d563c885a32c87fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 12:32:27 +0100 Subject: [PATCH 150/917] l10n does not send a proposal if there are issues --- rust/agama-l10n/src/message.rs | 2 +- rust/agama-l10n/src/service.rs | 18 +++++++++++++++--- rust/agama-l10n/src/start.rs | 5 ++++- rust/agama-server/src/supervisor/proposal.rs | 3 ++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 464b3450e4..532d91c29d 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -73,7 +73,7 @@ impl SetConfig { pub struct GetProposal; impl Message for GetProposal { - type Reply = Proposal; + type Reply = Option; } pub struct Install; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 094b711e91..434fd0dd1d 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -76,6 +76,7 @@ pub struct Service { struct State { system: SystemInfo, config: ExtendedConfig, + proposal: Option, } impl Service { @@ -86,7 +87,11 @@ impl Service { ) -> Service { let system = SystemInfo::read_from(&model); let config = ExtendedConfig::new_from(&system); - let state = State { system, config }; + let state = State { + system, + config, + proposal: None, + }; Self { state, @@ -183,6 +188,13 @@ impl MessageHandler> for Service { self.state.config = merged; let issues = self.find_issues(); + + self.state.proposal = if issues.is_empty() { + None + } else { + Some((&self.state.config).into()) + }; + _ = self .issues .cast(issue::message::Update::new("localization", issues)); @@ -193,8 +205,8 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetProposal) -> Result { - Ok((&self.state.config).into()) + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + Ok(self.state.proposal.clone()) } } diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 7c1cc09f92..fcc9c402c1 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -251,7 +251,10 @@ mod tests { let message = message::SetConfig::new(input_config.clone()); handler.call(message).await?; - let proposal = handler.call(message::GetProposal).await?; + let proposal = handler + .call(message::GetProposal) + .await? + .expect("Could not get the proposal"); assert_eq!(proposal.locale.to_string(), input_config.locale.unwrap()); assert_eq!(proposal.keymap.to_string(), input_config.keymap.unwrap()); assert_eq!( diff --git a/rust/agama-server/src/supervisor/proposal.rs b/rust/agama-server/src/supervisor/proposal.rs index f916cbe198..8cd9b9df21 100644 --- a/rust/agama-server/src/supervisor/proposal.rs +++ b/rust/agama-server/src/supervisor/proposal.rs @@ -23,5 +23,6 @@ use serde::Serialize; #[derive(Clone, Debug, Serialize)] pub struct Proposal { - pub localization: l10n::Proposal, + #[serde(skip_serializing_if = "Option::is_none")] + pub localization: Option, } From 22e86f94a69c3c7fe49a4cce73b82b5d554e2980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 12:43:00 +0100 Subject: [PATCH 151/917] Fix l10n proposal --- rust/agama-l10n/src/service.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 434fd0dd1d..266f1add89 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -190,9 +190,9 @@ impl MessageHandler> for Service { let issues = self.find_issues(); self.state.proposal = if issues.is_empty() { - None - } else { Some((&self.state.config).into()) + } else { + None }; _ = self From 22f1331ff0b83c7c8c529250949b9ea96567927b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 12:56:13 +0100 Subject: [PATCH 152/917] Commit l10n proposal instead of the configuration --- rust/agama-l10n/src/model.rs | 12 ++++++------ rust/agama-l10n/src/service.rs | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/rust/agama-l10n/src/model.rs b/rust/agama-l10n/src/model.rs index 50f0eafcca..a453c8ab91 100644 --- a/rust/agama-l10n/src/model.rs +++ b/rust/agama-l10n/src/model.rs @@ -67,9 +67,9 @@ pub trait ModelAdapter: Send + 'static { /// at the end of the installation. fn install( &self, - _locale: LocaleId, - _keymap: KeymapId, - _timezone: TimezoneId, + _locale: &LocaleId, + _keymap: &KeymapId, + _timezone: &TimezoneId, ) -> Result<(), service::Error> { Ok(()) } @@ -172,9 +172,9 @@ impl ModelAdapter for Model { fn install( &self, - locale: LocaleId, - keymap: KeymapId, - timezone: TimezoneId, + locale: &LocaleId, + keymap: &KeymapId, + timezone: &TimezoneId, ) -> Result<(), service::Error> { const ROOT: &str = "/mnt"; const VCONSOLE_CONF: &str = "/etc/vconsole.conf"; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 266f1add89..11122cc091 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -56,6 +56,8 @@ pub enum Error { IO(#[from] std::io::Error), #[error(transparent)] Generic(#[from] anyhow::Error), + #[error("There is no proposal for localization")] + MissingProposal, } /// Localization service. @@ -213,9 +215,12 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { - let proposal: Proposal = (&self.state.config).into(); + let Some(proposal) = &self.state.proposal else { + return Err(Error::MissingProposal); + }; + self.model - .install(proposal.locale, proposal.keymap, proposal.timezone) + .install(&proposal.locale, &proposal.keymap, &proposal.timezone) .unwrap(); Ok(()) } From 69b0ee34ae89a0b729635e37618d95eb581bf61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 13:05:59 +0100 Subject: [PATCH 153/917] Do not use unwrap on l10n install --- rust/agama-l10n/src/service.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 11122cc091..6a912db663 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -220,8 +220,7 @@ impl MessageHandler for Service { }; self.model - .install(&proposal.locale, &proposal.keymap, &proposal.timezone) - .unwrap(); + .install(&proposal.locale, &proposal.keymap, &proposal.timezone)?; Ok(()) } } From 39eb1a07a805070c08f7c6b93607b2b311423603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 8 Oct 2025 13:29:25 +0100 Subject: [PATCH 154/917] Fix l10n service tests --- rust/agama-l10n/src/model/locale.rs | 2 +- rust/agama-l10n/src/service.rs | 3 ++- rust/agama-l10n/src/start.rs | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/rust/agama-l10n/src/model/locale.rs b/rust/agama-l10n/src/model/locale.rs index 86ebf154a9..62bd04b52b 100644 --- a/rust/agama-l10n/src/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -59,7 +59,7 @@ impl LocalesDatabase { #[cfg(test)] pub fn with_entries(data: &[LocaleEntry]) -> Self { Self { - known_locales: vec![], + known_locales: data.iter().map(|l| l.id.clone()).collect(), locales: data.to_vec(), } } diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 6a912db663..7f4a7a9b62 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -89,10 +89,11 @@ impl Service { ) -> Service { let system = SystemInfo::read_from(&model); let config = ExtendedConfig::new_from(&system); + let proposal = (&config).into(); let state = State { system, config, - proposal: None, + proposal: Some(proposal), }; Self { diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index fcc9c402c1..0254363ac0 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -172,6 +172,9 @@ mod tests { let updated = handler.call(message::GetConfig).await?; assert_eq!(&updated, &input_config); + let proposal = handler.call(message::GetProposal).await?; + assert!(proposal.is_some()); + let event = events_rx.recv().await.expect("Did not receive the event"); assert!(matches!(event, Event::ProposalChanged)); Ok(()) @@ -226,6 +229,9 @@ mod tests { let found_issues = issues.call(issue::message::Get).await?; let l10n_issues = found_issues.get("localization").unwrap(); assert_eq!(l10n_issues.len(), 3); + + let proposal = handler.call(message::GetProposal).await?; + assert!(proposal.is_none()); Ok(()) } From b56780f22aa248574d9e2adec6b6e522fdff70c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 16:08:38 +0100 Subject: [PATCH 155/917] Move supervisor to agama-manager project --- rust/Cargo.lock | 22 +++++++++++++++++ rust/Cargo.toml | 1 + rust/agama-lib/src/http.rs | 2 +- rust/agama-lib/src/http/event.rs | 4 ++++ rust/agama-manager/Cargo.toml | 24 +++++++++++++++++++ .../src/lib.rs} | 0 .../src}/listener.rs | 15 ++++++------ .../src}/message.rs | 5 ++-- .../src}/proposal.rs | 2 +- .../supervisor => agama-manager/src}/scope.rs | 2 +- .../src}/service.rs | 2 +- .../supervisor => agama-manager/src}/start.rs | 16 ++++++------- .../src}/system_info.rs | 2 +- rust/agama-server/src/lib.rs | 1 - 14 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 rust/agama-manager/Cargo.toml rename rust/{agama-server/src/supervisor.rs => agama-manager/src/lib.rs} (100%) rename rust/{agama-server/src/supervisor => agama-manager/src}/listener.rs (84%) rename rust/{agama-server/src/supervisor => agama-manager/src}/message.rs (99%) rename rust/{agama-server/src/supervisor => agama-manager/src}/proposal.rs (97%) rename rust/{agama-server/src/supervisor => agama-manager/src}/scope.rs (97%) rename rust/{agama-server/src/supervisor => agama-manager/src}/service.rs (99%) rename rust/{agama-server/src/supervisor => agama-manager/src}/start.rs (94%) rename rust/{agama-server/src/supervisor => agama-manager/src}/system_info.rs (97%) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d72527e9bd..5bf047f2d8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -129,6 +129,27 @@ dependencies = [ "utoipa", ] +[[package]] +name = "agama-manager" +version = "0.1.0" +dependencies = [ + "agama-l10n", + "agama-lib", + "agama-utils", + "async-trait", + "merge-struct", + "serde", + "serde_json", + "strum", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "utoipa", + "zbus", +] + [[package]] name = "agama-network" version = "0.1.0" @@ -161,6 +182,7 @@ dependencies = [ "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", "agama-utils", "anyhow", "async-trait", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b16499414d..fc41a8f3f0 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", "agama-network", "agama-server", "agama-utils", diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index b4ea8cbb2b..37b5a23721 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -21,7 +21,7 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; -mod event; +pub mod event; pub use event::{Event, EventPayload}; mod websocket; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 72df6b2f93..b605799aba 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -38,6 +38,10 @@ use agama_l10n as l10n; use agama_utils::{issue, progress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use tokio::sync::broadcast; + +pub type Sender = broadcast::Sender; +pub type Receiver = broadcast::Receiver; /// Agama event. /// diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml new file mode 100644 index 0000000000..4fec4ce553 --- /dev/null +++ b/rust/agama-manager/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agama-manager" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } +agama-l10n = { path = "../agama-l10n" } +thiserror = "2.0.12" +serde = { version = "1.0.210", features = ["derive"] } +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" +async-trait = "0.1.83" +serde_json = "1.0.128" +utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } +strum = { version = "0.27.2", features = ["derive"] } +tracing = "0.1.40" +zbus = { version = "5", default-features = false, features = ["tokio"] } +merge-struct = "0.1.0" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/rust/agama-server/src/supervisor.rs b/rust/agama-manager/src/lib.rs similarity index 100% rename from rust/agama-server/src/supervisor.rs rename to rust/agama-manager/src/lib.rs diff --git a/rust/agama-server/src/supervisor/listener.rs b/rust/agama-manager/src/listener.rs similarity index 84% rename from rust/agama-server/src/supervisor/listener.rs rename to rust/agama-manager/src/listener.rs index fbdbac88de..4615562e3f 100644 --- a/rust/agama-server/src/supervisor/listener.rs +++ b/rust/agama-manager/src/listener.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::web::EventsSender; -use agama_lib::http::{Event, EventPayload}; +use agama_lib::http; use std::pin::Pin; use tokio::sync::mpsc; use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamMap}; @@ -30,12 +29,12 @@ use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamM /// `agama_l10n::Event`) and has to be converted to the [Event /// struct](agama_lib::http::Event). pub struct EventsListener { - inner: StreamMap<&'static str, Pin + Send>>>, - sender: EventsSender, + inner: StreamMap<&'static str, Pin + Send>>>, + sender: http::event::Sender, } impl EventsListener { - pub fn new(sender: EventsSender) -> Self { + pub fn new(sender: http::event::Sender) -> Self { EventsListener { inner: StreamMap::new(), sender, @@ -47,10 +46,10 @@ impl EventsListener { name: &'static str, channel: mpsc::UnboundedReceiver, ) where - EventPayload: From, + http::EventPayload: From, { - let stream = - UnboundedReceiverStream::new(channel).map(|e| Event::new(EventPayload::from(e))); + let stream = UnboundedReceiverStream::new(channel) + .map(|e| http::Event::new(http::EventPayload::from(e))); self.inner.insert(name, Box::pin(stream)); } diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-manager/src/message.rs similarity index 99% rename from rust/agama-server/src/supervisor/message.rs rename to rust/agama-manager/src/message.rs index 040b05a5fd..8a3987f10f 100644 --- a/rust/agama-server/src/supervisor/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,14 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::collections::HashMap; - -use crate::supervisor::{ +use crate::{ l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, service, system_info::SystemInfo, }; use agama_lib::{install_settings::InstallSettings, issue::Issue}; use agama_utils::{actor::Message, progress::Progress}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Gets the installation status. pub struct GetStatus; diff --git a/rust/agama-server/src/supervisor/proposal.rs b/rust/agama-manager/src/proposal.rs similarity index 97% rename from rust/agama-server/src/supervisor/proposal.rs rename to rust/agama-manager/src/proposal.rs index 8cd9b9df21..2a2d5a9ba1 100644 --- a/rust/agama-server/src/supervisor/proposal.rs +++ b/rust/agama-manager/src/proposal.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::Serialize; #[derive(Clone, Debug, Serialize)] diff --git a/rust/agama-server/src/supervisor/scope.rs b/rust/agama-manager/src/scope.rs similarity index 97% rename from rust/agama-server/src/supervisor/scope.rs rename to rust/agama-manager/src/scope.rs index cf3d304da8..4374d5d75d 100644 --- a/rust/agama-server/src/supervisor/scope.rs +++ b/rust/agama-manager/src/scope.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::{Deserialize, Serialize}; #[derive( diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-manager/src/service.rs similarity index 99% rename from rust/agama-server/src/supervisor/service.rs rename to rust/agama-manager/src/service.rs index 78381b7d4a..0afa4bf0ae 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::{ +use crate::{ l10n, message::{self, Action}, proposal::Proposal, diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-manager/src/start.rs similarity index 94% rename from rust/agama-server/src/supervisor/start.rs rename to rust/agama-manager/src/start.rs index 16b8d8d77f..76085907b8 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-manager/src/start.rs @@ -19,13 +19,11 @@ // find current contact information at www.suse.com. use crate::{ - supervisor::{ - l10n, - listener::{self, EventsListener}, - service::Service, - }, - web::EventsSender, + l10n, + listener::{self, EventsListener}, + service::Service, }; +use agama_lib::http; use agama_utils::{ actor::{self, Handler}, issue, progress, @@ -42,11 +40,11 @@ pub enum Error { Issues(#[from] issue::start::Error), } -/// Starts the supervisor service. +/// Starts the manager service. /// /// It starts two Tokio tasks: /// -/// * The main service, called "Supervisor", which coordinates the rest of services +/// * The main service, called "Manager", which coordinates the rest of services /// an entry point for the HTTP API. /// * An events listener which retransmit the events from all the services. /// @@ -56,7 +54,7 @@ pub enum Error { /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( - events: EventsSender, + events: http::event::Sender, dbus: Option, ) -> Result, Error> { let mut listener = EventsListener::new(events); diff --git a/rust/agama-server/src/supervisor/system_info.rs b/rust/agama-manager/src/system_info.rs similarity index 97% rename from rust/agama-server/src/supervisor/system_info.rs rename to rust/agama-manager/src/system_info.rs index 3bed029ed3..c2e9cc6ac5 100644 --- a/rust/agama-server/src/supervisor/system_info.rs +++ b/rust/agama-manager/src/system_info.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::Serialize; #[derive(Clone, Debug, Serialize)] diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 3ae7ee4c52..26206ffc7b 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -37,4 +37,3 @@ pub mod users; pub mod web; pub use web::service; pub mod server; -pub(crate) mod supervisor; From 0d38fbf7b1c26525ad38900dc9258c7b45dfb62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 16:10:05 +0100 Subject: [PATCH 156/917] Adapt server to use manager --- rust/agama-manager/Cargo.toml | 3 ++ rust/agama-manager/src/start.rs | 8 ++-- rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/server/web.rs | 49 ++++++++++-------------- rust/agama-server/src/web/docs/config.rs | 6 +-- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 4fec4ce553..4273679aa5 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -22,3 +22,6 @@ merge-struct = "0.1.0" [dev-dependencies] tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 76085907b8..a22af33c28 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -81,14 +81,14 @@ pub async fn start( #[cfg(test)] mod test { - use crate::supervisor::{self, l10n, message, service::Service}; - use agama_lib::{http::Event, install_settings::InstallSettings}; + use crate::{self as manager, l10n, message, service::Service}; + use agama_lib::{http, install_settings::InstallSettings}; use agama_utils::actor::Handler; use tokio::sync::broadcast; async fn start_service() -> Handler { - let (events_tx, _events_rx) = broadcast::channel::(16); - supervisor::start(events_tx, None).await.unwrap() + let (events_sender, _events_receiver) = broadcast::channel::(16); + manager::start(events_sender, None).await.unwrap() } #[tokio::test] diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 52136c2167..8836150002 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -12,6 +12,7 @@ agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-manager = { path = "../agama-manager" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 8dc8814cd9..ee7554644b 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,11 +20,8 @@ //! This module implements Agama's HTTP API. -use crate::{ - supervisor::{self, message, ConfigScope, Scope, Service, SystemInfo}, - web::EventsSender, -}; -use agama_lib::{error::ServiceError, install_settings::InstallSettings}; +use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; +use agama_manager::{self as manager, message, ConfigScope, Scope, SystemInfo}; use agama_utils::actor::Handler; use anyhow; use axum::{ @@ -44,7 +41,7 @@ pub enum Error { #[error("The given configuration does not belong to the '{0}' scope.")] Scope(Scope), #[error(transparent)] - Supervisor(#[from] supervisor::service::Error), + Manager(#[from] manager::service::Error), } impl IntoResponse for Error { @@ -66,7 +63,7 @@ fn to_option_response(value: Option) -> Response { #[derive(Clone)] pub struct ServerState { - supervisor: Handler, + manager: Handler, } type ServerResult = Result; @@ -77,14 +74,14 @@ type ServerResult = Result; /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( - events: EventsSender, + events: http::event::Sender, dbus: Option, ) -> Result { - let supervisor = supervisor::start(events, dbus) + let manager = manager::start(events, dbus) .await .map_err(|e| anyhow::Error::new(e))?; - let state = ServerState { supervisor }; + let state = ServerState { manager }; Ok(Router::new() .route("/status", get(get_status)) @@ -118,7 +115,7 @@ pub async fn server_service( ) )] async fn get_status(State(state): State) -> ServerResult> { - let status = state.supervisor.call(message::GetStatus).await?; + let status = state.manager.call(message::GetStatus).await?; Ok(Json(status)) } @@ -133,7 +130,7 @@ async fn get_status(State(state): State) -> ServerResult) -> ServerResult> { - let system = state.supervisor.call(message::GetSystem).await?; + let system = state.manager.call(message::GetSystem).await?; Ok(Json(system)) } @@ -150,7 +147,7 @@ async fn get_system(State(state): State) -> ServerResult, ) -> ServerResult> { - let config = state.supervisor.call(message::GetExtendedConfig).await?; + let config = state.manager.call(message::GetExtendedConfig).await?; Ok(Json(config)) } @@ -172,7 +169,7 @@ async fn get_extended_config_scope( Path(scope): Path, ) -> ServerResult { let config = state - .supervisor + .manager .call(message::GetExtendedConfigScope::new(scope)) .await?; Ok(to_option_response(config)) @@ -189,7 +186,7 @@ async fn get_extended_config_scope( ) )] async fn get_config(State(state): State) -> ServerResult> { - let config = state.supervisor.call(message::GetConfig).await?; + let config = state.manager.call(message::GetConfig).await?; Ok(Json(config)) } @@ -211,7 +208,7 @@ async fn get_config_scope( Path(scope): Path, ) -> ServerResult { let config = state - .supervisor + .manager .call(message::GetConfigScope::new(scope)) .await?; Ok(to_option_response(config)) @@ -236,10 +233,7 @@ async fn put_config( State(state): State, Json(config): Json, ) -> ServerResult<()> { - state - .supervisor - .call(message::SetConfig::new(config)) - .await?; + state.manager.call(message::SetConfig::new(config)).await?; Ok(()) } @@ -263,7 +257,7 @@ async fn patch_config( Json(config): Json, ) -> ServerResult<()> { state - .supervisor + .manager .call(message::UpdateConfig::new(config)) .await?; Ok(()) @@ -295,7 +289,7 @@ async fn put_config_scope( } state - .supervisor + .manager .call(message::SetConfigScope::new(config_scope)) .await?; Ok(()) @@ -327,7 +321,7 @@ async fn patch_config_scope( } state - .supervisor + .manager .call(message::UpdateConfigScope::new(config_scope)) .await?; Ok(()) @@ -344,7 +338,7 @@ async fn patch_config_scope( ) )] async fn get_proposal(State(state): State) -> ServerResult { - let proposal = state.supervisor.call(message::GetProposal).await?; + let proposal = state.manager.call(message::GetProposal).await?; Ok(to_option_response(proposal)) } @@ -359,7 +353,7 @@ async fn get_proposal(State(state): State) -> ServerResult) -> ServerResult> { - let issues = state.supervisor.call(message::GetIssues).await?; + let issues = state.manager.call(message::GetIssues).await?; let issues_map: IssuesMap = issues.into(); Ok(Json(issues_map)) } @@ -380,9 +374,6 @@ async fn run_action( State(state): State, Json(action): Json, ) -> ServerResult<()> { - state - .supervisor - .call(message::RunAction::new(action)) - .await?; + state.manager.call(message::RunAction::new(action)).await?; Ok(()) } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 0389e968e9..ff6eeefd7c 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -164,9 +164,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .build() } From 8ecf67294b37ff81d94726d8f610cf6600368eb0 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 9 Oct 2025 09:00:04 +0200 Subject: [PATCH 157/917] fix merge relicts --- rust/agama-server/src/error.rs | 8 ++++---- rust/agama-server/src/lib.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 1787a3346b..2f2f38df97 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -26,8 +26,10 @@ use axum::{ }; use serde_json::json; -use crate::{users::password::PasswordCheckerError, web::common::ProgressServiceError, - software_ng::SoftwareServiceError} +use crate::{ + software_ng::SoftwareServiceError, users::password::PasswordCheckerError, + web::common::ProgressServiceError, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -41,8 +43,6 @@ pub enum Error { Questions(QuestionsError), #[error("Software service error: {0}")] SoftwareServiceError(#[from] SoftwareServiceError), - #[error("Issues service error: {0}")] - Issues(#[from] IssuesServiceError), #[error("Progress service error: {0}")] Progress(#[from] ProgressServiceError), #[error("Could not check the password")] diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index d4b4694d27..e45d241b15 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -37,6 +37,6 @@ pub mod storage; pub mod users; pub mod web; pub use web::service; -pub mod software_ng; pub mod server; +pub mod software_ng; pub(crate) mod supervisor; From 560165cfa318b258bc21521525b76895d60211f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 08:54:10 +0100 Subject: [PATCH 158/917] Remove reduntant types --- rust/agama-server/src/network/web.rs | 6 ++--- rust/agama-server/src/software/web.rs | 11 ++++----- rust/agama-server/src/web.rs | 14 ++++++----- rust/agama-server/src/web/common/progress.rs | 7 +++--- rust/agama-server/src/web/event.rs | 25 -------------------- rust/agama-server/src/web/service.rs | 8 +++---- rust/agama-server/src/web/state.rs | 5 ++-- rust/agama-server/src/web/ws.rs | 13 ++++++---- 8 files changed, 33 insertions(+), 56 deletions(-) delete mode 100644 rust/agama-server/src/web/event.rs diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 37303ecd22..18783c895e 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -20,7 +20,7 @@ //! This module implements the web API for the network module. -use crate::{error::Error, web::EventsSender}; +use crate::error::Error; use anyhow::Context; use axum::{ extract::{Path, State}, @@ -33,7 +33,7 @@ use uuid::Uuid; use agama_lib::{ error::ServiceError, - event, + event, http, network::{ error::NetworkStateError, model::{AccessPoint, Connection, Device, GeneralState}, @@ -85,7 +85,7 @@ struct NetworkServiceState { /// * `events`: sending-half of the broadcast channel. pub async fn network_service( adapter: T, - events: EventsSender, + events: http::event::Sender, ) -> Result { let network = NetworkSystem::new(adapter); // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 846154e242..7ca1a8320f 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -27,16 +27,13 @@ use crate::{ error::Error, - web::{ - common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, - EventsReceiver, - }, + web::common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, }; use agama_lib::{ error::ServiceError, event, - http::{Event, EventPayload}, + http::{self, Event, EventPayload}, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ @@ -221,7 +218,7 @@ fn reason_to_selected_by( /// * `events`: channel to listen for events. /// * `products`: list of products (shared behind a mutex). pub async fn receive_events( - mut events: EventsReceiver, + mut events: http::event::Receiver, products: Arc>>, config: Arc>>, client: ProductClient<'_>, @@ -265,7 +262,7 @@ pub async fn receive_events( /// Sets up and returns the axum service for the software module. pub async fn software_service( dbus: zbus::Connection, - events: EventsReceiver, + events: http::event::Receiver, progress: ProgressClient, ) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 712e6f09e7..63b0dd7dcc 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -47,16 +47,18 @@ mod auth; pub mod common; mod config; pub mod docs; -mod event; mod http; mod service; mod state; mod ws; -use agama_lib::{connection, error::ServiceError, http::Event}; +use agama_lib::{ + connection, + error::ServiceError, + http::event::{self, Event}, +}; use common::ProgressService; pub use config::ServiceConfig; -pub use event::{EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; use std::path::Path; use tokio_stream::{StreamExt, StreamMap}; @@ -69,7 +71,7 @@ use tokio_stream::{StreamExt, StreamMap}; /// * `web_ui_dir`: public directory containing the web UI. pub async fn service

    ( config: ServiceConfig, - events: EventsSender, + events: event::Sender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result @@ -116,7 +118,7 @@ where /// The events are sent to the `events` channel. /// /// * `events`: channel to send the events to. -pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { +pub async fn run_monitor(events: event::Sender) -> Result<(), ServiceError> { let connection = connection().await?; tokio::spawn(run_events_monitor(connection, events.clone())); @@ -127,7 +129,7 @@ pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. -async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { +async fn run_events_monitor(dbus: zbus::Connection, events: event::Sender) -> Result<(), Error> { let mut stream = StreamMap::new(); stream.insert("manager", manager_stream(dbus.clone()).await?); diff --git a/rust/agama-server/src/web/common/progress.rs b/rust/agama-server/src/web/common/progress.rs index 8fa3feaa3a..6eebeba5e6 100644 --- a/rust/agama-server/src/web/common/progress.rs +++ b/rust/agama-server/src/web/common/progress.rs @@ -35,10 +35,9 @@ //! //! At this point, it only handles the progress that are exposed through D-Bus. -use crate::web::EventsSender; use agama_lib::{ event, - http::Event, + http::{self, Event}, progress::{Progress, ProgressSequence}, proxies::{ProgressChanged, ProgressProxy}, }; @@ -77,7 +76,7 @@ pub enum ProgressCommand { pub struct ProgressService { cache: HashMap, commands: mpsc::Receiver, - events: EventsSender, + events: http::event::Sender, dbus: zbus::Connection, } @@ -88,7 +87,7 @@ impl ProgressService { /// /// * Commands from a client ([ProgressClient]). /// * Relevant events from D-Bus. - pub async fn start(dbus: zbus::Connection, events: EventsSender) -> ProgressClient { + pub async fn start(dbus: zbus::Connection, events: http::event::Sender) -> ProgressClient { let (tx, rx) = mpsc::channel(4); let mut service = ProgressService { cache: HashMap::new(), diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs deleted file mode 100644 index beb7611e33..0000000000 --- a/rust/agama-server/src/web/event.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_lib::http::Event; -use tokio::sync::broadcast::{Receiver, Sender}; - -pub type EventsSender = Sender; -pub type EventsReceiver = Receiver; diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index a6fe10d0eb..e188451d5a 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use super::http::{login, login_from_query, logout, session}; -use super::{config::ServiceConfig, state::ServiceState, EventsSender}; -use agama_lib::auth::TokenClaims; +use super::{config::ServiceConfig, state::ServiceState}; +use agama_lib::{auth::TokenClaims, http}; use axum::http::HeaderValue; use axum::middleware::Next; use axum::{ @@ -55,7 +55,7 @@ use tracing::Span; /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, - events: EventsSender, + events: http::event::Sender, api_router: Router, public_dir: PathBuf, } @@ -65,7 +65,7 @@ impl MainServiceBuilder { /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. - pub fn new

    (events: EventsSender, public_dir: P) -> Self + pub fn new

    (events: http::event::Sender, public_dir: P) -> Self where P: AsRef, { diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index 4ef889e6c3..d207e9923d 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -20,7 +20,8 @@ //! Implements the web service state. -use super::{config::ServiceConfig, EventsSender}; +use super::config::ServiceConfig; +use agama_lib::http; use std::path::PathBuf; /// Web service state. @@ -29,6 +30,6 @@ use std::path::PathBuf; #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, - pub events: EventsSender, + pub events: http::event::Sender, pub public_dir: PathBuf, } diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index c42bc0caff..414a9f6c6e 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -20,10 +20,8 @@ //! Implements the websocket handling. -use std::sync::Arc; - -use super::{state::ServiceState, EventsSender}; -use agama_lib::auth::ClientId; +use super::state::ServiceState; +use agama_lib::{auth::ClientId, http}; use axum::{ extract::{ ws::{Message, WebSocket}, @@ -32,6 +30,7 @@ use axum::{ response::IntoResponse, Extension, }; +use std::sync::Arc; pub async fn ws_handler( State(state): State, @@ -41,7 +40,11 @@ pub async fn ws_handler( ws.on_upgrade(move |socket| handle_socket(socket, state.events, client_id)) } -async fn handle_socket(mut socket: WebSocket, events: EventsSender, client_id: Arc) { +async fn handle_socket( + mut socket: WebSocket, + events: http::event::Sender, + client_id: Arc, +) { let mut rx = events.subscribe(); let conn_event = agama_lib::event!(ClientConnected, client_id.as_ref()); From c208f170449501bd7934f132c6ab9ca3cb734d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:23:27 +0100 Subject: [PATCH 159/917] Remove unused deps --- rust/Cargo.lock | 2 -- rust/agama-server/Cargo.toml | 2 -- 2 files changed, 4 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5bf047f2d8..cbd331c534 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -181,7 +181,6 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-lib", - "agama-locale-data", "agama-manager", "agama-utils", "anyhow", @@ -197,7 +196,6 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "libsystemd", - "merge-struct", "openssl", "pam", "pin-project", diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 8836150002..59f4a5bc7a 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -8,7 +8,6 @@ rust-version.workspace = true [dependencies] anyhow = "1.0" -agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } @@ -58,7 +57,6 @@ gethostname = "1.0.0" tokio-util = "0.7.12" tempfile = "3.13.0" url = "2.5.2" -merge-struct = "0.1.0" strum = { version = "0.27.2", features = ["derive"] } [[bin]] From 36ed02e3a8dc618d805d2dc5ba8d8dbabe9b60b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:25:37 +0100 Subject: [PATCH 160/917] Changelog --- rust/package/agama.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 5d3cdf67e6..bf66b36e3d 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Oct 9 10:24:03 UTC 2025 - José Iván López González + +- Extract supervisor to agama-manager package (gh#agama-project/agama#2793). + ------------------------------------------------------------------- Wed Oct 8 06:48:47 UTC 2025 - Imobach Gonzalez Sosa From 6a33cec17133942d366d0f0700d9794336141f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:59:15 +0100 Subject: [PATCH 161/917] Remove scope from HTTP API --- rust/agama-manager/src/lib.rs | 3 - rust/agama-manager/src/message.rs | 68 +----------- rust/agama-manager/src/scope.rs | 45 -------- rust/agama-manager/src/service.rs | 75 -------------- rust/agama-server/src/server/web.rs | 125 +---------------------- rust/agama-server/src/web/docs/config.rs | 4 - 6 files changed, 3 insertions(+), 317 deletions(-) delete mode 100644 rust/agama-manager/src/scope.rs diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 138b36ab20..bc47c3a8d1 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -24,9 +24,6 @@ pub use start::start; pub mod service; pub use service::Service; -mod scope; -pub use scope::{ConfigScope, Scope}; - mod system_info; pub use system_info::SystemInfo; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 8a3987f10f..3571189ebf 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,9 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, service, system_info::SystemInfo, -}; +use crate::{l10n, proposal::Proposal, service, system_info::SystemInfo}; use agama_lib::{install_settings::InstallSettings, issue::Issue}; use agama_utils::{actor::Message, progress::Progress}; use serde::{Deserialize, Serialize}; @@ -61,22 +59,6 @@ impl Message for GetExtendedConfig { type Reply = InstallSettings; } -/// Gets a scope from the full config. -#[derive(Debug)] -pub struct GetExtendedConfigScope { - pub scope: Scope, -} - -impl GetExtendedConfigScope { - pub fn new(scope: Scope) -> Self { - Self { scope } - } -} - -impl Message for GetExtendedConfigScope { - type Reply = Option; -} - /// Gets the current config set by the user. #[derive(Debug)] pub struct GetConfig; @@ -117,54 +99,6 @@ impl Message for UpdateConfig { type Reply = (); } -/// Gets a scope from the config. -#[derive(Debug)] -pub struct GetConfigScope { - pub scope: Scope, -} - -impl GetConfigScope { - pub fn new(scope: Scope) -> Self { - Self { scope } - } -} - -impl Message for GetConfigScope { - type Reply = Option; -} - -/// Sets a config scope -#[derive(Debug)] -pub struct SetConfigScope { - pub config: ConfigScope, -} - -impl SetConfigScope { - pub fn new(config: ConfigScope) -> Self { - Self { config } - } -} - -impl Message for SetConfigScope { - type Reply = (); -} - -/// Updates a config scope -#[derive(Debug)] -pub struct UpdateConfigScope { - pub config: ConfigScope, -} - -impl UpdateConfigScope { - pub fn new(config: ConfigScope) -> Self { - Self { config } - } -} - -impl Message for UpdateConfigScope { - type Reply = (); -} - /// Gets the proposal. #[derive(Debug)] pub struct GetProposal; diff --git a/rust/agama-manager/src/scope.rs b/rust/agama-manager/src/scope.rs deleted file mode 100644 index 4374d5d75d..0000000000 --- a/rust/agama-manager/src/scope.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::l10n; -use serde::{Deserialize, Serialize}; - -#[derive( - Copy, Clone, Debug, strum::EnumString, strum::Display, Deserialize, PartialEq, utoipa::ToSchema, -)] -#[strum(serialize_all = "snake_case")] -#[serde(rename_all = "snake_case")] -pub enum Scope { - L10n, -} - -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum ConfigScope { - L10n(l10n::Config), -} - -impl ConfigScope { - pub fn to_scope(&self) -> Scope { - match &self { - Self::L10n(_) => Scope::L10n, - } - } -} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 0afa4bf0ae..d5b1fd85cd 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -22,7 +22,6 @@ use crate::{ l10n, message::{self, Action}, proposal::Proposal, - scope::{ConfigScope, Scope}, system_info::SystemInfo, }; use agama_lib::install_settings::InstallSettings; @@ -147,23 +146,6 @@ impl MessageHandler for Service { } } -#[async_trait] -impl MessageHandler for Service { - /// It returns the configuration for the given scope. - async fn handle( - &mut self, - message: message::GetExtendedConfigScope, - ) -> Result, Error> { - let option = match message.scope { - Scope::L10n => { - let l10n_config = self.l10n.call(l10n::message::GetConfig).await?; - Some(ConfigScope::L10n(l10n_config)) - } - }; - Ok(option) - } -} - #[async_trait] impl MessageHandler for Service { /// Gets the current configuration set by the user. @@ -205,63 +187,6 @@ impl MessageHandler for Service { } } -#[async_trait] -impl MessageHandler for Service { - /// It returns the configuration set by the user for the given scope. - async fn handle( - &mut self, - message: message::GetConfigScope, - ) -> Result, Error> { - // FIXME: implement this logic at InstallSettings level: self.get_config().by_scope(...) - // It would allow us to drop this method. - let option = match message.scope { - Scope::L10n => self - .config - .localization - .clone() - .map(|c| ConfigScope::L10n(c)), - }; - Ok(option) - } -} - -#[async_trait] -impl MessageHandler for Service { - /// Sets the user configuration within the given scope. - /// - /// It replaces the current configuration with the given one and calculates a - /// new proposal. Only the configuration in the given scope is affected. - async fn handle(&mut self, message: message::SetConfigScope) -> Result<(), Error> { - match message.config { - ConfigScope::L10n(l10n_config) => { - self.l10n - .call(l10n::message::SetConfig::new(l10n_config.clone())) - .await?; - self.config.localization = Some(l10n_config); - } - } - Ok(()) - } -} - -#[async_trait] -impl MessageHandler for Service { - /// Patches the user configuration within the given scope. - /// - /// It merges the current configuration with the given one. - async fn handle(&mut self, message: message::UpdateConfigScope) -> Result<(), Error> { - match message.config { - ConfigScope::L10n(l10n_config) => { - let base_config = self.config.localization.clone().unwrap_or_default(); - let config = merge(&base_config, &l10n_config).map_err(|_| Error::MergeConfig)?; - self.handle(message::SetConfigScope::new(ConfigScope::L10n(config))) - .await?; - } - } - Ok(()) - } -} - #[async_trait] impl MessageHandler for Service { /// It returns the current proposal, if any. diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index ee7554644b..b8f524fcb8 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -21,11 +21,11 @@ //! This module implements Agama's HTTP API. use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; -use agama_manager::{self as manager, message, ConfigScope, Scope, SystemInfo}; +use agama_manager::{self as manager, message, SystemInfo}; use agama_utils::actor::Handler; use anyhow; use axum::{ - extract::{Path, State}, + extract::State, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, @@ -38,8 +38,6 @@ use super::types::IssuesMap; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("The given configuration does not belong to the '{0}' scope.")] - Scope(Scope), #[error(transparent)] Manager(#[from] manager::service::Error), } @@ -86,14 +84,7 @@ pub async fn server_service( Ok(Router::new() .route("/status", get(get_status)) .route("/system", get(get_system)) - .route("/extended_config/:scope", get(get_extended_config_scope)) .route("/extended_config", get(get_extended_config)) - .route( - "/config/:scope", - get(get_config_scope) - .put(put_config_scope) - .patch(patch_config_scope), - ) .route( "/config", get(get_config).put(put_config).patch(patch_config), @@ -151,30 +142,6 @@ async fn get_extended_config( Ok(Json(config)) } -/// Returns the extended configuration for the given scope. -#[utoipa::path( - get, - path = "/extended_config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "Extended configuration for the given scope."), - (status = 400, description = "Not possible to retrieve the configuration scope.") - ), - params( - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") - ) -)] -async fn get_extended_config_scope( - State(state): State, - Path(scope): Path, -) -> ServerResult { - let config = state - .manager - .call(message::GetExtendedConfigScope::new(scope)) - .await?; - Ok(to_option_response(config)) -} - /// Returns the configuration. #[utoipa::path( get, @@ -190,30 +157,6 @@ async fn get_config(State(state): State) -> ServerResult, - Path(scope): Path, -) -> ServerResult { - let config = state - .manager - .call(message::GetConfigScope::new(scope)) - .await?; - Ok(to_option_response(config)) -} - /// Updates the configuration. /// /// Replaces the whole configuration. If some value is missing, it will be removed. @@ -263,70 +206,6 @@ async fn patch_config( Ok(()) } -/// Updates the configuration for the given scope. -/// -/// Replaces the whole scope. If some value is missing, it will be removed. -#[utoipa::path( - put, - path = "/config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "The configuration scope was replaced. Other operations can be running in background."), - (status = 400, description = "Not possible to replace the configuration scope.") - ), - params( - ("config" = InstallSettings, description = "Configuration scope to apply."), - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'localization', etc).") - ) -)] -async fn put_config_scope( - State(state): State, - Path(scope): Path, - Json(config_scope): Json, -) -> ServerResult<()> { - if config_scope.to_scope() != scope { - return Err(Error::Scope(scope)); - } - - state - .manager - .call(message::SetConfigScope::new(config_scope)) - .await?; - Ok(()) -} - -/// Patches the configuration for the given scope. -/// -/// It only chagnes the specified values, keeping the rest as they are. -#[utoipa::path( - patch, - path = "/config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "The configuration scope was patched. Other operations can be running in background."), - (status = 400, description = "Not possible to patch the configuration scope.") - ), - params( - ("config" = InstallSettings, description = "Changes in the configuration scope."), - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") - ) -)] -async fn patch_config_scope( - State(state): State, - Path(scope): Path, - Json(config_scope): Json, -) -> ServerResult<()> { - if config_scope.to_scope() != scope { - return Err(Error::Scope(scope)); - } - - state - .manager - .call(message::UpdateConfigScope::new(config_scope)) - .await?; - Ok(()) -} - /// Returns how the target system is configured (proposal). #[utoipa::path( get, diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ff6eeefd7c..bc2f21de4b 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -33,13 +33,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { PathsBuilder::new() .path_from::() .path_from::() - .path_from::() .path_from::() .path_from::() .path_from::() - .path_from::() - .path_from::() - .path_from::() .path_from::() .path_from::() .build() From 5337bd01404d0cc93bebd0337430256abbbc086e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:01:18 +0100 Subject: [PATCH 162/917] Add specific patch document for config --- rust/agama-server/src/server/types.rs | 15 ++++++++---- rust/agama-server/src/server/web.rs | 29 ++++++++++++----------- rust/agama-server/src/web/docs/config.rs | 2 ++ rust/agama-server/tests/server_service.rs | 13 +++++++--- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index a81982cb7b..639d232211 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -20,12 +20,12 @@ //! This module defines some ancillary types for the HTTP API. -use std::collections::HashMap; - +use agama_lib::install_settings::InstallSettings; use agama_utils::issue; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; -#[derive(Serialize, utoipa::ToSchema)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] /// Holds the installation issues for each scope. pub struct IssuesMap { /// iSCSI issues. @@ -60,3 +60,10 @@ impl From>> for IssuesMap { } } } + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +/// Patch for the config. +pub struct ConfigPatch { + /// Update for the current config. + pub update: Option, +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index b8f524fcb8..f1b98d4bfe 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,6 +20,7 @@ //! This module implements Agama's HTTP API. +use crate::server::types::{ConfigPatch, IssuesMap}; use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; use agama_manager::{self as manager, message, SystemInfo}; use agama_utils::actor::Handler; @@ -34,8 +35,6 @@ use hyper::StatusCode; use serde::Serialize; use serde_json::json; -use super::types::IssuesMap; - #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] @@ -52,13 +51,6 @@ impl IntoResponse for Error { } } -fn to_option_response(value: Option) -> Response { - match value { - Some(inner) => Json(inner).into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } -} - #[derive(Clone)] pub struct ServerState { manager: Handler, @@ -197,12 +189,14 @@ async fn put_config( )] async fn patch_config( State(state): State, - Json(config): Json, + Json(patch): Json, ) -> ServerResult<()> { - state - .manager - .call(message::UpdateConfig::new(config)) - .await?; + if let Some(config) = patch.update { + state + .manager + .call(message::UpdateConfig::new(config)) + .await?; + } Ok(()) } @@ -256,3 +250,10 @@ async fn run_action( state.manager.call(message::RunAction::new(action)).await?; Ok(()) } + +fn to_option_response(value: Option) -> Response { + match value { + Some(inner) => Json(inner).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index bc2f21de4b..b4d203057c 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -159,6 +159,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index b18bd806a9..bd6e62c416 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -150,13 +150,15 @@ async fn test_put_config() -> Result<(), Box> { #[test] #[cfg(not(ci))] async fn test_patch_config() -> Result<(), Box> { + use agama_server::server::types::ConfigPatch; + let localization = Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }; - let mut config = InstallSettings { + let config = InstallSettings { localization: Some(localization), ..Default::default() }; @@ -177,13 +179,18 @@ async fn test_patch_config() -> Result<(), Box> { keymap: Some("en".to_string()), timezone: None, }; - config.localization = Some(localization); + let patch = ConfigPatch { + update: Some(InstallSettings { + localization: Some(localization), + ..Default::default() + }), + }; let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") .method(Method::PATCH) - .body(serde_json::to_string(&config)?) + .body(serde_json::to_string(&patch)?) .unwrap(); let response = server_service.clone().oneshot(request).await.unwrap(); From 45851574888935a1e70be3cee5edddbf69f709b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:01:43 +0100 Subject: [PATCH 163/917] Adapt UI client --- web/src/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 56d01542e7..17050ebbbe 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -37,7 +37,7 @@ const fetchProposal = (): Promise => get("/api/v2/proposal"); /** * Updates configuration */ -const updateConfig = (config) => patch("/api/v2/config", config); +const updateConfig = (config) => patch("/api/v2/config", { update: config }); /** * Triggers an action */ From 4e379c5a702f7fd2f07e45b21152a54ccb60482c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:41:39 +0100 Subject: [PATCH 164/917] Update documentation --- doc/http_api.md | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/doc/http_api.md b/doc/http_api.md index 221ac8415a..a7ab4e0eff 100644 --- a/doc/http_api.md +++ b/doc/http_api.md @@ -20,18 +20,20 @@ The API is designed around 3 main concepts: *system*, *config* and *proposal*. The *config* contains elements that can modify the *system*, the *proposal* or both. For example, the *dasd* config changes the *system*, and the *storage* config changes the *proposal*. In other cases like *network*, the config can affect to both *system* and *proposal*. ~~~ +GET /status GET /system GET /extended_config -GET /extended_config/{scope} GET PUT PATCH /config -GET PUT PATCH /config/{scope} GET POST PATCH /questions GET /proposal -GET /status GET /issues POST /action ~~~ +### GET /status + +Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. + ### GET /system Returns a JSON with the info of the system (storage devices, network connections, current localization, etc). @@ -47,31 +49,43 @@ There is a distinction between *extended config* and *config*: For example, if only the *locale* was configured by the user, then the *config* has no *keymap* property. Nevertheless, the *extended config* would have a *keymap* with the value from the default *extended config*. -The scope can be indicated to retrieve only a part of the config, for example *GET /extended_config/l10n*. +### GET PUT /config -### GET PUT PATCH /config +Reads or replaces the *config*. In case of patching, the given config is merged into the current *extended config*. -Reads, replaces or modifies the explicitly set *config*. In case of patching, the given config is merged into the current *extended config*. +### PATCH /config -The scope can be indicated to manage only part of the config, for example *PUT /config/l10n*. +Applies changes in the *config*. There is an own patch document: -### POST /action +~~~json +{ + "update": { + "l10n": { + "keymap": "es" + } + } +} +~~~ -Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. +The given config from the *update* key is merged into current *extended config*. -### GET /status +The patch document could be extended in the future with more options, for example for resetting some parts of the config. -Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. +See https://datatracker.ietf.org/doc/html/rfc5789#section-2 + +### POST /action + +Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. -### Example: reload the system +#### Example: reload the system In some cases, clients need to request a system reload. For example, if you create a RAID device using the terminal, then you need to reload the system in order to see the new device. In the future, reloading the system could be automatically done (e.g., by listening udisk D-Bus). For now, reloading has to be manually requested. ~~~ -POST /action { "reloadSystem": { scope: "storage" } } +POST /action { "reloadSystem": "storage" } ~~~ -### Example: change the system localization +#### Example: change the system localization Sometimes we need to directly modify the system without changing the config. For example, switching the locale of the running system (UI language). @@ -79,7 +93,7 @@ Sometimes we need to directly modify the system without changing the config. For POST /action { "configureL10n": { language: "es_ES" } } ~~~ -### Example: start installation +#### Example: start installation The installation can be started by calling the proper action. From 11ee8062e6a7c4dbd87af6c4a57028df673e668e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Thu, 9 Oct 2025 16:28:31 +0200 Subject: [PATCH 165/917] Redirect libzypp logs to systemd journal --- rust/zypp-agama/zypp-agama-sys/build.rs | 1 + .../zypp-agama-sys/c-layer/Makefile | 2 +- .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 90 ++++++++++++++++++- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/build.rs b/rust/zypp-agama/zypp-agama-sys/build.rs index 0dbe0ea1f5..7a025043e1 100644 --- a/rust/zypp-agama/zypp-agama-sys/build.rs +++ b/rust/zypp-agama/zypp-agama-sys/build.rs @@ -56,6 +56,7 @@ fn main() { ); println!("cargo::rustc-link-lib=static=agama-zypp"); println!("cargo::rustc-link-lib=dylib=zypp"); + println!("cargo::rustc-link-lib=dylib=systemd"); // NOTE: install the matching library for your compiler version, for example // libstdc++6-devel-gcc13.rpm println!("cargo::rustc-link-lib=dylib=stdc++"); diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile index e2fd4b9261..f02476b35f 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/Makefile @@ -1,6 +1,6 @@ AR=ar CXX=g++ -CXXFLAGS=-Wall -I./include -I./internal -Izypp -Wall -std=c++14 -lzypp -fPIE +CXXFLAGS=-Wall -I./include -I./internal -Izypp -Wall -std=c++14 -lzypp -lsystemd -fPIE DEPS = include/lib.h include/callbacks.h internal/callbacks.hxx OBJ = lib.o callbacks.o diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 67724c0866..7c043ae922 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -28,6 +28,8 @@ extern "C" { +#include + struct Zypp { zypp::ZYpp::Ptr zypp_pointer; zypp::RepoManager *repo_manager; @@ -37,6 +39,81 @@ static struct Zypp the_zypp { .zypp_pointer = NULL, .repo_manager = NULL, }; +// formatter which actually logs the messages to the systemd journal, +// that is a bit hacky but in the logger we receive an already formatted +// message as a single string and it would not be easy to get back the original +// components of the message +struct AgamaFormatter : public zypp::base::LogControl::LineFormater { + virtual std::string format(const std::string &zypp_group, + zypp::base::logger::LogLevel zypp_level, + const char *zypp_file, const char *zypp_func, + int zypp_line, const std::string &zypp_message) { + // the systemd/syslog compatible log level + int level; + + // convert the zypp log level to the systemd/syslog log level + switch (zypp_level) { + // for details about the systemd levels see + // https://www.freedesktop.org/software/systemd/man/latest/sd-daemon.html + case zypp::base::logger::E_DBG: + level = LOG_DEBUG; + break; + case zypp::base::logger::E_MIL: + level = LOG_INFO; + break; + case zypp::base::logger::E_WAR: + level = LOG_WARNING; + break; + case zypp::base::logger::E_ERR: + level = LOG_ERR; + break; + case zypp::base::logger::E_SEC: + // security error => critical + level = LOG_CRIT; + break; + case zypp::base::logger::E_INT: + // internal error => critical + level = LOG_CRIT; + break; + // libzypp specific level + case zypp::base::logger::E_USR: + level = LOG_INFO; + break; + // libzypp specific level + case zypp::base::logger::E_XXX: + level = LOG_CRIT; + break; + } + + // unlike the other values, the location needs to be sent in an already + // formatted strings + std::string file("CODE_FILE="); + file.append(zypp_file); + std::string line("CODE_LINE="); + line.append(std::to_string(zypp_line)); + + // this will log the message with libzypp location, not from *this* file, + // see "man sd_journal_send_with_location" + sd_journal_send_with_location( + file.c_str(), line.c_str(), zypp_func, "PRIORITY=%i", level, + "MESSAGE=[%s] %s", zypp_group.c_str(), zypp_message.c_str(), + // some custom data to allow easy filtering of the libzypp messages + "COMPONENT=libzypp", "ZYPP_GROUP=%s", zypp_group.c_str(), + "ZYPP_LEVEL=%i", zypp_level, NULL); + + // libzypp aborts when the returned message is empty, + // return some static fake data to make it happy + return "msg"; + } +}; + +// a dummy logger +struct AgamaLogger : public zypp::base::LogControl::LineWriter { + virtual void writeOut(const std::string &formatted) { + // do nothing, the message has been already logged by the formatter + } +}; + void free_zypp(struct Zypp *zypp) noexcept { // ensure that target is unloaded otherwise nasty things can happen if new // zypp is created in different thread @@ -48,10 +125,15 @@ void free_zypp(struct Zypp *zypp) noexcept { } static zypp::ZYpp::Ptr zypp_ptr() { - // set logging to ~/zypp-agama.log for now. For final we need to decide it - zypp::Pathname home(getenv("HOME")); - zypp::Pathname log_path = home.cat("zypp-agama.log"); - zypp::base::LogControl::instance().logfile(log_path); + sd_journal_print(LOG_NOTICE, "Redirecting libzypp logs to systemd journal"); + + // log to systemd journal using our specific formatter + boost::shared_ptr formatter(new AgamaFormatter); + zypp::base::LogControl::instance().setLineFormater(formatter); + // use a dummy logger, using a NULL logger would skip the formatter completely + // so the messages would not be logged in the end + boost::shared_ptr logger(new AgamaLogger); + zypp::base::LogControl::instance().setLineWriter(logger); int max_count = 5; unsigned int seconds = 3; From 17463b5b5a7257a62e9c74f0f7f16f8d87a22efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 06:48:05 +0100 Subject: [PATCH 166/917] Drop the unused agama_utils::service module --- rust/agama-utils/src/lib.rs | 8 +-- rust/agama-utils/src/service.rs | 88 --------------------------------- 2 files changed, 1 insertion(+), 95 deletions(-) delete mode 100644 rust/agama-utils/src/service.rs diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index abb8421102..27c8e62265 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -22,13 +22,7 @@ //! other Agama's crates. pub mod actor; - -pub mod service; -pub use service::Service; - -pub mod issue; - pub mod dbus; +pub mod issue; pub mod openapi; - pub mod progress; diff --git a/rust/agama-utils/src/service.rs b/rust/agama-utils/src/service.rs deleted file mode 100644 index ba1e01bf29..0000000000 --- a/rust/agama-utils/src/service.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Offers a trait to implement an Agama service. -//! -//! An Agama service is composed of, at least, two parts: -//! -//! * The service itself, which holds the configuration and takes care of -//! performing the changes at installation time. It is private to each -//! Agama module (agama-l10n, agama-network, etc.). It should implement -//! the [Service trait]. -//! * The handler, which offers an API to talk to the service. It should -//! implement the [Handler](crate::Handler) trait. - -use core::future::Future; -use std::{any, error}; -use tokio::sync::mpsc; - -/// Implements the basic behavior for an Agama service. -/// -/// It is responsible for: -/// -/// * Holding the configuration. -/// * Making an installation proposal for one aspect of the system -/// (localization, partitioning, etc.). -/// * Performing the changes a installation time. -/// * Optionally, making changes to the system running Agama -/// (e.g., changing the keyboard layout). -/// -/// Usually, a service runs on a separate task and receives the actions to -/// perform through a [mpsc::UnboundedReceiver -/// channel](tokio::sync::mpsc::UnboundedReceiver). -pub trait Service: Send { - type Err: error::Error; - type Message: Send; - - /// Returns the service name used for logging and debugging purposes. - /// - /// An example might be "agama_l10n::l10n::L10n". - fn name() -> &'static str { - any::type_name::() - } - - /// Main loop of the service. - /// - /// It dispatches one message at a time. - fn run(&mut self) -> impl Future + Send { - async { - loop { - let message = self.channel().recv().await; - let Some(message) = message else { - eprintln!("channel closed for {}", Self::name()); - break; - }; - - if let Err(error) = &mut self.dispatch(message).await { - eprintln!("error dispatching command: {error}"); - } - } - } - } - - /// Returns the channel to read the messages from. - fn channel(&mut self) -> &mut mpsc::UnboundedReceiver; - - /// Dispatches a message. - fn dispatch( - &mut self, - command: Self::Message, - ) -> impl Future> + Send; -} From b8cb7de4cd5c818d353f72a843cba20adf69bb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 11:40:14 +0100 Subject: [PATCH 167/917] Rename http::Event a http::OldEvent --- rust/agama-lib/src/http.rs | 2 +- rust/agama-lib/src/http/event.rs | 20 +++++++++---------- rust/agama-lib/src/http/websocket.rs | 6 +++--- rust/agama-lib/src/monitor.rs | 4 ++-- rust/agama-manager/src/listener.rs | 8 ++++---- rust/agama-manager/src/start.rs | 4 ++-- rust/agama-server/src/manager/web.rs | 4 ++-- rust/agama-server/src/network/web.rs | 2 +- rust/agama-server/src/questions/web.rs | 4 ++-- rust/agama-server/src/server/web.rs | 2 +- rust/agama-server/src/software/web.rs | 16 +++++++-------- rust/agama-server/src/storage/web.rs | 6 +++--- .../src/storage/web/dasd/stream.rs | 12 +++++------ rust/agama-server/src/storage/web/iscsi.rs | 6 +++--- .../src/storage/web/iscsi/stream.rs | 6 +++--- .../src/storage/web/zfcp/stream.rs | 10 +++++----- rust/agama-server/src/users/web.rs | 6 +++--- rust/agama-server/src/web.rs | 8 ++++---- rust/agama-server/src/web/common.rs | 6 +++--- rust/agama-server/src/web/common/jobs.rs | 8 ++++---- rust/agama-server/src/web/common/progress.rs | 8 ++++---- rust/agama-server/src/web/service.rs | 4 ++-- rust/agama-server/src/web/state.rs | 2 +- rust/agama-server/src/web/ws.rs | 2 +- 24 files changed, 78 insertions(+), 78 deletions(-) diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index 37b5a23721..43a76f90b8 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -22,7 +22,7 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; pub mod event; -pub use event::{Event, EventPayload}; +pub use event::{OldEvent, EventPayload}; mod websocket; pub use websocket::{WebSocketClient, WebSocketError}; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index b605799aba..5507da1279 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -40,15 +40,15 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tokio::sync::broadcast; -pub type Sender = broadcast::Sender; -pub type Receiver = broadcast::Receiver; +pub type OldSender = broadcast::Sender; +pub type OldReceiver = broadcast::Receiver; /// Agama event. /// /// It represents an event that occurs in Agama. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct Event { +pub struct OldEvent { /// The identifier of the client which caused the event. #[serde(skip_serializing_if = "Option::is_none")] pub client_id: Option, @@ -57,12 +57,12 @@ pub struct Event { pub payload: EventPayload, } -impl Event { +impl OldEvent { /// Creates a new event. /// /// * `payload`: event payload. pub fn new(payload: EventPayload) -> Self { - Event { + OldEvent { client_id: None, payload, } @@ -73,7 +73,7 @@ impl Event { /// * `payload`: event payload. /// * `client_id`: client ID. pub fn new_with_client_id(payload: EventPayload, client_id: &ClientId) -> Self { - Event { + OldEvent { client_id: Some(client_id.clone()), payload, } @@ -254,21 +254,21 @@ impl From for EventPayload { #[macro_export] macro_rules! event { ($variant:ident) => { - agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant) + agama_lib::http::OldEvent::new(agama_lib::http::EventPayload::$variant) }; ($variant:ident, $client:expr) => { - agama_lib::http::Event::new_with_client_id( + agama_lib::http::OldEvent::new_with_client_id( agama_lib::http::EventPayload::$variant, $client, ) }; ($variant:ident $inner:tt, $client:expr) => { - agama_lib::http::Event::new_with_client_id( + agama_lib::http::OldEvent::new_with_client_id( agama_lib::http::EventPayload::$variant $inner, $client ) }; ($variant:ident $inner:tt) => { - agama_lib::http::Event::new(agama_lib::http::EventPayload::$variant $inner) + agama_lib::http::OldEvent::new(agama_lib::http::EventPayload::$variant $inner) }; } diff --git a/rust/agama-lib/src/http/websocket.rs b/rust/agama-lib/src/http/websocket.rs index 40afdcdd43..ea3d83aa49 100644 --- a/rust/agama-lib/src/http/websocket.rs +++ b/rust/agama-lib/src/http/websocket.rs @@ -34,7 +34,7 @@ use tokio_tungstenite::{ }; use url::Url; -use super::Event; +use super::OldEvent; use crate::auth::AuthToken; #[derive(Debug, thiserror::Error)] @@ -102,10 +102,10 @@ impl WebSocketClient { /// Receive an event from the websocket. /// /// It returns the message as an event. - pub async fn receive(&mut self) -> Result { + pub async fn receive(&mut self) -> Result { let msg = self.socket.next().await.ok_or(WebSocketError::Closed)?; let content = msg?.to_string(); - let event: Event = serde_json::from_str(&content)?; + let event: OldEvent = serde_json::from_str(&content)?; Ok(event) } } diff --git a/rust/agama-lib/src/monitor.rs b/rust/agama-lib/src/monitor.rs index 8a2d5d4369..2a1bb229eb 100644 --- a/rust/agama-lib/src/monitor.rs +++ b/rust/agama-lib/src/monitor.rs @@ -56,7 +56,7 @@ use tokio::sync::{broadcast, mpsc, oneshot}; use crate::{ http::{ - BaseHTTPClient, BaseHTTPClientError, Event, EventPayload, WebSocketClient, WebSocketError, + BaseHTTPClient, BaseHTTPClientError, OldEvent, EventPayload, WebSocketClient, WebSocketError, }, manager::{InstallationPhase, InstallerStatus}, progress::Progress, @@ -224,7 +224,7 @@ impl Monitor { /// sends the updated state to its subscribers. /// /// * `event`: Agama event. - fn handle_event(&mut self, event: Event) { + fn handle_event(&mut self, event: OldEvent) { match event.payload { EventPayload::ProgressChanged { path, progress } => { self.status.update_progress(path, progress); diff --git a/rust/agama-manager/src/listener.rs b/rust/agama-manager/src/listener.rs index 4615562e3f..23b4db0697 100644 --- a/rust/agama-manager/src/listener.rs +++ b/rust/agama-manager/src/listener.rs @@ -29,12 +29,12 @@ use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamM /// `agama_l10n::Event`) and has to be converted to the [Event /// struct](agama_lib::http::Event). pub struct EventsListener { - inner: StreamMap<&'static str, Pin + Send>>>, - sender: http::event::Sender, + inner: StreamMap<&'static str, Pin + Send>>>, + sender: http::event::OldSender, } impl EventsListener { - pub fn new(sender: http::event::Sender) -> Self { + pub fn new(sender: http::event::OldSender) -> Self { EventsListener { inner: StreamMap::new(), sender, @@ -49,7 +49,7 @@ impl EventsListener { http::EventPayload: From, { let stream = UnboundedReceiverStream::new(channel) - .map(|e| http::Event::new(http::EventPayload::from(e))); + .map(|e| http::OldEvent::new(http::EventPayload::from(e))); self.inner.insert(name, Box::pin(stream)); } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index a22af33c28..b0533295c0 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -54,7 +54,7 @@ pub enum Error { /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( - events: http::event::Sender, + events: http::event::OldSender, dbus: Option, ) -> Result, Error> { let mut listener = EventsListener::new(events); @@ -87,7 +87,7 @@ mod test { use tokio::sync::broadcast; async fn start_service() -> Handler { - let (events_sender, _events_receiver) = broadcast::channel::(16); + let (events_sender, _events_receiver) = broadcast::channel::(16); manager::start(events_sender, None).await.unwrap() } diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 3cc7a49bc4..1e0485ac58 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -51,7 +51,7 @@ use crate::{ error::Error, web::common::{service_status_router, ProgressClient, ProgressRouterBuilder}, }; -use agama_lib::http::Event; +use agama_lib::http::OldEvent; #[derive(Clone)] pub struct ManagerState<'a> { @@ -66,7 +66,7 @@ pub struct ManagerState<'a> { /// * `connection`: D-Bus connection to listen for events. pub async fn manager_stream( dbus: zbus::Connection, -) -> Result + Send>>, Error> { +) -> Result + Send>>, Error> { let proxy = Manager1Proxy::new(&dbus).await?; let stream = proxy .receive_current_installation_phase_changed() diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 18783c895e..004f75d231 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -85,7 +85,7 @@ struct NetworkServiceState { /// * `events`: sending-half of the broadcast channel. pub async fn network_service( adapter: T, - events: http::event::Sender, + events: http::event::OldSender, ) -> Result { let network = NetworkSystem::new(adapter); // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs index bd34f708de..f3f5fffbf6 100644 --- a/rust/agama-server/src/questions/web.rs +++ b/rust/agama-server/src/questions/web.rs @@ -29,7 +29,7 @@ use crate::error::Error; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, proxies::questions::{GenericQuestionProxy, QuestionWithPasswordProxy, QuestionsProxy}, questions::{ answers::{self, Answers}, @@ -285,7 +285,7 @@ pub async fn questions_service(dbus: zbus::Connection) -> Result Result + Send>>, Error> { +) -> Result + Send>>, Error> { let question_path = OwnedObjectPath::from( ObjectPath::try_from("/org/opensuse/Agama1/Questions") .context("failed to create object path")?, diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index f1b98d4bfe..4beb23ed53 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -64,7 +64,7 @@ type ServerResult = Result; /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( - events: http::event::Sender, + events: http::event::OldSender, dbus: Option, ) -> Result { let manager = manager::start(events, dbus) diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 7ca1a8320f..a52e2947d9 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -33,7 +33,7 @@ use crate::{ use agama_lib::{ error::ServiceError, event, - http::{self, Event, EventPayload}, + http::{self, EventPayload, OldEvent}, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ @@ -104,7 +104,7 @@ pub async fn software_streams(dbus: zbus::Connection) -> Result Result, Error> { +) -> Result, Error> { let proxy = SoftwareProductProxy::new(&dbus).await?; let stream = proxy .receive_selected_product_changed() @@ -121,7 +121,7 @@ async fn product_changed_stream( async fn patterns_changed_stream( dbus: zbus::Connection, -) -> Result, Error> { +) -> Result, Error> { let proxy = Software1Proxy::new(&dbus).await?; let stream = proxy .receive_selected_patterns_changed() @@ -144,7 +144,7 @@ async fn patterns_changed_stream( async fn conflicts_changed_stream( dbus: zbus::Connection, -) -> Result, Error> { +) -> Result, Error> { let proxy = Software1Proxy::new(&dbus).await?; let stream = proxy .receive_conflicts_changed() @@ -166,7 +166,7 @@ async fn conflicts_changed_stream( async fn registration_email_changed_stream( dbus: zbus::Connection, -) -> Result, Error> { +) -> Result, Error> { let proxy = RegistrationProxy::new(&dbus).await?; let stream = proxy .receive_email_changed() @@ -184,7 +184,7 @@ async fn registration_email_changed_stream( async fn registration_code_changed_stream( dbus: zbus::Connection, -) -> Result, Error> { +) -> Result, Error> { let proxy = RegistrationProxy::new(&dbus).await?; let stream = proxy .receive_reg_code_changed() @@ -218,7 +218,7 @@ fn reason_to_selected_by( /// * `events`: channel to listen for events. /// * `products`: list of products (shared behind a mutex). pub async fn receive_events( - mut events: http::event::Receiver, + mut events: http::event::OldReceiver, products: Arc>>, config: Arc>>, client: ProductClient<'_>, @@ -262,7 +262,7 @@ pub async fn receive_events( /// Sets up and returns the axum service for the software module. pub async fn software_service( dbus: zbus::Connection, - events: http::event::Receiver, + events: http::event::OldReceiver, progress: ProgressClient, ) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 5114881883..ccc0de2e4d 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -31,7 +31,7 @@ use agama_lib::{ auth::ClientId, error::ServiceError, event, - http::Event, + http::OldEvent, storage::{ model::{Action, Device, DeviceSid, ProposalSettings, ProposalSettingsPatch, Volume}, proxies::Storage1Proxy, @@ -87,7 +87,7 @@ pub async fn storage_streams(dbus: zbus::Connection) -> Result Result, Error> { +async fn devices_dirty_stream(dbus: zbus::Connection) -> Result, Error> { let proxy = Storage1Proxy::new(&dbus).await?; let stream = proxy .receive_deprecated_system_changed() @@ -102,7 +102,7 @@ async fn devices_dirty_stream(dbus: zbus::Connection) -> Result Result, Error> { +async fn configured_stream(dbus: zbus::Connection) -> Result, Error> { let proxy = Storage1Proxy::new(&dbus).await?; let stream = proxy.receive_configured().await?.filter_map(|signal| { if let Ok(args) = signal.args() { diff --git a/rust/agama-server/src/storage/web/dasd/stream.rs b/rust/agama-server/src/storage/web/dasd/stream.rs index 2312967dcd..7ec9cb7d94 100644 --- a/rust/agama-server/src/storage/web/dasd/stream.rs +++ b/rust/agama-server/src/storage/web/dasd/stream.rs @@ -25,7 +25,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, storage::{ client::dasd::DASDClient, model::dasd::{DASDDevice, DASDFormatSummary}, @@ -134,7 +134,7 @@ impl DASDDeviceStream { fn handle_change( cache: &mut ObjectsCache, change: &DBusObjectChange, - ) -> Result { + ) -> Result { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; @@ -157,7 +157,7 @@ impl DASDDeviceStream { } impl Stream for DASDDeviceStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, @@ -204,7 +204,7 @@ impl DASDFormatJobStream { Ok(Self { inner }) } - fn handle_change(message: Result) -> Option { + fn handle_change(message: Result) -> Option { let Ok(message) = message else { return None; }; @@ -224,7 +224,7 @@ impl DASDFormatJobStream { event } - fn to_event(path: String, properties_changed: &PropertiesChangedArgs) -> Option { + fn to_event(path: String, properties_changed: &PropertiesChangedArgs) -> Option { let dict = properties_changed .changed_properties() .get("Summary")? @@ -260,7 +260,7 @@ impl DASDFormatJobStream { } impl Stream for DASDFormatJobStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, diff --git a/rust/agama-server/src/storage/web/iscsi.rs b/rust/agama-server/src/storage/web/iscsi.rs index 669a995025..8178253a77 100644 --- a/rust/agama-server/src/storage/web/iscsi.rs +++ b/rust/agama-server/src/storage/web/iscsi.rs @@ -29,7 +29,7 @@ use crate::{error::Error, web::common::EventStreams}; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, storage::{ client::iscsi::{ISCSIAuth, ISCSIInitiator, ISCSINode, LoginResult}, ISCSIClient, @@ -72,7 +72,7 @@ pub async fn iscsi_stream(dbus: &zbus::Connection) -> Result Result + Send, Error> { +) -> Result + Send, Error> { let proxy = PropertiesProxy::builder(dbus) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? @@ -91,7 +91,7 @@ async fn initiator_stream( Ok(stream) } -fn handle_initiator_change(change: PropertiesChanged) -> Result, ServiceError> { +fn handle_initiator_change(change: PropertiesChanged) -> Result, ServiceError> { let args = change.args()?; let iscsi_iface = InterfaceName::from_str_unchecked("org.opensuse.Agama.Storage1.ISCSI.Initiator"); diff --git a/rust/agama-server/src/storage/web/iscsi/stream.rs b/rust/agama-server/src/storage/web/iscsi/stream.rs index 6749a5a4da..9d2228cc91 100644 --- a/rust/agama-server/src/storage/web/iscsi/stream.rs +++ b/rust/agama-server/src/storage/web/iscsi/stream.rs @@ -23,7 +23,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, storage::{ISCSIClient, ISCSINode}, }; use agama_utils::{ @@ -128,7 +128,7 @@ impl ISCSINodeStream { fn handle_change( cache: &mut ObjectsCache, change: &DBusObjectChange, - ) -> Result { + ) -> Result { match change { DBusObjectChange::Added(path, values) => { let node = Self::update_node(cache, path, values)?; @@ -147,7 +147,7 @@ impl ISCSINodeStream { } impl Stream for ISCSINodeStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, diff --git a/rust/agama-server/src/storage/web/zfcp/stream.rs b/rust/agama-server/src/storage/web/zfcp/stream.rs index 9d718770c2..e35ee94425 100644 --- a/rust/agama-server/src/storage/web/zfcp/stream.rs +++ b/rust/agama-server/src/storage/web/zfcp/stream.rs @@ -25,7 +25,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, storage::{ client::zfcp::ZFCPClient, model::zfcp::{ZFCPController, ZFCPDisk}, @@ -127,7 +127,7 @@ impl ZFCPDiskStream { fn handle_change( cache: &mut ObjectsCache, change: &DBusObjectChange, - ) -> Result { + ) -> Result { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; @@ -150,7 +150,7 @@ impl ZFCPDiskStream { } impl Stream for ZFCPDiskStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, @@ -261,7 +261,7 @@ impl ZFCPControllerStream { fn handle_change( cache: &mut ObjectsCache, change: &DBusObjectChange, - ) -> Result { + ) -> Result { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; @@ -284,7 +284,7 @@ impl ZFCPControllerStream { } impl Stream for ZFCPControllerStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 4f96f79f64..bf11994162 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -32,7 +32,7 @@ use crate::{ use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, users::{model::RootPatchSettings, proxies::Users1Proxy, FirstUser, RootUser, UsersClient}, }; use axum::{ @@ -76,7 +76,7 @@ pub async fn users_streams(dbus: zbus::Connection) -> Result Result + Send, Error> { +) -> Result + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy .receive_first_user_changed() @@ -99,7 +99,7 @@ async fn first_user_changed_stream( async fn root_user_changed_stream( dbus: zbus::Connection, -) -> Result + Send, Error> { +) -> Result + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy .receive_root_user_changed() diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 63b0dd7dcc..13cdeb8459 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -55,7 +55,7 @@ mod ws; use agama_lib::{ connection, error::ServiceError, - http::event::{self, Event}, + http::event::{self, OldEvent}, }; use common::ProgressService; pub use config::ServiceConfig; @@ -71,7 +71,7 @@ use tokio_stream::{StreamExt, StreamMap}; /// * `web_ui_dir`: public directory containing the web UI. pub async fn service

    ( config: ServiceConfig, - events: event::Sender, + events: event::OldSender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result @@ -118,7 +118,7 @@ where /// The events are sent to the `events` channel. /// /// * `events`: channel to send the events to. -pub async fn run_monitor(events: event::Sender) -> Result<(), ServiceError> { +pub async fn run_monitor(events: event::OldSender) -> Result<(), ServiceError> { let connection = connection().await?; tokio::spawn(run_events_monitor(connection, events.clone())); @@ -129,7 +129,7 @@ pub async fn run_monitor(events: event::Sender) -> Result<(), ServiceError> { /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. -async fn run_events_monitor(dbus: zbus::Connection, events: event::Sender) -> Result<(), Error> { +async fn run_events_monitor(dbus: zbus::Connection, events: event::OldSender) -> Result<(), Error> { let mut stream = StreamMap::new(); stream.insert("manager", manager_stream(dbus.clone()).await?); diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index b6cf4d687b..ab7edbd829 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -34,9 +34,9 @@ pub use jobs::{jobs_service, jobs_stream}; mod progress; pub use progress::{ProgressClient, ProgressRouterBuilder, ProgressService, ProgressServiceError}; -use super::Event; +use super::OldEvent; -pub type EventStreams = Vec<(&'static str, Pin + Send>>)>; +pub type EventStreams = Vec<(&'static str, Pin + Send>>)>; /// Builds a router to the `org.opensuse.Agama1.ServiceStatus` interface of the /// given D-Bus object. @@ -106,7 +106,7 @@ pub async fn service_status_stream( dbus: zbus::Connection, destination: &'static str, path: &'static str, -) -> Result + Send>>, Error> { +) -> Result + Send>>, Error> { let proxy = build_service_status_proxy(&dbus, destination, path).await?; let stream = proxy .receive_current_changed() diff --git a/rust/agama-server/src/web/common/jobs.rs b/rust/agama-server/src/web/common/jobs.rs index 0f67483deb..7a932d8924 100644 --- a/rust/agama-server/src/web/common/jobs.rs +++ b/rust/agama-server/src/web/common/jobs.rs @@ -23,7 +23,7 @@ use std::{collections::HashMap, pin::Pin, task::Poll}; use agama_lib::{ error::ServiceError, event, - http::Event, + http::OldEvent, jobs::{client::JobsClient, Job}, }; use agama_utils::{dbus::get_optional_property, property_from_dbus}; @@ -78,7 +78,7 @@ pub async fn jobs_stream( destination: &'static str, manager: &'static str, namespace: &'static str, -) -> Result + Send>>, Error> { +) -> Result + Send>>, Error> { let stream = JobsStream::new(&dbus, destination, manager, namespace).await?; Ok(Box::pin(stream)) } @@ -159,7 +159,7 @@ impl JobsStream { fn handle_change( cache: &mut ObjectsCache, change: &DBusObjectChange, - ) -> Result { + ) -> Result { match change { DBusObjectChange::Added(path, values) => { let job = Self::update_job(cache, path, values)?; @@ -178,7 +178,7 @@ impl JobsStream { } impl Stream for JobsStream { - type Item = Event; + type Item = OldEvent; fn poll_next( self: std::pin::Pin<&mut Self>, diff --git a/rust/agama-server/src/web/common/progress.rs b/rust/agama-server/src/web/common/progress.rs index 6eebeba5e6..b935d7aa24 100644 --- a/rust/agama-server/src/web/common/progress.rs +++ b/rust/agama-server/src/web/common/progress.rs @@ -37,7 +37,7 @@ use agama_lib::{ event, - http::{self, Event}, + http::{self, OldEvent}, progress::{Progress, ProgressSequence}, proxies::{ProgressChanged, ProgressProxy}, }; @@ -64,7 +64,7 @@ pub enum ProgressServiceError { #[error("Invalid D-Bus name: {0}")] DBusName(#[from] zbus::names::Error), #[error("Could not send the event: {0}")] - SendEvent(#[from] broadcast::error::SendError), + SendEvent(#[from] broadcast::error::SendError), } #[derive(Debug)] @@ -76,7 +76,7 @@ pub enum ProgressCommand { pub struct ProgressService { cache: HashMap, commands: mpsc::Receiver, - events: http::event::Sender, + events: http::event::OldSender, dbus: zbus::Connection, } @@ -87,7 +87,7 @@ impl ProgressService { /// /// * Commands from a client ([ProgressClient]). /// * Relevant events from D-Bus. - pub async fn start(dbus: zbus::Connection, events: http::event::Sender) -> ProgressClient { + pub async fn start(dbus: zbus::Connection, events: http::event::OldSender) -> ProgressClient { let (tx, rx) = mpsc::channel(4); let mut service = ProgressService { cache: HashMap::new(), diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index e188451d5a..b4e00ae3dc 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -55,7 +55,7 @@ use tracing::Span; /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, - events: http::event::Sender, + events: http::event::OldSender, api_router: Router, public_dir: PathBuf, } @@ -65,7 +65,7 @@ impl MainServiceBuilder { /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. - pub fn new

    (events: http::event::Sender, public_dir: P) -> Self + pub fn new

    (events: http::event::OldSender, public_dir: P) -> Self where P: AsRef, { diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index d207e9923d..3a1aa9d983 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -30,6 +30,6 @@ use std::path::PathBuf; #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, - pub events: http::event::Sender, + pub events: http::event::OldSender, pub public_dir: PathBuf, } diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 414a9f6c6e..5a2254c91a 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -42,7 +42,7 @@ pub async fn ws_handler( async fn handle_socket( mut socket: WebSocket, - events: http::event::Sender, + events: http::event::OldSender, client_id: Arc, ) { let mut rx = events.subscribe(); From 1f8b84286a851681d07a4c66113b536a57ac952b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 12:23:28 +0100 Subject: [PATCH 168/917] Move new events to agama_utils --- rust/agama-l10n/src/lib.rs | 3 - rust/agama-l10n/src/service.rs | 14 ++-- rust/agama-l10n/src/start.rs | 20 ++--- rust/agama-lib/src/http/event.rs | 26 ------- rust/agama-manager/src/lib.rs | 1 - rust/agama-manager/src/listener.rs | 73 ------------------- rust/agama-manager/src/start.rs | 33 ++------- rust/agama-server/src/agama-web-server.rs | 4 +- rust/agama-server/src/server/web.rs | 6 +- rust/agama-server/src/web.rs | 15 ++-- rust/agama-utils/src/issue.rs | 7 +- rust/agama-utils/src/issue/service.rs | 13 ++-- rust/agama-utils/src/issue/start.rs | 21 ++++-- rust/agama-utils/src/lib.rs | 2 + rust/agama-utils/src/progress.rs | 3 - rust/agama-utils/src/progress/event.rs | 36 --------- rust/agama-utils/src/progress/service.rs | 14 ++-- rust/agama-utils/src/progress/start.rs | 15 ++-- .../src/{issue/event.rs => types.rs} | 18 +---- .../src => agama-utils/src/types}/event.rs | 20 ++--- 20 files changed, 92 insertions(+), 252 deletions(-) delete mode 100644 rust/agama-manager/src/listener.rs delete mode 100644 rust/agama-utils/src/progress/event.rs rename rust/agama-utils/src/{issue/event.rs => types.rs} (66%) rename rust/{agama-l10n/src => agama-utils/src/types}/event.rs (69%) diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index 4eecfab9eb..7b19f7f8d2 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -53,9 +53,6 @@ pub use config::Config; mod proposal; pub use proposal::Proposal; -pub mod event; -pub use event::Event; - pub mod helpers; pub mod message; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 7f4a7a9b62..41162aa976 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -19,18 +19,14 @@ // find current contact information at www.suse.com. use crate::{ - config::Config, - event::{self, Event}, - extended_config::ExtendedConfig, - message, - model::ModelAdapter, - proposal::Proposal, - system_info::SystemInfo, + config::Config, extended_config::ExtendedConfig, message, model::ModelAdapter, + proposal::Proposal, system_info::SystemInfo, }; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, issue::{self, Issue}, + types::{Event, EventsSender}, }; use async_trait::async_trait; @@ -72,7 +68,7 @@ pub struct Service { state: State, model: Box, issues: Handler, - events: event::Sender, + events: EventsSender, } struct State { @@ -85,7 +81,7 @@ impl Service { pub fn new( model: T, issues: Handler, - events: event::Sender, + events: EventsSender, ) -> Service { let system = SystemInfo::read_from(&model); let config = ExtendedConfig::new_from(&system); diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 0254363ac0..8d3048a758 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -19,7 +19,6 @@ // find current contact information at www.suse.com. use crate::{ - event, model::Model, monitor::{self, Monitor}, service::{self, Service}, @@ -27,6 +26,7 @@ use crate::{ use agama_utils::{ actor::{self, Handler}, issue, + types::EventsSender, }; #[derive(thiserror::Error, Debug)] @@ -50,7 +50,7 @@ pub enum Error { /// * `issues`: handler to the issues service. pub async fn start( issues: Handler, - events: event::Sender, + events: EventsSender, ) -> Result, Error> { let model = Model::from_system()?; let service = Service::new(model, issues, events); @@ -63,20 +63,20 @@ pub async fn start( #[cfg(test)] mod tests { use crate::{ - event::Receiver, message, model::{ Keymap, KeymapsDatabase, LocaleEntry, LocalesDatabase, ModelAdapter, TimezoneEntry, TimezonesDatabase, }, - service, Config, Event, Service, + service, Config, Service, }; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::{ actor::{self, Handler}, issue, + types::{Event, EventsReceiver}, }; - use tokio::sync::mpsc; + use tokio::sync::broadcast; pub struct TestModel { pub locales: LocalesDatabase, @@ -141,11 +141,11 @@ mod tests { } } - async fn start_testing_service() -> (Receiver, Handler, Handler) { - let (events_tx, _events_rx) = mpsc::unbounded_channel::(); - let issues = issue::start(events_tx, None).await.unwrap(); + async fn start_testing_service() -> (EventsReceiver, Handler, Handler) + { + let (events_tx, events_rx) = broadcast::channel::(16); + let issues = issue::start(events_tx.clone(), None).await.unwrap(); - let (events_tx, events_rx) = mpsc::unbounded_channel::(); let model = build_adapter(); let service = Service::new(model, issues.clone(), events_tx); @@ -211,7 +211,7 @@ mod tests { let _ = handler.call(message::GetConfig).await?; let event = events_rx.try_recv(); - assert!(matches!(event, Err(mpsc::error::TryRecvError::Empty))); + assert!(matches!(event, Err(broadcast::error::TryRecvError::Empty))); Ok(()) } diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 5507da1279..048c06e22e 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -34,8 +34,6 @@ use crate::{ }, users::{FirstUser, RootUser}, }; -use agama_l10n as l10n; -use agama_utils::{issue, progress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tokio::sync::broadcast; @@ -87,10 +85,6 @@ pub enum EventPayload { LocaleChanged { locale: String, }, - #[serde(rename = "progress")] - ProgressEvent(progress::Event), - #[serde(rename = "l10n")] - L10nEvent(l10n::Event), DevicesDirty { dirty: bool, }, @@ -126,8 +120,6 @@ pub enum EventPayload { service: String, status: u32, }, - #[serde(rename = "issues")] - Issues(issue::Event), ValidationChanged { service: String, path: String, @@ -189,24 +181,6 @@ pub enum EventPayload { }, } -impl From for EventPayload { - fn from(value: progress::Event) -> Self { - EventPayload::ProgressEvent(value) - } -} - -impl From for EventPayload { - fn from(value: l10n::Event) -> Self { - EventPayload::L10nEvent(value) - } -} - -impl From for EventPayload { - fn from(value: issue::Event) -> Self { - EventPayload::Issues(value) - } -} - /// Makes it easier to create an event, reducing the boilerplate. /// /// # Event without additional data diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index bc47c3a8d1..9107509fd7 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -29,7 +29,6 @@ pub use system_info::SystemInfo; pub mod message; -mod listener; mod proposal; pub use agama_l10n as l10n; diff --git a/rust/agama-manager/src/listener.rs b/rust/agama-manager/src/listener.rs deleted file mode 100644 index 23b4db0697..0000000000 --- a/rust/agama-manager/src/listener.rs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_lib::http; -use std::pin::Pin; -use tokio::sync::mpsc; -use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamMap}; - -/// Listens for events of each service and retransmit them over the websocket. -/// -/// The events from each service comes in their own types (e.g., -/// `agama_l10n::Event`) and has to be converted to the [Event -/// struct](agama_lib::http::Event). -pub struct EventsListener { - inner: StreamMap<&'static str, Pin + Send>>>, - sender: http::event::OldSender, -} - -impl EventsListener { - pub fn new(sender: http::event::OldSender) -> Self { - EventsListener { - inner: StreamMap::new(), - sender, - } - } - - pub fn add_channel( - &mut self, - name: &'static str, - channel: mpsc::UnboundedReceiver, - ) where - http::EventPayload: From, - { - let stream = UnboundedReceiverStream::new(channel) - .map(|e| http::OldEvent::new(http::EventPayload::from(e))); - self.inner.insert(name, Box::pin(stream)); - } - - pub async fn run(self) { - let mut stream = self.inner; - while let Some((_, event)) = stream.next().await { - if let Err(e) = self.sender.send(event) { - tracing::error!("Could no retransmit the event: {e}"); - }; - } - } -} - -/// Spawns a Tokio task for the listener. -/// -/// * `listener`: listener to spawn. -pub fn spawn(listener: EventsListener) { - tokio::spawn(async move { - listener.run().await; - }); -} diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index b0533295c0..59c87b189b 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,17 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - l10n, - listener::{self, EventsListener}, - service::Service, -}; -use agama_lib::http; +use crate::{l10n, service::Service}; use agama_utils::{ actor::{self, Handler}, issue, progress, + types::EventsSender, }; -use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -54,28 +49,16 @@ pub enum Error { /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( - events: http::event::OldSender, + events: EventsSender, dbus: Option, ) -> Result, Error> { - let mut listener = EventsListener::new(events); - - let (events_sender, events_receiver) = mpsc::unbounded_channel::(); - let issues = issue::start(events_sender, dbus).await?; - listener.add_channel("issues", events_receiver); - - let (events_sender, events_receiver) = mpsc::unbounded_channel::(); - let progress = progress::start(events_sender).await?; - listener.add_channel("progress", events_receiver); - - let (events_sender, events_receiver) = mpsc::unbounded_channel::(); - let l10n = l10n::start(issues.clone(), events_sender).await?; - listener.add_channel("l10n", events_receiver); + let issues = issue::start(events.clone(), dbus).await?; + let progress = progress::start(events.clone()).await?; + let l10n = l10n::start(issues.clone(), events.clone()).await?; let service = Service::new(l10n, issues, progress); let handler = actor::spawn(service); - listener::spawn(listener); - Ok(handler) } @@ -83,11 +66,11 @@ pub async fn start( mod test { use crate::{self as manager, l10n, message, service::Service}; use agama_lib::{http, install_settings::InstallSettings}; - use agama_utils::actor::Handler; + use agama_utils::{actor::Handler, types::Event}; use tokio::sync::broadcast; async fn start_service() -> Handler { - let (events_sender, _events_receiver) = broadcast::channel::(16); + let (events_sender, _events_receiver) = broadcast::channel::(16); manager::start(events_sender, None).await.unwrap() } diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 82d760c8af..53340f97ac 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -322,6 +322,8 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { let (tx, _) = channel(16); run_monitor(tx.clone()).await?; + let (events_tx, _) = channel(16); + let config = web::ServiceConfig::load()?; write_token(TOKEN_FILE, &config.jwt_secret).context("could not create the token file")?; @@ -331,7 +333,7 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { .web_ui_dir .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_UI_DIR)); - let service = web::service(config, tx, dbus, web_ui_dir).await?; + let service = web::service(config, events_tx, tx, dbus, web_ui_dir).await?; // TODO: Move elsewhere? Use a singleton? (It would be nice to use the same // generated self-signed certificate on both ports.) let ssl_acceptor = if let Ok(ssl_acceptor) = ssl_acceptor(&args.to_certificate()?) { diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 4beb23ed53..9bcfa32af5 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -21,9 +21,9 @@ //! This module implements Agama's HTTP API. use crate::server::types::{ConfigPatch, IssuesMap}; -use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; +use agama_lib::{error::ServiceError, install_settings::InstallSettings}; use agama_manager::{self as manager, message, SystemInfo}; -use agama_utils::actor::Handler; +use agama_utils::{actor::Handler, types::EventsSender}; use anyhow; use axum::{ extract::State, @@ -64,7 +64,7 @@ type ServerResult = Result; /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( - events: http::event::OldSender, + events: EventsSender, dbus: Option, ) -> Result { let manager = manager::start(events, dbus) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 13cdeb8459..a3cc73116a 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -41,6 +41,7 @@ use crate::{ users::web::{users_service, users_streams}, web::common::{jobs_stream, service_status_stream}, }; +use agama_utils::types::EventsSender; use axum::Router; mod auth; @@ -71,7 +72,8 @@ use tokio_stream::{StreamExt, StreamMap}; /// * `web_ui_dir`: public directory containing the web UI. pub async fn service

    ( config: ServiceConfig, - events: event::OldSender, + events: EventsSender, + old_events: event::OldSender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result @@ -82,9 +84,9 @@ where .await .expect("Could not connect to NetworkManager to read the configuration"); - let progress = ProgressService::start(dbus.clone(), events.clone()).await; + let progress = ProgressService::start(dbus.clone(), old_events.clone()).await; - let router = MainServiceBuilder::new(events.clone(), web_ui_dir) + let router = MainServiceBuilder::new(old_events.clone(), web_ui_dir) .add_service( "/manager", manager_service(dbus.clone(), progress.clone()).await?, @@ -96,12 +98,15 @@ where .add_service("/security", security_service(dbus.clone()).await?) .add_service( "/software", - software_service(dbus.clone(), events.subscribe(), progress.clone()).await?, + software_service(dbus.clone(), old_events.subscribe(), progress.clone()).await?, ) .add_service("/storage", storage_service(dbus.clone(), progress).await?) .add_service("/iscsi", iscsi_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) - .add_service("/network", network_service(network_adapter, events).await?) + .add_service( + "/network", + network_service(network_adapter, old_events).await?, + ) .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index 54ff2489d5..1b6f8709c1 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -31,11 +31,11 @@ //! //! ```no_run //! use agama_utils::issue::{self, message}; -//! use tokio::sync::mpsc; +//! use tokio::sync::broadcast; //! //! # tokio_test::block_on(async { //! async fn use_issues_service() { -//! let (events_tx, _events_rx) = mpsc::unbounded_channel(); +//! let (events_tx, _events_rx) = broadcast::channel(16); //! let issues = issue::start(events_tx, None).await.unwrap(); //! _ = issues.call(message::Update::new("my-service", vec![])); //! } @@ -43,9 +43,6 @@ //! //! ``` -pub mod event; -pub use event::Event; - pub mod model; pub use model::{Issue, IssueSeverity, IssueSource}; diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 3703cddb48..fe17ee41c1 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -18,8 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{event, message, Issue}; -use crate::actor::{self, Actor, MessageHandler}; +use super::{message, Issue}; +use crate::{ + actor::{self, Actor, MessageHandler}, + types::{Event, EventsSender}, +}; use async_trait::async_trait; use std::collections::HashMap; @@ -33,11 +36,11 @@ pub enum Error { pub struct Service { issues: HashMap>, - events: event::Sender, + events: EventsSender, } impl Service { - pub fn new(events: event::Sender) -> Self { + pub fn new(events: EventsSender) -> Self { Self { issues: HashMap::new(), events, @@ -69,7 +72,7 @@ impl MessageHandler for Service { } if message.notify { - _ = self.events.send(event::Event::IssuesChanged); + _ = self.events.send(Event::IssuesChanged); } Ok(()) } diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 3143b4fc1f..969e8aab33 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -19,11 +19,13 @@ // find current contact information at www.suse.com. use super::{ - event, monitor::{self, Monitor}, service, Service, }; -use crate::actor::{self, Handler}; +use crate::{ + actor::{self, Handler}, + types::EventsSender, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -32,7 +34,7 @@ pub enum Error { } pub async fn start( - events: event::Sender, + events: EventsSender, dbus: Option, ) -> Result, Error> { let service = Service::new(events); @@ -48,12 +50,15 @@ pub async fn start( #[cfg(test)] mod tests { - use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; - use tokio::sync::mpsc::{self, error::TryRecvError}; + use crate::{ + issue::{self, message, Issue, IssueSeverity, IssueSource}, + types::Event, + }; + use tokio::sync::broadcast::{self, error::TryRecvError}; #[tokio::test] async fn test_get_and_update_issues() -> Result<(), Box> { - let (events_tx, mut events_rx) = mpsc::unbounded_channel(); + let (events_tx, mut events_rx) = broadcast::channel::(16); let issues = issue::start(events_tx, None).await.unwrap(); let issue = Issue { description: "Product not selected".to_string(), @@ -73,13 +78,13 @@ mod tests { let issues_list = issues.call(message::Get).await.unwrap(); assert_eq!(issues_list.len(), 1); - assert!(events_rx.recv().await.is_some()); + assert!(events_rx.recv().await.is_ok()); Ok(()) } #[tokio::test] async fn test_update_without_event() -> Result<(), Box> { - let (events_tx, mut events_rx) = mpsc::unbounded_channel(); + let (events_tx, mut events_rx) = broadcast::channel::(16); let issues = issue::start(events_tx, None).await.unwrap(); let issue = Issue { description: "Product not selected".to_string(), diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index abb8421102..3b2d568908 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -32,3 +32,5 @@ pub mod dbus; pub mod openapi; pub mod progress; + +pub mod types; diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs index 6699d94d75..702482b739 100644 --- a/rust/agama-utils/src/progress.rs +++ b/rust/agama-utils/src/progress.rs @@ -24,9 +24,6 @@ pub use start::start; pub mod service; pub use service::Service; -pub mod event; -pub use event::Event; - pub mod message; mod model; diff --git a/rust/agama-utils/src/progress/event.rs b/rust/agama-utils/src/progress/event.rs deleted file mode 100644 index c1f4afd13b..0000000000 --- a/rust/agama-utils/src/progress/event.rs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; - -/// Progress-related events. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "name")] -pub enum Event { - /// Progress changed. - ProgressChanged, -} - -/// Multi-producer single-consumer events sender. -pub type Sender = mpsc::UnboundedSender; - -/// Multi-producer single-consumer events receiver. -pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index fbed3d1ce6..4beb6a21cf 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -19,13 +19,11 @@ // find current contact information at www.suse.com. use crate::actor::{self, Actor, MessageHandler}; +use crate::progress::message; use crate::progress::model::Progress; -use crate::progress::{ - event::{self, Event}, - message, -}; +use crate::types::{Event, EventsSender}; use async_trait::async_trait; -use tokio::sync::mpsc::error::SendError; +use tokio::sync::broadcast; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -36,18 +34,18 @@ pub enum Error { #[error("Next step does not exist for {0}")] MissingStep(String), #[error(transparent)] - Event(#[from] SendError), + Event(#[from] broadcast::error::SendError), #[error(transparent)] Actor(#[from] actor::Error), } pub struct Service { - events: event::Sender, + events: EventsSender, progresses: Vec, } impl Service { - pub fn new(events: event::Sender) -> Service { + pub fn new(events: EventsSender) -> Service { Self { events, progresses: Vec::new(), diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 9760798d7d..d84b830ba9 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -20,7 +20,8 @@ use crate::{ actor::{self, Handler}, - progress::{event, service::Service}, + progress::service::Service, + types::EventsSender, }; use std::convert::Infallible; @@ -32,8 +33,8 @@ pub enum Error { /// Starts the progress service. /// -/// * `events`: channel to emit the [progress-specific events](crate::progress::event::Event). -pub async fn start(events: event::Sender) -> Result, Error> { +/// * `events`: channel to emit the [events](agama_utils::types::Event). +pub async fn start(events: EventsSender) -> Result, Error> { let handler = actor::spawn(Service::new(events)); Ok(handler) } @@ -42,14 +43,14 @@ pub async fn start(events: event::Sender) -> Result, Error> { mod tests { use crate::actor::{self, Handler}; use crate::progress::{ - event::{Event, Receiver}, message, service::{self, Service}, }; - use tokio::sync::mpsc; + use crate::types::{Event, EventsReceiver}; + use tokio::sync::broadcast; - fn start_testing_service() -> (Receiver, Handler) { - let (events, receiver) = mpsc::unbounded_channel::(); + fn start_testing_service() -> (EventsReceiver, Handler) { + let (events, receiver) = broadcast::channel::(16); let service = Service::new(events); let handler = actor::spawn(service); diff --git a/rust/agama-utils/src/issue/event.rs b/rust/agama-utils/src/types.rs similarity index 66% rename from rust/agama-utils/src/issue/event.rs rename to rust/agama-utils/src/types.rs index 3f307feaaa..205e803220 100644 --- a/rust/agama-utils/src/issue/event.rs +++ b/rust/agama-utils/src/types.rs @@ -18,18 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. -/// Issues changed event. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "name")] -pub enum Event { - /// Issues changed. - IssuesChanged, -} - -/// Multi-producer single-consumer events sender. -pub type Sender = mpsc::UnboundedSender; -/// Multi-producer single-consumer events receiver. -pub type Receiver = mpsc::UnboundedReceiver; +pub mod event; +pub use event::{Event, EventsReceiver, EventsSender}; diff --git a/rust/agama-l10n/src/event.rs b/rust/agama-utils/src/types/event.rs similarity index 69% rename from rust/agama-l10n/src/event.rs rename to rust/agama-utils/src/types/event.rs index 9ea47511b1..e6e4e08cf7 100644 --- a/rust/agama-l10n/src/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -18,21 +18,21 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; +use tokio::sync::broadcast; -/// Localization-related events. -// FIXME: is it really needed to implement Deserialize? -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "name")] +#[derive(Clone, Debug)] pub enum Event { + // FIXME: move service::State to agama_utils::types::manager. + StatusChanged, + /// The list of issues has changed. + IssuesChanged, + /// Progress changed. + ProgressChanged, /// Proposal changed. ProposalChanged, /// The underlying system changed. SystemChanged, } -/// Multi-producer single-consumer events sender. -pub type Sender = mpsc::UnboundedSender; -/// Multi-producer single-consumer events receiver. -pub type Receiver = mpsc::UnboundedReceiver; +pub type EventsSender = broadcast::Sender; +pub type EventsReceiver = broadcast::Receiver; From 8696787990e88cfc6940699a550a0fdf3f632c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 13:08:06 +0100 Subject: [PATCH 169/917] Emit new style events --- rust/agama-manager/src/start.rs | 2 +- rust/agama-server/src/web.rs | 7 ++--- rust/agama-server/src/web/service.rs | 8 +++-- rust/agama-server/src/web/state.rs | 4 ++- rust/agama-server/src/web/ws.rs | 46 ++++++++++++++++++++++------ rust/agama-server/tests/service.rs | 6 ++-- rust/agama-utils/src/types/event.rs | 3 +- 7 files changed, 54 insertions(+), 22 deletions(-) diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 59c87b189b..dcbd4aabf3 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -65,7 +65,7 @@ pub async fn start( #[cfg(test)] mod test { use crate::{self as manager, l10n, message, service::Service}; - use agama_lib::{http, install_settings::InstallSettings}; + use agama_lib::install_settings::InstallSettings; use agama_utils::{actor::Handler, types::Event}; use tokio::sync::broadcast; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index a3cc73116a..7790673d64 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -86,15 +86,12 @@ where let progress = ProgressService::start(dbus.clone(), old_events.clone()).await; - let router = MainServiceBuilder::new(old_events.clone(), web_ui_dir) + let router = MainServiceBuilder::new(events.clone(), old_events.clone(), web_ui_dir) .add_service( "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) - .add_service( - "/v2", - server_service(events.clone(), Some(dbus.clone())).await?, - ) + .add_service("/v2", server_service(events, Some(dbus.clone())).await?) .add_service("/security", security_service(dbus.clone()).await?) .add_service( "/software", diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index b4e00ae3dc..9646af835f 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -21,6 +21,7 @@ use super::http::{login, login_from_query, logout, session}; use super::{config::ServiceConfig, state::ServiceState}; use agama_lib::{auth::TokenClaims, http}; +use agama_utils::types::EventsSender; use axum::http::HeaderValue; use axum::middleware::Next; use axum::{ @@ -55,7 +56,8 @@ use tracing::Span; /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, - events: http::event::OldSender, + events: EventsSender, + old_events: http::event::OldSender, api_router: Router, public_dir: PathBuf, } @@ -65,7 +67,7 @@ impl MainServiceBuilder { /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. - pub fn new

    (events: http::event::OldSender, public_dir: P) -> Self + pub fn new

    (events: EventsSender, old_events: http::event::OldSender, public_dir: P) -> Self where P: AsRef, { @@ -74,6 +76,7 @@ impl MainServiceBuilder { Self { events, + old_events, api_router, config, public_dir: PathBuf::from(public_dir.as_ref()), @@ -104,6 +107,7 @@ impl MainServiceBuilder { let state = ServiceState { config: self.config, events: self.events, + old_events: self.old_events, public_dir: self.public_dir.clone(), }; diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index 3a1aa9d983..fc07df8c1a 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -22,6 +22,7 @@ use super::config::ServiceConfig; use agama_lib::http; +use agama_utils::types::EventsSender; use std::path::PathBuf; /// Web service state. @@ -30,6 +31,7 @@ use std::path::PathBuf; #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, - pub events: http::event::OldSender, + pub events: EventsSender, + pub old_events: http::event::OldSender, pub public_dir: PathBuf, } diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 5a2254c91a..563e96ff21 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -22,6 +22,7 @@ use super::state::ServiceState; use agama_lib::{auth::ClientId, http}; +use agama_utils::types::EventsSender; use axum::{ extract::{ ws::{Message, WebSocket}, @@ -30,39 +31,64 @@ use axum::{ response::IntoResponse, Extension, }; +use serde::Serialize; use std::sync::Arc; +use tokio::sync::broadcast; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Error serializing WebSocket message")] + Serialize(#[from] serde_json::Error), + #[error("Could not receive the event")] + RecvEvent(#[from] broadcast::error::RecvError), + #[error("Websocket closed")] + WebSocketClosed(#[from] axum::Error), +} pub async fn ws_handler( State(state): State, Extension(client_id): Extension>, ws: WebSocketUpgrade, ) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state.events, client_id)) + ws.on_upgrade(move |socket| handle_socket(socket, state.events, state.old_events, client_id)) } async fn handle_socket( mut socket: WebSocket, - events: http::event::OldSender, + events: EventsSender, + old_events: http::event::OldSender, client_id: Arc, ) { - let mut rx = events.subscribe(); + let mut events_rx = events.subscribe(); + let mut old_events_rx = old_events.subscribe(); let conn_event = agama_lib::event!(ClientConnected, client_id.as_ref()); if let Ok(json) = serde_json::to_string(&conn_event) { _ = socket.send(Message::Text(json)).await; } - while let Ok(msg) = rx.recv().await { - match serde_json::to_string(&msg) { - Ok(json) => { - if let Err(e) = socket.send(Message::Text(json)).await { - tracing::info!("ws: client disconnected: {e}"); + loop { + tokio::select! { + msg = old_events_rx.recv() => { + if send_msg(&mut socket, msg).await.is_err() { return; } } - Err(e) => { - tracing::error!("ws: error serializing message: {e}") + + msg = events_rx.recv() => { + if send_msg(&mut socket, msg).await.is_err() { + return; + } } } } } + +async fn send_msg( + socket: &mut WebSocket, + msg: Result, +) -> Result<(), Error> { + let content = msg?; + let json = serde_json::to_string(&content)?; + Ok(socket.send(Message::Text(json)).await?) +} diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index cdc843c5cd..5232486246 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -40,8 +40,9 @@ fn public_dir() -> PathBuf { #[test] async fn test_ping() -> Result<(), Box> { let config = ServiceConfig::default(); + let (events_tx, _) = channel(16); let (tx, _) = channel(16); - let web_service = MainServiceBuilder::new(tx, public_dir()) + let web_service = MainServiceBuilder::new(events_tx, tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); @@ -67,8 +68,9 @@ async fn access_protected_route(token: &str, jwt_secret: &str) -> Response { let config = ServiceConfig { jwt_secret: jwt_secret.to_string(), }; + let (events_tx, _) = channel(16); let (tx, _) = channel(16); - let web_service = MainServiceBuilder::new(tx, public_dir()) + let web_service = MainServiceBuilder::new(events_tx, tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index e6e4e08cf7..0645859313 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -18,9 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum Event { // FIXME: move service::State to agama_utils::types::manager. StatusChanged, From 1fcc4055ef4f0629e1971d47b1b9ecfc055c0da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 13:08:57 +0100 Subject: [PATCH 170/917] Serialize new events as objects with a "type" --- rust/agama-utils/src/types/event.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index 0645859313..a9b413d8f4 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -22,6 +22,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type")] pub enum Event { // FIXME: move service::State to agama_utils::types::manager. StatusChanged, From e06f7dfba5cbae690caf0b8e4811204639b93de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 14:42:04 +0100 Subject: [PATCH 171/917] Add scope information to some events --- rust/agama-l10n/src/service.rs | 18 ++++++++++++------ rust/agama-l10n/src/start.rs | 2 +- rust/agama-utils/src/types/event.rs | 8 ++++++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 41162aa976..303f736331 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -30,6 +30,8 @@ use agama_utils::{ }; use async_trait::async_trait; +pub(crate) const SCOPE: &str = "localization"; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Unknown locale: {0}")] @@ -194,10 +196,10 @@ impl MessageHandler> for Service { None }; - _ = self - .issues - .cast(issue::message::Update::new("localization", issues)); - _ = self.events.send(Event::ProposalChanged); + _ = self.issues.cast(issue::message::Update::new(SCOPE, issues)); + _ = self.events.send(Event::ProposalChanged { + scope: SCOPE.to_string(), + }); Ok(()) } } @@ -226,7 +228,9 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateLocale) -> Result<(), Error> { self.state.system.locale = message.locale; - _ = self.events.send(Event::SystemChanged); + _ = self.events.send(Event::SystemChanged { + scope: SCOPE.to_string(), + }); Ok(()) } } @@ -235,7 +239,9 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateKeymap) -> Result<(), Error> { self.state.system.keymap = message.keymap; - _ = self.events.send(Event::SystemChanged); + _ = self.events.send(Event::SystemChanged { + scope: SCOPE.to_string(), + }); Ok(()) } } diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 8d3048a758..6ac38e2949 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -176,7 +176,7 @@ mod tests { assert!(proposal.is_some()); let event = events_rx.recv().await.expect("Did not receive the event"); - assert!(matches!(event, Event::ProposalChanged)); + assert!(matches!(event, Event::ProposalChanged { scope: _scope })); Ok(()) } diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index a9b413d8f4..76ed9b9ece 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -31,9 +31,13 @@ pub enum Event { /// Progress changed. ProgressChanged, /// Proposal changed. - ProposalChanged, + ProposalChanged { + scope: String, + }, /// The underlying system changed. - SystemChanged, + SystemChanged { + scope: String, + }, } pub type EventsSender = broadcast::Sender; From 4431cef91f262b1d9dc656416dec398eac75cbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 15:00:41 +0100 Subject: [PATCH 172/917] Adapt the web UI to the new events --- web/src/queries/issues.ts | 2 +- web/src/queries/l10n.ts | 2 +- web/src/queries/proposal.ts | 2 +- web/src/queries/system.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 37bb2253ee..b767800062 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -62,7 +62,7 @@ const useIssuesChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.name === "IssuesChanged") { + if (event.type === "IssuesChanged") { queryClient.invalidateQueries({ queryKey: ["issues"] }); queryClient.invalidateQueries({ queryKey: ["status"] }); } diff --git a/web/src/queries/l10n.ts b/web/src/queries/l10n.ts index cdb6edab51..9c87688e98 100644 --- a/web/src/queries/l10n.ts +++ b/web/src/queries/l10n.ts @@ -38,7 +38,7 @@ const useL10nConfigChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "l10n" && event.name === "ProposalChanged") { + if (event.type === "ProposalChanged" && event.scope === "localization") { queryClient.invalidateQueries({ queryKey: ["l10n"] }); } }); diff --git a/web/src/queries/proposal.ts b/web/src/queries/proposal.ts index 1b47019e56..c83df902f2 100644 --- a/web/src/queries/proposal.ts +++ b/web/src/queries/proposal.ts @@ -48,7 +48,7 @@ const useProposalChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "l10n" && event.name === "ProposalChanged") { + if (event.type === "ProposalChanged" && event.scope === "localization") { queryClient.invalidateQueries({ queryKey: ["proposal"] }); } }); diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index 924a2eb612..56ebf837d5 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -84,7 +84,7 @@ const useSystemChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "l10n" && event.name === "SystemChanged") { + if (event.type === "SystemChanged" && event.scope === "localization") { queryClient.invalidateQueries({ queryKey: ["system"] }); } }); From 4b774d935adda2bb395dbcb081b9ffc8cd2db7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 10 Oct 2025 12:51:22 +0100 Subject: [PATCH 173/917] Add types for progress --- rust/agama-utils/src/lib.rs | 8 ++----- rust/agama-utils/src/progress.rs | 3 --- rust/agama-utils/src/progress/message.rs | 9 ++++--- rust/agama-utils/src/progress/service.rs | 24 ++++++++++--------- rust/agama-utils/src/progress/start.rs | 13 +++++----- rust/agama-utils/src/types.rs | 2 ++ .../{progress/model.rs => types/progress.rs} | 13 +++++++--- 7 files changed, 38 insertions(+), 34 deletions(-) rename rust/agama-utils/src/{progress/model.rs => types/progress.rs} (87%) diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 3b2d568908..03edabac2e 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -21,16 +21,12 @@ //! This crate offers a set of utility struct and functions to be used accross //! other Agama's crates. -pub mod actor; - pub mod service; pub use service::Service; -pub mod issue; - +pub mod actor; pub mod dbus; +pub mod issue; pub mod openapi; - pub mod progress; - pub mod types; diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs index 702482b739..0aac74353a 100644 --- a/rust/agama-utils/src/progress.rs +++ b/rust/agama-utils/src/progress.rs @@ -25,6 +25,3 @@ pub mod service; pub use service::Service; pub mod message; - -mod model; -pub use model::Progress; diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 12db520a7f..15e57eb873 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::actor::Message; -use crate::progress::model::Progress; +use crate::{actor::Message, types::progress::Progress}; pub struct Get; @@ -81,12 +80,12 @@ impl Message for Next { type Reply = (); } -pub struct NextStep { +pub struct NextWithStep { pub scope: String, pub step: String, } -impl NextStep { +impl NextWithStep { pub fn new(scope: &str, step: &str) -> Self { Self { scope: scope.to_string(), @@ -95,7 +94,7 @@ impl NextStep { } } -impl Message for NextStep { +impl Message for NextWithStep { type Reply = (); } diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 4beb6a21cf..c049881b38 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -20,7 +20,7 @@ use crate::actor::{self, Actor, MessageHandler}; use crate::progress::message; -use crate::progress::model::Progress; +use crate::types::progress::{self, Progress}; use crate::types::{Event, EventsSender}; use async_trait::async_trait; use tokio::sync::broadcast; @@ -31,8 +31,8 @@ pub enum Error { DuplicatedProgress(String), #[error("Progress does not exist for {0}")] MissingProgress(String), - #[error("Next step does not exist for {0}")] - MissingStep(String), + #[error(transparent)] + Progress(#[from] progress::Error), #[error(transparent)] Event(#[from] broadcast::error::SendError), #[error(transparent)] @@ -105,20 +105,22 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Next) -> Result<(), Error> { - self.get_mut_progress(message.scope.as_str()) - .ok_or(Error::MissingProgress(message.scope)) - .and_then(|p| p.next())?; + let Some(progress) = self.get_mut_progress(message.scope.as_str()) else { + return Err(Error::MissingProgress(message.scope)); + }; + progress.next()?; self.events.send(Event::ProgressChanged)?; Ok(()) } } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, message: message::NextStep) -> Result<(), Error> { - self.get_mut_progress(message.scope.as_str()) - .ok_or(Error::MissingProgress(message.scope)) - .and_then(|p| p.next_step(message.step))?; +impl MessageHandler for Service { + async fn handle(&mut self, message: message::NextWithStep) -> Result<(), Error> { + let Some(progress) = self.get_mut_progress(message.scope.as_str()) else { + return Err(Error::MissingProgress(message.scope)); + }; + progress.next_with_step(message.step)?; self.events.send(Event::ProgressChanged)?; Ok(()) } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index d84b830ba9..0ccbc71df7 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -42,10 +42,9 @@ pub async fn start(events: EventsSender) -> Result, Error> { #[cfg(test)] mod tests { use crate::actor::{self, Handler}; - use crate::progress::{ - message, - service::{self, Service}, - }; + use crate::progress::message; + use crate::service::{self, Service}; + use crate::types::progress; use crate::types::{Event, EventsReceiver}; use tokio::sync::broadcast; @@ -81,7 +80,7 @@ mod tests { // Second step handler - .call(message::NextStep::new("test", "second step")) + .call(message::NextWithStep::new("test", "second step")) .await?; let event = receiver.recv().await.unwrap(); @@ -190,7 +189,9 @@ mod tests { handler.call(message::Start::new("test", 1, "")).await?; let error = handler.call(message::Next::new("test")).await; - assert!(matches!(error, Err(service::Error::MissingStep(scope)) if scope == "test")); + assert!( + matches!(error, Err(service::Error::Progress(progress::Error::MissingStep(scope))) if scope == "test") + ); Ok(()) } diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/types.rs index 205e803220..4d6c3d88e9 100644 --- a/rust/agama-utils/src/types.rs +++ b/rust/agama-utils/src/types.rs @@ -23,3 +23,5 @@ pub mod event; pub use event::{Event, EventsReceiver, EventsSender}; + +pub mod progress; diff --git a/rust/agama-utils/src/progress/model.rs b/rust/agama-utils/src/types/progress.rs similarity index 87% rename from rust/agama-utils/src/progress/model.rs rename to rust/agama-utils/src/types/progress.rs index 5b6a0d1e0a..2e3b049c10 100644 --- a/rust/agama-utils/src/progress/model.rs +++ b/rust/agama-utils/src/types/progress.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2025] SUSE LLC +// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // @@ -18,9 +18,16 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::progress::service::Error; +//! This module includes the struct that represent a service progress step. + use serde::Serialize; +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Next step does not exist for {0}")] + MissingStep(String), +} + #[derive(Clone, Default, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Progress { @@ -67,7 +74,7 @@ impl Progress { Ok(()) } - pub fn next_step(&mut self, step: String) -> Result<(), Error> { + pub fn next_with_step(&mut self, step: String) -> Result<(), Error> { self.next()?; self.step = step; Ok(()) From cf43466b8defcd6ac19a3f76e9ad07178f464c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 10 Oct 2025 15:28:30 +0100 Subject: [PATCH 174/917] Add scope to progress event --- rust/agama-utils/src/progress/service.rs | 35 +++++++++++++++++------- rust/agama-utils/src/progress/start.rs | 10 +++---- rust/agama-utils/src/types/event.rs | 4 ++- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index c049881b38..775b5785f3 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -82,9 +82,14 @@ impl MessageHandler for Service { if self.get_progress(message.scope.as_str()).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses - .push(Progress::new(message.scope, message.size, message.step)); - self.events.send(Event::ProgressChanged)?; + self.progresses.push(Progress::new( + message.scope.clone(), + message.size, + message.step, + )); + self.events.send(Event::ProgressChanged { + scope: message.scope, + })?; Ok(()) } } @@ -95,9 +100,13 @@ impl MessageHandler for Service { if self.get_progress(message.scope.as_str()).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses - .push(Progress::new_with_steps(message.scope, message.steps)); - self.events.send(Event::ProgressChanged)?; + self.progresses.push(Progress::new_with_steps( + message.scope.clone(), + message.steps, + )); + self.events.send(Event::ProgressChanged { + scope: message.scope, + })?; Ok(()) } } @@ -109,7 +118,9 @@ impl MessageHandler for Service { return Err(Error::MissingProgress(message.scope)); }; progress.next()?; - self.events.send(Event::ProgressChanged)?; + self.events.send(Event::ProgressChanged { + scope: message.scope, + })?; Ok(()) } } @@ -121,7 +132,9 @@ impl MessageHandler for Service { return Err(Error::MissingProgress(message.scope)); }; progress.next_with_step(message.step)?; - self.events.send(Event::ProgressChanged)?; + self.events.send(Event::ProgressChanged { + scope: message.scope, + })?; Ok(()) } } @@ -131,9 +144,11 @@ impl MessageHandler for Service { async fn handle(&mut self, message: message::Finish) -> Result<(), Error> { let index = self .get_progress_index(message.scope.as_str()) - .ok_or(Error::MissingProgress(message.scope))?; + .ok_or(Error::MissingProgress(message.scope.clone()))?; self.progresses.remove(index); - self.events.send(Event::ProgressChanged)?; + self.events.send(Event::ProgressChanged { + scope: message.scope, + })?; Ok(()) } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 0ccbc71df7..020da0a3ea 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -43,7 +43,7 @@ pub async fn start(events: EventsSender) -> Result, Error> { mod tests { use crate::actor::{self, Handler}; use crate::progress::message; - use crate::service::{self, Service}; + use crate::progress::service::{self, Service}; use crate::types::progress; use crate::types::{Event, EventsReceiver}; use tokio::sync::broadcast; @@ -66,7 +66,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged)); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); let progresses = handler.call(message::Get).await?; assert_eq!(progresses.len(), 1); @@ -84,7 +84,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged)); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -98,7 +98,7 @@ mod tests { handler.call(message::Next::new("test")).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged)); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -112,7 +112,7 @@ mod tests { handler.call(message::Finish::new("test")).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged)); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); let progresses = handler.call(message::Get).await.unwrap(); assert!(progresses.is_empty()); diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index 76ed9b9ece..ab60e6ff6e 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -29,7 +29,9 @@ pub enum Event { /// The list of issues has changed. IssuesChanged, /// Progress changed. - ProgressChanged, + ProgressChanged { + scope: String, + }, /// Proposal changed. ProposalChanged { scope: String, From 01a2ba195c5db86e0fff66c4b871edc5cfaa9a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 10 Oct 2025 16:36:18 +0100 Subject: [PATCH 175/917] Use Scope type in progress --- rust/agama-utils/src/progress/message.rs | 38 +++++------ rust/agama-utils/src/progress/service.rs | 36 +++++----- rust/agama-utils/src/progress/start.rs | 83 ++++++++++++++---------- rust/agama-utils/src/types.rs | 4 ++ rust/agama-utils/src/types/event.rs | 3 +- rust/agama-utils/src/types/progress.rs | 13 ++-- rust/agama-utils/src/types/scope.rs | 42 ++++++++++++ 7 files changed, 139 insertions(+), 80 deletions(-) create mode 100644 rust/agama-utils/src/types/scope.rs diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 15e57eb873..282defb8eb 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -18,7 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{actor::Message, types::progress::Progress}; +use crate::actor::Message; +use crate::types::progress::Progress; +use crate::types::scope::Scope; pub struct Get; @@ -27,15 +29,15 @@ impl Message for Get { } pub struct Start { - pub scope: String, + pub scope: Scope, pub size: usize, pub step: String, } impl Start { - pub fn new(scope: &str, size: usize, step: &str) -> Self { + pub fn new(scope: Scope, size: usize, step: &str) -> Self { Self { - scope: scope.to_string(), + scope, size, step: step.to_string(), } @@ -47,14 +49,14 @@ impl Message for Start { } pub struct StartWithSteps { - pub scope: String, + pub scope: Scope, pub steps: Vec, } impl StartWithSteps { - pub fn new(scope: &str, steps: &[&str]) -> Self { + pub fn new(scope: Scope, steps: &[&str]) -> Self { Self { - scope: scope.to_string(), + scope, steps: steps.into_iter().map(ToString::to_string).collect(), } } @@ -65,14 +67,12 @@ impl Message for StartWithSteps { } pub struct Next { - pub scope: String, + pub scope: Scope, } impl Next { - pub fn new(scope: &str) -> Self { - Self { - scope: scope.to_string(), - } + pub fn new(scope: Scope) -> Self { + Self { scope } } } @@ -81,14 +81,14 @@ impl Message for Next { } pub struct NextWithStep { - pub scope: String, + pub scope: Scope, pub step: String, } impl NextWithStep { - pub fn new(scope: &str, step: &str) -> Self { + pub fn new(scope: Scope, step: &str) -> Self { Self { - scope: scope.to_string(), + scope, step: step.to_string(), } } @@ -99,14 +99,12 @@ impl Message for NextWithStep { } pub struct Finish { - pub scope: String, + pub scope: Scope, } impl Finish { - pub fn new(scope: &str) -> Self { - Self { - scope: scope.to_string(), - } + pub fn new(scope: Scope) -> Self { + Self { scope } } } diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 775b5785f3..f2c37ae60e 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -21,6 +21,7 @@ use crate::actor::{self, Actor, MessageHandler}; use crate::progress::message; use crate::types::progress::{self, Progress}; +use crate::types::scope::Scope; use crate::types::{Event, EventsSender}; use async_trait::async_trait; use tokio::sync::broadcast; @@ -28,9 +29,9 @@ use tokio::sync::broadcast; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Progress already exists for {0}")] - DuplicatedProgress(String), + DuplicatedProgress(Scope), #[error("Progress does not exist for {0}")] - MissingProgress(String), + MissingProgress(Scope), #[error(transparent)] Progress(#[from] progress::Error), #[error(transparent)] @@ -52,15 +53,15 @@ impl Service { } } - fn get_progress(&self, scope: &str) -> Option<&Progress> { + fn get_progress(&self, scope: Scope) -> Option<&Progress> { self.progresses.iter().find(|p| p.scope == scope) } - fn get_mut_progress(&mut self, scope: &str) -> Option<&mut Progress> { + fn get_mut_progress(&mut self, scope: Scope) -> Option<&mut Progress> { self.progresses.iter_mut().find(|p| p.scope == scope) } - fn get_progress_index(&self, scope: &str) -> Option { + fn get_progress_index(&self, scope: Scope) -> Option { self.progresses.iter().position(|p| p.scope == scope) } } @@ -79,14 +80,11 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { - if self.get_progress(message.scope.as_str()).is_some() { + if self.get_progress(message.scope).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses.push(Progress::new( - message.scope.clone(), - message.size, - message.step, - )); + self.progresses + .push(Progress::new(message.scope, message.size, message.step)); self.events.send(Event::ProgressChanged { scope: message.scope, })?; @@ -97,13 +95,11 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::StartWithSteps) -> Result<(), Error> { - if self.get_progress(message.scope.as_str()).is_some() { + if self.get_progress(message.scope).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses.push(Progress::new_with_steps( - message.scope.clone(), - message.steps, - )); + self.progresses + .push(Progress::new_with_steps(message.scope, message.steps)); self.events.send(Event::ProgressChanged { scope: message.scope, })?; @@ -114,7 +110,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Next) -> Result<(), Error> { - let Some(progress) = self.get_mut_progress(message.scope.as_str()) else { + let Some(progress) = self.get_mut_progress(message.scope) else { return Err(Error::MissingProgress(message.scope)); }; progress.next()?; @@ -128,7 +124,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::NextWithStep) -> Result<(), Error> { - let Some(progress) = self.get_mut_progress(message.scope.as_str()) else { + let Some(progress) = self.get_mut_progress(message.scope) else { return Err(Error::MissingProgress(message.scope)); }; progress.next_with_step(message.step)?; @@ -143,8 +139,8 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::Finish) -> Result<(), Error> { let index = self - .get_progress_index(message.scope.as_str()) - .ok_or(Error::MissingProgress(message.scope.clone()))?; + .get_progress_index(message.scope) + .ok_or(Error::MissingProgress(message.scope))?; self.progresses.remove(index); self.events.send(Event::ProgressChanged { scope: message.scope, diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 020da0a3ea..186cf3ad33 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -45,6 +45,7 @@ mod tests { use crate::progress::message; use crate::progress::service::{self, Service}; use crate::types::progress; + use crate::types::scope::Scope; use crate::types::{Event, EventsReceiver}; use tokio::sync::broadcast; @@ -62,17 +63,17 @@ mod tests { // Start a progress (first step) handler - .call(message::Start::new("test", 3, "first step")) + .call(message::Start::new(Scope::L10n, 3, "first step")) .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); let progresses = handler.call(message::Get).await?; assert_eq!(progresses.len(), 1); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, "test"); + assert_eq!(progress.scope, Scope::L10n); assert_eq!(progress.size, 3); assert!(progress.steps.is_empty()); assert_eq!(progress.step, "first step"); @@ -80,39 +81,39 @@ mod tests { // Second step handler - .call(message::NextWithStep::new("test", "second step")) + .call(message::NextWithStep::new(Scope::L10n, "second step")) .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, "test"); + assert_eq!(progress.scope, Scope::L10n); assert_eq!(progress.size, 3); assert!(progress.steps.is_empty()); assert_eq!(progress.step, "second step"); assert_eq!(progress.index, 2); // Last step (without step text) - handler.call(message::Next::new("test")).await?; + handler.call(message::Next::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, "test"); + assert_eq!(progress.scope, Scope::L10n); assert_eq!(progress.size, 3); assert!(progress.steps.is_empty()); assert_eq!(progress.step, ""); assert_eq!(progress.index, 3); // Finish the progress - handler.call(message::Finish::new("test")).await?; + handler.call(message::Finish::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == "test")); + assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); let progresses = handler.call(message::Get).await.unwrap(); assert!(progresses.is_empty()); @@ -127,14 +128,14 @@ mod tests { // Start a progress (first step) handler .call(message::StartWithSteps::new( - "test", + Scope::L10n, &["first step", "second step", "third step"], )) .await?; let progresses = handler.call(message::Get).await?; let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, "test"); + assert_eq!(progress.scope, Scope::L10n); assert_eq!(progress.size, 3); assert_eq!(progress.steps.len(), 3); assert_eq!(progress.steps[0], "first step"); @@ -144,7 +145,7 @@ mod tests { assert_eq!(progress.index, 1); // Second step - handler.call(message::Next::new("test")).await?; + handler.call(message::Next::new(Scope::L10n)).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -152,7 +153,7 @@ mod tests { assert_eq!(progress.index, 2); // Third step - handler.call(message::Next::new("test")).await?; + handler.call(message::Next::new(Scope::L10n)).await?; let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -160,7 +161,7 @@ mod tests { assert_eq!(progress.index, 3); // Finish the progress - handler.call(message::Finish::new("test")).await?; + handler.call(message::Finish::new(Scope::L10n)).await?; let progresses = handler.call(message::Get).await.unwrap(); assert!(progresses.is_empty()); @@ -172,13 +173,17 @@ mod tests { async fn test_several_progresses() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test1", 2, "")).await?; - handler.call(message::Start::new("test2", 2, "")).await?; + handler + .call(message::Start::new(Scope::Manager, 2, "")) + .await?; + handler + .call(message::Start::new(Scope::L10n, 2, "")) + .await?; let progresses = handler.call(message::Get).await.unwrap(); assert_eq!(progresses.len(), 2); - assert_eq!(progresses[0].scope, "test1"); - assert_eq!(progresses[1].scope, "test2"); + assert_eq!(progresses[0].scope, Scope::Manager); + assert_eq!(progresses[1].scope, Scope::L10n); Ok(()) } @@ -187,11 +192,13 @@ mod tests { async fn test_progress_missing_step() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test", 1, "")).await?; - let error = handler.call(message::Next::new("test")).await; - assert!( - matches!(error, Err(service::Error::Progress(progress::Error::MissingStep(scope))) if scope == "test") - ); + handler + .call(message::Start::new(Scope::L10n, 1, "")) + .await?; + let error = handler.call(message::Next::new(Scope::L10n)).await; + assert!(matches!( + error, + Err(service::Error::Progress(progress::Error::MissingStep(scope))) if scope == Scope::L10n)); Ok(()) } @@ -200,9 +207,13 @@ mod tests { async fn test_missing_progress() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test1", 2, "")).await?; - let error = handler.call(message::Next::new("test2")).await; - assert!(matches!(error, Err(service::Error::MissingProgress(scope)) if scope == "test2")); + handler + .call(message::Start::new(Scope::Manager, 2, "")) + .await?; + let error = handler.call(message::Next::new(Scope::L10n)).await; + assert!( + matches!(error, Err(service::Error::MissingProgress(scope)) if scope == Scope::L10n) + ); Ok(()) } @@ -211,15 +222,21 @@ mod tests { async fn test_duplicated_progress() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); - handler.call(message::Start::new("test", 2, "")).await?; + handler + .call(message::Start::new(Scope::L10n, 2, "")) + .await?; - let error = handler.call(message::Start::new("test", 1, "")).await; - assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); + let error = handler.call(message::Start::new(Scope::L10n, 1, "")).await; + assert!( + matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == Scope::L10n) + ); let error = handler - .call(message::StartWithSteps::new("test", &["step"])) + .call(message::StartWithSteps::new(Scope::L10n, &["step"])) .await; - assert!(matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == "test")); + assert!( + matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == Scope::L10n) + ); Ok(()) } diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/types.rs index 4d6c3d88e9..ff500ad2a7 100644 --- a/rust/agama-utils/src/types.rs +++ b/rust/agama-utils/src/types.rs @@ -25,3 +25,7 @@ pub mod event; pub use event::{Event, EventsReceiver, EventsSender}; pub mod progress; +pub use progress::Progress; + +pub mod scope; +pub use scope::Scope; diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index ab60e6ff6e..bfe6d67972 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::types::scope::Scope; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -30,7 +31,7 @@ pub enum Event { IssuesChanged, /// Progress changed. ProgressChanged { - scope: String, + scope: Scope, }, /// Proposal changed. ProposalChanged { diff --git a/rust/agama-utils/src/types/progress.rs b/rust/agama-utils/src/types/progress.rs index 2e3b049c10..373b8ad9b7 100644 --- a/rust/agama-utils/src/types/progress.rs +++ b/rust/agama-utils/src/types/progress.rs @@ -20,19 +20,20 @@ //! This module includes the struct that represent a service progress step. +use crate::types::scope::Scope; use serde::Serialize; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Next step does not exist for {0}")] - MissingStep(String), + MissingStep(Scope), } -#[derive(Clone, Default, Serialize, utoipa::ToSchema)] +#[derive(Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Progress { /// Scope of the progress - pub scope: String, + pub scope: Scope, /// Max number of steps pub size: usize, /// List of steps @@ -44,7 +45,7 @@ pub struct Progress { } impl Progress { - pub fn new(scope: String, size: usize, step: String) -> Self { + pub fn new(scope: Scope, size: usize, step: String) -> Self { Self { scope, size, @@ -54,7 +55,7 @@ impl Progress { } } - pub fn new_with_steps(scope: String, steps: Vec) -> Self { + pub fn new_with_steps(scope: Scope, steps: Vec) -> Self { Self { scope, size: steps.len(), @@ -66,7 +67,7 @@ impl Progress { pub fn next(&mut self) -> Result<(), Error> { if self.index >= self.size { - return Err(Error::MissingStep(self.scope.clone())); + return Err(Error::MissingStep(self.scope)); } self.index += 1; diff --git a/rust/agama-utils/src/types/scope.rs b/rust/agama-utils/src/types/scope.rs new file mode 100644 index 0000000000..ff014a50ad --- /dev/null +++ b/rust/agama-utils/src/types/scope.rs @@ -0,0 +1,42 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module includes the struct that represent a service progress step. + +use serde::{Deserialize, Serialize}; + +/// Scope to distinguish each service. +#[derive( + Copy, + Clone, + Debug, + strum::EnumString, + strum::Display, + Deserialize, + Serialize, + utoipa::ToSchema, + PartialEq, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum Scope { + Manager, + L10n, +} From c70a58ab1f2ff4b945be73060e182b37abdd995e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 16:50:20 +0100 Subject: [PATCH 176/917] Emit IssuesChanged only when they change --- rust/agama-utils/src/issue/model.rs | 12 ++++++--- rust/agama-utils/src/issue/service.rs | 13 ++++++++- rust/agama-utils/src/issue/start.rs | 39 ++++++++++++++++++--------- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/rust/agama-utils/src/issue/model.rs b/rust/agama-utils/src/issue/model.rs index 3895abe290..d0248ba65d 100644 --- a/rust/agama-utils/src/issue/model.rs +++ b/rust/agama-utils/src/issue/model.rs @@ -31,7 +31,9 @@ pub enum Error { UnknownSeverity(u8), } -#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +// NOTE: in order to compare two issues, it should be enough to compare the description +// and the details. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Issue { pub description: String, @@ -41,7 +43,9 @@ pub struct Issue { pub kind: String, } -#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, Eq, Hash, utoipa::ToSchema, +)] #[repr(u8)] #[serde(rename_all = "camelCase")] pub enum IssueSource { @@ -50,7 +54,9 @@ pub enum IssueSource { Config = 2, } -#[derive(Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, utoipa::ToSchema)] +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, Eq, Hash, utoipa::ToSchema, +)] #[repr(u8)] #[serde(rename_all = "camelCase")] pub enum IssueSeverity { diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index fe17ee41c1..f66e43f93a 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -24,7 +24,7 @@ use crate::{ types::{Event, EventsSender}, }; use async_trait::async_trait; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -65,6 +65,17 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Update) -> Result<(), Error> { + // Compare whether the issues has changed. + let old_issues_hash: HashSet<_> = self + .issues + .get(&message.list) + .map(|v| v.iter().cloned().collect()) + .unwrap_or_default(); + let new_issues_hash: HashSet<_> = message.issues.iter().cloned().collect(); + if old_issues_hash == new_issues_hash { + return Ok(()); + } + if message.issues.is_empty() { _ = self.issues.remove(&message.list); } else { diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 969e8aab33..0830f8994d 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -56,21 +56,24 @@ mod tests { }; use tokio::sync::broadcast::{self, error::TryRecvError}; - #[tokio::test] - async fn test_get_and_update_issues() -> Result<(), Box> { - let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); - let issue = Issue { + fn build_issue() -> Issue { + Issue { description: "Product not selected".to_string(), kind: "missing_product".to_string(), details: Some("A product is required.".to_string()), source: IssueSource::Config, severity: IssueSeverity::Error, - }; + } + } + #[tokio::test] + async fn test_get_and_update_issues() -> Result<(), Box> { + let (events_tx, mut events_rx) = broadcast::channel::(16); + let issues = issue::start(events_tx, None).await.unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); + let issue = build_issue(); _ = issues .cast(message::Update::new("my-service", vec![issue])) .unwrap(); @@ -86,17 +89,11 @@ mod tests { async fn test_update_without_event() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); let issues = issue::start(events_tx, None).await.unwrap(); - let issue = Issue { - description: "Product not selected".to_string(), - kind: "missing_product".to_string(), - details: Some("A product is required.".to_string()), - source: IssueSource::Config, - severity: IssueSeverity::Error, - }; let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); + let issue = build_issue(); let update = message::Update::new("my-service", vec![issue]).notify(false); _ = issues.cast(update).unwrap(); @@ -106,4 +103,20 @@ mod tests { assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); Ok(()) } + + #[tokio::test] + async fn test_update_without_change() -> Result<(), Box> { + let (events_tx, mut events_rx) = broadcast::channel::(16); + let issues = issue::start(events_tx, None).await.unwrap(); + + let issue = build_issue(); + let update = message::Update::new("my-service", vec![issue.clone()]); + issues.call(update).await.unwrap(); + assert!(events_rx.try_recv().is_ok()); + + let update = message::Update::new("my-service", vec![issue]); + issues.call(update).await.unwrap(); + assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); + Ok(()) + } } From 8349224795e7b0919ab213c965a11ee880af18e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 10 Oct 2025 17:00:26 +0100 Subject: [PATCH 177/917] Minor improvements to Rust code formatting --- rust/agama-lib/src/http.rs | 2 +- rust/agama-lib/src/monitor.rs | 3 ++- rust/agama-server/src/storage/web.rs | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index 43a76f90b8..eb560a6d2d 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -22,7 +22,7 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; pub mod event; -pub use event::{OldEvent, EventPayload}; +pub use event::{EventPayload, OldEvent}; mod websocket; pub use websocket::{WebSocketClient, WebSocketError}; diff --git a/rust/agama-lib/src/monitor.rs b/rust/agama-lib/src/monitor.rs index 2a1bb229eb..a57c4843b1 100644 --- a/rust/agama-lib/src/monitor.rs +++ b/rust/agama-lib/src/monitor.rs @@ -56,7 +56,8 @@ use tokio::sync::{broadcast, mpsc, oneshot}; use crate::{ http::{ - BaseHTTPClient, BaseHTTPClientError, OldEvent, EventPayload, WebSocketClient, WebSocketError, + BaseHTTPClient, BaseHTTPClientError, EventPayload, OldEvent, WebSocketClient, + WebSocketError, }, manager::{InstallationPhase, InstallerStatus}, progress::Progress, diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index ccc0de2e4d..c9dc75315b 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -87,7 +87,9 @@ pub async fn storage_streams(dbus: zbus::Connection) -> Result Result, Error> { +async fn devices_dirty_stream( + dbus: zbus::Connection, +) -> Result, Error> { let proxy = Storage1Proxy::new(&dbus).await?; let stream = proxy .receive_deprecated_system_changed() From aca941d4093b4bbb279ccd36b1ba9275139c63fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 10 Oct 2025 17:27:18 +0100 Subject: [PATCH 178/917] Rename sender and receiver --- rust/agama-l10n/src/service.rs | 22 ++++++------- rust/agama-l10n/src/start.rs | 41 ++++++++++-------------- rust/agama-manager/src/start.rs | 24 ++++++++------ rust/agama-server/src/server/web.rs | 23 +++++++------ rust/agama-server/src/web.rs | 16 ++++----- rust/agama-server/src/web/service.rs | 6 ++-- rust/agama-server/src/web/state.rs | 4 +-- rust/agama-server/src/web/ws.rs | 4 +-- rust/agama-utils/src/issue/service.rs | 12 +++---- rust/agama-utils/src/issue/start.rs | 20 ++++-------- rust/agama-utils/src/progress/service.rs | 6 ++-- rust/agama-utils/src/progress/start.rs | 14 ++++---- rust/agama-utils/src/types.rs | 2 +- rust/agama-utils/src/types/event.rs | 4 +-- rust/agama-utils/src/types/scope.rs | 2 +- 15 files changed, 93 insertions(+), 107 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 303f736331..29756fdd0c 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -18,16 +18,16 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - config::Config, extended_config::ExtendedConfig, message, model::ModelAdapter, - proposal::Proposal, system_info::SystemInfo, -}; +use crate::config::Config; +use crate::extended_config::ExtendedConfig; +use crate::message; +use crate::model::ModelAdapter; +use crate::proposal::Proposal; +use crate::system_info::SystemInfo; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; -use agama_utils::{ - actor::{self, Actor, Handler, MessageHandler}, - issue::{self, Issue}, - types::{Event, EventsSender}, -}; +use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::issue::{self, Issue}; +use agama_utils::types::event::{self, Event}; use async_trait::async_trait; pub(crate) const SCOPE: &str = "localization"; @@ -70,7 +70,7 @@ pub struct Service { state: State, model: Box, issues: Handler, - events: EventsSender, + events: event::Sender, } struct State { @@ -83,7 +83,7 @@ impl Service { pub fn new( model: T, issues: Handler, - events: EventsSender, + events: event::Sender, ) -> Service { let system = SystemInfo::read_from(&model); let config = ExtendedConfig::new_from(&system); diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 6ac38e2949..95701c6f1a 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -18,16 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - model::Model, - monitor::{self, Monitor}, - service::{self, Service}, -}; -use agama_utils::{ - actor::{self, Handler}, - issue, - types::EventsSender, -}; +use crate::model::Model; +use crate::monitor::{self, Monitor}; +use crate::service::{self, Service}; +use agama_utils::actor::{self, Handler}; +use agama_utils::issue; +use agama_utils::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -50,7 +46,7 @@ pub enum Error { /// * `issues`: handler to the issues service. pub async fn start( issues: Handler, - events: EventsSender, + events: event::Sender, ) -> Result, Error> { let model = Model::from_system()?; let service = Service::new(model, issues, events); @@ -62,20 +58,17 @@ pub async fn start( #[cfg(test)] mod tests { - use crate::{ - message, - model::{ - Keymap, KeymapsDatabase, LocaleEntry, LocalesDatabase, ModelAdapter, TimezoneEntry, - TimezonesDatabase, - }, - service, Config, Service, + use crate::message; + use crate::model::{ + Keymap, KeymapsDatabase, LocaleEntry, LocalesDatabase, ModelAdapter, TimezoneEntry, + TimezonesDatabase, }; + use crate::service::{self, Service}; + use crate::Config; use agama_locale_data::{KeymapId, LocaleId}; - use agama_utils::{ - actor::{self, Handler}, - issue, - types::{Event, EventsReceiver}, - }; + use agama_utils::actor::{self, Handler}; + use agama_utils::issue; + use agama_utils::types::event::{self, Event}; use tokio::sync::broadcast; pub struct TestModel { @@ -141,7 +134,7 @@ mod tests { } } - async fn start_testing_service() -> (EventsReceiver, Handler, Handler) + async fn start_testing_service() -> (event::Receiver, Handler, Handler) { let (events_tx, events_rx) = broadcast::channel::(16); let issues = issue::start(events_tx.clone(), None).await.unwrap(); diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index dcbd4aabf3..06cb9c9bc4 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,12 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, service::Service}; -use agama_utils::{ - actor::{self, Handler}, - issue, progress, - types::EventsSender, -}; +use crate::l10n; +use crate::service::Service; +use agama_utils::actor::{self, Handler}; +use agama_utils::issue; +use agama_utils::progress; +use agama_utils::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -45,11 +45,11 @@ pub enum Error { /// /// It receives the following argument: /// -/// * `events`: channel to emit the [events](agama_lib::http::Event). +/// * `events`: channel to emit the [events](agama_utils::Event). /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( - events: EventsSender, + events: event::Sender, dbus: Option, ) -> Result, Error> { let issues = issue::start(events.clone(), dbus).await?; @@ -64,9 +64,13 @@ pub async fn start( #[cfg(test)] mod test { - use crate::{self as manager, l10n, message, service::Service}; + use crate::l10n; + use crate::message; + use crate::service::Service; + use crate::{self as manager}; use agama_lib::install_settings::InstallSettings; - use agama_utils::{actor::Handler, types::Event}; + use agama_utils::actor::Handler; + use agama_utils::types::Event; use tokio::sync::broadcast; async fn start_service() -> Handler { diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 9bcfa32af5..408e93f24b 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -21,16 +21,19 @@ //! This module implements Agama's HTTP API. use crate::server::types::{ConfigPatch, IssuesMap}; -use agama_lib::{error::ServiceError, install_settings::InstallSettings}; -use agama_manager::{self as manager, message, SystemInfo}; -use agama_utils::{actor::Handler, types::EventsSender}; +use agama_lib::error::ServiceError; +use agama_lib::install_settings::InstallSettings; +use agama_manager::message; +use agama_manager::SystemInfo; +use agama_manager::{self as manager}; +use agama_utils::actor::Handler; +use agama_utils::types::event; use anyhow; -use axum::{ - extract::State, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Json; +use axum::Router; use hyper::StatusCode; use serde::Serialize; use serde_json::json; @@ -64,7 +67,7 @@ type ServerResult = Result; /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( - events: EventsSender, + events: event::Sender, dbus: Option, ) -> Result { let manager = manager::start(events, dbus) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 7790673d64..3f6f69a382 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -41,7 +41,7 @@ use crate::{ users::web::{users_service, users_streams}, web::common::{jobs_stream, service_status_stream}, }; -use agama_utils::types::EventsSender; +use agama_utils::types::event; use axum::Router; mod auth; @@ -53,11 +53,7 @@ mod service; mod state; mod ws; -use agama_lib::{ - connection, - error::ServiceError, - http::event::{self, OldEvent}, -}; +use agama_lib::{connection, error::ServiceError, http::event::OldEvent}; use common::ProgressService; pub use config::ServiceConfig; pub use service::MainServiceBuilder; @@ -72,8 +68,8 @@ use tokio_stream::{StreamExt, StreamMap}; /// * `web_ui_dir`: public directory containing the web UI. pub async fn service

    ( config: ServiceConfig, - events: EventsSender, - old_events: event::OldSender, + events: event::Sender, + old_events: OldSender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result @@ -120,7 +116,7 @@ where /// The events are sent to the `events` channel. /// /// * `events`: channel to send the events to. -pub async fn run_monitor(events: event::OldSender) -> Result<(), ServiceError> { +pub async fn run_monitor(events: OldSender) -> Result<(), ServiceError> { let connection = connection().await?; tokio::spawn(run_events_monitor(connection, events.clone())); @@ -131,7 +127,7 @@ pub async fn run_monitor(events: event::OldSender) -> Result<(), ServiceError> { /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. -async fn run_events_monitor(dbus: zbus::Connection, events: event::OldSender) -> Result<(), Error> { +async fn run_events_monitor(dbus: zbus::Connection, events: OldSender) -> Result<(), Error> { let mut stream = StreamMap::new(); stream.insert("manager", manager_stream(dbus.clone()).await?); diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 9646af835f..36e43e3b79 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -21,7 +21,7 @@ use super::http::{login, login_from_query, logout, session}; use super::{config::ServiceConfig, state::ServiceState}; use agama_lib::{auth::TokenClaims, http}; -use agama_utils::types::EventsSender; +use agama_utils::types::event; use axum::http::HeaderValue; use axum::middleware::Next; use axum::{ @@ -56,7 +56,7 @@ use tracing::Span; /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, - events: EventsSender, + events: event::Sender, old_events: http::event::OldSender, api_router: Router, public_dir: PathBuf, @@ -67,7 +67,7 @@ impl MainServiceBuilder { /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. - pub fn new

    (events: EventsSender, old_events: http::event::OldSender, public_dir: P) -> Self + pub fn new

    (events: event::Sender, old_events: http::event::OldSender, public_dir: P) -> Self where P: AsRef, { diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index fc07df8c1a..936d122d10 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -22,7 +22,7 @@ use super::config::ServiceConfig; use agama_lib::http; -use agama_utils::types::EventsSender; +use agama_utils::types::event; use std::path::PathBuf; /// Web service state. @@ -31,7 +31,7 @@ use std::path::PathBuf; #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, - pub events: EventsSender, + pub events: event::Sender, pub old_events: http::event::OldSender, pub public_dir: PathBuf, } diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 563e96ff21..a4c4707815 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -22,7 +22,7 @@ use super::state::ServiceState; use agama_lib::{auth::ClientId, http}; -use agama_utils::types::EventsSender; +use agama_utils::types::event; use axum::{ extract::{ ws::{Message, WebSocket}, @@ -55,7 +55,7 @@ pub async fn ws_handler( async fn handle_socket( mut socket: WebSocket, - events: EventsSender, + events: event::Sender, old_events: http::event::OldSender, client_id: Arc, ) { diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index f66e43f93a..e422318fce 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -18,11 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{message, Issue}; -use crate::{ - actor::{self, Actor, MessageHandler}, - types::{Event, EventsSender}, -}; +use crate::actor::{self, Actor, MessageHandler}; +use crate::issue::{message, Issue}; +use crate::types::event::{self, Event}; use async_trait::async_trait; use std::collections::{HashMap, HashSet}; @@ -36,11 +34,11 @@ pub enum Error { pub struct Service { issues: HashMap>, - events: EventsSender, + events: event::Sender, } impl Service { - pub fn new(events: EventsSender) -> Self { + pub fn new(events: event::Sender) -> Self { Self { issues: HashMap::new(), events, diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 0830f8994d..67d4c6217d 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -18,14 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{ - monitor::{self, Monitor}, - service, Service, -}; -use crate::{ - actor::{self, Handler}, - types::EventsSender, -}; +use crate::actor::{self, Handler}; +use crate::issue::monitor::{self, Monitor}; +use crate::issue::service::{self, Service}; +use crate::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -34,7 +30,7 @@ pub enum Error { } pub async fn start( - events: EventsSender, + events: event::Sender, dbus: Option, ) -> Result, Error> { let service = Service::new(events); @@ -50,10 +46,8 @@ pub async fn start( #[cfg(test)] mod tests { - use crate::{ - issue::{self, message, Issue, IssueSeverity, IssueSource}, - types::Event, - }; + use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; + use crate::types::event::Event; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index f2c37ae60e..91a82f9552 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -20,9 +20,9 @@ use crate::actor::{self, Actor, MessageHandler}; use crate::progress::message; +use crate::types::event::{self, Event}; use crate::types::progress::{self, Progress}; use crate::types::scope::Scope; -use crate::types::{Event, EventsSender}; use async_trait::async_trait; use tokio::sync::broadcast; @@ -41,12 +41,12 @@ pub enum Error { } pub struct Service { - events: EventsSender, + events: event::Sender, progresses: Vec, } impl Service { - pub fn new(events: EventsSender) -> Service { + pub fn new(events: event::Sender) -> Service { Self { events, progresses: Vec::new(), diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 186cf3ad33..94d901f41c 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -18,11 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - actor::{self, Handler}, - progress::service::Service, - types::EventsSender, -}; +use crate::actor::{self, Handler}; +use crate::progress::service::Service; +use crate::types::event; use std::convert::Infallible; #[derive(thiserror::Error, Debug)] @@ -34,7 +32,7 @@ pub enum Error { /// Starts the progress service. /// /// * `events`: channel to emit the [events](agama_utils::types::Event). -pub async fn start(events: EventsSender) -> Result, Error> { +pub async fn start(events: event::Sender) -> Result, Error> { let handler = actor::spawn(Service::new(events)); Ok(handler) } @@ -44,12 +42,12 @@ mod tests { use crate::actor::{self, Handler}; use crate::progress::message; use crate::progress::service::{self, Service}; + use crate::types::event::{self, Event}; use crate::types::progress; use crate::types::scope::Scope; - use crate::types::{Event, EventsReceiver}; use tokio::sync::broadcast; - fn start_testing_service() -> (EventsReceiver, Handler) { + fn start_testing_service() -> (event::Receiver, Handler) { let (events, receiver) = broadcast::channel::(16); let service = Service::new(events); diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/types.rs index ff500ad2a7..89aad34a9e 100644 --- a/rust/agama-utils/src/types.rs +++ b/rust/agama-utils/src/types.rs @@ -22,7 +22,7 @@ //! the HTTP and WebSocket API. pub mod event; -pub use event::{Event, EventsReceiver, EventsSender}; +pub use event::Event; pub mod progress; pub use progress::Progress; diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index bfe6d67972..db13f102db 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -43,5 +43,5 @@ pub enum Event { }, } -pub type EventsSender = broadcast::Sender; -pub type EventsReceiver = broadcast::Receiver; +pub type Sender = broadcast::Sender; +pub type Receiver = broadcast::Receiver; diff --git a/rust/agama-utils/src/types/scope.rs b/rust/agama-utils/src/types/scope.rs index ff014a50ad..9d62a782f1 100644 --- a/rust/agama-utils/src/types/scope.rs +++ b/rust/agama-utils/src/types/scope.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // From 8ade24d1b4fb4d9dbb08e03b2d0397fcc163bb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 10 Oct 2025 17:40:41 +0100 Subject: [PATCH 179/917] Fix manager service --- rust/agama-manager/src/message.rs | 11 ++++++++--- rust/agama-manager/src/service.rs | 24 ++++++++++-------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 3571189ebf..31eb7ddbbe 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,9 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, proposal::Proposal, service, system_info::SystemInfo}; -use agama_lib::{install_settings::InstallSettings, issue::Issue}; -use agama_utils::{actor::Message, progress::Progress}; +use crate::l10n; +use crate::proposal::Proposal; +use crate::service; +use crate::system_info::SystemInfo; +use agama_lib::install_settings::InstallSettings; +use agama_lib::issue::Issue; +use agama_utils::actor::Message; +use agama_utils::types::Progress; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index d5b1fd85cd..c0fd47fb75 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,24 +18,20 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - l10n, - message::{self, Action}, - proposal::Proposal, - system_info::SystemInfo, -}; +use crate::l10n; +use crate::message::{self, Action}; +use crate::proposal::Proposal; +use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; -use agama_utils::{ - actor::{self, Actor, Handler, MessageHandler}, - issue, progress, -}; +use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::issue; +use agama_utils::progress; +use agama_utils::types::Scope; use async_trait::async_trait; use merge_struct::merge; use serde::Serialize; use std::collections::HashMap; -const PROGRESS_SCOPE: &str = "main"; - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Cannot merge the configuration")] @@ -89,14 +85,14 @@ impl Service { // TODO: translate progress steps. self.progress .call(progress::message::StartWithSteps::new( - PROGRESS_SCOPE, + Scope::Manager, &["Installing l10n"], )) .await?; self.l10n.call(l10n::message::Install).await?; self.state = State::Finished; self.progress - .call(progress::message::Finish::new(PROGRESS_SCOPE)) + .call(progress::message::Finish::new(Scope::Manager)) .await?; Ok(()) } From 1b73f71c1a05a190d226148fab077bc850d2ee66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 07:05:22 +0100 Subject: [PATCH 180/917] Some fixes --- rust/agama-manager/src/message.rs | 2 +- rust/agama-server/src/web.rs | 4 ++- rust/agama-server/src/web/docs/config.rs | 2 +- rust/agama-utils/src/progress/start.rs | 46 ++++++++++++++++-------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 31eb7ddbbe..53cd511078 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -23,8 +23,8 @@ use crate::proposal::Proposal; use crate::service; use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; -use agama_lib::issue::Issue; use agama_utils::actor::Message; +use agama_utils::issue::Issue; use agama_utils::types::Progress; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 3f6f69a382..04792b31cc 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -53,7 +53,9 @@ mod service; mod state; mod ws; -use agama_lib::{connection, error::ServiceError, http::event::OldEvent}; +use agama_lib::connection; +use agama_lib::error::ServiceError; +use agama_lib::http::event::{OldEvent, OldSender}; use common::ProgressService; pub use config::ServiceConfig; pub use service::MainServiceBuilder; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index b4d203057c..d55815708d 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -165,7 +165,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 94d901f41c..2ea4704467 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -65,7 +65,10 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); + assert!(matches!( + event, + Event::ProgressChanged { scope: Scope::L10n } + )); let progresses = handler.call(message::Get).await?; assert_eq!(progresses.len(), 1); @@ -83,7 +86,10 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); + assert!(matches!( + event, + Event::ProgressChanged { scope: Scope::L10n } + )); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -97,7 +103,10 @@ mod tests { handler.call(message::Next::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); + assert!(matches!( + event, + Event::ProgressChanged { scope: Scope::L10n } + )); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -111,7 +120,10 @@ mod tests { handler.call(message::Finish::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { scope } if scope == Scope::L10n)); + assert!(matches!( + event, + Event::ProgressChanged { scope: Scope::L10n } + )); let progresses = handler.call(message::Get).await.unwrap(); assert!(progresses.is_empty()); @@ -196,7 +208,10 @@ mod tests { let error = handler.call(message::Next::new(Scope::L10n)).await; assert!(matches!( error, - Err(service::Error::Progress(progress::Error::MissingStep(scope))) if scope == Scope::L10n)); + Err(service::Error::Progress(progress::Error::MissingStep( + Scope::L10n + ))) + )); Ok(()) } @@ -209,9 +224,10 @@ mod tests { .call(message::Start::new(Scope::Manager, 2, "")) .await?; let error = handler.call(message::Next::new(Scope::L10n)).await; - assert!( - matches!(error, Err(service::Error::MissingProgress(scope)) if scope == Scope::L10n) - ); + assert!(matches!( + error, + Err(service::Error::MissingProgress(Scope::L10n)) + )); Ok(()) } @@ -225,16 +241,18 @@ mod tests { .await?; let error = handler.call(message::Start::new(Scope::L10n, 1, "")).await; - assert!( - matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == Scope::L10n) - ); + assert!(matches!( + error, + Err(service::Error::DuplicatedProgress(Scope::L10n)) + )); let error = handler .call(message::StartWithSteps::new(Scope::L10n, &["step"])) .await; - assert!( - matches!(error, Err(service::Error::DuplicatedProgress(scope)) if scope == Scope::L10n) - ); + assert!(matches!( + error, + Err(service::Error::DuplicatedProgress(Scope::L10n)) + )); Ok(()) } From 775042a9b94d6ffd25da0366f796b0ba4ee8b8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 10:50:43 +0100 Subject: [PATCH 181/917] Add scope to progress --- rust/agama-l10n/src/service.rs | 16 ++++++++++------ rust/agama-l10n/src/start.rs | 6 +++++- rust/agama-utils/src/types/event.rs | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 29756fdd0c..63c143c065 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -28,7 +28,9 @@ use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, Key use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::issue::{self, Issue}; use agama_utils::types::event::{self, Event}; +use agama_utils::types::scope::Scope; use async_trait::async_trait; +use tokio::sync::broadcast; pub(crate) const SCOPE: &str = "localization"; @@ -46,8 +48,10 @@ pub enum Error { InvalidKeymap(#[from] InvalidKeymapId), #[error(transparent)] InvalidTimezone(#[from] InvalidTimezoneId), - #[error("l10n service could not send the event")] - Event, + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + Issue(#[from] issue::service::Error), #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] @@ -196,10 +200,10 @@ impl MessageHandler> for Service { None }; - _ = self.issues.cast(issue::message::Update::new(SCOPE, issues)); - _ = self.events.send(Event::ProposalChanged { - scope: SCOPE.to_string(), - }); + self.issues + .cast(issue::message::Update::new(SCOPE, issues))?; + self.events + .send(Event::ProposalChanged { scope: Scope::L10n })?; Ok(()) } } diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 95701c6f1a..23cd232a3b 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -69,6 +69,7 @@ mod tests { use agama_utils::actor::{self, Handler}; use agama_utils::issue; use agama_utils::types::event::{self, Event}; + use agama_utils::types::scope::Scope; use tokio::sync::broadcast; pub struct TestModel { @@ -169,7 +170,10 @@ mod tests { assert!(proposal.is_some()); let event = events_rx.recv().await.expect("Did not receive the event"); - assert!(matches!(event, Event::ProposalChanged { scope: _scope })); + assert!(matches!( + event, + Event::ProposalChanged { scope: Scope::L10n } + )); Ok(()) } diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index db13f102db..b21463aeb3 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -35,7 +35,7 @@ pub enum Event { }, /// Proposal changed. ProposalChanged { - scope: String, + scope: Scope, }, /// The underlying system changed. SystemChanged { From 08d7812b3d27d9f823aa38f67177be3a59ec2d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Mon, 13 Oct 2025 13:37:18 +0200 Subject: [PATCH 182/917] Select software patterns --- rust/agama-server/src/software_ng/web.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs index 345df1962c..dd133081bb 100644 --- a/rust/agama-server/src/software_ng/web.rs +++ b/rust/agama-server/src/software_ng/web.rs @@ -171,6 +171,23 @@ async fn set_config( state.client.select_product(&product).await?; } + if let Some(patterns) = config.patterns { + let selected_patterns: Vec<_> = patterns + .iter() + .filter(|(_name, selected)| **selected) + .map(|(name, _selected)| name.as_str()) + .collect(); + + tracing::info!("Setting selected patterns: {:?}", selected_patterns); + + state.client.set_resolvables( + "user_patterns", + ResolvableType::Pattern, + &selected_patterns, + false, + )? + } + Ok(()) } From 68b75a8af2b229d1f24620b1da8fb2c7ab074876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Mon, 13 Oct 2025 13:37:57 +0200 Subject: [PATCH 183/917] Temporarily disable reading repositories to avoid crash --- web/src/components/software/SoftwarePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index 707d51e996..4428406729 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -136,7 +136,8 @@ function SoftwarePage(): React.ReactNode { const issues = useIssues("software"); const proposal = useSoftwareProposal(); const patterns = usePatterns(); - const repos = useRepositories(); + // FIXME: temporarily disabled, the API end point is not implemented yet + const repos = []; // useRepositories(); const [loading, setLoading] = useState(false); const { mutate: probe } = useRepositoryMutation(() => setLoading(false)); From f50291be49ae255915cd9fdd74c57d489fd788c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Mon, 13 Oct 2025 13:45:58 +0200 Subject: [PATCH 184/917] Fix web frontend build --- web/src/components/software/SoftwarePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index 4428406729..c4b04929e5 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -41,7 +41,6 @@ import { usePatterns, useSoftwareProposal, useSoftwareProposalChanges, - useRepositories, useRepositoryMutation, } from "~/queries/software"; import { Pattern, SelectedBy } from "~/types/software"; From c36668ca142a0ba55694b81b0023d0b44cf3bef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Mon, 13 Oct 2025 15:13:48 +0200 Subject: [PATCH 185/917] Create the libzypp lock also in the target directory To allow using "zypper" in the Live ISO system --- rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 7c043ae922..44413445ed 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -192,6 +192,9 @@ struct Zypp *init_target(const char *root, struct Status *status, const std::string root_str(root); + // create the libzypp lock also in the target directory + setenv("ZYPP_LOCKFILE_ROOT", root, 1 /* allow overwrite */); + struct Zypp *zypp = NULL; try { zypp::RepoManagerOptions repo_manager_options(root); From c51d7490a9ee1b28f350c0e1f5fdcbdc3b39721e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 12:23:29 +0100 Subject: [PATCH 186/917] Add scope to issues --- rust/agama-l10n/src/service.rs | 2 +- rust/agama-l10n/src/start.rs | 2 +- rust/agama-manager/src/message.rs | 3 +- rust/agama-manager/src/service.rs | 2 +- rust/agama-server/src/server/types.rs | 17 ++++++----- rust/agama-server/src/web/docs/config.rs | 3 ++ rust/agama-server/tests/server_service.rs | 8 ++++- rust/agama-utils/src/issue.rs | 3 +- rust/agama-utils/src/issue/message.rs | 11 +++---- rust/agama-utils/src/issue/monitor.rs | 36 ++++++++++++----------- rust/agama-utils/src/issue/service.rs | 22 +++++++++----- rust/agama-utils/src/issue/start.rs | 9 +++--- rust/agama-utils/src/types/event.rs | 4 ++- rust/agama-utils/src/types/scope.rs | 7 +++++ 14 files changed, 80 insertions(+), 49 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 63c143c065..c7c50ff332 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -201,7 +201,7 @@ impl MessageHandler> for Service { }; self.issues - .cast(issue::message::Update::new(SCOPE, issues))?; + .cast(issue::message::Update::new(Scope::L10n, issues))?; self.events .send(Event::ProposalChanged { scope: Scope::L10n })?; Ok(()) diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 23cd232a3b..ae1100bfff 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -224,7 +224,7 @@ mod tests { let _ = handler.call(message::SetConfig::new(config)).await?; let found_issues = issues.call(issue::message::Get).await?; - let l10n_issues = found_issues.get("localization").unwrap(); + let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); assert_eq!(l10n_issues.len(), 3); let proposal = handler.call(message::GetProposal).await?; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 53cd511078..3852363770 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -25,6 +25,7 @@ use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; use agama_utils::issue::Issue; +use agama_utils::types::scope::Scope; use agama_utils::types::Progress; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -116,7 +117,7 @@ impl Message for GetProposal { pub struct GetIssues; impl Message for GetIssues { - type Reply = HashMap>; + type Reply = HashMap>; } /// Runs the given action. diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index c0fd47fb75..5a55305e60 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -198,7 +198,7 @@ impl MessageHandler for Service { async fn handle( &mut self, _message: message::GetIssues, - ) -> Result>, Error> { + ) -> Result>, Error> { Ok(self.issues.call(issue::message::Get).await?) } } diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index 639d232211..0883747e1f 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -22,6 +22,7 @@ use agama_lib::install_settings::InstallSettings; use agama_utils::issue; +use agama_utils::types::Scope; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -48,15 +49,15 @@ pub struct IssuesMap { pub users: Vec, } -impl From>> for IssuesMap { - fn from(mut value: HashMap>) -> Self { +impl From>> for IssuesMap { + fn from(mut value: HashMap>) -> Self { Self { - iscsi: value.remove("iscsi").unwrap_or_default(), - localization: value.remove("localization").unwrap_or_default(), - product: value.remove("product").unwrap_or_default(), - software: value.remove("software").unwrap_or_default(), - storage: value.remove("storage").unwrap_or_default(), - users: value.remove("users").unwrap_or_default(), + iscsi: value.remove(&Scope::Iscsi).unwrap_or_default(), + localization: value.remove(&Scope::L10n).unwrap_or_default(), + product: value.remove(&Scope::Product).unwrap_or_default(), + software: value.remove(&Scope::Software).unwrap_or_default(), + storage: value.remove(&Scope::Storage).unwrap_or_default(), + users: value.remove(&Scope::Users).unwrap_or_default(), } } } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index d55815708d..36fc4fb817 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -166,6 +166,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index bd6e62c416..a7f74819bf 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -35,7 +35,13 @@ use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; async fn build_server_service() -> Result { - let (tx, _rx) = channel(16); + let (tx, mut rx) = channel(16); + + tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + println!("{:?}", event); + } + }); server_service(tx, None).await } diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index 1b6f8709c1..eb2c186430 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -31,13 +31,14 @@ //! //! ```no_run //! use agama_utils::issue::{self, message}; +//! use agama_utils::types::Scope; //! use tokio::sync::broadcast; //! //! # tokio_test::block_on(async { //! async fn use_issues_service() { //! let (events_tx, _events_rx) = broadcast::channel(16); //! let issues = issue::start(events_tx, None).await.unwrap(); -//! _ = issues.call(message::Update::new("my-service", vec![])); +//! _ = issues.call(message::Update::new(Scope::Manager, vec![])); //! } //! # }); //! diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index b0f258a2b2..f5e04f17b7 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -18,28 +18,29 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::Issue; use crate::actor::Message; +use crate::issue::Issue; +use crate::types::Scope; use std::collections::HashMap; pub struct Get; impl Message for Get { - type Reply = HashMap>; + type Reply = HashMap>; } // FIXME: consider an alternative approach to avoid pub(crate), // making it only visible to the service. pub struct Update { - pub(crate) list: String, + pub(crate) scope: Scope, pub(crate) issues: Vec, pub(crate) notify: bool, } impl Update { - pub fn new(list: &str, issues: Vec) -> Self { + pub fn new(scope: Scope, issues: Vec) -> Self { Self { - list: list.to_string(), + scope, issues, notify: true, } diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 3da2eb91bc..58774c7e11 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -18,9 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{actor::Handler, dbus::build_properties_changed_stream}; - -use super::{message, model, Issue, Service}; +use crate::actor::Handler; +use crate::dbus::build_properties_changed_stream; +use crate::issue::{self, message, model, Issue, Service}; +use crate::types::scope::Scope; use tokio_stream::StreamExt; use zbus::fdo::PropertiesChanged; use zbus::names::BusName; @@ -29,13 +30,15 @@ use zvariant::OwnedObjectPath; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error(transparent)] - DBus(#[from] zbus::Error), #[error("Error parsing issues from D-Bus: {0}")] InvalidIssue(#[from] zbus::zvariant::Error), #[error("Invalid D-Bus name")] InvalidDBusName(#[from] zbus::names::Error), #[error(transparent)] + DBus(#[from] zbus::Error), + #[error(transparent)] + Issue(#[from] issue::service::Error), + #[error(transparent)] Model(#[from] model::Error), } @@ -155,11 +158,10 @@ impl Monitor { /// Updates the list of issues. fn update_issues(&self, path: &str, issues: Vec, notify: bool) -> Result<(), Error> { - match Self::list_id_from_path(path) { - Some(list) => { - _ = self - .handler - .cast(message::Update::new(list, issues).notify(notify)); + match Self::scope_from_path(path) { + Some(scope) => { + self.handler + .cast(message::Update::new(scope, issues).notify(notify))?; } None => { eprintln!("Unknown issues object {}", path); @@ -168,14 +170,14 @@ impl Monitor { Ok(()) } - /// Turns the D-Bus path into an issues list ID. - fn list_id_from_path(path: &str) -> Option<&'static str> { + /// Turns the D-Bus path into a scope. + fn scope_from_path(path: &str) -> Option { match path { - SOFTWARE_PATH => Some("software"), - PRODUCT_PATH => Some("product"), - STORAGE_PATH => Some("storage"), - USERS_PATH => Some("users"), - ISCSI_PATH => Some("iscsi"), + SOFTWARE_PATH => Some(Scope::Software), + PRODUCT_PATH => Some(Scope::Product), + STORAGE_PATH => Some(Scope::Storage), + USERS_PATH => Some(Scope::Users), + ISCSI_PATH => Some(Scope::Iscsi), _ => None, } } diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index e422318fce..67b8579668 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -19,21 +19,25 @@ // find current contact information at www.suse.com. use crate::actor::{self, Actor, MessageHandler}; -use crate::issue::{message, Issue}; +use crate::issue::{message, model, Issue}; use crate::types::event::{self, Event}; +use crate::types::Scope; use async_trait::async_trait; use std::collections::{HashMap, HashSet}; +use tokio::sync::broadcast; #[derive(thiserror::Error, Debug)] pub enum Error { + #[error(transparent)] + Event(#[from] broadcast::error::SendError), #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] - Model(#[from] super::model::Error), + Model(#[from] model::Error), } pub struct Service { - issues: HashMap>, + issues: HashMap>, events: event::Sender, } @@ -55,7 +59,7 @@ impl MessageHandler for Service { async fn handle( &mut self, _message: message::Get, - ) -> Result>, Error> { + ) -> Result>, Error> { Ok(self.issues.clone()) } } @@ -66,7 +70,7 @@ impl MessageHandler for Service { // Compare whether the issues has changed. let old_issues_hash: HashSet<_> = self .issues - .get(&message.list) + .get(&message.scope) .map(|v| v.iter().cloned().collect()) .unwrap_or_default(); let new_issues_hash: HashSet<_> = message.issues.iter().cloned().collect(); @@ -75,13 +79,15 @@ impl MessageHandler for Service { } if message.issues.is_empty() { - _ = self.issues.remove(&message.list); + _ = self.issues.remove(&message.scope); } else { - self.issues.insert(message.list, message.issues); + self.issues.insert(message.scope, message.issues); } if message.notify { - _ = self.events.send(Event::IssuesChanged); + self.events.send(Event::IssuesChanged { + scope: message.scope, + })?; } Ok(()) } diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 67d4c6217d..ad624e7d0b 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -48,6 +48,7 @@ pub async fn start( mod tests { use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; use crate::types::event::Event; + use crate::types::scope::Scope; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { @@ -69,7 +70,7 @@ mod tests { let issue = build_issue(); _ = issues - .cast(message::Update::new("my-service", vec![issue])) + .cast(message::Update::new(Scope::Manager, vec![issue])) .unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -88,7 +89,7 @@ mod tests { assert!(issues_list.is_empty()); let issue = build_issue(); - let update = message::Update::new("my-service", vec![issue]).notify(false); + let update = message::Update::new(Scope::Manager, vec![issue]).notify(false); _ = issues.cast(update).unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -104,11 +105,11 @@ mod tests { let issues = issue::start(events_tx, None).await.unwrap(); let issue = build_issue(); - let update = message::Update::new("my-service", vec![issue.clone()]); + let update = message::Update::new(Scope::Manager, vec![issue.clone()]); issues.call(update).await.unwrap(); assert!(events_rx.try_recv().is_ok()); - let update = message::Update::new("my-service", vec![issue]); + let update = message::Update::new(Scope::Manager, vec![issue]); issues.call(update).await.unwrap(); assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); Ok(()) diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index b21463aeb3..bceca4c8e1 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -28,7 +28,9 @@ pub enum Event { // FIXME: move service::State to agama_utils::types::manager. StatusChanged, /// The list of issues has changed. - IssuesChanged, + IssuesChanged { + scope: Scope, + }, /// Progress changed. ProgressChanged { scope: Scope, diff --git a/rust/agama-utils/src/types/scope.rs b/rust/agama-utils/src/types/scope.rs index 9d62a782f1..a7e1e8bf63 100644 --- a/rust/agama-utils/src/types/scope.rs +++ b/rust/agama-utils/src/types/scope.rs @@ -27,6 +27,8 @@ use serde::{Deserialize, Serialize}; Copy, Clone, Debug, + Eq, + Hash, strum::EnumString, strum::Display, Deserialize, @@ -39,4 +41,9 @@ use serde::{Deserialize, Serialize}; pub enum Scope { Manager, L10n, + Product, + Software, + Storage, + Iscsi, + Users, } From e00ede5eb5b512962ab4c433d79fa0089e5d4306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 14:31:19 +0100 Subject: [PATCH 187/917] Add event for changes in installation state --- rust/agama-manager/src/message.rs | 16 ++------- rust/agama-manager/src/service.rs | 29 +++++++-------- rust/agama-manager/src/start.rs | 22 +++++++++--- rust/agama-server/src/server/web.rs | 4 +-- rust/agama-server/src/web/docs/config.rs | 2 -- rust/agama-utils/src/types.rs | 3 ++ rust/agama-utils/src/types/event.rs | 16 ++++----- rust/agama-utils/src/types/status.rs | 45 ++++++++++++++++++++++++ 8 files changed, 91 insertions(+), 46 deletions(-) create mode 100644 rust/agama-utils/src/types/status.rs diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 3852363770..093c126993 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -20,29 +20,17 @@ use crate::l10n; use crate::proposal::Proposal; -use crate::service; use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; use agama_utils::issue::Issue; -use agama_utils::types::scope::Scope; -use agama_utils::types::Progress; -use serde::{Deserialize, Serialize}; +use agama_utils::types::{Scope, Status}; +use serde::Deserialize; use std::collections::HashMap; /// Gets the installation status. pub struct GetStatus; -#[derive(Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Status { - /// State of the installation - pub state: service::State, - #[serde(skip_serializing_if = "Vec::is_empty")] - /// Active progresses - pub progresses: Vec, -} - impl Message for GetStatus { type Reply = Status; } diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 5a55305e60..e619e6fe08 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -26,17 +26,20 @@ use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::issue; use agama_utils::progress; -use agama_utils::types::Scope; +use agama_utils::types::status::State; +use agama_utils::types::{event, Event, Scope, Status}; use async_trait::async_trait; use merge_struct::merge; -use serde::Serialize; use std::collections::HashMap; +use tokio::sync::broadcast; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Cannot merge the configuration")] MergeConfig, #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] Progress(#[from] progress::service::Error), @@ -46,23 +49,13 @@ pub enum Error { Issues(#[from] agama_utils::issue::service::Error), } -#[derive(Clone, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum State { - /// Configuring the installation - Configuring, - /// Installing the system - Installing, - /// Installation finished - Finished, -} - pub struct Service { l10n: Handler, issues: Handler, progress: Handler, state: State, config: InstallSettings, + events: event::Sender, } impl Service { @@ -70,11 +63,13 @@ impl Service { l10n: Handler, issues: Handler, progress: Handler, + events: event::Sender, ) -> Self { Self { l10n, issues, progress, + events, state: State::Configuring, config: InstallSettings::default(), } @@ -82,6 +77,7 @@ impl Service { async fn install(&mut self) -> Result<(), Error> { self.state = State::Installing; + self.events.send(Event::StateChanged)?; // TODO: translate progress steps. self.progress .call(progress::message::StartWithSteps::new( @@ -90,10 +86,11 @@ impl Service { )) .await?; self.l10n.call(l10n::message::Install).await?; - self.state = State::Finished; self.progress .call(progress::message::Finish::new(Scope::Manager)) .await?; + self.state = State::Finished; + self.events.send(Event::StateChanged)?; Ok(()) } } @@ -105,9 +102,9 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { /// It returns the status of the installation. - async fn handle(&mut self, _message: message::GetStatus) -> Result { + async fn handle(&mut self, _message: message::GetStatus) -> Result { let progresses = self.progress.call(progress::message::Get).await?; - Ok(message::Status { + Ok(Status { state: self.state.clone(), progresses, }) diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 06cb9c9bc4..0a55451b81 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -56,9 +56,8 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let service = Service::new(l10n, issues, progress); + let service = Service::new(l10n, issues, progress, events.clone()); let handler = actor::spawn(service); - Ok(handler) } @@ -74,7 +73,14 @@ mod test { use tokio::sync::broadcast; async fn start_service() -> Handler { - let (events_sender, _events_receiver) = broadcast::channel::(16); + let (events_sender, mut events_receiver) = broadcast::channel::(16); + + tokio::spawn(async move { + while let Ok(event) = events_receiver.recv().await { + println!("{:?}", event); + } + }); + manager::start(events_sender, None).await.unwrap() } @@ -111,9 +117,17 @@ mod test { async fn test_patch_config() -> Result<(), Box> { let handler = start_service().await; + // Ensure the keymap is different to the system one. + let config = handler.call(message::GetExtendedConfig).await?; + let keymap = if config.localization.unwrap().keymap.unwrap() == "es" { + "en" + } else { + "es" + }; + let input_config = InstallSettings { localization: Some(l10n::Config { - keymap: Some("es".to_string()), + keymap: Some(keymap.to_string()), ..Default::default() }), ..Default::default() diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 408e93f24b..d085644caf 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -27,7 +27,7 @@ use agama_manager::message; use agama_manager::SystemInfo; use agama_manager::{self as manager}; use agama_utils::actor::Handler; -use agama_utils::types::event; +use agama_utils::types::{event, Status}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; @@ -100,7 +100,7 @@ pub async fn server_service( (status = 400, description = "Not possible to retrieve the status of the installation.") ) )] -async fn get_status(State(state): State) -> ServerResult> { +async fn get_status(State(state): State) -> ServerResult> { let status = state.manager.call(message::GetStatus).await?; Ok(Json(status)) } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 36fc4fb817..d30dffd01f 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -163,8 +163,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/types.rs index 89aad34a9e..451a7cd6ed 100644 --- a/rust/agama-utils/src/types.rs +++ b/rust/agama-utils/src/types.rs @@ -29,3 +29,6 @@ pub use progress::Progress; pub mod scope; pub use scope::Scope; + +pub mod status; +pub use status::Status; diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index bceca4c8e1..658384ab94 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -25,24 +25,24 @@ use tokio::sync::broadcast; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Event { - // FIXME: move service::State to agama_utils::types::manager. - StatusChanged, - /// The list of issues has changed. - IssuesChanged { - scope: Scope, - }, + // The state of the installation changed. + StateChanged, /// Progress changed. ProgressChanged { scope: Scope, }, - /// Proposal changed. - ProposalChanged { + /// The list of issues has changed. + IssuesChanged { scope: Scope, }, /// The underlying system changed. SystemChanged { scope: String, }, + /// Proposal changed. + ProposalChanged { + scope: Scope, + }, } pub type Sender = broadcast::Sender; diff --git a/rust/agama-utils/src/types/status.rs b/rust/agama-utils/src/types/status.rs new file mode 100644 index 0000000000..1ac84916db --- /dev/null +++ b/rust/agama-utils/src/types/status.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::types::progress::Progress; +use serde::Serialize; + +// Information about the status of the installation. +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Status { + /// State of the installation + pub state: State, + #[serde(skip_serializing_if = "Vec::is_empty")] + /// Active progresses + pub progresses: Vec, +} + +/// Represents the current state of the installation process. +#[derive(Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum State { + /// Configuring the installation + Configuring, + /// Installing the system + Installing, + /// Installation finished + Finished, +} From 5faae1d06a1748c40f90a05f489f036deccf4897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 15:35:38 +0100 Subject: [PATCH 188/917] Add info to progress events - Info about the changed progress - Add event on finish --- rust/agama-utils/src/progress/service.rs | 16 ++++++---- rust/agama-utils/src/progress/start.rs | 37 ++++++++++++++++++------ rust/agama-utils/src/types/event.rs | 6 ++++ rust/agama-utils/src/types/progress.rs | 4 +-- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 91a82f9552..e4f7c3e8c7 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -83,10 +83,11 @@ impl MessageHandler for Service { if self.get_progress(message.scope).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses - .push(Progress::new(message.scope, message.size, message.step)); + let progress = Progress::new(message.scope, message.size, message.step); + self.progresses.push(progress.clone()); self.events.send(Event::ProgressChanged { scope: message.scope, + progress, })?; Ok(()) } @@ -98,10 +99,11 @@ impl MessageHandler for Service { if self.get_progress(message.scope).is_some() { return Err(Error::DuplicatedProgress(message.scope)); } - self.progresses - .push(Progress::new_with_steps(message.scope, message.steps)); + let progress = Progress::new_with_steps(message.scope, message.steps); + self.progresses.push(progress.clone()); self.events.send(Event::ProgressChanged { scope: message.scope, + progress, })?; Ok(()) } @@ -114,8 +116,10 @@ impl MessageHandler for Service { return Err(Error::MissingProgress(message.scope)); }; progress.next()?; + let progress = progress.clone(); self.events.send(Event::ProgressChanged { scope: message.scope, + progress, })?; Ok(()) } @@ -128,8 +132,10 @@ impl MessageHandler for Service { return Err(Error::MissingProgress(message.scope)); }; progress.next_with_step(message.step)?; + let progress = progress.clone(); self.events.send(Event::ProgressChanged { scope: message.scope, + progress, })?; Ok(()) } @@ -142,7 +148,7 @@ impl MessageHandler for Service { .get_progress_index(message.scope) .ok_or(Error::MissingProgress(message.scope))?; self.progresses.remove(index); - self.events.send(Event::ProgressChanged { + self.events.send(Event::ProgressFinished { scope: message.scope, })?; Ok(()) diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 2ea4704467..67159fe6a8 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -67,18 +67,31 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!( event, - Event::ProgressChanged { scope: Scope::L10n } + Event::ProgressChanged { + scope: Scope::L10n, + progress: _ + } )); + let Event::ProgressChanged { + scope: _, + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::L10n); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "first step"); + assert_eq!(event_progress.index, 1); + let progresses = handler.call(message::Get).await?; assert_eq!(progresses.len(), 1); let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, Scope::L10n); - assert_eq!(progress.size, 3); - assert!(progress.steps.is_empty()); - assert_eq!(progress.step, "first step"); - assert_eq!(progress.index, 1); + assert_eq!(*progress, event_progress); // Second step handler @@ -88,7 +101,10 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!( event, - Event::ProgressChanged { scope: Scope::L10n } + Event::ProgressChanged { + scope: Scope::L10n, + progress: _ + } )); let progresses = handler.call(message::Get).await.unwrap(); @@ -105,7 +121,10 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!( event, - Event::ProgressChanged { scope: Scope::L10n } + Event::ProgressChanged { + scope: Scope::L10n, + progress: _ + } )); let progresses = handler.call(message::Get).await.unwrap(); @@ -122,7 +141,7 @@ mod tests { let event = receiver.recv().await.unwrap(); assert!(matches!( event, - Event::ProgressChanged { scope: Scope::L10n } + Event::ProgressFinished { scope: Scope::L10n } )); let progresses = handler.call(message::Get).await.unwrap(); diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index 658384ab94..0fe6d367ff 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::types::progress::Progress; use crate::types::scope::Scope; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -30,6 +31,11 @@ pub enum Event { /// Progress changed. ProgressChanged { scope: Scope, + progress: Progress, + }, + /// Progress finished. + ProgressFinished { + scope: Scope, }, /// The list of issues has changed. IssuesChanged { diff --git a/rust/agama-utils/src/types/progress.rs b/rust/agama-utils/src/types/progress.rs index 373b8ad9b7..2a28136e65 100644 --- a/rust/agama-utils/src/types/progress.rs +++ b/rust/agama-utils/src/types/progress.rs @@ -21,7 +21,7 @@ //! This module includes the struct that represent a service progress step. use crate::types::scope::Scope; -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -29,7 +29,7 @@ pub enum Error { MissingStep(Scope), } -#[derive(Clone, Serialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Progress { /// Scope of the progress From 91dcd7bf1b9765f5f2127e2e90c326480f8624aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 13 Oct 2025 16:13:28 +0100 Subject: [PATCH 189/917] Fix set config for l10n --- rust/agama-l10n/src/service.rs | 3 ++- rust/agama-l10n/src/start.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index c7c50ff332..6e0db4f191 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -186,7 +186,8 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let merged = self.state.config.merge(&message.config)?; + let config = ExtendedConfig::new_from(&self.state.system); + let merged = config.merge(&message.config)?; if merged == self.state.config { return Ok(()); } diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index ae1100bfff..e2182719b7 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -174,6 +174,28 @@ mod tests { event, Event::ProposalChanged { scope: Scope::L10n } )); + + let input_config = Config { + locale: None, + keymap: Some("es".to_string()), + timezone: None, + }; + + // Use system info for missing values. + handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + + let updated = handler.call(message::GetConfig).await?; + assert_eq!( + updated, + Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + Ok(()) } From 6929a705e8a39562d240c5defb8fa1e63636438a Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 13 Oct 2025 19:18:25 +0200 Subject: [PATCH 190/917] prepare code to not obtain lock for whatever reason like intentional stress test by mvidner :) --- rust/zypp-agama/src/lib.rs | 16 ++++++------ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 25 ++++++------------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 22852d29b6..70bd6fbd33 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -96,6 +96,7 @@ static GLOBAL_LOCK: Mutex = Mutex::new(false); /// The only instance of Zypp on which all zypp calls should be invoked. /// It is intentionally !Send and !Sync as libzypp gives no guarantees regarding /// threads, so it should be run only in single thread and sequentially. +#[derive(Debug)] pub struct Zypp { ptr: *mut zypp_agama_sys::Zypp, } @@ -577,12 +578,6 @@ mod tests { let result = Zypp::init_target("/", progress_cb); assert!(result.is_ok()); } - { - setup(); - // when the target pathis not a (potential) root diretory - let result = Zypp::init_target("/dev/full", progress_cb); - assert!(result.is_err()); - } { setup(); // a nonexistent relative root triggers a C++ exception @@ -598,7 +593,7 @@ mod tests { assert!(z2.is_err()); // z1 call after init target for z2 to ensure that it is not dropped too soon - assert!(z1.is_ok()) + assert!(z1.is_ok(), "z1 is not properly init {:?}.", z1); } { // list repositories test @@ -615,6 +610,13 @@ mod tests { let repos = zypp.list_repositories()?; assert!(repos.len() == 1); } + { + setup(); + // when the target path is not a (potential) root diretory + // NOTE: run it as last test as it keeps ZyppLock in cached state, so next init root with correct path will fail. + let result = Zypp::init_target("/dev/full", progress_cb); + assert!(result.is_err()); + } Ok(()) } } diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 44413445ed..9f9b458c6c 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -135,23 +135,9 @@ static zypp::ZYpp::Ptr zypp_ptr() { boost::shared_ptr logger(new AgamaLogger); zypp::base::LogControl::instance().setLineWriter(logger); - int max_count = 5; - unsigned int seconds = 3; - - zypp::ZYpp::Ptr zypp = NULL; - while (zypp == NULL && max_count > 0) { - try { - zypp = zypp::getZYpp(); - - return zypp; - } catch (const zypp::Exception &excpt) { - max_count--; - - sleep(seconds); - } - } - - return NULL; + // do not do any magic waiting for lock as in agama context we work + // on our own root, so there should be no need to wait + return zypp::getZYpp(); } void switch_target(struct Zypp *zypp, const char *root, @@ -212,6 +198,11 @@ struct Zypp *init_target(const char *root, struct Status *status, if (progress != NULL) progress("Initializing the Target System", 0, 2, user_data); the_zypp.zypp_pointer = zypp_ptr(); + if (the_zypp.zypp_pointer == NULL) { + STATUS_ERROR(status, "Failed to obtain zypp pointer. " + "See journalctl for details."); + return NULL; + } zypp = &the_zypp; zypp->zypp_pointer->initializeTarget(root_str, false); if (progress != NULL) From 5e95d1b93cc44fb41a3a1e2a8f9fb585eb38f9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 11:44:11 +0100 Subject: [PATCH 191/917] Add scope to event for system changed --- rust/agama-l10n/src/service.rs | 14 ++++++-------- rust/agama-utils/src/types/event.rs | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 6e0db4f191..a76e2ab4be 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -32,8 +32,6 @@ use agama_utils::types::scope::Scope; use async_trait::async_trait; use tokio::sync::broadcast; -pub(crate) const SCOPE: &str = "localization"; - #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Unknown locale: {0}")] @@ -233,9 +231,9 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateLocale) -> Result<(), Error> { self.state.system.locale = message.locale; - _ = self.events.send(Event::SystemChanged { - scope: SCOPE.to_string(), - }); + _ = self + .events + .send(Event::SystemChanged { scope: Scope::L10n }); Ok(()) } } @@ -244,9 +242,9 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateKeymap) -> Result<(), Error> { self.state.system.keymap = message.keymap; - _ = self.events.send(Event::SystemChanged { - scope: SCOPE.to_string(), - }); + _ = self + .events + .send(Event::SystemChanged { scope: Scope::L10n }); Ok(()) } } diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/types/event.rs index 0fe6d367ff..0536e79ed8 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/types/event.rs @@ -43,7 +43,7 @@ pub enum Event { }, /// The underlying system changed. SystemChanged { - scope: String, + scope: Scope, }, /// Proposal changed. ProposalChanged { From 9c4ab558fe0f3ac9df87762c2bbd3e5dec9f73b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 12:23:11 +0100 Subject: [PATCH 192/917] Move l10n config to types --- rust/agama-l10n/src/extended_config.rs | 6 ++++-- rust/agama-l10n/src/lib.rs | 3 --- rust/agama-l10n/src/message.rs | 6 ++++-- rust/agama-l10n/src/service.rs | 18 +++++++++++----- rust/agama-l10n/src/start.rs | 19 +++++++---------- rust/agama-lib/src/install_settings.rs | 4 ++-- rust/agama-lib/src/lib.rs | 2 -- rust/agama-server/src/web/docs/config.rs | 2 +- rust/agama-utils/src/types.rs | 2 ++ .../src/types/l10n.rs} | 6 +++++- .../src/types/l10n}/config.rs | 21 ++----------------- 11 files changed, 41 insertions(+), 48 deletions(-) rename rust/{agama-lib/src/config.rs => agama-utils/src/types/l10n.rs} (84%) rename rust/{agama-l10n/src => agama-utils/src/types/l10n}/config.rs (68%) diff --git a/rust/agama-l10n/src/extended_config.rs b/rust/agama-l10n/src/extended_config.rs index 5194a32dbc..b886aacf21 100644 --- a/rust/agama-l10n/src/extended_config.rs +++ b/rust/agama-l10n/src/extended_config.rs @@ -18,8 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{config::Config, service, system_info::SystemInfo}; +use crate::service; +use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use agama_utils::types; #[derive(Clone, PartialEq)] pub struct ExtendedConfig { @@ -37,7 +39,7 @@ impl ExtendedConfig { } } - pub fn merge(&self, config: &Config) -> Result { + pub fn merge(&self, config: &types::l10n::Config) -> Result { let mut merged = self.clone(); if let Some(language) = &config.locale { diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index 7b19f7f8d2..b5306f7dc9 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -47,9 +47,6 @@ pub use model::{Model, ModelAdapter}; mod system_info; pub use system_info::SystemInfo; -mod config; -pub use config::Config; - mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 532d91c29d..a8908faacd 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -18,9 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{config::Config, proposal::Proposal, system_info::SystemInfo}; +use crate::proposal::Proposal; +use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::Message; +use agama_utils::types; use serde::Deserialize; #[derive(Clone)] @@ -53,7 +55,7 @@ pub struct SystemConfig { pub struct GetConfig; impl Message for GetConfig { - type Reply = Config; + type Reply = types::l10n::Config; } pub struct SetConfig { diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index a76e2ab4be..1ffece32f2 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -18,7 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::config::Config; use crate::extended_config::ExtendedConfig; use crate::message; use crate::model::ModelAdapter; @@ -27,6 +26,7 @@ use crate::system_info::SystemInfo; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::issue::{self, Issue}; +use agama_utils::types; use agama_utils::types::event::{self, Event}; use agama_utils::types::scope::Scope; use async_trait::async_trait; @@ -176,14 +176,22 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetConfig) -> Result { - Ok((&self.state.config).into()) + async fn handle(&mut self, _message: message::GetConfig) -> Result { + let config = self.state.config.clone(); + Ok(types::l10n::Config { + locale: Some(config.locale.to_string()), + keymap: Some(config.keymap.to_string()), + timezone: Some(config.timezone.to_string()), + }) } } #[async_trait] -impl MessageHandler> for Service { - async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { let config = ExtendedConfig::new_from(&self.state.system); let merged = config.merge(&message.config)?; if merged == self.state.config { diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index e2182719b7..40fd698ecd 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -64,10 +64,10 @@ mod tests { TimezonesDatabase, }; use crate::service::{self, Service}; - use crate::Config; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::{self, Handler}; use agama_utils::issue; + use agama_utils::types; use agama_utils::types::event::{self, Event}; use agama_utils::types::scope::Scope; use tokio::sync::broadcast; @@ -154,7 +154,7 @@ mod tests { let config = handler.call(message::GetConfig).await.unwrap(); assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - let input_config = Config { + let input_config = types::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), @@ -175,7 +175,7 @@ mod tests { Event::ProposalChanged { scope: Scope::L10n } )); - let input_config = Config { + let input_config = types::l10n::Config { locale: None, keymap: Some("es".to_string()), timezone: None, @@ -189,7 +189,7 @@ mod tests { let updated = handler.call(message::GetConfig).await?; assert_eq!( updated, - Config { + types::l10n::Config { locale: Some("en_US.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Europe/Berlin".to_string()), @@ -203,7 +203,7 @@ mod tests { async fn test_set_invalid_config() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; - let input_config = Config { + let input_config = types::l10n::Config { locale: Some("es-ES.UTF-8".to_string()), ..Default::default() }; @@ -211,10 +211,7 @@ mod tests { let result = handler .call(message::SetConfig::new(input_config.clone())) .await; - assert!(matches!( - result, - Err(crate::service::Error::InvalidLocale(_)) - )); + assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); Ok(()) } @@ -238,7 +235,7 @@ mod tests { async fn test_set_config_unknown_values() -> Result<(), Box> { let (mut _events_rx, handler, issues) = start_testing_service().await; - let config = Config { + let config = types::l10n::Config { keymap: Some("jk".to_string()), locale: Some("xx_XX.UTF-8".to_string()), timezone: Some("Unknown/Unknown".to_string()), @@ -268,7 +265,7 @@ mod tests { async fn test_get_proposal() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; - let input_config = Config { + let input_config = types::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index 61b78a822a..8ed86eb9ce 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -26,7 +26,6 @@ use crate::context::InstallationContext; use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; -use crate::l10n; use crate::questions::config::QuestionsConfig; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; @@ -34,6 +33,7 @@ use crate::{ network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, }; +use agama_utils::types; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; @@ -86,7 +86,7 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, + pub localization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 1f1f7ab286..3c3e3094c7 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -45,7 +45,6 @@ pub mod auth; pub mod bootloader; -pub mod config; pub mod context; pub mod error; pub mod file_source; @@ -72,7 +71,6 @@ mod store; pub mod users; pub use store::Store; pub mod utils; -pub(crate) use agama_l10n as l10n; pub use agama_utils::{dbus, openapi}; use crate::error::ServiceError; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index d30dffd01f..af7585f3a5 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -46,7 +46,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema("IpAddr", schemas::ip_addr()) .schema("IpInet", schemas::ip_inet()) .schema("macaddr.MacAddr6", schemas::mac_addr6()) - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -167,6 +166,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/types.rs index 451a7cd6ed..51da7c665e 100644 --- a/rust/agama-utils/src/types.rs +++ b/rust/agama-utils/src/types.rs @@ -32,3 +32,5 @@ pub use scope::Scope; pub mod status; pub use status::Status; + +pub mod l10n; diff --git a/rust/agama-lib/src/config.rs b/rust/agama-utils/src/types/l10n.rs similarity index 84% rename from rust/agama-lib/src/config.rs rename to rust/agama-utils/src/types/l10n.rs index ddf81c5ffc..806253ace3 100644 --- a/rust/agama-lib/src/config.rs +++ b/rust/agama-utils/src/types/l10n.rs @@ -18,4 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub use agama_l10n::Config; +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. + +pub mod config; +pub use config::Config; diff --git a/rust/agama-l10n/src/config.rs b/rust/agama-utils/src/types/l10n/config.rs similarity index 68% rename from rust/agama-l10n/src/config.rs rename to rust/agama-utils/src/types/l10n/config.rs index 399fc7f769..9a06c5c185 100644 --- a/rust/agama-l10n/src/config.rs +++ b/rust/agama-utils/src/types/l10n/config.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,14 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! Representation of the localization settings - -use crate::extended_config::ExtendedConfig; use serde::{Deserialize, Serialize}; -/// User configuration for the localization of the target system. -/// -/// This configuration is provided by the user, so all the values are optional. +/// Localization config. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[schema(as = l10n::UserConfig)] #[serde(rename_all = "camelCase")] @@ -42,15 +37,3 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub timezone: Option, } - -/// Converts the localization configuration, which contains values for all the -/// elements, into a user configuration. -impl From<&ExtendedConfig> for Config { - fn from(config: &ExtendedConfig) -> Self { - Config { - locale: Some(config.locale.to_string()), - keymap: Some(config.keymap.to_string()), - timezone: Some(config.timezone.to_string()), - } - } -} From e0872e18b2f3fc61822efd102de81b73d28b56e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 13:08:49 +0100 Subject: [PATCH 193/917] Rename types as api --- rust/agama-l10n/src/extended_config.rs | 4 ++-- rust/agama-l10n/src/message.rs | 4 ++-- rust/agama-l10n/src/service.rs | 14 ++++++------- rust/agama-l10n/src/start.rs | 20 +++++++++---------- rust/agama-lib/src/install_settings.rs | 4 ++-- rust/agama-manager/src/message.rs | 2 +- rust/agama-manager/src/service.rs | 4 ++-- rust/agama-manager/src/start.rs | 10 +++++----- rust/agama-server/src/server/types.rs | 2 +- rust/agama-server/src/server/web.rs | 2 +- rust/agama-server/src/web.rs | 2 +- rust/agama-server/src/web/docs/config.rs | 10 +++++----- rust/agama-server/src/web/service.rs | 2 +- rust/agama-server/src/web/state.rs | 2 +- rust/agama-server/src/web/ws.rs | 2 +- rust/agama-server/tests/server_service.rs | 11 +++++----- rust/agama-utils/src/{types.rs => api.rs} | 0 rust/agama-utils/src/{types => api}/event.rs | 4 ++-- rust/agama-utils/src/{types => api}/l10n.rs | 0 .../src/{types => api}/l10n/config.rs | 0 .../src/{types => api}/progress.rs | 2 +- rust/agama-utils/src/{types => api}/scope.rs | 0 rust/agama-utils/src/{types => api}/status.rs | 2 +- rust/agama-utils/src/issue.rs | 2 +- rust/agama-utils/src/issue/message.rs | 2 +- rust/agama-utils/src/issue/monitor.rs | 2 +- rust/agama-utils/src/issue/service.rs | 4 ++-- rust/agama-utils/src/issue/start.rs | 6 +++--- rust/agama-utils/src/lib.rs | 2 +- rust/agama-utils/src/progress/message.rs | 4 ++-- rust/agama-utils/src/progress/service.rs | 6 +++--- rust/agama-utils/src/progress/start.rs | 8 ++++---- 32 files changed, 69 insertions(+), 70 deletions(-) rename rust/agama-utils/src/{types.rs => api.rs} (100%) rename rust/agama-utils/src/{types => api}/event.rs (95%) rename rust/agama-utils/src/{types => api}/l10n.rs (100%) rename rust/agama-utils/src/{types => api}/l10n/config.rs (100%) rename rust/agama-utils/src/{types => api}/progress.rs (98%) rename rust/agama-utils/src/{types => api}/scope.rs (100%) rename rust/agama-utils/src/{types => api}/status.rs (97%) diff --git a/rust/agama-l10n/src/extended_config.rs b/rust/agama-l10n/src/extended_config.rs index b886aacf21..a36b2ed8c1 100644 --- a/rust/agama-l10n/src/extended_config.rs +++ b/rust/agama-l10n/src/extended_config.rs @@ -21,7 +21,7 @@ use crate::service; use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; -use agama_utils::types; +use agama_utils::api; #[derive(Clone, PartialEq)] pub struct ExtendedConfig { @@ -39,7 +39,7 @@ impl ExtendedConfig { } } - pub fn merge(&self, config: &types::l10n::Config) -> Result { + pub fn merge(&self, config: &api::l10n::Config) -> Result { let mut merged = self.clone(); if let Some(language) = &config.locale { diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index a8908faacd..ca5266ef24 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -22,7 +22,7 @@ use crate::proposal::Proposal; use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::Message; -use agama_utils::types; +use agama_utils::api; use serde::Deserialize; #[derive(Clone)] @@ -55,7 +55,7 @@ pub struct SystemConfig { pub struct GetConfig; impl Message for GetConfig { - type Reply = types::l10n::Config; + type Reply = api::l10n::Config; } pub struct SetConfig { diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 1ffece32f2..503313dc9d 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -25,10 +25,10 @@ use crate::proposal::Proposal; use crate::system_info::SystemInfo; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::api; +use agama_utils::api::event::{self, Event}; +use agama_utils::api::scope::Scope; use agama_utils::issue::{self, Issue}; -use agama_utils::types; -use agama_utils::types::event::{self, Event}; -use agama_utils::types::scope::Scope; use async_trait::async_trait; use tokio::sync::broadcast; @@ -176,9 +176,9 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetConfig) -> Result { + async fn handle(&mut self, _message: message::GetConfig) -> Result { let config = self.state.config.clone(); - Ok(types::l10n::Config { + Ok(api::l10n::Config { locale: Some(config.locale.to_string()), keymap: Some(config.keymap.to_string()), timezone: Some(config.timezone.to_string()), @@ -187,10 +187,10 @@ impl MessageHandler for Service { } #[async_trait] -impl MessageHandler> for Service { +impl MessageHandler> for Service { async fn handle( &mut self, - message: message::SetConfig, + message: message::SetConfig, ) -> Result<(), Error> { let config = ExtendedConfig::new_from(&self.state.system); let merged = config.merge(&message.config)?; diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 40fd698ecd..8e93e1785a 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -22,8 +22,8 @@ use crate::model::Model; use crate::monitor::{self, Monitor}; use crate::service::{self, Service}; use agama_utils::actor::{self, Handler}; +use agama_utils::api::event; use agama_utils::issue; -use agama_utils::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -66,10 +66,10 @@ mod tests { use crate::service::{self, Service}; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::{self, Handler}; + use agama_utils::api; + use agama_utils::api::event::{self, Event}; + use agama_utils::api::scope::Scope; use agama_utils::issue; - use agama_utils::types; - use agama_utils::types::event::{self, Event}; - use agama_utils::types::scope::Scope; use tokio::sync::broadcast; pub struct TestModel { @@ -154,7 +154,7 @@ mod tests { let config = handler.call(message::GetConfig).await.unwrap(); assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - let input_config = types::l10n::Config { + let input_config = api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), @@ -175,7 +175,7 @@ mod tests { Event::ProposalChanged { scope: Scope::L10n } )); - let input_config = types::l10n::Config { + let input_config = api::l10n::Config { locale: None, keymap: Some("es".to_string()), timezone: None, @@ -189,7 +189,7 @@ mod tests { let updated = handler.call(message::GetConfig).await?; assert_eq!( updated, - types::l10n::Config { + api::l10n::Config { locale: Some("en_US.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Europe/Berlin".to_string()), @@ -203,7 +203,7 @@ mod tests { async fn test_set_invalid_config() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; - let input_config = types::l10n::Config { + let input_config = api::l10n::Config { locale: Some("es-ES.UTF-8".to_string()), ..Default::default() }; @@ -235,7 +235,7 @@ mod tests { async fn test_set_config_unknown_values() -> Result<(), Box> { let (mut _events_rx, handler, issues) = start_testing_service().await; - let config = types::l10n::Config { + let config = api::l10n::Config { keymap: Some("jk".to_string()), locale: Some("xx_XX.UTF-8".to_string()), timezone: Some("Unknown/Unknown".to_string()), @@ -265,7 +265,7 @@ mod tests { async fn test_get_proposal() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; - let input_config = types::l10n::Config { + let input_config = api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index 8ed86eb9ce..2aa22e4d34 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -33,7 +33,7 @@ use crate::{ network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, }; -use agama_utils::types; +use agama_utils::api; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; @@ -86,7 +86,7 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, + pub localization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 093c126993..1fb60befd4 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -23,8 +23,8 @@ use crate::proposal::Proposal; use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; +use agama_utils::api::{Scope, Status}; use agama_utils::issue::Issue; -use agama_utils::types::{Scope, Status}; use serde::Deserialize; use std::collections::HashMap; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index e619e6fe08..bf48541db5 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -24,10 +24,10 @@ use crate::proposal::Proposal; use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::api::status::State; +use agama_utils::api::{event, Event, Scope, Status}; use agama_utils::issue; use agama_utils::progress; -use agama_utils::types::status::State; -use agama_utils::types::{event, Event, Scope, Status}; use async_trait::async_trait; use merge_struct::merge; use std::collections::HashMap; diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 0a55451b81..cfb285f6d0 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -21,9 +21,9 @@ use crate::l10n; use crate::service::Service; use agama_utils::actor::{self, Handler}; +use agama_utils::api::event; use agama_utils::issue; use agama_utils::progress; -use agama_utils::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -63,13 +63,13 @@ pub async fn start( #[cfg(test)] mod test { - use crate::l10n; use crate::message; use crate::service::Service; use crate::{self as manager}; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Handler; - use agama_utils::types::Event; + use agama_utils::api; + use agama_utils::api::Event; use tokio::sync::broadcast; async fn start_service() -> Handler { @@ -90,7 +90,7 @@ mod test { let handler = start_service().await; let input_config = InstallSettings { - localization: Some(l10n::Config { + localization: Some(api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), @@ -126,7 +126,7 @@ mod test { }; let input_config = InstallSettings { - localization: Some(l10n::Config { + localization: Some(api::l10n::Config { keymap: Some(keymap.to_string()), ..Default::default() }), diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index 0883747e1f..b34262ecc5 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -21,8 +21,8 @@ //! This module defines some ancillary types for the HTTP API. use agama_lib::install_settings::InstallSettings; +use agama_utils::api::Scope; use agama_utils::issue; -use agama_utils::types::Scope; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index d085644caf..e370c6cfe0 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -27,7 +27,7 @@ use agama_manager::message; use agama_manager::SystemInfo; use agama_manager::{self as manager}; use agama_utils::actor::Handler; -use agama_utils::types::{event, Status}; +use agama_utils::api::{event, Status}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 04792b31cc..411a6d4ee2 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -41,7 +41,7 @@ use crate::{ users::web::{users_service, users_streams}, web::common::{jobs_stream, service_status_stream}, }; -use agama_utils::types::event; +use agama_utils::api::event; use axum::Router; mod auth; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index af7585f3a5..5558607edf 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -162,11 +162,11 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 36e43e3b79..6a0ba0d95a 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -21,7 +21,7 @@ use super::http::{login, login_from_query, logout, session}; use super::{config::ServiceConfig, state::ServiceState}; use agama_lib::{auth::TokenClaims, http}; -use agama_utils::types::event; +use agama_utils::api::event; use axum::http::HeaderValue; use axum::middleware::Next; use axum::{ diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index 936d122d10..a761ad224e 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -22,7 +22,7 @@ use super::config::ServiceConfig; use agama_lib::http; -use agama_utils::types::event; +use agama_utils::api::event; use std::path::PathBuf; /// Web service state. diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index a4c4707815..8af2e53d47 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -22,7 +22,7 @@ use super::state::ServiceState; use agama_lib::{auth::ClientId, http}; -use agama_utils::types::event; +use agama_utils::api::event; use axum::{ extract::{ ws::{Message, WebSocket}, diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index a7f74819bf..43e5b6efd0 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -19,11 +19,10 @@ // find current contact information at www.suse.com. pub mod common; - -use agama_l10n::Config; use agama_lib::error::ServiceError; use agama_lib::install_settings::InstallSettings; use agama_server::server::server_service; +use agama_utils::api; use axum::{ body::Body, http::{Method, Request, StatusCode}, @@ -87,7 +86,7 @@ async fn test_get_empty_config() -> Result<(), Box> { #[test] #[cfg(not(ci))] async fn test_put_config() -> Result<(), Box> { - let localization = Config { + let localization = api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), @@ -122,7 +121,7 @@ async fn test_put_config() -> Result<(), Box> { r#""localization":{"locale":"es_ES.UTF-8","keymap":"es","timezone":"Atlantic/Canary"# )); - let localization = Config { + let localization = api::l10n::Config { locale: None, keymap: Some("en".to_string()), timezone: None, @@ -158,7 +157,7 @@ async fn test_put_config() -> Result<(), Box> { async fn test_patch_config() -> Result<(), Box> { use agama_server::server::types::ConfigPatch; - let localization = Config { + let localization = api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), @@ -180,7 +179,7 @@ async fn test_patch_config() -> Result<(), Box> { let response = server_service.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let localization = Config { + let localization = api::l10n::Config { locale: None, keymap: Some("en".to_string()), timezone: None, diff --git a/rust/agama-utils/src/types.rs b/rust/agama-utils/src/api.rs similarity index 100% rename from rust/agama-utils/src/types.rs rename to rust/agama-utils/src/api.rs diff --git a/rust/agama-utils/src/types/event.rs b/rust/agama-utils/src/api/event.rs similarity index 95% rename from rust/agama-utils/src/types/event.rs rename to rust/agama-utils/src/api/event.rs index 0536e79ed8..6fa83187b8 100644 --- a/rust/agama-utils/src/types/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::types::progress::Progress; -use crate::types::scope::Scope; +use crate::api::progress::Progress; +use crate::api::scope::Scope; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; diff --git a/rust/agama-utils/src/types/l10n.rs b/rust/agama-utils/src/api/l10n.rs similarity index 100% rename from rust/agama-utils/src/types/l10n.rs rename to rust/agama-utils/src/api/l10n.rs diff --git a/rust/agama-utils/src/types/l10n/config.rs b/rust/agama-utils/src/api/l10n/config.rs similarity index 100% rename from rust/agama-utils/src/types/l10n/config.rs rename to rust/agama-utils/src/api/l10n/config.rs diff --git a/rust/agama-utils/src/types/progress.rs b/rust/agama-utils/src/api/progress.rs similarity index 98% rename from rust/agama-utils/src/types/progress.rs rename to rust/agama-utils/src/api/progress.rs index 2a28136e65..69e908696f 100644 --- a/rust/agama-utils/src/types/progress.rs +++ b/rust/agama-utils/src/api/progress.rs @@ -20,7 +20,7 @@ //! This module includes the struct that represent a service progress step. -use crate::types::scope::Scope; +use crate::api::scope::Scope; use serde::{Deserialize, Serialize}; #[derive(thiserror::Error, Debug)] diff --git a/rust/agama-utils/src/types/scope.rs b/rust/agama-utils/src/api/scope.rs similarity index 100% rename from rust/agama-utils/src/types/scope.rs rename to rust/agama-utils/src/api/scope.rs diff --git a/rust/agama-utils/src/types/status.rs b/rust/agama-utils/src/api/status.rs similarity index 97% rename from rust/agama-utils/src/types/status.rs rename to rust/agama-utils/src/api/status.rs index 1ac84916db..38768ec870 100644 --- a/rust/agama-utils/src/types/status.rs +++ b/rust/agama-utils/src/api/status.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::types::progress::Progress; +use crate::api::progress::Progress; use serde::Serialize; // Information about the status of the installation. diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index eb2c186430..c2bf6f7e9e 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -31,7 +31,7 @@ //! //! ```no_run //! use agama_utils::issue::{self, message}; -//! use agama_utils::types::Scope; +//! use agama_utils::api::Scope; //! use tokio::sync::broadcast; //! //! # tokio_test::block_on(async { diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index f5e04f17b7..5b541d3247 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::actor::Message; +use crate::api::Scope; use crate::issue::Issue; -use crate::types::Scope; use std::collections::HashMap; pub struct Get; diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 58774c7e11..44e9d206fd 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -19,9 +19,9 @@ // find current contact information at www.suse.com. use crate::actor::Handler; +use crate::api::scope::Scope; use crate::dbus::build_properties_changed_stream; use crate::issue::{self, message, model, Issue, Service}; -use crate::types::scope::Scope; use tokio_stream::StreamExt; use zbus::fdo::PropertiesChanged; use zbus::names::BusName; diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 67b8579668..090fb7c52e 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -19,9 +19,9 @@ // find current contact information at www.suse.com. use crate::actor::{self, Actor, MessageHandler}; +use crate::api::event::{self, Event}; +use crate::api::Scope; use crate::issue::{message, model, Issue}; -use crate::types::event::{self, Event}; -use crate::types::Scope; use async_trait::async_trait; use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index ad624e7d0b..228dee6a2c 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -19,9 +19,9 @@ // find current contact information at www.suse.com. use crate::actor::{self, Handler}; +use crate::api::event; use crate::issue::monitor::{self, Monitor}; use crate::issue::service::{self, Service}; -use crate::types::event; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -46,9 +46,9 @@ pub async fn start( #[cfg(test)] mod tests { + use crate::api::event::Event; + use crate::api::scope::Scope; use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; - use crate::types::event::Event; - use crate::types::scope::Scope; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 03edabac2e..d708cd92b5 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -25,8 +25,8 @@ pub mod service; pub use service::Service; pub mod actor; +pub mod api; pub mod dbus; pub mod issue; pub mod openapi; pub mod progress; -pub mod types; diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 282defb8eb..1d3c5b6fc5 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::actor::Message; -use crate::types::progress::Progress; -use crate::types::scope::Scope; +use crate::api::progress::Progress; +use crate::api::scope::Scope; pub struct Get; diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index e4f7c3e8c7..76fed67da7 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -19,10 +19,10 @@ // find current contact information at www.suse.com. use crate::actor::{self, Actor, MessageHandler}; +use crate::api::event::{self, Event}; +use crate::api::progress::{self, Progress}; +use crate::api::scope::Scope; use crate::progress::message; -use crate::types::event::{self, Event}; -use crate::types::progress::{self, Progress}; -use crate::types::scope::Scope; use async_trait::async_trait; use tokio::sync::broadcast; diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 67159fe6a8..6074a1fd6a 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::actor::{self, Handler}; +use crate::api::event; use crate::progress::service::Service; -use crate::types::event; use std::convert::Infallible; #[derive(thiserror::Error, Debug)] @@ -40,11 +40,11 @@ pub async fn start(events: event::Sender) -> Result, Error> { #[cfg(test)] mod tests { use crate::actor::{self, Handler}; + use crate::api::event::{self, Event}; + use crate::api::progress; + use crate::api::scope::Scope; use crate::progress::message; use crate::progress::service::{self, Service}; - use crate::types::event::{self, Event}; - use crate::types::progress; - use crate::types::scope::Scope; use tokio::sync::broadcast; fn start_testing_service() -> (event::Receiver, Handler) { From eae8c4d2f26534d37998faa04cfb2588bb190a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 15:14:02 +0100 Subject: [PATCH 194/917] Move l10n system info to api --- rust/Cargo.lock | 3 + rust/agama-l10n/src/extended_config.rs | 2 +- rust/agama-l10n/src/lib.rs | 3 - rust/agama-l10n/src/message.rs | 2 +- rust/agama-l10n/src/model.rs | 28 ++++- rust/agama-l10n/src/model/keyboard.rs | 41 +------ rust/agama-l10n/src/model/locale.rs | 18 +--- rust/agama-l10n/src/model/timezone.rs | 13 +-- rust/agama-l10n/src/service.rs | 4 +- rust/agama-l10n/src/start.rs | 6 +- rust/agama-l10n/src/system_info.rs | 64 ----------- rust/agama-manager/src/system_info.rs | 2 +- rust/agama-utils/Cargo.toml | 3 + rust/agama-utils/src/api/l10n.rs | 3 + rust/agama-utils/src/api/l10n/system_info.rs | 106 +++++++++++++++++++ 15 files changed, 152 insertions(+), 146 deletions(-) delete mode 100644 rust/agama-l10n/src/system_info.rs create mode 100644 rust/agama-utils/src/api/l10n/system_info.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cbd331c534..339346e8d3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -229,9 +229,12 @@ dependencies = [ name = "agama-utils" version = "0.1.0" dependencies = [ + "agama-locale-data", "async-trait", + "gettext-rs", "serde", "serde_json", + "serde_with", "strum", "thiserror 2.0.16", "tokio", diff --git a/rust/agama-l10n/src/extended_config.rs b/rust/agama-l10n/src/extended_config.rs index a36b2ed8c1..e5cc3feebf 100644 --- a/rust/agama-l10n/src/extended_config.rs +++ b/rust/agama-l10n/src/extended_config.rs @@ -19,9 +19,9 @@ // find current contact information at www.suse.com. use crate::service; -use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; use agama_utils::api; +use agama_utils::api::l10n::SystemInfo; #[derive(Clone, PartialEq)] pub struct ExtendedConfig { diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index b5306f7dc9..7948fa2187 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -44,9 +44,6 @@ pub use service::Service; mod model; pub use model::{Model, ModelAdapter}; -mod system_info; -pub use system_info::SystemInfo; - mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index ca5266ef24..67b93732ad 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -19,10 +19,10 @@ // find current contact information at www.suse.com. use crate::proposal::Proposal; -use crate::system_info::SystemInfo; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::Message; use agama_utils::api; +use agama_utils::api::l10n::SystemInfo; use serde::Deserialize; #[derive(Clone)] diff --git a/rust/agama-l10n/src/model.rs b/rust/agama-l10n/src/model.rs index a453c8ab91..49799eb42a 100644 --- a/rust/agama-l10n/src/model.rs +++ b/rust/agama-l10n/src/model.rs @@ -19,18 +19,22 @@ // find current contact information at www.suse.com. mod keyboard; -pub use keyboard::{Keymap, KeymapsDatabase}; +pub use keyboard::KeymapsDatabase; mod locale; -pub use locale::{LocaleEntry, LocalesDatabase}; +pub use locale::LocalesDatabase; mod timezone; -pub use timezone::{TimezoneEntry, TimezonesDatabase}; +pub use timezone::TimezonesDatabase; use crate::{helpers, service}; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use agama_utils::api::l10n::SystemInfo; use regex::Regex; -use std::{env, fs::OpenOptions, io::Write, process::Command}; +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::process::Command; /// Abstract the localization-related configuration from the underlying system. /// @@ -38,6 +42,22 @@ use std::{env, fs::OpenOptions, io::Write, process::Command}; /// system. This trait can be implemented to replace the real system during /// tests. pub trait ModelAdapter: Send + 'static { + /// Reads the system info. + fn read_system_info(&self) -> SystemInfo { + let locales = self.locales_db().entries().clone(); + let keymaps = self.keymaps_db().entries().clone(); + let timezones = self.timezones_db().entries().clone(); + + SystemInfo { + locales, + keymaps, + timezones, + locale: self.locale(), + keymap: self.keymap().unwrap(), + timezone: Default::default(), + } + } + /// Locales database. fn locales_db(&self) -> &LocalesDatabase; diff --git a/rust/agama-l10n/src/model/keyboard.rs b/rust/agama-l10n/src/model/keyboard.rs index e958a7bd44..f06da8e6a4 100644 --- a/rust/agama-l10n/src/model/keyboard.rs +++ b/rust/agama-l10n/src/model/keyboard.rs @@ -18,45 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_locale_data::{get_localectl_keymaps, keyboard::XkbConfigRegistry, KeymapId}; -use gettextrs::*; -use serde::ser::{Serialize, SerializeStruct}; +use agama_locale_data::get_localectl_keymaps; +use agama_locale_data::keyboard::XkbConfigRegistry; +use agama_locale_data::KeymapId; +use agama_utils::api::l10n::Keymap; use std::collections::HashMap; -// Minimal representation of a keymap -#[derive(Clone, Debug, utoipa::ToSchema)] -pub struct Keymap { - /// Keymap identifier (e.g., "us") - pub id: KeymapId, - /// Keymap description - description: String, -} - -impl Keymap { - pub fn new(id: KeymapId, description: &str) -> Self { - Self { - id, - description: description.to_string(), - } - } - - pub fn localized_description(&self) -> String { - gettext(&self.description) - } -} - -impl Serialize for Keymap { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("Keymap", 2)?; - state.serialize_field("id", &self.id.to_string())?; - state.serialize_field("description", &self.localized_description())?; - state.end() - } -} - /// Represents the keymaps database. /// /// The list of supported keymaps is read from `systemd-localed` and the diff --git a/rust/agama-l10n/src/model/locale.rs b/rust/agama-l10n/src/model/locale.rs index 62bd04b52b..811af05b1e 100644 --- a/rust/agama-l10n/src/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -21,26 +21,10 @@ //! This module provides support for reading the locales database. use agama_locale_data::LocaleId; +use agama_utils::api::l10n::LocaleEntry; use anyhow::Context; -use serde::Serialize; -use serde_with::{serde_as, DisplayFromStr}; use std::{fs, process::Command}; -/// Represents a locale, including the localized language and territory. -#[serde_as] -#[derive(Debug, Serialize, Clone, utoipa::ToSchema)] -pub struct LocaleEntry { - /// The locale code (e.g., "es_ES.UTF-8"). - #[serde_as(as = "DisplayFromStr")] - pub id: LocaleId, - /// Localized language name (e.g., "Spanish", "Español", etc.) - pub language: String, - /// Localized territory name (e.g., "Spain", "España", etc.) - pub territory: String, - /// Console font - pub consolefont: Option, -} - /// Represents the locales database. /// /// The list of supported locales is read from `systemd-localed`. However, the diff --git a/rust/agama-l10n/src/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs index 76914c5de5..28d7e5cec3 100644 --- a/rust/agama-l10n/src/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -21,20 +21,9 @@ //! This module provides support for reading the timezones database. use agama_locale_data::{territory::Territories, timezone_part::TimezoneIdParts, TimezoneId}; -use serde::Serialize; +use agama_utils::api::l10n::TimezoneEntry; use std::collections::HashMap; -/// Represents a timezone, including each part as localized. -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct TimezoneEntry { - /// Timezone identifier (e.g. "Atlantic/Canary"). - pub id: TimezoneId, - /// Localized parts (e.g., "Atlántico", "Canarias"). - pub parts: Vec, - /// Localized name of the territory this timezone is associated to - pub country: Option, -} - #[derive(Default)] pub struct TimezonesDatabase { timezones: Vec, diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 503313dc9d..f597ed0906 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -22,11 +22,11 @@ use crate::extended_config::ExtendedConfig; use crate::message; use crate::model::ModelAdapter; use crate::proposal::Proposal; -use crate::system_info::SystemInfo; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api; use agama_utils::api::event::{self, Event}; +use agama_utils::api::l10n::SystemInfo; use agama_utils::api::scope::Scope; use agama_utils::issue::{self, Issue}; use async_trait::async_trait; @@ -87,7 +87,7 @@ impl Service { issues: Handler, events: event::Sender, ) -> Service { - let system = SystemInfo::read_from(&model); + let system = model.read_system_info(); let config = ExtendedConfig::new_from(&system); let proposal = (&config).into(); let state = State { diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 8e93e1785a..ef8aa5d299 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -59,15 +59,13 @@ pub async fn start( #[cfg(test)] mod tests { use crate::message; - use crate::model::{ - Keymap, KeymapsDatabase, LocaleEntry, LocalesDatabase, ModelAdapter, TimezoneEntry, - TimezonesDatabase, - }; + use crate::model::{KeymapsDatabase, LocalesDatabase, ModelAdapter, TimezonesDatabase}; use crate::service::{self, Service}; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::{self, Handler}; use agama_utils::api; use agama_utils::api::event::{self, Event}; + use agama_utils::api::l10n::{Keymap, LocaleEntry, TimezoneEntry}; use agama_utils::api::scope::Scope; use agama_utils::issue; use tokio::sync::broadcast; diff --git a/rust/agama-l10n/src/system_info.rs b/rust/agama-l10n/src/system_info.rs deleted file mode 100644 index a33d113ffc..0000000000 --- a/rust/agama-l10n/src/system_info.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::model::{Keymap, LocaleEntry, ModelAdapter, TimezoneEntry}; -use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; -use serde::Serialize; -use serde_with::{serde_as, DisplayFromStr}; - -/// Localization-related information of the system where the installer -/// is running. -#[serde_as] -#[derive(Clone, Debug, Serialize)] -pub struct SystemInfo { - /// List of know locales. - pub locales: Vec, - /// List of known timezones. - pub timezones: Vec, - /// List of known keymaps. - pub keymaps: Vec, - /// Locale of the system where Agama is running. - #[serde_as(as = "DisplayFromStr")] - pub locale: LocaleId, - /// Keymap of the system where Agama is running. - #[serde_as(as = "DisplayFromStr")] - pub keymap: KeymapId, - /// Timezone of the system where Agama is running. - #[serde_as(as = "DisplayFromStr")] - pub timezone: TimezoneId, -} - -impl SystemInfo { - /// Reads the information from the system adapter. - pub fn read_from(model: &T) -> Self { - let locales = model.locales_db().entries().clone(); - let keymaps = model.keymaps_db().entries().clone(); - let timezones = model.timezones_db().entries().clone(); - - Self { - locales, - keymaps, - timezones, - locale: model.locale(), - keymap: model.keymap().unwrap(), - timezone: Default::default(), - } - } -} diff --git a/rust/agama-manager/src/system_info.rs b/rust/agama-manager/src/system_info.rs index c2e9cc6ac5..0a3e2185ce 100644 --- a/rust/agama-manager/src/system_info.rs +++ b/rust/agama-manager/src/system_info.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; +use agama_utils::api::l10n; use serde::Serialize; #[derive(Clone, Debug, Serialize)] diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 2d9426acb2..1f1b3ef9e6 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true async-trait = "0.1.89" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.140" +serde_with = "3.14.0" strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } @@ -15,6 +16,8 @@ tokio-stream = "0.1.17" utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" +gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +agama-locale-data = { path = "../agama-locale-data" } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api/l10n.rs b/rust/agama-utils/src/api/l10n.rs index 806253ace3..7fdb2afbc6 100644 --- a/rust/agama-utils/src/api/l10n.rs +++ b/rust/agama-utils/src/api/l10n.rs @@ -23,3 +23,6 @@ pub mod config; pub use config::Config; + +pub mod system_info; +pub use system_info::{Keymap, LocaleEntry, SystemInfo, TimezoneEntry}; diff --git a/rust/agama-utils/src/api/l10n/system_info.rs b/rust/agama-utils/src/api/l10n/system_info.rs new file mode 100644 index 0000000000..4c7b4cee84 --- /dev/null +++ b/rust/agama-utils/src/api/l10n/system_info.rs @@ -0,0 +1,106 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use gettextrs::*; +use serde::ser::SerializeStruct; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; + +/// Localization-related information of the system where the installer is running. +#[serde_as] +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +pub struct SystemInfo { + /// List of know locales. + pub locales: Vec, + /// List of known timezones. + pub timezones: Vec, + /// List of known keymaps. + pub keymaps: Vec, + /// Locale of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub locale: LocaleId, + /// Keymap of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub keymap: KeymapId, + /// Timezone of the system where Agama is running. + #[serde_as(as = "DisplayFromStr")] + pub timezone: TimezoneId, +} + +/// Represents a locale, including the localized language and territory. +#[serde_as] +#[derive(Debug, Serialize, Clone, utoipa::ToSchema)] +pub struct LocaleEntry { + /// The locale code (e.g., "es_ES.UTF-8"). + #[serde_as(as = "DisplayFromStr")] + pub id: LocaleId, + /// Localized language name (e.g., "Spanish", "Español", etc.) + pub language: String, + /// Localized territory name (e.g., "Spain", "España", etc.) + pub territory: String, + /// Console font + pub consolefont: Option, +} + +/// Represents a timezone, including each part as localized. +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct TimezoneEntry { + /// Timezone identifier (e.g. "Atlantic/Canary"). + pub id: TimezoneId, + /// Localized parts (e.g., "Atlántico", "Canarias"). + pub parts: Vec, + /// Localized name of the territory this timezone is associated to + pub country: Option, +} + +// Minimal representation of a keymap +#[derive(Clone, Debug, utoipa::ToSchema)] +pub struct Keymap { + /// Keymap identifier (e.g., "us") + pub id: KeymapId, + /// Keymap description + description: String, +} + +impl Keymap { + pub fn new(id: KeymapId, description: &str) -> Self { + Self { + id, + description: description.to_string(), + } + } + + pub fn localized_description(&self) -> String { + gettext(&self.description) + } +} + +impl Serialize for Keymap { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("Keymap", 2)?; + state.serialize_field("id", &self.id.to_string())?; + state.serialize_field("description", &self.localized_description())?; + state.end() + } +} From f508fca7a2a422a1e3a81edd0b324d1ca71446da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 15:30:45 +0100 Subject: [PATCH 195/917] Move system info to api --- rust/agama-manager/src/lib.rs | 4 ---- rust/agama-manager/src/message.rs | 3 +-- rust/agama-manager/src/service.rs | 3 +-- rust/agama-server/src/server/web.rs | 3 +-- rust/agama-server/src/web/docs/config.rs | 8 +++++--- rust/agama-utils/src/api.rs | 3 +++ .../src => agama-utils/src/api}/system_info.rs | 4 ++-- 7 files changed, 13 insertions(+), 15 deletions(-) rename rust/{agama-manager/src => agama-utils/src/api}/system_info.rs (92%) diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 9107509fd7..03ff7a04aa 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -24,11 +24,7 @@ pub use start::start; pub mod service; pub use service::Service; -mod system_info; -pub use system_info::SystemInfo; - pub mod message; - mod proposal; pub use agama_l10n as l10n; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 1fb60befd4..a115c44f13 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -20,10 +20,9 @@ use crate::l10n; use crate::proposal::Proposal; -use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; -use agama_utils::api::{Scope, Status}; +use agama_utils::api::{Scope, Status, SystemInfo}; use agama_utils::issue::Issue; use serde::Deserialize; use std::collections::HashMap; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index bf48541db5..f4e620d4c5 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -21,11 +21,10 @@ use crate::l10n; use crate::message::{self, Action}; use crate::proposal::Proposal; -use crate::system_info::SystemInfo; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api::status::State; -use agama_utils::api::{event, Event, Scope, Status}; +use agama_utils::api::{event, Event, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index e370c6cfe0..db9217e30d 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -24,10 +24,9 @@ use crate::server::types::{ConfigPatch, IssuesMap}; use agama_lib::error::ServiceError; use agama_lib::install_settings::InstallSettings; use agama_manager::message; -use agama_manager::SystemInfo; use agama_manager::{self as manager}; use agama_utils::actor::Handler; -use agama_utils::api::{event, Status}; +use agama_utils::api::{event, Status, SystemInfo}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 5558607edf..ceed24b036 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -162,10 +162,12 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .build() } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 51da7c665e..80a92f1f4e 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -33,4 +33,7 @@ pub use scope::Scope; pub mod status; pub use status::Status; +pub mod system_info; +pub use system_info::SystemInfo; + pub mod l10n; diff --git a/rust/agama-manager/src/system_info.rs b/rust/agama-utils/src/api/system_info.rs similarity index 92% rename from rust/agama-manager/src/system_info.rs rename to rust/agama-utils/src/api/system_info.rs index 0a3e2185ce..d7122ad6e8 100644 --- a/rust/agama-manager/src/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::l10n; +use crate::api::l10n; use serde::Serialize; -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct SystemInfo { pub localization: l10n::SystemInfo, } From 9006aee9b9f43a00b62966f169babc29c43316d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 16:23:07 +0100 Subject: [PATCH 196/917] Move proposal to api --- rust/agama-l10n/src/lib.rs | 8 +-- rust/agama-l10n/src/message.rs | 3 +- rust/agama-l10n/src/service.rs | 65 +++++++++---------- rust/agama-manager/src/lib.rs | 1 - rust/agama-manager/src/message.rs | 3 +- rust/agama-manager/src/service.rs | 3 +- rust/agama-server/src/web/docs/config.rs | 4 +- rust/agama-utils/src/api.rs | 3 + rust/agama-utils/src/api/l10n.rs | 7 +- .../src/api/l10n}/proposal.rs | 17 +---- .../src => agama-utils/src/api}/proposal.rs | 4 +- 11 files changed, 48 insertions(+), 70 deletions(-) rename rust/{agama-l10n/src => agama-utils/src/api/l10n}/proposal.rs (73%) rename rust/{agama-manager/src => agama-utils/src/api}/proposal.rs (92%) diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index 7948fa2187..efff03d05d 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -44,12 +44,8 @@ pub use service::Service; mod model; pub use model::{Model, ModelAdapter}; -mod proposal; -pub use proposal::Proposal; - -pub mod helpers; -pub mod message; - mod dbus; mod extended_config; +pub mod helpers; +pub mod message; mod monitor; diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 67b93732ad..c346789bec 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -18,11 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::proposal::Proposal; use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::Message; use agama_utils::api; -use agama_utils::api::l10n::SystemInfo; +use agama_utils::api::l10n::{Proposal, SystemInfo}; use serde::Deserialize; #[derive(Clone)] diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index f597ed0906..68f16be188 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -21,12 +21,11 @@ use crate::extended_config::ExtendedConfig; use crate::message; use crate::model::ModelAdapter; -use crate::proposal::Proposal; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api; use agama_utils::api::event::{self, Event}; -use agama_utils::api::l10n::SystemInfo; +use agama_utils::api::l10n::{Proposal, SystemInfo}; use agama_utils::api::scope::Scope; use agama_utils::issue::{self, Issue}; use async_trait::async_trait; @@ -69,18 +68,13 @@ pub enum Error { /// * Holds the user configuration. /// * Applies the user configuration at the end of the installation. pub struct Service { - state: State, + system: SystemInfo, + config: ExtendedConfig, model: Box, issues: Handler, events: event::Sender, } -struct State { - system: SystemInfo, - config: ExtendedConfig, - proposal: Option, -} - impl Service { pub fn new( model: T, @@ -89,26 +83,33 @@ impl Service { ) -> Service { let system = model.read_system_info(); let config = ExtendedConfig::new_from(&system); - let proposal = (&config).into(); - let state = State { - system, - config, - proposal: Some(proposal), - }; Self { - state, + system, + config, model: Box::new(model), issues, events, } } + fn get_proposal(&self) -> Option { + if !self.find_issues().is_empty() { + return None; + } + + Some(Proposal { + keymap: self.config.keymap.clone(), + locale: self.config.locale.clone(), + timezone: self.config.timezone.clone(), + }) + } + /// Returns configuration issues. /// /// It returns an issue for each unknown element (locale, keymap and timezone). fn find_issues(&self) -> Vec { - let config = &self.state.config; + let config = &self.config; let mut issues = vec![]; if !self.model.locales_db().exists(&config.locale) { issues.push(Issue { @@ -151,7 +152,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { - Ok(self.state.system.clone()) + Ok(self.system.clone()) } } @@ -177,11 +178,10 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetConfig) -> Result { - let config = self.state.config.clone(); Ok(api::l10n::Config { - locale: Some(config.locale.to_string()), - keymap: Some(config.keymap.to_string()), - timezone: Some(config.timezone.to_string()), + locale: Some(self.config.locale.to_string()), + keymap: Some(self.config.keymap.to_string()), + timezone: Some(self.config.timezone.to_string()), }) } } @@ -192,21 +192,14 @@ impl MessageHandler> for Service { &mut self, message: message::SetConfig, ) -> Result<(), Error> { - let config = ExtendedConfig::new_from(&self.state.system); + let config = ExtendedConfig::new_from(&self.system); let merged = config.merge(&message.config)?; - if merged == self.state.config { + if merged == self.config { return Ok(()); } - self.state.config = merged; + self.config = merged; let issues = self.find_issues(); - - self.state.proposal = if issues.is_empty() { - Some((&self.state.config).into()) - } else { - None - }; - self.issues .cast(issue::message::Update::new(Scope::L10n, issues))?; self.events @@ -218,14 +211,14 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { - Ok(self.state.proposal.clone()) + Ok(self.get_proposal()) } } #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { - let Some(proposal) = &self.state.proposal else { + let Some(proposal) = self.get_proposal() else { return Err(Error::MissingProposal); }; @@ -238,7 +231,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateLocale) -> Result<(), Error> { - self.state.system.locale = message.locale; + self.system.locale = message.locale; _ = self .events .send(Event::SystemChanged { scope: Scope::L10n }); @@ -249,7 +242,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateKeymap) -> Result<(), Error> { - self.state.system.keymap = message.keymap; + self.system.keymap = message.keymap; _ = self .events .send(Event::SystemChanged { scope: Scope::L10n }); diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 03ff7a04aa..9a4f0730e9 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -25,6 +25,5 @@ pub mod service; pub use service::Service; pub mod message; -mod proposal; pub use agama_l10n as l10n; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index a115c44f13..a332cb731b 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -19,10 +19,9 @@ // find current contact information at www.suse.com. use crate::l10n; -use crate::proposal::Proposal; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; -use agama_utils::api::{Scope, Status, SystemInfo}; +use agama_utils::api::{Proposal, Scope, Status, SystemInfo}; use agama_utils::issue::Issue; use serde::Deserialize; use std::collections::HashMap; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index f4e620d4c5..0f4e97ecd8 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -20,11 +20,10 @@ use crate::l10n; use crate::message::{self, Action}; -use crate::proposal::Proposal; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api::status::State; -use agama_utils::api::{event, Event, Scope, Status, SystemInfo}; +use agama_utils::api::{event, Event, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ceed24b036..3efc778040 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -162,13 +162,15 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 80a92f1f4e..2d021808bb 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -36,4 +36,7 @@ pub use status::Status; pub mod system_info; pub use system_info::SystemInfo; +pub mod proposal; +pub use proposal::Proposal; + pub mod l10n; diff --git a/rust/agama-utils/src/api/l10n.rs b/rust/agama-utils/src/api/l10n.rs index 7fdb2afbc6..f73ac8807e 100644 --- a/rust/agama-utils/src/api/l10n.rs +++ b/rust/agama-utils/src/api/l10n.rs @@ -21,8 +21,11 @@ //! This module contains all Agama public types that might be available over //! the HTTP and WebSocket API. -pub mod config; +mod config; pub use config::Config; -pub mod system_info; +mod system_info; pub use system_info::{Keymap, LocaleEntry, SystemInfo, TimezoneEntry}; + +mod proposal; +pub use proposal::Proposal; diff --git a/rust/agama-l10n/src/proposal.rs b/rust/agama-utils/src/api/l10n/proposal.rs similarity index 73% rename from rust/agama-l10n/src/proposal.rs rename to rust/agama-utils/src/api/l10n/proposal.rs index c915fc3f60..2c02e3d627 100644 --- a/rust/agama-l10n/src/proposal.rs +++ b/rust/agama-utils/src/api/l10n/proposal.rs @@ -18,14 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::extended_config::ExtendedConfig; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; /// Describes what Agama proposes for the target system. #[serde_as] -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct Proposal { /// Keymap (e.g., "us", "cz(qwerty)", etc.). #[serde_as(as = "DisplayFromStr")] @@ -37,17 +36,3 @@ pub struct Proposal { #[serde_as(as = "DisplayFromStr")] pub timezone: TimezoneId, } - -/// Turns the configuration into a proposal. -/// -/// It is possible because, in the l10n module, the configuration and the -/// proposal are mostly the same. -impl From<&ExtendedConfig> for Proposal { - fn from(config: &ExtendedConfig) -> Self { - Proposal { - keymap: config.keymap.clone(), - locale: config.locale.clone(), - timezone: config.timezone.clone(), - } - } -} diff --git a/rust/agama-manager/src/proposal.rs b/rust/agama-utils/src/api/proposal.rs similarity index 92% rename from rust/agama-manager/src/proposal.rs rename to rust/agama-utils/src/api/proposal.rs index 2a2d5a9ba1..83c4822772 100644 --- a/rust/agama-manager/src/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; +use crate::api::l10n; use serde::Serialize; -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] pub localization: Option, From e9f72d787436175235269406be938c3035735907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 16:32:40 +0100 Subject: [PATCH 197/917] Add missing types to openapi --- rust/Cargo.lock | 1 + rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/web/docs/config.rs | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 339346e8d3..246ac8eeb1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -181,6 +181,7 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-lib", + "agama-locale-data", "agama-manager", "agama-utils", "anyhow", diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 59f4a5bc7a..8d2b05f581 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1.0" agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-locale-data = { path = "../agama-locale-data" } agama-manager = { path = "../agama-manager" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 3efc778040..89a234630d 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -168,9 +168,15 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .build() } } From 1f85b8259d1a7a870414492039a05df891713848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 14 Oct 2025 16:57:45 +0100 Subject: [PATCH 198/917] Move action to api --- rust/agama-l10n/src/message.rs | 7 ----- rust/agama-l10n/src/service.rs | 9 ++---- rust/agama-manager/src/message.rs | 12 +------- rust/agama-manager/src/service.rs | 4 +-- rust/agama-server/src/server/web.rs | 6 ++-- rust/agama-server/src/web/docs/config.rs | 6 ++-- rust/agama-utils/src/api.rs | 7 +++-- rust/agama-utils/src/api/action.rs | 30 +++++++++++++++++++ rust/agama-utils/src/api/l10n.rs | 3 ++ .../agama-utils/src/api/l10n/system_config.rs | 27 +++++++++++++++++ 10 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 rust/agama-utils/src/api/action.rs create mode 100644 rust/agama-utils/src/api/l10n/system_config.rs diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index c346789bec..08bdb7e899 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -22,7 +22,6 @@ use agama_locale_data::{KeymapId, LocaleId}; use agama_utils::actor::Message; use agama_utils::api; use agama_utils::api::l10n::{Proposal, SystemInfo}; -use serde::Deserialize; #[derive(Clone)] pub struct GetSystem; @@ -45,12 +44,6 @@ impl SetSystem { } } -#[derive(Clone, Debug, Deserialize, utoipa::ToSchema)] -pub struct SystemConfig { - pub locale: Option, - pub keymap: Option, -} - pub struct GetConfig; impl Message for GetConfig { diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 68f16be188..c905a6ad5d 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -25,7 +25,7 @@ use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, Key use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api; use agama_utils::api::event::{self, Event}; -use agama_utils::api::l10n::{Proposal, SystemInfo}; +use agama_utils::api::l10n::{Proposal, SystemConfig, SystemInfo}; use agama_utils::api::scope::Scope; use agama_utils::issue::{self, Issue}; use async_trait::async_trait; @@ -157,11 +157,8 @@ impl MessageHandler for Service { } #[async_trait] -impl MessageHandler> for Service { - async fn handle( - &mut self, - message: message::SetSystem, - ) -> Result<(), Error> { +impl MessageHandler> for Service { + async fn handle(&mut self, message: message::SetSystem) -> Result<(), Error> { let config = &message.config; if let Some(locale) = &config.locale { self.model.set_locale(locale.parse()?)?; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index a332cb731b..a1f7eb4d5c 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,12 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; -use agama_utils::api::{Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{Action, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue::Issue; -use serde::Deserialize; use std::collections::HashMap; /// Gets the installation status. @@ -121,11 +119,3 @@ impl RunAction { impl Message for RunAction { type Reply = (); } - -#[derive(Debug, Deserialize, utoipa::ToSchema)] -pub enum Action { - #[serde(rename = "configureL10n")] - ConfigureL10n(l10n::message::SystemConfig), - #[serde(rename = "install")] - Install, -} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 0f4e97ecd8..16621b0791 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -19,11 +19,11 @@ // find current contact information at www.suse.com. use crate::l10n; -use crate::message::{self, Action}; +use crate::message; use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api::status::State; -use agama_utils::api::{event, Event, Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{event, Action, Event, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index db9217e30d..c60c65ee77 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -26,7 +26,7 @@ use agama_lib::install_settings::InstallSettings; use agama_manager::message; use agama_manager::{self as manager}; use agama_utils::actor::Handler; -use agama_utils::api::{event, Status, SystemInfo}; +use agama_utils::api::{event, Action, Status, SystemInfo}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; @@ -242,12 +242,12 @@ async fn get_issues(State(state): State) -> ServerResult, - Json(action): Json, + Json(action): Json, ) -> ServerResult<()> { state.manager.call(message::RunAction::new(action)).await?; Ok(()) diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 89a234630d..4b72b207e7 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -46,7 +46,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema("IpAddr", schemas::ip_addr()) .schema("IpInet", schemas::ip_inet()) .schema("macaddr.MacAddr6", schemas::mac_addr6()) - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -161,13 +160,14 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() - .schema_from::() .schema_from::() + .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 2d021808bb..8c879011e8 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -33,10 +33,13 @@ pub use scope::Scope; pub mod status; pub use status::Status; -pub mod system_info; +mod system_info; pub use system_info::SystemInfo; -pub mod proposal; +mod proposal; pub use proposal::Proposal; +mod action; +pub use action::Action; + pub mod l10n; diff --git a/rust/agama-utils/src/api/action.rs b/rust/agama-utils/src/api/action.rs new file mode 100644 index 0000000000..858f1aaac8 --- /dev/null +++ b/rust/agama-utils/src/api/action.rs @@ -0,0 +1,30 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::l10n; +use serde::Deserialize; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub enum Action { + #[serde(rename = "configureL10n")] + ConfigureL10n(l10n::SystemConfig), + #[serde(rename = "install")] + Install, +} diff --git a/rust/agama-utils/src/api/l10n.rs b/rust/agama-utils/src/api/l10n.rs index f73ac8807e..09653a0f34 100644 --- a/rust/agama-utils/src/api/l10n.rs +++ b/rust/agama-utils/src/api/l10n.rs @@ -27,5 +27,8 @@ pub use config::Config; mod system_info; pub use system_info::{Keymap, LocaleEntry, SystemInfo, TimezoneEntry}; +mod system_config; +pub use system_config::SystemConfig; + mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-utils/src/api/l10n/system_config.rs b/rust/agama-utils/src/api/l10n/system_config.rs new file mode 100644 index 0000000000..72bdef17a1 --- /dev/null +++ b/rust/agama-utils/src/api/l10n/system_config.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, utoipa::ToSchema)] +pub struct SystemConfig { + pub locale: Option, + pub keymap: Option, +} From 531c66bada32ebb1da2626de6cd327677c40e469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 08:41:59 +0100 Subject: [PATCH 199/917] Add config to api --- rust/agama-lib/src/install_settings.rs | 3 -- rust/agama-manager/src/message.rs | 15 +++--- rust/agama-manager/src/service.rs | 25 ++++----- rust/agama-manager/src/start.rs | 31 ++++------- rust/agama-server/src/server/types.rs | 8 --- rust/agama-server/src/server/web.rs | 23 ++++---- rust/agama-server/src/web/docs/config.rs | 3 +- rust/agama-server/tests/server_service.rs | 66 ++++++++++------------- rust/agama-utils/src/api.rs | 3 ++ rust/agama-utils/src/api/config.rs | 36 +++++++++++++ 10 files changed, 108 insertions(+), 105 deletions(-) create mode 100644 rust/agama-utils/src/api/config.rs diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index 2aa22e4d34..f1e82354ed 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -33,7 +33,6 @@ use crate::{ network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, }; -use agama_utils::api; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; @@ -86,8 +85,6 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub zfcp: Option, diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index a1f7eb4d5c..ce74bf13ed 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,9 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Message; -use agama_utils::api::{Action, Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{Action, Config, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue::Issue; use std::collections::HashMap; @@ -46,7 +45,7 @@ impl Message for GetSystem { pub struct GetExtendedConfig; impl Message for GetExtendedConfig { - type Reply = InstallSettings; + type Reply = Config; } /// Gets the current config set by the user. @@ -54,17 +53,17 @@ impl Message for GetExtendedConfig { pub struct GetConfig; impl Message for GetConfig { - type Reply = InstallSettings; + type Reply = Config; } /// Replaces the config. #[derive(Debug)] pub struct SetConfig { - pub config: InstallSettings, + pub config: Config, } impl SetConfig { - pub fn new(config: InstallSettings) -> Self { + pub fn new(config: Config) -> Self { Self { config } } } @@ -76,11 +75,11 @@ impl Message for SetConfig { /// Updates the config. #[derive(Debug)] pub struct UpdateConfig { - pub config: InstallSettings, + pub config: Config, } impl UpdateConfig { - pub fn new(config: InstallSettings) -> Self { + pub fn new(config: Config) -> Self { Self { config } } } diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 16621b0791..c419e47751 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -20,10 +20,10 @@ use crate::l10n; use crate::message; -use agama_lib::install_settings::InstallSettings; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; +use agama_utils::api::event; use agama_utils::api::status::State; -use agama_utils::api::{event, Action, Event, Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{Action, Config, Event, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; @@ -52,7 +52,7 @@ pub struct Service { issues: Handler, progress: Handler, state: State, - config: InstallSettings, + config: Config, events: event::Sender, } @@ -69,7 +69,7 @@ impl Service { progress, events, state: State::Configuring, - config: InstallSettings::default(), + config: Config::default(), } } @@ -125,13 +125,10 @@ impl MessageHandler for Service { /// Gets the current configuration. /// /// It includes user and default values. - async fn handle( - &mut self, - _message: message::GetExtendedConfig, - ) -> Result { - let l10n_config = self.l10n.call(l10n::message::GetConfig).await?; - Ok(InstallSettings { - localization: Some(l10n_config), + async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { + let l10n = self.l10n.call(l10n::message::GetConfig).await?; + Ok(Config { + l10n: Some(l10n), ..Default::default() }) } @@ -142,7 +139,7 @@ impl MessageHandler for Service { /// Gets the current configuration set by the user. /// /// It includes only the values that were set by the user. - async fn handle(&mut self, _message: message::GetConfig) -> Result { + async fn handle(&mut self, _message: message::GetConfig) -> Result { Ok(self.config.clone()) } } @@ -157,9 +154,9 @@ impl MessageHandler for Service { /// FIXME: We should replace not given sections with the default ones. /// After all, now we have config/user/:scope URLs. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - if let Some(l10n_config) = &message.config.localization { + if let Some(l10n) = &message.config.l10n { self.l10n - .call(l10n::message::SetConfig::new(l10n_config.clone())) + .call(l10n::message::SetConfig::new(l10n.clone())) .await?; } self.config = message.config; diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index cfb285f6d0..87ca9d460a 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -63,13 +63,12 @@ pub async fn start( #[cfg(test)] mod test { + use crate as manager; use crate::message; use crate::service::Service; - use crate::{self as manager}; - use agama_lib::install_settings::InstallSettings; use agama_utils::actor::Handler; - use agama_utils::api; - use agama_utils::api::Event; + use agama_utils::api::l10n; + use agama_utils::api::{Config, Event}; use tokio::sync::broadcast; async fn start_service() -> Handler { @@ -89,13 +88,12 @@ mod test { async fn test_update_config() -> Result<(), Box> { let handler = start_service().await; - let input_config = InstallSettings { - localization: Some(api::l10n::Config { + let input_config = Config { + l10n: Some(l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }), - ..Default::default() }; handler @@ -104,10 +102,7 @@ mod test { let config = handler.call(message::GetConfig).await?; - assert_eq!( - input_config.localization.unwrap(), - config.localization.unwrap() - ); + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); Ok(()) } @@ -119,18 +114,17 @@ mod test { // Ensure the keymap is different to the system one. let config = handler.call(message::GetExtendedConfig).await?; - let keymap = if config.localization.unwrap().keymap.unwrap() == "es" { + let keymap = if config.l10n.unwrap().keymap.unwrap() == "es" { "en" } else { "es" }; - let input_config = InstallSettings { - localization: Some(api::l10n::Config { + let input_config = Config { + l10n: Some(l10n::Config { keymap: Some(keymap.to_string()), ..Default::default() }), - ..Default::default() }; handler @@ -139,13 +133,10 @@ mod test { let config = handler.call(message::GetConfig).await?; - assert_eq!( - input_config.localization.unwrap(), - config.localization.unwrap() - ); + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); let extended_config = handler.call(message::GetExtendedConfig).await?; - let l10n_config = extended_config.localization.unwrap(); + let l10n_config = extended_config.l10n.unwrap(); assert!(l10n_config.locale.is_some()); assert!(l10n_config.keymap.is_some()); diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index b34262ecc5..4d61233ffd 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -20,7 +20,6 @@ //! This module defines some ancillary types for the HTTP API. -use agama_lib::install_settings::InstallSettings; use agama_utils::api::Scope; use agama_utils::issue; use serde::{Deserialize, Serialize}; @@ -61,10 +60,3 @@ impl From>> for IssuesMap { } } } - -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -/// Patch for the config. -pub struct ConfigPatch { - /// Update for the current config. - pub update: Option, -} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index c60c65ee77..28d50031ec 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,13 +20,14 @@ //! This module implements Agama's HTTP API. -use crate::server::types::{ConfigPatch, IssuesMap}; +use crate::server::types::IssuesMap; use agama_lib::error::ServiceError; -use agama_lib::install_settings::InstallSettings; +use agama_manager as manager; use agama_manager::message; -use agama_manager::{self as manager}; use agama_utils::actor::Handler; -use agama_utils::api::{event, Action, Status, SystemInfo}; +use agama_utils::api::config; +use agama_utils::api::event; +use agama_utils::api::{Action, Config, Status, SystemInfo}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; @@ -129,9 +130,7 @@ async fn get_system(State(state): State) -> ServerResult, -) -> ServerResult> { +async fn get_extended_config(State(state): State) -> ServerResult> { let config = state.manager.call(message::GetExtendedConfig).await?; Ok(Json(config)) } @@ -146,7 +145,7 @@ async fn get_extended_config( (status = 400, description = "Not possible to retrieve the configuration.") ) )] -async fn get_config(State(state): State) -> ServerResult> { +async fn get_config(State(state): State) -> ServerResult> { let config = state.manager.call(message::GetConfig).await?; Ok(Json(config)) } @@ -163,12 +162,12 @@ async fn get_config(State(state): State) -> ServerResult, - Json(config): Json, + Json(config): Json, ) -> ServerResult<()> { state.manager.call(message::SetConfig::new(config)).await?; Ok(()) @@ -186,12 +185,12 @@ async fn put_config( (status = 400, description = "Not possible to patch the configuration.") ), params( - ("config" = InstallSettings, description = "Changes in the configuration.") + ("config" = Config, description = "Changes in the configuration.") ) )] async fn patch_config( State(state): State, - Json(patch): Json, + Json(patch): Json, ) -> ServerResult<()> { if let Some(config) = patch.update { state diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 4b72b207e7..58356703a5 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -157,7 +157,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -166,6 +165,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 43e5b6efd0..4581577210 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -20,7 +20,6 @@ pub mod common; use agama_lib::error::ServiceError; -use agama_lib::install_settings::InstallSettings; use agama_server::server::server_service; use agama_utils::api; use axum::{ @@ -86,15 +85,12 @@ async fn test_get_empty_config() -> Result<(), Box> { #[test] #[cfg(not(ci))] async fn test_put_config() -> Result<(), Box> { - let localization = api::l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }; - - let mut config = InstallSettings { - localization: Some(localization), - ..Default::default() + let config = api::Config { + l10n: Some(api::l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), }; let server_service = build_server_service().await?; @@ -117,16 +113,16 @@ async fn test_put_config() -> Result<(), Box> { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains( - r#""localization":{"locale":"es_ES.UTF-8","keymap":"es","timezone":"Atlantic/Canary"# - )); - - let localization = api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, + assert!(body + .contains(r#""l10n":{"locale":"es_ES.UTF-8","keymap":"es","timezone":"Atlantic/Canary"#)); + + let config = api::Config { + l10n: Some(api::l10n::Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, + }), }; - config.localization = Some(localization); let request = Request::builder() .uri("/config") @@ -147,7 +143,7 @@ async fn test_put_config() -> Result<(), Box> { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""localization":{"keymap":"en"}"#)); + assert!(body.contains(r#""l10n":{"keymap":"en"}"#)); Ok(()) } @@ -155,18 +151,13 @@ async fn test_put_config() -> Result<(), Box> { #[test] #[cfg(not(ci))] async fn test_patch_config() -> Result<(), Box> { - use agama_server::server::types::ConfigPatch; - - let localization = api::l10n::Config { + let l10n = api::l10n::Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }; - let config = InstallSettings { - localization: Some(localization), - ..Default::default() - }; + let config = api::Config { l10n: Some(l10n) }; let server_service = build_server_service().await?; let request = Request::builder() @@ -179,15 +170,13 @@ async fn test_patch_config() -> Result<(), Box> { let response = server_service.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let localization = api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, - }; - let patch = ConfigPatch { - update: Some(InstallSettings { - localization: Some(localization), - ..Default::default() + let patch = api::config::Patch { + update: Some(api::Config { + l10n: Some(api::l10n::Config { + locale: None, + keymap: Some("en".to_string()), + timezone: None, + }), }), }; @@ -209,9 +198,8 @@ async fn test_patch_config() -> Result<(), Box> { assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body.contains( - r#""localization":{"locale":"es_ES.UTF-8","keymap":"en","timezone":"Atlantic/Canary"# - )); + assert!(body + .contains(r#""l10n":{"locale":"es_ES.UTF-8","keymap":"en","timezone":"Atlantic/Canary"#)); Ok(()) } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 8c879011e8..8972187878 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -36,6 +36,9 @@ pub use status::Status; mod system_info; pub use system_info::SystemInfo; +pub mod config; +pub use config::Config; + mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs new file mode 100644 index 0000000000..a70fda792c --- /dev/null +++ b/rust/agama-utils/src/api/config.rs @@ -0,0 +1,36 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::l10n; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "localization")] + pub l10n: Option, +} + +/// Patch for the config. +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct Patch { + /// Update for the current config. + pub update: Option, +} From 1dc90600d2dae0ac8e2e5347e0abd1647a29f9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 10:31:15 +0100 Subject: [PATCH 200/917] Move issue to api --- rust/agama-l10n/src/service.rs | 23 +++++++++++-------- rust/agama-manager/src/message.rs | 3 +-- rust/agama-manager/src/service.rs | 6 ++--- rust/agama-server/src/server/types.rs | 19 ++++++++------- rust/agama-server/src/web/docs/config.rs | 6 ++--- rust/agama-utils/src/api.rs | 3 +++ .../src/{issue/model.rs => api/issue.rs} | 0 rust/agama-utils/src/issue.rs | 3 --- rust/agama-utils/src/issue/message.rs | 2 +- rust/agama-utils/src/issue/monitor.rs | 10 +++++--- rust/agama-utils/src/issue/service.rs | 11 +++++---- rust/agama-utils/src/issue/start.rs | 4 +++- 12 files changed, 50 insertions(+), 40 deletions(-) rename rust/agama-utils/src/{issue/model.rs => api/issue.rs} (100%) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index c905a6ad5d..10c6a02bdb 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -24,10 +24,11 @@ use crate::model::ModelAdapter; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api; -use agama_utils::api::event::{self, Event}; +use agama_utils::api::event; +use agama_utils::api::event::Event; use agama_utils::api::l10n::{Proposal, SystemConfig, SystemInfo}; -use agama_utils::api::scope::Scope; -use agama_utils::issue::{self, Issue}; +use agama_utils::api::{Issue, IssueSeverity, IssueSource, Scope}; +use agama_utils::issue; use async_trait::async_trait; use tokio::sync::broadcast; @@ -48,7 +49,9 @@ pub enum Error { #[error(transparent)] Event(#[from] broadcast::error::SendError), #[error(transparent)] - Issue(#[from] issue::service::Error), + Issue(#[from] api::issue::Error), + #[error(transparent)] + IssueService(#[from] issue::service::Error), #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] @@ -115,8 +118,8 @@ impl Service { issues.push(Issue { description: format!("Locale '{}' is unknown", &config.locale), details: None, - source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Error, + source: IssueSource::Config, + severity: IssueSeverity::Error, kind: "unknown_locale".to_string(), }); } @@ -125,8 +128,8 @@ impl Service { issues.push(Issue { description: format!("Keymap '{}' is unknown", &config.keymap), details: None, - source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Error, + source: IssueSource::Config, + severity: IssueSeverity::Error, kind: "unknown_keymap".to_string(), }); } @@ -135,8 +138,8 @@ impl Service { issues.push(Issue { description: format!("Timezone '{}' is unknown", &config.timezone), details: None, - source: issue::IssueSource::Config, - severity: issue::IssueSeverity::Error, + source: IssueSource::Config, + severity: IssueSeverity::Error, kind: "unknown_timezone".to_string(), }); } diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index ce74bf13ed..ee13225342 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -19,8 +19,7 @@ // find current contact information at www.suse.com. use agama_utils::actor::Message; -use agama_utils::api::{Action, Config, Proposal, Scope, Status, SystemInfo}; -use agama_utils::issue::Issue; +use agama_utils::api::{Action, Config, Issue, Proposal, Scope, Status, SystemInfo}; use std::collections::HashMap; /// Gets the installation status. diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index c419e47751..3563ea2375 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -23,7 +23,7 @@ use crate::message; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api::event; use agama_utils::api::status::State; -use agama_utils::api::{Action, Config, Event, Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{Action, Config, Event, Issue, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; @@ -44,7 +44,7 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] - Issues(#[from] agama_utils::issue::service::Error), + IssueService(#[from] issue::service::Error), } pub struct Service { @@ -190,7 +190,7 @@ impl MessageHandler for Service { async fn handle( &mut self, _message: message::GetIssues, - ) -> Result>, Error> { + ) -> Result>, Error> { Ok(self.issues.call(issue::message::Get).await?) } } diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index 4d61233ffd..a574750eb5 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -20,8 +20,7 @@ //! This module defines some ancillary types for the HTTP API. -use agama_utils::api::Scope; -use agama_utils::issue; +use agama_utils::api::{Issue, Scope}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -30,26 +29,26 @@ use std::collections::HashMap; pub struct IssuesMap { /// iSCSI issues. #[serde(skip_serializing_if = "Vec::is_empty")] - pub iscsi: Vec, + pub iscsi: Vec, /// Localization issues. #[serde(skip_serializing_if = "Vec::is_empty")] - pub localization: Vec, + pub localization: Vec, /// Product related issues (product selection, registration, etc.). #[serde(skip_serializing_if = "Vec::is_empty")] - pub product: Vec, + pub product: Vec, /// Storage related issues. #[serde(skip_serializing_if = "Vec::is_empty")] - pub storage: Vec, + pub storage: Vec, /// Software management issues. #[serde(skip_serializing_if = "Vec::is_empty")] - pub software: Vec, + pub software: Vec, /// First user and authentication issues. #[serde(skip_serializing_if = "Vec::is_empty")] - pub users: Vec, + pub users: Vec, } -impl From>> for IssuesMap { - fn from(mut value: HashMap>) -> Self { +impl From>> for IssuesMap { + fn from(mut value: HashMap>) -> Self { Self { iscsi: value.remove(&Scope::Iscsi).unwrap_or_default(), localization: value.remove(&Scope::L10n).unwrap_or_default(), diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 58356703a5..0a186199de 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -51,9 +51,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -167,6 +164,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 8972187878..ea9d505ff0 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -33,6 +33,9 @@ pub use scope::Scope; pub mod status; pub use status::Status; +pub mod issue; +pub use issue::{Issue, IssueSeverity, IssueSource}; + mod system_info; pub use system_info::SystemInfo; diff --git a/rust/agama-utils/src/issue/model.rs b/rust/agama-utils/src/api/issue.rs similarity index 100% rename from rust/agama-utils/src/issue/model.rs rename to rust/agama-utils/src/api/issue.rs diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index c2bf6f7e9e..fe04bd83a7 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -44,9 +44,6 @@ //! //! ``` -pub mod model; -pub use model::{Issue, IssueSeverity, IssueSource}; - pub mod service; pub use service::Service; diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index 5b541d3247..78f526d8ea 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::actor::Message; +use crate::api::issue::Issue; use crate::api::Scope; -use crate::issue::Issue; use std::collections::HashMap; pub struct Get; diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 44e9d206fd..85df4ff667 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -19,9 +19,13 @@ // find current contact information at www.suse.com. use crate::actor::Handler; +use crate::api::issue; +use crate::api::issue::Issue; use crate::api::scope::Scope; use crate::dbus::build_properties_changed_stream; -use crate::issue::{self, message, model, Issue, Service}; +use crate::issue::message; +use crate::issue::service; +use crate::issue::Service; use tokio_stream::StreamExt; use zbus::fdo::PropertiesChanged; use zbus::names::BusName; @@ -37,9 +41,9 @@ pub enum Error { #[error(transparent)] DBus(#[from] zbus::Error), #[error(transparent)] - Issue(#[from] issue::service::Error), + Service(#[from] service::Error), #[error(transparent)] - Model(#[from] model::Error), + Issue(#[from] issue::Error), } /// Listens the D-Bus server and updates the list of issues. diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 090fb7c52e..5a845c76a1 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -19,9 +19,12 @@ // find current contact information at www.suse.com. use crate::actor::{self, Actor, MessageHandler}; -use crate::api::event::{self, Event}; -use crate::api::Scope; -use crate::issue::{message, model, Issue}; +use crate::api::event; +use crate::api::event::Event; +use crate::api::issue; +use crate::api::issue::Issue; +use crate::api::scope::Scope; +use crate::issue::message; use async_trait::async_trait; use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; @@ -33,7 +36,7 @@ pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] - Model(#[from] model::Error), + Issue(#[from] issue::Error), } pub struct Service { diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 228dee6a2c..d557f4410d 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -47,8 +47,10 @@ pub async fn start( #[cfg(test)] mod tests { use crate::api::event::Event; + use crate::api::issue::{Issue, IssueSeverity, IssueSource}; use crate::api::scope::Scope; - use crate::issue::{self, message, Issue, IssueSeverity, IssueSource}; + use crate::issue; + use crate::issue::message; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { From cd91ef53b2702186a60cd4448730d8ce909f6e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 11:13:38 +0100 Subject: [PATCH 201/917] Remove unnecessary type --- rust/agama-manager/src/message.rs | 5 +- rust/agama-manager/src/service.rs | 8 +--- rust/agama-server/src/server.rs | 1 - rust/agama-server/src/server/types.rs | 61 ------------------------ rust/agama-server/src/server/web.rs | 9 ++-- rust/agama-server/src/web/docs/config.rs | 1 - rust/agama-utils/src/api.rs | 2 +- rust/agama-utils/src/api/issue.rs | 4 ++ rust/agama-utils/src/issue/message.rs | 5 +- rust/agama-utils/src/issue/service.rs | 14 ++---- 10 files changed, 20 insertions(+), 90 deletions(-) delete mode 100644 rust/agama-server/src/server/types.rs diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index ee13225342..0d9327c03d 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -19,8 +19,7 @@ // find current contact information at www.suse.com. use agama_utils::actor::Message; -use agama_utils::api::{Action, Config, Issue, Proposal, Scope, Status, SystemInfo}; -use std::collections::HashMap; +use agama_utils::api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}; /// Gets the installation status. pub struct GetStatus; @@ -99,7 +98,7 @@ impl Message for GetProposal { pub struct GetIssues; impl Message for GetIssues { - type Reply = HashMap>; + type Reply = IssueMap; } /// Runs the given action. diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 3563ea2375..6e267f97ed 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -23,12 +23,11 @@ use crate::message; use agama_utils::actor::{self, Actor, Handler, MessageHandler}; use agama_utils::api::event; use agama_utils::api::status::State; -use agama_utils::api::{Action, Config, Event, Issue, Proposal, Scope, Status, SystemInfo}; +use agama_utils::api::{Action, Config, Event, IssueMap, Proposal, Scope, Status, SystemInfo}; use agama_utils::issue; use agama_utils::progress; use async_trait::async_trait; use merge_struct::merge; -use std::collections::HashMap; use tokio::sync::broadcast; #[derive(Debug, thiserror::Error)] @@ -187,10 +186,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// It returns the current proposal, if any. - async fn handle( - &mut self, - _message: message::GetIssues, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::GetIssues) -> Result { Ok(self.issues.call(issue::message::Get).await?) } } diff --git a/rust/agama-server/src/server.rs b/rust/agama-server/src/server.rs index c0832053ca..a429170043 100644 --- a/rust/agama-server/src/server.rs +++ b/rust/agama-server/src/server.rs @@ -20,4 +20,3 @@ pub mod web; pub use web::server_service; -pub mod types; diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs deleted file mode 100644 index a574750eb5..0000000000 --- a/rust/agama-server/src/server/types.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module defines some ancillary types for the HTTP API. - -use agama_utils::api::{Issue, Scope}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -/// Holds the installation issues for each scope. -pub struct IssuesMap { - /// iSCSI issues. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub iscsi: Vec, - /// Localization issues. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub localization: Vec, - /// Product related issues (product selection, registration, etc.). - #[serde(skip_serializing_if = "Vec::is_empty")] - pub product: Vec, - /// Storage related issues. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub storage: Vec, - /// Software management issues. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub software: Vec, - /// First user and authentication issues. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub users: Vec, -} - -impl From>> for IssuesMap { - fn from(mut value: HashMap>) -> Self { - Self { - iscsi: value.remove(&Scope::Iscsi).unwrap_or_default(), - localization: value.remove(&Scope::L10n).unwrap_or_default(), - product: value.remove(&Scope::Product).unwrap_or_default(), - software: value.remove(&Scope::Software).unwrap_or_default(), - storage: value.remove(&Scope::Storage).unwrap_or_default(), - users: value.remove(&Scope::Users).unwrap_or_default(), - } - } -} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 28d50031ec..d71179f7cd 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,14 +20,13 @@ //! This module implements Agama's HTTP API. -use crate::server::types::IssuesMap; use agama_lib::error::ServiceError; use agama_manager as manager; use agama_manager::message; use agama_utils::actor::Handler; use agama_utils::api::config; use agama_utils::api::event; -use agama_utils::api::{Action, Config, Status, SystemInfo}; +use agama_utils::api::{Action, Config, IssueMap, Status, SystemInfo}; use anyhow; use axum::extract::State; use axum::response::{IntoResponse, Response}; @@ -222,13 +221,13 @@ async fn get_proposal(State(state): State) -> ServerResult) -> ServerResult> { +async fn get_issues(State(state): State) -> ServerResult> { let issues = state.manager.call(message::GetIssues).await?; - let issues_map: IssuesMap = issues.into(); + let issues_map: IssueMap = issues.into(); Ok(Json(issues_map)) } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 0a186199de..297e6edcca 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -154,7 +154,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index ea9d505ff0..2b5efdf311 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -34,7 +34,7 @@ pub mod status; pub use status::Status; pub mod issue; -pub use issue::{Issue, IssueSeverity, IssueSource}; +pub use issue::{Issue, IssueMap, IssueSeverity, IssueSource}; mod system_info; pub use system_info::SystemInfo; diff --git a/rust/agama-utils/src/api/issue.rs b/rust/agama-utils/src/api/issue.rs index d0248ba65d..0a3035d9ef 100644 --- a/rust/agama-utils/src/api/issue.rs +++ b/rust/agama-utils/src/api/issue.rs @@ -18,9 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::api::scope::Scope; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use strum::FromRepr; +pub type IssueMap = HashMap>; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("D-Bus conversion error")] diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index 78f526d8ea..abd7e3e90a 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -19,14 +19,13 @@ // find current contact information at www.suse.com. use crate::actor::Message; -use crate::api::issue::Issue; +use crate::api::issue::{Issue, IssueMap}; use crate::api::Scope; -use std::collections::HashMap; pub struct Get; impl Message for Get { - type Reply = HashMap>; + type Reply = IssueMap; } // FIXME: consider an alternative approach to avoid pub(crate), diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 5a845c76a1..97c59431f9 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -22,11 +22,10 @@ use crate::actor::{self, Actor, MessageHandler}; use crate::api::event; use crate::api::event::Event; use crate::api::issue; -use crate::api::issue::Issue; -use crate::api::scope::Scope; +use crate::api::issue::IssueMap; use crate::issue::message; use async_trait::async_trait; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use tokio::sync::broadcast; #[derive(thiserror::Error, Debug)] @@ -40,14 +39,14 @@ pub enum Error { } pub struct Service { - issues: HashMap>, + issues: IssueMap, events: event::Sender, } impl Service { pub fn new(events: event::Sender) -> Self { Self { - issues: HashMap::new(), + issues: IssueMap::new(), events, } } @@ -59,10 +58,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { - async fn handle( - &mut self, - _message: message::Get, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::Get) -> Result { Ok(self.issues.clone()) } } From e2ffcca684df200df86e47defed9ddd8e1f490ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 11:28:53 +0100 Subject: [PATCH 202/917] Always use l10n instead of localization --- rust/agama-manager/src/service.rs | 15 +++++---------- rust/agama-utils/src/api/proposal.rs | 2 +- rust/agama-utils/src/api/system_info.rs | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 6e267f97ed..9670b8d9c8 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -112,10 +112,8 @@ impl MessageHandler for Service { impl MessageHandler for Service { /// It returns the information of the underlying system. async fn handle(&mut self, _message: message::GetSystem) -> Result { - let l10n_system = self.l10n.call(l10n::message::GetSystem).await?; - Ok(SystemInfo { - localization: l10n_system, - }) + let l10n = self.l10n.call(l10n::message::GetSystem).await?; + Ok(SystemInfo { l10n }) } } @@ -126,10 +124,7 @@ impl MessageHandler for Service { /// It includes user and default values. async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; - Ok(Config { - l10n: Some(l10n), - ..Default::default() - }) + Ok(Config { l10n: Some(l10n) }) } } @@ -178,8 +173,8 @@ impl MessageHandler for Service { impl MessageHandler for Service { /// It returns the current proposal, if any. async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { - let localization = self.l10n.call(l10n::message::GetProposal).await?; - Ok(Some(Proposal { localization })) + let l10n = self.l10n.call(l10n::message::GetProposal).await?; + Ok(Some(Proposal { l10n })) } } diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 83c4822772..d66e151167 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -24,5 +24,5 @@ use serde::Serialize; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, + pub l10n: Option, } diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index d7122ad6e8..e609126aa2 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -23,5 +23,5 @@ use serde::Serialize; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct SystemInfo { - pub localization: l10n::SystemInfo, + pub l10n: l10n::SystemInfo, } From ca66d0c9eb332197fd64ef81e4757917b3312779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 11:35:25 +0100 Subject: [PATCH 203/917] Rename l10n extended config --- rust/agama-l10n/src/{extended_config.rs => config.rs} | 4 ++-- rust/agama-l10n/src/lib.rs | 2 +- rust/agama-l10n/src/service.rs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) rename rust/agama-l10n/src/{extended_config.rs => config.rs} (97%) diff --git a/rust/agama-l10n/src/extended_config.rs b/rust/agama-l10n/src/config.rs similarity index 97% rename from rust/agama-l10n/src/extended_config.rs rename to rust/agama-l10n/src/config.rs index e5cc3feebf..3103c289c3 100644 --- a/rust/agama-l10n/src/extended_config.rs +++ b/rust/agama-l10n/src/config.rs @@ -24,13 +24,13 @@ use agama_utils::api; use agama_utils::api::l10n::SystemInfo; #[derive(Clone, PartialEq)] -pub struct ExtendedConfig { +pub struct Config { pub locale: LocaleId, pub keymap: KeymapId, pub timezone: TimezoneId, } -impl ExtendedConfig { +impl Config { pub fn new_from(system: &SystemInfo) -> Self { Self { locale: system.locale.clone(), diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index efff03d05d..8e531beca4 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -44,8 +44,8 @@ pub use service::Service; mod model; pub use model::{Model, ModelAdapter}; +mod config; mod dbus; -mod extended_config; pub mod helpers; pub mod message; mod monitor; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 10c6a02bdb..553f1cc3d2 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::extended_config::ExtendedConfig; +use crate::config::Config; use crate::message; use crate::model::ModelAdapter; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; @@ -72,7 +72,7 @@ pub enum Error { /// * Applies the user configuration at the end of the installation. pub struct Service { system: SystemInfo, - config: ExtendedConfig, + config: Config, model: Box, issues: Handler, events: event::Sender, @@ -85,7 +85,7 @@ impl Service { events: event::Sender, ) -> Service { let system = model.read_system_info(); - let config = ExtendedConfig::new_from(&system); + let config = Config::new_from(&system); Self { system, @@ -192,7 +192,7 @@ impl MessageHandler> for Service { &mut self, message: message::SetConfig, ) -> Result<(), Error> { - let config = ExtendedConfig::new_from(&self.system); + let config = Config::new_from(&self.system); let merged = config.merge(&message.config)?; if merged == self.config { return Ok(()); From f4d1bc704adc2f27ae9dbad4ecb3a62157ac30cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 11:42:12 +0100 Subject: [PATCH 204/917] Remove unnecessary dependencies from agama-l10n --- rust/Cargo.lock | 5 ----- rust/agama-l10n/Cargo.toml | 5 ----- 2 files changed, 10 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 246ac8eeb1..2d61c93fd8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -61,17 +61,12 @@ dependencies = [ "anyhow", "async-trait", "gettext-rs", - "merge-struct", "regex", - "serde", - "serde_json", - "serde_with", "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-test", "tracing", - "utoipa", "zbus", ] diff --git a/rust/agama-l10n/Cargo.toml b/rust/agama-l10n/Cargo.toml index 40b8e85707..c7ed3085f8 100644 --- a/rust/agama-l10n/Cargo.toml +++ b/rust/agama-l10n/Cargo.toml @@ -6,17 +6,12 @@ edition.workspace = true [dependencies] anyhow = "1.0.99" -merge-struct = "0.1.0" -serde = { version = "1.0.219", features = ["derive"] } thiserror = "2.0.16" agama-locale-data = { path = "../agama-locale-data" } agama-utils = { path = "../agama-utils" } regex = "1.11.2" tracing = "0.1.41" -serde_with = "3.14.0" -utoipa = "5.4.0" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } -serde_json = "1.0.143" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.17" zbus = "5.11.0" From 03b595813710b20bcdefac5663edb7bbc3472a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 11:56:15 +0100 Subject: [PATCH 205/917] Remove unnecessary dependencies from agama-manager --- rust/Cargo.lock | 7 ------- rust/agama-manager/Cargo.toml | 7 ------- 2 files changed, 14 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2d61c93fd8..53f62d239f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -129,19 +129,12 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-l10n", - "agama-lib", "agama-utils", "async-trait", "merge-struct", - "serde", - "serde_json", - "strum", "thiserror 2.0.16", "tokio", - "tokio-stream", "tokio-test", - "tracing", - "utoipa", "zbus", ] diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 4273679aa5..05f303cca7 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -5,18 +5,11 @@ rust-version.workspace = true edition.workspace = true [dependencies] -agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } thiserror = "2.0.12" -serde = { version = "1.0.210", features = ["derive"] } tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } -tokio-stream = "0.1.16" async-trait = "0.1.83" -serde_json = "1.0.128" -utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } -strum = { version = "0.27.2", features = ["derive"] } -tracing = "0.1.40" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" From 716abdff944ccab663eaef4cedf7ff25b3868db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 15 Oct 2025 15:15:26 +0100 Subject: [PATCH 206/917] Adapt UI client to changes in the HTTP API --- web/src/api/api.ts | 3 +- .../components/core/InstallerOptions.test.tsx | 8 ++--- web/src/components/core/InstallerOptions.tsx | 6 ++-- .../l10n/KeyboardSelection.test.tsx | 6 ++-- web/src/components/l10n/KeyboardSelection.tsx | 6 ++-- web/src/components/l10n/L10nPage.test.tsx | 10 +++---- web/src/components/l10n/L10nPage.tsx | 12 ++++---- .../components/l10n/LocaleSelection.test.tsx | 6 ++-- web/src/components/l10n/LocaleSelection.tsx | 6 ++-- .../l10n/TimezoneSelection.test.tsx | 6 ++-- web/src/components/l10n/TimezoneSelection.tsx | 6 ++-- .../components/overview/L10nSection.test.tsx | 4 +-- web/src/components/overview/L10nSection.tsx | 6 ++-- .../questions/LuksActivationQuestion.test.tsx | 2 +- .../questions/QuestionWithPassword.test.tsx | 2 +- web/src/context/installerL10n.test.tsx | 12 ++++---- web/src/context/installerL10n.tsx | 4 +-- web/src/queries/system.ts | 25 ++++++++-------- web/src/test-utils.tsx | 2 +- web/src/types/config.ts | 29 +++++++++++++++++++ web/src/types/proposal.ts | 2 +- web/src/types/system.ts | 2 +- 22 files changed, 99 insertions(+), 66 deletions(-) create mode 100644 web/src/types/config.ts diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 17050ebbbe..87911ea4ec 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -21,6 +21,7 @@ */ import { get, patch, post } from "~/api/http"; +import { Config } from "~/types/config"; import { Proposal } from "~/types/proposal"; import { System } from "~/types/system"; @@ -37,7 +38,7 @@ const fetchProposal = (): Promise => get("/api/v2/proposal"); /** * Updates configuration */ -const updateConfig = (config) => patch("/api/v2/config", { update: config }); +const updateConfig = (config: Config) => patch("/api/v2/config", { update: config }); /** * Triggers an action */ diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index a4837246f9..335df6a12d 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -60,7 +60,7 @@ const mockChangeUILanguage = jest.fn(); jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/system"), - useSystem: () => ({ localization: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" } }), + useSystem: () => ({ l10n: { locales, keymaps, locale: "us_US.UTF-8", keymap: "us" } }), })); jest.mock("~/api/api", () => ({ @@ -188,7 +188,7 @@ describe("InstallerOptions", () => { await user.click(acceptButton); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { + l10n: { locale: "es_ES.UTF-8", keymap: "gb", }, @@ -309,7 +309,7 @@ describe("InstallerOptions", () => { await user.click(acceptButton); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { + l10n: { locale: "es_ES.UTF-8", }, }); @@ -400,7 +400,7 @@ describe("InstallerOptions", () => { await user.click(acceptButton); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { + l10n: { keymap: "gb", }, }); diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 7bce144c27..3b97d0c31c 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -89,7 +89,7 @@ const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( */ const KeyboardFormInput = ({ value, onChange }: SelectProps) => { const { - localization: { keymaps }, + l10n: { keymaps }, } = useSystem(); if (!localConnection()) { @@ -553,7 +553,7 @@ export default function InstallerOptions({ }: InstallerOptionsProps) { const location = useLocation(); const { - localization: { locales }, + l10n: { locales }, } = useSystem(); const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); const { phase } = useInstallerStatus({ suspense: true }); @@ -593,7 +593,7 @@ export default function InstallerOptions({ if (variant !== "keyboard") systemL10n.locale = systemLocale?.id; if (variant !== "language" && localConnection()) systemL10n.keymap = formState.keymap; - updateConfig({ localization: systemL10n }); + updateConfig({ l10n: systemL10n }); }; const close = () => { diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index 31702f496e..3c5ec93c03 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -40,12 +40,12 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/system"), - useSystem: () => ({ localization: { keymaps } }), + useSystem: () => ({ l10n: { keymaps } }), })); jest.mock("~/queries/proposal", () => ({ ...jest.requireActual("~/queries/proposal"), - useProposal: () => ({ localization: { keymap: "us" } }), + useProposal: () => ({ l10n: { keymap: "us" } }), })); jest.mock("~/api/api", () => ({ @@ -65,6 +65,6 @@ it("allows changing the keyboard", async () => { await userEvent.click(option); const button = await screen.findByRole("button", { name: "Select" }); await userEvent.click(button); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ localization: { keymap: "es" } }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ l10n: { keymap: "es" } }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index ade94e37ce..6b34273243 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -34,10 +34,10 @@ import { _ } from "~/i18n"; export default function KeyboardSelection() { const navigate = useNavigate(); const { - localization: { keymaps }, + l10n: { keymaps }, } = useSystem(); const { - localization: { keymap: currentKeymap }, + l10n: { keymap: currentKeymap }, } = useProposal(); // FIXME: get current keymap from either, proposal or config @@ -51,7 +51,7 @@ export default function KeyboardSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); // FIXME: udpate when new API is ready - updateConfig({ localization: { keymap: selected } }); + updateConfig({ l10n: { keymap: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index 3701dd9552..a4d25c230f 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -61,7 +61,7 @@ jest.mock("~/queries/proposal", () => ({ beforeEach(() => { mockSystemData = { - localization: { + l10n: { locales, keymaps, timezones, @@ -69,7 +69,7 @@ beforeEach(() => { }; mockProposedData = { - localization: { + l10n: { locales, keymaps, timezones, @@ -96,7 +96,7 @@ it("renders a section for configuring the language", () => { describe("if the language selected is wrong", () => { beforeEach(() => { - mockProposedData.localization.locale = "us_US.UTF-8"; + mockProposedData.l10n.locale = "us_US.UTF-8"; }); it("renders a button for selecting a language", () => { @@ -116,7 +116,7 @@ it("renders a section for configuring the keyboard", () => { describe("if the keyboard selected is wrong", () => { beforeEach(() => { - mockProposedData.localization.keymap = "ess"; + mockProposedData.l10n.keymap = "ess"; }); it("renders a button for selecting a keyboard", () => { @@ -136,7 +136,7 @@ it("renders a section for configuring the time zone", () => { describe("if the time zone selected is wrong", () => { beforeEach(() => { - mockProposedData.localization.timezone = "Europee/Beeerlin"; + mockProposedData.l10n.timezone = "Europee/Beeerlin"; }); it("renders a button for selecting a time zone", () => { diff --git a/web/src/components/l10n/L10nPage.tsx b/web/src/components/l10n/L10nPage.tsx index 86a420086a..534b4a0911 100644 --- a/web/src/components/l10n/L10nPage.tsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -68,14 +68,16 @@ const InstallerL10nSettingsInfo = () => { */ export default function L10nPage() { // FIXME: retrieve selection from config when ready - const { localization: l10nProposal } = useProposal(); - const { localization: l10n } = useSystem(); + const { l10n: l10nProposal } = useProposal(); + const { l10n: l10nSystem } = useSystem(); console.log(l10nProposal); - const locale = l10nProposal.locale && l10n.locales.find((l) => l.id === l10nProposal.locale); - const keymap = l10nProposal.keymap && l10n.keymaps.find((k) => k.id === l10nProposal.keymap); + const locale = + l10nProposal.locale && l10nSystem.locales.find((l) => l.id === l10nProposal.locale); + const keymap = + l10nProposal.keymap && l10nSystem.keymaps.find((k) => k.id === l10nProposal.keymap); const timezone = - l10nProposal.timezone && l10n.timezones.find((t) => t.id === l10nProposal.timezone); + l10nProposal.timezone && l10nSystem.timezones.find((t) => t.id === l10nProposal.timezone); return ( diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index f486d533f1..0bf485e541 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -40,12 +40,12 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/system"), - useSystem: () => ({ localization: { locales } }), + useSystem: () => ({ l10n: { locales } }), })); jest.mock("~/queries/proposal", () => ({ ...jest.requireActual("~/queries/proposal"), - useProposal: () => ({ localization: { locales, locale: "us_US.UTF-8", keymap: "us" } }), + useProposal: () => ({ l10n: { locales, locale: "us_US.UTF-8", keymap: "us" } }), })); jest.mock("~/api/api", () => ({ @@ -66,7 +66,7 @@ it("allows changing the keyboard", async () => { const button = await screen.findByRole("button", { name: "Select" }); await userEvent.click(button); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { locale: "es_ES.UTF-8" }, + l10n: { locale: "es_ES.UTF-8" }, }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 02613774b1..3243a83969 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -35,10 +35,10 @@ import { _ } from "~/i18n"; export default function LocaleSelection() { const navigate = useNavigate(); const { - localization: { locales }, + l10n: { locales }, } = useSystem(); const { - localization: { locale: currentLocale }, + l10n: { locale: currentLocale }, } = useProposal(); const [selected, setSelected] = useState(currentLocale); const [filteredLocales, setFilteredLocales] = useState(locales); @@ -47,7 +47,7 @@ export default function LocaleSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - updateConfig({ localization: { locale: selected } }); + updateConfig({ l10n: { locale: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index d35791ef32..72cfb9b57c 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -52,12 +52,12 @@ const timezones: Timezone[] = [ jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/system"), - useSystem: () => ({ localization: { timezones } }), + useSystem: () => ({ l10n: { timezones } }), })); jest.mock("~/queries/proposal", () => ({ ...jest.requireActual("~/queries/proposal"), - useProposal: () => ({ localization: { timezones, timezone: "Europe/Berlin" } }), + useProposal: () => ({ l10n: { timezones, timezone: "Europe/Berlin" } }), })); jest.mock("react-router-dom", () => ({ @@ -89,7 +89,7 @@ it("allows changing the timezone", async () => { await user.click(option); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(mockUpdateConfigFn).toHaveBeenCalledWith({ localization: { timezone: "Europe/Madrid" } }); + expect(mockUpdateConfigFn).toHaveBeenCalledWith({ l10n: { timezone: "Europe/Madrid" } }); expect(mockNavigateFn).toHaveBeenCalledWith(-1); }); diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index b153e625ed..5656a8e795 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -69,10 +69,10 @@ export default function TimezoneSelection() { date = new Date(); const navigate = useNavigate(); const { - localization: { timezones }, + l10n: { timezones }, } = useSystem(); const { - localization: { timezone: currentTimezone }, + l10n: { timezone: currentTimezone }, } = useProposal(); const displayTimezones = timezones.map(timezoneWithDetails); @@ -83,7 +83,7 @@ export default function TimezoneSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - updateConfig({ localization: { timezone: selected } }); + updateConfig({ l10n: { timezone: selected } }); navigate(-1); }; diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx index ac2b234339..d0e9e040ae 100644 --- a/web/src/components/overview/L10nSection.test.tsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -34,14 +34,14 @@ const locales: Locale[] = [ jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/system"), useSystem: () => ({ - localization: { locale: "en_US.UTF-8", locales, keymap: "us" }, + l10n: { locale: "en_US.UTF-8", locales, keymap: "us" }, }), })); jest.mock("~/queries/proposal", () => ({ ...jest.requireActual("~/queries/proposal"), useProposal: () => ({ - localization: { locale: "en_US.UTF-8", keymap: "us" }, + l10n: { locale: "en_US.UTF-8", keymap: "us" }, }), })); diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx index 4cd450303c..9fe2a48c69 100644 --- a/web/src/components/overview/L10nSection.tsx +++ b/web/src/components/overview/L10nSection.tsx @@ -28,10 +28,10 @@ import { _ } from "~/i18n"; import { Locale } from "~/types/l10n"; export default function L10nSection() { - const { localization: l10nProposal } = useProposal(); - const { localization: l10n } = useSystem(); + const { l10n: l10nProposal } = useProposal(); + const { l10n: l10nSystem } = useSystem(); const locale = - l10nProposal.locale && l10n.locales.find((l: Locale) => l.id === l10nProposal.locale); + l10nProposal.locale && l10nSystem.locales.find((l: Locale) => l.id === l10nProposal.locale); // TRANSLATORS: %s will be replaced by a language name and territory, example: // "English (United States)". diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 491ad9100c..1922e77b8d 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -57,7 +57,7 @@ const keymaps: Keymap[] = [ jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/l10n"), - useSystem: () => ({ localization: { locales, keymaps, keymap: "us", language: "de-DE" } }), + useSystem: () => ({ l10n: { locales, keymaps, keymap: "us", language: "de-DE" } }), })); const answerFn: AnswerCallback = jest.fn(); diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index e70e292493..e6a725b211 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -65,7 +65,7 @@ jest.mock("~/queries/status", () => ({ jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/l10n"), - useSystem: () => ({ localization: { locales, keymaps, keymap: "us", language: "de-DE" } }), + useSystem: () => ({ l10n: { locales, keymaps, keymap: "us", language: "de-DE" } }), })); jest.mock("~/queries/software", () => ({ diff --git a/web/src/context/installerL10n.test.tsx b/web/src/context/installerL10n.test.tsx index a8cbb4e202..8493eb7bd7 100644 --- a/web/src/context/installerL10n.test.tsx +++ b/web/src/context/installerL10n.test.tsx @@ -99,7 +99,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ localization: { locale: "en_US.UTF-8" } }); + mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "en_US.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -123,7 +123,7 @@ describe("InstallerL10nProvider", () => { // Ensure both, UI and backend mock languages, are in sync since // client.setUILocale is mocked too. // See navigator.language in the beforeAll at the top of the file. - mockFetchConfigFn.mockResolvedValue({ localization: { locale: "es_ES.UTF-8" } }); + mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "es_ES.UTF-8" } }); }); it("sets the language from backend", async () => { @@ -158,7 +158,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is already set to 'cs-CZ'", () => { beforeEach(() => { document.cookie = "agamaLang=cs-CZ; path=/;"; - mockFetchConfigFn.mockResolvedValue({ localization: { locale: "cs_CZ.UTF-8" } }); + mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "cs_CZ.UTF-8" } }); }); it("displays the children content and does not reload", async () => { @@ -183,7 +183,7 @@ describe("InstallerL10nProvider", () => { describe("when the language is set to 'en-US'", () => { beforeEach(() => { document.cookie = "agamaLang=en-US; path=/;"; - mockFetchConfigFn.mockResolvedValue({ localization: { locale: "en_US" } }); + mockFetchConfigFn.mockResolvedValue({ l10n: { locale: "en_US" } }); }); it.skip("sets the 'cs-CZ' language and reloads", async () => { @@ -206,7 +206,7 @@ describe("InstallerL10nProvider", () => { await waitFor(() => screen.getByText("ahoj")); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { locale: "cs_CZ.UTF-8" }, + l10n: { locale: "cs_CZ.UTF-8" }, }); }); }); @@ -236,7 +236,7 @@ describe("InstallerL10nProvider", () => { await waitFor(() => screen.getByText("ahoj")); expect(mockUpdateConfigFn).toHaveBeenCalledWith({ - localization: { locale: "cs_CZ.UTF-8" }, + l10n: { locale: "cs_CZ.UTF-8" }, }); }); }); diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index b85ab8dde3..1dc2afa005 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -139,7 +139,7 @@ function languageToLocale(language: string): string { */ async function languageFromBackend(fetchConfig): Promise { const config = await fetchConfig(); - return languageFromLocale(config?.localization?.locale); + return languageFromLocale(config?.l10n?.locale); } /** @@ -306,7 +306,7 @@ function InstallerL10nProvider({ }, [language, syncBackendLanguage]); useEffect(() => { - fetchConfig().then((c) => setKeymap(c?.localization?.keymap)); + fetchConfig().then((c) => setKeymap(c?.l10n?.keymap)); }, [setKeymap, fetchConfig]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index 56ebf837d5..8d792c5c33 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -25,6 +25,7 @@ import { tzOffset } from "@date-fns/tz/tzOffset"; import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { fetchSystem } from "~/api/api"; +import { System } from "~/types/system"; const transformLocales = (locales) => locales.map(({ id, language: name, territory }) => ({ id, name, territory })); @@ -57,23 +58,23 @@ const systemQuery = () => { // React Query layer, in a dedicated "state layer" or transformation step, so // that data remains normalized and consistently shaped for the rest of the app. - select: (data) => ({ - ...data, - localization: { - locales: transformLocales(data.localization.locales), - keymaps: tranformKeymaps(data.localization.keymaps), - timezones: transformTimezones(data.localization.timezones), - locale: data.locale, - keypmap: data.keymap, - timezone: data.timezone, + select: (system: System) => ({ + ...system, + l10n: { + locales: transformLocales(system.l10n.locales), + keymaps: tranformKeymaps(system.l10n.keymaps), + timezones: transformTimezones(system.l10n.timezones), + locale: system.l10n.locale, + keypmap: system.l10n.keymap, + timezone: system.l10n.timezone, }, }), }; }; const useSystem = () => { - const { data: config } = useSuspenseQuery(systemQuery()); - return config; + const { data: system } = useSuspenseQuery(systemQuery()); + return system; }; const useSystemChanges = () => { @@ -84,7 +85,7 @@ const useSystemChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "SystemChanged" && event.scope === "localization") { + if (event.type === "SystemChanged" && event.scope === "l10n") { queryClient.invalidateQueries({ queryKey: ["system"] }); } }); diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index f1bff20d9c..6df15d8af3 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -120,7 +120,7 @@ const Providers = ({ children, withL10n }) => { if (withL10n) { const fetchConfig = async (): Promise => ({ - localization: { + l10n: { keymap: "us", timezone: "Europe/Berlin", locale: "en_US", diff --git a/web/src/types/config.ts b/web/src/types/config.ts new file mode 100644 index 0000000000..f7248c72cf --- /dev/null +++ b/web/src/types/config.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { Localization } from "./l10n"; + +type Config = { + l10n?: Localization; +}; + +export type { Config }; diff --git a/web/src/types/proposal.ts b/web/src/types/proposal.ts index a9826c7ee1..1eacf176f9 100644 --- a/web/src/types/proposal.ts +++ b/web/src/types/proposal.ts @@ -23,7 +23,7 @@ import { Localization } from "./l10n"; type Proposal = { - localization?: Localization; + l10n?: Localization; }; export type { Proposal }; diff --git a/web/src/types/system.ts b/web/src/types/system.ts index 69a562435f..60fb1f35c1 100644 --- a/web/src/types/system.ts +++ b/web/src/types/system.ts @@ -23,7 +23,7 @@ import { Localization } from "./l10n"; type System = { - localization?: Localization; + l10n?: Localization; }; export type { System }; From 51ecae3b2a5827fd2aacbd6a66b99aa72c018648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 16 Oct 2025 05:54:02 +0100 Subject: [PATCH 207/917] Changelogs --- rust/package/agama.changes | 7 +++++++ web/package/agama-web-ui.changes | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index bf66b36e3d..bd035137b8 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Thu Oct 16 04:46:20 UTC 2025 - José Iván López González + +- Move API types to agama-utils, simplify events code, emit event + when the installation state changes, and add progress information + to the progress changed event (gh#agama-project/agama#2801). + ------------------------------------------------------------------- Thu Oct 9 10:24:03 UTC 2025 - José Iván López González diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 8004295de9..cee2120a2e 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Oct 16 04:52:55 UTC 2025 - José Iván López González + +- Adapt to the changes in the HTTP API (gh#agama-project/agama#2801). + ------------------------------------------------------------------- Mon Oct 6 08:16:06 UTC 2025 - Imobach Gonzalez Sosa From 41525537e4a16601e46785b3c3d3c962d0e29ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 16 Oct 2025 07:05:05 +0100 Subject: [PATCH 208/917] Prevent the events channel from closing --- rust/agama-server/src/agama-web-server.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 53340f97ac..f9717062ba 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -31,6 +31,7 @@ use agama_server::{ logs::init_logging, web::{self, run_monitor}, }; +use agama_utils::api::event::Receiver; use anyhow::Context; use axum::{ extract::Request as AxumRequest, @@ -322,7 +323,8 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { let (tx, _) = channel(16); run_monitor(tx.clone()).await?; - let (events_tx, _) = channel(16); + let (events_tx, events_rx) = channel(16); + monitor_events_channel(events_rx); let config = web::ServiceConfig::load()?; @@ -381,6 +383,18 @@ fn write_token(path: &str, secret: &str) -> anyhow::Result<()> { Ok(token.write(path)?) } +// Keep the receiver running to avoid the channel being closed. +fn monitor_events_channel(mut events_rx: Receiver) { + tokio::spawn(async move { + loop { + if let Err(error) = events_rx.recv().await { + eprintln!("Error receiving events: {error}"); + break; + } + } + }); +} + /// Represents the result of execution. pub enum CliResult { /// Successful execution. From 2c47ce7063e90ea2b415521e4b4be82689da394d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 10 Oct 2025 22:23:57 +0200 Subject: [PATCH 209/917] WIP --- rust/Cargo.lock | 21 + rust/Cargo.toml | 2 +- rust/agama-software/Cargo.toml | 22 + rust/agama-software/src/config.rs | 162 +++++++ rust/agama-software/src/event.rs | 38 ++ rust/agama-software/src/extended_config.rs | 95 ++++ rust/agama-software/src/lib.rs | 61 +++ rust/agama-software/src/message.rs | 94 ++++ rust/agama-software/src/model.rs | 334 ++++++++++++++ rust/agama-software/src/model/conflict.rs | 104 +++++ rust/agama-software/src/model/license.rs | 347 ++++++++++++++ rust/agama-software/src/model/packages.rs | 119 +++++ rust/agama-software/src/model/pattern.rs | 17 + rust/agama-software/src/model/product.rs | 19 + rust/agama-software/src/model/products.rs | 237 ++++++++++ rust/agama-software/src/model/registration.rs | 88 ++++ .../src/model/software_selection.rs | 145 ++++++ rust/agama-software/src/proposal.rs | 57 +++ rust/agama-software/src/service.rs | 152 ++++++ rust/agama-software/src/start.rs | 59 +++ rust/agama-software/src/system_info.rs | 63 +++ rust/agama-software/src/zypp_server.rs | 432 ++++++++++++++++++ .../test/share/products.d/kalpa.yaml | 100 ++++ .../test/share/products.d/leap_160.yaml | 178 ++++++++ .../test/share/products.d/leap_micro_62.yaml | 111 +++++ .../test/share/products.d/microos.yaml | 198 ++++++++ .../test/share/products.d/sles_160.yaml | 200 ++++++++ .../test/share/products.d/sles_sap_160.yaml | 174 +++++++ .../test/share/products.d/slowroll.yaml | 169 +++++++ .../test/share/products.d/tumbleweed.yaml | 224 +++++++++ 30 files changed, 4021 insertions(+), 1 deletion(-) create mode 100644 rust/agama-software/Cargo.toml create mode 100644 rust/agama-software/src/config.rs create mode 100644 rust/agama-software/src/event.rs create mode 100644 rust/agama-software/src/extended_config.rs create mode 100644 rust/agama-software/src/lib.rs create mode 100644 rust/agama-software/src/message.rs create mode 100644 rust/agama-software/src/model.rs create mode 100644 rust/agama-software/src/model/conflict.rs create mode 100644 rust/agama-software/src/model/license.rs create mode 100644 rust/agama-software/src/model/packages.rs create mode 100644 rust/agama-software/src/model/pattern.rs create mode 100644 rust/agama-software/src/model/product.rs create mode 100644 rust/agama-software/src/model/products.rs create mode 100644 rust/agama-software/src/model/registration.rs create mode 100644 rust/agama-software/src/model/software_selection.rs create mode 100644 rust/agama-software/src/proposal.rs create mode 100644 rust/agama-software/src/service.rs create mode 100644 rust/agama-software/src/start.rs create mode 100644 rust/agama-software/src/system_info.rs create mode 100644 rust/agama-software/src/zypp_server.rs create mode 100644 rust/agama-software/test/share/products.d/kalpa.yaml create mode 100644 rust/agama-software/test/share/products.d/leap_160.yaml create mode 100644 rust/agama-software/test/share/products.d/leap_micro_62.yaml create mode 100644 rust/agama-software/test/share/products.d/microos.yaml create mode 100644 rust/agama-software/test/share/products.d/sles_160.yaml create mode 100644 rust/agama-software/test/share/products.d/sles_sap_160.yaml create mode 100644 rust/agama-software/test/share/products.d/slowroll.yaml create mode 100644 rust/agama-software/test/share/products.d/tumbleweed.yaml diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e267abedd4..28347d2fb5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -210,6 +210,27 @@ dependencies = [ "zypp-agama", ] +[[package]] +name = "agama-software" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "async-trait", + "glob", + "regex", + "serde", + "serde_with", + "serde_yaml", + "strum", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tracing", + "utoipa", + "zypp-agama", +] + [[package]] name = "agama-utils" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e963246f3a..bf5a04bfca 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,7 +10,7 @@ members = [ "agama-utils", "zypp-agama", "zypp-agama/zypp-agama-sys", - "xtask", + "xtask", "agama-software", ] resolver = "2" diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml new file mode 100644 index 0000000000..04da840905 --- /dev/null +++ b/rust/agama-software/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "agama-software" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-locale-data = { path = "../agama-locale-data" } +agama-utils = { path = "../agama-utils" } +async-trait = "0.1.89" +glob = "0.3.1" +regex = "1.11.0" +serde = { version = "1.0.210", features = ["derive"] } +serde_with = "3.10.0" +serde_yaml = "0.9.34" +strum = { version = "0.27.2", features = ["derive"] } +thiserror = "2.0.12" +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" +tracing = "0.1.41" +utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } +zypp-agama = { path = "../zypp-agama" } \ No newline at end of file diff --git a/rust/agama-software/src/config.rs b/rust/agama-software/src/config.rs new file mode 100644 index 0000000000..2275b2c997 --- /dev/null +++ b/rust/agama-software/src/config.rs @@ -0,0 +1,162 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the software settings + +use std::collections::HashMap; + +use crate::model::packages::RepositoryParams; +use serde::{Deserialize, Serialize}; + +/// User configuration for the localization of the target system. +/// +/// This configuration is provided by the user, so all the values are optional. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[schema(as = software::UserConfig)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Product related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + /// Software related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub software: Option, +} + +/// Addon settings for registration +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddonSettings { + pub id: String, + /// Optional version of the addon, if not specified the version is found + /// from the available addons + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Free extensions do not require a registration code + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_code: Option, +} + +/// Software settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProductSettings { + /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub addons: Option>, +} + +impl ProductSettings { + pub fn is_empty(&self) -> bool { + self.id.is_none() + && self.registration_code.is_none() + && self.registration_email.is_none() + && self.registration_url.is_none() + && self.addons.is_none() + } +} + +/// Software settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SoftwareSettings { + /// List of user selected patterns to install. + #[serde(skip_serializing_if = "Option::is_none")] + pub patterns: Option, + /// List of user selected packages to install. + #[serde(skip_serializing_if = "Option::is_none")] + pub packages: Option>, + /// List of user specified repositories to use on top of default ones. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_repositories: Option>, + /// Flag indicating if only hard requirements should be used by solver. + #[serde(skip_serializing_if = "Option::is_none")] + pub only_required: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(untagged)] +pub enum PatternsSettings { + PatternsList(Vec), + PatternsMap(PatternsMap), +} + +impl Default for PatternsSettings { + fn default() -> Self { + PatternsSettings::PatternsMap(PatternsMap { + add: None, + remove: None, + }) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +pub struct PatternsMap { + #[serde(skip_serializing_if = "Option::is_none")] + pub add: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub remove: Option>, +} + +impl From> for PatternsSettings { + fn from(list: Vec) -> Self { + Self::PatternsList(list) + } +} + +impl From>> for PatternsSettings { + fn from(map: HashMap>) -> Self { + let add = if let Some(to_add) = map.get("add") { + Some(to_add.to_owned()) + } else { + None + }; + + let remove = if let Some(to_remove) = map.get("remove") { + Some(to_remove.to_owned()) + } else { + None + }; + + Self::PatternsMap(PatternsMap { add, remove }) + } +} + +impl SoftwareSettings { + pub fn to_option(self) -> Option { + if self.patterns.is_none() + && self.packages.is_none() + && self.extra_repositories.is_none() + && self.only_required.is_none() + { + None + } else { + Some(self) + } + } +} diff --git a/rust/agama-software/src/event.rs b/rust/agama-software/src/event.rs new file mode 100644 index 0000000000..9ea47511b1 --- /dev/null +++ b/rust/agama-software/src/event.rs @@ -0,0 +1,38 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// Localization-related events. +// FIXME: is it really needed to implement Deserialize? +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "name")] +pub enum Event { + /// Proposal changed. + ProposalChanged, + /// The underlying system changed. + SystemChanged, +} + +/// Multi-producer single-consumer events sender. +pub type Sender = mpsc::UnboundedSender; +/// Multi-producer single-consumer events receiver. +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-software/src/extended_config.rs b/rust/agama-software/src/extended_config.rs new file mode 100644 index 0000000000..de83f949b3 --- /dev/null +++ b/rust/agama-software/src/extended_config.rs @@ -0,0 +1,95 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + config::{Config, PatternsSettings, ProductSettings, SoftwareSettings}, + model::packages::RepositoryParams, +}; +use serde::Serialize; + +#[derive(Clone, PartialEq, Serialize)] +pub struct ExtendedConfig { + /// Product related configuration + #[serde(skip_serializing_if = "ProductSettings::is_empty")] + pub product: ProductSettings, + /// Software related configuration + pub software: ExtendedSoftwareSettings, +} + +/// Software settings for installation +#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExtendedSoftwareSettings { + /// List of user selected patterns to install. + pub patterns: PatternsSettings, + /// List of user selected packages to install. + pub packages: Vec, + /// List of user specified repositories to use on top of default ones. + pub extra_repositories: Vec, + /// Flag indicating if only hard requirements should be used by solver. + pub only_required: bool, +} + +impl ExtendedSoftwareSettings { + pub fn merge(&mut self, config: &SoftwareSettings) -> &Self { + if let Some(patterns) = &config.patterns { + self.patterns = patterns.clone(); + } + + if let Some(packages) = &config.packages { + self.packages = packages.clone(); + } + + if let Some(extra_repositories) = &config.extra_repositories { + self.extra_repositories = extra_repositories.clone(); + } + + if let Some(only_required) = config.only_required { + self.only_required = only_required; + } + + self + } +} + +impl Default for ExtendedSoftwareSettings { + fn default() -> Self { + Self { + patterns: PatternsSettings::default(), + packages: Default::default(), + extra_repositories: Default::default(), + only_required: false, + } + } +} + +impl ExtendedConfig { + pub fn merge(&mut self, config: &Config) -> &Self { + if let Some(product_settings) = &config.product { + self.product = product_settings.clone(); + } + + if let Some(software) = &config.software { + self.software.merge(software); + } + + self + } +} diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs new file mode 100644 index 0000000000..f550e8f997 --- /dev/null +++ b/rust/agama-software/src/lib.rs @@ -0,0 +1,61 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate implements the support for software handling in Agama. +//! It takes care of setting the product, registration and software for the +//! target system. +//! +//! From a technical point of view, it includes: +//! +//! * The [UserConfig] struct that defines the settings the user can +//! alter for the target system. +//! * The [Proposal] struct that describes how the system will look like after +//! the installation. +//! * The [SystemInfo] which includes information about the available +//! stuff like products, repositories and others. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system +//! and also creates own separate thread for libzypp to satisfy its requirements. + +pub mod start; +pub use start::start; + +pub mod service; +pub use service::Service; + +mod model; +pub use model::{Model, ModelAdapter}; + +mod system_info; +pub use system_info::SystemInfo; + +mod config; +pub use config::Config; + +mod proposal; +pub use proposal::Proposal; + +pub mod event; +pub use event::Event; + +mod extended_config; +pub mod message; +mod zypp_server; diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs new file mode 100644 index 0000000000..8631d0a470 --- /dev/null +++ b/rust/agama-software/src/message.rs @@ -0,0 +1,94 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{config::Config, proposal::Proposal, system_info::SystemInfo}; +use agama_utils::actor::Message; +use serde::Deserialize; + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +pub struct SetSystem { + pub config: T, +} + +impl Message for SetSystem { + type Reply = (); +} + +impl SetSystem { + pub fn new(config: T) -> Self { + Self { config } + } +} + +#[derive(Clone, Debug, Deserialize, utoipa::ToSchema)] +pub struct SystemConfig { + pub locale: Option, + pub keymap: Option, +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +pub struct SetConfig { + pub config: T, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: T) -> Self { + Self { config } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +pub struct Install; + +impl Message for Install { + type Reply = bool; +} + +pub struct Probe; + +impl Message for Probe { + type Reply = (); +} + +pub struct Finish; + +impl Message for Finish { + type Reply = (); +} diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs new file mode 100644 index 0000000000..f6060c5430 --- /dev/null +++ b/rust/agama-software/src/model.rs @@ -0,0 +1,334 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use async_trait::async_trait; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + model::{ + license::License, + packages::{Repository, ResolvableType}, + pattern::Pattern, + product::Product, + products::{ProductSpec, ProductsRegistry, UserPattern}, + registration::{AddonProperties, RegistrationInfo}, + software_selection::SoftwareSelection, + }, + service, + zypp_server::SoftwareAction, +}; + +pub mod conflict; +pub mod license; +pub mod packages; +pub mod pattern; +pub mod product; +pub mod products; +pub mod registration; +pub mod software_selection; + +/// Abstract the software-related configuration from the underlying system. +/// +/// It offers an API to query and set different software and product elements of a +/// libzypp. This trait can be implemented to replace the real libzypp interaction during +/// tests. +#[async_trait] +pub trait ModelAdapter: Send + Sync + 'static { + /// List of available patterns. + async fn patterns(&self) -> Result, service::Error>; + + /// List of available products. + fn products(&self) -> Vec; + + /// List of available repositories. + fn repositories(&self) -> Result, service::Error>; + + /// List of available licenses. + fn licenses(&self) -> Result, service::Error>; + + /// List of available addons. + fn addons(&self) -> Result, service::Error>; + + /// selected product + fn selected_product(&self) -> Result, service::Error>; + + /// info about registration + fn registration_info(&self) -> Result; + + /// selects given product + fn select_product(&mut self, product_id: &str) -> Result<(), service::Error>; + + /// check if package is available + async fn is_package_available(&self, tag: String) -> Result; + + /// check if package is selected for installation + async fn is_package_selected(&self, tag: String) -> Result; + + /// Gets resolvables set for given combination of id, type and optional flag + fn get_resolvables( + &self, + id: &str, + r#type: ResolvableType, + optional: bool, + ) -> Vec; + + /// Sets resolvables set for given combination of id, type and optional flag + async fn set_resolvables( + &mut self, + id: &str, + r#type: ResolvableType, + resolvables: &[&str], + optional: bool, + ) -> Result<(), service::Error>; + + /// Probes system and updates info about it. + async fn probe(&mut self) -> Result<(), service::Error>; + + /// install rpms to target system + async fn install(&self) -> Result; + + /// Finalizes system like disabling local repositories + fn finish(&self) -> Result<(), service::Error>; +} + +/// [ModelAdapter] implementation for libzypp systems. +pub struct Model { + zypp_sender: mpsc::UnboundedSender, + products: ProductsRegistry, + // FIXME: what about having a SoftwareServiceState to keep business logic state? + selected_product: Option, + software_selection: SoftwareSelection, +} + +impl Model { + /// Initializes the struct with the information from the underlying system. + pub fn new(zypp_sender: mpsc::UnboundedSender) -> Result { + Ok(Self { + zypp_sender, + products: ProductsRegistry::load()?, + selected_product: None, + software_selection: SoftwareSelection::default(), + }) + } +} + +#[async_trait] +impl ModelAdapter for Model { + 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??) + } + + fn products(&self) -> Vec { + self.products + .products + .iter() + .map(|p| Product { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon: p.icon.clone(), + registration: p.registration, + license: None, + }) + .collect() + } + + fn select_product(&mut self, product_id: &str) -> Result<(), service::Error> { + let product_str = product_id.to_string(); + let Some(product_spec) = self.products.find(product_id) else { + return Err(service::Error::WrongProduct(product_str)); + }; + self.selected_product = Some(product_spec.clone()); + Ok(()) + } + + async fn is_package_available(&self, tag: String) -> Result { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::PackageAvailable(tag, tx))?; + Ok(rx.await??) + } + + async fn is_package_selected(&self, tag: String) -> Result { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::PackageSelected(tag, tx))?; + Ok(rx.await??) + } + + fn get_resolvables( + &self, + id: &str, + r#type: ResolvableType, + optional: bool, + ) -> Vec { + self.software_selection.get(id, r#type, optional).unwrap_or_default() + } + + async fn set_resolvables( + &mut self, + id: &str, + r#type: ResolvableType, + resolvables: &[&str], + optional: bool, + ) -> Result<(), service::Error> { + self.software_selection.set(&self.zypp_sender, id, r#type, optional, resolvables).await?; + Ok(()) + } + + async fn probe(&mut self) -> Result<(), service::Error> { + let Some(product) = &self.selected_product else { + return Err(service::Error::MissingProduct); + }; + + let (tx, rx) = oneshot::channel(); + let repositories = product + .software + .repositories() + .into_iter() + .map(|r| r.clone()) + .collect(); + self.zypp_sender + .send(SoftwareAction::AddRepositories(repositories, tx))?; + rx.await??; + + let installer_id = "Installer"; + self.software_selection + .set( + &self.zypp_sender, + installer_id, + ResolvableType::Product, + false, + &[product.id.as_str()], + ) + .await?; + + let resolvables: Vec<_> = product + .software + .mandatory_patterns + .iter() + .map(String::as_str) + .collect(); + self.software_selection + .set( + &self.zypp_sender, + installer_id, + ResolvableType::Pattern, + false, + &resolvables, + ) + .await?; + + let resolvables: Vec<_> = product + .software + .mandatory_packages + .iter() + .map(String::as_str) + .collect(); + self.software_selection + .set( + &self.zypp_sender, + installer_id, + ResolvableType::Package, + false, + &resolvables, + ) + .await?; + + let resolvables: Vec<_> = product + .software + .optional_patterns + .iter() + .map(String::as_str) + .collect(); + self.software_selection + .set( + &self.zypp_sender, + installer_id, + ResolvableType::Pattern, + true, + &resolvables, + ) + .await?; + + let resolvables: Vec<_> = product + .software + .optional_packages + .iter() + .map(String::as_str) + .collect(); + self.software_selection + .set( + &self.zypp_sender, + installer_id, + ResolvableType::Package, + true, + &resolvables, + ) + .await?; + + Ok(()) + } + + fn finish(&self) -> Result<(), service::Error> { + todo!() + } + + async fn install(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Install(tx))?; + Ok(rx.await??) + } + + fn repositories(&self) -> Result, service::Error> { + todo!() + } + + fn licenses(&self) -> Result, service::Error> { + todo!() + } + + fn addons(&self) -> Result, service::Error> { + todo!() + } + + fn selected_product(&self) -> Result, service::Error> { + todo!() + } + + fn registration_info(&self) -> Result { + todo!() + } +} diff --git a/rust/agama-software/src/model/conflict.rs b/rust/agama-software/src/model/conflict.rs new file mode 100644 index 0000000000..527fea41a7 --- /dev/null +++ b/rust/agama-software/src/model/conflict.rs @@ -0,0 +1,104 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; + +/// Information about conflict when resolving software +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConflictSolve { + /// conflict id + pub conflict_id: u32, + /// selected solution id + pub solution_id: u32, +} + +impl From for (u32, u32) { + fn from(solve: ConflictSolve) -> Self { + (solve.conflict_id, solve.solution_id) + } +} + +/// Information about possible solution for conflict +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Solution { + /// conflict id + pub id: u32, + /// localized description of solution + pub description: String, + /// localized details about solution. Can be missing + pub details: Option, +} + +/// Information about conflict when resolving software +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Conflict { + /// conflict id + pub id: u32, + /// localized description of conflict + pub description: String, + /// localized details about conflict. Can be missing + pub details: Option, + /// list of possible solutions + pub solutions: Vec, +} + +impl Solution { + pub fn from_dbus(dbus_solution: (u32, String, String)) -> Self { + let details = dbus_solution.2; + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + Self { + id: dbus_solution.0, + description: dbus_solution.1, + details, + } + } +} + +impl Conflict { + pub fn from_dbus(dbus_conflict: (u32, String, String, Vec<(u32, String, String)>)) -> Self { + let details = dbus_conflict.2; + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + let solutions = dbus_conflict.3; + let solutions = solutions + .into_iter() + .map(|s| Solution::from_dbus(s)) + .collect(); + + Self { + id: dbus_conflict.0, + description: dbus_conflict.1, + details, + solutions, + } + } +} diff --git a/rust/agama-software/src/model/license.rs b/rust/agama-software/src/model/license.rs new file mode 100644 index 0000000000..5ae4436086 --- /dev/null +++ b/rust/agama-software/src/model/license.rs @@ -0,0 +1,347 @@ +// Copyright (c) [2024-2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements support for reading software licenses. + +use agama_locale_data::get_territories; +use regex::Regex; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; +use std::{ + collections::HashMap, + fmt::Display, + fs::read_dir, + path::{Path, PathBuf}, +}; +use thiserror::Error; + +/// Represents a product license. +/// +/// It contains the license ID and the list of languages that with a translation. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct License { + /// License ID. + pub id: String, + /// Languages in which the license is translated. + #[serde_as(as = "Vec")] + pub languages: Vec, +} + +/// Represents a license content. +/// +/// It contains the license ID and the body. +/// +/// TODO: in the future it might contain a title, extracted from the text. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct LicenseContent { + /// License ID. + pub id: String, + /// License text. + pub body: String, + /// License language. + #[serde_as(as = "DisplayFromStr")] + pub language: LanguageTag, +} + +/// Represents a repository of software licenses. +/// +/// The repository consists of a directory in the file system which contains the licenses in +/// different languages. +/// +/// Each license is stored on a separate directory (e.g., "/usr/share/agama/eula/license.beta"). +/// The license diectory contains the default text (license.txt) and a set of translations (e.g., +/// "license.es.txt", "license.zh_CH.txt", etc.). +#[derive(Clone)] +pub struct LicensesRepo { + /// Repository path. + pub path: std::path::PathBuf, + /// Licenses in the repository. + pub licenses: Vec, + /// Fallback languages per territory. + fallback: HashMap, +} + +impl LicensesRepo { + pub fn new>(path: P) -> Self { + Self { + path: path.as_ref().to_owned(), + licenses: vec![], + fallback: HashMap::new(), + } + } + + /// Reads the licenses from the repository. + pub fn read(&mut self) -> Result<(), std::io::Error> { + let entries = read_dir(self.path.as_path())?; + + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() { + let Ok(id) = entry.file_name().into_string() else { + continue; + }; + let license = License { + id, + languages: Self::find_translations(&entry.path())?, + }; + self.licenses.push(license); + } + } + + self.fallback.clear(); + + let territories = get_territories().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Cannot read the territories list: {}", e), + ) + })?; + + for territory in territories.territory { + if let Some(language) = territory.languages.language.first() { + let fallback = LanguageTag { + language: language.id.to_string(), + territory: None, + }; + self.fallback.insert(territory.id, fallback); + } + } + + Ok(()) + } + + /// Finds a license with the given ID and language. + /// + /// If a translation is not found for the given language, it returns the default text. + pub fn find(&self, id: &str, language: &LanguageTag) -> Option { + let license = self.licenses.iter().find(|l| l.id.as_str() == id)?; + let license_language = self.find_language(&license, &language).unwrap_or_default(); + self.read_license_content(id, &license_language).ok() + } + + /// Finds translations in the given directory. + /// + /// * `path`: directory to search translations. + fn find_translations(path: &PathBuf) -> Result, std::io::Error> { + let entries = read_dir(path).unwrap().filter_map(|entry| entry.ok()); + + let files = entries + .filter(|entry| entry.file_type().is_ok_and(|f| f.is_file())) + .filter_map(|entry| { + let path = entry.path(); + let file = path.file_name()?; + file.to_owned().into_string().ok() + }); + + Ok(files + .filter_map(|f| Self::language_tag_from_file(&f)) + .collect()) + } + + /// Returns the language tag for the given file. + /// + /// The language is inferred from the file name (e.g., "es-ES" for license.es_ES.txt"). + fn language_tag_from_file(name: &str) -> Option { + if !name.starts_with("license") { + tracing::warn!("Unexpected file in the licenses directory: {}", &name); + return None; + } + let mut parts = name.split("."); + let mut code = parts.nth(1)?; + + if code == "txt" { + code = "en" + } + + code.try_into().ok() + } + + /// Read a license content for a given language. + fn read_license_content( + &self, + id: &str, + language: &LanguageTag, + ) -> std::io::Result { + let file_name = if *language == LanguageTag::default() { + "license.txt".to_string() + } else if let Some(territory) = &language.territory { + format!("license.{}_{}.txt", language.language, territory) + } else { + format!("license.{}.txt", language.language) + }; + + let license_path = self.path.join(id).join(file_name); + let body = std::fs::read_to_string(license_path)?; + Ok(LicenseContent { + id: id.to_string(), + body, + language: language.clone(), + }) + } + + /// It search for an available language for the translation. + /// + /// If translated to the given language, it returns that language. If that's + /// not the case, it searches for a "compatible" language (the main language + /// on the same territory, if given). + fn find_language(&self, license: &License, candidate: &LanguageTag) -> Option { + let mut candidates: Vec = vec![candidate.clone()]; + candidates.push(LanguageTag { + language: candidate.language.clone(), + territory: None, + }); + + if let Some(territory) = &candidate.territory { + if let Some(fallback) = self.fallback.get(territory) { + candidates.push(fallback.clone()); + } + } + + candidates + .into_iter() + .find(|c| license.languages.contains(&c)) + } +} + +impl Default for LicensesRepo { + fn default() -> Self { + let relative_path = Path::new("share/eula"); + let path = if relative_path.exists() { + relative_path + } else { + Path::new("/usr/share/agama/eula") + }; + Self::new(path) + } +} + +/// Simplified representation of the RFC 5646 language code. +/// +/// It only considers xx and xx-XX formats. +#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] +pub struct LanguageTag { + // ISO-639 + pub language: String, + // ISO-3166 + pub territory: Option, +} + +impl Default for LanguageTag { + fn default() -> Self { + LanguageTag { + language: "en".to_string(), + territory: None, + } + } +} + +impl Display for LanguageTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(territory) = &self.territory { + write!(f, "{}-{}", &self.language, territory) + } else { + write!(f, "{}", &self.language) + } + } +} + +#[derive(Error, Debug)] +#[error("Not a valid language code: {0}")] +pub struct InvalidLanguageCode(String); + +impl TryFrom<&str> for LanguageTag { + type Error = InvalidLanguageCode; + + fn try_from(value: &str) -> Result { + let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); + + let captures = language_regexp + .captures(value) + .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; + + Ok(Self { + language: captures.get(1).unwrap().as_str().to_string(), + territory: captures.get(2).map(|e| e.as_str().to_string()), + }) + } +} + +#[cfg(test)] +mod test { + use super::{LanguageTag, LicensesRepo}; + use std::path::Path; + + fn build_repo() -> LicensesRepo { + let mut repo = LicensesRepo::new(Path::new("../share/eula")); + repo.read().unwrap(); + repo + } + + #[test] + fn test_read_licenses_repository() { + let repo = build_repo(); + let license = repo.licenses.first().unwrap(); + assert_eq!(&license.id, "license.final"); + } + + #[test] + fn test_find_license() { + let repo = build_repo(); + let es_language: LanguageTag = "es".try_into().unwrap(); + let license = repo.find("license.final", &es_language).unwrap(); + assert!(license.body.starts_with("Acuerdo de licencia")); + assert_eq!(license.language, es_language); + + let language: LanguageTag = "es-ES".try_into().unwrap(); + let license = repo.find("license.final", &language).unwrap(); + assert!(license.body.starts_with("Acuerdo de licencia")); + assert_eq!(license.language, es_language); + + let language: LanguageTag = "zh-CN".try_into().unwrap(); + let license = repo.find("license.final", &language).unwrap(); + assert!(license.body.starts_with("SUSE 软件")); + assert_eq!(license.language, language); + + let language: LanguageTag = "xx".try_into().unwrap(); + let license = repo.find("license.final", &language).unwrap(); + assert!(license.body.starts_with("End User License")); + assert_eq!(license.language, LanguageTag::default()); + } + + #[test] + fn test_find_alternate_license() { + let repo = build_repo(); + + // Tries to use the main language for the territory. + let ca_language: LanguageTag = "ca-ES".try_into().unwrap(); + let es_language: LanguageTag = "es".try_into().unwrap(); + let license = repo.find("license.final", &ca_language).unwrap(); + assert_eq!(license.language, es_language); + } + + #[test] + fn test_language_tag() { + let tag: LanguageTag = "zh-CH".try_into().unwrap(); + assert_eq!(tag.language, "zh"); + assert_eq!(tag.territory, Some("CH".to_string())); + } +} diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs new file mode 100644 index 0000000000..bc52334057 --- /dev/null +++ b/rust/agama-software/src/model/packages.rs @@ -0,0 +1,119 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Software service configuration (product, patterns, etc.). +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SoftwareConfig { + /// A map where the keys are the pattern names and the values whether to install them or not. + pub patterns: Option>, + /// Packages to install. + pub packages: Option>, + /// Name of the product to install. + pub product: Option, + /// Extra repositories defined by user. + pub extra_repositories: Option>, + /// Flag if solver should use only hard dependencies. + pub only_required: Option, +} + +/// Software resolvable type (package or pattern). +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ResolvableType { + Package = 0, + Pattern = 1, + Product = 2, +} + +impl From for zypp_agama::ResolvableKind { + fn from(value: ResolvableType) -> Self { + match value { + ResolvableType::Package => zypp_agama::ResolvableKind::Package, + ResolvableType::Product => zypp_agama::ResolvableKind::Product, + ResolvableType::Pattern => zypp_agama::ResolvableKind::Pattern, + } + } +} + +/// Resolvable list specification. +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct ResolvableParams { + /// List of resolvables. + pub names: Vec, + /// Resolvable type. + pub r#type: ResolvableType, + /// Whether the resolvables are optional or not. + pub optional: bool, +} + +/// Repository specification. +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Repository { + /// repository identifier + pub id: i32, + /// repository alias. Has to be unique + pub alias: String, + /// repository name + pub name: String, + /// Repository url (raw format without expanded variables) + pub url: String, + /// product directory (currently not used, valid only for multiproduct DVDs) + pub product_dir: String, + /// Whether the repository is enabled + pub enabled: bool, + /// Whether the repository is loaded + pub loaded: bool, +} + +/// Parameters for creating new a repository +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryParams { + /// repository alias. Has to be unique + pub alias: String, + /// repository name, if not specified the alias is used + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Repository url (raw format without expanded variables) + pub url: String, + /// product directory (currently not used, valid only for multiproduct DVDs) + #[serde(skip_serializing_if = "Option::is_none")] + pub product_dir: Option, + /// Whether the repository is enabled, if missing the repository is enabled + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Repository priority, lower number means higher priority, the default priority is 99 + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Whenever repository can be unsigned. Default is false + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_unsigned: Option, + /// List of fingerprints for GPG keys used for repository signing. By default empty + #[serde(skip_serializing_if = "Option::is_none")] + pub gpg_fingerprints: Option>, +} diff --git a/rust/agama-software/src/model/pattern.rs b/rust/agama-software/src/model/pattern.rs new file mode 100644 index 0000000000..f40e1acc5f --- /dev/null +++ b/rust/agama-software/src/model/pattern.rs @@ -0,0 +1,17 @@ +use serde::Serialize; + +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct Pattern { + /// Pattern name (eg., "aaa_base", "gnome") + pub name: String, + /// Pattern category (e.g., "Production") + pub category: String, + /// Pattern icon path locally on system + pub icon: String, + /// Pattern description + pub description: String, + /// Pattern summary + pub summary: String, + /// Pattern order + pub order: String, +} diff --git a/rust/agama-software/src/model/product.rs b/rust/agama-software/src/model/product.rs new file mode 100644 index 0000000000..b8ab6ae424 --- /dev/null +++ b/rust/agama-software/src/model/product.rs @@ -0,0 +1,19 @@ +use serde::Serialize; + +/// Represents a software product +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Product { + /// Product ID (eg., "ALP", "Tumbleweed", etc.) + pub id: String, + /// Product name (e.g., "openSUSE Tumbleweed") + pub name: String, + /// Product description + pub description: String, + /// Product icon (e.g., "default.svg") + pub icon: String, + /// Registration requirement + pub registration: bool, + /// License ID + pub license: Option, +} diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs new file mode 100644 index 0000000000..643fb04522 --- /dev/null +++ b/rust/agama-software/src/model/products.rs @@ -0,0 +1,237 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a products registry. +//! +//! The products registry contains the specification of every known product. +//! It reads the list of products from the `products.d` directory (usually, +//! `/usr/share/agama/products.d`). + +use serde::{Deserialize, Deserializer}; +use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; +use std::path::{Path, PathBuf}; + +#[derive(thiserror::Error, Debug)] +pub enum ProductsRegistryError { + #[error("Could not read the products registry: {0}")] + IO(#[from] std::io::Error), + #[error("Could not deserialize a product specification: {0}")] + Deserialize(#[from] serde_yaml::Error), +} + +/// Products registry. +/// +/// It holds the products specifications. At runtime it is possible to change the `products.d` +/// location by setting the `AGAMA_PRODUCTS_DIR` environment variable. +/// +/// Dynamic behavior, like filtering by architecture, is not supported yet. +#[derive(Clone, Default, Debug, Deserialize)] +pub struct ProductsRegistry { + pub products: Vec, +} + +impl ProductsRegistry { + /// Creates a registry loading the products from the default location. + pub fn load() -> Result { + let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { + PathBuf::from(dir) + } else { + PathBuf::from("/usr/share/agama/products.d") + }; + + if !products_dir.exists() { + return Err(ProductsRegistryError::IO(std::io::Error::new( + std::io::ErrorKind::NotFound, + "products.d directory does not exist", + ))); + } + + Self::load_from(products_dir) + } + + /// Creates a registry loading the products from the given location. + pub fn load_from>(products_path: P) -> Result { + let entries = std::fs::read_dir(products_path)?; + let mut products = vec![]; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + let Some(ext) = path.extension() else { + continue; + }; + + if path.is_file() && (ext == "yaml" || ext == "yml") { + let product = ProductSpec::load_from(path)?; + products.push(product); + } + } + + Ok(Self { products }) + } + + /// Determines whether the are are multiple products. + pub fn is_multiproduct(&self) -> bool { + self.products.len() > 1 + } + + /// Finds a product by its ID. + /// + /// * `id`: product ID. + pub fn find(&self, id: &str) -> Option<&ProductSpec> { + self.products.iter().find(|p| p.id == id) + } +} + +// TODO: ideally, part of this code could be auto-generated from a JSON schema definition. +/// Product specification (e.g., Tumbleweed). +#[derive(Clone, Debug, Deserialize)] +pub struct ProductSpec { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + #[serde(default)] + pub registration: bool, + pub version: Option, + pub software: SoftwareSpec, +} + +impl ProductSpec { + pub fn load_from>(path: P) -> Result { + let contents = std::fs::read_to_string(path)?; + let product: ProductSpec = serde_yaml::from_str(&contents)?; + Ok(product) + } +} + +fn parse_optional<'de, D>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(d).map(|x: Option<_>| x.unwrap_or_default()) +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SoftwareSpec { + installation_repositories: Vec, + #[serde(default)] + pub installation_labels: Vec, + #[serde(default)] + pub user_patterns: Vec, + #[serde(default)] + pub mandatory_patterns: Vec, + #[serde(default)] + pub mandatory_packages: Vec, + #[serde(deserialize_with = "parse_optional")] + pub optional_patterns: Vec, + #[serde(deserialize_with = "parse_optional")] + pub optional_packages: Vec, + pub base_product: String, +} + +impl SoftwareSpec { + // NOTE: perhaps implementing our own iterator would be more efficient. + pub fn repositories(&self) -> Vec<&RepositorySpec> { + let arch = std::env::consts::ARCH.to_string(); + self.installation_repositories + .iter() + .filter(|r| r.archs.contains(&arch)) + .collect() + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub enum UserPattern { + Plain(String), + Preselected(PreselectedPattern), +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PreselectedPattern { + pub name: String, + pub selected: bool, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct RepositorySpec { + pub url: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[serde_as] +#[derive(Clone, Debug, Deserialize)] +pub struct LabelSpec { + pub label: String, + #[serde(default)] + #[serde_as(as = "StringWithSeparator::")] + pub archs: Vec, +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_load_registry() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let config = ProductsRegistry::load_from(path.as_path()).unwrap(); + // ensuring that we can load all products from tests + assert_eq!(config.products.len(), 8); + } + + #[test] + fn test_find_product() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let products = ProductsRegistry::load_from(path.as_path()).unwrap(); + let tw = products.find("Tumbleweed").unwrap(); + assert_eq!(tw.id, "Tumbleweed"); + assert_eq!(tw.name, "openSUSE Tumbleweed"); + assert_eq!(tw.icon, "Tumbleweed.svg"); + assert_eq!(tw.registration, false); + assert_eq!(tw.version, None); + let software = &tw.software; + assert_eq!(software.installation_repositories.len(), 12); + assert_eq!(software.installation_labels.len(), 4); + assert_eq!(software.base_product, "openSUSE"); + assert_eq!(software.user_patterns.len(), 11); + + let preselected = software + .user_patterns + .iter() + .find(|p| matches!(p, UserPattern::Preselected(_))); + let expected_pattern = PreselectedPattern { + name: "selinux".to_string(), + selected: true, + }; + assert_eq!( + preselected, + Some(&UserPattern::Preselected(expected_pattern)) + ); + + let missing = products.find("Missing"); + assert!(missing.is_none()); + } +} diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs new file mode 100644 index 0000000000..8b84342a2a --- /dev/null +++ b/rust/agama-software/src/model/registration.rs @@ -0,0 +1,88 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; + +/// Software service configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RegistrationParams { + /// Registration key. + pub key: String, + /// Registration email. + pub email: String, +} + +/// Addon registration +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddonParams { + // Addon identifier + pub id: String, + // Addon version, if not specified the version is found from the available addons + pub version: Option, + // Optional registration code, not required for free extensions + pub registration_code: Option, +} + +/// Addon registration +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddonProperties { + /// Addon identifier + pub id: String, + /// Version of the addon + pub version: String, + /// User visible name + pub label: String, + /// Whether the addon is mirrored on the RMT server, on SCC it is always `true` + pub available: bool, + /// Whether a registration code is required for registering the addon + pub free: bool, + /// Whether the addon is recommended for the users + pub recommended: bool, + /// Short description of the addon (translated) + pub description: String, + /// Type of the addon, like "extension" or "module" + pub r#type: String, + /// Release status of the addon, e.g. "beta" + pub release: String, +} + +/// Information about registration configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationInfo { + /// Registration status. True if base system is already registered. + pub registered: bool, + /// Registration key. Empty value mean key not used or not registered. + pub key: String, + /// Registration email. Empty value mean email not used or not registered. + pub email: String, + /// Registration URL. Empty value mean that de default value is used. + pub url: String, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RegistrationError { + /// ID of error. See dbus API for possible values + pub id: u32, + /// human readable error string intended to be displayed to user + pub message: String, +} diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs new file mode 100644 index 0000000000..358f60cdea --- /dev/null +++ b/rust/agama-software/src/model/software_selection.rs @@ -0,0 +1,145 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use tokio::sync::{mpsc, oneshot}; + +use crate::{model::packages::ResolvableType, service, zypp_server::SoftwareAction}; + +pub struct ResolvablesSelection { + id: String, + optional: bool, + resolvables: Vec, + r#type: ResolvableType, +} + +/// A selection of resolvables to be installed. +/// +/// It holds a selection of patterns and packages to be installed and whether they are optional or +/// not. This class is similar to the `PackagesProposal` YaST module. +#[derive(Default)] +pub struct SoftwareSelection { + selections: Vec, +} + +impl SoftwareSelection { + /// Updates a set of resolvables. + /// + /// * `zypp` - pointer to message bus to zypp thread to do real action + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + /// * `resolvables` - The resolvables included in the set. + pub async fn set( + &mut self, + zypp: &mpsc::UnboundedSender, + id: &str, + r#type: ResolvableType, + optional: bool, + resolvables: &[&str], + ) -> Result<(), service::Error> { + let list = self.find_or_create_selection(id, r#type, optional); + // FIXME: use reference counting here, if multiple ids require some package, to not unselect it + let (tx, rx) = oneshot::channel(); + zypp.send(SoftwareAction::UnsetResolvables { + tx, + resolvables: list.resolvables.clone(), + r#type: r#type.into(), + optional + })?; + rx.await??; + + let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); + list.resolvables = new_resolvables; + let (tx, rx) = oneshot::channel(); + zypp.send(SoftwareAction::UnsetResolvables { + tx, + resolvables: list.resolvables.clone(), + r#type: r#type.into(), + optional + })?; + rx.await??; + Ok(()) + } + + /// Returns a set of resolvables. + /// + /// * `id` - The id of the set. + /// * `r#type` - The type of the resolvables (patterns or packages). + /// * `optional` - Whether the selection is optional or not. + pub fn get(&self, id: &str, r#type: ResolvableType, optional: bool) -> Option> { + self.selections + .iter() + .find(|l| l.id == id && l.r#type == r#type && l.optional == optional) + .map(|l| l.resolvables.clone()) + } + + fn find_or_create_selection( + &mut self, + id: &str, + r#type: ResolvableType, + optional: bool, + ) -> &mut ResolvablesSelection { + let found = self + .selections + .iter() + .position(|l| l.id == id && l.r#type == r#type && l.optional == optional); + + if let Some(index) = found { + &mut self.selections[index] + } else { + let selection = ResolvablesSelection { + id: id.to_string(), + r#type, + optional, + resolvables: vec![], + }; + self.selections.push(selection); + self.selections.last_mut().unwrap() + } + } +} + +/* TODO: Fix tests with real mock of libzypp +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_selection() { + let mut selection = SoftwareSelection::new(); + selection.add("agama", ResolvableType::Package, false, &["agama-scripts"]); + selection.set("agama", ResolvableType::Package, false, &["suse"]); + + let packages = selection + .get("agama", ResolvableType::Package, false) + .unwrap(); + assert_eq!(packages.len(), 1); + } + + #[test] + fn test_remove_selection() { + let mut selection = SoftwareSelection::new(); + selection.add("agama", ResolvableType::Package, true, &["agama-scripts"]); + selection.remove("agama", ResolvableType::Package, true); + let packages = selection.get("agama", ResolvableType::Package, true); + assert_eq!(packages, None); + } +} + */ diff --git a/rust/agama-software/src/proposal.rs b/rust/agama-software/src/proposal.rs new file mode 100644 index 0000000000..30c185e0f8 --- /dev/null +++ b/rust/agama-software/src/proposal.rs @@ -0,0 +1,57 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::collections::HashMap; + +use serde::Serialize; + +/// Represents the reason why a pattern is selected. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, utoipa::ToSchema)] +pub enum SelectedBy { + /// The pattern was selected by the user. + User, + /// The pattern was selected automatically. + Auto, + /// The pattern has not be selected. + None, +} + +#[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, + /// 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, +} + +/// Describes what Agama proposes for the target system. +#[derive(Clone, Debug, Serialize)] +pub struct Proposal { + /// Software specific proposal + #[serde(skip_serializing_if = "Option::is_none")] + software: Option, + /// Registration proposal. Maybe same as config? + /// TODO: implement it + #[serde(skip_serializing_if = "Option::is_none")] + registration: Option<()>, +} diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs new file mode 100644 index 0000000000..63edb4a713 --- /dev/null +++ b/rust/agama-software/src/service.rs @@ -0,0 +1,152 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + config::Config, + event::{self}, + message, + model::{products::ProductsRegistryError, ModelAdapter}, + proposal::Proposal, + system_info::SystemInfo, + zypp_server::{self, SoftwareAction}, +}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + issue::{self}, +}; +use async_trait::async_trait; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("software service could not send the event")] + Event, + #[error(transparent)] + Actor(#[from] actor::Error), + #[error("Failed to send message to libzypp thread: {0}")] + ZyppSender(#[from] tokio::sync::mpsc::error::SendError), + #[error("Failed to receive result from libzypp thread: {0}")] + ZyppReceiver(#[from] tokio::sync::oneshot::error::RecvError), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error("There is no proposal for software")] + MissingProposal, + #[error("There is no product selected")] + MissingProduct, + #[error("There is no {0} product")] + WrongProduct(String), + #[error(transparent)] + ProductsRegistry(#[from] ProductsRegistryError), + #[error(transparent)] + ZyppServerError(#[from] zypp_server::ZyppServerError), + #[error(transparent)] + ZyppError(#[from] zypp_agama::errors::ZyppError), +} + +/// Localization service. +/// +/// It is responsible for handling the localization part of the installation: +/// +/// * Reads the list of known locales, keymaps and timezones. +/// * Keeps track of the localization settings of the underlying system (the installer). +/// * Holds the user configuration. +/// * Applies the user configuration at the end of the installation. +pub struct Service { + model: Box, + issues: Handler, + events: event::Sender, + state: State, +} + +struct State { + config: Config, +} + +impl Service { + pub fn new( + model: T, + issues: Handler, + events: event::Sender, + ) -> Service { + Self { + model: Box::new(model), + issues, + events, + state: State { + // we start with empty config as without product selection, there is basically nothing in config + config: Config::default(), + }, + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(SystemInfo::read_from(self.model.as_ref()).await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.state.config.clone()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + todo!(); + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + todo!(); + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { + self.model.probe().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result { + self.model.install().await + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + self.model.finish()?; + Ok(()) + } +} diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs new file mode 100644 index 0000000000..796859b862 --- /dev/null +++ b/rust/agama-software/src/start.rs @@ -0,0 +1,59 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + event, + model::Model, + service::{self, Service}, + zypp_server::{ZyppServer, ZyppServerError}, +}; +use agama_utils::{ + actor::{self, Handler}, + issue, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Service(#[from] service::Error), + #[error(transparent)] + ZyppError(#[from] ZyppServerError), +} + +/// Starts the localization service. +/// +/// It starts two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - zypp thread for tasks which needs libzypp +/// - It depends on the issues service to keep the installation issues. +/// +/// * `events`: channel to emit the [localization-specific events](crate::Event). +/// * `issues`: handler to the issues service. +pub async fn start( + issues: Handler, + events: event::Sender, +) -> Result, Error> { + let zypp_sender = ZyppServer::start()?; + let model = Model::new(zypp_sender)?; + let service = Service::new(model, issues, events); + let handler = actor::spawn(service); + Ok(handler) +} diff --git a/rust/agama-software/src/system_info.rs b/rust/agama-software/src/system_info.rs new file mode 100644 index 0000000000..31ec6355c7 --- /dev/null +++ b/rust/agama-software/src/system_info.rs @@ -0,0 +1,63 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + model::{ + license::License, packages::Repository, pattern::Pattern, product::Product, + registration::AddonProperties, ModelAdapter, + }, + service, +}; +use serde::Serialize; + +/// Localization-related information of the system where the installer +/// is running. +#[derive(Clone, Debug, Serialize)] +pub struct SystemInfo { + /// List of known patterns. + pub patterns: Vec, + /// List of known repositories. + pub repositories: Vec, + /// List of known products. + pub products: Vec, + /// List of known licenses + pub licenses: Vec, + /// List of available addons to register + pub addons: Vec, +} + +impl SystemInfo { + /// Reads the information from the system adapter. + pub async fn read_from(model: &dyn ModelAdapter) -> Result { + let patterns = model.patterns().await?; + let repositories = model.repositories()?; + let products = model.products(); + let licenses = model.licenses()?; + let addons = model.addons()?; + + Ok(Self { + patterns, + repositories, + products, + licenses, + addons, + }) + } +} diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs new file mode 100644 index 0000000000..147a5f8cb2 --- /dev/null +++ b/rust/agama-software/src/zypp_server.rs @@ -0,0 +1,432 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::path::Path; +use tokio::sync::{ + mpsc::{self, UnboundedSender}, + oneshot, +}; +use zypp_agama::ZyppError; + +use crate::model::{ + packages::{ResolvableType, SoftwareConfig}, + pattern::{self, Pattern}, + product::Product, + products::{ProductSpec, RepositorySpec}, + software_selection::SoftwareSelection, +}; + +const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; +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("Response channel closed")] + ResponseChannelClosed, + #[error("Target creation failed: {0}")] + TargetCreationFailed(#[source] std::io::Error), +} + +#[derive(thiserror::Error, Debug)] +pub enum ZyppServerError { + #[error("Response channel closed")] + ResponseChannelClosed, + + #[error("Receiver error: {0}")] + RecvError(#[from] oneshot::error::RecvError), + + #[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), +} + +pub type ZyppServerResult = Result; + +#[derive(Debug)] +pub enum SoftwareAction { + AddRepositories(Vec, oneshot::Sender>), + Install(oneshot::Sender>), + Finish, + GetPatternsMetadata(Vec, oneshot::Sender>>), + PackageAvailable(String, oneshot::Sender>), + PackageSelected(String, oneshot::Sender>), + Solve(oneshot::Sender>), + SetResolvables { + tx: oneshot::Sender>, + resolvables: Vec, + r#type: ResolvableType, + optional: bool, + }, + UnsetResolvables { + tx: oneshot::Sender>, + resolvables: Vec, + r#type: ResolvableType, + optional: bool, + }, +} + +/// Software service server. +pub struct ZyppServer { + receiver: mpsc::UnboundedReceiver, +} + +impl ZyppServer { + /// Starts the software service loop and returns a client. + /// + /// The service runs on a separate thread and gets the client requests using a channel. + pub fn start() -> ZyppServerResult> { + let (sender, receiver) = mpsc::unbounded_channel(); + + let server = Self { receiver }; + + // see https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#use-inside-tokiospawn for explain how to ensure that zypp + // runs locally on single thread + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + // drop the returned JoinHandle: the thread will be detached + // but that's OK for it to run until the process dies + std::thread::spawn(move || { + let local = tokio::task::LocalSet::new(); + + local.spawn_local(server.run()); + + // This will return once all senders are dropped and all + // spawned tasks have returned. + rt.block_on(local); + }); + Ok(sender) + } + + /// Runs the server dispatching the actions received through the input channel. + async fn run(mut self) -> Result<(), ZyppDispatchError> { + let zypp = self.initialize_target_dir()?; + + loop { + let action = self.receiver.recv().await; + tracing::debug!("software dispatching action: {:?}", action); + let Some(action) = action else { + tracing::error!("Software action channel closed"); + break; + }; + + if let Err(error) = self.dispatch(action, &zypp).await { + tracing::error!("Software dispatch error: {:?}", error); + } + } + + Ok(()) + } + + /// Forwards the action to the appropriate handler. + async fn dispatch( + &mut self, + action: SoftwareAction, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + match action { + SoftwareAction::AddRepositories(repos, tx) => { + self.add_repositories(repos, tx, zypp).await?; + } + + SoftwareAction::GetPatternsMetadata(names, tx) => { + self.get_patterns(names, tx, zypp).await?; + } + + SoftwareAction::PackageSelected(tag, tx) => { + self.package_selected(zypp, tag, tx).await?; + } + + SoftwareAction::PackageAvailable(tag, tx) => { + self.package_available(zypp, tag, tx).await?; + } + + SoftwareAction::Install(tx) => { + tx.send(self.install(zypp)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + + SoftwareAction::Finish => { + //self.finish(zypp).await?; + } + + SoftwareAction::SetResolvables { + tx, + r#type, + resolvables, + optional, + } => { + // TODO: support optional with check if resolvable is available + for res in resolvables { + let result = zypp.select_resolvable( + &res, + r#type.into(), + zypp_agama::ResolvableSelected::Installation, + ); + if let Err(e) = result { + tx.send(Err(e)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + break; + } + } + } + + SoftwareAction::UnsetResolvables { + tx, + r#type, + resolvables, + optional, + } => { + // TODO: support optional with check if resolvable is available + for res in resolvables { + let result = zypp.unselect_resolvable( + &res, + r#type.into(), + zypp_agama::ResolvableSelected::Installation, + ); + if let Err(e) = result { + tx.send(Err(e)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + break; + } + } + } + + SoftwareAction::Solve(tx) => { + let res = zypp.run_solver(); + tx.send(res) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + } + Ok(()) + } + + // Install rpms + fn install(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult { + let target = "/mnt"; + zypp.switch_target(target)?; + let result = zypp.commit()?; + tracing::info!("libzypp commit ends with {}", result); + Ok(result) + } + + async fn add_repositories( + &self, + repos: Vec, + tx: oneshot::Sender>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + for (idx, repo) in repos.iter().enumerate() { + // TODO: we should add a repository ID in the configuration file. + let name = format!("agama-{}", idx); + let res = zypp + .add_repository(&name, &repo.url, |percent, alias| { + tracing::info!("Adding repository {} ({}%)", alias, percent); + true + }) + .map_err(ZyppServerError::AddRepositoryFailed); + if res.is_err() { + tx.send(res) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + } + + let res = zypp + .load_source(|percent, alias| { + tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); + true + }) + .map_err(ZyppServerError::LoadSourcesFailed); + if res.is_err() { + tx.send(res) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + + Ok(()) + } + + async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + self.remove_dud_repo(zypp)?; + self.disable_local_repos(zypp)?; + self.registration_finish()?; + self.modify_zypp_conf()?; + self.modify_full_repo(zypp)?; + Ok(()) + } + + fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos + .iter() + .filter(|r| r.url.starts_with("dvd:/install?devices=")); + for r in repos { + zypp.set_repository_url(&r.alias, "dvd:/install")?; + } + Ok(()) + } + + fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + const DUD_NAME: &str = "AgamaDriverUpdate"; + let repos = zypp.list_repositories()?; + let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); + if let Some(repo) = repo { + zypp.remove_repository(&repo.alias, |_, _| true)?; + } + Ok(()) + } + + fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { + let repos = zypp.list_repositories()?; + // if url is invalid, then do not disable it and do not touch it + let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false)); + for r in repos { + zypp.disable_repository(&r.alias)?; + } + Ok(()) + } + + fn registration_finish(&self) -> ZyppServerResult<()> { + // TODO: implement when registration is ready + Ok(()) + } + + fn modify_zypp_conf(&self) -> ZyppServerResult<()> { + // TODO: implement when requireOnly is implemented + Ok(()) + } + + async fn package_available( + &self, + zypp: &zypp_agama::Zypp, + tag: String, + tx: oneshot::Sender>, + ) -> Result<(), ZyppDispatchError> { + let result = zypp.is_package_available(&tag); + tx.send(result) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + Ok(()) + } + + async fn package_selected( + &self, + zypp: &zypp_agama::Zypp, + tag: String, + tx: oneshot::Sender>, + ) -> Result<(), ZyppDispatchError> { + let result = zypp.is_package_selected(&tag); + tx.send(result) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + Ok(()) + } + + async fn get_patterns( + &self, + names: Vec, + 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(); + + tx.send(Ok(patterns)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } + } + + Ok(()) + } + + fn initialize_target_dir(&self) -> Result { + let target_dir = Path::new(TARGET_DIR); + if target_dir.exists() { + _ = std::fs::remove_dir_all(target_dir); + } + + std::fs::create_dir_all(target_dir).map_err(ZyppDispatchError::TargetCreationFailed)?; + + let zypp = zypp_agama::Zypp::init_target(TARGET_DIR, |text, step, total| { + tracing::info!("Initializing target: {} ({}/{})", text, step, total); + })?; + + self.import_gpg_keys(&zypp); + Ok(zypp) + } + + fn import_gpg_keys(&self, zypp: &zypp_agama::Zypp) { + for file in glob::glob(GPG_KEYS).unwrap() { + match file { + Ok(file) => { + if let Err(e) = zypp.import_gpg_key(&file.to_string_lossy()) { + tracing::error!("Failed to import GPG key: {}", e); + } + } + Err(e) => { + tracing::error!("Could not read GPG key file: {}", e); + } + } + } + } +} diff --git a/rust/agama-software/test/share/products.d/kalpa.yaml b/rust/agama-software/test/share/products.d/kalpa.yaml new file mode 100644 index 0000000000..0298a97754 --- /dev/null +++ b/rust/agama-software/test/share/products.d/kalpa.yaml @@ -0,0 +1,100 @@ +id: Kalpa +name: Kalpa Desktop +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "A rolling release immutable desktop product, using the Plasma + Desktop, leveraging Flatpak for Application Delivery, a Read-Only base, and + automatic and atomic updates of your system" +icon: Kalpa.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + # device labels for offline installation media + installation_labels: + - label: Kalpa-desktop-DVD-x86_64 + archs: x86_64 + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_kde_desktop + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: Kalpa + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-software/test/share/products.d/leap_160.yaml b/rust/agama-software/test/share/products.d/leap_160.yaml new file mode 100644 index 0000000000..2a627eb6b0 --- /dev/null +++ b/rust/agama-software/test/share/products.d/leap_160.yaml @@ -0,0 +1,178 @@ +id: Leap_16.0 +name: Leap 16.0 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'The latest version of a community distribution based on the latest + SUSE Linux Enterprise Server.' +# Do not manually change any translations! See README.md for more details. +icon: Leap16.svg +translations: + description: + ca: La darrera versió d'una distribució comunitària basada en l'últim SUSE Linux + Enterprise Server. + cs: Nejnovější verze komunitní distribuce založené na nejnovějším SUSE Linux + Enterprise Serveru. + de: Die neueste Version einer Community-Distribution, die auf dem aktuellen SUSE + Linux Enterprise Server basiert. + es: La última versión de una distribución comunitaria basada en el último SUSE + Linux Enterprise Server. + ja: 最新のSUSE Linux Enterprise Server をベースにした、コミュニティディストリビューションの最新版です。 + nb_NO: Leap 16.0 er den nyeste versjonen av den fellesskapte distribusjon basert + på den nyeste SUSE Linux Enterprise Server. + pt_BR: A versão mais recente de uma distribuição comunitária baseada no mais + recente SUSE Linux Enterprise Server. + ru: Leap 16.0 - это последняя версия дистрибутива от сообщества, основанного на + последней версии SUSE Linux Enterprise Server. + sv: Den senaste versionen av en gemenskapsdistribution baserad på den senaste + SUSE Linux Enterprise Server. + tr: En son SUSE Linux Enterprise Server'ı temel alan bir topluluk dağıtımının en + son sürümü. + zh_Hans: Leap 16.0 是基于 SUSE Linux Enterprise Server 构建的社区发行版的最新版本。 +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap/16.0/repo/oss/$basearch + installation_labels: + - label: Leap-DVD-x86_64 + archs: x86_64 + - label: Leap-DVD-aarch64 + archs: aarch64 + - label: Leap-DVD-s390x + archs: s390 + - label: Leap-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on Leap + optional_patterns: null # no optional pattern shared + user_patterns: + - gnome + - kde + - xfce_wayland + - multimedia + - office + - cockpit + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - printing + mandatory_packages: + - NetworkManager + - openSUSE-repos-Leap + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: Leap + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-software/test/share/products.d/leap_micro_62.yaml b/rust/agama-software/test/share/products.d/leap_micro_62.yaml new file mode 100644 index 0000000000..e38012b577 --- /dev/null +++ b/rust/agama-software/test/share/products.d/leap_micro_62.yaml @@ -0,0 +1,111 @@ +id: LeapMicro_6.2 +name: openSUSE Leap Micro 6.2 Beta +archs: x86_64,aarch64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'Leap Micro is an ultra-reliable, lightweight operating system + built for containerized and virtualized workloads.' +icon: LeapMicro.svg +software: + installation_repositories: + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-x86_64 + archs: x86_64 + - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-aarch64 + archs: aarch64 + # device labels for offline installation media + installation_labels: + - label: openSUSE-Leap-Micro-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Leap-Micro-DVD-aarch64 + archs: aarch64 + + mandatory_patterns: + - cockpit + - base + - transactional + - traditional + - hardware + - selinux + + optional_patterns: null + + user_patterns: + - cloud + - container_runtime + - fips + - ima_evm + - kvm_host + - ra_agent + - ra_verifier + - salt_minion + - sssd_ldap + + mandatory_packages: + - NetworkManager + - openSUSE-repos-LeapMicro + optional_packages: null + base_product: Leap-Micro + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/x86_64-efi + archs: x86_64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-software/test/share/products.d/microos.yaml b/rust/agama-software/test/share/products.d/microos.yaml new file mode 100644 index 0000000000..ac8bbc7c48 --- /dev/null +++ b/rust/agama-software/test/share/products.d/microos.yaml @@ -0,0 +1,198 @@ +id: MicroOS +name: openSUSE MicroOS +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A quick, small distribution designed to host container workloads + with automated administration & patching. openSUSE MicroOS provides + transactional (atomic) updates upon a read-only btrfs root file system. As + rolling release distribution the software is always up-to-date.' +icon: MicroOS.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una distribució ràpida i petita dissenyada per allotjar càrregues de treball + de contenidors amb administració i pedaços automatitzats. L'openSUSE + MicroSO proporciona actualitzacions transaccionals (atòmiques) en un + sistema de fitxers d'arrel btrfs només de lectura. Com a distribució + contínua, el programari està sempre actualitzat. + cs: Rychlá, malá distribuce určená pro úlohy hostitelského kontejneru s + automatizovanou správou a záplatováním. openSUSE MicroOS poskytuje + transakční (atomické) aktualizace na kořenovém souborovém systému btrfs + určeném pouze pro čtení. Jako distribuce s průběžným vydáváním je software + vždy aktuální. + de: Eine schnelle, kleine Distribution, die für den Betrieb von + Container-Arbeitslasten mit automatischer Verwaltung und automatisiertem + Patching entwickelt wurde. openSUSE MicroOS bietet transaktionale + (atomare) Aktualisierungen auf einem schreibgeschützten + btrfs-Wurzeldateisystem. Als Distribution mit rollierenden + Veröffentlichungen ist die Software immer auf dem neuesten Stand. + es: Una distribución pequeña y rápida diseñada para alojar cargas de trabajo de + contenedores con administración y parches automatizados. openSUSE MicroOS + proporciona actualizaciones transaccionales (atómicas) en un sistema de + archivos raíz btrfs de solo lectura. Como distribución de actualización + continua, el software siempre está actualizado. + fr: Une petite distribution rapide conçue pour héberger des charges de travail + de conteneurs avec une administration et des correctifs automatisés. + openSUSE MicroOS fournit des mises à jour transactionnelles (atomiques) + sur un système de fichiers racine btrfs en lecture seule. En tant que + distribution continue, le logiciel est toujours à jour. + id: Distribusi cepat dan ramping yang dirancang untuk menampung beban kerja + kontainer dengan administrasi & penambalan otomatis. openSUSE MicroOS + menyediakan pembaruan transaksional (atomik) pada sistem berkas root btrfs + yang hanya dapat dibaca. Sebagai distribusi rilis bergulir, perangkat + lunak didalamnya selalu diperbarui. + ja: 高速で小型のディストリビューションで、管理やパッチ適用の自動化のようなコンテナ処理を賄うのに最適な仕組みです。 openSUSE MicroOS + はトランザクション型の (不可分の) 更新機構が提供されており、 btrfs + のルートファイルシステムを読み込み専用にすることができます。こちらもローリングリリース型のディストリビューションであるため、常に最新を維持することができます。 + nb_NO: En rask, liten distribusjon laget for å være vert til container + arbeidsoppgaver med automatisk administrasjon & lapping. openSUSE MicroOS + gir transaksjonelle (atomisk) oppdateringer oppå en skrivebeskyttet btrfs + rotfilsystem. Som rullerende distribusjon er programvaren alltid + oppdatert. + pt_BR: Uma distribuição pequena e rápida projetada para hospedar cargas de + trabalho de contêiner com administração e aplicação de patches + automatizadas. O openSUSE MicroOS fornece atualizações transacionais + (atômicas) em um sistema de arquivos raiz btrfs somente leitura. Como + distribuição contínua, o software está sempre atualizado. + ru: Быстрый, минималистичный дистрибутив, предназначенный для размещения + контейнерных рабочих нагрузок с автоматизированным администрированием и + исправлениями. openSUSE MicroOS обеспечивает транзакционные (атомарные) + обновления на корневой файловой системе btrfs, доступной только для + чтения. Так как дистрибутив использует плавающий выпуск обновлений, + программное обеспечение всегда актуально. + sv: En snabb, liten distribution utformad för att vara värd för + arbetsbelastningar i behållare med automatiserad administration och + patchning. openSUSE MicroOS tillhandahåller transaktionella (atomära) + uppdateringar på ett skrivskyddat btrfs-rootfilsystem. Som rullande + releasedistribution är mjukvaran alltid uppdaterad. + tr: Otomatik yönetim ve yama uygulamayla konteyner iş yüklerini barındırmak için + tasarlanmış hızlı, küçük bir dağıtım. openSUSE MicroOS, salt okunur bir + btrfs kök dosya sistemi üzerinde işlemsel (atomik) güncellemeler sağlar. + Sürekli sürüm dağıtımı olarak yazılım her zaman günceldir. + zh_Hans: 一个快速、小型的发行版,旨在通过自动化管理和修补来托管容器工作负载。openSUSE MicroOS 提供基于只读 Btrfs + 根文件系统之上的事务性(原子)更新。作为滚动发行版,它的软件始终保持最新。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + # aarch64 does not have non-oss ports. Keep eye if it change + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-MicroOS-DVD-x86_64 + archs: x86_64 + - label: openSUSE-MicroOS-DVD-aarch64 + archs: aarch64 + - label: openSUSE-MicroOS-DVD-s390x + archs: s390 + - label: openSUSE-MicroOS-DVD-ppc64le + archs: ppc + mandatory_patterns: + - microos_base + - microos_base_zypper + - microos_defaults + - microos_hardware + - microos_selinux + optional_patterns: null + user_patterns: + - container_runtime + - microos_ra_agent + - microos_ra_verifier + mandatory_packages: + - NetworkManager + - openSUSE-repos-MicroOS + optional_packages: null + base_product: MicroOS + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - microos_selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "/var" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: true + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + - path: boot/writable + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + snapshots_configurable: false + filesystems: + - btrfs + auto_size: + base_min: 5 GiB + base_max: 25 GiB + max_fallback_for: + - "/var" + - mount_path: "/var" + filesystem: btrfs + mount_options: + - "x-initrd.mount" + - "nodatacow" + size: + auto: false + min: 5 GiB + outline: + required: false + filesystems: + - btrfs diff --git a/rust/agama-software/test/share/products.d/sles_160.yaml b/rust/agama-software/test/share/products.d/sles_160.yaml new file mode 100644 index 0000000000..8e018535b7 --- /dev/null +++ b/rust/agama-software/test/share/products.d/sles_160.yaml @@ -0,0 +1,200 @@ +id: SLES +name: SUSE Linux Enterprise Server 16.0 +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "An open, reliable, compliant, and future-proof Linux Server choice + that ensures the enterprise's business continuity. It is the secure and + adaptable OS for long-term supported, innovation-ready infrastructure running + business-critical workloads on-premises, in the cloud, and at the edge." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una opció de servidor de Linux oberta, fiable, compatible i a prova del + futur que garanteix la continuïtat del negoci de l'empresa. És el sistema + operatiu segur i adaptable per a una infraestructura amb suport a llarg + termini i preparada per a la innovació que executa càrregues de treball + crítiques per a l'empresa a les instal·lacions, al núvol i a l'última. + cs: Otevřená, spolehlivá, kompatibilní a perspektivní volba linuxového serveru, + která zajišťuje kontinuitu podnikání podniku. Je to bezpečný a + přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu + připravenou na inovace, na které běží kritické podnikové úlohy v lokálním + prostředí, v cloudu i na okraji sítě. + de: Ein offener, zuverlässiger, kompatibler und zukunftssicherer Linux-Server, + der die Geschäftskontinuität des Unternehmens gewährleistet. Es ist das + sichere und anpassungsfähige Betriebssystem für eine langfristig + unterstützte, innovationsbereite Infrastruktur, auf der geschäftskritische + Arbeitslasten vor Ort, in der Cloud und am Netzwerkrand ausgeführt werden. + es: Una opción de servidor Linux abierta, confiable, compatible y preparada para + el futuro que garantiza la continuidad del negocio de la empresa. Es el + sistema operativo seguro y adaptable para una infraestructura lista para + la innovación y con soporte a largo plazo que ejecuta cargas de trabajo + críticas para el negocio en las instalaciones, en la nube y en el borde. + ja: オープンで信頼性が高く、各種の標準にも準拠し、将来性とビジネスの継続性を支援する Linux + サーバです。長期のサポートが提供されていることから、安全性と順応性に優れ、オンプレミスからクラウド、エッジ環境に至るまで、様々な場所で重要なビジネス処理をこなすことのできる革新性の高いインフラストラクチャです。 + pt_BR: Uma escolha de servidor Linux aberta, confiável, compatível e à prova do + futuro que garante a continuidade dos negócios da empresa. É o SO seguro e + adaptável para infraestrutura com suporte de longo prazo e pronta para + inovação, executando cargas de trabalho críticas para os negócios no + local, na nuvem e na borda. + sv: Ett öppet, pålitligt, kompatibelt och framtidssäkert Linux-serverval som + säkerställer företagets affärskontinuitet. Det är det säkra och + anpassningsbara operativsystemet för långsiktigt stödd, innovationsfärdig + infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet + och vid kanten. + tr: İşletmenin iş sürekliliğini garanti eden açık, güvenilir, uyumlu ve geleceğe + dönük bir Linux Sunucu seçeneği. Uzun vadeli desteklenen, inovasyona hazır + altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, + bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. +software: + installation_repositories: [] + installation_labels: + - label: SLES160-x86_64 + archs: x86_64 + - label: SLES160-arch64 + archs: aarch64 + - label: SLES160-s390x + archs: s390 + - label: SLES160-ppc64 + archs: ppc + + mandatory_patterns: + - enhanced_base + - bootloader + optional_patterns: null # no optional pattern shared + user_patterns: + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-software/test/share/products.d/sles_sap_160.yaml b/rust/agama-software/test/share/products.d/sles_sap_160.yaml new file mode 100644 index 0000000000..a11ff9bc9a --- /dev/null +++ b/rust/agama-software/test/share/products.d/sles_sap_160.yaml @@ -0,0 +1,174 @@ +id: SLES_SAP +name: SUSE Linux Enterprise Server for SAP applications 16.0 +archs: x86_64,ppc +registration: true +version: "16.0" +license: "license.final" +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: "The leading OS for a secure and reliable SAP platform. + Endorsed for SAP deployments, SUSE Linux Enterprise Server for SAP applications + futureproofs the SAP project, offers uninterrupted business, and minimizes + operational risks and costs." +icon: SUSE.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: +software: + installation_repositories: [] + installation_labels: + - label: S4SAP160-x86_64 + archs: x86_64 + - label: S4SAP160-ppc64 + archs: ppc + + mandatory_patterns: + - base + - enhanced_base + - bootloader + - sles_sap_base_sap_server + optional_patterns: null # no optional pattern shared + user_patterns: + # First all patterns from file sles_160.yaml + - cockpit + - sles_sap_minimal_sap + - fips + - name: selinux + selected: true + - documentation + - sw_management + - container_runtime_docker + - container_runtime_podman + - dhcp_dns_server + - directory_server + - file_server + - gateway_server + - kvm_server + - kvm_tools + - lamp_server + - mail_server + - gnome + - gnome_internet + - devel_basis + - devel_kernel + - oracle_server + - print_server + # Second, all patterns for SAP only + - sles_sap_DB + - sles_sap_HADB + - sles_sap_APP + - sles_sap_HAAPP + - sles_sap_trento_server + - sles_sap_trento_agent + - sles_sap_automation + - sles_sap_monitoring + - sles_sap_gui + mandatory_packages: + - NetworkManager + # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier + - NetworkManager-config-server + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: SLES_SAP + +security: + lsm: selinux + available_lsms: + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 150% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-software/test/share/products.d/slowroll.yaml b/rust/agama-software/test/share/products.d/slowroll.yaml new file mode 100644 index 0000000000..9ff192fd4a --- /dev/null +++ b/rust/agama-software/test/share/products.d/slowroll.yaml @@ -0,0 +1,169 @@ +id: Slowroll +name: Slowroll +archs: x86_64 +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'An experimental and slightly slower rolling release of openSUSE + designed to update less often than Tumbleweed but more often than Leap without + forcing users to choose between "stable" and newer packages.' +icon: Slowroll.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió experimental d'openSUSE però lleugerament més lenta quant a la + continuïtat, dissenyada per actualitzar-se amb menys freqüència que el + Tumbleweed però més sovint que el Leap, sense obligar els usuaris a triar + entre paquets estables i nous. + cs: Experimentální a mírně zpomalené rolující vydání openSUSE, které je navržené + tak, aby se aktualizovalo méně často než Tumbleweed. Zároveň se však + aktualizuje častěji než Leap, aby se uživatelé nemuseli rozhodovat mezi + "stabilními" a novějšími balíčky. + de: Ein experimentelles und etwas langsameres Rolling Release von openSUSE, das + darauf ausgelegt ist, weniger häufig als Tumbleweed, aber häufiger als + Leap zu aktualisieren, ohne die Benutzer zu zwingen, zwischen „stabilen“ + und neueren Paketen zu wählen. + es: Una versión experimental y de actualización contínua ligeramente más lenta + de openSUSE, diseñada para actualizarse con menos frecuencia que + Tumbleweed pero más a menudo que Leap, sin obligar a los usuarios a elegir + entre paquetes "estables" y más nuevos. + ja: 実験的なディストリビューションではありますが、 Tumbleweed よりは比較的ゆっくりした、かつ Leap よりは速いペースで公開される + openSUSE ローリングリリース型ディストリビューションです。 "安定性" と最新パッケージの中間を目指しています。 + pt_BR: Uma versão experimental e um pouco mais lenta do openSUSE, projetada para + atualizar com menos frequência que o Tumbleweed, mas com mais frequência + que o Leap, sem forçar os usuários a escolher entre pacotes "estáveis" e + mais novos. + sv: En experimentell och något långsammare rullande utgåva av openSUSE utformad + för att få nya paketuppdateringar mer sällan än Tumbleweed men oftare än + Leap utan att tvinga användarna att välja mellan "stabila" eller nyare + paket. +software: + installation_repositories: + - url: https://download.opensuse.org/slowroll/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/slowroll/repo/non-oss/ + archs: x86_64 + + mandatory_patterns: + - enhanced_base + optional_patterns: null + user_patterns: + - basic-desktop + - gnome + - kde + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + mandatory_packages: + - NetworkManager + - openSUSE-repos-Slowroll + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: apparmor + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "0" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat diff --git a/rust/agama-software/test/share/products.d/tumbleweed.yaml b/rust/agama-software/test/share/products.d/tumbleweed.yaml new file mode 100644 index 0000000000..561ff8aea7 --- /dev/null +++ b/rust/agama-software/test/share/products.d/tumbleweed.yaml @@ -0,0 +1,224 @@ +id: Tumbleweed +name: openSUSE Tumbleweed +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A pure rolling release version of openSUSE containing the latest + "stable" versions of all software instead of relying on rigid periodic release + cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió de llançament continuada d'openSUSE que conté les darreres + versions estables de tot el programari en lloc de dependre de cicles de + llançament periòdics rígids. El projecte fa això per als usuaris que volen + el programari estable més nou. + cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze + veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. + Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. + fr: La distribution Tumbleweed est une pure "rolling release" (publication + continue) d'openSUSE contenant les dernières versions "stables" de tous + les logiciels au lieu de se baser sur des cycles de publication + périodiques et fixes. Le projet fait cela pour les utilisateurs qui + veulent les logiciels stables les plus récents. + id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE + yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak + bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk + memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil + terbaru. + ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 + nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av + openSUSE som inneholder de siste "stabile" versjonene av all programvare i + stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet + gjør dette for brukere som vil ha de nyeste stabile programvarene. + pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas + versões "estáveis" de todos os softwares em vez de depender de ciclos de + lançamento periódicos rígidos. O projeto faz isso para usuários que querem + o software estável mais novo. + ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние + "стабильные" версии всего программного обеспечения, вместо того чтобы + полагаться на жесткие периодические циклы выпуска. Проект делает его для + пользователей, которым нужно самое новое стабильное программное + обеспечение. + sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" + versionerna av all programvara istället för att förlita sig på stela + periodiska släppcykler. Projektet gör detta för användare som vill ha den + senaste stabila mjukvaran. + tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son + "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje + bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. + zh_Hans: Tumbleweed 发行版是 openSUSE + 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-Tumbleweed-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Tumbleweed-DVD-aarch64 + archs: aarch64 + - label: openSUSE-Tumbleweed-DVD-s390x + archs: s390 + - label: openSUSE-Tumbleweed-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on TW + optional_patterns: null # no optional pattern shared + user_patterns: + - basic_desktop + - xfce + - kde + - gnome + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + - name: selinux + selected: true + - apparmor + mandatory_packages: + - NetworkManager + - openSUSE-repos-Tumbleweed + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat From f9d6add6c82c5f60ba747eb3586f56a6f411fe82 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 20 Oct 2025 11:26:52 +0200 Subject: [PATCH 210/917] add repositories list --- rust/agama-software/src/model.rs | 45 ++++++++++--------- .../src/model/software_selection.rs | 30 ++++++------- rust/agama-software/src/system_info.rs | 2 +- rust/agama-software/src/zypp_server.rs | 28 +++++++++++- 4 files changed, 66 insertions(+), 39 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index f6060c5430..8adcb61cae 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -58,7 +58,7 @@ pub trait ModelAdapter: Send + Sync + 'static { fn products(&self) -> Vec; /// List of available repositories. - fn repositories(&self) -> Result, service::Error>; + async fn repositories(&self) -> Result, service::Error>; /// List of available licenses. fn licenses(&self) -> Result, service::Error>; @@ -67,7 +67,7 @@ pub trait ModelAdapter: Send + Sync + 'static { fn addons(&self) -> Result, service::Error>; /// selected product - fn selected_product(&self) -> Result, service::Error>; + fn selected_product(&self) -> Option; /// info about registration fn registration_info(&self) -> Result; @@ -82,12 +82,7 @@ pub trait ModelAdapter: Send + Sync + 'static { async fn is_package_selected(&self, tag: String) -> Result; /// Gets resolvables set for given combination of id, type and optional flag - fn get_resolvables( - &self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> Vec; + fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec; /// Sets resolvables set for given combination of id, type and optional flag async fn set_resolvables( @@ -178,23 +173,22 @@ impl ModelAdapter for Model { async fn is_package_available(&self, tag: String) -> Result { let (tx, rx) = oneshot::channel(); - self.zypp_sender.send(SoftwareAction::PackageAvailable(tag, tx))?; + self.zypp_sender + .send(SoftwareAction::PackageAvailable(tag, tx))?; Ok(rx.await??) } async fn is_package_selected(&self, tag: String) -> Result { let (tx, rx) = oneshot::channel(); - self.zypp_sender.send(SoftwareAction::PackageSelected(tag, tx))?; + self.zypp_sender + .send(SoftwareAction::PackageSelected(tag, tx))?; Ok(rx.await??) } - fn get_resolvables( - &self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> Vec { - self.software_selection.get(id, r#type, optional).unwrap_or_default() + fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec { + self.software_selection + .get(id, r#type, optional) + .unwrap_or_default() } async fn set_resolvables( @@ -204,7 +198,9 @@ impl ModelAdapter for Model { resolvables: &[&str], optional: bool, ) -> Result<(), service::Error> { - self.software_selection.set(&self.zypp_sender, id, r#type, optional, resolvables).await?; + self.software_selection + .set(&self.zypp_sender, id, r#type, optional, resolvables) + .await?; Ok(()) } @@ -312,8 +308,13 @@ impl ModelAdapter for Model { Ok(rx.await??) } - fn repositories(&self) -> Result, service::Error> { - todo!() + // FIXME: do we want to store here only user specified repos or also ones e.g. get from registration server? + // now we query libzypp to get all of them + async fn repositories(&self) -> Result, service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::ListRepositories(tx))?; + Ok(rx.await??) } fn licenses(&self) -> Result, service::Error> { @@ -324,8 +325,8 @@ impl ModelAdapter for Model { todo!() } - fn selected_product(&self) -> Result, service::Error> { - todo!() + fn selected_product(&self) -> Option { + self.selected_product.clone().map(|p| p.id) } fn registration_info(&self) -> Result { diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs index 358f60cdea..08a7e7f3c4 100644 --- a/rust/agama-software/src/model/software_selection.rs +++ b/rust/agama-software/src/model/software_selection.rs @@ -56,25 +56,25 @@ impl SoftwareSelection { ) -> Result<(), service::Error> { let list = self.find_or_create_selection(id, r#type, optional); // FIXME: use reference counting here, if multiple ids require some package, to not unselect it - let (tx, rx) = oneshot::channel(); - zypp.send(SoftwareAction::UnsetResolvables { - tx, - resolvables: list.resolvables.clone(), - r#type: r#type.into(), - optional - })?; - rx.await??; + let (tx, rx) = oneshot::channel(); + zypp.send(SoftwareAction::UnsetResolvables { + tx, + resolvables: list.resolvables.clone(), + r#type: r#type.into(), + optional, + })?; + rx.await??; let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); list.resolvables = new_resolvables; let (tx, rx) = oneshot::channel(); - zypp.send(SoftwareAction::UnsetResolvables { - tx, - resolvables: list.resolvables.clone(), - r#type: r#type.into(), - optional - })?; - rx.await??; + zypp.send(SoftwareAction::UnsetResolvables { + tx, + resolvables: list.resolvables.clone(), + r#type: r#type.into(), + optional, + })?; + rx.await??; Ok(()) } diff --git a/rust/agama-software/src/system_info.rs b/rust/agama-software/src/system_info.rs index 31ec6355c7..f3ed5148d2 100644 --- a/rust/agama-software/src/system_info.rs +++ b/rust/agama-software/src/system_info.rs @@ -47,7 +47,7 @@ impl SystemInfo { /// Reads the information from the system adapter. pub async fn read_from(model: &dyn ModelAdapter) -> Result { let patterns = model.patterns().await?; - let repositories = model.repositories()?; + let repositories = model.repositories().await?; let products = model.products(); let licenses = model.licenses()?; let addons = model.addons()?; diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 147a5f8cb2..d9b4ed769f 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -26,7 +26,7 @@ use tokio::sync::{ use zypp_agama::ZyppError; use crate::model::{ - packages::{ResolvableType, SoftwareConfig}, + packages::{Repository, ResolvableType, SoftwareConfig}, pattern::{self, Pattern}, product::Product, products::{ProductSpec, RepositorySpec}, @@ -86,6 +86,7 @@ pub enum SoftwareAction { AddRepositories(Vec, oneshot::Sender>), Install(oneshot::Sender>), Finish, + ListRepositories(oneshot::Sender>>), GetPatternsMetadata(Vec, oneshot::Sender>>), PackageAvailable(String, oneshot::Sender>), PackageSelected(String, oneshot::Sender>), @@ -239,6 +240,31 @@ impl ZyppServer { tx.send(res) .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; } + + SoftwareAction::ListRepositories(tx) => { + let repos_res = zypp.list_repositories(); + let result = repos_res + .map(|repos| { + repos + .into_iter() + .enumerate() + .map(|(index, repo)| Repository { + url: repo.url, + // unwrap here is ok, as number of repos are low + id: index.try_into().unwrap(), // TODO: remove it when not needed, DBus relict, alias should be always unique + alias: repo.alias, + name: repo.user_name, + product_dir: "/".to_string(), // TODO: get it from zypp + enabled: repo.enabled, + loaded: true, + }) + .collect() + }) + .map_err(|e| e.into()); + + tx.send(result) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + } } Ok(()) } From e4f3cb2f15f673ebfe8f37432fe6d9a7828aa7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 16:08:38 +0100 Subject: [PATCH 211/917] Move supervisor to agama-manager project --- rust/Cargo.lock | 22 +++++++++++++++++ rust/Cargo.toml | 1 + rust/agama-lib/src/http.rs | 2 +- rust/agama-lib/src/http/event.rs | 4 ++++ rust/agama-manager/Cargo.toml | 24 +++++++++++++++++++ .../src/lib.rs} | 0 .../src}/listener.rs | 15 ++++++------ .../src}/message.rs | 5 ++-- .../src}/proposal.rs | 2 +- .../supervisor => agama-manager/src}/scope.rs | 2 +- .../src}/service.rs | 2 +- .../supervisor => agama-manager/src}/start.rs | 16 ++++++------- .../src}/system_info.rs | 2 +- rust/agama-server/src/lib.rs | 1 - 14 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 rust/agama-manager/Cargo.toml rename rust/{agama-server/src/supervisor.rs => agama-manager/src/lib.rs} (100%) rename rust/{agama-server/src/supervisor => agama-manager/src}/listener.rs (84%) rename rust/{agama-server/src/supervisor => agama-manager/src}/message.rs (99%) rename rust/{agama-server/src/supervisor => agama-manager/src}/proposal.rs (97%) rename rust/{agama-server/src/supervisor => agama-manager/src}/scope.rs (97%) rename rust/{agama-server/src/supervisor => agama-manager/src}/service.rs (99%) rename rust/{agama-server/src/supervisor => agama-manager/src}/start.rs (94%) rename rust/{agama-server/src/supervisor => agama-manager/src}/system_info.rs (97%) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e267abedd4..99d04c95e8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -130,6 +130,27 @@ dependencies = [ "utoipa", ] +[[package]] +name = "agama-manager" +version = "0.1.0" +dependencies = [ + "agama-l10n", + "agama-lib", + "agama-utils", + "async-trait", + "merge-struct", + "serde", + "serde_json", + "strum", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "utoipa", + "zbus", +] + [[package]] name = "agama-network" version = "0.1.0" @@ -162,6 +183,7 @@ dependencies = [ "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", "agama-utils", "anyhow", "async-trait", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e963246f3a..11b1575b45 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agama-l10n", "agama-lib", "agama-locale-data", + "agama-manager", "agama-network", "agama-server", "agama-utils", diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs index b4ea8cbb2b..37b5a23721 100644 --- a/rust/agama-lib/src/http.rs +++ b/rust/agama-lib/src/http.rs @@ -21,7 +21,7 @@ mod base_http_client; pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; -mod event; +pub mod event; pub use event::{Event, EventPayload}; mod websocket; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 72df6b2f93..b605799aba 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -38,6 +38,10 @@ use agama_l10n as l10n; use agama_utils::{issue, progress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use tokio::sync::broadcast; + +pub type Sender = broadcast::Sender; +pub type Receiver = broadcast::Receiver; /// Agama event. /// diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml new file mode 100644 index 0000000000..4fec4ce553 --- /dev/null +++ b/rust/agama-manager/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agama-manager" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } +agama-l10n = { path = "../agama-l10n" } +thiserror = "2.0.12" +serde = { version = "1.0.210", features = ["derive"] } +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" +async-trait = "0.1.83" +serde_json = "1.0.128" +utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } +strum = { version = "0.27.2", features = ["derive"] } +tracing = "0.1.40" +zbus = { version = "5", default-features = false, features = ["tokio"] } +merge-struct = "0.1.0" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/rust/agama-server/src/supervisor.rs b/rust/agama-manager/src/lib.rs similarity index 100% rename from rust/agama-server/src/supervisor.rs rename to rust/agama-manager/src/lib.rs diff --git a/rust/agama-server/src/supervisor/listener.rs b/rust/agama-manager/src/listener.rs similarity index 84% rename from rust/agama-server/src/supervisor/listener.rs rename to rust/agama-manager/src/listener.rs index fbdbac88de..4615562e3f 100644 --- a/rust/agama-server/src/supervisor/listener.rs +++ b/rust/agama-manager/src/listener.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::web::EventsSender; -use agama_lib::http::{Event, EventPayload}; +use agama_lib::http; use std::pin::Pin; use tokio::sync::mpsc; use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamMap}; @@ -30,12 +29,12 @@ use tokio_stream::{wrappers::UnboundedReceiverStream, Stream, StreamExt, StreamM /// `agama_l10n::Event`) and has to be converted to the [Event /// struct](agama_lib::http::Event). pub struct EventsListener { - inner: StreamMap<&'static str, Pin + Send>>>, - sender: EventsSender, + inner: StreamMap<&'static str, Pin + Send>>>, + sender: http::event::Sender, } impl EventsListener { - pub fn new(sender: EventsSender) -> Self { + pub fn new(sender: http::event::Sender) -> Self { EventsListener { inner: StreamMap::new(), sender, @@ -47,10 +46,10 @@ impl EventsListener { name: &'static str, channel: mpsc::UnboundedReceiver, ) where - EventPayload: From, + http::EventPayload: From, { - let stream = - UnboundedReceiverStream::new(channel).map(|e| Event::new(EventPayload::from(e))); + let stream = UnboundedReceiverStream::new(channel) + .map(|e| http::Event::new(http::EventPayload::from(e))); self.inner.insert(name, Box::pin(stream)); } diff --git a/rust/agama-server/src/supervisor/message.rs b/rust/agama-manager/src/message.rs similarity index 99% rename from rust/agama-server/src/supervisor/message.rs rename to rust/agama-manager/src/message.rs index 040b05a5fd..8a3987f10f 100644 --- a/rust/agama-server/src/supervisor/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,14 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::collections::HashMap; - -use crate::supervisor::{ +use crate::{ l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, service, system_info::SystemInfo, }; use agama_lib::{install_settings::InstallSettings, issue::Issue}; use agama_utils::{actor::Message, progress::Progress}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Gets the installation status. pub struct GetStatus; diff --git a/rust/agama-server/src/supervisor/proposal.rs b/rust/agama-manager/src/proposal.rs similarity index 97% rename from rust/agama-server/src/supervisor/proposal.rs rename to rust/agama-manager/src/proposal.rs index 8cd9b9df21..2a2d5a9ba1 100644 --- a/rust/agama-server/src/supervisor/proposal.rs +++ b/rust/agama-manager/src/proposal.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::Serialize; #[derive(Clone, Debug, Serialize)] diff --git a/rust/agama-server/src/supervisor/scope.rs b/rust/agama-manager/src/scope.rs similarity index 97% rename from rust/agama-server/src/supervisor/scope.rs rename to rust/agama-manager/src/scope.rs index cf3d304da8..4374d5d75d 100644 --- a/rust/agama-server/src/supervisor/scope.rs +++ b/rust/agama-manager/src/scope.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::{Deserialize, Serialize}; #[derive( diff --git a/rust/agama-server/src/supervisor/service.rs b/rust/agama-manager/src/service.rs similarity index 99% rename from rust/agama-server/src/supervisor/service.rs rename to rust/agama-manager/src/service.rs index 78381b7d4a..0afa4bf0ae 100644 --- a/rust/agama-server/src/supervisor/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::{ +use crate::{ l10n, message::{self, Action}, proposal::Proposal, diff --git a/rust/agama-server/src/supervisor/start.rs b/rust/agama-manager/src/start.rs similarity index 94% rename from rust/agama-server/src/supervisor/start.rs rename to rust/agama-manager/src/start.rs index 16b8d8d77f..76085907b8 100644 --- a/rust/agama-server/src/supervisor/start.rs +++ b/rust/agama-manager/src/start.rs @@ -19,13 +19,11 @@ // find current contact information at www.suse.com. use crate::{ - supervisor::{ - l10n, - listener::{self, EventsListener}, - service::Service, - }, - web::EventsSender, + l10n, + listener::{self, EventsListener}, + service::Service, }; +use agama_lib::http; use agama_utils::{ actor::{self, Handler}, issue, progress, @@ -42,11 +40,11 @@ pub enum Error { Issues(#[from] issue::start::Error), } -/// Starts the supervisor service. +/// Starts the manager service. /// /// It starts two Tokio tasks: /// -/// * The main service, called "Supervisor", which coordinates the rest of services +/// * The main service, called "Manager", which coordinates the rest of services /// an entry point for the HTTP API. /// * An events listener which retransmit the events from all the services. /// @@ -56,7 +54,7 @@ pub enum Error { /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( - events: EventsSender, + events: http::event::Sender, dbus: Option, ) -> Result, Error> { let mut listener = EventsListener::new(events); diff --git a/rust/agama-server/src/supervisor/system_info.rs b/rust/agama-manager/src/system_info.rs similarity index 97% rename from rust/agama-server/src/supervisor/system_info.rs rename to rust/agama-manager/src/system_info.rs index 3bed029ed3..c2e9cc6ac5 100644 --- a/rust/agama-server/src/supervisor/system_info.rs +++ b/rust/agama-manager/src/system_info.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::supervisor::l10n; +use crate::l10n; use serde::Serialize; #[derive(Clone, Debug, Serialize)] diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index e45d241b15..74b30e6360 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -39,4 +39,3 @@ pub mod web; pub use web::service; pub mod server; pub mod software_ng; -pub(crate) mod supervisor; From 5b59a8506fe710ee6ca29a0e5d7639cdf3a827c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 8 Oct 2025 16:10:05 +0100 Subject: [PATCH 212/917] Adapt server to use manager --- rust/agama-manager/Cargo.toml | 3 ++ rust/agama-manager/src/start.rs | 8 ++-- rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/server/web.rs | 49 ++++++++++-------------- rust/agama-server/src/web/docs/config.rs | 6 +-- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 4fec4ce553..4273679aa5 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -22,3 +22,6 @@ merge-struct = "0.1.0" [dev-dependencies] tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 76085907b8..a22af33c28 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -81,14 +81,14 @@ pub async fn start( #[cfg(test)] mod test { - use crate::supervisor::{self, l10n, message, service::Service}; - use agama_lib::{http::Event, install_settings::InstallSettings}; + use crate::{self as manager, l10n, message, service::Service}; + use agama_lib::{http, install_settings::InstallSettings}; use agama_utils::actor::Handler; use tokio::sync::broadcast; async fn start_service() -> Handler { - let (events_tx, _events_rx) = broadcast::channel::(16); - supervisor::start(events_tx, None).await.unwrap() + let (events_sender, _events_receiver) = broadcast::channel::(16); + manager::start(events_sender, None).await.unwrap() } #[tokio::test] diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 6501a72e15..8659a095a1 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -12,6 +12,7 @@ agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-manager = { path = "../agama-manager" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 8dc8814cd9..ee7554644b 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,11 +20,8 @@ //! This module implements Agama's HTTP API. -use crate::{ - supervisor::{self, message, ConfigScope, Scope, Service, SystemInfo}, - web::EventsSender, -}; -use agama_lib::{error::ServiceError, install_settings::InstallSettings}; +use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; +use agama_manager::{self as manager, message, ConfigScope, Scope, SystemInfo}; use agama_utils::actor::Handler; use anyhow; use axum::{ @@ -44,7 +41,7 @@ pub enum Error { #[error("The given configuration does not belong to the '{0}' scope.")] Scope(Scope), #[error(transparent)] - Supervisor(#[from] supervisor::service::Error), + Manager(#[from] manager::service::Error), } impl IntoResponse for Error { @@ -66,7 +63,7 @@ fn to_option_response(value: Option) -> Response { #[derive(Clone)] pub struct ServerState { - supervisor: Handler, + manager: Handler, } type ServerResult = Result; @@ -77,14 +74,14 @@ type ServerResult = Result; /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( - events: EventsSender, + events: http::event::Sender, dbus: Option, ) -> Result { - let supervisor = supervisor::start(events, dbus) + let manager = manager::start(events, dbus) .await .map_err(|e| anyhow::Error::new(e))?; - let state = ServerState { supervisor }; + let state = ServerState { manager }; Ok(Router::new() .route("/status", get(get_status)) @@ -118,7 +115,7 @@ pub async fn server_service( ) )] async fn get_status(State(state): State) -> ServerResult> { - let status = state.supervisor.call(message::GetStatus).await?; + let status = state.manager.call(message::GetStatus).await?; Ok(Json(status)) } @@ -133,7 +130,7 @@ async fn get_status(State(state): State) -> ServerResult) -> ServerResult> { - let system = state.supervisor.call(message::GetSystem).await?; + let system = state.manager.call(message::GetSystem).await?; Ok(Json(system)) } @@ -150,7 +147,7 @@ async fn get_system(State(state): State) -> ServerResult, ) -> ServerResult> { - let config = state.supervisor.call(message::GetExtendedConfig).await?; + let config = state.manager.call(message::GetExtendedConfig).await?; Ok(Json(config)) } @@ -172,7 +169,7 @@ async fn get_extended_config_scope( Path(scope): Path, ) -> ServerResult { let config = state - .supervisor + .manager .call(message::GetExtendedConfigScope::new(scope)) .await?; Ok(to_option_response(config)) @@ -189,7 +186,7 @@ async fn get_extended_config_scope( ) )] async fn get_config(State(state): State) -> ServerResult> { - let config = state.supervisor.call(message::GetConfig).await?; + let config = state.manager.call(message::GetConfig).await?; Ok(Json(config)) } @@ -211,7 +208,7 @@ async fn get_config_scope( Path(scope): Path, ) -> ServerResult { let config = state - .supervisor + .manager .call(message::GetConfigScope::new(scope)) .await?; Ok(to_option_response(config)) @@ -236,10 +233,7 @@ async fn put_config( State(state): State, Json(config): Json, ) -> ServerResult<()> { - state - .supervisor - .call(message::SetConfig::new(config)) - .await?; + state.manager.call(message::SetConfig::new(config)).await?; Ok(()) } @@ -263,7 +257,7 @@ async fn patch_config( Json(config): Json, ) -> ServerResult<()> { state - .supervisor + .manager .call(message::UpdateConfig::new(config)) .await?; Ok(()) @@ -295,7 +289,7 @@ async fn put_config_scope( } state - .supervisor + .manager .call(message::SetConfigScope::new(config_scope)) .await?; Ok(()) @@ -327,7 +321,7 @@ async fn patch_config_scope( } state - .supervisor + .manager .call(message::UpdateConfigScope::new(config_scope)) .await?; Ok(()) @@ -344,7 +338,7 @@ async fn patch_config_scope( ) )] async fn get_proposal(State(state): State) -> ServerResult { - let proposal = state.supervisor.call(message::GetProposal).await?; + let proposal = state.manager.call(message::GetProposal).await?; Ok(to_option_response(proposal)) } @@ -359,7 +353,7 @@ async fn get_proposal(State(state): State) -> ServerResult) -> ServerResult> { - let issues = state.supervisor.call(message::GetIssues).await?; + let issues = state.manager.call(message::GetIssues).await?; let issues_map: IssuesMap = issues.into(); Ok(Json(issues_map)) } @@ -380,9 +374,6 @@ async fn run_action( State(state): State, Json(action): Json, ) -> ServerResult<()> { - state - .supervisor - .call(message::RunAction::new(action)) - .await?; + state.manager.call(message::RunAction::new(action)).await?; Ok(()) } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 0389e968e9..ff6eeefd7c 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -164,9 +164,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .build() } From aef59a708aba8a15371c670c7479f292dce018db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 08:54:10 +0100 Subject: [PATCH 213/917] Remove reduntant types --- rust/agama-server/src/network/web.rs | 6 ++--- rust/agama-server/src/software/web.rs | 11 ++++----- rust/agama-server/src/web.rs | 14 ++++++----- rust/agama-server/src/web/common/progress.rs | 7 +++--- rust/agama-server/src/web/event.rs | 25 -------------------- rust/agama-server/src/web/service.rs | 8 +++---- rust/agama-server/src/web/state.rs | 5 ++-- rust/agama-server/src/web/ws.rs | 13 ++++++---- 8 files changed, 33 insertions(+), 56 deletions(-) delete mode 100644 rust/agama-server/src/web/event.rs diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 37303ecd22..18783c895e 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -20,7 +20,7 @@ //! This module implements the web API for the network module. -use crate::{error::Error, web::EventsSender}; +use crate::error::Error; use anyhow::Context; use axum::{ extract::{Path, State}, @@ -33,7 +33,7 @@ use uuid::Uuid; use agama_lib::{ error::ServiceError, - event, + event, http, network::{ error::NetworkStateError, model::{AccessPoint, Connection, Device, GeneralState}, @@ -85,7 +85,7 @@ struct NetworkServiceState { /// * `events`: sending-half of the broadcast channel. pub async fn network_service( adapter: T, - events: EventsSender, + events: http::event::Sender, ) -> Result { let network = NetworkSystem::new(adapter); // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 0ccd1d781c..4d785deb6b 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -27,16 +27,13 @@ use crate::{ error::Error, - web::{ - common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, - EventsReceiver, - }, + web::common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, }; use agama_lib::{ error::ServiceError, event, - http::{Event, EventPayload}, + http::{self, Event, EventPayload}, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ @@ -221,7 +218,7 @@ fn reason_to_selected_by( /// * `events`: channel to listen for events. /// * `products`: list of products (shared behind a mutex). pub async fn receive_events( - mut events: EventsReceiver, + mut events: http::event::Receiver, products: Arc>>, config: Arc>>, client: ProductClient<'_>, @@ -265,7 +262,7 @@ pub async fn receive_events( /// Sets up and returns the axum service for the software module. pub async fn software_service( dbus: zbus::Connection, - events: EventsReceiver, + events: http::event::Receiver, progress: ProgressClient, ) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 6c12ff0675..bdfb23e571 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -49,16 +49,18 @@ mod auth; pub mod common; mod config; pub mod docs; -mod event; mod http; mod service; mod state; mod ws; -use agama_lib::{connection, error::ServiceError, http::Event}; +use agama_lib::{ + connection, + error::ServiceError, + http::event::{self, Event}, +}; use common::ProgressService; pub use config::ServiceConfig; -pub use event::{EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; use std::{path::Path, sync::Arc}; use tokio::sync::Mutex; @@ -72,7 +74,7 @@ use tokio_stream::{StreamExt, StreamMap}; /// * `web_ui_dir`: public directory containing the web UI. pub async fn service

    ( config: ServiceConfig, - events: EventsSender, + events: event::Sender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result @@ -124,7 +126,7 @@ where /// The events are sent to the `events` channel. /// /// * `events`: channel to send the events to. -pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { +pub async fn run_monitor(events: event::Sender) -> Result<(), ServiceError> { let connection = connection().await?; tokio::spawn(run_events_monitor(connection, events.clone())); @@ -135,7 +137,7 @@ pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. -async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { +async fn run_events_monitor(dbus: zbus::Connection, events: event::Sender) -> Result<(), Error> { let mut stream = StreamMap::new(); stream.insert("manager", manager_stream(dbus.clone()).await?); diff --git a/rust/agama-server/src/web/common/progress.rs b/rust/agama-server/src/web/common/progress.rs index 8fa3feaa3a..6eebeba5e6 100644 --- a/rust/agama-server/src/web/common/progress.rs +++ b/rust/agama-server/src/web/common/progress.rs @@ -35,10 +35,9 @@ //! //! At this point, it only handles the progress that are exposed through D-Bus. -use crate::web::EventsSender; use agama_lib::{ event, - http::Event, + http::{self, Event}, progress::{Progress, ProgressSequence}, proxies::{ProgressChanged, ProgressProxy}, }; @@ -77,7 +76,7 @@ pub enum ProgressCommand { pub struct ProgressService { cache: HashMap, commands: mpsc::Receiver, - events: EventsSender, + events: http::event::Sender, dbus: zbus::Connection, } @@ -88,7 +87,7 @@ impl ProgressService { /// /// * Commands from a client ([ProgressClient]). /// * Relevant events from D-Bus. - pub async fn start(dbus: zbus::Connection, events: EventsSender) -> ProgressClient { + pub async fn start(dbus: zbus::Connection, events: http::event::Sender) -> ProgressClient { let (tx, rx) = mpsc::channel(4); let mut service = ProgressService { cache: HashMap::new(), diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs deleted file mode 100644 index beb7611e33..0000000000 --- a/rust/agama-server/src/web/event.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_lib::http::Event; -use tokio::sync::broadcast::{Receiver, Sender}; - -pub type EventsSender = Sender; -pub type EventsReceiver = Receiver; diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index a6fe10d0eb..e188451d5a 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use super::http::{login, login_from_query, logout, session}; -use super::{config::ServiceConfig, state::ServiceState, EventsSender}; -use agama_lib::auth::TokenClaims; +use super::{config::ServiceConfig, state::ServiceState}; +use agama_lib::{auth::TokenClaims, http}; use axum::http::HeaderValue; use axum::middleware::Next; use axum::{ @@ -55,7 +55,7 @@ use tracing::Span; /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, - events: EventsSender, + events: http::event::Sender, api_router: Router, public_dir: PathBuf, } @@ -65,7 +65,7 @@ impl MainServiceBuilder { /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. - pub fn new

    (events: EventsSender, public_dir: P) -> Self + pub fn new

    (events: http::event::Sender, public_dir: P) -> Self where P: AsRef, { diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index 4ef889e6c3..d207e9923d 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -20,7 +20,8 @@ //! Implements the web service state. -use super::{config::ServiceConfig, EventsSender}; +use super::config::ServiceConfig; +use agama_lib::http; use std::path::PathBuf; /// Web service state. @@ -29,6 +30,6 @@ use std::path::PathBuf; #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, - pub events: EventsSender, + pub events: http::event::Sender, pub public_dir: PathBuf, } diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index c42bc0caff..414a9f6c6e 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -20,10 +20,8 @@ //! Implements the websocket handling. -use std::sync::Arc; - -use super::{state::ServiceState, EventsSender}; -use agama_lib::auth::ClientId; +use super::state::ServiceState; +use agama_lib::{auth::ClientId, http}; use axum::{ extract::{ ws::{Message, WebSocket}, @@ -32,6 +30,7 @@ use axum::{ response::IntoResponse, Extension, }; +use std::sync::Arc; pub async fn ws_handler( State(state): State, @@ -41,7 +40,11 @@ pub async fn ws_handler( ws.on_upgrade(move |socket| handle_socket(socket, state.events, client_id)) } -async fn handle_socket(mut socket: WebSocket, events: EventsSender, client_id: Arc) { +async fn handle_socket( + mut socket: WebSocket, + events: http::event::Sender, + client_id: Arc, +) { let mut rx = events.subscribe(); let conn_event = agama_lib::event!(ClientConnected, client_id.as_ref()); From 848b5a6afc0d1170f93225202371a006518f1fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:23:27 +0100 Subject: [PATCH 214/917] Remove unused deps --- rust/Cargo.lock | 2 -- rust/agama-server/Cargo.toml | 2 -- rust/agama-server/src/software_ng.rs | 5 +++-- rust/agama-server/src/software_ng/backend.rs | 5 +++-- rust/agama-server/src/software_ng/backend/server.rs | 10 ++++------ 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 99d04c95e8..990bede565 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -182,7 +182,6 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-lib", - "agama-locale-data", "agama-manager", "agama-utils", "anyhow", @@ -200,7 +199,6 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "libsystemd", - "merge-struct", "openssl", "pam", "pin-project", diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 8659a095a1..2e0e827693 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -8,7 +8,6 @@ rust-version.workspace = true [dependencies] anyhow = "1.0" -agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } @@ -61,7 +60,6 @@ glob = "0.3.1" tempfile = "3.13.0" url = "2.5.2" serde_yaml = "0.9.34" -merge-struct = "0.1.0" strum = { version = "0.27.2", features = ["derive"] } [[bin]] diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs index f13588aabb..d0b9b8a0a6 100644 --- a/rust/agama-server/src/software_ng.rs +++ b/rust/agama-server/src/software_ng.rs @@ -23,15 +23,16 @@ pub(crate) mod web; use std::sync::Arc; +use agama_lib::http::event; use axum::Router; use backend::SoftwareService; pub use backend::SoftwareServiceError; use tokio::sync::Mutex; -use crate::{products::ProductsRegistry, web::EventsSender}; +use crate::{products::ProductsRegistry}; pub async fn software_ng_service( - events: EventsSender, + events: event::Sender, products: Arc>, ) -> Router { let client = diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index 5a32241b50..c1a5096e8f 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -34,11 +34,12 @@ use std::sync::Arc; +use agama_lib::http::event; pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; use zypp_agama::ZyppError; -use crate::{products::ProductsRegistry, web::EventsSender}; +use crate::{products::ProductsRegistry}; mod client; mod server; @@ -90,7 +91,7 @@ pub struct SoftwareService {} impl SoftwareService { /// Starts the software service. pub fn start( - events: EventsSender, + events: event::Sender, products: Arc>, ) -> Result { server::SoftwareServiceServer::start(events, products) diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 64e290eded..51d4494720 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -21,18 +21,16 @@ use std::{path::Path, sync::Arc}; use agama_lib::{ - product::Product, - software::{ + http::event, product::Product, software::{ model::{ResolvableType, SoftwareConfig, SoftwareSelection}, Pattern, - }, + } }; use tokio::sync::{mpsc, oneshot, Mutex}; use crate::{ products::{ProductSpec, ProductsRegistry}, software_ng::backend::SoftwareServiceResult, - web::EventsSender, }; use super::{client::SoftwareServiceClient, SoftwareServiceError}; @@ -68,7 +66,7 @@ pub enum SoftwareAction { /// Software service server. pub struct SoftwareServiceServer { receiver: mpsc::UnboundedReceiver, - events: EventsSender, + events: event::Sender, products: Arc>, // FIXME: what about having a SoftwareServiceState to keep business logic state? selected_product: Option, @@ -80,7 +78,7 @@ impl SoftwareServiceServer { /// /// The service runs on a separate thread and gets the client requests using a channel. pub fn start( - events: EventsSender, + events: event::Sender, products: Arc>, ) -> SoftwareServiceResult { let (sender, receiver) = mpsc::unbounded_channel(); From fd4079808ba730ecfec7c4f2b81f9c39403a0b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:25:37 +0100 Subject: [PATCH 215/917] Changelog --- rust/package/agama.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 01aa350ff5..56e6d78af8 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Thu Oct 9 10:24:03 UTC 2025 - José Iván López González + +- Extract supervisor to agama-manager package (gh#agama-project/agama#2793). + ------------------------------------------------------------------- Wed Oct 8 06:48:47 UTC 2025 - Imobach Gonzalez Sosa From b50f3bc3aa9fbd9f745ca1e0698a87ddea0a2056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 11:59:15 +0100 Subject: [PATCH 216/917] Remove scope from HTTP API --- rust/agama-manager/src/lib.rs | 3 - rust/agama-manager/src/message.rs | 68 +----------- rust/agama-manager/src/scope.rs | 45 -------- rust/agama-manager/src/service.rs | 75 -------------- rust/agama-server/src/server/web.rs | 125 +---------------------- rust/agama-server/src/web/docs/config.rs | 4 - 6 files changed, 3 insertions(+), 317 deletions(-) delete mode 100644 rust/agama-manager/src/scope.rs diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 138b36ab20..bc47c3a8d1 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -24,9 +24,6 @@ pub use start::start; pub mod service; pub use service::Service; -mod scope; -pub use scope::{ConfigScope, Scope}; - mod system_info; pub use system_info::SystemInfo; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 8a3987f10f..3571189ebf 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,9 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - l10n, proposal::Proposal, scope::ConfigScope, scope::Scope, service, system_info::SystemInfo, -}; +use crate::{l10n, proposal::Proposal, service, system_info::SystemInfo}; use agama_lib::{install_settings::InstallSettings, issue::Issue}; use agama_utils::{actor::Message, progress::Progress}; use serde::{Deserialize, Serialize}; @@ -61,22 +59,6 @@ impl Message for GetExtendedConfig { type Reply = InstallSettings; } -/// Gets a scope from the full config. -#[derive(Debug)] -pub struct GetExtendedConfigScope { - pub scope: Scope, -} - -impl GetExtendedConfigScope { - pub fn new(scope: Scope) -> Self { - Self { scope } - } -} - -impl Message for GetExtendedConfigScope { - type Reply = Option; -} - /// Gets the current config set by the user. #[derive(Debug)] pub struct GetConfig; @@ -117,54 +99,6 @@ impl Message for UpdateConfig { type Reply = (); } -/// Gets a scope from the config. -#[derive(Debug)] -pub struct GetConfigScope { - pub scope: Scope, -} - -impl GetConfigScope { - pub fn new(scope: Scope) -> Self { - Self { scope } - } -} - -impl Message for GetConfigScope { - type Reply = Option; -} - -/// Sets a config scope -#[derive(Debug)] -pub struct SetConfigScope { - pub config: ConfigScope, -} - -impl SetConfigScope { - pub fn new(config: ConfigScope) -> Self { - Self { config } - } -} - -impl Message for SetConfigScope { - type Reply = (); -} - -/// Updates a config scope -#[derive(Debug)] -pub struct UpdateConfigScope { - pub config: ConfigScope, -} - -impl UpdateConfigScope { - pub fn new(config: ConfigScope) -> Self { - Self { config } - } -} - -impl Message for UpdateConfigScope { - type Reply = (); -} - /// Gets the proposal. #[derive(Debug)] pub struct GetProposal; diff --git a/rust/agama-manager/src/scope.rs b/rust/agama-manager/src/scope.rs deleted file mode 100644 index 4374d5d75d..0000000000 --- a/rust/agama-manager/src/scope.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::l10n; -use serde::{Deserialize, Serialize}; - -#[derive( - Copy, Clone, Debug, strum::EnumString, strum::Display, Deserialize, PartialEq, utoipa::ToSchema, -)] -#[strum(serialize_all = "snake_case")] -#[serde(rename_all = "snake_case")] -pub enum Scope { - L10n, -} - -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum ConfigScope { - L10n(l10n::Config), -} - -impl ConfigScope { - pub fn to_scope(&self) -> Scope { - match &self { - Self::L10n(_) => Scope::L10n, - } - } -} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 0afa4bf0ae..d5b1fd85cd 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -22,7 +22,6 @@ use crate::{ l10n, message::{self, Action}, proposal::Proposal, - scope::{ConfigScope, Scope}, system_info::SystemInfo, }; use agama_lib::install_settings::InstallSettings; @@ -147,23 +146,6 @@ impl MessageHandler for Service { } } -#[async_trait] -impl MessageHandler for Service { - /// It returns the configuration for the given scope. - async fn handle( - &mut self, - message: message::GetExtendedConfigScope, - ) -> Result, Error> { - let option = match message.scope { - Scope::L10n => { - let l10n_config = self.l10n.call(l10n::message::GetConfig).await?; - Some(ConfigScope::L10n(l10n_config)) - } - }; - Ok(option) - } -} - #[async_trait] impl MessageHandler for Service { /// Gets the current configuration set by the user. @@ -205,63 +187,6 @@ impl MessageHandler for Service { } } -#[async_trait] -impl MessageHandler for Service { - /// It returns the configuration set by the user for the given scope. - async fn handle( - &mut self, - message: message::GetConfigScope, - ) -> Result, Error> { - // FIXME: implement this logic at InstallSettings level: self.get_config().by_scope(...) - // It would allow us to drop this method. - let option = match message.scope { - Scope::L10n => self - .config - .localization - .clone() - .map(|c| ConfigScope::L10n(c)), - }; - Ok(option) - } -} - -#[async_trait] -impl MessageHandler for Service { - /// Sets the user configuration within the given scope. - /// - /// It replaces the current configuration with the given one and calculates a - /// new proposal. Only the configuration in the given scope is affected. - async fn handle(&mut self, message: message::SetConfigScope) -> Result<(), Error> { - match message.config { - ConfigScope::L10n(l10n_config) => { - self.l10n - .call(l10n::message::SetConfig::new(l10n_config.clone())) - .await?; - self.config.localization = Some(l10n_config); - } - } - Ok(()) - } -} - -#[async_trait] -impl MessageHandler for Service { - /// Patches the user configuration within the given scope. - /// - /// It merges the current configuration with the given one. - async fn handle(&mut self, message: message::UpdateConfigScope) -> Result<(), Error> { - match message.config { - ConfigScope::L10n(l10n_config) => { - let base_config = self.config.localization.clone().unwrap_or_default(); - let config = merge(&base_config, &l10n_config).map_err(|_| Error::MergeConfig)?; - self.handle(message::SetConfigScope::new(ConfigScope::L10n(config))) - .await?; - } - } - Ok(()) - } -} - #[async_trait] impl MessageHandler for Service { /// It returns the current proposal, if any. diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index ee7554644b..b8f524fcb8 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -21,11 +21,11 @@ //! This module implements Agama's HTTP API. use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; -use agama_manager::{self as manager, message, ConfigScope, Scope, SystemInfo}; +use agama_manager::{self as manager, message, SystemInfo}; use agama_utils::actor::Handler; use anyhow; use axum::{ - extract::{Path, State}, + extract::State, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, @@ -38,8 +38,6 @@ use super::types::IssuesMap; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("The given configuration does not belong to the '{0}' scope.")] - Scope(Scope), #[error(transparent)] Manager(#[from] manager::service::Error), } @@ -86,14 +84,7 @@ pub async fn server_service( Ok(Router::new() .route("/status", get(get_status)) .route("/system", get(get_system)) - .route("/extended_config/:scope", get(get_extended_config_scope)) .route("/extended_config", get(get_extended_config)) - .route( - "/config/:scope", - get(get_config_scope) - .put(put_config_scope) - .patch(patch_config_scope), - ) .route( "/config", get(get_config).put(put_config).patch(patch_config), @@ -151,30 +142,6 @@ async fn get_extended_config( Ok(Json(config)) } -/// Returns the extended configuration for the given scope. -#[utoipa::path( - get, - path = "/extended_config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "Extended configuration for the given scope."), - (status = 400, description = "Not possible to retrieve the configuration scope.") - ), - params( - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") - ) -)] -async fn get_extended_config_scope( - State(state): State, - Path(scope): Path, -) -> ServerResult { - let config = state - .manager - .call(message::GetExtendedConfigScope::new(scope)) - .await?; - Ok(to_option_response(config)) -} - /// Returns the configuration. #[utoipa::path( get, @@ -190,30 +157,6 @@ async fn get_config(State(state): State) -> ServerResult, - Path(scope): Path, -) -> ServerResult { - let config = state - .manager - .call(message::GetConfigScope::new(scope)) - .await?; - Ok(to_option_response(config)) -} - /// Updates the configuration. /// /// Replaces the whole configuration. If some value is missing, it will be removed. @@ -263,70 +206,6 @@ async fn patch_config( Ok(()) } -/// Updates the configuration for the given scope. -/// -/// Replaces the whole scope. If some value is missing, it will be removed. -#[utoipa::path( - put, - path = "/config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "The configuration scope was replaced. Other operations can be running in background."), - (status = 400, description = "Not possible to replace the configuration scope.") - ), - params( - ("config" = InstallSettings, description = "Configuration scope to apply."), - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'localization', etc).") - ) -)] -async fn put_config_scope( - State(state): State, - Path(scope): Path, - Json(config_scope): Json, -) -> ServerResult<()> { - if config_scope.to_scope() != scope { - return Err(Error::Scope(scope)); - } - - state - .manager - .call(message::SetConfigScope::new(config_scope)) - .await?; - Ok(()) -} - -/// Patches the configuration for the given scope. -/// -/// It only chagnes the specified values, keeping the rest as they are. -#[utoipa::path( - patch, - path = "/config/{scope}", - context_path = "/api/v2", - responses( - (status = 200, description = "The configuration scope was patched. Other operations can be running in background."), - (status = 400, description = "Not possible to patch the configuration scope.") - ), - params( - ("config" = InstallSettings, description = "Changes in the configuration scope."), - ("scope" = String, Path, description = "Configuration scope (e.g., 'storage', 'l10n', etc).") - ) -)] -async fn patch_config_scope( - State(state): State, - Path(scope): Path, - Json(config_scope): Json, -) -> ServerResult<()> { - if config_scope.to_scope() != scope { - return Err(Error::Scope(scope)); - } - - state - .manager - .call(message::UpdateConfigScope::new(config_scope)) - .await?; - Ok(()) -} - /// Returns how the target system is configured (proposal). #[utoipa::path( get, diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ff6eeefd7c..bc2f21de4b 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -33,13 +33,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { PathsBuilder::new() .path_from::() .path_from::() - .path_from::() .path_from::() .path_from::() .path_from::() - .path_from::() - .path_from::() - .path_from::() .path_from::() .path_from::() .build() From 92d16caea56ec632f19e621d7d0b4032a3409587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:01:18 +0100 Subject: [PATCH 217/917] Add specific patch document for config --- rust/agama-server/src/server/types.rs | 15 ++++++++---- rust/agama-server/src/server/web.rs | 29 ++++++++++++----------- rust/agama-server/src/web/docs/config.rs | 2 ++ rust/agama-server/tests/server_service.rs | 13 +++++++--- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/rust/agama-server/src/server/types.rs b/rust/agama-server/src/server/types.rs index a81982cb7b..639d232211 100644 --- a/rust/agama-server/src/server/types.rs +++ b/rust/agama-server/src/server/types.rs @@ -20,12 +20,12 @@ //! This module defines some ancillary types for the HTTP API. -use std::collections::HashMap; - +use agama_lib::install_settings::InstallSettings; use agama_utils::issue; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; -#[derive(Serialize, utoipa::ToSchema)] +#[derive(Deserialize, Serialize, utoipa::ToSchema)] /// Holds the installation issues for each scope. pub struct IssuesMap { /// iSCSI issues. @@ -60,3 +60,10 @@ impl From>> for IssuesMap { } } } + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +/// Patch for the config. +pub struct ConfigPatch { + /// Update for the current config. + pub update: Option, +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index b8f524fcb8..f1b98d4bfe 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,6 +20,7 @@ //! This module implements Agama's HTTP API. +use crate::server::types::{ConfigPatch, IssuesMap}; use agama_lib::{error::ServiceError, http, install_settings::InstallSettings}; use agama_manager::{self as manager, message, SystemInfo}; use agama_utils::actor::Handler; @@ -34,8 +35,6 @@ use hyper::StatusCode; use serde::Serialize; use serde_json::json; -use super::types::IssuesMap; - #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] @@ -52,13 +51,6 @@ impl IntoResponse for Error { } } -fn to_option_response(value: Option) -> Response { - match value { - Some(inner) => Json(inner).into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } -} - #[derive(Clone)] pub struct ServerState { manager: Handler, @@ -197,12 +189,14 @@ async fn put_config( )] async fn patch_config( State(state): State, - Json(config): Json, + Json(patch): Json, ) -> ServerResult<()> { - state - .manager - .call(message::UpdateConfig::new(config)) - .await?; + if let Some(config) = patch.update { + state + .manager + .call(message::UpdateConfig::new(config)) + .await?; + } Ok(()) } @@ -256,3 +250,10 @@ async fn run_action( state.manager.call(message::RunAction::new(action)).await?; Ok(()) } + +fn to_option_response(value: Option) -> Response { + match value { + Some(inner) => Json(inner).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index bc2f21de4b..b4d203057c 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -159,6 +159,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index b18bd806a9..bd6e62c416 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -150,13 +150,15 @@ async fn test_put_config() -> Result<(), Box> { #[test] #[cfg(not(ci))] async fn test_patch_config() -> Result<(), Box> { + use agama_server::server::types::ConfigPatch; + let localization = Config { locale: Some("es_ES.UTF-8".to_string()), keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }; - let mut config = InstallSettings { + let config = InstallSettings { localization: Some(localization), ..Default::default() }; @@ -177,13 +179,18 @@ async fn test_patch_config() -> Result<(), Box> { keymap: Some("en".to_string()), timezone: None, }; - config.localization = Some(localization); + let patch = ConfigPatch { + update: Some(InstallSettings { + localization: Some(localization), + ..Default::default() + }), + }; let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") .method(Method::PATCH) - .body(serde_json::to_string(&config)?) + .body(serde_json::to_string(&patch)?) .unwrap(); let response = server_service.clone().oneshot(request).await.unwrap(); From 5edc0e595b7d9c2ac92f1cee58528045614fb91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:01:43 +0100 Subject: [PATCH 218/917] Adapt UI client --- web/src/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 56d01542e7..17050ebbbe 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -37,7 +37,7 @@ const fetchProposal = (): Promise => get("/api/v2/proposal"); /** * Updates configuration */ -const updateConfig = (config) => patch("/api/v2/config", config); +const updateConfig = (config) => patch("/api/v2/config", { update: config }); /** * Triggers an action */ From 31d8bc99e949e849ac5868a04cc032b0ddf35c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 9 Oct 2025 13:41:39 +0100 Subject: [PATCH 219/917] Update documentation --- doc/http_api.md | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/doc/http_api.md b/doc/http_api.md index 221ac8415a..a7ab4e0eff 100644 --- a/doc/http_api.md +++ b/doc/http_api.md @@ -20,18 +20,20 @@ The API is designed around 3 main concepts: *system*, *config* and *proposal*. The *config* contains elements that can modify the *system*, the *proposal* or both. For example, the *dasd* config changes the *system*, and the *storage* config changes the *proposal*. In other cases like *network*, the config can affect to both *system* and *proposal*. ~~~ +GET /status GET /system GET /extended_config -GET /extended_config/{scope} GET PUT PATCH /config -GET PUT PATCH /config/{scope} GET POST PATCH /questions GET /proposal -GET /status GET /issues POST /action ~~~ +### GET /status + +Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. + ### GET /system Returns a JSON with the info of the system (storage devices, network connections, current localization, etc). @@ -47,31 +49,43 @@ There is a distinction between *extended config* and *config*: For example, if only the *locale* was configured by the user, then the *config* has no *keymap* property. Nevertheless, the *extended config* would have a *keymap* with the value from the default *extended config*. -The scope can be indicated to retrieve only a part of the config, for example *GET /extended_config/l10n*. +### GET PUT /config -### GET PUT PATCH /config +Reads or replaces the *config*. In case of patching, the given config is merged into the current *extended config*. -Reads, replaces or modifies the explicitly set *config*. In case of patching, the given config is merged into the current *extended config*. +### PATCH /config -The scope can be indicated to manage only part of the config, for example *PUT /config/l10n*. +Applies changes in the *config*. There is an own patch document: -### POST /action +~~~json +{ + "update": { + "l10n": { + "keymap": "es" + } + } +} +~~~ -Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. +The given config from the *update* key is merged into current *extended config*. -### GET /status +The patch document could be extended in the future with more options, for example for resetting some parts of the config. -Reports the status of the installation. It contains the installation state (*configuring*, *installing*, *finished*) and the active progresses. +See https://datatracker.ietf.org/doc/html/rfc5789#section-2 + +### POST /action + +Allows performing actions that cannot be done as side effect of applying a config. For example, start the installation, reload the system, etc. The *actions schema* defines the possible actions, parameters, etc. -### Example: reload the system +#### Example: reload the system In some cases, clients need to request a system reload. For example, if you create a RAID device using the terminal, then you need to reload the system in order to see the new device. In the future, reloading the system could be automatically done (e.g., by listening udisk D-Bus). For now, reloading has to be manually requested. ~~~ -POST /action { "reloadSystem": { scope: "storage" } } +POST /action { "reloadSystem": "storage" } ~~~ -### Example: change the system localization +#### Example: change the system localization Sometimes we need to directly modify the system without changing the config. For example, switching the locale of the running system (UI language). @@ -79,7 +93,7 @@ Sometimes we need to directly modify the system without changing the config. For POST /action { "configureL10n": { language: "es_ES" } } ~~~ -### Example: start installation +#### Example: start installation The installation can be started by calling the proper action. From 526850c21c3675f6bd43bf617b4c0119b54e6b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 20 Oct 2025 12:21:52 +0100 Subject: [PATCH 220/917] feat: add a new questions API (#2813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a new questions API. It is build on a single resource `/questions` which lists all the registered questions. ```json [ { "id": 1, "text": "LUKS password", "class": "storage.luks", "field": { "type": "string" }, "actions": [ { "id": "accept", "label": "Accept" }, { "id": "skip", "label": "Skip" } ], "defaultAction": "skip" } ] ``` ## The new model According to this new API, each question is composed by: * `text`: the text for the question. * `class`: it works as a hint for the UI or to match pre-defined answers (e.g., "autoyast.unsupported"). * `field`: optionally, a question might define an additional field (e.g., a password, a selector, etc.). * `actions`: list of available actions (e.g., "next", "skip", etc.). * `defaultAction`: default action. ## Registering a question A new question is registered through a `POST` request to the `/questions` API. The payload describes the question. ```json { "text": "LUKS password", "class": "storage.luks", "field": { "type": "string" }, "actions": [ { "id": "accept", "label": "Accept" }, { "id": "skip", "label": "Skip" } ], "defaultAction": "skip" } ``` ## Answering a question A question is answered by sending a `PATCH` on the connection with the following payload. ```json { "id": 1, "action": "accept", "value": "my-password" } ``` ## Automatic answers As in the previous API, it is possible to set up the questions service to automatically response some questions. ```json { "update": { "questions": { "policy": "auto", "answers": [ { "class": "storage.luks", "action": "ok", "value": "secret" } ] } } } ``` ## Field types The field types allow to grow this API to cover more use cases in the future, like software conflicts. At this time, it supports: * `none`: when no additional data is needed (most of the cases). * `string`: currently unused. * `password`: for LUKS. * `selection`: unused but planed for software conflicts (although it might need some improvements). --------- Co-authored-by: Ladislav Slezák --- ....opensuse.Agama1.Questions.Generic.bus.xml | 48 -- ...suse.Agama1.Questions.WithPassword.bus.xml | 51 -- .../bus/org.opensuse.Agama1.Questions.bus.xml | 99 ---- doc/dbus/bus/seed.sh | 54 +-- ....opensuse.Agama1.Questions.Generic.doc.xml | 51 -- ...suse.Agama1.Questions.WithPassword.doc.xml | 12 - .../org.opensuse.Agama1.Questions.doc.xml | 105 ---- live/src/config.sh | 2 +- rust/Cargo.lock | 2 + rust/agama-autoinstall/Cargo.toml | 1 + rust/agama-autoinstall/src/questions.rs | 36 +- rust/agama-cli/Cargo.toml | 1 + rust/agama-cli/src/questions.rs | 63 ++- rust/agama-l10n/src/config.rs | 3 +- rust/agama-l10n/src/message.rs | 10 +- rust/agama-l10n/src/service.rs | 17 +- rust/agama-l10n/src/start.rs | 8 +- rust/agama-lib/src/http/base_http_client.rs | 2 +- rust/agama-lib/src/install_settings.rs | 3 - rust/agama-lib/src/proxies.rs | 2 - rust/agama-lib/src/proxies/questions.rs | 28 -- rust/agama-lib/src/proxies/questions/base.rs | 86 ---- .../src/proxies/questions/generic.rs | 58 --- .../src/proxies/questions/with_password.rs | 34 -- rust/agama-lib/src/questions.rs | 109 ----- .../agama-lib/src/questions/answers/custom.rs | 327 ------------- .../src/questions/answers/default.rs | 46 -- .../src/questions/answers/strategy.rs | 45 -- rust/agama-lib/src/questions/config.rs | 42 -- rust/agama-lib/src/questions/error.rs | 27 -- rust/agama-lib/src/questions/http_client.rs | 234 ++++----- rust/agama-lib/src/questions/model.rs | 84 ---- rust/agama-lib/src/questions/store.rs | 53 --- rust/agama-lib/src/store.rs | 11 - rust/agama-manager/src/message.rs | 6 +- rust/agama-manager/src/service.rs | 32 +- rust/agama-manager/src/start.rs | 20 +- rust/agama-server/Cargo.toml | 4 - rust/agama-server/src/agama-dbus-server.rs | 54 --- rust/agama-server/src/error.rs | 4 +- rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/questions.rs | 318 ------------- rust/agama-server/src/questions/web.rs | 448 ------------------ rust/agama-server/src/server/web.rs | 109 ++++- rust/agama-server/src/web.rs | 3 - rust/agama-server/src/web/config.rs | 2 +- rust/agama-server/src/web/docs.rs | 2 - rust/agama-server/src/web/docs/config.rs | 50 +- rust/agama-server/src/web/docs/questions.rs | 51 -- rust/agama-server/src/web/ws.rs | 8 +- rust/agama-server/tests/server_service.rs | 8 +- rust/agama-utils/src/api.rs | 2 + rust/agama-utils/src/api/config.rs | 4 +- rust/agama-utils/src/api/event.rs | 8 + rust/agama-utils/src/api/question.rs | 419 ++++++++++++++++ rust/agama-utils/src/lib.rs | 1 + rust/agama-utils/src/question.rs | 60 +++ .../src/question/config.rs} | 9 +- rust/agama-utils/src/question/message.rs | 90 ++++ rust/agama-utils/src/question/service.rs | 161 +++++++ rust/agama-utils/src/question/start.rs | 175 +++++++ rust/install.sh | 3 - rust/package/agama.changes | 5 + rust/package/agama.spec | 5 - rust/share/org.opensuse.Agama1.service | 4 - rust/xtask/src/main.rs | 5 +- service/Gemfile.lock | 2 +- service/lib/agama/answer.rb | 39 ++ .../lib/agama/autoyast/profile_reporter.rb | 10 +- service/lib/agama/commands/agama_autoyast.rb | 4 +- service/lib/agama/config_reader.rb | 2 +- service/lib/agama/dbus/clients/question.rb | 78 --- service/lib/agama/dbus/clients/questions.rb | 156 ------ .../software/modules/PackageCallbacks.rb | 4 +- service/lib/agama/http/clients.rb | 1 + service/lib/agama/http/clients/base.rb | 2 +- service/lib/agama/http/clients/questions.rb | 95 ++++ service/lib/agama/product_reader.rb | 2 +- service/lib/agama/question.rb | 57 ++- service/lib/agama/registration.rb | 4 +- service/lib/agama/software/callbacks/base.rb | 4 +- .../lib/agama/software/callbacks/digest.rb | 12 +- service/lib/agama/software/callbacks/media.rb | 4 +- .../lib/agama/software/callbacks/progress.rb | 10 +- .../lib/agama/software/callbacks/provide.rb | 4 +- .../lib/agama/software/callbacks/script.rb | 6 +- .../lib/agama/software/callbacks/signature.rb | 16 +- .../lib/agama/storage/callbacks/activate.rb | 4 +- .../agama/storage/callbacks/activate_luks.rb | 10 +- .../storage/callbacks/activate_multipath.rb | 8 +- service/lib/agama/storage/callbacks/commit.rb | 4 +- .../agama/storage/callbacks/commit_error.rb | 12 +- service/lib/agama/storage/manager.rb | 6 +- service/package/rubygem-agama-yast.changes | 5 + .../agama/commands/agama_autoyast_test.rb | 4 +- .../test/agama/dbus/clients/question_test.rb | 76 --- .../test/agama/dbus/clients/questions_test.rb | 97 ---- .../test/agama/http/clients/scripts_test.rb | 3 +- service/test/agama/question_test.rb | 67 +++ service/test/agama/registration_test.rb | 12 +- .../agama/software/callbacks/digest_test.rb | 30 +- .../agama/software/callbacks/media_test.rb | 17 +- .../software/callbacks/pkg_gpg_check_test.rb | 2 +- .../agama/software/callbacks/provide_test.rb | 17 +- .../agama/software/callbacks/script_test.rb | 19 +- .../software/callbacks/signature_test.rb | 33 +- service/test/agama/software/manager_test.rb | 6 +- .../storage/callbacks/activate_luks_test.rb | 22 +- .../callbacks/activate_multipath_test.rb | 19 +- .../agama/storage/callbacks/activate_test.rb | 4 +- .../storage/callbacks/commit_error_test.rb | 18 +- .../agama/storage/callbacks/commit_test.rb | 4 +- service/test/agama/storage/manager_test.rb | 6 +- web/package/agama-web-ui.changes | 5 + web/src/api/questions.ts | 44 +- .../questions/GenericQuestion.test.tsx | 18 +- .../components/questions/GenericQuestion.tsx | 8 +- .../LoadConfigRetryQuestion.test.tsx | 21 +- .../questions/LoadConfigRetryQuestion.tsx | 8 +- .../questions/LuksActivationQuestion.test.tsx | 22 +- .../questions/LuksActivationQuestion.tsx | 10 +- .../questions/PackageErrorQuestion.test.tsx | 12 +- .../questions/PackageErrorQuestion.tsx | 8 +- .../questions/QuestionActions.test.tsx | 27 +- .../components/questions/QuestionActions.tsx | 28 +- .../questions/QuestionWithPassword.test.tsx | 16 +- .../questions/QuestionWithPassword.tsx | 13 +- .../components/questions/Questions.test.tsx | 60 ++- web/src/components/questions/Questions.tsx | 19 +- .../RegistrationCertificateQuestion.test.tsx | 24 +- .../RegistrationCertificateQuestion.tsx | 8 +- .../questions/UnsupportedAutoYaST.test.tsx | 10 +- .../questions/UnsupportedAutoYaST.tsx | 8 +- web/src/queries/questions.ts | 2 +- web/src/types/questions.ts | 50 +- 135 files changed, 1936 insertions(+), 3327 deletions(-) delete mode 100644 doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml delete mode 100644 doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml delete mode 100644 doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml delete mode 100644 doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml delete mode 100644 doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml delete mode 100644 doc/dbus/org.opensuse.Agama1.Questions.doc.xml delete mode 100644 rust/agama-lib/src/proxies/questions.rs delete mode 100644 rust/agama-lib/src/proxies/questions/base.rs delete mode 100644 rust/agama-lib/src/proxies/questions/generic.rs delete mode 100644 rust/agama-lib/src/proxies/questions/with_password.rs delete mode 100644 rust/agama-lib/src/questions/answers/custom.rs delete mode 100644 rust/agama-lib/src/questions/answers/default.rs delete mode 100644 rust/agama-lib/src/questions/answers/strategy.rs delete mode 100644 rust/agama-lib/src/questions/config.rs delete mode 100644 rust/agama-lib/src/questions/error.rs delete mode 100644 rust/agama-lib/src/questions/model.rs delete mode 100644 rust/agama-lib/src/questions/store.rs delete mode 100644 rust/agama-server/src/agama-dbus-server.rs delete mode 100644 rust/agama-server/src/questions.rs delete mode 100644 rust/agama-server/src/questions/web.rs delete mode 100644 rust/agama-server/src/web/docs/questions.rs create mode 100644 rust/agama-utils/src/api/question.rs create mode 100644 rust/agama-utils/src/question.rs rename rust/{agama-lib/src/questions/answers.rs => agama-utils/src/question/config.rs} (85%) create mode 100644 rust/agama-utils/src/question/message.rs create mode 100644 rust/agama-utils/src/question/service.rs create mode 100644 rust/agama-utils/src/question/start.rs delete mode 100644 rust/share/org.opensuse.Agama1.service create mode 100644 service/lib/agama/answer.rb delete mode 100644 service/lib/agama/dbus/clients/question.rb delete mode 100644 service/lib/agama/dbus/clients/questions.rb create mode 100644 service/lib/agama/http/clients/questions.rb delete mode 100644 service/test/agama/dbus/clients/question_test.rb delete mode 100644 service/test/agama/dbus/clients/questions_test.rb create mode 100644 service/test/agama/question_test.rb diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml deleted file mode 100644 index 0bc9fe9d10..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.Generic.bus.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml deleted file mode 100644 index f9dd49994c..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.WithPassword.bus.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml deleted file mode 100644 index 6a85392d5c..0000000000 --- a/doc/dbus/bus/org.opensuse.Agama1.Questions.bus.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/bus/seed.sh b/doc/dbus/bus/seed.sh index 664a52f6fe..a2ef64f46c 100755 --- a/doc/dbus/bus/seed.sh +++ b/doc/dbus/bus/seed.sh @@ -1,6 +1,6 @@ #!/bin/bash abusctl() { - busctl --address=unix:path=/run/agama/bus "$@" + busctl --address=unix:path=/run/agama/bus "$@" } # a stdio filter for XML introspection, @@ -8,11 +8,11 @@ abusctl() { # - remove detailed introspection of _child_ nodes # - make interfaces order deterministic by sorting them cleanup() { - # also remove the DTD declaration - # otherwise xmlstarlet will complain about it not being available - sed -e '/^ $DD.$1.bus.xml + abusctl tree --list $DD.${1%.*} + abusctl introspect --xml-interface $DD.${1%.*} $SS/${1//./\/} | + cleanup \ + >$DD.$1.bus.xml } look Manager1 @@ -32,36 +32,8 @@ look Software1 look Software1.Proposal look Storage1 -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions \ - | cleanup \ - > ${DD}1.Questions.bus.xml - -abusctl call \ - ${DD}1 \ - ${SS}1/Questions \ - ${DD}1.Questions \ - New "ssassa{ss}" "org.bands.Clash" "should I stay or should I go" 2 yes no yes 0 -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions/0 | - cleanup \ - >${DD}1.Questions.Generic.bus.xml - -abusctl call \ - ${DD}1 \ - ${SS}1/Questions \ - ${DD}1.Questions \ - NewWithPassword "ssassa{ss}" "world.MiddleEarth.Moria.gate1" "Speak friend and enter" 2 enter giveup giveup 0 -abusctl introspect --xml-interface \ - ${DD}1 \ - ${SS}1/Questions/1 | - cleanup \ - >${DD}1.Questions.WithPassword.bus.xml - abusctl introspect --xml-interface \ ${DD}.Manager1 \ - ${SS}/Users1 \ - | cleanup \ - >${DD}.Users1.bus.xml + ${SS}/Users1 | + cleanup \ + >${DD}.Users1.bus.xml diff --git a/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml deleted file mode 100644 index a58f40cbab..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.Generic.doc.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml deleted file mode 100644 index 5e93a51921..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.WithPassword.doc.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/doc/dbus/org.opensuse.Agama1.Questions.doc.xml b/doc/dbus/org.opensuse.Agama1.Questions.doc.xml deleted file mode 100644 index 9e47507d8a..0000000000 --- a/doc/dbus/org.opensuse.Agama1.Questions.doc.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/live/src/config.sh b/live/src/config.sh index 1aac6efedf..d5c71012c7 100644 --- a/live/src/config.sh +++ b/live/src/config.sh @@ -35,7 +35,7 @@ REPO="/etc/zypp/repos.d/agama-${DISTRO}.repo" if [ -f "${REPO}.disabled" ]; then mv "${REPO}.disabled" $REPO fi -rm /etc/zypp/repos.d/*.disabled +rm -f /etc/zypp/repos.d/*.disabled # configure the repositories in the Live system # import the OBS key for the systemsmanagement OBS project diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 53f62d239f..f884cc2de3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -22,6 +22,7 @@ name = "agama-autoinstall" version = "0.1.0" dependencies = [ "agama-lib", + "agama-utils", "anyhow", "tempfile", "thiserror 2.0.16", @@ -34,6 +35,7 @@ name = "agama-cli" version = "1.0.0" dependencies = [ "agama-lib", + "agama-utils", "anyhow", "async-trait", "chrono", diff --git a/rust/agama-autoinstall/Cargo.toml b/rust/agama-autoinstall/Cargo.toml index 48df1a7cc0..9eda50e3b7 100644 --- a/rust/agama-autoinstall/Cargo.toml +++ b/rust/agama-autoinstall/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } anyhow = { version = "1.0.98" } tempfile = "3.20.0" thiserror = "2.0.12" diff --git a/rust/agama-autoinstall/src/questions.rs b/rust/agama-autoinstall/src/questions.rs index 22113e929c..c91e3f1aba 100644 --- a/rust/agama-autoinstall/src/questions.rs +++ b/rust/agama-autoinstall/src/questions.rs @@ -20,15 +20,8 @@ //! This module offers a mechanism to ask questions to users. -use std::collections::HashMap; - -use agama_lib::{ - http::BaseHTTPClient, - questions::{ - http_client::HTTPClient as QuestionsHTTPClient, - model::{GenericQuestion, Question}, - }, -}; +use agama_lib::{http::BaseHTTPClient, questions::http_client::HTTPClient as QuestionsHTTPClient}; +use agama_utils::api::question::QuestionSpec; pub struct UserQuestions { questions: QuestionsHTTPClient, @@ -43,24 +36,13 @@ impl UserQuestions { /// Asks the user whether to retry loading the profile. pub async fn should_retry(&self, text: &str, error: &str) -> anyhow::Result { - let data = HashMap::from([("error".to_string(), error.to_string())]); - let generic = GenericQuestion { - id: None, - class: "load.retry".to_string(), - text: text.to_string(), - options: vec!["Yes".to_string(), "No".to_string()], - default_option: "No".to_string(), - data, - }; - let question = Question { - generic, - with_password: None, - }; + let question = QuestionSpec::new(text, "load.retry") + .with_actions(&[("yes", "Yes"), ("no", "No")]) + .with_default_action("no") + .with_data(&[("error", error)]); + let question = self.questions.create_question(&question).await?; - let answer = self - .questions - .get_answer(question.generic.id.unwrap()) - .await?; - Ok(answer.generic.answer == "Yes") + let answer = self.questions.get_answer(question.id).await?; + Ok(answer.action == "yes") } } diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 9daa10479d..da6110de27 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" [dependencies] clap = { version = "4.5.19", features = ["derive", "wrap_help"] } agama-lib = { path = "../agama-lib" } +agama-utils = { path = "../agama-utils" } serde_json = "1.0.128" indicatif = "0.17.8" thiserror = "2.0.12" diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 3cde3e6839..87d09b2531 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -18,10 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{ - connection, http::BaseHTTPClient, proxies::questions::QuestionsProxy, - questions::http_client::HTTPClient, -}; +use std::{fs::File, io::BufReader}; + +use agama_lib::{http::BaseHTTPClient, questions::http_client::HTTPClient}; +use agama_utils::api::question::{AnswerRule, Policy, QuestionSpec}; use anyhow::anyhow; use clap::{Args, Subcommand, ValueEnum}; @@ -62,53 +62,48 @@ pub enum Modes { NonInteractive, } -async fn set_mode(proxy: QuestionsProxy<'_>, value: Modes) -> anyhow::Result<()> { - proxy - .set_interactive(value == Modes::Interactive) - .await - .map_err(|e| e.into()) +async fn set_mode(client: HTTPClient, value: Modes) -> anyhow::Result<()> { + let policy = if value == Modes::Interactive { + Policy::User + } else { + Policy::Auto + }; + + client.set_mode(policy).await?; + Ok(()) } -async fn set_answers(proxy: QuestionsProxy<'_>, path: String) -> anyhow::Result<()> { - proxy - .add_answer_file(path.as_str()) - .await - .map_err(|e| e.into()) +async fn set_answers(client: HTTPClient, path: &str) -> anyhow::Result<()> { + let file = File::open(&path)?; + let reader = BufReader::new(file); + let rules: Vec = serde_json::from_reader(reader)?; + client.set_answers(rules).await?; + Ok(()) } -async fn list_questions(client: BaseHTTPClient) -> anyhow::Result<()> { - let client = HTTPClient::new(client); - let questions = client.list_questions().await?; - // FIXME: if performance is bad, we can skip converting json from http to struct and then - // serialize it, but it won't be pretty string +async fn list_questions(client: HTTPClient) -> anyhow::Result<()> { + let questions = client.get_questions().await?; let questions_json = serde_json::to_string_pretty(&questions)?; println!("{}", questions_json); Ok(()) } -async fn ask_question(client: BaseHTTPClient) -> anyhow::Result<()> { - let client = HTTPClient::new(client); - let question = serde_json::from_reader(std::io::stdin())?; - - let created_question = client.create_question(&question).await?; - let Some(id) = created_question.generic.id else { - return Err(anyhow!("The created question does not have an ID")); - }; - let answer = client.get_answer(id).await?; +async fn ask_question(client: HTTPClient) -> anyhow::Result<()> { + let spec: QuestionSpec = serde_json::from_reader(std::io::stdin())?; + let question = client.create_question(&spec).await?; + let answer = client.get_answer(question.id).await?; let answer_json = serde_json::to_string_pretty(&answer).map_err(|e| anyhow!(e.to_string()))?; println!("{}", answer_json); - client.delete_question(id).await?; + client.delete_question(question.id).await?; Ok(()) } pub async fn run(client: BaseHTTPClient, subcommand: QuestionsCommands) -> anyhow::Result<()> { - let connection = connection().await?; - let proxy = QuestionsProxy::new(&connection).await?; - + let client = HTTPClient::new(client); match subcommand { - QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await, - QuestionsCommands::Answers { path } => set_answers(proxy, path).await, + QuestionsCommands::Mode(value) => set_mode(client, value.value).await, + QuestionsCommands::Answers { path } => set_answers(client, &path).await, QuestionsCommands::List => list_questions(client).await, QuestionsCommands::Ask => ask_question(client).await, } diff --git a/rust/agama-l10n/src/config.rs b/rust/agama-l10n/src/config.rs index 3103c289c3..4c5eaf80e3 100644 --- a/rust/agama-l10n/src/config.rs +++ b/rust/agama-l10n/src/config.rs @@ -20,8 +20,7 @@ use crate::service; use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; -use agama_utils::api; -use agama_utils::api::l10n::SystemInfo; +use agama_utils::api::{self, l10n::SystemInfo}; #[derive(Clone, PartialEq)] pub struct Config { diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 08bdb7e899..31145c86a8 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -19,9 +19,13 @@ // find current contact information at www.suse.com. use agama_locale_data::{KeymapId, LocaleId}; -use agama_utils::actor::Message; -use agama_utils::api; -use agama_utils::api::l10n::{Proposal, SystemInfo}; +use agama_utils::{ + actor::Message, + api::{ + self, + l10n::{Proposal, SystemInfo}, + }, +}; #[derive(Clone)] pub struct GetSystem; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index 553f1cc3d2..beed1fadc2 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -22,13 +22,16 @@ use crate::config::Config; use crate::message; use crate::model::ModelAdapter; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; -use agama_utils::actor::{self, Actor, Handler, MessageHandler}; -use agama_utils::api; -use agama_utils::api::event; -use agama_utils::api::event::Event; -use agama_utils::api::l10n::{Proposal, SystemConfig, SystemInfo}; -use agama_utils::api::{Issue, IssueSeverity, IssueSource, Scope}; -use agama_utils::issue; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, + event::{self, Event}, + l10n::{Proposal, SystemConfig, SystemInfo}, + Issue, IssueSeverity, IssueSource, Scope, + }, + issue, +}; use async_trait::async_trait; use tokio::sync::broadcast; diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index ef8aa5d299..0129524e4d 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -21,9 +21,11 @@ use crate::model::Model; use crate::monitor::{self, Monitor}; use crate::service::{self, Service}; -use agama_utils::actor::{self, Handler}; -use agama_utils::api::event; -use agama_utils::issue; +use agama_utils::{ + actor::{self, Handler}, + api::event, + issue, +}; #[derive(thiserror::Error, Debug)] pub enum Error { diff --git a/rust/agama-lib/src/http/base_http_client.rs b/rust/agama-lib/src/http/base_http_client.rs index bae02a1b93..3895da60bf 100644 --- a/rust/agama-lib/src/http/base_http_client.rs +++ b/rust/agama-lib/src/http/base_http_client.rs @@ -46,7 +46,7 @@ pub enum BaseHTTPClientError { /// Usage should be just thin layer in domain specific client. /// /// ```no_run -/// use agama_lib::questions::model::Question; +/// use agama_utils::api::question::Question; /// use agama_lib::http::{BaseHTTPClient, BaseHTTPClientError}; /// /// async fn get_questions() -> Result, BaseHTTPClientError> { diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index f1e82354ed..ffeded93e8 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -26,7 +26,6 @@ use crate::context::InstallationContext; use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; -use crate::questions::config::QuestionsConfig; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ @@ -88,8 +87,6 @@ pub struct InstallSettings { pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] pub zfcp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub questions: Option, } impl InstallSettings { diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index b0b5f38a60..89e2435382 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -27,8 +27,6 @@ pub use service_status::ServiceStatusProxy; mod manager1; pub use manager1::Manager1Proxy; -pub mod questions; - mod locale; pub use locale::LocaleMixinProxy; diff --git a/rust/agama-lib/src/proxies/questions.rs b/rust/agama-lib/src/proxies/questions.rs deleted file mode 100644 index 97d74f7517..0000000000 --- a/rust/agama-lib/src/proxies/questions.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -mod base; -pub use base::QuestionsProxy; - -mod generic; -pub use generic::GenericProxy as GenericQuestionProxy; - -mod with_password; -pub use with_password::WithPasswordProxy as QuestionWithPasswordProxy; diff --git a/rust/agama-lib/src/proxies/questions/base.rs b/rust/agama-lib/src/proxies/questions/base.rs deleted file mode 100644 index abb97ffbd9..0000000000 --- a/rust/agama-lib/src/proxies/questions/base.rs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::ObjectManagerProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions", - default_path = "/org/opensuse/Agama1/Questions", - assume_defaults = true -)] -pub trait Questions { - /// AddAnswerFile method - fn add_answer_file(&self, path: &str) -> zbus::Result<()>; - - /// Remove Answers method - fn remove_answers(&self) -> zbus::Result<()>; - - /// Delete method - fn delete(&self, question: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; - - /// New method - #[zbus(name = "New")] - fn new_question( - &self, - class: &str, - text: &str, - options: &[&str], - default_option: &str, - data: std::collections::HashMap<&str, &str>, - ) -> zbus::Result; - - /// NewWithPassword method - fn new_with_password( - &self, - class: &str, - text: &str, - options: &[&str], - default_option: &str, - data: std::collections::HashMap<&str, &str>, - ) -> zbus::Result; - - /// Interactive property - #[zbus(property)] - fn interactive(&self) -> zbus::Result; - #[zbus(property)] - fn set_interactive(&self, value: bool) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/proxies/questions/generic.rs b/rust/agama-lib/src/proxies/questions/generic.rs deleted file mode 100644 index 69f8bddbbe..0000000000 --- a/rust/agama-lib/src/proxies/questions/generic.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions.Generic` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.WithPassword.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions.Generic", - assume_defaults = true -)] -pub trait Generic { - /// Answer property - #[zbus(property)] - fn answer(&self) -> zbus::Result; - #[zbus(property)] - fn set_answer(&self, value: &str) -> zbus::Result<()>; - - /// Class property - #[zbus(property)] - fn class(&self) -> zbus::Result; - - /// Data property - #[zbus(property)] - fn data(&self) -> zbus::Result>; - - /// DefaultOption property - #[zbus(property)] - fn default_option(&self) -> zbus::Result; - - /// Id property - #[zbus(property)] - fn id(&self) -> zbus::Result; - - /// Options property - #[zbus(property)] - fn options(&self) -> zbus::Result>; - - /// Text property - #[zbus(property)] - fn text(&self) -> zbus::Result; -} diff --git a/rust/agama-lib/src/proxies/questions/with_password.rs b/rust/agama-lib/src/proxies/questions/with_password.rs deleted file mode 100644 index 5bb2af7082..0000000000 --- a/rust/agama-lib/src/proxies/questions/with_password.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Questions.WithPassword` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama1.Questions.WithPassword.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::IntrospectableProxy`] -//! * [`zbus::fdo::PeerProxy`] -//! * [`zbus::fdo::PropertiesProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama1", - interface = "org.opensuse.Agama1.Questions.WithPassword", - assume_defaults = true -)] -pub trait WithPassword { - /// Password property - #[zbus(property)] - fn password(&self) -> zbus::Result; - #[zbus(property)] - fn set_password(&self, value: &str) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/questions.rs b/rust/agama-lib/src/questions.rs index 67fc17f103..04c0712c0e 100644 --- a/rust/agama-lib/src/questions.rs +++ b/rust/agama-lib/src/questions.rs @@ -20,113 +20,4 @@ //! Data model for Agama questions -use std::collections::HashMap; -pub mod answers; -pub mod config; -pub mod error; pub mod http_client; -pub mod model; -pub mod store; -pub use error::QuestionsError; - -/// Basic generic question that fits question without special needs -/// -/// structs living directly under questions namespace is for D-Bus usage and holds complete questions data -/// for user side data model see questions::model -#[derive(Clone, Debug)] -pub struct GenericQuestion { - /// numeric id used to identify question on D-Bus - pub id: u32, - /// class of questions. Similar kinds of questions share same class. - /// It is dot separated list of elements. Examples are - /// `storage.luks.actication` or `software.repositories.unknown_gpg` - pub class: String, - /// Textual representation of question. Expected to be read by people - pub text: String, - /// possible answers for question - pub options: Vec, - /// default answer. Can be used as hint or preselection and it is used as answer for unattended questions. - pub default_option: String, - /// additional data to help identify questions. Useful for automatic answers. It is question specific. - pub data: HashMap, - /// Confirmed answer. If empty then not answered yet. - pub answer: String, -} - -impl GenericQuestion { - pub fn new( - id: u32, - class: String, - text: String, - options: Vec, - default_option: String, - data: HashMap, - ) -> Self { - Self { - id, - class, - text, - options, - default_option, - data, - answer: String::from(""), - } - } - - /// Gets object path of given question. It is useful as parameter - /// for deleting it. - /// - /// # Examples - /// - /// ``` - /// use std::collections::HashMap; - /// use agama_lib::questions::GenericQuestion; - /// let question = GenericQuestion::new( - /// 2, - /// "test_class".to_string(), - /// "Really?".to_string(), - /// vec!["Yes".to_string(), "No".to_string()], - /// "No".to_string(), - /// HashMap::new() - /// ); - /// assert_eq!(question.object_path(), "/org/opensuse/Agama1/Questions/2".to_string()); - /// ``` - pub fn object_path(&self) -> String { - format!("/org/opensuse/Agama1/Questions/{}", self.id) - } -} - -/// Composition for questions which include password. -/// -/// ## Extension -/// If there is need to provide more mixins, then this structure does not work -/// well as it is hard do various combinations. Idea is when need for more -/// mixins arise to convert it to Question Struct that have optional mixins -/// inside like -/// -/// ```no_compile -/// struct Question { -/// base: GenericQuestion, -/// with_password: Option, -/// another_mixin: Option -/// } -/// ``` -/// -/// This way all handling code can check if given mixin is used and -/// act appropriate. -#[derive(Clone, Debug)] -pub struct WithPassword { - /// Luks password. Empty means no password set. - pub password: String, - /// rest of question data that is same as for other questions - pub base: GenericQuestion, -} - -impl WithPassword { - pub fn new(base: GenericQuestion) -> Self { - Self { - password: "".to_string(), - base, - } - } -} diff --git a/rust/agama-lib/src/questions/answers/custom.rs b/rust/agama-lib/src/questions/answers/custom.rs deleted file mode 100644 index e335e43ab8..0000000000 --- a/rust/agama-lib/src/questions/answers/custom.rs +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use crate::questions::{GenericQuestion, QuestionsError}; -use serde::{Deserialize, Serialize}; - -use super::AnswerStrategy; - -/// Data structure for single JSON answer. For variables specification see -/// corresponding [agama_lib::questions::GenericQuestion] fields. -/// The *matcher* part is: `class`, `text`, `data`. -/// The *answer* part is: `answer`, `password`. -#[derive(Clone, Serialize, Deserialize, PartialEq, Debug, utoipa::ToSchema)] -pub struct Answer { - #[serde(skip_serializing_if = "Option::is_none")] - pub class: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - /// A matching GenericQuestion can have other data fields too - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option>, - /// The answer text is the only mandatory part of an Answer - pub answer: String, - /// All possible mixins have to be here, so they can be specified in an Answer - #[serde(skip_serializing_if = "Option::is_none")] - pub password: Option, -} - -impl Answer { - /// Determines whether the answer responds to the given question. - /// - /// * `question`: question to compare with. - pub fn responds(&self, question: &GenericQuestion) -> bool { - if let Some(class) = &self.class { - if question.class != *class { - return false; - } - } - - if let Some(text) = &self.text { - if question.text != *text { - return false; - } - } - - if let Some(data) = &self.data { - return data.iter().all(|(key, value)| { - let Some(e_val) = question.data.get(key) else { - return false; - }; - - e_val == value - }); - } - - true - } -} - -/// Data structure holding list of Answer. -/// The first matching Answer is used, even if there is -/// a better (more specific) match later in the list. -#[derive(Serialize, Deserialize, PartialEq, Debug)] -pub struct Answers { - answers: Vec, -} - -impl Answers { - pub fn new(answers: Vec) -> Self { - Self { answers } - } - pub fn new_from_file(path: &str) -> Result { - let f = std::fs::File::open(path).map_err(QuestionsError::IO)?; - let result: Self = serde_json::from_reader(f).map_err(QuestionsError::Deserialize)?; - - Ok(result) - } - - pub fn id() -> u8 { - 2 - } - - fn find_answer(&self, question: &GenericQuestion) -> Option<&Answer> { - self.answers.iter().find(|a| a.responds(question)) - } -} - -impl AnswerStrategy for Answers { - fn id(&self) -> u8 { - Answers::id() - } - - fn answer(&self, question: &GenericQuestion) -> Option { - let answer = self.find_answer(question); - answer.map(|answer| answer.answer.clone()) - } - - fn answer_with_password( - &self, - question: &crate::questions::WithPassword, - ) -> (Option, Option) { - // use here fact that with password share same matchers as generic one - let answer = self.find_answer(&question.base); - if let Some(answer) = answer { - (Some(answer.answer.clone()), answer.password.clone()) - } else { - (None, None) - } - } -} - -#[cfg(test)] -mod tests { - use crate::questions::{answers::AnswerStrategy, GenericQuestion, WithPassword}; - - use super::*; - - // set of fixtures for test - fn get_answers() -> Answers { - Answers { - answers: vec![ - Answer { - class: Some("without_data".to_string()), - data: None, - text: None, - answer: "Ok".to_string(), - password: Some("testing pwd".to_string()), // ignored for generic question - }, - Answer { - class: Some("with_data".to_string()), - data: Some(HashMap::from([ - ("data1".to_string(), "value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ])), - text: None, - answer: "Maybe".to_string(), - password: None, - }, - Answer { - class: Some("with_data".to_string()), - data: Some(HashMap::from([( - "data1".to_string(), - "another_value1".to_string(), - )])), - text: None, - answer: "Ok2".to_string(), - password: None, - }, - ], - } - } - - #[test] - fn test_class_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "JFYI we will kill all bugs during installation.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(Some("Ok".to_string()), answers.answer(&question)); - } - - #[test] - fn test_no_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "non-existing".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(None, answers.answer(&question)); - } - - #[test] - fn test_with_password() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "Please provide password for dooms day.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - let with_password = WithPassword { - password: "".to_string(), - base: question, - }; - let expected = (Some("Ok".to_string()), Some("testing pwd".to_string())); - assert_eq!(expected, answers.answer_with_password(&with_password)); - } - - /// An Answer matches on *data* if all its keys and values are in the GenericQuestion *data*. - /// The GenericQuestion can have other *data* keys. - #[test] - fn test_partial_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(Some("Maybe".to_string()), answers.answer(&question)); - } - - #[test] - fn test_full_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "another_value1".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(Some("Ok2".to_string()), answers.answer(&question)); - } - - #[test] - fn test_no_data_match() { - let answers = get_answers(); - let question = GenericQuestion { - id: 1, - class: "with_data".to_string(), - text: "Hard question?".to_string(), - options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::from([ - ("data1".to_string(), "different value".to_string()), - ("data2".to_string(), "value2".to_string()), - ("data3".to_string(), "value3".to_string()), - ]), - answer: "".to_string(), - }; - assert_eq!(None, answers.answer(&question)); - } - - // A "universal answer" with unspecified class+text+data is possible - #[test] - fn test_universal_match() { - let answers = Answers { - answers: vec![Answer { - class: None, - text: None, - data: None, - answer: "Yes".into(), - password: None, - }], - }; - let question = GenericQuestion { - id: 1, - class: "without_data".to_string(), - text: "JFYI we will kill all bugs during installation.".to_string(), - options: vec!["Ok".to_string(), "Cancel".to_string()], - default_option: "Cancel".to_string(), - data: HashMap::new(), - answer: "".to_string(), - }; - assert_eq!(Some("Yes".to_string()), answers.answer(&question)); - } - - #[test] - fn test_loading_json() { - let file = r#" - { - "answers": [ - { - "class": "without_data", - "answer": "OK" - }, - { - "class": "with_data", - "data": { - "testk": "testv", - "testk2": "testv2" - }, - "answer": "Cancel" - }] - } - "#; - let result: Answers = serde_json::from_str(file).expect("failed to load JSON string"); - assert_eq!(result.answers.len(), 2); - } -} diff --git a/rust/agama-lib/src/questions/answers/default.rs b/rust/agama-lib/src/questions/answers/default.rs deleted file mode 100644 index dc308f8ef6..0000000000 --- a/rust/agama-lib/src/questions/answers/default.rs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::questions::{GenericQuestion, WithPassword}; - -use super::AnswerStrategy; - -/// AnswerStrategy that provides as answer the default option. -pub struct DefaultAnswers; - -impl DefaultAnswers { - pub fn id() -> u8 { - 1 - } -} - -impl AnswerStrategy for DefaultAnswers { - fn id(&self) -> u8 { - DefaultAnswers::id() - } - - fn answer(&self, question: &GenericQuestion) -> Option { - Some(question.default_option.clone()) - } - - fn answer_with_password(&self, question: &WithPassword) -> (Option, Option) { - (Some(question.base.default_option.clone()), None) - } -} diff --git a/rust/agama-lib/src/questions/answers/strategy.rs b/rust/agama-lib/src/questions/answers/strategy.rs deleted file mode 100644 index 3f4ef6b1c7..0000000000 --- a/rust/agama-lib/src/questions/answers/strategy.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::questions::{GenericQuestion, WithPassword}; - -/// Trait for objects that can provide answers to all kind of Question. -/// -/// If no strategy is selected or the answer is unknown, then ask to the user. -pub trait AnswerStrategy { - /// Id for quick runtime inspection of strategy type - fn id(&self) -> u8; - /// Provides answer for generic question - /// - /// I gets as argument the question to answer. Returned value is `answer` - /// property or None. If `None` is used, it means that this object does not - /// answer to given question. - fn answer(&self, question: &GenericQuestion) -> Option; - /// Provides answer and password for base question with password - /// - /// I gets as argument the question to answer. Returned value is pair - /// of `answer` and `password` properties. If `None` is used in any - /// position it means that this object does not respond to given property. - /// - /// It is object responsibility to provide correct pair. For example if - /// possible answer can be "Ok" and "Cancel". Then for `Ok` password value - /// should be provided and for `Cancel` it can be `None`. - fn answer_with_password(&self, question: &WithPassword) -> (Option, Option); -} diff --git a/rust/agama-lib/src/questions/config.rs b/rust/agama-lib/src/questions/config.rs deleted file mode 100644 index 774a2d98a7..0000000000 --- a/rust/agama-lib/src/questions/config.rs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -use super::answers::Answer; - -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum QuestionsPolicy { - /// Automatically answer questions. - Auto, - /// Ask the user. - User, -} - -/// Questions configuration. -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionsConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub policy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub answers: Option>, -} diff --git a/rust/agama-lib/src/questions/error.rs b/rust/agama-lib/src/questions/error.rs deleted file mode 100644 index 2b5b1140f8..0000000000 --- a/rust/agama-lib/src/questions/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -#[derive(thiserror::Error, Debug)] -pub enum QuestionsError { - #[error("Could not read the answers file: {0}")] - IO(std::io::Error), - #[error("Could not deserialize the answers file: {0}")] - Deserialize(serde_json::Error), -} diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 0b2991f157..b44dac91ad 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -20,20 +20,24 @@ use std::time::Duration; -use reqwest::StatusCode; +use agama_utils::api::{ + config::Patch, + question::{ + Answer, AnswerRule, Config as QuestionsConfig, Policy, Question, QuestionSpec, + UpdateOperation, + }, + Config, +}; use tokio::time::sleep; use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use super::{ - config::QuestionsConfig, - model::{self, Answer, Question}, -}; - #[derive(Debug, thiserror::Error)] pub enum QuestionsHTTPClientError { #[error(transparent)] HTTP(#[from] BaseHTTPClientError), + #[error("Unknown question with ID {0}")] + UnknownQuestion(u32), } pub struct HTTPClient { @@ -45,41 +49,37 @@ impl HTTPClient { Self { client } } - pub async fn list_questions(&self) -> Result, QuestionsHTTPClientError> { - Ok(self.client.get("/questions").await?) + pub async fn get_questions(&self) -> Result, QuestionsHTTPClientError> { + Ok(self.client.get("/v2/questions").await?) } /// Creates question and return newly created question including id pub async fn create_question( &self, - question: &Question, + question: &QuestionSpec, ) -> Result { - Ok(self.client.post("/questions", question).await?) + Ok(self.client.post("/v2/questions", question).await?) } - /// non blocking varient of checking if question has already answer - pub async fn try_answer( + pub async fn get_question( &self, - question_id: u32, - ) -> Result, QuestionsHTTPClientError> { - let path = format!("/questions/{}/answer", question_id); - let result: Result, _> = self.client.get(path.as_str()).await; - - if let Err(BaseHTTPClientError::BackendError(code, ref _body_s)) = result { - if code == StatusCode::NOT_FOUND { - return Ok(None); - } - } - - Ok(result?) + id: u32, + ) -> Result, QuestionsHTTPClientError> { + let questions = self.get_questions().await?; + Ok(questions.into_iter().find(|q| q.id == id)) } /// Blocking variant of getting answer for given question. pub async fn get_answer(&self, question_id: u32) -> Result { loop { - let answer = self.try_answer(question_id).await?; - if let Some(result) = answer { - return Ok(result); + let question = self.get_question(question_id).await?; + match question { + Some(question) => { + if let Some(answer) = question.answer { + return Ok(answer); + } + } + None => return Err(QuestionsHTTPClientError::UnknownQuestion(question_id)), } let duration = Duration::from_secs(1); sleep(duration).await; @@ -89,30 +89,56 @@ impl HTTPClient { } } - pub async fn delete_question(&self, question_id: u32) -> Result<(), QuestionsHTTPClientError> { - let path = format!("/questions/{}", question_id); - Ok(self.client.delete_void(path.as_str()).await?) - } + pub async fn set_mode(&self, policy: Policy) -> Result<(), QuestionsHTTPClientError> { + let questions = QuestionsConfig { + policy: Some(policy), + ..Default::default() + }; + let config = Config { + questions: Some(questions), + ..Default::default() + }; + + let patch = Patch { + update: Some(config), + }; - pub async fn get_config(&self) -> Result { - Ok(QuestionsConfig::default()) + _ = self.client.patch_void("/v2/config", &patch).await?; + Ok(()) } - pub async fn set_config( + pub async fn set_answers( &self, - config: &QuestionsConfig, + answers: Vec, ) -> Result<(), QuestionsHTTPClientError> { - Ok(self.client.put_void("/questions/config", config).await?) + let questions = QuestionsConfig { + answers, + ..Default::default() + }; + let config = Config { + questions: Some(questions), + ..Default::default() + }; + + let patch = Patch { + update: Some(config), + }; + self.client.patch_void("/v2/config", &patch).await?; + Ok(()) + } + + pub async fn delete_question(&self, id: u32) -> Result<(), QuestionsHTTPClientError> { + let update = UpdateOperation::Delete { id }; + self.client.patch_void("/v2/questions", &update).await?; + Ok(()) } } #[cfg(test)] mod test { - use super::model::{GenericAnswer, GenericQuestion}; - use super::*; + use super::HTTPClient; use crate::http::BaseHTTPClient; use httpmock::prelude::*; - use std::collections::HashMap; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" @@ -122,136 +148,40 @@ mod test { } #[test] - async fn test_list_questions() -> Result<(), Box> { + async fn test_get_questions() -> Result<(), Box> { let server = MockServer::start(); let client = questions_client(server.url("/api")); let mock = server.mock(|when, then| { - when.method(GET).path("/api/questions"); + when.method(GET).path("/api/v2/questions"); then.status(200) .header("content-type", "application/json") .body( r#"[ { - "generic": { - "id": 42, - "class": "foo", - "text": "Shape", - "options": ["bouba","kiki"], - "defaultOption": "bouba", - "data": { "a": "A" } - }, - "withPassword":null + "id": 42, + "class": "foo", + "text": "Shape", + "actions": [ + { "id": "next", "label": "Next" }, + { "id": "skip", "label": "Skip" } + ], + "defaultAction": "skip", + "data": { "id": "42" } } ]"#, ); }); - let expected: Vec = vec![Question { - generic: GenericQuestion { - id: Some(42), - class: "foo".to_owned(), - text: "Shape".to_owned(), - options: vec!["bouba".to_owned(), "kiki".to_owned()], - default_option: "bouba".to_owned(), - data: HashMap::from([("a".to_owned(), "A".to_owned())]), - }, - with_password: None, - }]; - let actual = client.list_questions().await?; - assert_eq!(actual, expected); - - mock.assert(); - Ok(()) - } - - #[test] - async fn test_create_question() -> Result<(), Box> { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/api/questions") - .header("content-type", "application/json") - .body( - r#"{"generic":{"id":null,"class":"fiction.hamlet","text":"To be or not to be","options":["to be","not to be"],"defaultOption":"to be","data":{"a":"A"}},"withPassword":null}"# - ); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "generic": { - "id": 7, - "class": "fiction.hamlet", - "text": "To be or not to be", - "options": ["to be","not to be"], - "defaultOption": "to be", - "data": { "a": "A" } - }, - "withPassword":null - }"#, - ); - }); - let client = questions_client(server.url("/api")); - - let posted_question = Question { - generic: GenericQuestion { - id: None, - class: "fiction.hamlet".to_owned(), - text: "To be or not to be".to_owned(), - options: vec!["to be".to_owned(), "not to be".to_owned()], - default_option: "to be".to_owned(), - data: HashMap::from([("a".to_owned(), "A".to_owned())]), - }, - with_password: None, - }; - let mut expected_question = posted_question.clone(); - expected_question.generic.id = Some(7); - - let actual = client.create_question(&posted_question).await?; - assert_eq!(actual, expected_question); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - mock.assert(); - Ok(()) - } - - #[test] - async fn test_try_answer() -> Result<(), Box> { - let server = MockServer::start(); - let client = questions_client(server.url("/api")); - - let mock = server.mock(|when, then| { - when.method(GET).path("/api/questions/42/answer"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "generic": { - "answer": "maybe" - }, - "withPassword":null - }"#, - ); - }); - - let expected = Some(Answer { - generic: GenericAnswer { - answer: "maybe".to_owned(), - }, - with_password: None, - }); - let actual = client.try_answer(42).await?; - assert_eq!(actual, expected); + let questions = client.get_questions().await?; - let mock2 = server.mock(|when, then| { - when.method(GET).path("/api/questions/666/answer"); - then.status(404); - }); - let actual = client.try_answer(666).await?; - assert_eq!(actual, None); + let question = questions.first().unwrap(); + assert_eq!(question.id, 42); + assert_eq!(question.spec.class, "foo"); + assert_eq!(question.spec.text, "Shape"); + assert_eq!(question.spec.default_action, Some("skip".to_string())); mock.assert(); - mock2.assert(); Ok(()) } } diff --git a/rust/agama-lib/src/questions/model.rs b/rust/agama-lib/src/questions/model.rs deleted file mode 100644 index a36da4d0f1..0000000000 --- a/rust/agama-lib/src/questions/model.rs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Question { - pub generic: GenericQuestion, - pub with_password: Option, -} - -/// Facade of agama_lib::questions::GenericQuestion -/// For fields details see it. -/// Reason why it does not use directly GenericQuestion from lib -/// is that it contain both question and answer. It works for dbus -/// API which has both as attributes, but web API separate -/// question and its answer. So here it is split into GenericQuestion -/// and GenericAnswer -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericQuestion { - /// id is optional as newly created questions does not have it assigned - pub id: Option, - pub class: String, - pub text: String, - pub options: Vec, - pub default_option: String, - pub data: HashMap, -} - -/// Facade of agama_lib::questions::WithPassword -/// For fields details see it. -/// Reason why it does not use directly WithPassword from lib -/// is that it is not composition as used here, but more like -/// child of generic question and contain reference to Base. -/// Here for web API we want to have in json that separation that would -/// allow to compose any possible future specialization of question. -/// Also note that question is empty as QuestionWithPassword does not -/// provide more details for question, but require additional answer. -/// Can be potentionally extended in future e.g. with list of allowed characters? -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionWithPassword {} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Answer { - pub generic: GenericAnswer, - pub with_password: Option, -} - -/// Answer needed for GenericQuestion -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericAnswer { - pub answer: String, -} - -/// Answer needed for Password specific questions. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PasswordAnswer { - pub password: String, -} diff --git a/rust/agama-lib/src/questions/store.rs b/rust/agama-lib/src/questions/store.rs deleted file mode 100644 index 8b9c726ce8..0000000000 --- a/rust/agama-lib/src/questions/store.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::BaseHTTPClient; - -use super::{ - config::QuestionsConfig, - http_client::{HTTPClient as QuestionsHTTPClient, QuestionsHTTPClientError}, -}; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing questions settings: {0}")] -pub struct QuestionsStoreError(#[from] QuestionsHTTPClientError); - -type QuestionsStoreResult = Result; - -/// Loads and stores the questions settings from/to the HTTP API. -pub struct QuestionsStore { - questions_client: QuestionsHTTPClient, -} - -impl QuestionsStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - questions_client: QuestionsHTTPClient::new(client), - } - } - - pub async fn load(&self) -> QuestionsStoreResult> { - Ok(None) - } - - pub async fn store(&self, config: &QuestionsConfig) -> QuestionsStoreResult<()> { - Ok(self.questions_client.set_config(config).await?) - } -} diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index ed70b32b97..1774654c59 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -30,7 +30,6 @@ use crate::{ manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, product::{ProductHTTPClient, ProductStore, ProductStoreError}, - questions::store::{QuestionsStore, QuestionsStoreError}, scripts::{ScriptsClient, ScriptsClientError, ScriptsGroup, ScriptsStore, ScriptsStoreError}, security::store::{SecurityStore, SecurityStoreError}, software::{SoftwareStore, SoftwareStoreError}, @@ -63,8 +62,6 @@ pub enum StoreError { #[error(transparent)] Network(#[from] NetworkStoreError), #[error(transparent)] - Questions(#[from] QuestionsStoreError), - #[error(transparent)] Product(#[from] ProductStoreError), #[error(transparent)] Security(#[from] SecurityStoreError), @@ -102,7 +99,6 @@ pub struct Store { hostname: HostnameStore, users: UsersStore, network: NetworkStore, - questions: QuestionsStore, product: ProductStore, security: SecurityStore, software: SoftwareStore, @@ -123,7 +119,6 @@ impl Store { hostname: HostnameStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::new(http_client.clone()), - questions: QuestionsStore::new(http_client.clone()), product: ProductStore::new(http_client.clone()), security: SecurityStore::new(http_client.clone()), software: SoftwareStore::new(http_client.clone()), @@ -144,8 +139,6 @@ impl Store { files: self.files.load().await?, hostname: Some(self.hostname.load().await?), network: Some(self.network.load().await?), - // FIXME: do not export questions yet. - questions: self.questions.load().await?, security: self.security.load().await?.to_option(), software: self.software.load().await?.to_option(), user: Some(self.users.load().await?), @@ -180,10 +173,6 @@ impl Store { } } - if let Some(questions) = &settings.questions { - self.questions.store(questions).await?; - } - if let Some(network) = &settings.network { self.network.store(network).await?; } diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 0d9327c03d..47b79216df 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,8 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::actor::Message; -use agama_utils::api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}; +use agama_utils::{ + actor::Message, + api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, +}; /// Gets the installation status. pub struct GetStatus; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 9670b8d9c8..960bfd413c 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -20,12 +20,13 @@ use crate::l10n; use crate::message; -use agama_utils::actor::{self, Actor, Handler, MessageHandler}; -use agama_utils::api::event; -use agama_utils::api::status::State; -use agama_utils::api::{Action, Config, Event, IssueMap, Proposal, Scope, Status, SystemInfo}; -use agama_utils::issue; -use agama_utils::progress; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + event, status::State, Action, Config, Event, IssueMap, Proposal, Scope, Status, SystemInfo, + }, + issue, progress, question, +}; use async_trait::async_trait; use merge_struct::merge; use tokio::sync::broadcast; @@ -43,13 +44,16 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] - IssueService(#[from] issue::service::Error), + Issues(#[from] issue::service::Error), + #[error(transparent)] + Questions(#[from] question::service::Error), } pub struct Service { l10n: Handler, issues: Handler, progress: Handler, + questions: Handler, state: State, config: Config, events: event::Sender, @@ -60,12 +64,14 @@ impl Service { l10n: Handler, issues: Handler, progress: Handler, + questions: Handler, events: event::Sender, ) -> Self { Self { l10n, issues, progress, + questions, events, state: State::Configuring, config: Config::default(), @@ -124,7 +130,11 @@ impl MessageHandler for Service { /// It includes user and default values. async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; - Ok(Config { l10n: Some(l10n) }) + let questions = self.questions.call(question::message::GetConfig).await?; + Ok(Config { + l10n: Some(l10n), + questions: Some(questions), + }) } } @@ -153,6 +163,12 @@ impl MessageHandler for Service { .call(l10n::message::SetConfig::new(l10n.clone())) .await?; } + + if let Some(questions) = &message.config.questions { + self.questions + .call(question::message::SetConfig::new(questions.clone())) + .await?; + } self.config = message.config; Ok(()) } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 87ca9d460a..743d859042 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -20,10 +20,11 @@ use crate::l10n; use crate::service::Service; -use agama_utils::actor::{self, Handler}; -use agama_utils::api::event; -use agama_utils::issue; -use agama_utils::progress; +use agama_utils::{ + actor::{self, Handler}, + api::event, + issue, progress, question, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -49,6 +50,7 @@ pub enum Error { /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. pub async fn start( + questions: Handler, events: event::Sender, dbus: Option, ) -> Result, Error> { @@ -56,7 +58,7 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let service = Service::new(l10n, issues, progress, events.clone()); + let service = Service::new(l10n, issues, progress, questions, events.clone()); let handler = actor::spawn(service); Ok(handler) } @@ -69,6 +71,7 @@ mod test { use agama_utils::actor::Handler; use agama_utils::api::l10n; use agama_utils::api::{Config, Event}; + use agama_utils::question; use tokio::sync::broadcast; async fn start_service() -> Handler { @@ -80,7 +83,10 @@ mod test { } }); - manager::start(events_sender, None).await.unwrap() + let questions = question::start(events_sender.clone()).await.unwrap(); + manager::start(questions, events_sender, None) + .await + .unwrap() } #[tokio::test] @@ -94,6 +100,7 @@ mod test { keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }), + ..Default::default() }; handler @@ -125,6 +132,7 @@ mod test { keymap: Some(keymap.to_string()), ..Default::default() }), + ..Default::default() }; handler diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 8d2b05f581..338df05d2e 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -60,10 +60,6 @@ tempfile = "3.13.0" url = "2.5.2" strum = { version = "0.27.2", features = ["derive"] } -[[bin]] -name = "agama-dbus-server" -path = "src/agama-dbus-server.rs" - [[bin]] name = "agama-web-server" path = "src/agama-web-server.rs" diff --git a/rust/agama-server/src/agama-dbus-server.rs b/rust/agama-server/src/agama-dbus-server.rs deleted file mode 100644 index 879d1ab86a..0000000000 --- a/rust/agama-server/src/agama-dbus-server.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_l10n::helpers as l10n_helpers; -use agama_server::{logs::init_logging, questions}; - -use agama_lib::connection_to; -use anyhow::Context; -use std::future::pending; - -const ADDRESS: &str = "unix:path=/run/agama/bus"; -const SERVICE_NAME: &str = "org.opensuse.Agama1"; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let locale = l10n_helpers::init_locale()?; - tracing::info!("Using locale {}", locale); - init_logging().context("Could not initialize the logger")?; - - let connection = connection_to(ADDRESS) - .await - .expect("Could not connect to the D-Bus daemon"); - - // When adding more services here, the order might be important. - questions::export_dbus_objects(&connection).await?; - tracing::info!("Started questions interface"); - - connection - .request_name(SERVICE_NAME) - .await - .context(format!("Requesting name {SERVICE_NAME}"))?; - - // Do other things or go to wait forever - pending::<()>().await; - - Ok(()) -} diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 2704cb8280..1cf90a6890 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::{error::ServiceError, questions::QuestionsError}; +use agama_lib::error::ServiceError; use axum::{ http::StatusCode, response::{IntoResponse, Response}, @@ -36,8 +36,6 @@ pub enum Error { Anyhow(String), #[error("Agama service error: {0}")] Service(#[from] ServiceError), - #[error("Questions service error: {0}")] - Questions(QuestionsError), #[error("Progress service error: {0}")] Progress(#[from] ProgressServiceError), #[error("Could not check the password")] diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 26206ffc7b..3000c9fd87 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -28,7 +28,6 @@ pub mod logs; pub mod manager; pub mod network; pub mod profile; -pub mod questions; pub mod scripts; pub mod security; pub mod software; diff --git a/rust/agama-server/src/questions.rs b/rust/agama-server/src/questions.rs deleted file mode 100644 index 7046c7f77b..0000000000 --- a/rust/agama-server/src/questions.rs +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use agama_lib::questions::{ - self, - answers::{AnswerStrategy, Answers, DefaultAnswers}, - GenericQuestion, WithPassword, -}; -use zbus::{fdo::ObjectManager, interface, zvariant::ObjectPath, Connection}; - -pub mod web; - -#[derive(Clone, Debug)] -struct GenericQuestionObject(questions::GenericQuestion); - -#[interface(name = "org.opensuse.Agama1.Questions.Generic")] -impl GenericQuestionObject { - #[zbus(property)] - pub fn id(&self) -> u32 { - self.0.id - } - - #[zbus(property)] - pub fn class(&self) -> &str { - &self.0.class - } - - #[zbus(property)] - pub fn data(&self) -> HashMap { - self.0.data.to_owned() - } - - #[zbus(property)] - pub fn text(&self) -> &str { - self.0.text.as_str() - } - - #[zbus(property)] - pub fn options(&self) -> Vec { - self.0.options.to_owned() - } - - #[zbus(property)] - pub fn default_option(&self) -> &str { - self.0.default_option.as_str() - } - - #[zbus(property)] - pub fn answer(&self) -> &str { - &self.0.answer - } - - #[zbus(property)] - pub fn set_answer(&mut self, value: &str) -> zbus::fdo::Result<()> { - // TODO verify if answer exists in options or if it is valid in other way - self.0.answer = value.to_string(); - - Ok(()) - } -} - -/// Mixin interface for questions that are base + contain question for password -struct WithPasswordObject(questions::WithPassword); - -#[interface(name = "org.opensuse.Agama1.Questions.WithPassword")] -impl WithPasswordObject { - #[zbus(property)] - pub fn password(&self) -> &str { - self.0.password.as_str() - } - - #[zbus(property)] - pub fn set_password(&mut self, value: &str) { - self.0.password = value.to_string(); - } -} - -/// Question types used to be able to properly remove object from dbus -enum QuestionType { - Base, - BaseWithPassword, -} - -pub struct Questions { - questions: HashMap, - connection: Connection, - last_id: u32, - answer_strategies: Vec>, -} - -#[interface(name = "org.opensuse.Agama1.Questions")] -impl Questions { - /// creates new generic question without answer - #[zbus(name = "New")] - async fn new_question( - &mut self, - class: &str, - text: &str, - options: Vec<&str>, - default_option: &str, - data: HashMap, - ) -> zbus::fdo::Result { - tracing::info!("Creating new question with text: {}.", text); - let id = self.last_id; - self.last_id += 1; // TODO use some thread safety - let options = options.iter().map(|o| o.to_string()).collect(); - let mut question = questions::GenericQuestion::new( - id, - class.to_string(), - text.to_string(), - options, - default_option.to_string(), - data, - ); - self.fill_answer(&mut question); - let object_path = ObjectPath::try_from(question.object_path()).unwrap(); - let question_object = GenericQuestionObject(question); - - self.connection - .object_server() - .at(object_path.clone(), question_object) - .await?; - self.questions.insert(id, QuestionType::Base); - Ok(object_path) - } - - /// creates new specialized luks activation question without answer and password - async fn new_with_password( - &mut self, - class: &str, - text: &str, - options: Vec<&str>, - default_option: &str, - data: HashMap, - ) -> zbus::fdo::Result { - tracing::info!("Creating new question with password with text: {}.", text); - let id = self.last_id; - self.last_id += 1; // TODO use some thread safety - // TODO: share code better - let options = options.iter().map(|o| o.to_string()).collect(); - let base = questions::GenericQuestion::new( - id, - class.to_string(), - text.to_string(), - options, - default_option.to_string(), - data, - ); - let mut question = questions::WithPassword::new(base); - let object_path = ObjectPath::try_from(question.base.object_path()).unwrap(); - - self.fill_answer_with_password(&mut question); - let base_question = question.base.clone(); - let base_object = GenericQuestionObject(base_question); - - self.connection - .object_server() - .at(object_path.clone(), WithPasswordObject(question)) - .await?; - // NOTE: order here is important as each interface cause signal, so frontend should wait only for GenericQuestions - // which should be the last interface added - self.connection - .object_server() - .at(object_path.clone(), base_object) - .await?; - - self.questions.insert(id, QuestionType::BaseWithPassword); - Ok(object_path) - } - - /// Removes question at given object path - /// TODO: use id as parameter ( need at first check other users of method ) - async fn delete(&mut self, question: ObjectPath<'_>) -> zbus::fdo::Result<()> { - // TODO: error checking - let id: u32 = question.rsplit('/').next().unwrap().parse().unwrap(); - let qtype = self.questions.get(&id).unwrap(); - match qtype { - QuestionType::Base => { - self.connection - .object_server() - .remove::(question.clone()) - .await?; - } - QuestionType::BaseWithPassword => { - self.connection - .object_server() - .remove::(question.clone()) - .await?; - self.connection - .object_server() - .remove::(question.clone()) - .await?; - } - }; - self.questions.remove(&id); - Ok(()) - } - - /// property that defines if questions is interactive or automatically answered with - /// default answer - #[zbus(property)] - fn interactive(&self) -> bool { - self.answer_strategies - .iter() - .all(|s| s.id() != DefaultAnswers::id()) - } - - #[zbus(property)] - fn set_interactive(&mut self, value: bool) { - if value == self.interactive() { - tracing::info!("interactive value unchanged - {}", value); - return; - } - - tracing::info!("set interactive to {}", value); - if value { - self.answer_strategies - .retain(|s| s.id() == DefaultAnswers::id()); - } else { - self.answer_strategies.push(Box::new(DefaultAnswers {})); - } - } - - fn add_answer_file(&mut self, path: String) -> zbus::fdo::Result<()> { - tracing::info!("Adding answer file {}", path); - let answers = Answers::new_from_file(path.as_str()) - .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; - self.answer_strategies.insert(0, Box::new(answers)); - Ok(()) - } - - fn remove_answers(&mut self) -> zbus::fdo::Result<()> { - self.answer_strategies - .retain(|s| s.id() == DefaultAnswers::id()); - Ok(()) - } -} - -impl Questions { - /// Creates new questions interface with clone of connection to be able to - /// attach or detach question objects - fn new(connection: &Connection) -> Self { - Self { - questions: HashMap::new(), - connection: connection.to_owned(), - last_id: 0, - answer_strategies: vec![], - } - } - - /// tries to provide answer to question using answer strategies - /// - /// What happens under the hood is that it uses answer_strategies vector - /// and try to find the first strategy that provides answer. When - /// answer is provided, it returns immediately. - fn fill_answer(&self, question: &mut GenericQuestion) { - for strategy in self.answer_strategies.iter() { - match strategy.answer(question) { - None => (), - Some(answer) => { - question.answer = answer; - return; - } - } - } - } - - /// tries to provide answer to question using answer strategies - /// - /// What happens under the hood is that it uses answer_strategies vector - /// and try to find the first strategy that provides answer. When - /// answer is provided, it returns immediately. - fn fill_answer_with_password(&self, question: &mut WithPassword) { - for strategy in self.answer_strategies.iter() { - let (answer, password) = strategy.answer_with_password(question); - if let Some(password) = password { - question.password = password; - } - if let Some(answer) = answer { - question.base.answer = answer; - return; - } - } - } -} - -/// Starts questions dbus service together with Object manager -pub async fn export_dbus_objects( - connection: &Connection, -) -> Result<(), Box> { - const PATH: &str = "/org/opensuse/Agama1/Questions"; - - // When serving, request the service name _after_ exposing the main object - let questions = Questions::new(connection); - connection.object_server().at(PATH, questions).await?; - connection.object_server().at(PATH, ObjectManager).await?; - - Ok(()) -} diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs deleted file mode 100644 index f3f5fffbf6..0000000000 --- a/rust/agama-server/src/questions/web.rs +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the questions module. -//! -//! The module offers two public functions: -//! -//! * `questions_service` which returns the Axum service. -//! * `questions_stream` which offers an stream that emits questions related signals. - -use crate::error::Error; -use agama_lib::{ - error::ServiceError, - event, - http::OldEvent, - proxies::questions::{GenericQuestionProxy, QuestionWithPasswordProxy, QuestionsProxy}, - questions::{ - answers::{self, Answers}, - config::{QuestionsConfig, QuestionsPolicy}, - model::{self, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, - }, -}; -use agama_utils::dbus::{extract_id_from_path, get_property}; -use anyhow::{anyhow, Context}; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, put}, - Json, Router, -}; -use std::{collections::HashMap, io::Write, pin::Pin}; -use tempfile::NamedTempFile; -use tokio_stream::{Stream, StreamExt}; -use zbus::{ - fdo::ObjectManagerProxy, - names::{InterfaceName, OwnedInterfaceName}, - zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}, -}; - -// TODO: move to lib or maybe not and just have in lib client for http API? -#[derive(Clone)] -struct QuestionsClient<'a> { - connection: zbus::Connection, - objects_proxy: ObjectManagerProxy<'a>, - questions_proxy: QuestionsProxy<'a>, - generic_interface: OwnedInterfaceName, - with_password_interface: OwnedInterfaceName, -} - -impl QuestionsClient<'_> { - pub async fn new(dbus: zbus::Connection) -> Result { - let question_path = - OwnedObjectPath::from(ObjectPath::try_from("/org/opensuse/Agama1/Questions")?); - Ok(Self { - connection: dbus.clone(), - questions_proxy: QuestionsProxy::new(&dbus).await?, - objects_proxy: ObjectManagerProxy::builder(&dbus) - .path(question_path)? - .destination("org.opensuse.Agama1")? - .build() - .await?, - generic_interface: InterfaceName::from_str_unchecked( - "org.opensuse.Agama1.Questions.Generic", - ) - .into(), - with_password_interface: InterfaceName::from_str_unchecked( - "org.opensuse.Agama1.Questions.WithPassword", - ) - .into(), - }) - } - - pub async fn create_question(&self, question: Question) -> Result { - // TODO: ugly API is caused by dbus method to create question. It can be changed in future as DBus is internal only API - let generic = &question.generic; - let options: Vec<&str> = generic.options.iter().map(String::as_ref).collect(); - let data: HashMap<&str, &str> = generic - .data - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - let path = if question.with_password.is_some() { - tracing::info!("creating a question with password"); - self.questions_proxy - .new_with_password( - &generic.class, - &generic.text, - &options, - &generic.default_option, - data, - ) - .await? - } else { - tracing::info!("creating a generic question"); - self.questions_proxy - .new_question( - &generic.class, - &generic.text, - &options, - &generic.default_option, - data, - ) - .await? - }; - let mut res = question.clone(); - res.generic.id = Some(extract_id_from_path(&path)?); - tracing::info!("new question gets id {:?}", res.generic.id); - Ok(res) - } - - pub async fn questions(&self) -> Result, ServiceError> { - let objects = self - .objects_proxy - .get_managed_objects() - .await - .context("failed to get managed object with Object Manager")?; - let mut result: Vec = Vec::with_capacity(objects.len()); - - for (_path, interfaces_hash) in objects.iter() { - let generic_properties = interfaces_hash - .get(&self.generic_interface) - .context("Failed to create interface name for generic question")?; - // skip if question is already answered - let answer: String = get_property(generic_properties, "Answer")?; - if !answer.is_empty() { - continue; - } - let mut question = self.build_generic_question(generic_properties)?; - - if interfaces_hash.contains_key(&self.with_password_interface) { - question.with_password = Some(QuestionWithPassword {}); - } - - result.push(question); - } - Ok(result) - } - - fn build_generic_question( - &self, - properties: &HashMap, - ) -> Result { - let result = Question { - generic: GenericQuestion { - id: Some(get_property(properties, "Id")?), - class: get_property(properties, "Class")?, - text: get_property(properties, "Text")?, - options: get_property(properties, "Options")?, - default_option: get_property(properties, "DefaultOption")?, - data: get_property(properties, "Data")?, - }, - with_password: None, - }; - - Ok(result) - } - - pub async fn delete(&self, id: u32) -> Result<(), ServiceError> { - let question_path = ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create a D-Bus path")?; - - self.questions_proxy - .delete(&question_path) - .await - .map_err(|e| e.into()) - } - - pub async fn get_answer(&self, id: u32) -> Result, ServiceError> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create dbus path")?, - ); - let objects = self.objects_proxy.get_managed_objects().await?; - let password_interface = OwnedInterfaceName::from( - InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") - .context("Failed to create interface name for question with password")?, - ); - let mut result = model::Answer::default(); - let question = objects - .get(&question_path) - .ok_or(ServiceError::QuestionNotExist(id))?; - - if let Some(password_iface) = question.get(&password_interface) { - result.with_password = Some(PasswordAnswer { - password: get_property(password_iface, "Password")?, - }); - } - let generic_interface = OwnedInterfaceName::from( - InterfaceName::from_static_str("org.opensuse.Agama1.Questions.Generic") - .context("Failed to create interface name for generic question")?, - ); - let generic_iface = question - .get(&generic_interface) - .context("Question does not have generic interface")?; - let answer: String = get_property(generic_iface, "Answer")?; - if answer.is_empty() { - Ok(None) - } else { - result.generic.answer = answer; - Ok(Some(result)) - } - } - - pub async fn answer(&self, id: u32, answer: model::Answer) -> Result<(), ServiceError> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) - .context("Failed to create dbus path")?, - ); - if let Some(password) = answer.with_password { - let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - dbus_password - .set_password(password.password.as_str()) - .await? - } - let dbus_generic = GenericQuestionProxy::builder(&self.connection) - .path(&question_path)? - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - dbus_generic - .set_answer(answer.generic.answer.as_str()) - .await?; - Ok(()) - } - - pub async fn set_interactive(&self, value: bool) -> Result<(), ServiceError> { - self.questions_proxy.set_interactive(value).await?; - Ok(()) - } - - pub async fn set_answers(&self, answers: Vec) -> Result<(), ServiceError> { - let mut file = NamedTempFile::new().context("Cannot create the answers file")?; - let all_answers = Answers::new(answers); - let json = serde_json::to_string(&all_answers)?; - write!(file, "{}", &json).context("Cannot write the answers file")?; - self.questions_proxy.remove_answers().await?; - let path = file - .path() - .to_str() - .ok_or(anyhow!("Could not create the answers file"))?; - self.questions_proxy.add_answer_file(path).await?; - Ok(()) - } -} - -#[derive(Clone)] -struct QuestionsState<'a> { - questions: QuestionsClient<'a>, -} - -/// Sets up and returns the axum service for the questions module. -pub async fn questions_service(dbus: zbus::Connection) -> Result { - let questions = QuestionsClient::new(dbus.clone()).await?; - let state = QuestionsState { questions }; - let router = Router::new() - .route("/", get(list_questions).post(create_question)) - .route("/:id", delete(delete_question)) - .route("/:id/answer", get(get_answer).put(answer_question)) - .route("/config", put(set_config)) - .with_state(state); - Ok(router) -} - -pub async fn questions_stream( - dbus: zbus::Connection, -) -> Result + Send>>, Error> { - let question_path = OwnedObjectPath::from( - ObjectPath::try_from("/org/opensuse/Agama1/Questions") - .context("failed to create object path")?, - ); - let proxy = ObjectManagerProxy::builder(&dbus) - .path(question_path) - .context("Failed to create object manager path")? - .destination("org.opensuse.Agama1")? - .build() - .await - .context("Failed to create Object MAnager proxy")?; - let add_stream = proxy - .receive_interfaces_added() - .await? - .then(|_| async move { event!(QuestionsChanged) }); - let remove_stream = proxy - .receive_interfaces_removed() - .await? - .then(|_| async move { event!(QuestionsChanged) }); - let stream = StreamExt::merge(add_stream, remove_stream); - Ok(Box::pin(stream)) -} - -/// Returns the list of questions that waits for answer. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/", - context_path = "/api/questions", - responses( - (status = 200, description = "List of open questions", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn list_questions( - State(state): State>, -) -> Result>, Error> { - Ok(Json(state.questions.questions().await?)) -} - -/// Get answer to question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -#[utoipa::path( - put, - path = "/:id/answer", - context_path = "/api/questions", - responses( - (status = 200, description = "Answer"), - (status = 400, description = "The D-Bus service could not perform the action"), - (status = 404, description = "Answer was not yet provided"), - ) -)] -async fn get_answer( - State(state): State>, - Path(question_id): Path, -) -> Result { - let res = state.questions.get_answer(question_id).await?; - if let Some(answer) = res { - Ok(Json(answer).into_response()) - } else { - Ok(StatusCode::NOT_FOUND.into_response()) - } -} - -/// Provide answer to question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -/// * `answer`: struct with answer and possible other data needed for answer like password -#[utoipa::path( - put, - path = "/:id/answer", - context_path = "/api/questions", - responses( - (status = 200, description = "answer question"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn answer_question( - State(state): State>, - Path(question_id): Path, - Json(answer): Json, -) -> Result<(), Error> { - let res = state.questions.answer(question_id, answer).await; - Ok(res?) -} - -/// Deletes question. -/// -/// * `state`: service state. -/// * `questions_id`: id of question -#[utoipa::path( - delete, - path = "/:id", - context_path = "/api/questions", - responses( - (status = 200, description = "question deleted"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn delete_question( - State(state): State>, - Path(question_id): Path, -) -> Result<(), Error> { - let res = state.questions.delete(question_id).await; - Ok(res?) -} - -/// Create new question. -/// -/// * `state`: service state. -/// * `question`: struct with question where id of question is ignored and will be assigned -#[utoipa::path( - post, - path = "/", - context_path = "/api/questions", - responses( - (status = 200, description = "answer question"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn create_question( - State(state): State>, - Json(question): Json, -) -> Result, Error> { - let res = state.questions.create_question(question).await?; - Ok(Json(res)) -} - -#[utoipa::path( - put, - path = "/config", - context_path = "/api/questions", - responses( - (status = 200, description = "set questions configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_config( - State(state): State>, - Json(config): Json, -) -> Result<(), Error> { - if let Some(policy) = config.policy { - let interactive = match policy { - QuestionsPolicy::User => true, - QuestionsPolicy::Auto => false, - }; - - state.questions.set_interactive(interactive).await?; - } - - if let Some(answers) = config.answers { - state.questions.set_answers(answers).await?; - } - - Ok(()) -} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index d71179f7cd..a3b0159a83 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -21,18 +21,23 @@ //! This module implements Agama's HTTP API. use agama_lib::error::ServiceError; -use agama_manager as manager; -use agama_manager::message; -use agama_utils::actor::Handler; -use agama_utils::api::config; -use agama_utils::api::event; -use agama_utils::api::{Action, Config, IssueMap, Status, SystemInfo}; +use agama_manager::{self as manager, message}; +use agama_utils::{ + actor::Handler, + api::{ + config, event, + question::{Question, QuestionSpec, UpdateOperation}, + Action, Config, IssueMap, Status, SystemInfo, + }, + question, +}; use anyhow; -use axum::extract::State; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::Json; -use axum::Router; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; use hyper::StatusCode; use serde::Serialize; use serde_json::json; @@ -41,6 +46,8 @@ use serde_json::json; pub enum Error { #[error(transparent)] Manager(#[from] manager::service::Error), + #[error(transparent)] + Questions(#[from] question::service::Error), } impl IntoResponse for Error { @@ -56,6 +63,7 @@ impl IntoResponse for Error { #[derive(Clone)] pub struct ServerState { manager: Handler, + questions: Handler, } type ServerResult = Result; @@ -69,11 +77,14 @@ pub async fn server_service( events: event::Sender, dbus: Option, ) -> Result { - let manager = manager::start(events, dbus) + let questions = question::start(events.clone()) + .await + .map_err(|e| anyhow::Error::new(e))?; + let manager = manager::start(questions.clone(), events, dbus) .await .map_err(|e| anyhow::Error::new(e))?; - let state = ServerState { manager }; + let state = ServerState { manager, questions }; Ok(Router::new() .route("/status", get(get_status)) @@ -86,6 +97,10 @@ pub async fn server_service( .route("/proposal", get(get_proposal)) .route("/action", post(run_action)) .route("/issues", get(get_issues)) + .route( + "/questions", + get(get_questions).post(ask_question).patch(update_question), + ) .with_state(state)) } @@ -231,6 +246,74 @@ async fn get_issues(State(state): State) -> ServerResult), + (status = 400, description = "Not possible to retrieve the questions") + ) +)] +async fn get_questions(State(state): State) -> ServerResult>> { + let questions = state.questions.call(question::message::Get).await?; + Ok(Json(questions)) +} + +/// Registers a new question. +#[utoipa::path( + post, + path = "/questions", + context_path = "/api/v2", + responses( + (status = 200, description = "New question's ID", body = u32), + (status = 400, description = "Not possible to register the question") + ) +)] +async fn ask_question( + State(state): State, + Json(question): Json, +) -> ServerResult> { + let question = state + .questions + .call(question::message::Ask::new(question)) + .await?; + Ok(Json(question)) +} + +/// Updates the question collection by answering or removing a question. +#[utoipa::path( + patch, + path = "/questions", + context_path = "/api/v2", + request_body = UpdateOperation, + responses( + (status = 200, description = "The question was answered or deleted"), + (status = 400, description = "It was not possible to update the question") + ) +)] +async fn update_question( + State(state): State, + Json(operation): Json, +) -> ServerResult<()> { + match operation { + UpdateOperation::Answer { id, answer } => { + state + .questions + .call(question::message::Answer { id, answer }) + .await?; + } + UpdateOperation::Delete { id } => { + state + .questions + .call(question::message::Delete { id }) + .await?; + } + } + Ok(()) +} + #[utoipa::path( post, path = "/actions", diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 411a6d4ee2..c252266e66 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -32,7 +32,6 @@ use crate::{ manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, profile::web::profile_service, - questions::web::{questions_service, questions_stream}, scripts::web::scripts_service, security::security_service, server::server_service, @@ -102,7 +101,6 @@ where "/network", network_service(network_adapter, old_events).await?, ) - .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) .add_service("/files", files_service().await?) @@ -179,7 +177,6 @@ async fn run_events_monitor(dbus: zbus::Connection, events: OldSender) -> Result ) .await?, ); - stream.insert("questions", questions_stream(dbus.clone()).await?); tokio::pin!(stream); let e = events.clone(); diff --git a/rust/agama-server/src/web/config.rs b/rust/agama-server/src/web/config.rs index 328d5fa3b0..a3b11fb73e 100644 --- a/rust/agama-server/src/web/config.rs +++ b/rust/agama-server/src/web/config.rs @@ -25,7 +25,7 @@ //! //! * `/usr/etc/agama.d/server.{json/yaml}` //! * `/etc/agama.d/server.{json/yaml}` -//! * `./agama-dbus-server/share/server.{json/yaml}` +//! * `etc/agama.d/server.{json/yaml}` //! //! All the settings are merged into a single configuration. The values in the latter locations //! take precedence. diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 94d6b4cf84..219a476ed3 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -32,8 +32,6 @@ mod bootloader; pub use bootloader::BootloaderApiDocBuilder; mod software; pub use software::SoftwareApiDocBuilder; -mod questions; -pub use questions::QuestionsApiDocBuilder; mod profile; pub use profile::ProfileApiDocBuilder; mod manager; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 297e6edcca..6a4e84df1d 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -37,6 +37,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .path_from::() .path_from::() .path_from::() + .path_from::() + .path_from::() + .path_from::() .path_from::() .build() } @@ -50,7 +53,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -116,11 +118,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -154,29 +151,38 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() - .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .build() } } diff --git a/rust/agama-server/src/web/docs/questions.rs b/rust/agama-server/src/web/docs/questions.rs deleted file mode 100644 index bd302e85c8..0000000000 --- a/rust/agama-server/src/web/docs/questions.rs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; - -use super::ApiDocBuilder; - -pub struct QuestionsApiDocBuilder; - -impl ApiDocBuilder for QuestionsApiDocBuilder { - fn title(&self) -> String { - "Questions HTTP API".to_string() - } - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .build() - } -} diff --git a/rust/agama-server/src/web/ws.rs b/rust/agama-server/src/web/ws.rs index 8af2e53d47..2a15f08db5 100644 --- a/rust/agama-server/src/web/ws.rs +++ b/rust/agama-server/src/web/ws.rs @@ -70,14 +70,14 @@ async fn handle_socket( loop { tokio::select! { msg = old_events_rx.recv() => { - if send_msg(&mut socket, msg).await.is_err() { - return; + if let Err(e) = send_msg(&mut socket, msg).await { + eprintln!("Error sending old event: {e:?}"); } } msg = events_rx.recv() => { - if send_msg(&mut socket, msg).await.is_err() { - return; + if let Err(e) = send_msg(&mut socket, msg).await { + eprintln!("Error sending event: {e:?}"); } } } diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 4581577210..78275fc12b 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -91,6 +91,7 @@ async fn test_put_config() -> Result<(), Box> { keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }), + ..Default::default() }; let server_service = build_server_service().await?; @@ -122,6 +123,7 @@ async fn test_put_config() -> Result<(), Box> { keymap: Some("en".to_string()), timezone: None, }), + ..Default::default() }; let request = Request::builder() @@ -157,7 +159,10 @@ async fn test_patch_config() -> Result<(), Box> { timezone: Some("Atlantic/Canary".to_string()), }; - let config = api::Config { l10n: Some(l10n) }; + let config = api::Config { + l10n: Some(l10n), + ..Default::default() + }; let server_service = build_server_service().await?; let request = Request::builder() @@ -177,6 +182,7 @@ async fn test_patch_config() -> Result<(), Box> { keymap: Some("en".to_string()), timezone: None, }), + ..Default::default() }), }; diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 2b5efdf311..0664716790 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -49,3 +49,5 @@ mod action; pub use action::Action; pub mod l10n; + +pub mod question; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index a70fda792c..7b244c8fc6 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::l10n; +use crate::api::{l10n, question}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] @@ -26,6 +26,8 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] pub l10n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub questions: Option, } /// Patch for the config. diff --git a/rust/agama-utils/src/api/event.rs b/rust/agama-utils/src/api/event.rs index 6fa83187b8..21f520ebc2 100644 --- a/rust/agama-utils/src/api/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -49,6 +49,14 @@ pub enum Event { ProposalChanged { scope: Scope, }, + /// New question + QuestionAdded { + id: u32, + }, + /// A question was answered. + QuestionAnswered { + id: u32, + }, } pub type Sender = broadcast::Sender; diff --git a/rust/agama-utils/src/api/question.rs b/rust/agama-utils/src/api/question.rs new file mode 100644 index 0000000000..504924f4de --- /dev/null +++ b/rust/agama-utils/src/api/question.rs @@ -0,0 +1,419 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + // TODO: use it when checking the answer. + #[error("Invalid answer for question {0}")] + InvalidAnswer(u32), +} + +/// Questions configuration. +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub answers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// Automatically answer questions. + Auto, + /// Ask the user. + User, +} + +/// Defines the answer to use for any question which matches the rule. +/// +/// If the rule matches with the question ([class](Self::class), +/// [text](Self::text) or [data](Self::data), it applies the specified `answer`). +#[derive(Clone, Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct AnswerRule { + /// Question class (see [QuestionSpec::class]). + #[serde(skip_serializing_if = "Option::is_none")] + pub class: Option, + /// Question text (see [QuestionSpec::text]). + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// A question can include custom data. If any of the entries matches, + /// the rule is applied (see [QuestionSpec::data]). + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, + /// The answer to use (see [QuestionSpec::answer]). + #[serde(flatten)] + pub answer: Answer, +} + +impl AnswerRule { + /// Determines whether the answer responds to the given question. + /// + /// * `spec`: question spec to compare with. + pub fn answers_to(&self, spec: &QuestionSpec) -> bool { + if let Some(class) = &self.class { + if spec.class != *class { + return false; + } + } + + if let Some(text) = &self.text { + if spec.text != *text { + return false; + } + } + + if let Some(data) = &self.data { + return data.iter().all(|(key, value)| { + let Some(e_val) = spec.data.get(key) else { + return false; + }; + + e_val == value + }); + } + + true + } +} + +/// Represents a question including its [specification](QuestionSpec) and [answer](QuestionAnswer). +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Question { + /// Question ID. + pub id: u32, + /// Question specification. + #[serde(flatten)] + pub spec: QuestionSpec, + /// Question answer. + #[serde(skip_serializing_if = "Option::is_none")] + pub answer: Option, +} + +impl Question { + /// Creates a new question using the given ID and spec. + /// + /// The question does not have an answer yet. Use the [Question::set_answer] + /// function to set an answer. + /// + /// * `id`: question ID. + /// * `spec`: question specification. + pub fn new(id: u32, spec: QuestionSpec) -> Self { + Self { + id, + spec, + answer: None, + } + } + + /// Sets an answer for the question. + /// + /// FIXME: check whether the answer is valid. + /// + /// * `answer`: question answer. + pub fn set_answer(&mut self, answer: Answer) -> Result<(), Error> { + self.answer = Some(answer); + Ok(()) + } +} + +/// Defines how a question should look like. +/// +/// A question is composed by a set of actions and, optionally, and additional +/// field. For instance, a question to decrypt a device using a password would have: +/// +/// * a pair of actions, "decrypt" and "skip". +/// * a password field. +/// +/// In other cases, like a simple "yes/no" questions, the field would not be needed. +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct QuestionSpec { + /// Question text. + pub text: String, + /// Question class (e.g., "autoyast.unsupported"). It works as a hint for + /// the UI or to match pre-defined answers. The values that are understood + /// by Agama's UI are documented [in the Questions + /// page](https://agama-project.github.io/docs/user/reference/profile/answers). + pub class: String, + /// Optionally, a question might define an additional field (e.g., a + /// password, a selector, etc.). + #[serde(default)] + pub field: QuestionField, + /// List of available actions. + pub actions: Vec, + /// Default action. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_action: Option, + /// Additional data that can be set for any question. + // FIXME: set the proper value_type. + #[schema(value_type = HashMap)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub data: HashMap, +} + +impl QuestionSpec { + /// Creates a new question specification. + /// + /// * `text`: question text. + /// * `class`: question class. + pub fn new(text: &str, class: &str) -> Self { + Self { + text: text.to_string(), + class: class.to_string(), + field: QuestionField::None, + actions: vec![], + default_action: None, + data: HashMap::new(), + } + } + + /// Sets the question field to be a string. + pub fn as_string(mut self) -> Self { + self.field = QuestionField::String; + self + } + + /// Sets the question field to be a password. + pub fn as_password(mut self) -> Self { + self.field = QuestionField::Password; + self + } + + /// Sets the question field to be a selector. + /// + /// * `options`: available options in `(id, label)` format. + pub fn as_select(mut self, options: &[(&str, &str)]) -> Self { + let options: Vec<_> = options + .iter() + .map(|(id, label)| SelectionOption::new(id, label)) + .collect(); + self.field = QuestionField::Select { options }; + self + } + + /// Sets a default action. + /// + /// FIXME: check whether the action exists. + /// + /// * `action_id`: action identifier. + pub fn with_default_action(mut self, action_id: &str) -> Self { + self.default_action = Some(action_id.to_string()); + self + } + + /// Sets the available actions. + /// + /// * `actions`: available actions in `(id, label)` format. + pub fn with_actions(mut self, actions: &[(&str, &str)]) -> Self { + self.actions = actions + .iter() + .map(|(id, label)| Action::new(id, label)) + .collect(); + self + } + + /// Sets the additional data. + /// + /// * `data`: available actions in `(id, label)` format. + pub fn with_data(mut self, data: &[(&str, &str)]) -> Self { + self.data = data + .iter() + .map(|(id, label)| (id.to_string(), label.to_string())) + .collect::>(); + self + } +} + +/// Question field. +/// +/// Apart from the actions, a question can include a field. The field can go +/// from a string to a selector. +/// +/// The field is usually presented as a control in a form. +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum QuestionField { + /// No field. + #[default] + None, + /// A simple string field. + String, + /// A password field. + Password, + /// A selector field. + Select { options: Vec }, +} + +/// Selector option. +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SelectionOption { + id: String, + label: String, +} + +impl SelectionOption { + pub fn new(id: &str, label: &str) -> Self { + Self { + id: id.to_string(), + label: label.to_string(), + } + } +} + +/// Question action. +/// +/// They are usually presented as the button in a form. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +pub struct Action { + /// Action value. + pub id: String, + /// Localized option. + pub label: String, +} + +impl Action { + pub fn new(id: &str, label: &str) -> Self { + Self { + id: id.to_string(), + label: label.to_string(), + } + } +} + +/// Question answer. +/// +/// It includes the action and, optionally, and additional value which depends +/// on the question field. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +pub struct Answer { + pub action: String, + #[serde(alias = "password")] + pub value: Option, +} + +/// Represents an update operation over the list of questions. +/// +/// It is used by the HTTP layer only. +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub enum UpdateOperation { + /// Answer the question with the given answer. + Answer { id: u32, answer: Answer }, + /// Remove the question. + Delete { id: u32 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_question() { + let q = QuestionSpec::new("Please, enter a username", "username") + .as_string() + .with_actions(&[("next", "Next"), ("cancel", "Cancel")]); + + let q_str = serde_json::to_string_pretty(&q).unwrap(); + println!("{}", &q_str); + assert!(matches!(q.field, QuestionField::String)); + assert_eq!(q.actions[0], Action::new("next", "Next")); + assert_eq!(q.actions[1], Action::new("cancel", "Cancel")); + } + + #[test] + fn test_password_question() { + let q = QuestionSpec::new("Decrypt the device", "luks") + .as_password() + .with_actions(&[("decrypt", "Decrypt"), ("skip", "Skip")]); + + let q_str = serde_json::to_string_pretty(&q).unwrap(); + println!("{}", &q_str); + assert!(matches!(q.field, QuestionField::Password)); + assert_eq!(q.actions[0], Action::new("decrypt", "Decrypt")); + assert_eq!(q.actions[1], Action::new("skip", "Skip")); + } + + #[test] + fn test_select_question() { + let q = QuestionSpec::new("There is a solver conflict...", "conflict") + .as_select(&[("opt1", "Option 1"), ("opt2", "Option 2")]) + .with_actions(&[("decrypt", "Decrypt"), ("skip", "Skip")]); + + let q_str = serde_json::to_string_pretty(&q).unwrap(); + println!("{}", &q_str); + assert!(matches!( + q.field, + QuestionField::Select { options: _options } + )); + assert_eq!(q.actions[0], Action::new("decrypt", "Decrypt")); + assert_eq!(q.actions[1], Action::new("skip", "Skip")); + } + + #[test] + fn test_answers_to() { + let answer = Answer { + action: "cancel".to_string(), + value: None, + }; + + let q = QuestionSpec::new("Please, enter a username", "username") + .as_string() + .with_data(&[("id", "1")]) + .with_actions(&[("next", "Next"), ("cancel", "Cancel")]); + + let rule_by_text = AnswerRule { + text: Some("Please, enter a username".to_string()), + class: Default::default(), + data: Default::default(), + answer: answer.clone(), + }; + assert!(rule_by_text.answers_to(&q)); + + let rule_by_class = AnswerRule { + text: Default::default(), + class: Some("username".to_string()), + data: Default::default(), + answer: answer.clone(), + }; + assert!(rule_by_class.answers_to(&q)); + + let rule_by_data = AnswerRule { + text: Default::default(), + class: Default::default(), + data: Some(HashMap::from([("id".to_string(), "1".to_string())])), + answer: answer.clone(), + }; + assert!(rule_by_data.answers_to(&q)); + + let not_matching_rule = AnswerRule { + text: Some("Another text".to_string()), + class: None, + data: None, + answer: answer.clone(), + }; + assert!(!not_matching_rule.answers_to(&q)); + } +} diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index d708cd92b5..1319404b0c 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -30,3 +30,4 @@ pub mod dbus; pub mod issue; pub mod openapi; pub mod progress; +pub mod question; diff --git a/rust/agama-utils/src/question.rs b/rust/agama-utils/src/question.rs new file mode 100644 index 0000000000..b4776a469f --- /dev/null +++ b/rust/agama-utils/src/question.rs @@ -0,0 +1,60 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Service to handle questions for users. +//! +//! This service offers and API to register and answer questions. The questions +//! can be answered: +//! +//! * By the user (the UI is responsible for updating the question with the given answer). +//! * Using a pre-defined answer for specific questions (set through the `SetConfig` message). +//! * Using the default action (set through the `SetConfig` message). +//! +//! The service can be started calling the [start] function, which returns an +//! [agama_utils::actors::ActorHandler] to interact with it. +//! +//! # Example +//! +//! ```no_run +//! use agama_utils::{ +//! api::question::QuestionSpec, +//! question::{self, message}, +//! }; +//! use tokio::sync::broadcast; +//! +//! # tokio_test::block_on(async { +//! async fn use_questions_service() { +//! let (events_tx, _events_rx) = broadcast::channel(16); +//! +//! let question = QuestionSpec::new("Please, enter a username", "username") +//! .as_string() +//! .with_actions(&[("next", "Next"), ("cancel", "Cancel")]); +//! let questions = question::start(events_tx).await.unwrap(); +//! _ = questions.call(message::Ask::new(question)); +//! } +//! # }); +//! ``` +pub mod service; +pub use service::Service; + +pub mod message; + +pub mod start; +pub use start::start; diff --git a/rust/agama-lib/src/questions/answers.rs b/rust/agama-utils/src/question/config.rs similarity index 85% rename from rust/agama-lib/src/questions/answers.rs rename to rust/agama-utils/src/question/config.rs index ef2ef3f1ab..6dd9ebf776 100644 --- a/rust/agama-lib/src/questions/answers.rs +++ b/rust/agama-utils/src/question/config.rs @@ -18,11 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod strategy; -pub use strategy::AnswerStrategy; +use std::collections::HashMap; -mod default; -pub use default::DefaultAnswers; - -mod custom; -pub use custom::{Answer, Answers}; +use serde::{Deserialize, Serialize}; diff --git a/rust/agama-utils/src/question/message.rs b/rust/agama-utils/src/question/message.rs new file mode 100644 index 0000000000..d976da324b --- /dev/null +++ b/rust/agama-utils/src/question/message.rs @@ -0,0 +1,90 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + actor::Message, + api::{ + self, + question::{self, Config, Question}, + }, +}; + +/// Gets questions configuration (policy, pre-defined answers, etc.). +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +/// Sets questions configuration (policy, pre-defined answers, etc.). +pub struct SetConfig { + pub config: Config, +} + +impl SetConfig { + pub fn new(config: Config) -> Self { + Self { config } + } +} + +impl Message for SetConfig { + type Reply = (); +} + +/// Gets the list of questions. +pub struct Get; + +impl Message for Get { + type Reply = Vec; +} + +/// Asks a question, adding it to the list of questions. +pub struct Ask { + pub question: question::QuestionSpec, +} + +impl Ask { + pub fn new(question: question::QuestionSpec) -> Self { + Self { question } + } +} + +impl Message for Ask { + type Reply = Question; +} + +/// Answers a question, updating its current representation. +pub struct Answer { + pub id: u32, + pub answer: question::Answer, +} + +impl Message for Answer { + type Reply = (); +} + +/// Removes a question from the list. +pub struct Delete { + pub id: u32, +} + +impl Message for Delete { + type Reply = (); +} diff --git a/rust/agama-utils/src/question/service.rs b/rust/agama-utils/src/question/service.rs new file mode 100644 index 0000000000..dcf2d01d54 --- /dev/null +++ b/rust/agama-utils/src/question/service.rs @@ -0,0 +1,161 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use async_trait::async_trait; +use tokio::sync::broadcast; + +use super::message; +use crate::{ + actor::{self, Actor, MessageHandler}, + api::{ + self, event, + question::{Answer, Config, Policy, Question, QuestionSpec}, + Event, + }, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + Model(#[from] api::question::Error), + #[error("Unknown question: {0}")] + UnknownQuestion(u32), +} + +pub struct Service { + config: Config, + questions: Vec, + current_id: u32, + events: event::Sender, +} + +impl Service { + pub fn new(events: event::Sender) -> Self { + Self { + config: Default::default(), + questions: vec![], + current_id: 0, + events, + } + } + + pub fn find_answer(&self, spec: &QuestionSpec) -> Option { + let answer = self + .config + .answers + .iter() + .find(|a| a.answers_to(&spec)) + .map(|r| r.answer.clone()); + + if answer.is_some() { + return answer; + } + + if let Some(Policy::Auto) = self.config.policy { + spec.default_action.clone().map(|action| Answer { + action, + value: None, + }) + } else { + None + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.config.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + self.config = message.config; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::Get, + ) -> Result, Error> { + Ok(self.questions.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Ask) -> Result { + self.current_id += 1; + + let mut question = Question::new(self.current_id, message.question); + if let Some(answer) = self.find_answer(&question.spec) { + _ = question.set_answer(answer); + } + self.questions.push(question.clone()); + + self.events.send(Event::QuestionAdded { + id: self.current_id, + })?; + + if question.answer.is_some() { + self.events.send(Event::QuestionAnswered { + id: self.current_id, + })?; + } + Ok(question) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Answer) -> Result<(), Error> { + let found = self.questions.iter_mut().find(|q| q.id == message.id); + match found { + Some(question) => { + question.set_answer(message.answer)?; + self.events + .send(Event::QuestionAnswered { id: message.id })?; + Ok(()) + } + None => Err(Error::UnknownQuestion(message.id)), + } + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Delete) -> Result<(), Error> { + self.questions.retain(|q| q.id != message.id); + Ok(()) + } +} diff --git a/rust/agama-utils/src/question/start.rs b/rust/agama-utils/src/question/start.rs new file mode 100644 index 0000000000..7411d86e74 --- /dev/null +++ b/rust/agama-utils/src/question/start.rs @@ -0,0 +1,175 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use super::{service, Service}; +use crate::{ + actor::{self, Handler}, + api::event, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Service(#[from] service::Error), +} + +pub async fn start(events: event::Sender) -> Result, Error> { + let service = Service::new(events); + let handler = actor::spawn(service); + + Ok(handler) +} + +#[cfg(test)] +mod tests { + use crate::{ + api::{ + question::{Answer, AnswerRule, Config, Policy, QuestionSpec}, + Event, + }, + question::{self, message}, + }; + use tokio::sync::broadcast; + + fn build_question_spec() -> QuestionSpec { + QuestionSpec::new("Do you want to continue?", "continue") + .with_actions(&[("yes", "Yes"), ("no", "No")]) + .with_default_action("no") + } + + #[tokio::test] + async fn test_ask_and_answer_question() -> Result<(), Box> { + let (events_tx, mut events_rx) = broadcast::channel(16); + let questions = question::start(events_tx).await.unwrap(); + + // Ask the question + let question = questions + .call(message::Ask::new(build_question_spec())) + .await?; + assert!(question.answer.is_none()); + + // Answer the question + let answer = Answer { + action: "yes".to_string(), + value: None, + }; + questions + .call(message::Answer { + id: question.id, + answer, + }) + .await?; + + let new_question = events_rx.recv().await?; + assert!(matches!(new_question, Event::QuestionAdded { id: _ })); + + let answer_question = events_rx.recv().await?; + assert!(matches!( + answer_question, + Event::QuestionAnswered { id: _id } + )); + + Ok(()) + } + + #[tokio::test] + async fn test_auto_answer_question_by_policy() -> Result<(), Box> { + let (events_tx, mut _events_rx) = broadcast::channel(16); + let questions = question::start(events_tx).await.unwrap(); + + // Set the configuration + let config = Config { + policy: Some(Policy::Auto), + ..Default::default() + }; + questions.call(message::SetConfig::new(config)).await?; + + // Ask the question + let question = questions + .call(message::Ask::new(build_question_spec())) + .await?; + + // Check the answer + assert!(question.answer.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_auto_answer_question_by_rule() -> Result<(), Box> { + let (events_tx, mut _events_rx) = broadcast::channel(16); + let questions = question::start(events_tx).await.unwrap(); + + // Define a rule and an answer. + let answer = Answer { + action: "no".to_string(), + value: None, + }; + let rule_by_class = AnswerRule { + text: None, + class: Some("continue".to_string()), + data: None, + answer: answer.clone(), + }; + + // Set the configuration + let config = Config { + policy: Some(Policy::User), + answers: vec![rule_by_class], + }; + questions.call(message::SetConfig::new(config)).await?; + + // Ask the question + let question = questions + .call(message::Ask::new(build_question_spec())) + .await?; + + // Check the answer + assert_eq!(question.answer, Some(answer)); + + Ok(()) + } + + #[tokio::test] + async fn test_delete_question() -> Result<(), Box> { + let (events_tx, mut _events_rx) = broadcast::channel(16); + let questions = question::start(events_tx).await.unwrap(); + + // Ask the question + let question = questions + .call(message::Ask::new(build_question_spec())) + .await?; + + let all_questions = questions.call(message::Get).await?; + let found = all_questions.into_iter().find(|q| q.id == question.id); + assert!(found.is_some()); + + // Delete the question + questions + .call(message::Delete { id: question.id }) + .await + .unwrap(); + let all_questions = questions.call(message::Get).await?; + let found = all_questions.into_iter().find(|q| q.id == question.id); + assert!(found.is_none()); + + Ok(()) + } +} diff --git a/rust/install.sh b/rust/install.sh index 0c0727be0f..2e5cb4e9d8 100755 --- a/rust/install.sh +++ b/rust/install.sh @@ -36,7 +36,6 @@ install6() { install -D -t "${DESTDIR}${bindir}" "${SRCDIR}/target/${RUST_TARGET}/agama" install -D -t "${DESTDIR}${bindir}" "${SRCDIR}/target/${RUST_TARGET}/agama-autoinstall" -install -D -t "${DESTDIR}${bindir}" "${SRCDIR}/target/${RUST_TARGET}/agama-dbus-server" install -D -t "${DESTDIR}${bindir}" "${SRCDIR}/target/${RUST_TARGET}/agama-web-server" install6 -D -p "${SRCDIR}"/share/agama.pam "${DESTDIR}${pamvendordir}"/agama @@ -47,8 +46,6 @@ install6 -D -t "${DESTDIR}${datadir}"/agama-cli "${SRCDIR}"/agama-lib/share/stor install6 -D -t "${DESTDIR}${datadir}"/agama-cli "${SRCDIR}"/agama-lib/share/storage.model.schema.json install6 -D -t "${DESTDIR}${datadir}"/agama-cli "${SRCDIR}"/share/agama.libsonnet -install6 -D -t "${DESTDIR}${datadir}"/dbus-1/agama-services "${SRCDIR}"/share/org.opensuse.Agama1.service - install -D -t "${DESTDIR}${libexecdir}" "${SRCDIR}"/share/agama-scripts.sh install6 -D -t "${DESTDIR}${unitdir}" "${SRCDIR}"/share/agama-autoinstall.service diff --git a/rust/package/agama.changes b/rust/package/agama.changes index bd035137b8..3e3a352533 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Oct 17 13:14:31 UTC 2025 - Imobach Gonzalez Sosa + +- Add a new HTTP resource to register questions (gh#agama-project/agama#2813). + ------------------------------------------------------------------- Thu Oct 16 04:46:20 UTC 2025 - José Iván López González diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 5c03a79db9..72bc1eaac7 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -34,7 +34,6 @@ BuildRequires: pkgconfig(openssl) # used in tests for dbus service BuildRequires: dbus-1-common Requires: dbus-1-common -# required by agama-dbus-server integration tests BuildRequires: dbus-1-daemon BuildRequires: clang-devel BuildRequires: pkgconfig(pam) @@ -61,9 +60,6 @@ Requires: python-langtable-data # dependency on the YaST part of Agama Requires: agama-yast -# conflicts with the old packages -Conflicts: agama-dbus-server - %description Agama is a service-based Linux installer. It is composed of an HTTP-based API, a web user interface, a command-line interface and a D-Bus service which exposes @@ -216,7 +212,6 @@ echo $PATH %files %doc README.md %license LICENSE -%{_bindir}/agama-dbus-server %{_bindir}/agama-web-server %{_datadir}/dbus-1/agama-services %{_pam_vendordir}/agama diff --git a/rust/share/org.opensuse.Agama1.service b/rust/share/org.opensuse.Agama1.service deleted file mode 100644 index edbb330d2f..0000000000 --- a/rust/share/org.opensuse.Agama1.service +++ /dev/null @@ -1,4 +0,0 @@ -[D-BUS Service] -Name=org.opensuse.Agama1 -Exec=/usr/bin/agama-dbus-server -User=root diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 63814f93ad..92cf1d7a1f 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -6,8 +6,8 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, - MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, QuestionsApiDocBuilder, - ScriptsApiDocBuilder, SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, + MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, + SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -70,7 +70,6 @@ mod tasks { write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; - write_openapi(QuestionsApiDocBuilder {}, out_dir.join("questions.json"))?; write_openapi(ScriptsApiDocBuilder {}, out_dir.join("scripts.json"))?; write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; write_openapi(StorageApiDocBuilder {}, out_dir.join("storage.json"))?; diff --git a/service/Gemfile.lock b/service/Gemfile.lock index b3c14e46ad..7182a681bb 100755 --- a/service/Gemfile.lock +++ b/service/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - agama-yast (18.pre) + agama-yast (17.devel564.e889a82fd) cfa (~> 1.0.2) cfa_grub2 (~> 2.0.0) cheetah (~> 1.0.0) diff --git a/service/lib/agama/answer.rb b/service/lib/agama/answer.rb new file mode 100644 index 0000000000..8d98f67ee3 --- /dev/null +++ b/service/lib/agama/answer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + # Represents an answer for a [Agama::Question]. + class Answer + attr_reader :action + attr_reader :value + + class << self + def from_api(hash) + Answer.new(hash["action"], hash["value"]) + end + end + + def initialize(action, value = nil) + @action = action.to_sym + @value = value + end + end +end diff --git a/service/lib/agama/autoyast/profile_reporter.rb b/service/lib/agama/autoyast/profile_reporter.rb index 37511d4619..24af3cbef6 100644 --- a/service/lib/agama/autoyast/profile_reporter.rb +++ b/service/lib/agama/autoyast/profile_reporter.rb @@ -30,7 +30,7 @@ class ProfileReporter # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) textdomain "agama" @@ -53,16 +53,16 @@ def report(elements) question = Agama::Question.new( qclass: "autoyast.unsupported", text: message, - options: [:Continue, :Abort], - default_option: :Continue, + options: [:continue, :abort], + default_option: :continue, data: { "planned" => planned.map(&:key).join(","), "unsupported" => unsupported.map(&:key).join(",") } ) - questions_client.ask(question) do |question_client| - question_client.answer == :Continue + questions_client.ask(question) do |answer| + answer == :continue end end diff --git a/service/lib/agama/commands/agama_autoyast.rb b/service/lib/agama/commands/agama_autoyast.rb index bd5d0df2ed..99ca96e91f 100644 --- a/service/lib/agama/commands/agama_autoyast.rb +++ b/service/lib/agama/commands/agama_autoyast.rb @@ -26,7 +26,7 @@ require "agama/autoyast/profile_reporter" require "agama/autoyast/profile_checker" require "agama/cmdline_args" -require "agama/dbus/clients/questions" +require "agama/http/clients" module Agama # :nodoc: @@ -96,7 +96,7 @@ def write_agama_config(profile) end def questions_client - @questions_client ||= Agama::DBus::Clients::Questions.new(logger: logger) + @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end # Whether the report is enabled or not. diff --git a/service/lib/agama/config_reader.rb b/service/lib/agama/config_reader.rb index 35912ee826..c67590d387 100644 --- a/service/lib/agama/config_reader.rb +++ b/service/lib/agama/config_reader.rb @@ -137,7 +137,7 @@ def remote_config end def default_path - Dir.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end def config_paths diff --git a/service/lib/agama/dbus/clients/question.rb b/service/lib/agama/dbus/clients/question.rb deleted file mode 100644 index 54bdf7ba85..0000000000 --- a/service/lib/agama/dbus/clients/question.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/clients/base" - -module Agama - module DBus - module Clients - # D-Bus client for asking a question. - class Question < Base - WITH_PASSWORD_IFACE = "org.opensuse.Agama1.Questions.WithPassword" - private_constant :WITH_PASSWORD_IFACE - - # @return [::DBus::ProxyObject] - attr_reader :dbus_object - - # @param [::DBus::ObjectPath] object_path - def initialize(object_path) - super() - - @dbus_object = service[object_path] - @dbus_iface = @dbus_object["org.opensuse.Agama1.Questions.Generic"] - # one D-Bus client for all kinds of questions - return unless @dbus_object.has_iface?(WITH_PASSWORD_IFACE) - - @password_iface = @dbus_object[WITH_PASSWORD_IFACE] - end - - # @return [String] - def service_name - @service_name ||= "org.opensuse.Agama1" - end - - # @return [String] Question text - def text - @dbus_iface["Text"].to_s - end - - # @return [Symbol] no answer yet = :"" - def answer - @dbus_iface["Answer"].to_sym - end - - # @return [String,nil] Password or nil if there is no withPassword interface - def password - return nil unless @password_iface - - @password_iface["Password"] - end - - # Whether the question is already answered - # - # @return [Boolean] - def answered? - answer != :"" - end - end - end - end -end diff --git a/service/lib/agama/dbus/clients/questions.rb b/service/lib/agama/dbus/clients/questions.rb deleted file mode 100644 index a4f6145a0e..0000000000 --- a/service/lib/agama/dbus/clients/questions.rb +++ /dev/null @@ -1,156 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/clients/base" -require "agama/dbus/clients/question" -require "agama/question_with_password" - -module Agama - module DBus - module Clients - # D-Bus client for asking a question. - class Questions < Base - # Constructor - # - # @param logger [Logger, nil] - def initialize(logger: nil) - super - - @dbus_object = service["/org/opensuse/Agama1/Questions"] - @dbus_object.default_iface = "org.opensuse.Agama1.Questions" - end - - # @return [String] - def service_name - @service_name ||= "org.opensuse.Agama1" - end - - # Adds a question - # - # @param question [Agama::Question] - # @return [DBus::Clients::Question] - def add(question) - dbus_path = add_question(question) - DBus::Clients::Question.new(dbus_path) - end - - # Deletes the given question - # - # @raise [::DBus::Error] if trying to delete a question twice - # - # @param question [DBus::Clients::Question] - # @return [void] - def delete(question) - @dbus_object.Delete(question.dbus_object.path) - end - - # Waits until specified questions are answered - # - # @param questions [Array] - # @return [void] - def wait(questions) - logger.info "Waiting for questions to be answered" - - # TODO: detect if no UI showed up to display the questions and time out? - # for example: - # (0..Float::INFINITY).each { |i| break if i > 100 && !question.displayed; ... } - - # We should register the InterfacesAdded callback... BEFORE adding to avoid races. - # Stupid but simple way: poll the answer property, sleep, repeat - loop do - questions = questions.find_all { |q| !q.answered? } - break if questions.empty? - - sleep(0.5) - end - end - - # Asks the given question and waits until the question is answered - # - # @example - # ask(question1) #=> Symbol - # ask(question2) { |q| q.answer == :yes } #=> Boolean - # - # @param question [Agama::Question] - # @yield [Agama::DBus::Clients::Question] Gives the answered question to the block. - # @return [Symbol, Object] The question answer, or the result of the block in case a block - # is given. - def ask(question) - question_client = add(question) - wait([question_client]) - - answer = question_client.answer - logger.info("#{question.text} #{answer}") - - result = block_given? ? yield(question_client) : answer - delete(question_client) - - result - end - - private - - # @return [::DBus::Object] - attr_reader :dbus_object - - # Adds a question using the proper D-Bus method according to the question type - # - # @param question [Agama::Question] - # @return [::DBus::ObjectPath] - def add_question(question) - if question.is_a?(Agama::QuestionWithPassword) - add_question_with_password(question) - else - add_generic_question(question) - end - end - - # Adds a generic question - # - # @param question [Agama::Question] - # @return [::DBus::ObjectPath] - def add_generic_question(question) - @dbus_object.New( - question.qclass, - question.text, - question.options.map(&:to_s), - question.default_option.to_s, - question.data - ) - end - - # Adds a question for activating LUKS - # - # @param question [Agama::QuestionWithPassword] - # @return [::DBus::ObjectPath] - def add_question_with_password(question) - @dbus_object.NewWithPassword( - question.qclass, - question.text, - question.options.map(&:to_s), - question.default_option.to_s, - question.data - ) - end - end - end - end -end diff --git a/service/lib/agama/dbus/y2dir/software/modules/PackageCallbacks.rb b/service/lib/agama/dbus/y2dir/software/modules/PackageCallbacks.rb index 83493ae334..db2f77543b 100644 --- a/service/lib/agama/dbus/y2dir/software/modules/PackageCallbacks.rb +++ b/service/lib/agama/dbus/y2dir/software/modules/PackageCallbacks.rb @@ -20,7 +20,7 @@ require "yast" require "logger" require "agama/software/callbacks" -require "agama/dbus/clients/questions" +require "agama/http/clients" # :nodoc: module Yast @@ -63,7 +63,7 @@ def InitPackageCallbacks(logger = nil) # # @return [Agama::DBus::Clients::Questions] def questions_client - @questions_client ||= Agama::DBus::Clients::Questions.new(logger: logger) + @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end private diff --git a/service/lib/agama/http/clients.rb b/service/lib/agama/http/clients.rb index a835d05e51..5c7b765755 100644 --- a/service/lib/agama/http/clients.rb +++ b/service/lib/agama/http/clients.rb @@ -31,5 +31,6 @@ module Clients require "agama/http/clients/files" require "agama/http/clients/main" require "agama/http/clients/network" +require "agama/http/clients/questions" require "agama/http/clients/scripts" require "agama/http/clients/software" diff --git a/service/lib/agama/http/clients/base.rb b/service/lib/agama/http/clients/base.rb index e569ac2e45..02c2a27ab9 100644 --- a/service/lib/agama/http/clients/base.rb +++ b/service/lib/agama/http/clients/base.rb @@ -38,7 +38,7 @@ def initialize(logger) # @param data[#to_json] data to send in request def post(path, data) response = Net::HTTP.post(uri(path), data.to_json, headers) - return unless response.is_a?(Net::HTTPClientError) + return response unless response.is_a?(Net::HTTPClientError) @logger.warn "server returned #{response.code} with body: #{response.body}" end diff --git a/service/lib/agama/http/clients/questions.rb b/service/lib/agama/http/clients/questions.rb new file mode 100644 index 0000000000..b153edc65e --- /dev/null +++ b/service/lib/agama/http/clients/questions.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/http/clients/base" +require "agama/question" + +module Agama + module HTTP + module Clients + # HTTP client to interact with the files API. + class Questions < Base + # Adds a question + # + # @param question [Agama::Question] + # @return [Question, nil] created question or nil if the request failed + def add(question) + response = post("v2/questions", question.to_api) + response ? Question.from_api(JSON.parse(response.body)) : nil + end + + def questions + JSON.parse(get("v2/questions")).map { |q| Question.from_api(q) } + end + + # Deletes the given question + # + # @param id [Integer] question ID + # @return [void] + def delete(id) + payload = { "delete" => { "id" => id } } + patch("/v2/questions", payload) + end + + # Waits until specified question is answered + # + # @param id [Integer] question ID + # @return [void] + def wait_answer(id) + @logger.info "Waiting for question #{id} to be answered" + + # TODO: detect if no UI showed up to display the questions and time out? + # for example: + # (0..Float::INFINITY).each { |i| break if i > 100 && !question.displayed; ... } + loop do + found = questions.find { |q| q.id == id } + # raise an error if the question is not found. + return found.answer if found&.answer + + sleep(0.5) + end + end + + # Asks the given question and waits until the question is answered + # + # @example + # ask(question1) #=> Symbol + # ask(question2) { |a| a.action == :yes } #=> Boolean + # + # @param question [Agama::Question] + # @yield [Agama::Answer] Gives the answered question to the block. + # @return [Agama::Answer] The question answer, or the result of the block in case a block + # is given. + def ask(question) + question_id = add(question) + answer = wait_answer(question_id) + + logger.info("#{question.text} #{answer}") + + result = block_given? ? yield(answer) : answer + delete(question_id) + + result + end + end + end + end +end diff --git a/service/lib/agama/product_reader.rb b/service/lib/agama/product_reader.rb index 96acc58ef3..66ccdd779c 100644 --- a/service/lib/agama/product_reader.rb +++ b/service/lib/agama/product_reader.rb @@ -58,7 +58,7 @@ def load_products private def default_path - Dir.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end end end diff --git a/service/lib/agama/question.rb b/service/lib/agama/question.rb index 0824e857b2..9628924ddf 100644 --- a/service/lib/agama/question.rb +++ b/service/lib/agama/question.rb @@ -19,12 +19,21 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. +require "agama/answer" + module Agama # This class represents a question to be created # # Questions are used when some information needs to be asked. For example, a question could be # created for asking whether to continue or not when an error is detected. class Question + # Question ID. + # + # It is set only when it comes from the API. + # + # @return [Integer, nil] Question ID + attr_reader :id + # Class of the question # Helps with identification of same type of questions # @@ -50,7 +59,7 @@ class Question # Answer of the question # - # @return [Symbol, nil] nil if the question is not answered yet + # @return [Answer, nil] nil if the question is not answered yet attr_reader :answer # Additional data to hold identify question or improve UI to display it @@ -59,12 +68,56 @@ class Question # @return [Hash] attr_reader :data - def initialize(qclass:, text:, options:, default_option: nil, data: {}) + class << self + # Builds a question object from the HTTP API. + # + # @param hash [Hash] question data from the HTTTP API. + def from_api(hash) + answer = Answer.from_api(hash["answer"]) if hash["answer"] + question = new( + qclass: hash["class"], + text: hash["text"], + options: hash["actions"].map { |a| a["id"].to_sym }, + default_option: hash["defaultAction"]&.to_sym, + data: hash["data"] || {}, + answer: answer + ) + question.send(:id=, hash["id"]) + question + end + end + + def initialize(qclass:, text:, options:, default_option: nil, data: {}, answer: nil) + @id = nil @qclass = qclass @text = text @options = options @default_option = default_option @data = data + @answer = answer end + + # Converts a question into a hash to be consumed by the HTTP API. + def to_api + actions = @options.map do |option| + if option.is_a?(Hash) + option + else + { "id" => option.to_s, "label" => option.to_s.capitalize } + end + end + + question = { + "text" => @text, + "class" => @qclass, + "actions" => actions, + "defaultAction" => @default_option.to_s, + "data" => @data + } + end + + private + + attr_writer :id end end diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index bf0490e2dd..4dd3e483de 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -405,8 +405,8 @@ def handle_ssl_error(_error, certificate_imported) return false unless SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) question = certificate_question(cert) - questions_client = Agama::DBus::Clients::Questions.new(logger: @logger) - questions_client.ask(question) { |c| c.answer == :trust } + questions_client = Agama::HTTP::Clients::Questions.new(@logger) + questions_client.ask(question) { |a| a.action == :trust } end # @param certificate [Agama::SSL::Certificate] diff --git a/service/lib/agama/software/callbacks/base.rb b/service/lib/agama/software/callbacks/base.rb index 5903295486..fa6a26199d 100644 --- a/service/lib/agama/software/callbacks/base.rb +++ b/service/lib/agama/software/callbacks/base.rb @@ -30,7 +30,7 @@ class Base # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) textdomain "agama" @@ -80,7 +80,7 @@ def no_label private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/software/callbacks/digest.rb b/service/lib/agama/software/callbacks/digest.rb index 4b0639fafd..d36f9157b0 100644 --- a/service/lib/agama/software/callbacks/digest.rb +++ b/service/lib/agama/software/callbacks/digest.rb @@ -68,8 +68,8 @@ def accept_file_without_checksum(filename) options: [yes_label.to_sym, no_label.to_sym], default_option: yes_label.to_sym ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end @@ -94,8 +94,8 @@ def accept_unknown_digest(filename, digest) options: [yes_label.to_sym, no_label.to_sym], default_option: yes_label.to_sym ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end @@ -121,8 +121,8 @@ def accept_wrong_digest(filename, expected_digest, found_digest) options: [yes_label.to_sym, no_label.to_sym], default_option: yes_label.to_sym ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end diff --git a/service/lib/agama/software/callbacks/media.rb b/service/lib/agama/software/callbacks/media.rb index 935f0fcdd7..f0cf972641 100644 --- a/service/lib/agama/software/callbacks/media.rb +++ b/service/lib/agama/software/callbacks/media.rb @@ -104,8 +104,8 @@ def media_change(error_code, error, url, product, current, current_label, wanted options: [retry_label.to_sym, continue_label.to_sym], data: { "url" => url } ) - questions_client.ask(question) do |question_client| - if question_client.answer == retry_label.to_sym + questions_client.ask(question) do |answer| + if answer.action == retry_label.to_sym self.attempt += 1 "" else diff --git a/service/lib/agama/software/callbacks/progress.rb b/service/lib/agama/software/callbacks/progress.rb index d2157ad84a..ce23409ca1 100644 --- a/service/lib/agama/software/callbacks/progress.rb +++ b/service/lib/agama/software/callbacks/progress.rb @@ -22,7 +22,7 @@ require "logger" require "yast" require "agama/question" -require "agama/dbus/clients/questions" +require "agama/http/clients" require "agama/software/callbacks/base" Yast.import "Pkg" @@ -74,9 +74,9 @@ def setup # @return [Logger] attr_reader :logger - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] def questions_client - @questions_client ||= Agama::DBus::Clients::Questions.new(logger: logger) + @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end def start_package(package, _file, _summary, _size, _other) @@ -98,8 +98,8 @@ def done_package(error_code, description) data: { "package" => current_package } ) - questions_client.ask(question) do |question_client| - case question_client.answer + questions_client.ask(question) do |answer| + case answer when retry_label.to_sym "R" # FIXME: temporarily disabled diff --git a/service/lib/agama/software/callbacks/provide.rb b/service/lib/agama/software/callbacks/provide.rb index 2e70c1d0b8..708c2d9745 100644 --- a/service/lib/agama/software/callbacks/provide.rb +++ b/service/lib/agama/software/callbacks/provide.rb @@ -74,8 +74,8 @@ def done_provide(error, reason, name) data: { "package" => name, "error_code" => error_code } ) - questions_client.ask(question) do |question_client| - (question_client.answer == retry_label.to_sym) ? "R" : "I" + questions_client.ask(question) do |answer| + (answer.action == retry_label.to_sym) ? "R" : "I" end end end diff --git a/service/lib/agama/software/callbacks/script.rb b/service/lib/agama/software/callbacks/script.rb index 4e905eb6c3..74b66c8d39 100644 --- a/service/lib/agama/software/callbacks/script.rb +++ b/service/lib/agama/software/callbacks/script.rb @@ -54,14 +54,14 @@ def script_problem(description) options: [retry_label, continue_label], data: { "details" => description } ) - questions_client.ask(question) do |question_client| - (question_client.answer == retry_label.to_sym) ? "R" : "I" + questions_client.ask(question) do |answer| + (answer.action == retry_label.to_sym) ? "R" : "I" end end private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/software/callbacks/signature.rb b/service/lib/agama/software/callbacks/signature.rb index 377fc3d0d8..5aaabef771 100644 --- a/service/lib/agama/software/callbacks/signature.rb +++ b/service/lib/agama/software/callbacks/signature.rb @@ -82,8 +82,8 @@ def accept_unsigned_file(filename, repo_id) default_option: no_label.to_sym, data: { "filename" => filename } ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end @@ -114,8 +114,8 @@ def import_gpg_key(key, repo_id) } ) - questions_client.ask(question) do |question_client| - question_client.answer == trust_label.to_sym + questions_client.ask(question) do |answer| + answer.action == trust_label.to_sym end end @@ -151,8 +151,8 @@ def accept_unknown_gpg_key(filename, key_id, repo_id) } ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end @@ -186,8 +186,8 @@ def accept_verification_failed(filename, key, repo_id) default_option: no_label.to_sym, data: { "filename" => filename } ) - questions_client.ask(question) do |question_client| - question_client.answer == yes_label.to_sym + questions_client.ask(question) do |answer| + answer.action == yes_label.to_sym end end diff --git a/service/lib/agama/storage/callbacks/activate.rb b/service/lib/agama/storage/callbacks/activate.rb index c33ef3d4bd..1f5e60baa0 100644 --- a/service/lib/agama/storage/callbacks/activate.rb +++ b/service/lib/agama/storage/callbacks/activate.rb @@ -33,7 +33,7 @@ class Activate < ::Storage::ActivateCallbacksLuks # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) super() @@ -79,7 +79,7 @@ def luks(info, attempt) private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/storage/callbacks/activate_luks.rb b/service/lib/agama/storage/callbacks/activate_luks.rb index d13d42178a..2a2d33c3f7 100644 --- a/service/lib/agama/storage/callbacks/activate_luks.rb +++ b/service/lib/agama/storage/callbacks/activate_luks.rb @@ -29,7 +29,7 @@ module Callbacks class ActivateLuks # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) @questions_client = questions_client @@ -49,9 +49,9 @@ def initialize(questions_client, logger) def call(info, attempt) question = question(info, attempt) - questions_client.ask(question) do |question_client| - activate = question_client.answer == :decrypt - password = question_client.password + questions_client.ask(question) do |answer| + activate = answer.action == :decrypt + password = answer.value [activate, password] end @@ -59,7 +59,7 @@ def call(info, attempt) private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/storage/callbacks/activate_multipath.rb b/service/lib/agama/storage/callbacks/activate_multipath.rb index 6aeea247ad..8c9ddf9848 100644 --- a/service/lib/agama/storage/callbacks/activate_multipath.rb +++ b/service/lib/agama/storage/callbacks/activate_multipath.rb @@ -29,7 +29,7 @@ module Callbacks class ActivateMultipath # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger] def initialize(questions_client, logger) @questions_client = questions_client @@ -46,14 +46,14 @@ def call(looks_like_real_multipath) return true if Y2Storage::StorageEnv.instance.forced_multipath? return false unless looks_like_real_multipath - questions_client.ask(question) do |question_client| - question_client.answer == :yes + questions_client.ask(question) do |answer| + answer.action == :yes end end private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/storage/callbacks/commit.rb b/service/lib/agama/storage/callbacks/commit.rb index 9939a0e0c7..0796bbe0d4 100644 --- a/service/lib/agama/storage/callbacks/commit.rb +++ b/service/lib/agama/storage/callbacks/commit.rb @@ -29,7 +29,7 @@ module Callbacks class Commit < ::Storage::CommitCallbacks # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger, nil] def initialize(questions_client, logger: nil) super() @@ -61,7 +61,7 @@ def error(message, what) private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/storage/callbacks/commit_error.rb b/service/lib/agama/storage/callbacks/commit_error.rb index fcf8c75742..1a2a5f51fa 100644 --- a/service/lib/agama/storage/callbacks/commit_error.rb +++ b/service/lib/agama/storage/callbacks/commit_error.rb @@ -28,7 +28,7 @@ module Callbacks class CommitError # Constructor # - # @param questions_client [Agama::DBus::Clients::Questions] + # @param questions_client [Agama::HTTP::Clients::Questions] # @param logger [Logger, nil] def initialize(questions_client, logger: nil) @questions_client = questions_client @@ -51,16 +51,16 @@ def call(message, details) question = question(message, details) - questions_client.ask(question) do |question_client| - answer = question_client.answer - logger.info "User answer: #{answer}" - answer == :yes + questions_client.ask(question) do |answer| + action = answer.action + logger.info "User answer: #{action}" + action == :yes end end private - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] attr_reader :questions_client # @return [Logger] diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 9bce3c5228..0b74abd623 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/dbus/clients/questions" +require "agama/http/clients" require "agama/dbus/clients/software" require "agama/issue" require "agama/security" @@ -368,9 +368,9 @@ def candidate_devices_issue # Returns the client to ask questions # - # @return [Agama::DBus::Clients::Questions] + # @return [Agama::HTTP::Clients::Questions] def questions_client - @questions_client ||= Agama::DBus::Clients::Questions.new(logger: logger) + @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end MULTIPATH_CONFIG = "/etc/multipath.conf" diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index dedd215a47..728a7735ba 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Oct 17 13:15:00 UTC 2025 - Imobach Gonzalez Sosa + +- Adapt to the new questions HTTP API (gh#agama-project/agama#2813). + ------------------------------------------------------------------- Fri Oct 3 20:11:33 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/test/agama/commands/agama_autoyast_test.rb b/service/test/agama/commands/agama_autoyast_test.rb index eb0c843c69..cad3385ada 100644 --- a/service/test/agama/commands/agama_autoyast_test.rb +++ b/service/test/agama/commands/agama_autoyast_test.rb @@ -30,7 +30,7 @@ let(:fetcher) { instance_double(Agama::AutoYaST::ProfileFetcher) } let(:checker) { Agama::AutoYaST::ProfileChecker.new } let(:reporter) { instance_double(Agama::AutoYaST::ProfileReporter, report: true) } - let(:questions) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions) { instance_double(Agama::HTTP::Clients::Questions) } let(:profile) do Yast::ProfileHash.new({ "software" => { "products" => ["openSUSE"] } }) end @@ -42,7 +42,7 @@ allow(Agama::AutoYaST::ProfileFetcher).to receive(:new).with(url).and_return(fetcher) allow(Agama::AutoYaST::ProfileChecker).to receive(:new).and_return(checker) allow(Agama::AutoYaST::ProfileReporter).to receive(:new).and_return(reporter) - allow(Agama::DBus::Clients::Questions).to receive(:new).and_return(questions) + allow(Agama::HTTP::Clients::Questions).to receive(:new).and_return(questions) allow(Agama::CmdlineArgs).to receive(:read_from).and_return(cmdline_args) allow(fetcher).to receive(:fetch).and_return(profile) end diff --git a/service/test/agama/dbus/clients/question_test.rb b/service/test/agama/dbus/clients/question_test.rb deleted file mode 100644 index 6019e2db66..0000000000 --- a/service/test/agama/dbus/clients/question_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/dbus/clients/question" -require "dbus" - -describe Agama::DBus::Clients::Question do - before do - allow(Agama::DBus::Bus).to receive(:current).and_return(bus) - allow(bus).to receive(:service).with("org.opensuse.Agama1").and_return(service) - allow(service).to receive(:[]).with("/org/opensuse/Agama1/Questions/23") - .and_return(dbus_object) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Questions.Generic") - .and_return(generic_iface) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Questions.WithPassword") - .and_return(with_password_iface) - allow(dbus_object).to receive(:has_iface?).with(/WithPassword/).and_return(with_password?) - end - - let(:bus) { instance_double(Agama::DBus::Bus) } - let(:service) { instance_double(DBus::ProxyService) } - let(:dbus_object) { instance_double(DBus::ProxyObject) } - let(:generic_iface) { instance_double(DBus::ProxyObjectInterface) } - let(:with_password_iface) { instance_double(DBus::ProxyObjectInterface) } - let(:with_password?) { true } - - subject { described_class.new("/org/opensuse/Agama1/Questions/23") } - - describe "#answered?" do - it "returns false if there is no answer" do - expect(generic_iface).to receive(:[]).with("Answer").and_return("") - expect(subject.answered?).to eq false - end - end - - describe "#text" do - it "returns the appropriate property" do - expect(generic_iface).to receive(:[]).with("Text").and_return("the text") - expect(subject.text).to eq "the text" - end - end - - describe "#password" do - it "returns the appropriate property of the luks interface" do - expect(with_password_iface).to receive(:[]).with("Password").and_return("the password") - expect(subject.password).to eq "the password" - end - - context "when the luks interface is missing" do - let(:with_password?) { false } - - it "returns nil" do - expect(subject.password).to be_nil - end - end - end -end diff --git a/service/test/agama/dbus/clients/questions_test.rb b/service/test/agama/dbus/clients/questions_test.rb deleted file mode 100644 index e8def1d3f1..0000000000 --- a/service/test/agama/dbus/clients/questions_test.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/dbus/clients/questions" -require "agama/question" -require "dbus" - -describe Agama::DBus::Clients::Questions do - before do - allow(Agama::DBus::Bus).to receive(:current).and_return(bus) - allow(bus).to receive(:service).with("org.opensuse.Agama1").and_return(service) - allow(service).to receive(:[]).with("/org/opensuse/Agama1/Questions") - .and_return(dbus_object) - allow(dbus_object).to receive(:default_iface=) - end - - subject { described_class.new(logger: logger) } - - let(:logger) { Logger.new($stdout, level: :warn) } - - let(:bus) { instance_double(Agama::DBus::Bus) } - let(:service) { instance_double(DBus::ProxyService) } - let(:dbus_object) { instance_double(DBus::ProxyObject) } - let(:properties_iface) { instance_double(DBus::ProxyObjectInterface) } - - let(:question1) do - Agama::Question.new(text: "What?", qclass: "test2", options: [:this, :that], - default_option: :this) - end - let(:question2) do - Agama::Question.new(text: "When?", qclass: "test", options: [:now, :later], - default_option: :now) - end - let(:question1_proxy) do - instance_double(DBus::ProxyObject, path: "/org/opensuse/Agama/Questions1/33") - end - let(:question1_stub) do - instance_double(Agama::DBus::Clients::Question, dbus_object: question1_proxy) - end - - describe "#add" do - # Using partial double because methods are dynamically added to the proxy object - let(:dbus_object) { double(DBus::ProxyObject) } - - it "asks the service to add a question and returns a stub object for it" do - expect(dbus_object).to receive(:New).with("test2", "What?", ["this", "that"], "this", {}) - expect(Agama::DBus::Clients::Question).to receive(:new).and_return(question1_stub) - expect(subject.add(question1)).to eq question1_stub - end - end - - describe "#delete" do - # Using partial double because methods are dynamically added to the proxy object - let(:dbus_object) { double(DBus::ProxyObject) } - - it "asks the service to delete the question" do - expect(dbus_object).to receive(:Delete).with(question1_proxy.path) - expect { subject.delete(question1_stub) }.to_not raise_error - end - - it "propagates errors" do - # let's say we mistakenly try to delete the same Q twice - error = DBus::Error.new("Oopsie") - allow(dbus_object).to receive(:Delete).and_raise(error) - expect { subject.delete(question1_stub) }.to raise_error(DBus::Error) - end - end - - describe "#wait" do - it "loops and sleeps until all specified questions are answered" do - expect(question1).to receive(:answered?).and_return(true) - expect(question2).to receive(:answered?).and_return(false, true) - - expect(subject).to receive(:sleep).exactly(1).times - subject.wait([question1, question2]) - end - end -end diff --git a/service/test/agama/http/clients/scripts_test.rb b/service/test/agama/http/clients/scripts_test.rb index 84ab6af313..b7a6517d6a 100644 --- a/service/test/agama/http/clients/scripts_test.rb +++ b/service/test/agama/http/clients/scripts_test.rb @@ -24,6 +24,7 @@ describe Agama::HTTP::Clients::Scripts do subject(:scripts) { described_class.new(Logger.new($stdout)) } + let(:response) { instance_double(Net::HTTPResponse, body: "") } before do allow(File).to receive(:read).with("/run/agama/token") @@ -36,7 +37,7 @@ expect(Net::HTTP).to receive(:post).with(url, "post".to_json, { "Content-Type": "application/json", Authorization: "Bearer 123456" - }) + }).and_return(response) scripts.run("post") end end diff --git a/service/test/agama/question_test.rb b/service/test/agama/question_test.rb new file mode 100644 index 0000000000..cb2b1a2500 --- /dev/null +++ b/service/test/agama/question_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Copyright (c) [2022-2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../test_helper" +require "agama/question" + +describe Agama::Question do + describe ".from_api" do + let(:api_question) do + { + "text" => "Do you want to decrypt the file system?", + "class" => "storage.luks", + "actions" => [ + { "id" => "decrypt", "label" => "Decrypt" }, + { "id" => "skip", "label" => "Skip" } + ], + "defaultAction" => "skip", + "data" => { "unsupported" => "general" } + } + end + + it "builds an instance from an API hash" do + question = described_class.from_api(api_question) + expect(question.text).to eq(api_question["text"]) + expect(question.qclass).to eq(api_question["class"]) + expect(question.options).to eq([:decrypt, :skip]) + expect(question.default_option).to eq(:skip) + expect(question.data).to eq(api_question["data"]) + expect(question.id).to be_nil + end + + context "if the question has an ID" do + it "sets the ID" do + question = described_class.from_api(api_question.merge("id" => 1)) + expect(question.id).to eq(1) + end + end + + context "if the question has an answer" do + it "sets the answer" do + with_answer = api_question.merge("answer" => { "action" => "yes", "value" => "secret" }) + question = described_class.from_api(with_answer) + answer = question.answer + expect(answer.action).to eq(:yes) + expect(answer.value).to eq("secret") + end + end + end +end diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb index 7290ac3283..9f376e5554 100644 --- a/service/test/agama/registration_test.rb +++ b/service/test/agama/registration_test.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require_relative "../test_helper" +require "agama/answer" require "agama/config" require "agama/registration" require "agama/software/manager" @@ -259,6 +260,8 @@ end context "if the registration server has self-signed certificate" do + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:certificate) do Agama::SSL::Certificate.load(File.read(File.join(FIXTURES_PATH, "test.pem"))) end @@ -296,12 +299,9 @@ end it "opens question" do - expect(Agama::Question).to receive(:new) - q_client = double - expect(q_client).to receive(:ask).and_yield(q_client) - expect(q_client).to receive(:answer).and_return(:Abort) - expect(Agama::DBus::Clients::Questions).to receive(:new) - .and_return(q_client) + expect(questions_client).to receive(:ask).and_yield(Agama::Answer.new(:Abort)) + expect(Agama::HTTP::Clients::Questions).to receive(:new) + .and_return(questions_client) expect { subject.register("11112222", email: "test@test.com") }.to( raise_error(OpenSSL::SSL::SSLError) diff --git a/service/test/agama/software/callbacks/digest_test.rb b/service/test/agama/software/callbacks/digest_test.rb index 7140778d62..bd46bcb86c 100644 --- a/service/test/agama/software/callbacks/digest_test.rb +++ b/service/test/agama/software/callbacks/digest_test.rb @@ -21,24 +21,24 @@ require_relative "../../../test_helper" require "agama/software/callbacks/digest" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" +require "agama/answer" +require "agama/question" describe Agama::Software::Callbacks::Digest do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:question) { instance_double(Agama::Question, answer: answer) } let(:logger) { Logger.new($stdout, level: :warn) } before do - allow(questions_client).to receive(:ask).and_yield(question_client) - allow(question_client).to receive(:answer).and_return(answer) + allow(questions_client).to receive(:ask).and_yield(answer) end describe "#accept_file_without_checksum" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "registers a question informing of the error" do expect(questions_client).to receive(:ask) do |q| @@ -48,7 +48,7 @@ end context "when the user answers :Yes" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "returns true" do expect(subject.accept_file_without_checksum("repomd.xml")).to eq(true) @@ -56,7 +56,7 @@ end context "when the user answers :No" do - let(:answer) { subject.no_label.to_sym } + let(:answer) { Agama::Answer.new(subject.no_label) } it "returns false" do expect(subject.accept_file_without_checksum("repomd.xml")).to eq(false) @@ -65,7 +65,7 @@ end describe "#accept_unknown_digest" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "registers a question informing of the error" do expect(questions_client).to receive(:ask) do |q| @@ -75,7 +75,7 @@ end context "when the user answers :Yes" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "returns true" do expect(subject.accept_unknown_digest("repomd.xml", "123456")).to eq(true) @@ -83,7 +83,7 @@ end context "when the user answers :No" do - let(:answer) { subject.no_label.to_sym } + let(:answer) { Agama::Answer.new(subject.no_label) } it "returns false" do expect(subject.accept_unknown_digest("repomd.xml", "123456")).to eq(false) @@ -92,7 +92,7 @@ end describe "#accept_wrong_digest" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "registers a question informing of the error" do expect(questions_client).to receive(:ask) do |q| @@ -104,7 +104,7 @@ end context "when the user answers :Yes" do - let(:answer) { subject.yes_label.to_sym } + let(:answer) { Agama::Answer.new(subject.yes_label) } it "returns true" do expect(subject.accept_wrong_digest("repomd.xml", "123456", "654321")).to eq(true) @@ -112,7 +112,7 @@ end context "when the user answers :No" do - let(:answer) { subject.no_label.to_sym } + let(:answer) { Agama::Answer.new(subject.no_label) } it "returns false" do expect(subject.accept_wrong_digest("repomd.xml", "123456", "654321")).to eq(false) diff --git a/service/test/agama/software/callbacks/media_test.rb b/service/test/agama/software/callbacks/media_test.rb index 23ede77b76..b77056f806 100644 --- a/service/test/agama/software/callbacks/media_test.rb +++ b/service/test/agama/software/callbacks/media_test.rb @@ -21,29 +21,28 @@ require_relative "../../../test_helper" require "agama/software/callbacks/media" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" +require "agama/question" +require "agama/answer" describe Agama::Software::Callbacks::Media do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:question) { instance_double(Agama::Question, answer: answer) } let(:logger) { Logger.new($stdout, level: :warn) } describe "#media_changed" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) - allow(question_client).to receive(:answer).and_return(answer) + allow(questions_client).to receive(:ask).and_yield(answer) # mock sleep() to speed up test allow(subject).to receive(:sleep) end - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } - context "when the user answers :Retry" do - let(:answer) { subject.retry_label.to_sym } + let(:answer) { Agama::Answer.new(subject.retry_label) } it "returns ''" do ret = subject.media_change( @@ -54,7 +53,7 @@ end context "when the user answers :Skip" do - let(:answer) { subject.continue_label.to_sym } + let(:answer) { Agama::Answer.new(subject.continue_label.to_sym) } it "returns 'S'" do ret = subject.media_change( diff --git a/service/test/agama/software/callbacks/pkg_gpg_check_test.rb b/service/test/agama/software/callbacks/pkg_gpg_check_test.rb index c201f8841c..63ef576868 100644 --- a/service/test/agama/software/callbacks/pkg_gpg_check_test.rb +++ b/service/test/agama/software/callbacks/pkg_gpg_check_test.rb @@ -27,7 +27,7 @@ describe Agama::Software::Callbacks::PkgGpgCheck do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } let(:logger) { Logger.new($stdout, level: :error) } describe "#pkg_gpg_check" do diff --git a/service/test/agama/software/callbacks/provide_test.rb b/service/test/agama/software/callbacks/provide_test.rb index fdb4a8920a..a4070b2940 100644 --- a/service/test/agama/software/callbacks/provide_test.rb +++ b/service/test/agama/software/callbacks/provide_test.rb @@ -21,22 +21,23 @@ require_relative "../../../test_helper" require "agama/software/callbacks/provide" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients/questions" +require "agama/question" +require "agama/answer" describe Agama::Software::Callbacks::Provide do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:question) { instance_double(Agama::Question, answer: answer) } let(:logger) { Logger.new($stdout, level: :warn) } - let(:answer) { subject.retry_label.to_sym } + let(:answer) { Agama::Answer.new(subject.retry_label) } describe "#done_provide" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) - allow(question_client).to receive(:answer).and_return(answer) + allow(questions_client).to receive(:ask).and_yield(answer) end let(:question_client) { instance_double(Agama::DBus::Clients::Question) } @@ -69,8 +70,6 @@ end context "when the user answers :Retry" do - let(:answer) { subject.retry_label.to_sym } - it "returns 'R'" do ret = subject.done_provide( 2, "Some dummy reason", "dummy-package" @@ -80,7 +79,7 @@ end context "when the user answers :Skip" do - let(:answer) { subject.continue_label.to_sym } + let(:answer) { Agama::Answer.new(subject.continue_label) } it "returns 'I'" do ret = subject.done_provide( diff --git a/service/test/agama/software/callbacks/script_test.rb b/service/test/agama/software/callbacks/script_test.rb index 5876539e05..34251131e9 100644 --- a/service/test/agama/software/callbacks/script_test.rb +++ b/service/test/agama/software/callbacks/script_test.rb @@ -21,28 +21,27 @@ require_relative "../../../test_helper" require "agama/software/callbacks/script" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients/questions" +require "agama/question" +require "agama/answer" describe Agama::Software::Callbacks::Script do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:question) { instance_double(Agama::Question, answer: answer) } let(:logger) { Logger.new($stdout, level: :warn) } - let(:answer) { subject.retry_label.to_sym } + let(:answer) { Agama::Answer.new(subject.retry_label) } let(:description) { "Some description" } describe "#script_problem" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) - allow(question_client).to receive(:answer).and_return(answer) + allow(questions_client).to receive(:ask).and_yield(answer) end - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } - it "registers a question with the details" do expect(questions_client).to receive(:ask) do |q| expect(q.text).to include("running a package script") @@ -54,7 +53,7 @@ end context "when the user asks to retry" do - let(:answer) { subject.retry_label.to_sym } + let(:answer) { Agama::Answer.new(subject.retry_label) } it "returns 'R'" do ret = subject.script_problem(description) @@ -63,7 +62,7 @@ end context "when the user asks to continue" do - let(:answer) { subject.continue_label.to_sym } + let(:answer) { Agama::Answer.new(subject.continue_label) } it "returns 'I'" do ret = subject.script_problem(description) diff --git a/service/test/agama/software/callbacks/signature_test.rb b/service/test/agama/software/callbacks/signature_test.rb index bcde5c300e..e940cd5b7f 100644 --- a/service/test/agama/software/callbacks/signature_test.rb +++ b/service/test/agama/software/callbacks/signature_test.rb @@ -21,18 +21,17 @@ require_relative "../../../test_helper" require "agama/software/callbacks/signature" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" +require "agama/question" +require "agama/answer" describe Agama::Software::Callbacks::Signature do before do - allow(questions_client).to receive(:ask).and_yield(question_client) - allow(question_client).to receive(:answer).and_return(answer) + allow(questions_client).to receive(:ask).and_yield(answer) end - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } - - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:question) { instance_double(Agama::Question, answer: answer) } let(:answer) { nil } @@ -42,7 +41,7 @@ describe "#accept_unsigned_file" do context "when the user answers :Yes" do - let(:answer) { :Yes } + let(:answer) { Agama::Answer.new(:Yes) } it "returns true" do expect(subject.accept_unsigned_file("repomd.xml", -1)).to eq(true) @@ -50,7 +49,7 @@ end context "when the user answers :No" do - let(:answer) { :No } + let(:answer) { Agama::Answer.new(:No) } it "returns false" do expect(subject.accept_unsigned_file("repomd.xml", -1)).to eq(false) @@ -88,7 +87,7 @@ end describe "import_gpg_key" do - let(:answer) { :Trust } + let(:answer) { Agama::Answer.new("Trust") } let(:key) do { @@ -99,7 +98,7 @@ end context "when the user answers :Trust" do - let(:answer) { :Trust } + let(:answer) { Agama::Answer.new(:Trust) } it "returns true" do expect(subject.import_gpg_key(key, 1)).to eq(true) @@ -107,7 +106,7 @@ end context "when the user answers :Skip" do - let(:answer) { :Skip } + let(:answer) { Agama::Answer.new(:Skip) } it "returns false" do expect(subject.import_gpg_key(key, 1)).to eq(false) @@ -126,7 +125,7 @@ describe "#accept_unknown_gpg_key" do context "when the user answers :Yes" do - let(:answer) { :Yes } + let(:answer) { Agama::Answer.new(:Yes) } it "returns true" do expect(subject.accept_unknown_gpg_key("repomd.xml", "KEYID", 1)).to eq(true) @@ -134,7 +133,7 @@ end context "when the user answers :No" do - let(:answer) { :No } + let(:answer) { Agama::Answer.new(:No) } it "returns false" do expect(subject.accept_unknown_gpg_key("repomd.xml", "KEYID", 1)).to eq(false) @@ -172,7 +171,7 @@ end describe "accept_verification_failed" do - let(:answer) { :Trust } + let(:answer) { Agama::Answer.new(:Trust) } let(:key) do { @@ -185,7 +184,7 @@ let(:filename) { "repomd.xml" } context "when the user answers :Yes" do - let(:answer) { :Yes } + let(:answer) { Agama::Answer.new(:Yes) } it "returns true" do expect(subject.accept_verification_failed(filename, key, 1)).to eq(true) @@ -193,7 +192,7 @@ end context "when the user answers :No" do - let(:answer) { :No } + let(:answer) { Agama::Answer.new(:No) } it "returns false" do expect(subject.accept_verification_failed(filename, key, 1)).to eq(false) diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 407193a2f0..af0bca2aba 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -29,7 +29,7 @@ require "agama/software/manager" require "agama/software/product" require "agama/software/proposal" -require "agama/dbus/clients/questions" +require "agama/http/clients" describe Agama::Software::Manager do subject { described_class.new(config, logger) } @@ -79,7 +79,7 @@ end let(:questions_client) do - instance_double(Agama::DBus::Clients::Questions) + instance_double(Agama::HTTP::Clients::Questions) end let(:target_dir) { Dir.mktmpdir } @@ -104,7 +104,7 @@ .and_return(base_url) allow(Yast::Pkg).to receive(:SourceCreate) allow(Yast::Installation).to receive(:destdir).and_return(destdir) - allow(Agama::DBus::Clients::Questions).to receive(:new).and_return(questions_client) + allow(Agama::HTTP::Clients::Questions).to receive(:new).and_return(questions_client) allow(Agama::Software::RepositoriesManager).to receive(:instance).and_return(repositories) allow(Agama::Software::Proposal).to receive(:new).and_return(proposal) allow(Agama::ProductReader).to receive(:new).and_call_original diff --git a/service/test/agama/storage/callbacks/activate_luks_test.rb b/service/test/agama/storage/callbacks/activate_luks_test.rb index b0a2f681cb..25ef542d2b 100644 --- a/service/test/agama/storage/callbacks/activate_luks_test.rb +++ b/service/test/agama/storage/callbacks/activate_luks_test.rb @@ -22,24 +22,24 @@ require_relative "../../../test_helper" require "agama/storage/callbacks/activate_luks" require "agama/question_with_password" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" +require "agama/question" +require "agama/answer" require "storage" describe Agama::Storage::Callbacks::ActivateLuks do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:answer) { nil } let(:logger) { Logger.new($stdout, level: :warn) } describe "#call" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) + allow(questions_client).to receive(:ask).and_yield(answer) end - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } - let(:luks_info) do instance_double(Storage::LuksInfo, device_name: "/dev/sda1", @@ -58,10 +58,7 @@ end context "when the question is answered as :skip" do - before do - allow(question_client).to receive(:answer).and_return(:skip) - allow(question_client).to receive(:password).and_return("notsecret") - end + let(:answer) { Agama::Answer.new(:skip, "notsecret") } it "returns a tuple containing false and the password" do expect(subject.call(luks_info, attempt)).to eq([false, "notsecret"]) @@ -69,10 +66,7 @@ end context "when the question is answered as :decrypt" do - before do - allow(question_client).to receive(:answer).and_return(:decrypt) - allow(question_client).to receive(:password).and_return("notsecret") - end + let(:answer) { Agama::Answer.new(:decrypt, "notsecret") } it "returns a tuple containing true and the password" do expect(subject.call(luks_info, attempt)).to eq([true, "notsecret"]) diff --git a/service/test/agama/storage/callbacks/activate_multipath_test.rb b/service/test/agama/storage/callbacks/activate_multipath_test.rb index a45920ffff..d6611b4996 100644 --- a/service/test/agama/storage/callbacks/activate_multipath_test.rb +++ b/service/test/agama/storage/callbacks/activate_multipath_test.rb @@ -21,23 +21,22 @@ require_relative "../../../test_helper" require "agama/storage/callbacks/activate_multipath" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" +require "agama/answer" describe Agama::Storage::Callbacks::ActivateMultipath do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:answer) { Agama::Answer.new(:no) } let(:logger) { Logger.new($stdout, level: :warn) } describe "#call" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) + allow(questions_client).to receive(:ask).and_yield(answer) end - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } - context "if the devices do not look like real multipath" do let(:real_multipath) { false } @@ -64,9 +63,7 @@ end context "and the question is answered as :yes" do - before do - allow(question_client).to receive(:answer).and_return(:yes) - end + let(:answer) { Agama::Answer.new(:yes) } it "returns true" do expect(subject.call(real_multipath)).to eq(true) @@ -74,9 +71,7 @@ end context "and the question is answered as :no" do - before do - allow(question_client).to receive(:answer).and_return(:no) - end + let(:answer) { Agama::Answer.new(:no) } it "returns false" do expect(subject.call(real_multipath)).to eq(false) diff --git a/service/test/agama/storage/callbacks/activate_test.rb b/service/test/agama/storage/callbacks/activate_test.rb index 5655d4656a..c686cc0206 100644 --- a/service/test/agama/storage/callbacks/activate_test.rb +++ b/service/test/agama/storage/callbacks/activate_test.rb @@ -21,12 +21,12 @@ require_relative "../../../test_helper" require "agama/storage/callbacks/activate" -require "agama/dbus/clients/questions" +require "agama/http/clients" describe Agama::Storage::Callbacks::Activate do subject { described_class.new(questions_client, logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } let(:logger) { Logger.new($stdout, level: :warn) } diff --git a/service/test/agama/storage/callbacks/commit_error_test.rb b/service/test/agama/storage/callbacks/commit_error_test.rb index 4f36c0efb2..2b59621891 100644 --- a/service/test/agama/storage/callbacks/commit_error_test.rb +++ b/service/test/agama/storage/callbacks/commit_error_test.rb @@ -21,23 +21,21 @@ require_relative "../../../test_helper" require "agama/storage/callbacks/commit_error" -require "agama/dbus/clients/questions" -require "agama/dbus/clients/question" +require "agama/http/clients" describe Agama::Storage::Callbacks::CommitError do subject { described_class.new(questions_client, logger: logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } + let(:answer) { nil } let(:logger) { Logger.new($stdout, level: :warn) } describe "#call" do before do - allow(questions_client).to receive(:ask).and_yield(question_client) + allow(questions_client).to receive(:ask).and_yield(answer) end - let(:question_client) { instance_double(Agama::DBus::Clients::Question) } - it "reports the error and ask whether to continue" do expect(questions_client).to receive(:ask) do |question| expect(question.text).to match(/There was an error/) @@ -48,9 +46,7 @@ end context "and the question is answered as :yes" do - before do - allow(question_client).to receive(:answer).and_return(:yes) - end + let(:answer) { Agama::Answer.new(:yes) } it "returns true" do expect(subject.call("test", "details")).to eq(true) @@ -58,9 +54,7 @@ end context "and the question is answered as :no" do - before do - allow(question_client).to receive(:answer).and_return(:no) - end + let(:answer) { Agama::Answer.new(:no) } it "returns false" do expect(subject.call("test", "details")).to eq(false) diff --git a/service/test/agama/storage/callbacks/commit_test.rb b/service/test/agama/storage/callbacks/commit_test.rb index 037ebecfc1..d4ee4a837c 100644 --- a/service/test/agama/storage/callbacks/commit_test.rb +++ b/service/test/agama/storage/callbacks/commit_test.rb @@ -21,12 +21,12 @@ require_relative "../../../test_helper" require "agama/storage/callbacks/commit" -require "agama/dbus/clients/questions" +require "agama/http/clients" describe Agama::Storage::Callbacks::Commit do subject { described_class.new(questions_client, logger: logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } let(:logger) { Logger.new($stdout, level: :warn) } diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index bd71f04c3e..ed590db62e 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -23,7 +23,7 @@ require_relative "../with_progress_examples" require_relative "../with_issues_examples" require_relative "storage_helpers" -require "agama/dbus/clients/questions" +require "agama/http/clients" require "agama/config" require "agama/http" require "agama/issue" @@ -57,7 +57,7 @@ before do mock_storage(devicegraph: scenario) allow(Agama::Storage::Proposal).to receive(:new).and_return(proposal) - allow(Agama::DBus::Clients::Questions).to receive(:new).and_return(questions_client) + allow(Agama::HTTP::Clients::Questions).to receive(:new).and_return(questions_client) allow(Agama::DBus::Clients::Software).to receive(:instance).and_return(software) allow(Bootloader::FinishClient).to receive(:new).and_return(bootloader_finish) allow(Agama::Security).to receive(:new).and_return(security) @@ -77,7 +77,7 @@ let(:y2storage_manager) { Y2Storage::StorageManager.instance } let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } - let(:questions_client) { instance_double(Agama::DBus::Clients::Questions) } + let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } let(:software) do instance_double(Agama::DBus::Clients::Software, selected_product: "ALP") end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index cee2120a2e..37f0828d27 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Oct 17 13:14:52 UTC 2025 - Imobach Gonzalez Sosa + +- Adapt to the new questions HTTP API (gh#agama-project/agama#2813). + ------------------------------------------------------------------- Thu Oct 16 04:52:55 UTC 2025 - José Iván López González diff --git a/web/src/api/questions.ts b/web/src/api/questions.ts index 5610e79568..5e220c570e 100644 --- a/web/src/api/questions.ts +++ b/web/src/api/questions.ts @@ -20,42 +20,13 @@ * find current contact information at www.suse.com. */ -import { get, put } from "~/api/http"; -import { Answer, Question, QuestionType } from "~/types/questions"; - -type APIQuestion = { - generic?: Question; - withPassword?: Pick; -}; - -/** - * Internal method to build proper question objects - * - * TODO: improve/simplify it once the backend API is improved. - */ -function buildQuestion(httpQuestion: APIQuestion) { - const question: Question = { ...httpQuestion.generic }; - - if (httpQuestion.generic) { - question.type = QuestionType.generic; - question.answer = httpQuestion.generic.answer; - } - - if (httpQuestion.withPassword) { - question.type = QuestionType.withPassword; - question.password = httpQuestion.withPassword.password; - } - - return question; -} +import { get, patch } from "~/api/http"; +import { Question } from "~/types/questions"; /** * Returns the list of questions */ -const fetchQuestions = async (): Promise => { - const apiQuestions: APIQuestion[] = await get("/api/questions"); - return apiQuestions.map(buildQuestion); -}; +const fetchQuestions = async (): Promise => await get("/api/v2/questions"); /** * Update a questions' answer @@ -63,13 +34,8 @@ const fetchQuestions = async (): Promise => { * The answer is part of the Question object. */ const updateAnswer = async (question: Question): Promise => { - const answer: Answer = { generic: { answer: question.answer } }; - - if (question.type === QuestionType.withPassword) { - answer.withPassword = { password: question.password }; - } - - await put(`/api/questions/${question.id}/answer`, answer); + const { id, answer } = question; + await patch(`/api/v2/questions`, { id, answer }); }; export { fetchQuestions, updateAnswer }; diff --git a/web/src/components/questions/GenericQuestion.test.tsx b/web/src/components/questions/GenericQuestion.test.tsx index 8a4848f852..2c36b7e36c 100644 --- a/web/src/components/questions/GenericQuestion.test.tsx +++ b/web/src/components/questions/GenericQuestion.test.tsx @@ -23,14 +23,20 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import GenericQuestion from "~/components/questions/GenericQuestion"; const question: Question = { id: 1, text: "Do you write unit tests?", - options: ["always", "sometimes", "never"], - defaultOption: "sometimes", + class: "unit-test", + field: { type: FieldType.None }, + actions: [ + { id: "always", label: "Always" }, + { id: "sometimes", label: "Sometimes" }, + { id: "never", label: "Never" }, + ], + defaultAction: "sometimes", }; const answerFn = jest.fn(); @@ -50,17 +56,17 @@ describe("GenericQuestion", () => { let button = await screen.findByRole("button", { name: /Sometimes/ }); await user.click(button); - expect(question).toEqual(expect.objectContaining({ answer: "sometimes" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "sometimes" })); expect(answerFn).toHaveBeenCalledWith(question); button = await screen.findByRole("button", { name: /Always/ }); await user.click(button); - expect(question).toEqual(expect.objectContaining({ answer: "always" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "always" })); expect(answerFn).toHaveBeenCalledWith(question); button = await screen.findByRole("button", { name: /Never/ }); await user.click(button); - expect(question).toEqual(expect.objectContaining({ answer: "never" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "never" })); expect(answerFn).toHaveBeenCalledWith(question); }); }); diff --git a/web/src/components/questions/GenericQuestion.tsx b/web/src/components/questions/GenericQuestion.tsx index 74bea5ff9f..b4f0697ca2 100644 --- a/web/src/components/questions/GenericQuestion.tsx +++ b/web/src/components/questions/GenericQuestion.tsx @@ -40,8 +40,8 @@ export default function GenericQuestion({ question: Question; answerCallback: AnswerCallback; }): React.ReactNode { - const actionCallback = (option: string) => { - question.answer = option; + const actionCallback = (action: string) => { + question.answer = { action }; answerCallback(question); }; @@ -50,8 +50,8 @@ export default function GenericQuestion({ {question.text} diff --git a/web/src/components/questions/LoadConfigRetryQuestion.test.tsx b/web/src/components/questions/LoadConfigRetryQuestion.test.tsx index 12983a52e0..5d464a046b 100644 --- a/web/src/components/questions/LoadConfigRetryQuestion.test.tsx +++ b/web/src/components/questions/LoadConfigRetryQuestion.test.tsx @@ -23,21 +23,26 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import LoadConfigRetryQuestion from "~/components/questions/LoadConfigRetryQuestion"; const question: Question = { id: 1, + class: "retry", text: "It was not possible to load the configuration from http://wrong.config.file. It was unreachable or invalid. Do you want to try again?", + field: { type: FieldType.None }, data: { error: `Could not generate the configuration: Retrieving data from URL http://wrong.config.file -Caused by: -0: Could not retrieve http://wrong.config.file/ -1: [6] Could not resolve hostname (Could not resolve host: wrong.config.file)`, + Caused by: + 0: Could not retrieve http://wrong.config.file/ + 1: [6] Could not resolve hostname (Could not resolve host: wrong.config.file)`, }, - options: ["yes", "no"], - defaultOption: "no", + actions: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + defaultAction: "no", }; const answerFn = jest.fn(); @@ -68,12 +73,12 @@ it("calls the callback with answer value", async () => { const yesButton = await screen.findByRole("button", { name: "Yes" }); await user.click(yesButton); - expect(question).toEqual(expect.objectContaining({ answer: "yes" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "yes" })); expect(answerFn).toHaveBeenCalledWith(question); const noButton = await screen.findByRole("button", { name: "No" }); await user.click(noButton); - expect(question).toEqual(expect.objectContaining({ answer: "no" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "no" })); expect(answerFn).toHaveBeenCalledWith(question); }); diff --git a/web/src/components/questions/LoadConfigRetryQuestion.tsx b/web/src/components/questions/LoadConfigRetryQuestion.tsx index ab3ac187a8..d16c843a9e 100644 --- a/web/src/components/questions/LoadConfigRetryQuestion.tsx +++ b/web/src/components/questions/LoadConfigRetryQuestion.tsx @@ -40,8 +40,8 @@ export default function RetryLoadConfigQuestion({ question: Question; answerCallback: AnswerCallback; }): React.ReactNode { - const actionCallback = (option: string) => { - question.answer = option; + const actionCallback = (action: string) => { + question.answer = { action }; answerCallback(question); }; @@ -59,8 +59,8 @@ export default function RetryLoadConfigQuestion({ diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 1922e77b8d..fe65d32c72 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question, FieldType } from "~/types/questions"; import { InstallationPhase } from "~/types/status"; import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; @@ -34,8 +34,12 @@ const questionMock: Question = { id: 1, class: "storage.luks_activation", text: "A Luks device found. Do you want to open it?", - options: ["decrypt", "skip"], - defaultOption: "decrypt", + field: { type: FieldType.String }, + actions: [ + { id: "decrypt", label: "Decrypt" }, + { id: "skip", label: "Skip" }, + ], + defaultAction: "decrypt", data: { attempt: "1" }, }; const tumbleweed: Product = { @@ -141,8 +145,8 @@ describe("LuksActivationQuestion", () => { const skipButton = await screen.findByRole("button", { name: /Skip/ }); await user.click(skipButton); - expect(question).toEqual( - expect.objectContaining({ password: "notSecret", answer: "skip" }), + expect(question.answer).toEqual( + expect.objectContaining({ value: "notSecret", action: "skip" }), ); expect(answerFn).toHaveBeenCalledWith(question); }); @@ -157,8 +161,8 @@ describe("LuksActivationQuestion", () => { const decryptButton = await screen.findByRole("button", { name: /Decrypt/ }); await user.click(decryptButton); - expect(question).toEqual( - expect.objectContaining({ password: "notSecret", answer: "decrypt" }), + expect(question.answer).toEqual( + expect.objectContaining({ value: "notSecret", action: "decrypt" }), ); expect(answerFn).toHaveBeenCalledWith(question); }); @@ -171,8 +175,8 @@ describe("LuksActivationQuestion", () => { const passwordInput = await screen.findByLabelText("Encryption Password"); await user.type(passwordInput, "notSecret{enter}"); - expect(question).toEqual( - expect.objectContaining({ password: "notSecret", answer: "decrypt" }), + expect(question.answer).toEqual( + expect.objectContaining({ value: "notSecret", action: "decrypt" }), ); expect(answerFn).toHaveBeenCalledWith(question); }); diff --git a/web/src/components/questions/LuksActivationQuestion.tsx b/web/src/components/questions/LuksActivationQuestion.tsx index efd2eaaf91..5e7ed7693e 100644 --- a/web/src/components/questions/LuksActivationQuestion.tsx +++ b/web/src/components/questions/LuksActivationQuestion.tsx @@ -49,9 +49,9 @@ export default function LuksActivationQuestion({ question, answerCallback }) { const conditions = { disable: { decrypt: password === "" } }; const defaultAction = "decrypt"; - const actionCallback = (option: string) => { - question.password = password; - question.answer = option; + const actionCallback = (action: string) => { + const answer = { action, value: password }; + question.answer = answer; answerCallback(question); }; @@ -87,8 +87,8 @@ export default function LuksActivationQuestion({ question, answerCallback }) { diff --git a/web/src/components/questions/PackageErrorQuestion.test.tsx b/web/src/components/questions/PackageErrorQuestion.test.tsx index 57a18ff7cf..5598e453a9 100644 --- a/web/src/components/questions/PackageErrorQuestion.test.tsx +++ b/web/src/components/questions/PackageErrorQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import PackageErrorQuestion from "~/components/questions/PackageErrorQuestion"; const answerFn = jest.fn(); @@ -31,8 +31,12 @@ const question: Question = { id: 1, class: "software.package_error.provide_error", text: "Package download failed", - options: ["Retry", "Skip"], - defaultOption: "Retry", + field: { type: FieldType.None }, + actions: [ + { id: "retry", label: "Retry" }, + { id: "skip", label: "Skip" }, + ], + defaultAction: "Retry", data: { package: "foo", error_code: "INVALID" }, }; @@ -53,7 +57,7 @@ describe("PackageErrorQuestion", () => { const retryButton = await screen.findByRole("button", { name: "Retry" }); await user.click(retryButton); - expect(question).toEqual(expect.objectContaining({ answer: "Retry" })); + expect(question.answer).toEqual(expect.objectContaining({ action: "retry" })); expect(answerFn).toHaveBeenCalledWith(question); }); }); diff --git a/web/src/components/questions/PackageErrorQuestion.tsx b/web/src/components/questions/PackageErrorQuestion.tsx index b0bdfd0185..5cdc0914ff 100644 --- a/web/src/components/questions/PackageErrorQuestion.tsx +++ b/web/src/components/questions/PackageErrorQuestion.tsx @@ -41,8 +41,8 @@ export default function PackageErrorQuestion({ question: Question; answerCallback: AnswerCallback; }): React.ReactNode { - const actionCallback = (option: string) => { - question.answer = option; + const actionCallback = (action: string) => { + question.answer = { action }; answerCallback(question); }; @@ -68,8 +68,8 @@ export default function PackageErrorQuestion({ diff --git a/web/src/components/questions/QuestionActions.test.tsx b/web/src/components/questions/QuestionActions.test.tsx index d21725afb5..168f0421e2 100644 --- a/web/src/components/questions/QuestionActions.test.tsx +++ b/web/src/components/questions/QuestionActions.test.tsx @@ -23,16 +23,21 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import QuestionActions from "~/components/questions/QuestionActions"; -let defaultOption = "sure"; +let defaultAction = "sure"; -let question: Question = { +const question: Question = { id: 1, + class: "none", text: "Should we use a component for rendering actions?", - options: ["no", "maybe", "sure"], - defaultOption, + field: { type: FieldType.None }, + actions: [ + { id: "no", label: "No" }, + { id: "maybe", label: "Maybe" }, + { id: "sure", label: "Sure" }, + ], }; const actionCallback = jest.fn(); @@ -40,14 +45,18 @@ const actionCallback = jest.fn(); const renderQuestionActions = () => installerRender( , ); describe("QuestionActions", () => { + beforeEach(() => { + defaultAction = "sure"; + }); + describe("when question has a default option", () => { it("renders the default option as primary action", async () => { renderQuestionActions(); @@ -69,9 +78,7 @@ describe("QuestionActions", () => { describe("when question does not have a default option", () => { beforeEach(() => { - // Using destructuring for partially clone the object. - // See "Gotcha if there's no let" at https://javascript.info/destructuring-assignment#the-rest-pattern - ({ defaultOption, ...question } = question); + defaultAction = undefined; }); it("renders the first option as primary action", async () => { diff --git a/web/src/components/questions/QuestionActions.tsx b/web/src/components/questions/QuestionActions.tsx index 07965a9e9e..026d247071 100644 --- a/web/src/components/questions/QuestionActions.tsx +++ b/web/src/components/questions/QuestionActions.tsx @@ -23,13 +23,7 @@ import React from "react"; import { Popup } from "~/components/core"; import { fork } from "radashi"; - -/** - * Returns given text capitalized - * - * TODO: make it work with i18n - */ -const label = (text: string): string => `${text[0].toUpperCase()}${text.slice(1)}`; +import { Action } from "~/types/questions"; /** * A component for building a Question actions, using the defaultAction @@ -51,12 +45,12 @@ export default function QuestionActions({ actionCallback, conditions = {}, }: { - actions: string[]; + actions: Action[]; defaultAction?: string; actionCallback: (action: string) => void; conditions?: { disable?: { [key: string]: boolean } }; }): React.ReactNode { - let [[primaryAction], secondaryActions] = fork(actions, (a: string) => a === defaultAction); + let [[primaryAction], secondaryActions] = fork(actions, (a: Action) => a.id === defaultAction); // Ensure there is always a primary action if (!primaryAction) [primaryAction, ...secondaryActions] = secondaryActions; @@ -64,19 +58,19 @@ export default function QuestionActions({ return ( <> actionCallback(primaryAction)} - isDisabled={conditions?.disable?.[primaryAction]} + key={primaryAction.id} + onClick={() => actionCallback(primaryAction.id)} + isDisabled={conditions?.disable?.[primaryAction.id]} > - {label(primaryAction)} + {primaryAction.label} {secondaryActions.map((action) => ( actionCallback(action)} - isDisabled={conditions?.disable?.[action]} + key={action.id} + onClick={() => actionCallback(action.id)} + isDisabled={conditions?.disable?.[action.id]} > - {label(action)} + {action.label} ))} diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index e6a725b211..60a2961f83 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; @@ -34,8 +34,12 @@ const question: Question = { id: 1, class: "question.password", text: "Random question. Will you provide random password?", - options: ["ok", "cancel"], - defaultOption: "cancel", + field: { type: FieldType.None }, + actions: [ + { id: "ok", label: "OK" }, + { id: "cancel", label: "Cancel" }, + ], + defaultAction: "cancel", }; const tumbleweed: Product = { @@ -111,10 +115,12 @@ describe("QuestionWithPassword", () => { const passwordInput = await screen.findByLabelText("Password"); await user.type(passwordInput, "notSecret"); - const skipButton = await screen.findByRole("button", { name: "Ok" }); + const skipButton = await screen.findByRole("button", { name: "OK" }); await user.click(skipButton); - expect(question).toEqual(expect.objectContaining({ password: "notSecret", answer: "ok" })); + expect(question.answer).toEqual( + expect.objectContaining({ value: "notSecret", action: "ok" }), + ); expect(answerFn).toHaveBeenCalledWith(question); }); }); diff --git a/web/src/components/questions/QuestionWithPassword.tsx b/web/src/components/questions/QuestionWithPassword.tsx index 754d35b8f2..3811baf26c 100644 --- a/web/src/components/questions/QuestionWithPassword.tsx +++ b/web/src/components/questions/QuestionWithPassword.tsx @@ -41,12 +41,11 @@ export default function QuestionWithPassword({ question: Question; answerCallback: AnswerCallback; }): React.ReactNode { - const [password, setPassword] = useState(question.password || ""); - const defaultAction = question.defaultOption; + const [password, setPassword] = useState(question.answer?.value || ""); - const actionCallback = (option: string) => { - question.password = password; - question.answer = option; + const actionCallback = (action: string) => { + const answer = { action, value: password }; + question.answer = answer; answerCallback(question); }; @@ -74,8 +73,8 @@ export default function QuestionWithPassword({ diff --git a/web/src/components/questions/Questions.test.tsx b/web/src/components/questions/Questions.test.tsx index 4720026f9e..51e66474af 100644 --- a/web/src/components/questions/Questions.test.tsx +++ b/web/src/components/questions/Questions.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, plainRender } from "~/test-utils"; -import { Question, QuestionType } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import Questions from "~/components/questions/Questions"; import * as GenericQuestionComponent from "~/components/questions/GenericQuestion"; @@ -51,14 +51,52 @@ jest.mock("~/queries/questions", () => ({ const genericQuestion: Question = { id: 1, - type: QuestionType.generic, + class: "generic", text: "Do you write unit tests?", - options: ["always", "sometimes", "never"], - defaultOption: "sometimes", + field: { type: FieldType.None }, + actions: [ + { id: "always", label: "Always" }, + { id: "sometimes", label: "Sometimes" }, + { id: "never", label: "Never" }, + ], + defaultAction: "sometimes", +}; + +const passwordQuestion: Question = { + id: 2, + class: "password", + text: "Please, introduce the password", + field: { type: FieldType.Password }, + actions: [ + { id: "confirm", label: "Confirm" }, + { id: "cancel", label: "Cancel" }, + ], + defaultAction: "confirm", +}; + +const luksActivationQuestion: Question = { + id: 3, + class: "storage.luks_activation", + text: "Password to decrypt the device", + field: { type: FieldType.Password }, + actions: [ + { id: "decrypt", label: "Decrypt" }, + { id: "skip", label: "Skip" }, + ], + defaultAction: "decrypt", +}; + +const loadConfigurationQuestion: Question = { + id: 4, + class: "load.retry", + text: "Do you want to retry loading the configuration?", + field: { type: FieldType.None }, + actions: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + defaultAction: "no", }; -const passwordQuestion: Question = { id: 1, type: QuestionType.withPassword }; -const luksActivationQuestion: Question = { id: 2, class: "storage.luks_activation" }; -const loadConfigurationQuestion: Question = { id: 3, class: "load.retry" }; describe("Questions", () => { afterEach(() => { @@ -78,14 +116,18 @@ describe("Questions", () => { describe("when a question is answered", () => { beforeEach(() => { - mockQuestions = [genericQuestion]; + // Do not modify the original object. + mockQuestions = [{ ...genericQuestion }]; }); it("triggers the useQuestionMutation", async () => { const { user } = plainRender(); const button = screen.getByRole("button", { name: "Always" }); await user.click(button); - expect(mockMutation).toHaveBeenCalledWith({ ...genericQuestion, answer: "always" }); + expect(mockMutation).toHaveBeenCalledWith({ + ...genericQuestion, + answer: { action: "always" }, + }); }); }); diff --git a/web/src/components/questions/Questions.tsx b/web/src/components/questions/Questions.tsx index 01d267ce63..30c5f3ed42 100644 --- a/web/src/components/questions/Questions.tsx +++ b/web/src/components/questions/Questions.tsx @@ -29,13 +29,14 @@ import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import RegistrationCertificateQuestion from "~/components/questions/RegistrationCertificateQuestion"; import LoadConfigRetryQuestion from "~/components/questions/LoadConfigRetryQuestion"; import { useQuestions, useQuestionsConfig, useQuestionsChanges } from "~/queries/questions"; -import { AnswerCallback, QuestionType } from "~/types/questions"; +import { AnswerCallback, FieldType } from "~/types/questions"; export default function Questions(): React.ReactNode { useQuestionsChanges(); - const pendingQuestions = useQuestions(); + const allQuestions = useQuestions(); const questionsConfig = useQuestionsConfig(); + const pendingQuestions = allQuestions.filter((q) => !q.answer); if (pendingQuestions.length === 0) return null; const answerQuestion: AnswerCallback = (answeredQuestion) => @@ -46,33 +47,35 @@ export default function Questions(): React.ReactNode { let QuestionComponent = GenericQuestion; + const questionClass = currentQuestion.class; + // show specialized popup for question which need password - if (currentQuestion.type === QuestionType.withPassword) { + if (currentQuestion.field.type === FieldType.Password) { QuestionComponent = QuestionWithPassword; } // show specialized popup for luks activation question // more can follow as it will be needed - if (currentQuestion.class === "storage.luks_activation") { + if (questionClass === "storage.luks_activation") { QuestionComponent = LuksActivationQuestion; } - if (currentQuestion.class === "autoyast.unsupported") { + if (questionClass === "autoyast.unsupported") { QuestionComponent = UnsupportedAutoYaST; } // special popup for package errors (libzypp callbacks) - if (currentQuestion.class?.startsWith("software.package_error.")) { + if (questionClass.startsWith("software.package_error.")) { QuestionComponent = PackageErrorQuestion; } // special popup for self signed registration certificate - if (currentQuestion.class === "registration.certificate") { + if (questionClass === "registration.certificate") { QuestionComponent = RegistrationCertificateQuestion; } // special popup for self signed registration certificate - if (currentQuestion.class === "load.retry") { + if (questionClass === "load.retry") { QuestionComponent = LoadConfigRetryQuestion; } diff --git a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx index 1342e54540..238e34a99f 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx @@ -23,14 +23,19 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question } from "~/types/questions"; +import { Question, FieldType } from "~/types/questions"; import RegistrationCertificateQuestion from "~/components/questions/RegistrationCertificateQuestion"; const question: Question = { id: 1, + class: "registration.certificate", text: "Trust certificate?", - options: ["yes", "no"], - defaultOption: "yes", + field: { type: FieldType.None }, + actions: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + defaultAction: "yes", data: { url: "https://test.com", issuer_name: "test", @@ -55,10 +60,11 @@ it("renders the question text", async () => { it("renders the certificate data", async () => { renderQuestion(); - await screen.findByText(question.data.url); - await screen.findByText(question.data.issuer_name); - await screen.findByText(question.data.issue_date); - await screen.findByText(question.data.expiration_date); - await screen.findByText(question.data.sha1_fingerprint); - await screen.findByText(question.data.sha256_fingerprint); + const data = question.data; + await screen.findByText(data.url); + await screen.findByText(data.issuer_name); + await screen.findByText(data.issue_date); + await screen.findByText(data.expiration_date); + await screen.findByText(data.sha1_fingerprint); + await screen.findByText(data.sha256_fingerprint); }); diff --git a/web/src/components/questions/RegistrationCertificateQuestion.tsx b/web/src/components/questions/RegistrationCertificateQuestion.tsx index 0d736e4839..785553cf61 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.tsx @@ -65,8 +65,8 @@ export default function RegistrationCertificateQuestion({ question: Question; answerCallback: AnswerCallback; }): React.ReactNode { - const actionCallback = (option: string) => { - question.answer = option; + const actionCallback = (action: string) => { + question.answer = { action }; answerCallback(question); }; @@ -94,8 +94,8 @@ export default function RegistrationCertificateQuestion({ diff --git a/web/src/components/questions/UnsupportedAutoYaST.test.tsx b/web/src/components/questions/UnsupportedAutoYaST.test.tsx index a2c40c58d3..82e63ccbdf 100644 --- a/web/src/components/questions/UnsupportedAutoYaST.test.tsx +++ b/web/src/components/questions/UnsupportedAutoYaST.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question, FieldType } from "~/types/questions"; import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import { plainRender } from "~/test-utils"; @@ -30,8 +30,12 @@ const question: Question = { id: 1, class: "autoyast.unsupported", text: "Some elements from the AutoYaST profile are not supported.", - options: ["abort", "continue"], - defaultOption: "continue", + field: { type: FieldType.String }, + actions: [ + { id: "abort", label: "Abort" }, + { id: "continue", label: "Continue" }, + ], + defaultAction: "continue", data: { unsupported: "dns-server", planned: "iscsi-client", diff --git a/web/src/components/questions/UnsupportedAutoYaST.tsx b/web/src/components/questions/UnsupportedAutoYaST.tsx index 5a8b7dade2..21c62aaceb 100644 --- a/web/src/components/questions/UnsupportedAutoYaST.tsx +++ b/web/src/components/questions/UnsupportedAutoYaST.tsx @@ -69,8 +69,8 @@ export default function UnsupportedAutoYaST({ question: Question; answerCallback: AnswerCallback; }) { - const actionCallback = (option: string) => { - question.answer = option; + const actionCallback = (action: string) => { + question.answer = { action }; answerCallback(question); }; @@ -107,8 +107,8 @@ export default function UnsupportedAutoYaST({ diff --git a/web/src/queries/questions.ts b/web/src/queries/questions.ts index 2615e1b7e7..20c25bc348 100644 --- a/web/src/queries/questions.ts +++ b/web/src/queries/questions.ts @@ -59,7 +59,7 @@ const useQuestionsChanges = () => { if (!client) return; return client.onEvent((event) => { - if (event.type === "QuestionsChanged") { + if (event.type === "QuestionAdded" || event.type === "QuestionAnswered") { queryClient.invalidateQueries({ queryKey: ["questions"] }); } }); diff --git a/web/src/types/questions.ts b/web/src/types/questions.ts index 0dff268862..d3f799e7a3 100644 --- a/web/src/types/questions.ts +++ b/web/src/types/questions.ts @@ -20,32 +20,44 @@ * find current contact information at www.suse.com. */ -/** - * Enum for question types - */ -enum QuestionType { - generic = "generic", - withPassword = "withPassword", -} - type Question = { id: number; - type?: QuestionType; - class?: string; - options?: string[]; - defaultOption?: string; - text?: string; + text: string; + class: string; + field: SelectionField | Field; + actions: Action[]; + defaultAction?: string; data?: { [key: string]: string }; - answer?: string; - password?: string; + answer?: Answer; +}; + +type Field = { + type: FieldType; +}; + +type SelectionField = { + type: FieldType.Select; + options: object; +}; + +type Action = { + id: string; + label: string; }; type Answer = { - generic?: { answer: string }; - withPassword?: { password: string }; + action: string; + value?: string; }; +enum FieldType { + None = "none", + Password = "password", + String = "string", + Select = "select", +} + type AnswerCallback = (answeredQuestion: Question) => void; -export { QuestionType }; -export type { Answer, AnswerCallback, Question }; +export { FieldType }; +export type { Question, Action, AnswerCallback }; From 0a93b090d76176b47d82c0497e5842465c2774b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 20 Oct 2025 12:28:45 +0100 Subject: [PATCH 221/917] Improve list of members format --- rust/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c6a6b6f9c7..10c10c025d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,10 +8,11 @@ members = [ "agama-manager", "agama-network", "agama-server", + "agama-software", "agama-utils", + "xtask", "zypp-agama", "zypp-agama/zypp-agama-sys", - "xtask", "agama-software", ] resolver = "2" From 1c8fd6c40945e97da1c6862ab38978c31254816d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 20 Oct 2025 16:40:47 +0100 Subject: [PATCH 222/917] Make LicensesRepo and ProductRegistry more consistent --- rust/agama-software/src/model/license.rs | 21 +++++-- rust/agama-software/src/model/products.rs | 77 ++++++++++++++--------- rust/agama-software/src/start.rs | 1 + 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/rust/agama-software/src/model/license.rs b/rust/agama-software/src/model/license.rs index 5ae4436086..79b12a74ad 100644 --- a/rust/agama-software/src/model/license.rs +++ b/rust/agama-software/src/model/license.rs @@ -32,6 +32,14 @@ use std::{ }; use thiserror::Error; +#[derive(Error, Debug)] +pub enum Error { + #[error("Not a valid language code: {0}")] + InvalidLanguageCode(String), + #[error("I/O error")] + IO(#[from] std::io::Error), +} + /// Represents a product license. /// /// It contains the license ID and the list of languages that with a translation. @@ -73,9 +81,9 @@ pub struct LicenseContent { #[derive(Clone)] pub struct LicensesRepo { /// Repository path. - pub path: std::path::PathBuf, + path: std::path::PathBuf, /// Licenses in the repository. - pub licenses: Vec, + licenses: Vec, /// Fallback languages per territory. fallback: HashMap, } @@ -220,6 +228,11 @@ impl LicensesRepo { .into_iter() .find(|c| license.languages.contains(&c)) } + + /// Returns a vector with the licenses from the repository. + pub fn licenses(&self) -> Vec<&License> { + self.licenses.iter().collect() + } } impl Default for LicensesRepo { @@ -269,14 +282,14 @@ impl Display for LanguageTag { pub struct InvalidLanguageCode(String); impl TryFrom<&str> for LanguageTag { - type Error = InvalidLanguageCode; + type Error = Error; fn try_from(value: &str) -> Result { let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); let captures = language_regexp .captures(value) - .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; + .ok_or_else(|| Error::InvalidLanguageCode(value.to_string()))?; Ok(Self { language: captures.get(1).unwrap().as_str().to_string(), diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index 643fb04522..723d7e2162 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -28,6 +28,8 @@ use serde::{Deserialize, Deserializer}; use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use std::path::{Path, PathBuf}; +use crate::model::product::Product; + #[derive(thiserror::Error, Debug)] pub enum ProductsRegistryError { #[error("Could not read the products registry: {0}")] @@ -42,34 +44,24 @@ pub enum ProductsRegistryError { /// location by setting the `AGAMA_PRODUCTS_DIR` environment variable. /// /// Dynamic behavior, like filtering by architecture, is not supported yet. -#[derive(Clone, Default, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct ProductsRegistry { - pub products: Vec, + path: std::path::PathBuf, + products: Vec, } impl ProductsRegistry { - /// Creates a registry loading the products from the default location. - pub fn load() -> Result { - let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { - PathBuf::from(dir) - } else { - PathBuf::from("/usr/share/agama/products.d") - }; - - if !products_dir.exists() { - return Err(ProductsRegistryError::IO(std::io::Error::new( - std::io::ErrorKind::NotFound, - "products.d directory does not exist", - ))); + pub fn new>(path: P) -> Self { + Self { + path: path.as_ref().to_owned(), + products: vec![], } - - Self::load_from(products_dir) } - /// Creates a registry loading the products from the given location. - pub fn load_from>(products_path: P) -> Result { - let entries = std::fs::read_dir(products_path)?; - let mut products = vec![]; + /// Creates a registry loading the products from its location. + pub fn read(&mut self) -> Result<(), ProductsRegistryError> { + let entries = std::fs::read_dir(&self.path)?; + self.products.clear(); for entry in entries { let entry = entry?; @@ -81,11 +73,11 @@ impl ProductsRegistry { if path.is_file() && (ext == "yaml" || ext == "yml") { let product = ProductSpec::load_from(path)?; - products.push(product); + self.products.push(product); } } - Ok(Self { products }) + Ok(()) } /// Determines whether the are are multiple products. @@ -99,6 +91,33 @@ impl ProductsRegistry { pub fn find(&self, id: &str) -> Option<&ProductSpec> { self.products.iter().find(|p| p.id == id) } + + /// Returns a vector with the licenses from the repository. + pub fn products(&self) -> Vec { + self.products + .iter() + .map(|p| Product { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon: p.icon.clone(), + registration: p.registration, + license: None, + }) + .collect() + } +} + +impl Default for ProductsRegistry { + fn default() -> Self { + let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { + PathBuf::from(dir) + } else { + PathBuf::from("/usr/share/agama/products.d") + }; + + Self::new(products_dir) + } } // TODO: ideally, part of this code could be auto-generated from a JSON schema definition. @@ -197,16 +216,18 @@ mod test { #[test] fn test_load_registry() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); - let config = ProductsRegistry::load_from(path.as_path()).unwrap(); + let mut repo = ProductsRegistry::new(path.as_path()); + repo.read().unwrap(); // ensuring that we can load all products from tests - assert_eq!(config.products.len(), 8); + assert_eq!(repo.products.len(), 8); } #[test] fn test_find_product() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); - let products = ProductsRegistry::load_from(path.as_path()).unwrap(); - let tw = products.find("Tumbleweed").unwrap(); + let mut repo = ProductsRegistry::new(path.as_path()); + repo.read().unwrap(); + let tw = repo.find("Tumbleweed").unwrap(); assert_eq!(tw.id, "Tumbleweed"); assert_eq!(tw.name, "openSUSE Tumbleweed"); assert_eq!(tw.icon, "Tumbleweed.svg"); @@ -231,7 +252,7 @@ mod test { Some(&UserPattern::Preselected(expected_pattern)) ); - let missing = products.find("Missing"); + let missing = repo.find("Missing"); assert!(missing.is_none()); } } diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index 796859b862..839bafddf7 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -54,6 +54,7 @@ pub async fn start( let zypp_sender = ZyppServer::start()?; let model = Model::new(zypp_sender)?; let service = Service::new(model, issues, events); + service.read()?; let handler = actor::spawn(service); Ok(handler) } From 5ce3df107fe8d45d0319d2696ebd239414549dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 20 Oct 2025 16:44:44 +0100 Subject: [PATCH 223/917] Move LicensesRepo and ProductsReference out of the ModelAdapter --- rust/agama-software/src/model.rs | 58 ++------------------------ rust/agama-software/src/service.rs | 51 ++++++++++++++++++---- rust/agama-software/src/system_info.rs | 21 +--------- 3 files changed, 48 insertions(+), 82 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 8adcb61cae..fab5e22115 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -23,11 +23,9 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ - license::License, packages::{Repository, ResolvableType}, pattern::Pattern, - product::Product, - products::{ProductSpec, ProductsRegistry, UserPattern}, + products::{ProductSpec, UserPattern}, registration::{AddonProperties, RegistrationInfo}, software_selection::SoftwareSelection, }, @@ -54,27 +52,15 @@ pub trait ModelAdapter: Send + Sync + 'static { /// List of available patterns. async fn patterns(&self) -> Result, service::Error>; - /// List of available products. - fn products(&self) -> Vec; - /// List of available repositories. async fn repositories(&self) -> Result, service::Error>; - /// List of available licenses. - fn licenses(&self) -> Result, service::Error>; - /// List of available addons. fn addons(&self) -> Result, service::Error>; - /// selected product - fn selected_product(&self) -> Option; - /// info about registration fn registration_info(&self) -> Result; - /// selects given product - fn select_product(&mut self, product_id: &str) -> Result<(), service::Error>; - /// check if package is available async fn is_package_available(&self, tag: String) -> Result; @@ -94,7 +80,7 @@ pub trait ModelAdapter: Send + Sync + 'static { ) -> Result<(), service::Error>; /// Probes system and updates info about it. - async fn probe(&mut self) -> Result<(), service::Error>; + async fn probe(&mut self, product: &ProductSpec) -> Result<(), service::Error>; /// install rpms to target system async fn install(&self) -> Result; @@ -106,7 +92,6 @@ pub trait ModelAdapter: Send + Sync + 'static { /// [ModelAdapter] implementation for libzypp systems. pub struct Model { zypp_sender: mpsc::UnboundedSender, - products: ProductsRegistry, // FIXME: what about having a SoftwareServiceState to keep business logic state? selected_product: Option, software_selection: SoftwareSelection, @@ -117,7 +102,6 @@ impl Model { pub fn new(zypp_sender: mpsc::UnboundedSender) -> Result { Ok(Self { zypp_sender, - products: ProductsRegistry::load()?, selected_product: None, software_selection: SoftwareSelection::default(), }) @@ -147,30 +131,6 @@ impl ModelAdapter for Model { Ok(rx.await??) } - fn products(&self) -> Vec { - self.products - .products - .iter() - .map(|p| Product { - id: p.id.clone(), - name: p.name.clone(), - description: p.description.clone(), - icon: p.icon.clone(), - registration: p.registration, - license: None, - }) - .collect() - } - - fn select_product(&mut self, product_id: &str) -> Result<(), service::Error> { - let product_str = product_id.to_string(); - let Some(product_spec) = self.products.find(product_id) else { - return Err(service::Error::WrongProduct(product_str)); - }; - self.selected_product = Some(product_spec.clone()); - Ok(()) - } - async fn is_package_available(&self, tag: String) -> Result { let (tx, rx) = oneshot::channel(); self.zypp_sender @@ -204,11 +164,7 @@ impl ModelAdapter for Model { Ok(()) } - async fn probe(&mut self) -> Result<(), service::Error> { - let Some(product) = &self.selected_product else { - return Err(service::Error::MissingProduct); - }; - + async fn probe(&mut self, product: &ProductSpec) -> Result<(), service::Error> { let (tx, rx) = oneshot::channel(); let repositories = product .software @@ -317,18 +273,10 @@ impl ModelAdapter for Model { Ok(rx.await??) } - fn licenses(&self) -> Result, service::Error> { - todo!() - } - fn addons(&self) -> Result, service::Error> { todo!() } - fn selected_product(&self) -> Option { - self.selected_product.clone().map(|p| p.id) - } - fn registration_info(&self) -> Result { todo!() } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 63edb4a713..cd61cf9765 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -22,7 +22,11 @@ use crate::{ config::Config, event::{self}, message, - model::{products::ProductsRegistryError, ModelAdapter}, + model::{ + license::{Error as LicenseError, LicensesRepo}, + products::{ProductsRegistry, ProductsRegistryError}, + ModelAdapter, + }, proposal::Proposal, system_info::SystemInfo, zypp_server::{self, SoftwareAction}, @@ -54,6 +58,8 @@ pub enum Error { #[error(transparent)] ProductsRegistry(#[from] ProductsRegistryError), #[error(transparent)] + License(#[from] LicenseError), + #[error(transparent)] ZyppServerError(#[from] zypp_server::ZyppServerError), #[error(transparent)] ZyppError(#[from] zypp_agama::errors::ZyppError), @@ -69,13 +75,17 @@ pub enum Error { /// * Applies the user configuration at the end of the installation. pub struct Service { model: Box, + products: ProductsRegistry, + licenses: LicensesRepo, issues: Handler, events: event::Sender, state: State, } +#[derive(Default)] struct State { config: Config, + system: SystemInfo, } impl Service { @@ -88,12 +98,30 @@ impl Service { model: Box::new(model), issues, events, - state: State { - // we start with empty config as without product selection, there is basically nothing in config - config: Config::default(), - }, + licenses: LicensesRepo::default(), + products: ProductsRegistry::default(), + state: Default::default(), } } + + pub fn read(&mut self) -> Result<(), Error> { + self.licenses.read()?; + self.products.read()?; + Ok(()) + } + + pub fn update_system(&mut self) -> Result<(), Error> { + let licenses = self.licenses.licenses().into_iter().cloned().collect(); + let products = self.products.products(); + + self.state.system = SystemInfo { + licenses, + products, + ..Default::default() + }; + + Ok(()) + } } impl Actor for Service { @@ -103,7 +131,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { - Ok(SystemInfo::read_from(self.model.as_ref()).await?) + Ok(self.state.system.clone()) } } @@ -131,7 +159,16 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { - self.model.probe().await?; + let Some(product_id) = self.state.config.product.clone().and_then(|c| c.id) else { + return Err(Error::MissingProduct); + }; + + let Some(product) = self.products.find(&product_id) else { + return Err(Error::WrongProduct(product_id)); + }; + + self.model.probe(product).await?; + self.update_system(); Ok(()) } } diff --git a/rust/agama-software/src/system_info.rs b/rust/agama-software/src/system_info.rs index f3ed5148d2..09ec7c2d6a 100644 --- a/rust/agama-software/src/system_info.rs +++ b/rust/agama-software/src/system_info.rs @@ -29,7 +29,7 @@ use serde::Serialize; /// Localization-related information of the system where the installer /// is running. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Default, Serialize)] pub struct SystemInfo { /// List of known patterns. pub patterns: Vec, @@ -42,22 +42,3 @@ pub struct SystemInfo { /// List of available addons to register pub addons: Vec, } - -impl SystemInfo { - /// Reads the information from the system adapter. - pub async fn read_from(model: &dyn ModelAdapter) -> Result { - let patterns = model.patterns().await?; - let repositories = model.repositories().await?; - let products = model.products(); - let licenses = model.licenses()?; - let addons = model.addons()?; - - Ok(Self { - patterns, - repositories, - products, - licenses, - addons, - }) - } -} From 2e04a7a4456106d4c6ebf36797896042d686d194 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 21 Oct 2025 09:03:57 +0200 Subject: [PATCH 224/917] implement Remove Repositories --- rust/agama-server/src/software_ng.rs | 2 +- rust/agama-server/src/software_ng/backend.rs | 2 +- .../src/software_ng/backend/server.rs | 6 +- rust/agama-software/src/lib.rs | 3 - rust/agama-software/src/service.rs | 1 - rust/agama-software/src/zypp_server.rs | 67 ++++++++++++++----- 6 files changed, 58 insertions(+), 23 deletions(-) diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs index d0b9b8a0a6..de6724f21f 100644 --- a/rust/agama-server/src/software_ng.rs +++ b/rust/agama-server/src/software_ng.rs @@ -29,7 +29,7 @@ use backend::SoftwareService; pub use backend::SoftwareServiceError; use tokio::sync::Mutex; -use crate::{products::ProductsRegistry}; +use crate::products::ProductsRegistry; pub async fn software_ng_service( events: event::Sender, diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs index c1a5096e8f..3043d7b65f 100644 --- a/rust/agama-server/src/software_ng/backend.rs +++ b/rust/agama-server/src/software_ng/backend.rs @@ -39,7 +39,7 @@ pub use client::SoftwareServiceClient; use tokio::sync::{mpsc, oneshot, Mutex}; use zypp_agama::ZyppError; -use crate::{products::ProductsRegistry}; +use crate::products::ProductsRegistry; mod client; mod server; diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs index 51d4494720..5bb68148d0 100644 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ b/rust/agama-server/src/software_ng/backend/server.rs @@ -21,10 +21,12 @@ use std::{path::Path, sync::Arc}; use agama_lib::{ - http::event, product::Product, software::{ + http::event, + product::Product, + software::{ model::{ResolvableType, SoftwareConfig, SoftwareSelection}, Pattern, - } + }, }; use tokio::sync::{mpsc, oneshot, Mutex}; diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index f550e8f997..eda61b3a39 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -53,9 +53,6 @@ pub use config::Config; mod proposal; pub use proposal::Proposal; -pub mod event; -pub use event::Event; - mod extended_config; pub mod message; mod zypp_server; diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index cd61cf9765..01ebca91e9 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -20,7 +20,6 @@ use crate::{ config::Config, - event::{self}, message, model::{ license::{Error as LicenseError, LicensesRepo}, diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index d9b4ed769f..4a9b5fdc1d 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -26,13 +26,10 @@ use tokio::sync::{ use zypp_agama::ZyppError; use crate::model::{ - packages::{Repository, ResolvableType, SoftwareConfig}, - pattern::{self, Pattern}, - product::Product, - products::{ProductSpec, RepositorySpec}, - software_selection::SoftwareSelection, + packages::{Repository, ResolvableType}, + pattern::Pattern, + products::RepositorySpec, }; - const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -84,8 +81,9 @@ pub type ZyppServerResult = Result; #[derive(Debug)] pub enum SoftwareAction { AddRepositories(Vec, oneshot::Sender>), + RemoveRepositories(Vec, oneshot::Sender>), Install(oneshot::Sender>), - Finish, + Finish(oneshot::Sender>), ListRepositories(oneshot::Sender>>), GetPatternsMetadata(Vec, oneshot::Sender>>), PackageAvailable(String, oneshot::Sender>), @@ -172,6 +170,10 @@ impl ZyppServer { self.add_repositories(repos, tx, zypp).await?; } + SoftwareAction::RemoveRepositories(repos, tx) => { + self.remove_repositories(repos, tx, zypp).await?; + } + SoftwareAction::GetPatternsMetadata(names, tx) => { self.get_patterns(names, tx, zypp).await?; } @@ -189,8 +191,8 @@ impl ZyppServer { .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; } - SoftwareAction::Finish => { - //self.finish(zypp).await?; + SoftwareAction::Finish(tx) => { + self.finish(zypp, tx).await?; } SoftwareAction::SetResolvables { @@ -314,12 +316,47 @@ impl ZyppServer { Ok(()) } - async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> ZyppServerResult<()> { - self.remove_dud_repo(zypp)?; - self.disable_local_repos(zypp)?; - self.registration_finish()?; - self.modify_zypp_conf()?; - self.modify_full_repo(zypp)?; + async fn remove_repositories( + &self, + repos: Vec, + tx: oneshot::Sender>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + for repo in repos { + let res = zypp.remove_repository(&repo, |_, _| true); + if res.is_err() { + tx.send(res.map_err(|e| e.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + } + + Ok(()) + } + + async fn finish( + &mut self, + zypp: &zypp_agama::Zypp, + tx: oneshot::Sender>, + ) -> Result<(), ZyppDispatchError> { + if let Err(error) = self.remove_dud_repo(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + if let Err(error) = self.disable_local_repos(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } + self.registration_finish(); // TODO: move it outside of zypp server as it do not need zypp lock + self.modify_zypp_conf(); // TODO: move it outside of zypp server as it do not need zypp lock + + if let Err(error) = self.modify_full_repo(zypp) { + tx.send(Err(error.into())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + } Ok(()) } From e6adf05e7bc3d10758dbdffc91eaf54f9b2b42a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Oct 2025 12:39:51 +0100 Subject: [PATCH 225/917] Fix answering questions (#2822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix answering questions, which was not working. - Flatten the body to PATCH questions. - Add the missing Agama::HTTP::Base#patch method. --------- Co-authored-by: José Iván López --- rust/agama-lib/src/questions/http_client.rs | 4 ++-- rust/agama-server/src/server/web.rs | 10 +++++----- rust/agama-server/src/web/docs/config.rs | 2 +- rust/agama-utils/src/api/question.rs | 9 +++++++-- service/lib/agama/http/clients/base.rb | 22 ++++++++++++++++----- service/lib/agama/http/clients/questions.rb | 17 +++++++++++----- web/src/api/questions.ts | 7 +++++-- 7 files changed, 49 insertions(+), 22 deletions(-) diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index b44dac91ad..642f58b75e 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -24,7 +24,7 @@ use agama_utils::api::{ config::Patch, question::{ Answer, AnswerRule, Config as QuestionsConfig, Policy, Question, QuestionSpec, - UpdateOperation, + UpdateQuestion, }, Config, }; @@ -128,7 +128,7 @@ impl HTTPClient { } pub async fn delete_question(&self, id: u32) -> Result<(), QuestionsHTTPClientError> { - let update = UpdateOperation::Delete { id }; + let update = UpdateQuestion::Delete { id }; self.client.patch_void("/v2/questions", &update).await?; Ok(()) } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index a3b0159a83..1bb30d16e9 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -26,7 +26,7 @@ use agama_utils::{ actor::Handler, api::{ config, event, - question::{Question, QuestionSpec, UpdateOperation}, + question::{Question, QuestionSpec, UpdateQuestion}, Action, Config, IssueMap, Status, SystemInfo, }, question, @@ -287,7 +287,7 @@ async fn ask_question( patch, path = "/questions", context_path = "/api/v2", - request_body = UpdateOperation, + request_body = UpdateQuestion, responses( (status = 200, description = "The question was answered or deleted"), (status = 400, description = "It was not possible to update the question") @@ -295,16 +295,16 @@ async fn ask_question( )] async fn update_question( State(state): State, - Json(operation): Json, + Json(operation): Json, ) -> ServerResult<()> { match operation { - UpdateOperation::Answer { id, answer } => { + UpdateQuestion::Answer { id, answer } => { state .questions .call(question::message::Answer { id, answer }) .await?; } - UpdateOperation::Delete { id } => { + UpdateQuestion::Delete { id } => { state .questions .call(question::message::Delete { id }) diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 6a4e84df1d..ff37ec93a8 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -180,7 +180,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .build() diff --git a/rust/agama-utils/src/api/question.rs b/rust/agama-utils/src/api/question.rs index 504924f4de..cd101159ac 100644 --- a/rust/agama-utils/src/api/question.rs +++ b/rust/agama-utils/src/api/question.rs @@ -319,9 +319,14 @@ pub struct Answer { /// /// It is used by the HTTP layer only. #[derive(Serialize, Deserialize, utoipa::ToSchema)] -pub enum UpdateOperation { +#[serde(rename_all = "camelCase")] +pub enum UpdateQuestion { /// Answer the question with the given answer. - Answer { id: u32, answer: Answer }, + Answer { + id: u32, + #[serde(flatten)] + answer: Answer, + }, /// Remove the question. Delete { id: u32 }, } diff --git a/service/lib/agama/http/clients/base.rb b/service/lib/agama/http/clients/base.rb index 02c2a27ab9..84804671fa 100644 --- a/service/lib/agama/http/clients/base.rb +++ b/service/lib/agama/http/clients/base.rb @@ -34,8 +34,8 @@ def initialize(logger) end # send POST request with given data and path. - # @param path[String] path relatived to `api`` endpoint. - # @param data[#to_json] data to send in request + # @param path [String] path relatived to `api`` endpoint. + # @param data [#to_json] data to send in request def post(path, data) response = Net::HTTP.post(uri(path), data.to_json, headers) return response unless response.is_a?(Net::HTTPClientError) @@ -44,7 +44,7 @@ def post(path, data) end # send GET request with given path. - # @param path[String] path relatived to `api`` endpoint. + # @param path [String] path relatived to `api`` endpoint. # @return [Net::HTTPResponse, nil] Net::HTTPResponse if it is not an Net::HTTPClientError def get(path) response = Net::HTTP.get(uri(path), headers) @@ -54,8 +54,8 @@ def get(path) end # send PUT request with given data and path. - # @param path[String] path relatived to `api`` endpoint. - # @param data[#to_json] data to send in request + # @param path [String] path relatived to `api`` endpoint. + # @param data [#to_json] data to send in request def put(path, data) response = Net::HTTP.put(uri(path), data.to_json, headers) return unless response.is_a?(Net::HTTPClientError) @@ -63,6 +63,18 @@ def put(path, data) @logger.warn "server returned #{response.code} with body: #{response.body}" end + # send PATCH request with given data and path. + # @param path [String] path relatived to `api`` endpoint. + # @param data [#to_json] data to send in request + def patch(path, data) + url = uri(path) + http = Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https") + response = http.patch(url, data.to_json, headers) + return response unless response.is_a?(Net::HTTPClientError) + + @logger.warn "server returned #{response.code} with body: #{response.body}" + end + protected def uri(path) diff --git a/service/lib/agama/http/clients/questions.rb b/service/lib/agama/http/clients/questions.rb index b153edc65e..ff014d0439 100644 --- a/service/lib/agama/http/clients/questions.rb +++ b/service/lib/agama/http/clients/questions.rb @@ -27,6 +27,8 @@ module HTTP module Clients # HTTP client to interact with the files API. class Questions < Base + class CouldNotAddQuestion < StandardError; end + # Adds a question # # @param question [Agama::Question] @@ -46,7 +48,7 @@ def questions # @return [void] def delete(id) payload = { "delete" => { "id" => id } } - patch("/v2/questions", payload) + patch("v2/questions", payload) end # Waits until specified question is answered @@ -79,13 +81,18 @@ def wait_answer(id) # @return [Agama::Answer] The question answer, or the result of the block in case a block # is given. def ask(question) - question_id = add(question) - answer = wait_answer(question_id) + added_question = add(question) + if added_question.nil? + @logger.error "Could not add a question with data: {question.inspect}" + raise CouldNotAddQuestion + end + + answer = wait_answer(added_question.id) - logger.info("#{question.text} #{answer}") + @logger.info("#{added_question.text} #{answer}") result = block_given? ? yield(answer) : answer - delete(question_id) + delete(added_question.id) result end diff --git a/web/src/api/questions.ts b/web/src/api/questions.ts index 5e220c570e..80680a3bf7 100644 --- a/web/src/api/questions.ts +++ b/web/src/api/questions.ts @@ -34,8 +34,11 @@ const fetchQuestions = async (): Promise => await get("/api/v2/quest * The answer is part of the Question object. */ const updateAnswer = async (question: Question): Promise => { - const { id, answer } = question; - await patch(`/api/v2/questions`, { id, answer }); + const { + id, + answer: { action, value }, + } = question; + await patch(`/api/v2/questions`, { answer: { id, action, value } }); }; export { fetchQuestions, updateAnswer }; From 8661082f32584a5ff0ec6baf0c7220973dfe4142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Oct 2025 14:41:55 +0100 Subject: [PATCH 226/917] Do not crash if l10n cannot connect to D-Bus --- rust/agama-l10n/src/start.rs | 15 +++++++++++---- rust/agama-server/src/server/web.rs | 7 +++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 0129524e4d..46e6b9d6f8 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -31,8 +31,6 @@ use agama_utils::{ pub enum Error { #[error(transparent)] Service(#[from] service::Error), - #[error(transparent)] - Monitor(#[from] monitor::Error), } /// Starts the localization service. @@ -53,8 +51,17 @@ pub async fn start( let model = Model::from_system()?; let service = Service::new(model, issues, events); let handler = actor::spawn(service); - let monitor = Monitor::new(handler.clone()).await?; - monitor::spawn(monitor); + + match Monitor::new(handler.clone()).await { + Ok(monitor) => monitor::spawn(monitor), + Err(error) => { + tracing::error!( + "Could not launch the l10n monitor, therefore changes from systemd will be ignored. \ + The original error was {error}" + ); + } + } + Ok(handler) } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 1bb30d16e9..cb8e166670 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -31,7 +31,6 @@ use agama_utils::{ }, question, }; -use anyhow; use axum::{ extract::State, response::{IntoResponse, Response}, @@ -72,17 +71,17 @@ type ServerResult = Result; /// /// * `events`: channel to send events to the websocket. /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features -/// that require to connect to the Agama's D-Bus server won't work. +/// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( events: event::Sender, dbus: Option, ) -> Result { let questions = question::start(events.clone()) .await - .map_err(|e| anyhow::Error::new(e))?; + .map_err(anyhow::Error::msg)?; let manager = manager::start(questions.clone(), events, dbus) .await - .map_err(|e| anyhow::Error::new(e))?; + .map_err(anyhow::Error::msg)?; let state = ServerState { manager, questions }; From 4a6dce9448bd5c7504ff129fefcba54eb94c900a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 22 Oct 2025 16:17:05 +0100 Subject: [PATCH 227/917] Forget about the agama-services directory in the "agama" package --- rust/package/agama.spec | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 72bc1eaac7..cd62820c90 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -213,7 +213,6 @@ echo $PATH %doc README.md %license LICENSE %{_bindir}/agama-web-server -%{_datadir}/dbus-1/agama-services %{_pam_vendordir}/agama %{_unitdir}/agama-web-server.service From 09aea718f9234de60e4388b4d375d1897cfcb0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 16 Oct 2025 16:30:25 +0100 Subject: [PATCH 228/917] Add storage service --- rust/Cargo.lock | 11 +++++ rust/Cargo.toml | 2 +- rust/agama-manager/src/start.rs | 8 ---- rust/agama-storage/Cargo.toml | 12 +++++ rust/agama-storage/src/dbus.rs | 21 +++++++++ rust/agama-storage/src/dbus/client.rs | 68 +++++++++++++++++++++++++++ rust/agama-storage/src/lib.rs | 27 +++++++++++ rust/agama-storage/src/message.rs | 29 ++++++++++++ rust/agama-storage/src/service.rs | 57 ++++++++++++++++++++++ rust/agama-storage/src/start.rs | 34 ++++++++++++++ rust/agama-utils/Cargo.toml | 2 +- rust/agama-utils/src/api/progress.rs | 4 +- 12 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 rust/agama-storage/Cargo.toml create mode 100644 rust/agama-storage/src/dbus.rs create mode 100644 rust/agama-storage/src/dbus/client.rs create mode 100644 rust/agama-storage/src/lib.rs create mode 100644 rust/agama-storage/src/message.rs create mode 100644 rust/agama-storage/src/service.rs create mode 100644 rust/agama-storage/src/start.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f884cc2de3..b526b038b7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -216,6 +216,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "agama-storage" +version = "0.1.0" +dependencies = [ + "agama-utils", + "async-trait", + "serde_json", + "thiserror 2.0.16", + "zbus", +] + [[package]] name = "agama-utils" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index fc41a8f3f0..a5d68b1aba 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,7 +7,7 @@ members = [ "agama-locale-data", "agama-manager", "agama-network", - "agama-server", + "agama-server", "agama-storage", "agama-utils", "xtask", ] diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 743d859042..67012dba7f 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -38,14 +38,6 @@ pub enum Error { /// Starts the manager service. /// -/// It starts two Tokio tasks: -/// -/// * The main service, called "Manager", which coordinates the rest of services -/// an entry point for the HTTP API. -/// * An events listener which retransmit the events from all the services. -/// -/// It receives the following argument: -/// /// * `events`: channel to emit the [events](agama_utils::Event). /// * `dbus`: connection to Agama's D-Bus server. If it is not given, those features /// that require to connect to the Agama's D-Bus server won't work. diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml new file mode 100644 index 0000000000..d97f839915 --- /dev/null +++ b/rust/agama-storage/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agama-storage" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-utils = { path = "../agama-utils" } +thiserror = "2.0.16" +async-trait = "0.1.89" +zbus = "5.7.1" +serde_json = { version = "1.0.140", features = ["raw_value"] } diff --git a/rust/agama-storage/src/dbus.rs b/rust/agama-storage/src/dbus.rs new file mode 100644 index 0000000000..904a5d2a38 --- /dev/null +++ b/rust/agama-storage/src/dbus.rs @@ -0,0 +1,21 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod client; diff --git a/rust/agama-storage/src/dbus/client.rs b/rust/agama-storage/src/dbus/client.rs new file mode 100644 index 0000000000..814daf7494 --- /dev/null +++ b/rust/agama-storage/src/dbus/client.rs @@ -0,0 +1,68 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Agama's storage service. + +use serde_json::value::RawValue; +use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection}; + +const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; +const OBJECT_PATH: &str = "/org/opensuse/Agama/Storage1"; +const INTERFACE: &str = "org.opensuse.Agama.Storage1"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), + #[error(transparent)] + DBusName(#[from] zbus::names::Error), + #[error(transparent)] + DBusVariant(#[from] zbus::zvariant::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +/// D-Bus client for the storage service +#[derive(Clone)] +pub struct Client { + connection: Connection, +} + +impl Client { + pub fn new(connection: Connection) -> Self { + Self { connection } + } + + pub async fn get_config_model(&self) -> Result, Error> { + self.call("GetConfigModel").await + } + + async fn call(&self, method: &str) -> Result, Error> { + let bus = BusName::try_from(SERVICE_NAME.to_string())?; + let path = OwnedObjectPath::try_from(OBJECT_PATH)?; + let message = self + .connection + .call_method(Some(&bus), &path, Some(INTERFACE), method, &()) + .await?; + + let value: String = message.body().deserialize()?; + RawValue::from_string(value).map_err(|e| e.into()) + } +} diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs new file mode 100644 index 0000000000..3644a4fc6c --- /dev/null +++ b/rust/agama-storage/src/lib.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod service; +pub use service::Service; + +mod dbus; +pub mod message; + +pub mod start; diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs new file mode 100644 index 0000000000..92063d350b --- /dev/null +++ b/rust/agama-storage/src/message.rs @@ -0,0 +1,29 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::actor::Message; +use serde_json::value::RawValue; + +#[derive(Clone)] +pub struct GetModel; + +impl Message for GetModel { + type Reply = Box; +} diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs new file mode 100644 index 0000000000..f793e49b1f --- /dev/null +++ b/rust/agama-storage/src/service.rs @@ -0,0 +1,57 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::dbus::client::{self, Client}; +use crate::message; +use agama_utils::actor::{self, Actor, MessageHandler}; +use async_trait::async_trait; +use serde_json::value::RawValue; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + Client(#[from] client::Error), +} + +/// Storage service. +pub struct Service { + client: Client, +} + +impl Service { + pub fn new(connection: zbus::Connection) -> Service { + Self { + client: Client::new(connection), + } + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetModel) -> Result, Error> { + self.client.get_config_model().await.map_err(|e| e.into()) + } +} diff --git a/rust/agama-storage/src/start.rs b/rust/agama-storage/src/start.rs new file mode 100644 index 0000000000..1afae71369 --- /dev/null +++ b/rust/agama-storage/src/start.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::service::Service; +use agama_utils::actor::{self, Handler}; + +#[derive(thiserror::Error, Debug)] +pub enum Error {} + +/// Starts the storage service. +/// +/// * `dbus`: connection to Agama's D-Bus server. +pub async fn start(dbus: zbus::Connection) -> Result, Error> { + let service = Service::new(dbus); + let handler = actor::spawn(service); + Ok(handler) +} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 1f1b3ef9e6..c47a8b970d 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true [dependencies] async-trait = "0.1.89" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.140" +serde_json = { version = "1.0.140", features = ["raw_value"] } serde_with = "3.14.0" strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" diff --git a/rust/agama-utils/src/api/progress.rs b/rust/agama-utils/src/api/progress.rs index 69e908696f..a75464bc87 100644 --- a/rust/agama-utils/src/api/progress.rs +++ b/rust/agama-utils/src/api/progress.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! This module includes the struct that represent a service progress step. +//! This module define types related to the progress report. use crate::api::scope::Scope; use serde::{Deserialize, Serialize}; From 154dc6e8915c60d98ca77e919e7cb65ed0f6e216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 17 Oct 2025 15:46:35 +0100 Subject: [PATCH 229/917] Make dbus connection mandatory --- rust/Cargo.lock | 1 + rust/agama-l10n/src/start.rs | 19 +-- rust/agama-manager/src/start.rs | 18 +-- rust/agama-server/src/server/web.rs | 2 +- rust/agama-server/src/web.rs | 2 +- rust/agama-server/tests/common/mod.rs | 106 ----------------- rust/agama-server/tests/server_service.rs | 5 +- rust/agama-utils/Cargo.toml | 3 +- rust/agama-utils/src/issue.rs | 17 --- rust/agama-utils/src/issue/start.rs | 43 ++++--- rust/agama-utils/src/lib.rs | 1 + rust/agama-utils/src/test.rs | 24 ++++ rust/agama-utils/src/test/dbus.rs | 135 ++++++++++++++++++++++ 13 files changed, 215 insertions(+), 161 deletions(-) create mode 100644 rust/agama-utils/src/test.rs create mode 100644 rust/agama-utils/src/test/dbus.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b526b038b7..6197ac593d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -243,6 +243,7 @@ dependencies = [ "tokio-stream", "tokio-test", "utoipa", + "uuid", "zbus", "zvariant", ] diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index 46e6b9d6f8..b1fba9f4e7 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -71,12 +71,16 @@ mod tests { use crate::model::{KeymapsDatabase, LocalesDatabase, ModelAdapter, TimezonesDatabase}; use crate::service::{self, Service}; use agama_locale_data::{KeymapId, LocaleId}; - use agama_utils::actor::{self, Handler}; - use agama_utils::api; - use agama_utils::api::event::{self, Event}; - use agama_utils::api::l10n::{Keymap, LocaleEntry, TimezoneEntry}; - use agama_utils::api::scope::Scope; - use agama_utils::issue; + use agama_utils::{ + actor::{self, Handler}, + api::{ + self, + event::{self, Event}, + l10n::{Keymap, LocaleEntry, TimezoneEntry}, + scope::Scope, + }, + issue, test, + }; use tokio::sync::broadcast; pub struct TestModel { @@ -145,7 +149,8 @@ mod tests { async fn start_testing_service() -> (event::Receiver, Handler, Handler) { let (events_tx, events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx.clone(), None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx.clone(), dbus).await.unwrap(); let model = build_adapter(); let service = Service::new(model, issues.clone(), events_tx); diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 67012dba7f..625a8f0df9 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -44,7 +44,7 @@ pub enum Error { pub async fn start( questions: Handler, events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result, Error> { let issues = issue::start(events.clone(), dbus).await?; let progress = progress::start(events.clone()).await?; @@ -57,17 +57,17 @@ pub async fn start( #[cfg(test)] mod test { - use crate as manager; - use crate::message; - use crate::service::Service; - use agama_utils::actor::Handler; - use agama_utils::api::l10n; - use agama_utils::api::{Config, Event}; - use agama_utils::question; + use crate::{self as manager, message, service::Service}; + use agama_utils::{ + actor::Handler, + api::{l10n, Config, Event}, + question, test, + }; use tokio::sync::broadcast; async fn start_service() -> Handler { let (events_sender, mut events_receiver) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); tokio::spawn(async move { while let Ok(event) = events_receiver.recv().await { @@ -76,7 +76,7 @@ mod test { }); let questions = question::start(events_sender.clone()).await.unwrap(); - manager::start(questions, events_sender, None) + manager::start(questions, events_sender, dbus) .await .unwrap() } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index cb8e166670..0d426dda4c 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -74,7 +74,7 @@ type ServerResult = Result; /// that require to connect to the Agama's D-Bus server won't work. pub async fn server_service( events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result { let questions = question::start(events.clone()) .await diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index c252266e66..5bd9bd4b72 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -88,7 +88,7 @@ where "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) - .add_service("/v2", server_service(events, Some(dbus.clone())).await?) + .add_service("/v2", server_service(events, dbus.clone()).await?) .add_service("/security", security_service(dbus.clone()).await?) .add_service( "/software", diff --git a/rust/agama-server/tests/common/mod.rs b/rust/agama-server/tests/common/mod.rs index d5af955102..91f34ada8d 100644 --- a/rust/agama-server/tests/common/mod.rs +++ b/rust/agama-server/tests/common/mod.rs @@ -18,113 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::error::ServiceError; use axum::body::{to_bytes, Body}; -use std::{ - future::Future, - process::{Child, Command}, - time::Duration, -}; - -use uuid::Uuid; - -/// D-Bus server to be used on tests. -/// -/// Takes care of starting and stopping a dbus-daemon to be used on integration tests. Each server -/// uses a different socket, so they do not collide. -/// -/// NOTE: this struct implements the [typestate pattern](http://cliffle.com/blog/rust-typestate/). -pub struct DBusServer { - address: String, - extra: S, -} - -pub struct Started { - connection: zbus::Connection, - child: Child, -} - -impl Drop for Started { - fn drop(&mut self) { - self.child.kill().unwrap(); - } -} - -pub struct Stopped; - -pub trait ServerState {} -impl ServerState for Started {} -impl ServerState for Stopped {} - -impl Default for DBusServer { - fn default() -> Self { - Self::new() - } -} - -impl DBusServer { - pub fn new() -> Self { - let uuid = Uuid::new_v4(); - DBusServer { - address: format!("unix:path=/tmp/agama-tests-{uuid}"), - extra: Stopped, - } - } - - pub async fn start(self) -> Result, ServiceError> { - let child = Command::new("/usr/bin/dbus-daemon") - .args([ - "--config-file", - "../share/dbus-test.conf", - "--address", - &self.address, - ]) - .spawn() - .expect("to start the testing D-Bus daemon"); - - let connection = async_retry(|| agama_lib::connection_to(&self.address)).await?; - - Ok(DBusServer { - address: self.address, - extra: Started { child, connection }, - }) - } -} - -impl DBusServer { - pub fn connection(&self) -> zbus::Connection { - self.extra.connection.clone() - } -} - -/// Run and retry an async function. -/// -/// Beware that, if the function is failing for a legit reason, you will -/// introduce a delay in your code. -/// -/// * `func`: async function to run. -pub async fn async_retry(func: F) -> Result -where - F: Fn() -> O, - O: Future>, -{ - const RETRIES: u8 = 10; - const INTERVAL: u64 = 500; - let mut retry = 0; - loop { - match func().await { - Ok(result) => return Ok(result), - Err(error) => { - if retry > RETRIES { - return Err(error); - } - retry += 1; - let wait_time = Duration::from_millis(INTERVAL); - tokio::time::sleep(wait_time).await; - } - } - } -} pub async fn body_to_string(body: Body) -> String { let bytes = to_bytes(body, usize::MAX).await.unwrap(); diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 78275fc12b..2bb18516a1 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -21,7 +21,7 @@ pub mod common; use agama_lib::error::ServiceError; use agama_server::server::server_service; -use agama_utils::api; +use agama_utils::{api, test}; use axum::{ body::Body, http::{Method, Request, StatusCode}, @@ -34,6 +34,7 @@ use tower::ServiceExt; async fn build_server_service() -> Result { let (tx, mut rx) = channel(16); + let dbus = test::dbus::connection().await.unwrap(); tokio::spawn(async move { while let Ok(event) = rx.recv().await { @@ -41,7 +42,7 @@ async fn build_server_service() -> Result { } }); - server_service(tx, None).await + server_service(tx, dbus).await } #[test] diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index c47a8b970d..510ef8e037 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -5,6 +5,7 @@ rust-version.workspace = true edition.workspace = true [dependencies] +agama-locale-data = { path = "../agama-locale-data" } async-trait = "0.1.89" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.140", features = ["raw_value"] } @@ -17,7 +18,7 @@ utoipa = "5.3.1" zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } -agama-locale-data = { path = "../agama-locale-data" } +uuid = { version = "1.10.0", features = ["v4"] } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/issue.rs b/rust/agama-utils/src/issue.rs index fe04bd83a7..35abeae891 100644 --- a/rust/agama-utils/src/issue.rs +++ b/rust/agama-utils/src/issue.rs @@ -26,23 +26,6 @@ //! //! The service can be started calling the [start] function, which returns an //! [agama_utils::actors::ActorHandler] to interact with it. -//! -//! # Example -//! -//! ```no_run -//! use agama_utils::issue::{self, message}; -//! use agama_utils::api::Scope; -//! use tokio::sync::broadcast; -//! -//! # tokio_test::block_on(async { -//! async fn use_issues_service() { -//! let (events_tx, _events_rx) = broadcast::channel(16); -//! let issues = issue::start(events_tx, None).await.unwrap(); -//! _ = issues.call(message::Update::new(Scope::Manager, vec![])); -//! } -//! # }); -//! -//! ``` pub mod service; pub use service::Service; diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index d557f4410d..5728c4bcc1 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -18,10 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::actor::{self, Handler}; -use crate::api::event; -use crate::issue::monitor::{self, Monitor}; -use crate::issue::service::{self, Service}; +use crate::{ + actor::{self, Handler}, + api::event, + issue::{ + monitor::{self, Monitor}, + service::{self, Service}, + }, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -31,26 +35,28 @@ pub enum Error { pub async fn start( events: event::Sender, - dbus: Option, + dbus: zbus::Connection, ) -> Result, Error> { let service = Service::new(events); let handler = actor::spawn(service); - if let Some(conn) = dbus { - let dbus_monitor = Monitor::new(handler.clone(), conn); - monitor::spawn(dbus_monitor); - } + let dbus_monitor = Monitor::new(handler.clone(), dbus); + monitor::spawn(dbus_monitor); Ok(handler) } #[cfg(test)] mod tests { - use crate::api::event::Event; - use crate::api::issue::{Issue, IssueSeverity, IssueSource}; - use crate::api::scope::Scope; - use crate::issue; - use crate::issue::message; + use crate::{ + api::{ + event::Event, + issue::{Issue, IssueSeverity, IssueSource}, + scope::Scope, + }, + issue::{self, message}, + test, + }; use tokio::sync::broadcast::{self, error::TryRecvError}; fn build_issue() -> Issue { @@ -66,7 +72,8 @@ mod tests { #[tokio::test] async fn test_get_and_update_issues() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); @@ -85,7 +92,8 @@ mod tests { #[tokio::test] async fn test_update_without_event() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); assert!(issues_list.is_empty()); @@ -104,7 +112,8 @@ mod tests { #[tokio::test] async fn test_update_without_change() -> Result<(), Box> { let (events_tx, mut events_rx) = broadcast::channel::(16); - let issues = issue::start(events_tx, None).await.unwrap(); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx, dbus).await.unwrap(); let issue = build_issue(); let update = message::Update::new(Scope::Manager, vec![issue.clone()]); diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 1319404b0c..a1562dc900 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -31,3 +31,4 @@ pub mod issue; pub mod openapi; pub mod progress; pub mod question; +pub mod test; diff --git a/rust/agama-utils/src/test.rs b/rust/agama-utils/src/test.rs new file mode 100644 index 0000000000..087c152b51 --- /dev/null +++ b/rust/agama-utils/src/test.rs @@ -0,0 +1,24 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This crate offers a set of utility struct and functions to be used accross +//! other Agama's crates. + +pub mod dbus; diff --git a/rust/agama-utils/src/test/dbus.rs b/rust/agama-utils/src/test/dbus.rs new file mode 100644 index 0000000000..d485927ba7 --- /dev/null +++ b/rust/agama-utils/src/test/dbus.rs @@ -0,0 +1,135 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::{ + future::Future, + process::{Child, Command}, + time::Duration, +}; +use uuid::Uuid; +use zbus::conn::Builder; + +pub async fn connection() -> Result { + let server = DBusServer::::new(); + Ok(server.start().await?.connection()) +} + +/// D-Bus server to be used on tests. +/// +/// Takes care of starting and stopping a dbus-daemon to be used on integration tests. Each server +/// uses a different socket, so they do not collide. +/// +/// NOTE: this struct implements the [typestate pattern](http://cliffle.com/blog/rust-typestate/). +struct DBusServer { + address: String, + extra: S, +} + +struct Started { + connection: zbus::Connection, + child: Child, +} + +impl Drop for Started { + fn drop(&mut self) { + self.child.kill().unwrap(); + } +} + +pub struct Stopped; + +pub trait ServerState {} +impl ServerState for Started {} +impl ServerState for Stopped {} + +impl Default for DBusServer { + fn default() -> Self { + Self::new() + } +} + +impl DBusServer { + fn new() -> Self { + let uuid = Uuid::new_v4(); + DBusServer { + address: format!("unix:path=/tmp/agama-tests-{uuid}"), + extra: Stopped, + } + } + + async fn start(self) -> Result, zbus::Error> { + let child = Command::new("/usr/bin/dbus-daemon") + .args([ + "--config-file", + "../share/dbus-test.conf", + "--address", + &self.address, + ]) + .spawn() + .expect("to start the testing D-Bus daemon"); + + let connection = async_retry(|| connection_to(&self.address)).await?; + + Ok(DBusServer { + address: self.address, + extra: Started { child, connection }, + }) + } +} + +impl DBusServer { + fn connection(&self) -> zbus::Connection { + self.extra.connection.clone() + } +} + +/// Run and retry an async function. +/// +/// Beware that, if the function is failing for a legit reason, you will +/// introduce a delay in your code. +/// +/// * `func`: async function to run. +async fn async_retry(func: F) -> Result +where + F: Fn() -> O, + O: Future>, +{ + const RETRIES: u8 = 10; + const INTERVAL: u64 = 500; + let mut retry = 0; + loop { + match func().await { + Ok(result) => return Ok(result), + Err(error) => { + if retry > RETRIES { + return Err(error); + } + retry += 1; + let wait_time = Duration::from_millis(INTERVAL); + tokio::time::sleep(wait_time).await; + } + } + } +} + +async fn connection_to(address: &str) -> Result { + let connection = Builder::address(address)?.build().await?; + Ok(connection) +} From 9ed773f2cc7f1d5e90e686efa69a7cdc8118fc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 20 Oct 2025 07:03:17 +0100 Subject: [PATCH 230/917] Add HTTP resource for the storage model --- rust/Cargo.lock | 2 ++ rust/agama-manager/Cargo.toml | 2 ++ rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/message.rs | 23 ++++++++++++ rust/agama-manager/src/service.rs | 34 +++++++++++++++--- rust/agama-manager/src/start.rs | 12 ++++--- rust/agama-server/src/server/web.rs | 45 ++++++++++++++++++++++-- rust/agama-storage/src/dbus/client.rs | 32 +++++++++++++++-- rust/agama-storage/src/lib.rs | 3 +- rust/agama-storage/src/message.rs | 15 ++++++++ rust/agama-storage/src/service.rs | 10 ++++++ rust/agama-utils/src/question/message.rs | 5 +-- 12 files changed, 163 insertions(+), 21 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6197ac593d..662f3329eb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -131,9 +131,11 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-l10n", + "agama-storage", "agama-utils", "async-trait", "merge-struct", + "serde_json", "thiserror 2.0.16", "tokio", "tokio-test", diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 05f303cca7..c7978bfe4f 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -7,11 +7,13 @@ edition.workspace = true [dependencies] agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-storage = { path = "../agama-storage" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } async-trait = "0.1.83" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" +serde_json = { version = "1.0.140", features = ["raw_value"] } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 9a4f0730e9..49a1a5b366 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -27,3 +27,4 @@ pub use service::Service; pub mod message; pub use agama_l10n as l10n; +pub use agama_storage as storage; diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 47b79216df..cf08ce2eb7 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -22,6 +22,7 @@ use agama_utils::{ actor::Message, api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, }; +use serde_json::value::RawValue; /// Gets the installation status. pub struct GetStatus; @@ -118,3 +119,25 @@ impl RunAction { impl Message for RunAction { type Reply = (); } + +// Gets the storage model. +pub struct GetStorageModel; + +impl Message for GetStorageModel { + type Reply = Box; +} + +// Sets the storage model. +pub struct SetStorageModel { + pub model: Box, +} + +impl SetStorageModel { + pub fn new(model: Box) -> Self { + Self { model } + } +} + +impl Message for SetStorageModel { + type Reply = (); +} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 960bfd413c..64bcfccefd 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; -use crate::message; +use crate::{l10n, message, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -29,6 +28,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; +use serde_json::value::RawValue; use tokio::sync::broadcast; #[derive(Debug, thiserror::Error)] @@ -40,17 +40,20 @@ pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] - Progress(#[from] progress::service::Error), - #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] + Storage(#[from] storage::service::Error), + #[error(transparent)] Issues(#[from] issue::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), + #[error(transparent)] + Progress(#[from] progress::service::Error), } pub struct Service { - l10n: Handler, + l10n: Handler, + storage: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -62,6 +65,7 @@ pub struct Service { impl Service { pub fn new( l10n: Handler, + storage: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -69,6 +73,7 @@ impl Service { ) -> Self { Self { l10n, + storage, issues, progress, questions, @@ -218,3 +223,22 @@ impl MessageHandler for Service { Ok(()) } } + +#[async_trait] +impl MessageHandler for Service { + /// It returns the storage model. + async fn handle(&mut self, _message: message::GetStorageModel) -> Result, Error> { + Ok(self.storage.call(storage::message::GetModel).await?) + } +} + +#[async_trait] +impl MessageHandler for Service { + /// It sets the storage model. + async fn handle(&mut self, message: message::SetStorageModel) -> Result<(), Error> { + Ok(self + .storage + .call(storage::message::SetModel::new(message.model)) + .await?) + } +} diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 625a8f0df9..62897a29b7 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; -use crate::service::Service; +use crate::{l10n, service::Service, storage}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -31,9 +30,11 @@ pub enum Error { #[error(transparent)] Progress(#[from] progress::start::Error), #[error(transparent)] + Issues(#[from] issue::start::Error), + #[error(transparent)] L10n(#[from] l10n::start::Error), #[error(transparent)] - Issues(#[from] issue::start::Error), + Storage(#[from] storage::start::Error), } /// Starts the manager service. @@ -46,11 +47,12 @@ pub async fn start( events: event::Sender, dbus: zbus::Connection, ) -> Result, Error> { - let issues = issue::start(events.clone(), dbus).await?; + let issues = issue::start(events.clone(), dbus.clone()).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; + let storage = storage::start(dbus).await?; - let service = Service::new(l10n, issues, progress, questions, events.clone()); + let service = Service::new(l10n, storage, issues, progress, questions, events.clone()); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 0d426dda4c..f7f1a20f40 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -39,7 +39,7 @@ use axum::{ }; use hyper::StatusCode; use serde::Serialize; -use serde_json::json; +use serde_json::{json, value::RawValue}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -100,6 +100,10 @@ pub async fn server_service( "/questions", get(get_questions).post(ask_question).patch(update_question), ) + .route( + "/private/storage_model", + get(get_storage_model).put(set_storage_model), + ) .with_state(state)) } @@ -241,8 +245,7 @@ async fn get_proposal(State(state): State) -> ServerResult) -> ServerResult> { let issues = state.manager.call(message::GetIssues).await?; - let issues_map: IssueMap = issues.into(); - Ok(Json(issues_map)) + Ok(Json(issues)) } /// Returns the issues for each scope. @@ -333,6 +336,42 @@ async fn run_action( Ok(()) } +/// Returns how the target system is configured (proposal). +#[utoipa::path( + get, + path = "/private/storage_model", + context_path = "/api/v2", + responses( + (status = 200, description = "Storage model was successfully retrieved."), + (status = 400, description = "Not possible to retrieve the storage model.") + ) +)] +async fn get_storage_model(State(state): State) -> ServerResult>> { + let model = state.manager.call(message::GetStorageModel).await?; + Ok(Json(model)) +} + +#[utoipa::path( + put, + request_body = String, + path = "/private/storage_model", + context_path = "/api/v2", + responses( + (status = 200, description = "Set the storage model"), + (status = 400, description = "Not possible to set the storage model") + ) +)] +async fn set_storage_model( + State(state): State, + Json(model): Json>, +) -> ServerResult<()> { + state + .manager + .call(message::SetStorageModel::new(model)) + .await?; + Ok(()) +} + fn to_option_response(value: Option) -> Response { match value { Some(inner) => Json(inner).into_response(), diff --git a/rust/agama-storage/src/dbus/client.rs b/rust/agama-storage/src/dbus/client.rs index 814daf7494..35016abc7f 100644 --- a/rust/agama-storage/src/dbus/client.rs +++ b/rust/agama-storage/src/dbus/client.rs @@ -21,7 +21,12 @@ //! Implements a client to access Agama's storage service. use serde_json::value::RawValue; -use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection}; +use std::collections::HashMap; +use zbus::{ + names::BusName, + zvariant::{self, OwnedObjectPath}, + Connection, +}; const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; const OBJECT_PATH: &str = "/org/opensuse/Agama/Storage1"; @@ -51,10 +56,14 @@ impl Client { } pub async fn get_config_model(&self) -> Result, Error> { - self.call("GetConfigModel").await + self.get_json("GetConfigModel").await } - async fn call(&self, method: &str) -> Result, Error> { + pub async fn set_config_model(&self, model: Box) -> Result<(), Error> { + self.set_json("SetConfigModel", model).await + } + + async fn get_json(&self, method: &str) -> Result, Error> { let bus = BusName::try_from(SERVICE_NAME.to_string())?; let path = OwnedObjectPath::try_from(OBJECT_PATH)?; let message = self @@ -65,4 +74,21 @@ impl Client { let value: String = message.body().deserialize()?; RawValue::from_string(value).map_err(|e| e.into()) } + + async fn set_json(&self, method: &str, json: Box) -> Result<(), Error> { + let bus = BusName::try_from(SERVICE_NAME.to_string())?; + let path = OwnedObjectPath::try_from(OBJECT_PATH)?; + let data: HashMap<&str, &zvariant::Value> = HashMap::new(); + self.connection + .call_method( + Some(&bus), + &path, + Some(INTERFACE), + method, + &(json.to_string(), data), + ) + .await?; + + Ok(()) + } } diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index 3644a4fc6c..ed78b72576 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -18,10 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod service; +pub mod service; pub use service::Service; mod dbus; pub mod message; pub mod start; +pub use start::start; diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index 92063d350b..defb37929d 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -27,3 +27,18 @@ pub struct GetModel; impl Message for GetModel { type Reply = Box; } + +#[derive(Clone)] +pub struct SetModel { + pub model: Box, +} + +impl SetModel { + pub fn new(model: Box) -> Self { + Self { model } + } +} + +impl Message for SetModel { + type Reply = (); +} diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index f793e49b1f..fb5b918fe1 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -55,3 +55,13 @@ impl MessageHandler for Service { self.client.get_config_model().await.map_err(|e| e.into()) } } + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetModel) -> Result<(), Error> { + self.client + .set_config_model(message.model) + .await + .map_err(|e| e.into()) + } +} diff --git a/rust/agama-utils/src/question/message.rs b/rust/agama-utils/src/question/message.rs index d976da324b..8605b7ca4e 100644 --- a/rust/agama-utils/src/question/message.rs +++ b/rust/agama-utils/src/question/message.rs @@ -20,10 +20,7 @@ use crate::{ actor::Message, - api::{ - self, - question::{self, Config, Question}, - }, + api::question::{self, Config, Question}, }; /// Gets questions configuration (policy, pre-defined answers, etc.). From e92d64a7107feec28bf6acd9d2cc5909d87be14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 14:16:13 +0100 Subject: [PATCH 231/917] Skeleton of the new D-Bus API of storage --- service/lib/agama/dbus/storage/manager.rb | 57 ++++++++--------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 16f41e2660..f729e87d1a 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -193,44 +193,27 @@ def deprecated_system backend.deprecated_system? end - # FIXME: Revisit return values. - # * Methods like #SetConfig or #ResetConfig return whether the proposal successes, but - # they should return whether the config was actually applied. - # * Methods like #Probe or #Install return nothing. dbus_interface STORAGE_INTERFACE do - dbus_signal :Configured, "client_id:s" - dbus_method(:Probe, "in data:a{sv}") do |data| - busy_request(data) { probe } - end - dbus_method(:Reprobe, "in data:a{sv}") do |data| - busy_request(data) { probe(keep_config: true) } - end - dbus_method(:Reactivate, "in data:a{sv}") do |data| - busy_request(data) { probe(keep_config: true, keep_activation: false) } - end - dbus_method( - :SetConfig, - "in serialized_config:s, in data:a{sv}, out result:u" - ) do |serialized_config, data| - busy_request(data) { apply_config(serialized_config) } - end - dbus_method(:ResetConfig, "in data:a{sv}, out result:u") do |data| - busy_request(data) { reset_config } - end - dbus_method( - :SetConfigModel, - "in serialized_model:s, in data:a{sv}, out result:u" - ) do |serialized_model, data| - busy_request(data) { apply_config_model(serialized_model) } - end - dbus_method(:GetConfig, "out serialized_config:s") { recover_config } - dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } - dbus_method(:SolveConfigModel, "in sparse_model:s, out solved_model:s") do |sparse_model| - solve_model(sparse_model) - end - dbus_method(:Install) { install } - dbus_method(:Finish) { finish } - dbus_reader(:deprecated_system, "b") + dbus_method(:Activate) {} + dbus_method(:Probe) {} + dbus_method(:Install) {} + dbus_method(:Finish) {} + dbus_method(:SetLocale, "in locale:s") {} + dbus_method(:SetProduct, "in product_config:s") {} + dbus_method(:GetSystem, "out system:s") {} + dbus_method(:GetConfig, "out config:s") {} + dbus_method(:SetConfig, "in config:s") {} + dbus_method(:GetConfigModel, "out config_model:s") {} + dbus_method(:SetConfigModel, "in config_model:s") {} + dbus_method(:SolveConfigModel, "in config_model:s, out solved_config_model:s") {} + dbus_method(:GetProposal, "out proposal:s") {} + dbus_method(:GetIssues, "out issues:s") {} + dbus_method(:GetProgress, "out progress:s") {} + dbus_signal(:SystemChanged) + dbus_signal(:ConfigChanged) + dbus_signal(:ProposalChanged) + dbus_signal(:IssuesChanged) + dbus_signal(:ProgressChanged) end BOOTLOADER_INTERFACE = "org.opensuse.Agama.Storage1.Bootloader" From 7f36d4ae7669764e71003fd764969cd71bdaf374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 21 Oct 2025 16:19:42 +0100 Subject: [PATCH 232/917] Implement some methods of the new D-Bus API --- service/lib/agama/dbus/clients/storage.rb | 16 +-- service/lib/agama/dbus/storage/manager.rb | 143 +++++++++++----------- service/lib/agama/dbus/storage_service.rb | 1 + service/lib/agama/manager.rb | 20 ++- service/lib/agama/storage/manager.rb | 72 +++++------ 5 files changed, 122 insertions(+), 130 deletions(-) diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index 66b00ca95d..6458da22f2 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -43,22 +43,18 @@ def service_name @service_name ||= "org.opensuse.Agama.Storage1" end + def product=(id) + dbus_object.SetProduct(id) + end + # Starts the probing process # # If a block is given, the method returns immediately and the probing is performed in an # asynchronous way. # - # @param data [Hash] Extra data provided to the D-Bus call. # @param done [Proc] Block to execute once the probing is done - def probe(data = {}, &done) - dbus_object[STORAGE_IFACE].Probe(data, &done) - end - - # Reprobes (keeps the current settings). - # - # @param data [Hash] Extra data provided to the D-Bus call. - def reprobe(data = {}) - dbus_object.Reprobe(data) + def probe(&done) + dbus_object[STORAGE_IFACE].Probe(&done) end # Performs the packages installation diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index f729e87d1a..14a79b2dfa 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -93,18 +93,53 @@ def issues STORAGE_INTERFACE = "org.opensuse.Agama.Storage1" private_constant :STORAGE_INTERFACE - # @param keep_config [Boolean] Whether to use the current storage config for calculating - # the proposal. - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided - # LUKS passwords). - def probe(keep_config: false, keep_activation: true) - busy_while do - # Clean trees in advance to avoid having old objects exported in D-Bus. - system_devices_tree.clean - staging_devices_tree.clean - - backend.probe(keep_config: keep_config, keep_activation: keep_activation) - end + # Whether the system is in a deprecated status + # + # @return [Boolean] + def deprecated_system + backend.deprecated_system? + end + + # TODO: add progress + def activate + backend.reset_activation if backend.activated? + backend.activate + backend.probe + config_json = proposal.storage_json + backend.configure(config_json) if config_json + end + + # TODO: add progress + def probe + backend.activate unless backend.activated? + backend.probe + config_json = proposal.storage_json + backend.configure(config_json) if config_json + end + + def configure_product(id) + backend.product_config.pick_product(id) + backend.activate unless backend.activated? + backend.probe unless backend.probed? + backend.configure + end + + # TODO: add progress + def install + backend.install + end + + # TODO: add progress + def finish + backend.finish + end + + # Gets and serializes the storage config used for calculating the current proposal. + # + # @return [String] + def recover_config + json = proposal.storage_json + JSON.pretty_generate(json) end # @todo Drop support for the guided settings. @@ -121,7 +156,15 @@ def probe(keep_config: false, keep_activation: true) def apply_config(serialized_config) logger.info("Setting storage config from D-Bus: #{serialized_config}") config_json = JSON.parse(serialized_config, symbolize_names: true) - configure(config_json) + backend.configure(config_json) + end + + # Gets and serializes the storage config model. + # + # @return [String] + def recover_config_model + json = proposal.model_json + JSON.pretty_generate(json) end # Applies the given serialized config model according to the JSON schema. @@ -139,38 +182,14 @@ def apply_config_model(serialized_model) ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } - configure(config_json) - end - - # Resets to the default config. - # - # @return [Integer] 0 success; 1 error - def reset_config - logger.info("Reset storage config from D-Bus") - configure - end - - # Gets and serializes the storage config used for calculating the current proposal. - # - # @return [String] - def recover_config - json = proposal.storage_json - JSON.pretty_generate(json) - end - - # Gets and serializes the storage config model. - # - # @return [String] - def recover_model - json = proposal.model_json - JSON.pretty_generate(json) + backend.configure(config_json) end # Solves the given serialized config model. # # @param serialized_model [String] Serialized storage config model. # @return [String] Serialized solved model. - def solve_model(serialized_model) + def solve_config_model(serialized_model) logger.info("Solving storage config model from D-Bus: #{serialized_model}") model_json = JSON.parse(serialized_model, symbolize_names: true) @@ -178,34 +197,20 @@ def solve_model(serialized_model) JSON.pretty_generate(solved_model_json) end - def install - busy_while { backend.install } - end - - def finish - busy_while { backend.finish } - end - - # Whether the system is in a deprecated status - # - # @return [Boolean] - def deprecated_system - backend.deprecated_system? - end - dbus_interface STORAGE_INTERFACE do - dbus_method(:Activate) {} - dbus_method(:Probe) {} - dbus_method(:Install) {} - dbus_method(:Finish) {} + dbus_method(:Activate) { activate } + dbus_method(:Probe) { probe } + dbus_method(:Install) { install } + dbus_method(:Finish) { finish } dbus_method(:SetLocale, "in locale:s") {} - dbus_method(:SetProduct, "in product_config:s") {} + # TODO: receive a product_config instead of an id. + dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } dbus_method(:GetSystem, "out system:s") {} - dbus_method(:GetConfig, "out config:s") {} - dbus_method(:SetConfig, "in config:s") {} - dbus_method(:GetConfigModel, "out config_model:s") {} - dbus_method(:SetConfigModel, "in config_model:s") {} - dbus_method(:SolveConfigModel, "in config_model:s, out solved_config_model:s") {} + dbus_method(:GetConfig, "out config:s") { recover_config } + dbus_method(:SetConfig, "in config:s") { |c| apply_config(c) } + dbus_method(:GetConfigModel, "out model:s") { recover_config_model } + dbus_method(:SetConfigModel, "in model:s") { |m| apply_config_model(m)} + dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") {} dbus_method(:GetIssues, "out issues:s") {} dbus_method(:GetProgress, "out progress:s") {} @@ -469,16 +474,6 @@ def iscsi_delete(path) # @return [DBus::Storage::Proposal, nil] attr_reader :dbus_proposal - # Configures storage. - # - # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then - # the default config is applied. - # @return [Integer] 0 success; 1 error - def configure(config_json = nil) - success = backend.configure(config_json) - success ? 0 : 1 - end - def send_configured_signal self.Configured(request_data["client_id"].to_s) end diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index 1d49850031..5bf37c9aaf 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -56,6 +56,7 @@ def start # Inhibits various storage subsystem (udisk, systemd mounts, raid auto-assembly) that # interfere with the operation of yast-storage-ng and libstorage-ng. Y2Storage::Inhibitors.new.inhibit + manager.setup export end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 711afcaebb..2fd9a3aac4 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -93,10 +93,10 @@ def startup_phase # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. # @param data [Hash] Extra data provided to the D-Bus calls. - def config_phase(reprobe: false, data: {}) + def config_phase(reprobe: false, _data: {}) installation_phase.config start_progress_with_descriptions(_("Analyze disks"), _("Configure software")) - progress.step { reprobe ? storage.reprobe(data) : storage.probe(data) } + progress.step { configure_storage(reprobe) } progress.step { software.probe } logger.info("Config phase done") @@ -294,6 +294,22 @@ def iguana? # Finish shutdown option for each finish method SHUTDOWN_OPT = { REBOOT => "-r", HALT => "-H", POWEROFF => "-P" }.freeze + # Configures storage. + # + # Storage is configured as part of the config phase. The config phase is executed after + # selecting or registering a product. + # + # @param reprobe [Boolean] is used to keep the current storage config after registering a + # product, see https://github.com/agama-project/agama/pull/2532. + def configure_storage(reprobe) + # Note that probing storage is not needed after the product registration, but let's keep the + # current behavior. + return storage.probe if reprobe + + # Select the product + storage.product = software.selected_product + end + # @param method [String, nil] # @return [String] the cmd to be run for finishing the installation def finish_cmd(method) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 0b74abd623..1fbf78ad6c 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -122,37 +122,41 @@ def on_configure(&block) @on_configure_callbacks << block end - # Probes storage devices and performs an initial proposal - # - # @param keep_config [Boolean] Whether to use the current storage config for calculating the - # proposal. - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS - # passwords). - def probe(keep_config: false, keep_activation: true) - start_progress_with_descriptions( - _("Activating storage devices"), - _("Probing storage devices"), - _("Calculating the storage proposal") - ) - - product_config.pick_product(software.selected_product) + # TODO: move to storage_service + def setup # Underlying yast-storage-ng has own mechanism for proposing boot strategies. # However, we don't always want to use BLS when it proposes so. Currently # we want to use BLS only for Tumbleweed / Slowroll prohibit_bls_boot if !product_config.boot_strategy&.casecmp("BLS") check_multipath + end - progress.step { activate_devices(keep_activation: keep_activation) } - progress.step { probe_devices } - progress.step do - config_json = proposal.storage_json if keep_config - configure(config_json) - end + def activated? + !!@activated + end - # The system is not deprecated anymore - self.deprecated_system = false - update_issues - @on_probe_callbacks&.each(&:call) + def reset_activation + Y2Storage::Luks.reset_activation_infos + @activated = false + end + + # Activates the devices. + def activate + iscsi.activate + callbacks = Callbacks::Activate.new(questions_client, logger) + Y2Storage::StorageManager.instance.activate(callbacks) + @activated = true + end + + def probed? + Y2Storage::StorageManager.instance.probed? + end + + # Probes the devices. + def probe + iscsi.probe + callbacks = Y2Storage::Callbacks::UserProbe.new + Y2Storage::StorageManager.instance.probe(callbacks) end # Prepares the partitioning to install the system @@ -258,26 +262,6 @@ def register_progress_callbacks on_progress_change { logger.info(progress.to_s) } end - # Activates the devices, calling activation callbacks if needed - # - # @param keep_activation [Boolean] Whether to keep the current activation (e.g., provided LUKS - # passwords). - def activate_devices(keep_activation: true) - Y2Storage::Luks.reset_activation_infos unless keep_activation - - callbacks = Callbacks::Activate.new(questions_client, logger) - iscsi.activate - Y2Storage::StorageManager.instance.activate(callbacks) - end - - # Probes the devices - def probe_devices - callbacks = Y2Storage::Callbacks::UserProbe.new - - iscsi.probe - Y2Storage::StorageManager.instance.probe(callbacks) - end - # Adds the required packages to the list of resolvables to install def add_packages packages = devicegraph.used_features.pkg_list From 781c961565744a15ac2bf88d9bc694f13c7cd975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Oct 2025 12:57:37 +0100 Subject: [PATCH 233/917] Rename progress --- service/lib/agama/{progress.rb => old_progress.rb} | 4 ++-- service/lib/agama/progress_manager.rb | 4 ++-- service/test/agama/dbus/interfaces/progress_test.rb | 1 - service/test/agama/network_test.rb | 1 - .../test/agama/{progress_test.rb => old_progress_test.rb} | 6 +++--- service/test/agama/progress_manager_test.rb | 4 ++-- service/test/agama/storage/finisher_test.rb | 2 +- service/test/agama/with_progress_examples.rb | 2 +- 8 files changed, 11 insertions(+), 13 deletions(-) rename service/lib/agama/{progress.rb => old_progress.rb} (99%) rename service/test/agama/{progress_test.rb => old_progress_test.rb} (98%) diff --git a/service/lib/agama/progress.rb b/service/lib/agama/old_progress.rb similarity index 99% rename from service/lib/agama/progress.rb rename to service/lib/agama/old_progress.rb index 797ecda71b..d918940e75 100644 --- a/service/lib/agama/progress.rb +++ b/service/lib/agama/old_progress.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2025] SUSE LLC # # All Rights Reserved. # @@ -59,7 +59,7 @@ module Agama # progress.current_step.description #=> "Partitioning" # progress.step("Installing packages") { installing } # overwrite the description # progress.current_step.description # "Installing packages" - class Progress + class OldProgress # Step of the progress class Step # Id of the step diff --git a/service/lib/agama/progress_manager.rb b/service/lib/agama/progress_manager.rb index 85d5b2f401..4500bae1a9 100644 --- a/service/lib/agama/progress_manager.rb +++ b/service/lib/agama/progress_manager.rb @@ -19,7 +19,7 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/progress" +require "agama/old_progress" module Agama # There is an unfinished progress. @@ -86,7 +86,7 @@ def on_finish(&block) def start_progress(args) raise NotFinishedProgress if progress && !progress.finished? - @progress = Progress.new(**args).tap do |progress| + @progress = OldProgress.new(**args).tap do |progress| progress.on_change { on_change_callbacks.each(&:call) } progress.on_finish { on_finish_callbacks.each(&:call) } end diff --git a/service/test/agama/dbus/interfaces/progress_test.rb b/service/test/agama/dbus/interfaces/progress_test.rb index e6dd6b1bd1..dbb740d9d6 100644 --- a/service/test/agama/dbus/interfaces/progress_test.rb +++ b/service/test/agama/dbus/interfaces/progress_test.rb @@ -24,7 +24,6 @@ require "agama/dbus/interfaces/progress" require "agama/dbus/with_progress" require "agama/with_progress" -require "agama/progress" class DBusObjectWithProgressInterface < Agama::DBus::BaseObject include Agama::DBus::WithProgress diff --git a/service/test/agama/network_test.rb b/service/test/agama/network_test.rb index 89df015473..20d944cbbf 100644 --- a/service/test/agama/network_test.rb +++ b/service/test/agama/network_test.rb @@ -22,7 +22,6 @@ require_relative "../test_helper" require "tmpdir" require "agama/network" -require "agama/progress" describe Agama::Network do subject(:network) { described_class.new(logger) } diff --git a/service/test/agama/progress_test.rb b/service/test/agama/old_progress_test.rb similarity index 98% rename from service/test/agama/progress_test.rb rename to service/test/agama/old_progress_test.rb index 4688cbd86f..592f37556a 100644 --- a/service/test/agama/progress_test.rb +++ b/service/test/agama/old_progress_test.rb @@ -20,9 +20,9 @@ # find current contact information at www.suse.com. require_relative "../test_helper" -require "agama/progress" +require "agama/old_progress" -describe Agama::Progress do +describe Agama::OldProgress do subject { described_class.with_size(steps) } describe "when the steps are known in advance" do @@ -63,7 +63,7 @@ it "returns an step with the id and description of the current step" do step = subject.current_step - expect(step).to be_a(Agama::Progress::Step) + expect(step).to be_a(Agama::OldProgress::Step) expect(step.id).to eq(2) expect(step.description).to match(/step 2/) end diff --git a/service/test/agama/progress_manager_test.rb b/service/test/agama/progress_manager_test.rb index 008b3c75f4..c44a6848c1 100644 --- a/service/test/agama/progress_manager_test.rb +++ b/service/test/agama/progress_manager_test.rb @@ -20,7 +20,7 @@ # find current contact information at www.suse.com. require_relative "../test_helper" -require "agama/progress" +require "agama/old_progress" require "agama/progress_manager" shared_examples "unfinished progress" do |action| @@ -72,7 +72,7 @@ end it "returns the progress object" do - expect(subject.progress).to be_a(Agama::Progress) + expect(subject.progress).to be_a(Agama::OldProgress) end end end diff --git a/service/test/agama/storage/finisher_test.rb b/service/test/agama/storage/finisher_test.rb index 5d2c2baa81..4c2e3ed3a5 100644 --- a/service/test/agama/storage/finisher_test.rb +++ b/service/test/agama/storage/finisher_test.rb @@ -41,7 +41,7 @@ let(:config) { Agama::Config.from_file(config_path) } let(:security) { instance_double(Agama::Security, write: nil) } let(:copy_files) { Agama::Storage::Finisher::CopyFilesStep.new(logger) } - let(:progress) { instance_double(Agama::Progress, step: nil) } + let(:progress) { instance_double(Agama::OldProgress, step: nil) } describe "#run" do before do diff --git a/service/test/agama/with_progress_examples.rb b/service/test/agama/with_progress_examples.rb index 9e8876548c..3e7b9e1763 100644 --- a/service/test/agama/with_progress_examples.rb +++ b/service/test/agama/with_progress_examples.rb @@ -35,7 +35,7 @@ end it "returns the progress object" do - expect(subject.progress).to be_a(Agama::Progress) + expect(subject.progress).to be_a(Agama::OldProgress) end end end From d890b6f3c467991470f8000395fb0314918cdac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Oct 2025 14:18:34 +0100 Subject: [PATCH 234/917] Rename mixin --- service/lib/agama/manager.rb | 4 ++-- service/lib/agama/software/manager.rb | 4 ++-- service/lib/agama/storage/finisher.rb | 4 ++-- service/lib/agama/storage/iscsi/manager.rb | 4 ++-- service/lib/agama/storage/manager.rb | 2 +- .../lib/agama/{with_progress.rb => with_progress_manager.rb} | 2 +- service/test/agama/dbus/interfaces/progress_test.rb | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) rename service/lib/agama/{with_progress.rb => with_progress_manager.rb} (98%) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 2fd9a3aac4..8502152d08 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -26,7 +26,7 @@ require "agama/network" require "agama/proxy_setup" require "agama/with_locale" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/installation_phase" require "agama/service_status_recorder" require "agama/dbus/service_status" @@ -46,7 +46,7 @@ module Agama # {Agama::Network}, {Agama::Storage::Proposal}, etc.) or asks # other services via D-Bus (e.g., `org.opensuse.Agama.Software1`). class Manager - include WithProgress + include WithProgressManager include WithLocale include Helpers include Yast::I18n diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index 9ff399e93e..f75903dfe6 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -37,7 +37,7 @@ require "agama/software/proposal" require "agama/software/repositories_manager" require "agama/with_locale" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/with_issues" Yast.import "Installation" @@ -64,7 +64,7 @@ class Manager # rubocop:disable Metrics/ClassLength include Helpers include WithLocale include WithIssues - include WithProgress + include WithProgressManager include Yast::I18n GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" diff --git a/service/lib/agama/storage/finisher.rb b/service/lib/agama/storage/finisher.rb index 7e6c12f31a..31ecf18bd1 100644 --- a/service/lib/agama/storage/finisher.rb +++ b/service/lib/agama/storage/finisher.rb @@ -26,7 +26,7 @@ require "yast2/fs_snapshot" require "bootloader/finish_client" require "y2storage/storage_manager" -require "agama/with_progress" +require "agama/with_progress_manager" require "agama/helpers" require "agama/http" require "agama/network" @@ -40,7 +40,7 @@ module Agama module Storage # Auxiliary class to handle the last storage-related steps of the installation class Finisher - include WithProgress + include WithProgressManager include Helpers # Constructor diff --git a/service/lib/agama/storage/iscsi/manager.rb b/service/lib/agama/storage/iscsi/manager.rb index 7987a1d593..616127def3 100644 --- a/service/lib/agama/storage/iscsi/manager.rb +++ b/service/lib/agama/storage/iscsi/manager.rb @@ -24,7 +24,7 @@ require "agama/storage/iscsi/config_importer" require "agama/storage/iscsi/node" require "agama/with_issues" -require "agama/with_progress" +require "agama/with_progress_manager" require "yast/i18n" module Agama @@ -33,7 +33,7 @@ module ISCSI # Manager for iSCSI. class Manager include WithIssues - include WithProgress + include WithProgressManager include Yast::I18n STARTUP_OPTIONS = ["onboot", "manual", "automatic"].freeze diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 1fbf78ad6c..ba0915bffc 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -49,7 +49,7 @@ module Storage class Manager include WithLocale include WithIssues - include WithProgress + include WithProgressManager include Yast::I18n # @return [Agama::Config] diff --git a/service/lib/agama/with_progress.rb b/service/lib/agama/with_progress_manager.rb similarity index 98% rename from service/lib/agama/with_progress.rb rename to service/lib/agama/with_progress_manager.rb index cdf7a478a7..9c096524ce 100644 --- a/service/lib/agama/with_progress.rb +++ b/service/lib/agama/with_progress_manager.rb @@ -23,7 +23,7 @@ module Agama # Mixin that allows to start a progress and configure callbacks - module WithProgress + module WithProgressManager # @return [ProgressManager] def progress_manager @progress_manager ||= Agama::ProgressManager.new diff --git a/service/test/agama/dbus/interfaces/progress_test.rb b/service/test/agama/dbus/interfaces/progress_test.rb index dbb740d9d6..1c8047ece8 100644 --- a/service/test/agama/dbus/interfaces/progress_test.rb +++ b/service/test/agama/dbus/interfaces/progress_test.rb @@ -23,7 +23,7 @@ require "agama/dbus/base_object" require "agama/dbus/interfaces/progress" require "agama/dbus/with_progress" -require "agama/with_progress" +require "agama/with_progress_manager" class DBusObjectWithProgressInterface < Agama::DBus::BaseObject include Agama::DBus::WithProgress @@ -38,7 +38,7 @@ def backend end class Backend - include Agama::WithProgress + include Agama::WithProgressManager end end From 8df818763c0f1b9e60a8076abbfac02f6531febe Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 23 Oct 2025 11:44:48 +0200 Subject: [PATCH 235/917] non working attempt to make async set config --- rust/agama-software/src/event.rs | 40 +++++++++++ rust/agama-software/src/lib.rs | 3 + rust/agama-software/src/model.rs | 71 +++++++++---------- rust/agama-software/src/model/conflict.rs | 41 ----------- .../src/model/software_selection.rs | 5 +- rust/agama-software/src/service.rs | 69 +++++++++++++++--- rust/agama-software/src/start.rs | 2 +- rust/agama-software/src/zypp_server.rs | 6 +- 8 files changed, 143 insertions(+), 94 deletions(-) create mode 100644 rust/agama-software/src/event.rs diff --git a/rust/agama-software/src/event.rs b/rust/agama-software/src/event.rs new file mode 100644 index 0000000000..7b750454b6 --- /dev/null +++ b/rust/agama-software/src/event.rs @@ -0,0 +1,40 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// Localization-related events. +// FIXME: is it really needed to implement Deserialize? +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "name")] +pub enum Event { + /// Proposal changed. + ProposalChanged, + /// The underlying system changed. + SystemChanged, + /// The use configuration changed. + ConfigChanged, +} + +/// Multi-producer single-consumer events sender. +pub type Sender = mpsc::UnboundedSender; +/// Multi-producer single-consumer events receiver. +pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index eda61b3a39..1c4e1428a7 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -44,6 +44,9 @@ pub use service::Service; mod model; pub use model::{Model, ModelAdapter}; +mod event; +pub use event::Event; + mod system_info; pub use system_info::SystemInfo; diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index fab5e22115..08a3dc8d0c 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -23,7 +23,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ - packages::{Repository, ResolvableType}, + packages::{Repository, RepositoryParams, ResolvableType}, pattern::Pattern, products::{ProductSpec, UserPattern}, registration::{AddonProperties, RegistrationInfo}, @@ -55,6 +55,9 @@ pub trait ModelAdapter: Send + Sync + 'static { /// List of available repositories. async fn repositories(&self) -> Result, service::Error>; + /// Adds given list of repositories and loads them + async fn add_repositories(&self, list: Vec) -> Result<(), service::Error>; + /// List of available addons. fn addons(&self) -> Result, service::Error>; @@ -75,7 +78,7 @@ pub trait ModelAdapter: Send + Sync + 'static { &mut self, id: &str, r#type: ResolvableType, - resolvables: &[&str], + resolvables: Vec, optional: bool, ) -> Result<(), service::Error>; @@ -86,7 +89,7 @@ pub trait ModelAdapter: Send + Sync + 'static { async fn install(&self) -> Result; /// Finalizes system like disabling local repositories - fn finish(&self) -> Result<(), service::Error>; + async fn finish(&self) -> Result<(), service::Error>; } /// [ModelAdapter] implementation for libzypp systems. @@ -155,7 +158,7 @@ impl ModelAdapter for Model { &mut self, id: &str, r#type: ResolvableType, - resolvables: &[&str], + resolvables: Vec, optional: bool, ) -> Result<(), service::Error> { self.software_selection @@ -164,14 +167,26 @@ impl ModelAdapter for Model { Ok(()) } + async fn add_repositories(&self, list: Vec) -> Result<(), service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::AddRepositories(list, tx))?; + Ok(rx.await??) + } + async fn probe(&mut self, product: &ProductSpec) -> Result<(), service::Error> { let (tx, rx) = oneshot::channel(); + // TODO: create own repository registry that will hold all sources of repositories + // like manual ones, product ones or ones from kernel cmdline let repositories = product .software .repositories() .into_iter() - .map(|r| r.clone()) - .collect(); + .enumerate() + .map(|(i, r)| + // we need to get somehow better names and aliases + RepositoryParams { alias: format!("{}_{}", product.id, i), name: Some(format!("{} {}", product.name, i)), url: r.url.clone(), product_dir: None, enabled: Some(true), priority: None, allow_unsigned: Some(false), gpg_fingerprints: None } + ).collect(); self.zypp_sender .send(SoftwareAction::AddRepositories(repositories, tx))?; rx.await??; @@ -183,79 +198,61 @@ impl ModelAdapter for Model { installer_id, ResolvableType::Product, false, - &[product.id.as_str()], + vec![product.id.clone()], ) .await?; - let resolvables: Vec<_> = product - .software - .mandatory_patterns - .iter() - .map(String::as_str) - .collect(); + let resolvables: Vec<_> = product.software.mandatory_patterns.clone(); self.software_selection .set( &self.zypp_sender, installer_id, ResolvableType::Pattern, false, - &resolvables, + resolvables, ) .await?; - let resolvables: Vec<_> = product - .software - .mandatory_packages - .iter() - .map(String::as_str) - .collect(); + let resolvables: Vec<_> = product.software.mandatory_packages.clone(); self.software_selection .set( &self.zypp_sender, installer_id, ResolvableType::Package, false, - &resolvables, + resolvables, ) .await?; - let resolvables: Vec<_> = product - .software - .optional_patterns - .iter() - .map(String::as_str) - .collect(); + let resolvables: Vec<_> = product.software.optional_patterns.clone(); self.software_selection .set( &self.zypp_sender, installer_id, ResolvableType::Pattern, true, - &resolvables, + resolvables, ) .await?; - let resolvables: Vec<_> = product - .software - .optional_packages - .iter() - .map(String::as_str) - .collect(); + let resolvables: Vec<_> = product.software.optional_packages.clone(); self.software_selection .set( &self.zypp_sender, installer_id, ResolvableType::Package, true, - &resolvables, + resolvables, ) .await?; Ok(()) } - fn finish(&self) -> Result<(), service::Error> { - todo!() + async fn finish(&self) -> Result<(), service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Finish(tx))?; + Ok(rx.await??) } async fn install(&self) -> Result { diff --git a/rust/agama-software/src/model/conflict.rs b/rust/agama-software/src/model/conflict.rs index 527fea41a7..05c3392051 100644 --- a/rust/agama-software/src/model/conflict.rs +++ b/rust/agama-software/src/model/conflict.rs @@ -61,44 +61,3 @@ pub struct Conflict { /// list of possible solutions pub solutions: Vec, } - -impl Solution { - pub fn from_dbus(dbus_solution: (u32, String, String)) -> Self { - let details = dbus_solution.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - id: dbus_solution.0, - description: dbus_solution.1, - details, - } - } -} - -impl Conflict { - pub fn from_dbus(dbus_conflict: (u32, String, String, Vec<(u32, String, String)>)) -> Self { - let details = dbus_conflict.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - let solutions = dbus_conflict.3; - let solutions = solutions - .into_iter() - .map(|s| Solution::from_dbus(s)) - .collect(); - - Self { - id: dbus_conflict.0, - description: dbus_conflict.1, - details, - solutions, - } - } -} diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs index 08a7e7f3c4..2913a2a1b1 100644 --- a/rust/agama-software/src/model/software_selection.rs +++ b/rust/agama-software/src/model/software_selection.rs @@ -52,7 +52,7 @@ impl SoftwareSelection { id: &str, r#type: ResolvableType, optional: bool, - resolvables: &[&str], + resolvables: Vec, ) -> Result<(), service::Error> { let list = self.find_or_create_selection(id, r#type, optional); // FIXME: use reference counting here, if multiple ids require some package, to not unselect it @@ -65,8 +65,7 @@ impl SoftwareSelection { })?; rx.await??; - let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); - list.resolvables = new_resolvables; + list.resolvables = resolvables; let (tx, rx) = oneshot::channel(); zypp.send(SoftwareAction::UnsetResolvables { tx, diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 01ebca91e9..73a379403e 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -18,28 +18,34 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::sync::{Arc, Mutex, RwLock}; + use crate::{ config::Config, - message, + event, + message::{self, Probe}, model::{ license::{Error as LicenseError, LicensesRepo}, + packages::ResolvableType, products::{ProductsRegistry, ProductsRegistryError}, ModelAdapter, }, proposal::Proposal, system_info::SystemInfo, zypp_server::{self, SoftwareAction}, + Event, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, issue::{self}, }; use async_trait::async_trait; +use tokio::sync::mpsc::error::SendError; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("software service could not send the event")] - Event, + Event(#[from] SendError), #[error(transparent)] Actor(#[from] actor::Error), #[error("Failed to send message to libzypp thread: {0}")] @@ -73,7 +79,7 @@ pub enum Error { /// * Holds the user configuration. /// * Applies the user configuration at the end of the installation. pub struct Service { - model: Box, + model: Arc>, products: ProductsRegistry, licenses: LicensesRepo, issues: Handler, @@ -94,7 +100,7 @@ impl Service { events: event::Sender, ) -> Service { Self { - model: Box::new(model), + model: Arc::new(Mutex::new(model)), issues, events, licenses: LicensesRepo::default(), @@ -109,7 +115,7 @@ impl Service { Ok(()) } - pub fn update_system(&mut self) -> Result<(), Error> { + fn update_system(&mut self) -> Result<(), Error> { let licenses = self.licenses.licenses().into_iter().cloned().collect(); let products = self.products.products(); @@ -119,6 +125,39 @@ impl Service { ..Default::default() }; + self.events.send(Event::SystemChanged)?; + + Ok(()) + } + + async fn apply_config(&mut self) -> Result<(), Error> { + if let Some(software) = &self.state.config.software { + let user_id = "user"; + let patterns = software.patterns.clone().unwrap_or_default(); + let packages = software.packages.clone().unwrap_or_default(); + let extra_repositories = software.extra_repositories.clone().unwrap_or_default(); + //self.model + // .set_resolvables(user_id, ResolvableType::Pattern, patterns, false) + // .await?; + self.model + .set_resolvables(user_id, ResolvableType::Package, packages, false) + .await?; + // for repositories we should allow also to remove previously defined one, but now for simplicity just check if it there and if not, then add it + // TODO: replace it with future repository registry + let existing_repositories = self.model.repositories().await?; + let new_repos = extra_repositories + .iter() + .filter(|r| { + existing_repositories + .iter() + .find(|repo| repo.alias == r.alias) + .is_none() + }) + .cloned() + .collect(); + self.model.add_repositories(new_repos).await?; + } + Ok(()) } } @@ -144,7 +183,19 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - todo!(); + let need_probe = message.config.product.as_ref().map(|c| c.id.as_ref()) + != self.state.config.product.as_ref().map(|c| c.id.as_ref()); + self.state.config = message.config.clone(); + self.events.send(Event::ConfigChanged)?; + tokio::task::spawn( async move { + // FIXME: convert unwraps to sending issues + if need_probe { + self.handle(Probe).await.unwrap(); + } + self.apply_config().await.unwrap(); + }); + + Ok(()) } } @@ -166,7 +217,7 @@ impl MessageHandler for Service { return Err(Error::WrongProduct(product_id)); }; - self.model.probe(product).await?; + self.model.lock().unwrap().probe(product).await?; self.update_system(); Ok(()) } @@ -175,14 +226,14 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Install) -> Result { - self.model.install().await + self.model.lock().unwrap().install().await } } #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { - self.model.finish()?; + self.model.lock().unwrap().await?; Ok(()) } } diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index 839bafddf7..ea694618ab 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -53,7 +53,7 @@ pub async fn start( ) -> Result, Error> { let zypp_sender = ZyppServer::start()?; let model = Model::new(zypp_sender)?; - let service = Service::new(model, issues, events); + let mut service = Service::new(model, issues, events); service.read()?; let handler = actor::spawn(service); Ok(handler) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 4a9b5fdc1d..ef7170692d 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -26,7 +26,7 @@ use tokio::sync::{ use zypp_agama::ZyppError; use crate::model::{ - packages::{Repository, ResolvableType}, + packages::{Repository, RepositoryParams, ResolvableType}, pattern::Pattern, products::RepositorySpec, }; @@ -80,7 +80,7 @@ pub type ZyppServerResult = Result; #[derive(Debug)] pub enum SoftwareAction { - AddRepositories(Vec, oneshot::Sender>), + AddRepositories(Vec, oneshot::Sender>), RemoveRepositories(Vec, oneshot::Sender>), Install(oneshot::Sender>), Finish(oneshot::Sender>), @@ -282,7 +282,7 @@ impl ZyppServer { async fn add_repositories( &self, - repos: Vec, + repos: Vec, tx: oneshot::Sender>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { From ddd3124f7f7a8afd8bb3624a0a4c4b24c36250d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Oct 2025 15:40:45 +0100 Subject: [PATCH 236/917] Add new progress --- service/lib/agama/progress.rb | 81 ++++++++++++++++++++++++++++++ service/lib/agama/with_progress.rb | 72 ++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 service/lib/agama/progress.rb create mode 100644 service/lib/agama/with_progress.rb diff --git a/service/lib/agama/progress.rb b/service/lib/agama/progress.rb new file mode 100644 index 0000000000..3474168f64 --- /dev/null +++ b/service/lib/agama/progress.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "json" + +module Agama + class Progress + class MissingStep < StandardError; end + + # @return [Integer] + attr_reader :size + + # @return [Array] + attr_reader :steps + + # @return [String, nil] + attr_reader :step + + # @return [Integer] + attr_reader :index + + # @param steps [Array] + # @return [Progress] + def self.new_with_steps(steps) + @size = steps.size + @steps = steps + @step = steps.first + @index = 1 + end + + # @param size [Integer] + # @param step [String] + # @return [Progress] + def initialize(size, step) + @size = size + @steps = [] + @step= step + @index = 1 + end + + def next + raise MissingStep if index == steps.size + + @step = steps.at(@index) + @index += 1 + end + + # @param step [String] + def next_with_step(step) + self.next + @step = step + end + + def to_json(*args) + { + "size" => @size, + "steps" => @steps, + "step" => @step || "", + "index" => @index + }.to_json(*args) + end + end +end diff --git a/service/lib/agama/with_progress.rb b/service/lib/agama/with_progress.rb new file mode 100644 index 0000000000..4863d0b9af --- /dev/null +++ b/service/lib/agama/with_progress.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/progress" + +module Agama + module WithProgress + attr_reader :progress + + def start_progress(size, step) + @progress = Progress.new(size, step) + progress_change + end + + def start_progress_with_steps(steps) + @progress = Progress.new_with_steps(steps) + progress_change + end + + def next_progress_step(step = nil) + return unless @progress + + step ? @progress.next_with_step(step) : @progress.step + progress_change + end + + def finish_progress + return unless @progress + + @progress = nil + progress_finish + end + + def progress_change + @on_progress_change_callbacks.each(&:call) + end + + def progress_finish + @on_progress_finish_callbacks.each(&:call) + end + + # @param block [Proc] + def on_progress_change(&block) + @on_progress_change_callbacks ||= [] + @on_progress_change_callbacks << block + end + + # @param block [Proc] + def on_progress_finish(&block) + @on_progress_finish_callbacks ||= [] + @on_progress_finish_callbacks << block + end + end +end From 1410f795cf9d6432421d046771c44fe44765c8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 22 Oct 2025 15:41:32 +0100 Subject: [PATCH 237/917] Add progress to API methods --- service/lib/agama/dbus/clients/storage.rb | 2 +- service/lib/agama/dbus/manager.rb | 8 +-- service/lib/agama/dbus/storage/manager.rb | 62 ++++++++++++++++----- service/lib/agama/http/clients/questions.rb | 2 +- service/lib/agama/manager.rb | 3 +- service/lib/agama/storage/manager.rb | 8 +++ 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index 6458da22f2..0d041c15a1 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -54,7 +54,7 @@ def product=(id) # # @param done [Proc] Block to execute once the probing is done def probe(&done) - dbus_object[STORAGE_IFACE].Probe(&done) + dbus_object.Probe(&done) end # Performs the packages installation diff --git a/service/lib/agama/dbus/manager.rb b/service/lib/agama/dbus/manager.rb index b6b3c65950..444f8a0afb 100644 --- a/service/lib/agama/dbus/manager.rb +++ b/service/lib/agama/dbus/manager.rb @@ -63,8 +63,8 @@ def initialize(backend, logger) FINISH_PHASE = 3 dbus_interface MANAGER_INTERFACE do - dbus_method(:Probe, "in data:a{sv}") { |data| config_phase(data: data) } - dbus_method(:Reprobe, "in data:a{sv}") { |data| config_phase(reprobe: true, data: data) } + dbus_method(:Probe, "in data:a{sv}") { |_| config_phase } + dbus_method(:Reprobe, "in data:a{sv}") { |_| config_phase(reprobe: true) } dbus_method(:Commit, "") { install_phase } dbus_method(:CanInstall, "out result:b") { can_install? } dbus_method(:CollectLogs, "out tarball_filesystem_path:s") { collect_logs } @@ -79,9 +79,9 @@ def initialize(backend, logger) # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. # @param data [Hash] Extra data provided to the D-Bus calls. - def config_phase(reprobe: false, data: {}) + def config_phase(reprobe: false) safe_run do - busy_while { backend.config_phase(reprobe: reprobe, data: data) } + busy_while { backend.config_phase(reprobe: reprobe) } end end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 14a79b2dfa..052c29729b 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -26,19 +26,18 @@ require "agama/dbus/base_object" require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/locale" -require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" require "agama/dbus/storage/devices_tree" require "agama/dbus/storage/iscsi_nodes_tree" require "agama/dbus/storage/proposal" require "agama/dbus/storage/proposal_settings_conversion" require "agama/dbus/storage/volume_conversion" -require "agama/dbus/with_progress" require "agama/dbus/with_service_status" require "agama/storage/config_conversions" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings" require "agama/storage/volume_templates_builder" +require "agama/with_progress" Yast.import "Arch" @@ -47,12 +46,13 @@ module DBus module Storage # D-Bus object to manage storage installation class Manager < BaseObject # rubocop:disable Metrics/ClassLength + extend Yast::I18n + include WithProgress include WithServiceStatus include ::DBus::ObjectManager include DBus::Interfaces::Issues include DBus::Interfaces::Locale - include DBus::Interfaces::Progress include DBus::Interfaces::ServiceStatus PATH = "/org/opensuse/Agama/Storage1" @@ -64,6 +64,8 @@ class Manager < BaseObject # rubocop:disable Metrics/ClassLength # @param service_status [Agama::DBus::ServiceStatus, nil] # @param logger [Logger, nil] def initialize(backend, service_status: nil, logger: nil) + textdomain "agama" + super(PATH, logger: logger) @backend = backend @service_status = service_status @@ -100,28 +102,49 @@ def deprecated_system backend.deprecated_system? end - # TODO: add progress + # Implementation for the API method #Activate. def activate + start_progress(3, ACTIVATING_STEP) backend.reset_activation if backend.activated? backend.activate + + next_progress_step(PROBING_STEP) backend.probe - config_json = proposal.storage_json - backend.configure(config_json) if config_json + + next_progress_step(CONFIGURING_STEP) + backend.configure_with_current + + finish_progress end - # TODO: add progress + # Implementation for the API method #Probe. def probe + start_progress(3, ACTIVATING_STEP) backend.activate unless backend.activated? + + next_progress_step(PROBING_STEP) backend.probe - config_json = proposal.storage_json - backend.configure(config_json) if config_json + + next_progress_step(CONFIGURING_STEP) + backend.configure_with_current + + finish_progress end + # Implementation for the API method #SetProduct. def configure_product(id) backend.product_config.pick_product(id) + + start_progress(3, ACTIVATING_STEP) backend.activate unless backend.activated? + + next_progress_step(PROBING_STEP) backend.probe unless backend.probed? + + next_progress_step(CONFIGURING_STEP) backend.configure + + finish_progress end # TODO: add progress @@ -213,12 +236,13 @@ def solve_config_model(serialized_model) dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") {} dbus_method(:GetIssues, "out issues:s") {} - dbus_method(:GetProgress, "out progress:s") {} + dbus_method(:GetProgress, "out progress:s") { progress.to_json } dbus_signal(:SystemChanged) dbus_signal(:ConfigChanged) dbus_signal(:ProposalChanged) dbus_signal(:IssuesChanged) - dbus_signal(:ProgressChanged) + dbus_signal(:ProgressChanged, "progress:s") + dbus_signal(:ProgressFinished) end BOOTLOADER_INTERFACE = "org.opensuse.Agama.Storage1.Bootloader" @@ -468,14 +492,25 @@ def iscsi_delete(path) private + ACTIVATING_STEP = N_("Activating storage devices") + private_constant :ACTIVATING_STEP + + PROBING_STEP = N_("Probing storage devices") + private_constant :PROBING_STEP + + CONFIGURING_STEP = N_("Applying storage configuration") + private_constant :CONFIGURING_STEP + # @return [Agama::Storage::Manager] attr_reader :backend # @return [DBus::Storage::Proposal, nil] attr_reader :dbus_proposal - def send_configured_signal - self.Configured(request_data["client_id"].to_s) + + def register_progress_callbacks + on_progress_change { self.ProgressChanged(progress.to_json) } + on_progress_finish { self.ProgressFinished } end def add_s390_interfaces @@ -503,7 +538,6 @@ def register_storage_callbacks proposal_properties_changed refresh_staging_devices update_actions - send_configured_signal end end diff --git a/service/lib/agama/http/clients/questions.rb b/service/lib/agama/http/clients/questions.rb index ff014d0439..d8c087c0c2 100644 --- a/service/lib/agama/http/clients/questions.rb +++ b/service/lib/agama/http/clients/questions.rb @@ -89,7 +89,7 @@ def ask(question) answer = wait_answer(added_question.id) - @logger.info("#{added_question.text} #{answer}") + @logger.info("#{added_question.text} #{answer.inspect}") result = block_given? ? yield(answer) : answer delete(added_question.id) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 8502152d08..a99ff8035a 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -92,8 +92,7 @@ def startup_phase # Runs the config phase # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. - # @param data [Hash] Extra data provided to the D-Bus calls. - def config_phase(reprobe: false, _data: {}) + def config_phase(reprobe: false) installation_phase.config start_progress_with_descriptions(_("Analyze disks"), _("Configure software")) progress.step { configure_storage(reprobe) } diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index ba0915bffc..81b9bf6e84 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -195,6 +195,14 @@ def configure(config_json = nil) result end + # Configures storage using the current config. + # + # @note The proposal is not calculated if there is not a config yet. + def configure_with_current + config_json = proposal.storage_json + configure(config_json) if config_json + end + # Storage proposal manager # # @return [Storage::Proposal] From 15016eab7a0178e0b88a77c6f3d6f8b38a678294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 23 Oct 2025 15:32:18 +0100 Subject: [PATCH 238/917] Implement Install and Finish API methods --- service/lib/agama/dbus/storage/manager.rb | 45 +++++++++----- service/lib/agama/storage/bootloader.rb | 21 ++++++- service/lib/agama/storage/manager.rb | 75 ++++++++--------------- 3 files changed, 72 insertions(+), 69 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 052c29729b..e880a15e5a 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -58,8 +58,6 @@ class Manager < BaseObject # rubocop:disable Metrics/ClassLength PATH = "/org/opensuse/Agama/Storage1" private_constant :PATH - # Constructor - # # @param backend [Agama::Storage::Manager] # @param service_status [Agama::DBus::ServiceStatus, nil] # @param logger [Logger, nil] @@ -147,14 +145,29 @@ def configure_product(id) finish_progress end - # TODO: add progress + # Implementation for the API method #Install. def install + start_progress(4, _("Preparing bootloader proposal")) + backend.bootloader.configure + + next_progress_step(_("Adding storage-related packages")) + backend.add_packages + + next_progress_step(_("Preparing the storage devices")) backend.install + + next_progress_step(_("Writing bootloader sysconfig")) + backend.bootloader.install + + finish_progress end - # TODO: add progress + # Implementation for the API method #Finish. def finish + start_progress(1, _("Finishing installation")) backend.finish + + finish_progress end # Gets and serializes the storage config used for calculating the current proposal. @@ -165,6 +178,14 @@ def recover_config JSON.pretty_generate(json) end + # Gets and serializes the storage config model. + # + # @return [String] + def recover_config_model + json = proposal.model_json + JSON.pretty_generate(json) + end + # @todo Drop support for the guided settings. # # Applies the given serialized config according to the JSON schema. @@ -176,25 +197,17 @@ def recover_config # # @param serialized_config [String] Serialized storage config. # @return [Integer] 0 success; 1 error - def apply_config(serialized_config) + def configure(serialized_config) logger.info("Setting storage config from D-Bus: #{serialized_config}") config_json = JSON.parse(serialized_config, symbolize_names: true) backend.configure(config_json) end - # Gets and serializes the storage config model. - # - # @return [String] - def recover_config_model - json = proposal.model_json - JSON.pretty_generate(json) - end - # Applies the given serialized config model according to the JSON schema. # # @param serialized_model [String] Serialized storage config model. # @return [Integer] 0 success; 1 error - def apply_config_model(serialized_model) + def configure_with_model(serialized_model) logger.info("Setting storage config model from D-Bus: #{serialized_model}") model_json = JSON.parse(serialized_model, symbolize_names: true) @@ -230,9 +243,9 @@ def solve_config_model(serialized_model) dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } dbus_method(:GetSystem, "out system:s") {} dbus_method(:GetConfig, "out config:s") { recover_config } - dbus_method(:SetConfig, "in config:s") { |c| apply_config(c) } + dbus_method(:SetConfig, "in config:s") { |c| configure(c) } dbus_method(:GetConfigModel, "out model:s") { recover_config_model } - dbus_method(:SetConfigModel, "in model:s") { |m| apply_config_model(m)} + dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") {} dbus_method(:GetIssues, "out issues:s") {} diff --git a/service/lib/agama/storage/bootloader.rb b/service/lib/agama/storage/bootloader.rb index 0ac8f364b3..c3beb603f8 100644 --- a/service/lib/agama/storage/bootloader.rb +++ b/service/lib/agama/storage/bootloader.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -22,6 +22,7 @@ require "yast" require "json" require "bootloader/bootloader_factory" +require "bootloader/proposal_client" module Agama module Storage @@ -94,6 +95,22 @@ def initialize(logger) @logger = logger end + # Calculates proposal. + def configure + # first make bootloader proposal to be sure that required packages are installed + proposal = ::Bootloader::ProposalClient.new.make_proposal({}) + # then also apply changes to that proposal + write_config + @logger.debug "Bootloader proposal #{proposal.inspect}" + end + + # Installs bootloader. + def install + Yast::WFM.CallFunction("inst_bootloader", []) + end + + private + def write_config bootloader = ::Bootloader::BootloaderFactory.current write_stop_on_boot(bootloader) if @config.keys_to_export.include?(:stop_on_boot_menu) @@ -105,8 +122,6 @@ def write_config bootloader end - private - def write_extra_kernel_params(bootloader) # no systemd boot support for now return unless bootloader.respond_to?(:grub_default) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 81b9bf6e84..cf67d0d276 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -33,9 +33,7 @@ require "agama/storage/proposal_settings" require "agama/with_issues" require "agama/with_locale" -require "agama/with_progress" require "yast" -require "bootloader/proposal_client" require "y2storage/clients/inst_prepdisk" require "y2storage/luks" require "y2storage/storage_env" @@ -49,8 +47,6 @@ module Storage class Manager include WithLocale include WithIssues - include WithProgressManager - include Yast::I18n # @return [Agama::Config] attr_reader :product_config @@ -63,8 +59,6 @@ class Manager # @param product_config [Agama::Config] # @param logger [Logger, nil] def initialize(product_config, logger: nil) - textdomain "agama" - @product_config = product_config @logger = logger || Logger.new($stdout) @bootloader = Bootloader.new(logger) @@ -159,30 +153,6 @@ def probe Y2Storage::StorageManager.instance.probe(callbacks) end - # Prepares the partitioning to install the system - def install - start_progress_with_size(4) - progress.step(_("Preparing bootloader proposal")) do - # first make bootloader proposal to be sure that required packages are installed - proposal = ::Bootloader::ProposalClient.new.make_proposal({}) - # then also apply changes to that proposal - bootloader.write_config - logger.debug "Bootloader proposal #{proposal.inspect}" - end - progress.step(_("Adding storage-related packages")) { add_packages } - progress.step(_("Preparing the storage devices")) { perform_storage_actions } - progress.step(_("Writing bootloader sysconfig")) do - # call inst bootloader to get properly initialized bootloader - # sysconfig before package installation - Yast::WFM.CallFunction("inst_bootloader", []) - end - end - - # Performs the final steps on the target file system(s) - def finish - Finisher.new(logger, product_config, security).run - end - # Configures storage. # # @param config_json [Hash, nil] Storage config according to the JSON schema. If nil, then @@ -203,6 +173,31 @@ def configure_with_current configure(config_json) if config_json end + # Commits the storage changes. + # + # @return [Boolean] true if the all actions were successful. + def install + callbacks = Callbacks::Commit.new(questions_client, logger: logger) + + client = Y2Storage::Clients::InstPrepdisk.new(commit_callbacks: callbacks) + client.run == :next + end + + # Adds the required packages to the list of resolvables to install. + def add_packages + packages = devicegraph.used_features.pkg_list + packages += ISCSI::Manager::PACKAGES if need_iscsi? + return if packages.empty? + + logger.info "Selecting these packages for installation: #{packages}" + Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :package, packages) + end + + # Performs the final steps on the target file system(s). + def finish + Finisher.new(logger, product_config, security).run + end + # Storage proposal manager # # @return [Storage::Proposal] @@ -270,16 +265,6 @@ def register_progress_callbacks on_progress_change { logger.info(progress.to_s) } end - # Adds the required packages to the list of resolvables to install - def add_packages - packages = devicegraph.used_features.pkg_list - packages += ISCSI::Manager::PACKAGES if need_iscsi? - return if packages.empty? - - logger.info "Selecting these packages for installation: #{packages}" - Yast::PackagesProposal.SetResolvables(PROPOSAL_ID, :package, packages) - end - # Whether iSCSI is needed in the target system. # # @return [Boolean] @@ -294,16 +279,6 @@ def devicegraph Y2Storage::StorageManager.instance.staging end - # Prepares the storage devices for installation - # - # @return [Boolean] true if the all actions were successful - def perform_storage_actions - callbacks = Callbacks::Commit.new(questions_client, logger: logger) - - client = Y2Storage::Clients::InstPrepdisk.new(commit_callbacks: callbacks) - client.run == :next - end - # Recalculates the list of issues def update_issues self.issues = system_issues + proposal.issues From a40cc204f7ed0ed84a9cccd54377a19b2cabcccc Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 24 Oct 2025 10:34:57 +0200 Subject: [PATCH 239/917] fix build set config on background --- rust/agama-software/src/service.rs | 54 +++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 73a379403e..a2247ad375 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -18,12 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::sync::{Arc, Mutex, RwLock}; +use std::{ops::DerefMut, sync::Arc}; use crate::{ config::Config, event, - message::{self, Probe}, + message, model::{ license::{Error as LicenseError, LicensesRepo}, packages::ResolvableType, @@ -40,7 +40,7 @@ use agama_utils::{ issue::{self}, }; use async_trait::async_trait; -use tokio::sync::mpsc::error::SendError; +use tokio::sync::{mpsc::error::SendError, Mutex, RwLock}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -90,7 +90,7 @@ pub struct Service { #[derive(Default)] struct State { config: Config, - system: SystemInfo, + system: Arc>, } impl Service { @@ -115,36 +115,31 @@ impl Service { Ok(()) } - fn update_system(&mut self) -> Result<(), Error> { + async fn update_system(&self) -> Result<(), Error> { let licenses = self.licenses.licenses().into_iter().cloned().collect(); let products = self.products.products(); - self.state.system = SystemInfo { - licenses, - products, - ..Default::default() - }; + let mut system = self.state.system.write().await; + system.licenses = licenses; + system.products = products; self.events.send(Event::SystemChanged)?; Ok(()) } - async fn apply_config(&mut self) -> Result<(), Error> { - if let Some(software) = &self.state.config.software { + async fn apply_config(config: &Config, model: &mut dyn ModelAdapter) -> Result<(), Error> { + if let Some(software) = &config.software { let user_id = "user"; let patterns = software.patterns.clone().unwrap_or_default(); let packages = software.packages.clone().unwrap_or_default(); let extra_repositories = software.extra_repositories.clone().unwrap_or_default(); - //self.model - // .set_resolvables(user_id, ResolvableType::Pattern, patterns, false) - // .await?; - self.model - .set_resolvables(user_id, ResolvableType::Package, packages, false) + // TODO: patterns as it as it can be either set or add/remove set + model.set_resolvables(user_id, ResolvableType::Package, packages, false) .await?; // for repositories we should allow also to remove previously defined one, but now for simplicity just check if it there and if not, then add it // TODO: replace it with future repository registry - let existing_repositories = self.model.repositories().await?; + let existing_repositories = model.repositories().await?; let new_repos = extra_repositories .iter() .filter(|r| { @@ -155,7 +150,7 @@ impl Service { }) .cloned() .collect(); - self.model.add_repositories(new_repos).await?; + model.add_repositories(new_repos).await?; } Ok(()) @@ -169,7 +164,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { - Ok(self.state.system.clone()) + Ok(self.state.system.read().await.clone()) } } @@ -183,16 +178,21 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let need_probe = message.config.product.as_ref().map(|c| c.id.as_ref()) - != self.state.config.product.as_ref().map(|c| c.id.as_ref()); + let new_product = message.config.product.as_ref().and_then(|c| c.id.as_ref()); + let need_probe = new_product + != self.state.config.product.as_ref().and_then(|c| c.id.as_ref()); + let new_product_spec = new_product.and_then(|id| self.products.find(id).and_then(|p| Some(p.clone()))); + self.state.config = message.config.clone(); self.events.send(Event::ConfigChanged)?; + let model = self.model.clone(); tokio::task::spawn( async move { + let mut my_model = model.lock().await; // FIXME: convert unwraps to sending issues if need_probe { - self.handle(Probe).await.unwrap(); + my_model.probe(&new_product_spec.unwrap()).await.unwrap(); } - self.apply_config().await.unwrap(); + Self::apply_config(&message.config, my_model.deref_mut()).await.unwrap(); }); Ok(()) @@ -217,7 +217,7 @@ impl MessageHandler for Service { return Err(Error::WrongProduct(product_id)); }; - self.model.lock().unwrap().probe(product).await?; + self.model.lock().await.probe(product).await?; self.update_system(); Ok(()) } @@ -226,14 +226,14 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Install) -> Result { - self.model.lock().unwrap().install().await + self.model.lock().await.install().await } } #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { - self.model.lock().unwrap().await?; + self.model.lock().await.finish().await?; Ok(()) } } From 41224fffc1491f834492f99438d7c3b7c164094e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 24 Oct 2025 16:40:51 +0200 Subject: [PATCH 240/917] cargo fmt --- rust/agama-software/src/service.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index a2247ad375..dacf267939 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -22,8 +22,7 @@ use std::{ops::DerefMut, sync::Arc}; use crate::{ config::Config, - event, - message, + event, message, model::{ license::{Error as LicenseError, LicensesRepo}, packages::ResolvableType, @@ -135,7 +134,8 @@ impl Service { let packages = software.packages.clone().unwrap_or_default(); let extra_repositories = software.extra_repositories.clone().unwrap_or_default(); // TODO: patterns as it as it can be either set or add/remove set - model.set_resolvables(user_id, ResolvableType::Package, packages, false) + model + .set_resolvables(user_id, ResolvableType::Package, packages, false) .await?; // for repositories we should allow also to remove previously defined one, but now for simplicity just check if it there and if not, then add it // TODO: replace it with future repository registry @@ -180,21 +180,29 @@ impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { let new_product = message.config.product.as_ref().and_then(|c| c.id.as_ref()); let need_probe = new_product - != self.state.config.product.as_ref().and_then(|c| c.id.as_ref()); - let new_product_spec = new_product.and_then(|id| self.products.find(id).and_then(|p| Some(p.clone()))); + != self + .state + .config + .product + .as_ref() + .and_then(|c| c.id.as_ref()); + let new_product_spec = + new_product.and_then(|id| self.products.find(id).and_then(|p| Some(p.clone()))); self.state.config = message.config.clone(); self.events.send(Event::ConfigChanged)?; let model = self.model.clone(); - tokio::task::spawn( async move { + tokio::task::spawn(async move { let mut my_model = model.lock().await; // FIXME: convert unwraps to sending issues if need_probe { my_model.probe(&new_product_spec.unwrap()).await.unwrap(); } - Self::apply_config(&message.config, my_model.deref_mut()).await.unwrap(); + Self::apply_config(&message.config, my_model.deref_mut()) + .await + .unwrap(); }); - + Ok(()) } } From 85f306e1c3438c6b0973e22199e9279bd0149683 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 24 Oct 2025 22:58:10 +0200 Subject: [PATCH 241/917] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Imobach González Sosa --- rust/agama-software/src/lib.rs | 2 +- rust/agama-software/src/zypp_server.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index 1c4e1428a7..e2e1b4da6e 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -24,7 +24,7 @@ //! //! From a technical point of view, it includes: //! -//! * The [UserConfig] struct that defines the settings the user can +//! * The [Config] struct that defines the settings the user can //! alter for the target system. //! * The [Proposal] struct that describes how the system will look like after //! the installation. diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index ef7170692d..b2d37c934e 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // From f508466fe6ecad0e21320264c0b73c87067bbe26 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 24 Oct 2025 22:58:40 +0200 Subject: [PATCH 242/917] changes from review --- rust/agama-software/src/config.rs | 24 +++++++++++----------- rust/agama-software/src/extended_config.rs | 12 +++++------ rust/agama-software/src/message.rs | 7 ------- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/rust/agama-software/src/config.rs b/rust/agama-software/src/config.rs index 2275b2c997..235ae27a49 100644 --- a/rust/agama-software/src/config.rs +++ b/rust/agama-software/src/config.rs @@ -34,10 +34,10 @@ use serde::{Deserialize, Serialize}; pub struct Config { /// Product related configuration #[serde(skip_serializing_if = "Option::is_none")] - pub product: Option, + pub product: Option, /// Software related configuration #[serde(skip_serializing_if = "Option::is_none")] - pub software: Option, + pub software: Option, } /// Addon settings for registration @@ -57,7 +57,7 @@ pub struct AddonSettings { /// Software settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ProductSettings { +pub struct ProductConfig { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, @@ -71,7 +71,7 @@ pub struct ProductSettings { pub addons: Option>, } -impl ProductSettings { +impl ProductConfig { pub fn is_empty(&self) -> bool { self.id.is_none() && self.registration_code.is_none() @@ -84,10 +84,10 @@ impl ProductSettings { /// Software settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] -pub struct SoftwareSettings { +pub struct SoftwareConfig { /// List of user selected patterns to install. #[serde(skip_serializing_if = "Option::is_none")] - pub patterns: Option, + pub patterns: Option, /// List of user selected packages to install. #[serde(skip_serializing_if = "Option::is_none")] pub packages: Option>, @@ -101,14 +101,14 @@ pub struct SoftwareSettings { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(untagged)] -pub enum PatternsSettings { +pub enum PatternsConfig { PatternsList(Vec), PatternsMap(PatternsMap), } -impl Default for PatternsSettings { +impl Default for PatternsConfig { fn default() -> Self { - PatternsSettings::PatternsMap(PatternsMap { + PatternsConfig::PatternsMap(PatternsMap { add: None, remove: None, }) @@ -123,13 +123,13 @@ pub struct PatternsMap { pub remove: Option>, } -impl From> for PatternsSettings { +impl From> for PatternsConfig { fn from(list: Vec) -> Self { Self::PatternsList(list) } } -impl From>> for PatternsSettings { +impl From>> for PatternsConfig { fn from(map: HashMap>) -> Self { let add = if let Some(to_add) = map.get("add") { Some(to_add.to_owned()) @@ -147,7 +147,7 @@ impl From>> for PatternsSettings { } } -impl SoftwareSettings { +impl SoftwareConfig { pub fn to_option(self) -> Option { if self.patterns.is_none() && self.packages.is_none() diff --git a/rust/agama-software/src/extended_config.rs b/rust/agama-software/src/extended_config.rs index de83f949b3..696cef17af 100644 --- a/rust/agama-software/src/extended_config.rs +++ b/rust/agama-software/src/extended_config.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::{ - config::{Config, PatternsSettings, ProductSettings, SoftwareSettings}, + config::{Config, PatternsConfig, ProductConfig, SoftwareConfig}, model::packages::RepositoryParams, }; use serde::Serialize; @@ -27,8 +27,8 @@ use serde::Serialize; #[derive(Clone, PartialEq, Serialize)] pub struct ExtendedConfig { /// Product related configuration - #[serde(skip_serializing_if = "ProductSettings::is_empty")] - pub product: ProductSettings, + #[serde(skip_serializing_if = "ProductConfig::is_empty")] + pub product: ProductConfig, /// Software related configuration pub software: ExtendedSoftwareSettings, } @@ -38,7 +38,7 @@ pub struct ExtendedConfig { #[serde(rename_all = "camelCase")] pub struct ExtendedSoftwareSettings { /// List of user selected patterns to install. - pub patterns: PatternsSettings, + pub patterns: PatternsConfig, /// List of user selected packages to install. pub packages: Vec, /// List of user specified repositories to use on top of default ones. @@ -48,7 +48,7 @@ pub struct ExtendedSoftwareSettings { } impl ExtendedSoftwareSettings { - pub fn merge(&mut self, config: &SoftwareSettings) -> &Self { + pub fn merge(&mut self, config: &SoftwareConfig) -> &Self { if let Some(patterns) = &config.patterns { self.patterns = patterns.clone(); } @@ -72,7 +72,7 @@ impl ExtendedSoftwareSettings { impl Default for ExtendedSoftwareSettings { fn default() -> Self { Self { - patterns: PatternsSettings::default(), + patterns: PatternsConfig::default(), packages: Default::default(), extra_repositories: Default::default(), only_required: false, diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 8631d0a470..b3bfb29167 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -20,7 +20,6 @@ use crate::{config::Config, proposal::Proposal, system_info::SystemInfo}; use agama_utils::actor::Message; -use serde::Deserialize; #[derive(Clone)] pub struct GetSystem; @@ -43,12 +42,6 @@ impl SetSystem { } } -#[derive(Clone, Debug, Deserialize, utoipa::ToSchema)] -pub struct SystemConfig { - pub locale: Option, - pub keymap: Option, -} - pub struct GetConfig; impl Message for GetConfig { From b198623c9fdbdbeb2c9cd634452fcece0102a0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 26 Oct 2025 09:35:32 +0000 Subject: [PATCH 243/917] Drop the software_ng module --- rust/agama-server/src/error.rs | 7 +- rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/software_ng.rs | 43 -- rust/agama-server/src/software_ng/backend.rs | 99 ---- .../src/software_ng/backend/client.rs | 135 ----- .../src/software_ng/backend/server.rs | 521 ------------------ rust/agama-server/src/software_ng/web.rs | 405 -------------- rust/agama-server/src/web.rs | 18 - 8 files changed, 1 insertion(+), 1228 deletions(-) delete mode 100644 rust/agama-server/src/software_ng.rs delete mode 100644 rust/agama-server/src/software_ng/backend.rs delete mode 100644 rust/agama-server/src/software_ng/backend/client.rs delete mode 100644 rust/agama-server/src/software_ng/backend/server.rs delete mode 100644 rust/agama-server/src/software_ng/web.rs diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index 2f2f38df97..2704cb8280 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -26,10 +26,7 @@ use axum::{ }; use serde_json::json; -use crate::{ - software_ng::SoftwareServiceError, users::password::PasswordCheckerError, - web::common::ProgressServiceError, -}; +use crate::{users::password::PasswordCheckerError, web::common::ProgressServiceError}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -41,8 +38,6 @@ pub enum Error { Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), - #[error("Software service error: {0}")] - SoftwareServiceError(#[from] SoftwareServiceError), #[error("Progress service error: {0}")] Progress(#[from] ProgressServiceError), #[error("Could not check the password")] diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 74b30e6360..1cb2fce416 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -38,4 +38,3 @@ pub mod users; pub mod web; pub use web::service; pub mod server; -pub mod software_ng; diff --git a/rust/agama-server/src/software_ng.rs b/rust/agama-server/src/software_ng.rs deleted file mode 100644 index de6724f21f..0000000000 --- a/rust/agama-server/src/software_ng.rs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub(crate) mod backend; -pub(crate) mod web; - -use std::sync::Arc; - -use agama_lib::http::event; -use axum::Router; -use backend::SoftwareService; -pub use backend::SoftwareServiceError; -use tokio::sync::Mutex; - -use crate::products::ProductsRegistry; - -pub async fn software_ng_service( - events: event::Sender, - products: Arc>, -) -> Router { - let client = - SoftwareService::start(events, products).expect("Could not start the software service."); - web::software_router(client) - .await - .expect("Could not build the software router.") -} diff --git a/rust/agama-server/src/software_ng/backend.rs b/rust/agama-server/src/software_ng/backend.rs deleted file mode 100644 index 3043d7b65f..0000000000 --- a/rust/agama-server/src/software_ng/backend.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the logic for the software service. -//! -//! This service is responsible for the software management of the installer. The service uses -//! Tokio's tasks for long-running operations (e.g., when reading the repositories). However, only -//! one of those operations can run at the same time. It works in this way by design, not because of -//! a technical limitation. -//! -//! A service is composed of two parts: the server and the client. The server handles the business -//! logic and receives the actions to execute using a channel. The client is a simple wrapper around -//! the other end of the channel. -//! -//! Additionally, a service might implement a monitor which listens for events and talks to the -//! server when needed. - -use std::sync::Arc; - -use agama_lib::http::event; -pub use client::SoftwareServiceClient; -use tokio::sync::{mpsc, oneshot, Mutex}; -use zypp_agama::ZyppError; - -use crate::products::ProductsRegistry; - -mod client; -mod server; - -type SoftwareActionSender = tokio::sync::mpsc::UnboundedSender; - -#[derive(thiserror::Error, Debug)] -pub enum SoftwareServiceError { - #[error("Response channel closed")] - ResponseChannelClosed, - - #[error("Receiver error: {0}")] - RecvError(#[from] oneshot::error::RecvError), - - #[error("Sender error: {0}")] - SendError(#[from] mpsc::error::SendError), - - #[error("Unknown product: {0}")] - UnknownProduct(String), - - #[error("Target creation failed: {0}")] - TargetCreationFailed(#[source] std::io::Error), - - #[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), -} - -pub type SoftwareServiceResult = Result; - -/// Builds and starts the software service. -/// -pub struct SoftwareService {} - -impl SoftwareService { - /// Starts the software service. - pub fn start( - events: event::Sender, - products: Arc>, - ) -> Result { - server::SoftwareServiceServer::start(events, products) - } -} diff --git a/rust/agama-server/src/software_ng/backend/client.rs b/rust/agama-server/src/software_ng/backend/client.rs deleted file mode 100644 index e7c08f77cf..0000000000 --- a/rust/agama-server/src/software_ng/backend/client.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_lib::{ - product::Product, - software::{ - model::{ResolvableType, SoftwareConfig}, - Pattern, - }, -}; -use tokio::sync::oneshot; - -use super::{server::SoftwareAction, SoftwareActionSender, SoftwareServiceError}; - -/// Client to interact with the software service. -/// -/// It uses a channel to send the actions to the server. It can be cloned and used in different -/// tasks if needed. -#[derive(Clone)] -pub struct SoftwareServiceClient { - actions: SoftwareActionSender, -} - -impl SoftwareServiceClient { - /// Creates a new client. - pub fn new(actions: SoftwareActionSender) -> Self { - Self { actions } - } - - /// Returns the list of known products. - pub async fn get_products(&self) -> Result, SoftwareServiceError> { - let (tx, rx) = oneshot::channel(); - self.actions.send(SoftwareAction::GetProducts(tx))?; - Ok(rx.await?) - } - - /// Returns the list of known patterns. - pub async fn get_patterns(&self) -> Result, SoftwareServiceError> { - let (tx, rx) = oneshot::channel(); - self.actions.send(SoftwareAction::GetPatterns(tx))?; - Ok(rx.await?) - } - - pub async fn select_product(&self, product_id: &str) -> Result<(), SoftwareServiceError> { - self.actions - .send(SoftwareAction::SelectProduct(product_id.to_string()))?; - Ok(()) - } - - pub async fn get_config(&self) -> Result { - let (tx, rx) = oneshot::channel(); - self.actions.send(SoftwareAction::GetConfig(tx))?; - Ok(rx.await?) - } - - pub async fn is_package_available(&self, tag: String) -> Result { - let (tx, rx) = oneshot::channel(); - self.actions - .send(SoftwareAction::PackageAvailable(tag, tx))?; - Ok(rx.await?) - } - - pub async fn is_package_selected(&self, tag: String) -> Result { - let (tx, rx) = oneshot::channel(); - self.actions - .send(SoftwareAction::PackageSelected(tag, tx))?; - Ok(rx.await?) - } - - pub async fn probe(&self) -> Result<(), SoftwareServiceError> { - self.actions.send(SoftwareAction::Probe)?; - Ok(()) - } - - pub async fn install(&self) -> Result { - let (tx, rx) = oneshot::channel(); - self.actions.send(SoftwareAction::Install(tx))?; - Ok(rx.await?) - } - - pub async fn finish(&self) -> Result<(), SoftwareServiceError> { - self.actions.send(SoftwareAction::Finish)?; - Ok(()) - } - - pub fn set_resolvables( - &self, - id: &str, - r#type: ResolvableType, - resolvables: &[&str], - optional: bool, - ) -> Result<(), SoftwareServiceError> { - let resolvables: Vec = resolvables.iter().map(|r| r.to_string()).collect(); - self.actions.send(SoftwareAction::SetResolvables { - id: id.to_string(), - r#type, - resolvables, - optional, - })?; - Ok(()) - } - - pub async fn get_resolvables( - &self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> Result, SoftwareServiceError> { - let (tx, rx) = oneshot::channel(); - self.actions.send(SoftwareAction::GetResolvables { - tx, - id: id.to_string(), - r#type, - optional, - })?; - Ok(rx.await?) - } -} diff --git a/rust/agama-server/src/software_ng/backend/server.rs b/rust/agama-server/src/software_ng/backend/server.rs deleted file mode 100644 index 5bb68148d0..0000000000 --- a/rust/agama-server/src/software_ng/backend/server.rs +++ /dev/null @@ -1,521 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::{path::Path, sync::Arc}; - -use agama_lib::{ - http::event, - product::Product, - software::{ - model::{ResolvableType, SoftwareConfig, SoftwareSelection}, - Pattern, - }, -}; -use tokio::sync::{mpsc, oneshot, Mutex}; - -use crate::{ - products::{ProductSpec, ProductsRegistry}, - software_ng::backend::SoftwareServiceResult, -}; - -use super::{client::SoftwareServiceClient, SoftwareServiceError}; - -const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; -const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; - -#[derive(Debug)] -pub enum SoftwareAction { - Probe, - Install(oneshot::Sender), - Finish, - GetProducts(oneshot::Sender>), - GetPatterns(oneshot::Sender>), - GetConfig(oneshot::Sender), - PackageAvailable(String, oneshot::Sender), - PackageSelected(String, oneshot::Sender), - SelectProduct(String), - SetResolvables { - id: String, - r#type: ResolvableType, - resolvables: Vec, - optional: bool, - }, - GetResolvables { - tx: oneshot::Sender>, - id: String, - r#type: ResolvableType, - optional: bool, - }, -} - -/// Software service server. -pub struct SoftwareServiceServer { - receiver: mpsc::UnboundedReceiver, - events: event::Sender, - products: Arc>, - // FIXME: what about having a SoftwareServiceState to keep business logic state? - selected_product: Option, - software_selection: SoftwareSelection, -} - -impl SoftwareServiceServer { - /// Starts the software service loop and returns a client. - /// - /// The service runs on a separate thread and gets the client requests using a channel. - pub fn start( - events: event::Sender, - products: Arc>, - ) -> SoftwareServiceResult { - let (sender, receiver) = mpsc::unbounded_channel(); - - let server = Self { - receiver, - events, - products, - selected_product: None, - software_selection: SoftwareSelection::default(), - }; - - // see https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#use-inside-tokiospawn for explain how to ensure that zypp - // runs locally on single thread - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - // drop the returned JoinHandle: the thread will be detached - // but that's OK for it to run until the process dies - std::thread::spawn(move || { - let local = tokio::task::LocalSet::new(); - - local.spawn_local(server.run()); - - // This will return once all senders are dropped and all - // spawned tasks have returned. - rt.block_on(local); - }); - Ok(SoftwareServiceClient::new(sender)) - } - - /// Runs the server dispatching the actions received through the input channel. - async fn run(mut self) -> SoftwareServiceResult<()> { - let zypp = self.initialize_target_dir()?; - - loop { - let action = self.receiver.recv().await; - tracing::debug!("software dispatching action: {:?}", action); - let Some(action) = action else { - tracing::error!("Software action channel closed"); - break; - }; - - if let Err(error) = self.dispatch(action, &zypp).await { - tracing::error!("Software dispatch error: {:?}", error); - } - } - - Ok(()) - } - - /// Forwards the action to the appropriate handler. - async fn dispatch( - &mut self, - action: SoftwareAction, - zypp: &zypp_agama::Zypp, - ) -> SoftwareServiceResult<()> { - match action { - SoftwareAction::GetProducts(tx) => { - self.get_products(tx).await?; - } - - SoftwareAction::GetPatterns(tx) => { - self.get_patterns(tx, zypp).await?; - } - - SoftwareAction::SelectProduct(product_id) => { - self.select_product(product_id).await?; - } - - SoftwareAction::GetConfig(tx) => { - self.get_config(tx).await?; - } - - SoftwareAction::PackageSelected(tag, tx) => { - self.package_selected(zypp, tag, tx).await?; - } - - SoftwareAction::PackageAvailable(tag, tx) => { - self.package_available(zypp, tag, tx).await?; - } - - SoftwareAction::Probe => { - self.probe(zypp).await?; - self.run_solver(zypp)?; - } - - SoftwareAction::Install(tx) => { - tx.send(self.install(zypp)?) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - } - - SoftwareAction::Finish => { - self.finish(zypp).await?; - } - - SoftwareAction::SetResolvables { - id, - r#type, - resolvables, - optional, - } => { - self.set_resolvables(zypp, id, r#type, resolvables, optional)?; - self.run_solver(zypp)?; - } - - SoftwareAction::GetResolvables { - tx, - id, - r#type, - optional, - } => { - let result = self - .software_selection - .get(&id, r#type, optional) - .unwrap_or(vec![]); // Option::unwrap is OK - tx.send(result) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - } - } - Ok(()) - } - - fn set_resolvables( - &mut self, - zypp: &zypp_agama::Zypp, - id: String, - r#type: ResolvableType, - resolvables: Vec, - optional: bool, - ) -> SoftwareServiceResult<()> { - tracing::info!( - "Set resolvables for {} with type {} optional {} and list {:?}", - id, - r#type, - optional, - resolvables - ); - let resolvables: Vec<_> = resolvables.iter().map(String::as_str).collect(); - self.software_selection - .set(zypp, &id, r#type, optional, &resolvables)?; - Ok(()) - } - - // runs solver. It should be able in future to generate solver issues - fn run_solver(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - let result = zypp.run_solver()?; - tracing::info!("Solver runs ends with {}", result); - Ok(()) - } - - // Install rpms - fn install(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult { - let target = "/mnt"; - zypp.switch_target(target)?; - let result = zypp.commit()?; - tracing::info!("libzypp commit ends with {}", result); - Ok(result) - } - - /// Select the given product. - async fn select_product(&mut self, product_id: String) -> SoftwareServiceResult<()> { - tracing::info!("Selecting product {}", product_id); - let products = self.products.lock().await; - if products.find(&product_id).is_none() { - return Err(SoftwareServiceError::UnknownProduct(product_id)); - }; - - self.selected_product = Some(product_id.clone()); - Ok(()) - } - - async fn probe(&mut self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - let product = self.find_selected_product().await?; - let repositories = product.software.repositories(); - for (idx, repo) in repositories.iter().enumerate() { - // TODO: we should add a repository ID in the configuration file. - let name = format!("agama-{}", idx); - zypp.add_repository(&name, &repo.url, |percent, alias| { - tracing::info!("Adding repository {} ({}%)", alias, percent); - true - }) - .map_err(SoftwareServiceError::AddRepositoryFailed)?; - } - - zypp.load_source(|percent, alias| { - tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); - true - }) - .map_err(SoftwareServiceError::LoadSourcesFailed)?; - - self.select_product_software(zypp, product)?; - - Ok(()) - } - - async fn finish(&mut self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - self.remove_dud_repo(zypp)?; - self.disable_local_repos(zypp)?; - self.registration_finish()?; - self.modify_zypp_conf()?; - self.modify_full_repo(zypp)?; - Ok(()) - } - - fn modify_full_repo(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - let repos = zypp.list_repositories()?; - // if url is invalid, then do not disable it and do not touch it - let repos = repos - .iter() - .filter(|r| r.url.starts_with("dvd:/install?devices=")); - for r in repos { - zypp.set_repository_url(&r.alias, "dvd:/install")?; - } - Ok(()) - } - - fn remove_dud_repo(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - const DUD_NAME: &str = "AgamaDriverUpdate"; - let repos = zypp.list_repositories()?; - let repo = repos.iter().find(|r| r.alias.as_str() == DUD_NAME); - if let Some(repo) = repo { - zypp.remove_repository(&repo.alias, |_, _| true)?; - } - Ok(()) - } - - fn disable_local_repos(&self, zypp: &zypp_agama::Zypp) -> SoftwareServiceResult<()> { - let repos = zypp.list_repositories()?; - // if url is invalid, then do not disable it and do not touch it - let repos = repos.iter().filter(|r| r.is_local().unwrap_or(false)); - for r in repos { - zypp.disable_repository(&r.alias)?; - } - Ok(()) - } - - fn registration_finish(&self) -> SoftwareServiceResult<()> { - // TODO: implement when registration is ready - Ok(()) - } - - fn modify_zypp_conf(&self) -> SoftwareServiceResult<()> { - // TODO: implement when requireOnly is implemented - Ok(()) - } - - fn select_product_software( - &mut self, - zypp: &zypp_agama::Zypp, - product: ProductSpec, - ) -> SoftwareServiceResult<()> { - let installer_id_string = "installer".to_string(); - self.set_resolvables( - zypp, - installer_id_string.clone(), - ResolvableType::Product, - vec![product.software.base_product.clone()], - false, - )?; - self.set_resolvables( - zypp, - installer_id_string.clone(), - ResolvableType::Package, - product.software.mandatory_packages, - false, - )?; - self.set_resolvables( - zypp, - installer_id_string.clone(), - ResolvableType::Pattern, - product.software.mandatory_patterns, - false, - )?; - self.set_resolvables( - zypp, - installer_id_string.clone(), - ResolvableType::Package, - product.software.optional_packages, - true, - )?; - self.set_resolvables( - zypp, - installer_id_string.clone(), - ResolvableType::Pattern, - product.software.optional_patterns, - true, - )?; - Ok(()) - } - - /// Returns the software config. - async fn get_config(&self, tx: oneshot::Sender) -> SoftwareServiceResult<()> { - let result = SoftwareConfig { - // TODO: implement all Nones - packages: None, - patterns: None, - product: self.selected_product.clone(), - extra_repositories: None, - only_required: None, - }; - tx.send(result) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - Ok(()) - } - - async fn package_available( - &self, - zypp: &zypp_agama::Zypp, - tag: String, - tx: oneshot::Sender, - ) -> SoftwareServiceResult<()> { - let result = zypp.is_package_available(&tag)?; - tx.send(result) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - Ok(()) - } - - async fn package_selected( - &self, - zypp: &zypp_agama::Zypp, - tag: String, - tx: oneshot::Sender, - ) -> SoftwareServiceResult<()> { - let result = zypp.is_package_selected(&tag)?; - tx.send(result) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - Ok(()) - } - - /// Returns the list of products. - async fn get_products(&self, tx: oneshot::Sender>) -> SoftwareServiceResult<()> { - let products = self.products.lock().await; - // FIXME: implement this conversion at model's level. - let products: Vec<_> = products - .products - .iter() - .map(|p| Product { - id: p.id.clone(), - name: p.name.clone(), - description: p.description.clone(), - icon: p.icon.clone(), - registration: p.registration, - license: None, - }) - .collect(); - tx.send(products) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - Ok(()) - } - - async fn get_patterns( - &self, - tx: oneshot::Sender>, - zypp: &zypp_agama::Zypp, - ) -> SoftwareServiceResult<()> { - let product = self.find_selected_product().await?; - - let mandatory_patterns = product.software.mandatory_patterns.iter(); - let optional_patterns = product.software.optional_patterns.iter(); - let pattern_names: Vec<&str> = vec![mandatory_patterns, optional_patterns] - .into_iter() - .flatten() - .map(String::as_str) - .collect(); - - let patterns = zypp - .patterns_info(pattern_names) - .map_err(SoftwareServiceError::ListPatternsFailed)?; - - let patterns = patterns - .into_iter() - .map(|info| Pattern { - name: info.name, - category: info.category, - description: info.description, - icon: info.icon, - summary: info.summary, - order: info.order, - }) - .collect(); - - tx.send(patterns) - .map_err(|_| SoftwareServiceError::ResponseChannelClosed)?; - Ok(()) - } - - fn initialize_target_dir(&self) -> SoftwareServiceResult { - let target_dir = Path::new(TARGET_DIR); - if target_dir.exists() { - _ = std::fs::remove_dir_all(target_dir); - } - - std::fs::create_dir_all(target_dir).map_err(SoftwareServiceError::TargetCreationFailed)?; - - let zypp = zypp_agama::Zypp::init_target(TARGET_DIR, |text, step, total| { - tracing::info!("Initializing target: {} ({}/{})", text, step, total); - }) - .map_err(SoftwareServiceError::TargetInitFailed)?; - - self.import_gpg_keys(&zypp); - Ok(zypp) - } - - fn import_gpg_keys(&self, zypp: &zypp_agama::Zypp) { - for file in glob::glob(GPG_KEYS).unwrap() { - match file { - Ok(file) => { - if let Err(e) = zypp.import_gpg_key(&file.to_string_lossy()) { - tracing::error!("Failed to import GPG key: {}", e); - } - } - Err(e) => { - tracing::error!("Could not read GPG key file: {}", e); - } - } - } - } - - // Returns the spec of the selected product. - // - // It causes the spec to be cloned, so we should find a better way to do this. - async fn find_selected_product(&self) -> SoftwareServiceResult { - let products = self.products.lock().await; - let Some(product_id) = &self.selected_product else { - return Err(SoftwareServiceError::NoSelectedProduct); - }; - - let Some(product) = products.find(product_id) else { - return Err(SoftwareServiceError::UnknownProduct(product_id.clone())); - }; - - Ok(product.clone()) - } -} diff --git a/rust/agama-server/src/software_ng/web.rs b/rust/agama-server/src/software_ng/web.rs deleted file mode 100644 index dd133081bb..0000000000 --- a/rust/agama-server/src/software_ng/web.rs +++ /dev/null @@ -1,405 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use std::collections::HashMap; - -use agama_lib::{ - error::ServiceError, - issue::Issue, - product::Product, - software::{ - model::{RegistrationInfo, ResolvableParams, ResolvableType, SoftwareConfig}, - Pattern, SelectedBy, - }, -}; -use axum::{ - extract::{Path, Query, State}, - routing::{get, post, put}, - Json, Router, -}; -use serde::Deserialize; - -use crate::{error::Error, software::web::SoftwareProposal}; - -use super::backend::SoftwareServiceClient; - -#[derive(Clone)] -struct SoftwareState { - client: SoftwareServiceClient, -} - -pub async fn software_router(client: SoftwareServiceClient) -> Result { - let state = SoftwareState { client }; - let router = Router::new() - .route("/patterns", get(get_patterns)) - .route("/products", get(get_products)) - // FIXME: it should be PATCH (using PUT just for backward compatibility). - .route("/config", put(set_config).get(get_config)) - .route("/probe", post(probe)) - .route("/install", post(install)) - .route("/finish", post(finish)) - .route("/proposal", get(get_proposal)) - .route( - "/resolvables/:id", - put(set_resolvables).get(get_resolvables), - ) - .route("/available", get(get_available)) - .route("/selected", get(get_selected)) - .route("/issues/product", get(product_issues)) - .route("/issues/software", get(software_issues)) - .route("/registration", get(get_registration)) - .with_state(state); - Ok(router) -} - -#[derive(Deserialize)] -struct QueryParam { - /// tag that can be either package name, provides or even contained file path - tag: String, -} - -/// Returns the true if package is available. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/available", - context_path = "/api/software", - responses( - (status = 200, description = "Whenever matching package is available"), - (status = 400, description = "Failed to check if package is available") - ) -)] -async fn get_available( - Query(query): Query, - State(state): State, -) -> Result, Error> { - let result = state.client.is_package_available(query.tag).await?; - Ok(Json(result)) -} - -/// Returns the true if package is selected. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/selected", - context_path = "/api/software", - responses( - (status = 200, description = "Whenever matching package is selected for installation"), - (status = 400, description = "Failed to check if package is selected") - ) -)] -async fn get_selected( - Query(query): Query, - State(state): State, -) -> Result, Error> { - let result = state.client.is_package_selected(query.tag).await?; - Ok(Json(result)) -} - -/// Returns the list of available products. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/products", - context_path = "/api/software", - responses( - (status = 200, description = "List of known products", body = Vec), - (status = 400, description = "Cannot read the list of products") - ) -)] -async fn get_products(State(state): State) -> Result>, Error> { - let products = state.client.get_products().await?; - Ok(Json(products)) -} - -/// Returns the list of available patterns. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/patterns", - context_path = "/api/software", - responses( - (status = 200, description = "List of product patterns", body = Vec), - (status = 400, description = "Cannot read the list of patterns") - ) -)] -async fn get_patterns(State(state): State) -> Result>, Error> { - let products = state.client.get_patterns().await?; - Ok(Json(products)) -} - -/// Sets the software configuration. -/// -/// * `state`: service state. -/// * `config`: software configuration. -#[utoipa::path( - put, - path = "/config", - context_path = "/api/software", - operation_id = "set_software_config", - responses( - (status = 200, description = "Set the software configuration"), - (status = 400, description = "The service could not perform the action") - ) -)] -async fn set_config( - State(state): State, - Json(config): Json, -) -> Result<(), Error> { - if let Some(product) = config.product { - state.client.select_product(&product).await?; - } - - if let Some(patterns) = config.patterns { - let selected_patterns: Vec<_> = patterns - .iter() - .filter(|(_name, selected)| **selected) - .map(|(name, _selected)| name.as_str()) - .collect(); - - tracing::info!("Setting selected patterns: {:?}", selected_patterns); - - state.client.set_resolvables( - "user_patterns", - ResolvableType::Pattern, - &selected_patterns, - false, - )? - } - - Ok(()) -} - -/// Gets the software configuration. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/config", - context_path = "/api/software", - operation_id = "get_software_config", - responses( - (status = 200, description = "Get the software configuration"), - (status = 400, description = "The service could not perform the action") - ) -)] -async fn get_config(State(state): State) -> Result, Error> { - let result = state.client.get_config().await?; - - Ok(Json(result)) -} - -/// Refreshes the repositories. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - post, - path = "/probe", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The service could not perform the action -") - ), - operation_id = "software_probe" -)] -async fn probe(State(state): State) -> Result, Error> { - state.client.probe().await?; - Ok(Json(())) -} - -/// Install rpms. -/// -/// -#[utoipa::path( - post, - path = "/install", - context_path = "/api/software", - responses( - (status = 200, description = "Installation succeed"), - (status = 400, description = "The service could not perform the action -") - ), - operation_id = "software_install" -)] -async fn install(State(state): State) -> Result, Error> { - Ok(Json(state.client.install().await?)) -} - -/// Post install phase to do internal cleaning and configs. -/// -/// -#[utoipa::path( - post, - path = "/finish", - context_path = "/api/software", - responses( - (status = 200, description = "Finish step finished"), - (status = 400, description = "The service could not perform the action -") - ), - operation_id = "software_finish" -)] -async fn finish(State(state): State) -> Result, Error> { - Ok(Json(state.client.finish().await?)) -} - -/// Returns the proposal information. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - get, - path = "/proposal", - context_path = "/api/software", - responses( - (status = 200, description = "Software proposal", body = SoftwareProposal) - ) -)] -async fn get_proposal(State(state): State) -> Result, Error> { - let config = state.client.get_config().await?; - let patterns = config.patterns.unwrap_or(HashMap::new()); - let proposal = SoftwareProposal { - size: "TODO".to_string(), - patterns: patterns - .iter() - .filter(|(_name, selected)| **selected) - .map(|(name, _selected)| (name.clone(), SelectedBy::User)) - .collect(), - }; - - Ok(Json(proposal)) -} - -/// Returns the product issues -/// -/// At this point, only the required space is reported. -#[utoipa::path( - get, - path = "/issues/product", - context_path = "/api/software", - responses( - (status = 200, description = "Product issues", body = Vec) - ) -)] -async fn product_issues(State(state): State) -> Result>, Error> { - // TODO: implement it - Ok(Json(vec![])) -} - -/// Returns the software issues -/// -/// At this point, only the required space is reported. -#[utoipa::path( - get, - path = "/issues/software", - context_path = "/api/software", - responses( - (status = 200, description = "Product issues", body = Vec) - ) -)] -async fn software_issues(State(state): State) -> Result>, Error> { - // TODO: implement it - Ok(Json(vec![])) -} - -/// returns registration info -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration", - context_path = "/api/software", - responses( - (status = 200, description = "registration configuration", body = RegistrationInfo), - (status = 400, description = "The service could not perform the action") - ) -)] -async fn get_registration( - State(state): State, -) -> Result, Error> { - // TODO: implement it - let result = RegistrationInfo { - registered: false, - key: "".to_string(), - email: "".to_string(), - url: "".to_string(), - }; - Ok(Json(result)) -} - -/// Updates the resolvables list with the given `id`. -#[utoipa::path( - put, - path = "/resolvables/:id", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The service could not perform the action -") - ) -)] -async fn set_resolvables( - State(state): State, - Path(id): Path, - Json(params): Json, -) -> Result, Error> { - let names: Vec<_> = params.names.iter().map(|n| n.as_str()).collect(); - state - .client - .set_resolvables(&id, params.r#type, &names, params.optional)?; - Ok(Json(())) -} - -/// Returns the resolvables list with the given `id`. -#[utoipa::path( - get, - path = "/resolvables/:id", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositresolvable list"), - (status = 400, description = "The service could not perform the action -") - ) -)] -async fn get_resolvables( - State(state): State, - Path(id): Path, - Query(query): Query>, -) -> Result>, Error> { - let default = "package".to_string(); - let typ = query.get("type").unwrap_or(&default); - let typ = match typ.as_str() { - // TODO: support more and move to ResolvableKind - "package" => Ok(ResolvableType::Package), - "pattern" => Ok(ResolvableType::Pattern), - _ => Err(anyhow::Error::msg("Unknown resolveble type")), - }?; - - let optional = query - .get("optional") - .map_or(false, |v| v.as_str() == "true"); - - let result = state.client.get_resolvables(&id, typ, optional).await?; - Ok(Json(result)) -} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index bdfb23e571..1b54076e5b 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -37,8 +37,6 @@ use crate::{ scripts::web::scripts_service, security::security_service, server::server_service, - software::web::{software_service, software_streams}, - software_ng::software_ng_service, storage::web::{iscsi::iscsi_service, storage_service, storage_streams}, users::web::{users_service, users_streams}, web::common::{jobs_stream, service_status_stream}, @@ -109,10 +107,6 @@ where .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) - .add_service( - "/software", - software_ng_service(events.clone(), Arc::clone(&products)).await, - ) .add_service("/files", files_service().await?) .add_service("/hostname", hostname_service().await?) .add_service("/profile", profile_service().await?) @@ -156,9 +150,6 @@ async fn run_events_monitor(dbus: zbus::Connection, events: event::Sender) -> Re for (id, storage_stream) in storage_streams(dbus.clone()).await? { stream.insert(id, storage_stream); } - for (id, software_stream) in software_streams(dbus.clone()).await? { - stream.insert(id, software_stream); - } stream.insert( "storage-status", service_status_stream( @@ -178,15 +169,6 @@ async fn run_events_monitor(dbus: zbus::Connection, events: event::Sender) -> Re ) .await?, ); - stream.insert( - "software-status", - service_status_stream( - dbus.clone(), - "org.opensuse.Agama.Software1", - "/org/opensuse/Agama/Software1", - ) - .await?, - ); stream.insert("questions", questions_stream(dbus.clone()).await?); tokio::pin!(stream); From 0b00d2f35c1e8f0ec0e9c57bbdf9b8464d01adeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 26 Oct 2025 11:05:23 +0000 Subject: [PATCH 244/917] Move API types from agama-software --- rust/agama-lib/src/software/client.rs | 4 +-- rust/agama-lib/src/software/settings.rs | 2 +- rust/agama-manager/Cargo.toml | 1 + rust/agama-server/src/web/docs/config.rs | 2 +- rust/agama-server/src/web/docs/software.rs | 1 - rust/agama-software/src/extended_config.rs | 5 ++-- rust/agama-software/src/lib.rs | 3 -- rust/agama-software/src/message.rs | 4 +-- rust/agama-software/src/model.rs | 3 +- rust/agama-software/src/model/packages.rs | 29 +------------------ rust/agama-software/src/service.rs | 3 +- rust/agama-software/src/zypp_server.rs | 3 +- rust/agama-utils/src/api.rs | 2 +- .../src/api/software.rs} | 29 ++++++++++++++++++- 14 files changed, 44 insertions(+), 47 deletions(-) rename rust/{agama-software/src/config.rs => agama-utils/src/api/software.rs} (80%) diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 42d968b992..20c7728677 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -19,10 +19,10 @@ // find current contact information at www.suse.com. use super::{ - model::{Conflict, ConflictSolve, Repository, RepositoryParams, ResolvableType}, + model::{Conflict, ConflictSolve, Repository, ResolvableType}, proxies::{ProposalProxy, Software1Proxy}, }; -use crate::error::ServiceError; +use crate::{error::ServiceError, software::model::RepositoryParams}; use agama_utils::dbus::{get_optional_property, get_property}; use serde::Serialize; use serde_repr::{Deserialize_repr, Serialize_repr}; diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs index 16406a7eca..dbae2ea3d8 100644 --- a/rust/agama-lib/src/software/settings.rs +++ b/rust/agama-lib/src/software/settings.rs @@ -24,7 +24,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::model::RepositoryParams; +use crate::software::model::RepositoryParams; /// Software settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 05f303cca7..af87220e5d 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true [dependencies] agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-software = { path = "../agama-software" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } async-trait = "0.1.83" diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ff37ec93a8..23ae59ccd6 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -139,7 +139,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -181,6 +180,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .build() diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs index fedd4e77e3..52deb45745 100644 --- a/rust/agama-server/src/web/docs/software.rs +++ b/rust/agama-server/src/web/docs/software.rs @@ -59,7 +59,6 @@ impl ApiDocBuilder for SoftwareApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-software/src/extended_config.rs b/rust/agama-software/src/extended_config.rs index 696cef17af..2fab02c7a7 100644 --- a/rust/agama-software/src/extended_config.rs +++ b/rust/agama-software/src/extended_config.rs @@ -18,9 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - config::{Config, PatternsConfig, ProductConfig, SoftwareConfig}, - model::packages::RepositoryParams, +use agama_utils::api::software::{ + Config, PatternsConfig, ProductConfig, RepositoryParams, SoftwareConfig, }; use serde::Serialize; diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index e2e1b4da6e..a70bfd8e14 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -50,9 +50,6 @@ pub use event::Event; mod system_info; pub use system_info::SystemInfo; -mod config; -pub use config::Config; - mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index b3bfb29167..7236872926 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{config::Config, proposal::Proposal, system_info::SystemInfo}; -use agama_utils::actor::Message; +use crate::{proposal::Proposal, system_info::SystemInfo}; +use agama_utils::{actor::Message, api::software::Config}; #[derive(Clone)] pub struct GetSystem; diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 08a3dc8d0c..a131b7a013 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,12 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_utils::api::software::RepositoryParams; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ - packages::{Repository, RepositoryParams, ResolvableType}, + packages::{Repository, ResolvableType}, pattern::Pattern, products::{ProductSpec, UserPattern}, registration::{AddonProperties, RegistrationInfo}, diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index bc52334057..6fd3e5d611 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_utils::api::software::RepositoryParams; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -89,31 +90,3 @@ pub struct Repository { /// Whether the repository is loaded pub loaded: bool, } - -/// Parameters for creating new a repository -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RepositoryParams { - /// repository alias. Has to be unique - pub alias: String, - /// repository name, if not specified the alias is used - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - #[serde(skip_serializing_if = "Option::is_none")] - pub product_dir: Option, - /// Whether the repository is enabled, if missing the repository is enabled - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - /// Repository priority, lower number means higher priority, the default priority is 99 - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - /// Whenever repository can be unsigned. Default is false - #[serde(skip_serializing_if = "Option::is_none")] - pub allow_unsigned: Option, - /// List of fingerprints for GPG keys used for repository signing. By default empty - #[serde(skip_serializing_if = "Option::is_none")] - pub gpg_fingerprints: Option>, -} diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index dacf267939..088c5dee3a 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -21,8 +21,7 @@ use std::{ops::DerefMut, sync::Arc}; use crate::{ - config::Config, - event, message, + message, model::{ license::{Error as LicenseError, LicensesRepo}, packages::ResolvableType, diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index b2d37c934e..796208189f 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_utils::api::software::RepositoryParams; use std::path::Path; use tokio::sync::{ mpsc::{self, UnboundedSender}, @@ -26,7 +27,7 @@ use tokio::sync::{ use zypp_agama::ZyppError; use crate::model::{ - packages::{Repository, RepositoryParams, ResolvableType}, + packages::{Repository, ResolvableType}, pattern::Pattern, products::RepositorySpec, }; diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 0664716790..4ee384ec22 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -49,5 +49,5 @@ mod action; pub use action::Action; pub mod l10n; - pub mod question; +pub mod software; diff --git a/rust/agama-software/src/config.rs b/rust/agama-utils/src/api/software.rs similarity index 80% rename from rust/agama-software/src/config.rs rename to rust/agama-utils/src/api/software.rs index 235ae27a49..793e429dbc 100644 --- a/rust/agama-software/src/config.rs +++ b/rust/agama-utils/src/api/software.rs @@ -22,7 +22,6 @@ use std::collections::HashMap; -use crate::model::packages::RepositoryParams; use serde::{Deserialize, Serialize}; /// User configuration for the localization of the target system. @@ -160,3 +159,31 @@ impl SoftwareConfig { } } } + +/// Parameters for creating new a repository +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryParams { + /// repository alias. Has to be unique + pub alias: String, + /// repository name, if not specified the alias is used + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Repository url (raw format without expanded variables) + pub url: String, + /// product directory (currently not used, valid only for multiproduct DVDs) + #[serde(skip_serializing_if = "Option::is_none")] + pub product_dir: Option, + /// Whether the repository is enabled, if missing the repository is enabled + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Repository priority, lower number means higher priority, the default priority is 99 + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Whenever repository can be unsigned. Default is false + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_unsigned: Option, + /// List of fingerprints for GPG keys used for repository signing. By default empty + #[serde(skip_serializing_if = "Option::is_none")] + pub gpg_fingerprints: Option>, +} From ffc9edb3f3e0d4a4e5eb7cd23b4b5ea9a87c67bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 27 Oct 2025 05:53:32 +0000 Subject: [PATCH 245/917] Add the new software service to the API --- rust/Cargo.lock | 1 + rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/service.rs | 15 ++++++++++++++- rust/agama-manager/src/start.rs | 8 +++++--- rust/agama-software/src/service.rs | 18 +++++++++++++----- rust/agama-software/src/start.rs | 2 +- rust/agama-utils/src/api/config.rs | 4 +++- rust/agama-utils/src/api/event.rs | 5 +++++ 8 files changed, 43 insertions(+), 11 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b352e81a90..4b1c9a9925 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -132,6 +132,7 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-l10n", + "agama-software", "agama-utils", "async-trait", "merge-struct", diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 9a4f0730e9..465aa1e5eb 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -27,3 +27,4 @@ pub use service::Service; pub mod message; pub use agama_l10n as l10n; +pub use agama_software as software; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 960bfd413c..d0abf16a80 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; use crate::message; +use crate::{l10n, software}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -44,6 +44,8 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] + Software(#[from] software::service::Error), + #[error(transparent)] Issues(#[from] issue::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), @@ -51,6 +53,7 @@ pub enum Error { pub struct Service { l10n: Handler, + software: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -62,6 +65,7 @@ pub struct Service { impl Service { pub fn new( l10n: Handler, + software: Handler, issues: Handler, progress: Handler, questions: Handler, @@ -69,6 +73,7 @@ impl Service { ) -> Self { Self { l10n, + software, issues, progress, questions, @@ -130,10 +135,12 @@ impl MessageHandler for Service { /// It includes user and default values. async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; + let software = self.software.call(software::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; Ok(Config { l10n: Some(l10n), questions: Some(questions), + software: Some(software), }) } } @@ -169,6 +176,12 @@ impl MessageHandler for Service { .call(question::message::SetConfig::new(questions.clone())) .await?; } + + if let Some(software) = &message.config.software { + self.software + .call(software::message::SetConfig::new(software.clone())) + .await?; + } self.config = message.config; Ok(()) } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 743d859042..be2ec96126 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,8 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::l10n; -use crate::service::Service; +use crate::{l10n, service::Service, software}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -33,6 +32,8 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::start::Error), #[error(transparent)] + Software(#[from] software::start::Error), + #[error(transparent)] Issues(#[from] issue::start::Error), } @@ -57,8 +58,9 @@ pub async fn start( let issues = issue::start(events.clone(), dbus).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; + let software = software::start(issues.clone(), events.clone()).await?; - let service = Service::new(l10n, issues, progress, questions, events.clone()); + let service = Service::new(l10n, software, issues, progress, questions, events.clone()); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 088c5dee3a..4f0754ee43 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -31,19 +31,23 @@ use crate::{ proposal::Proposal, system_info::SystemInfo, zypp_server::{self, SoftwareAction}, - Event, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, + api::{ + event::{self, Event}, + software::Config, + Scope, + }, issue::{self}, }; use async_trait::async_trait; -use tokio::sync::{mpsc::error::SendError, Mutex, RwLock}; +use tokio::sync::{broadcast, Mutex, RwLock}; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("software service could not send the event")] - Event(#[from] SendError), + Event(#[from] broadcast::error::SendError), #[error(transparent)] Actor(#[from] actor::Error), #[error("Failed to send message to libzypp thread: {0}")] @@ -121,7 +125,9 @@ impl Service { system.licenses = licenses; system.products = products; - self.events.send(Event::SystemChanged)?; + self.events.send(Event::SystemChanged { + scope: Scope::Software, + })?; Ok(()) } @@ -189,7 +195,9 @@ impl MessageHandler> for Service { new_product.and_then(|id| self.products.find(id).and_then(|p| Some(p.clone()))); self.state.config = message.config.clone(); - self.events.send(Event::ConfigChanged)?; + self.events.send(Event::ConfigChanged { + scope: Scope::Software, + })?; let model = self.model.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index ea694618ab..bcca2f57a6 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -19,13 +19,13 @@ // find current contact information at www.suse.com. use crate::{ - event, model::Model, service::{self, Service}, zypp_server::{ZyppServer, ZyppServerError}, }; use agama_utils::{ actor::{self, Handler}, + api::event, issue, }; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 7b244c8fc6..66a03e1f6e 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question}; +use crate::api::{l10n, question, software}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] @@ -26,6 +26,8 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] pub l10n: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub software: Option, #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, } diff --git a/rust/agama-utils/src/api/event.rs b/rust/agama-utils/src/api/event.rs index 21f520ebc2..5f1f966827 100644 --- a/rust/agama-utils/src/api/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -45,6 +45,11 @@ pub enum Event { SystemChanged { scope: Scope, }, + /// The configuration changed. + // TODO: do we need this event? + ConfigChanged { + scope: Scope, + }, /// Proposal changed. ProposalChanged { scope: Scope, From 7648ac00a1db86de44ed7f4a7ec758f408cf9c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 27 Oct 2025 05:55:15 +0000 Subject: [PATCH 246/917] Fix user patterns parsing --- rust/agama-software/src/model/products.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index 723d7e2162..3b1b381e6c 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -179,6 +179,7 @@ impl SoftwareSpec { } #[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] pub enum UserPattern { Plain(String), Preselected(PreselectedPattern), From e2c13666abfecad83ca90265fa2f0d0d02a62e80 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Fri, 17 Oct 2025 10:10:53 +0200 Subject: [PATCH 247/917] First version of devicegraph_conversions --- .../agama/storage/devicegraph_conversions.rb | 30 ++++ .../devicegraph_conversions/to_json.rb | 58 +++++++ .../to_json_conversions/block.rb | 120 +++++++++++++ .../to_json_conversions/device.rb | 117 +++++++++++++ .../to_json_conversions/drive.rb | 146 ++++++++++++++++ .../to_json_conversions/filesystem.rb | 84 +++++++++ .../to_json_conversions/md.rb | 70 ++++++++ .../to_json_conversions/multipath.rb | 54 ++++++ .../to_json_conversions/partition.rb | 52 ++++++ .../to_json_conversions/partition_table.rb | 67 +++++++ .../to_json_conversions/section.rb | 81 +++++++++ .../to_json_conversions/sections.rb | 30 ++++ .../to_json_conversions/volume_group.rb | 62 +++++++ .../devicegraph_conversions/to_json_test.rb | 164 ++++++++++++++++++ 14 files changed, 1135 insertions(+) create mode 100644 service/lib/agama/storage/devicegraph_conversions.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb create mode 100644 service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb create mode 100644 service/test/agama/storage/devicegraph_conversions/to_json_test.rb diff --git a/service/lib/agama/storage/devicegraph_conversions.rb b/service/lib/agama/storage/devicegraph_conversions.rb new file mode 100644 index 0000000000..7f203adf9f --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json" + +module Agama + module Storage + # Conversions for the Y2Storage devicegraph + module DevicegraphConversion + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json.rb b/service/lib/agama/storage/devicegraph_conversions/to_json.rb new file mode 100644 index 0000000000..78422049a4 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/device" + +module Agama + module Storage + module DevicegraphConversions + # Devicegraph conversion to JSON array according to schema. + class ToJSON + # @param devicegraph [Y2Storage::Devicegraph] + def initialize(devicegraph) + @devicegraph = devicegraph + end + + # Performs the conversion to array according to the JSON schema. + # + # @return [Hash] + def convert + original_devices.map { |d| ToJSONConversions::Device.new(d).convert } + end + + private + + # @return [Y2Storage::Devicegraph] + attr_reader :devicegraph + + # First-level devices to be included in the JSON representation. + # + # @return [Array] + def original_devices + devicegraph.disk_devices + + devicegraph.stray_blk_devices + + devicegraph.software_raids + + devicegraph.lvm_vgs + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb new file mode 100644 index 0000000000..7fd8225e36 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "agama/storage/device_shrinking" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for block devices. + class Block < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) + end + + private + + # @see Section#conversions + def conversions + { + start: block_start, + active: block_active, + encrypted: block_encrypted, + udevIds: block_udev_ids, + udevPaths: block_udev_paths, + size: block_size, + shrinking: block_shrinking, + systems: block_systems + } + end + + # Position of the first block of the region. + # + # @return [Integer] + def block_start + storage_device.start + end + + # Whether the block device is currently active + # + # @return [Boolean] + def block_active + storage_device.active? + end + + # Whether the block device is encrypted. + # + # @return [Boolean] + def block_encrypted + storage_device.encrypted? + end + + # Name of the udev by-id links + # + # @return [Array] + def block_udev_ids + storage_device.udev_ids + end + + # Name of the udev by-path links + # + # @return [Array] + def block_udev_paths + storage_device.udev_paths + end + + # Size of the block device in bytes + # + # @return [Integer] + def block_size + storage_device.size.to_i + end + + # Shrinking information. + # + # @return [Hash] + def block_shrinking + shrinking = Agama::Storage::DeviceShrinking.new(storage_device) + + if shrinking.supported? + { supported: shrinking.min_size.to_i } + else + { unsupported: shrinking.unsupported_reasons } + end + end + + # Name of the currently installed systems + # + # @return [Array] + def block_systems + return @systems if @systems + + filesystems = storage_device.descendants.select { |d| d.is?(:filesystem) } + @systems = filesystems.map(&:system_name).compact + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb new file mode 100644 index 0000000000..f0254f2a32 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/sections" +require "y2storage/device_description" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Device conversion to JSON hash according to schema. + class Device + # @param storage_device [Y2Storage::Device] + def initialize(storage_device) + @storage_device = storage_device + end + + # Hash representing the Y2Storage device + # + # @return [Hash] + def convert + result = { + sid: device_sid, + name: device_name, + description: device_description + } + add_sections(result) + add_nested_devices(result) + result + end + + private + + # Device to convert + # @return [Y2Storage::Device] + attr_reader :storage_device + + # sid of the device. + # + # @return [Integer] + def device_sid + storage_device.sid + end + + # Name to represent the device. + # + # @return [String] e.g., "/dev/sda". + def device_name + storage_device.display_name || "" + end + + # Description of the device. + # + # @return [String] e.g., "EXT4 Partition". + def device_description + Y2Storage::DeviceDescription.new(storage_device, include_encryption: true).to_s + end + + # Adds the required sub-sections according to the storage object. + # + # @param hash [Hash] the argument gets modified + def add_sections(hash) + conversions = Section.subclasses.select { |c| c.apply?(storage_device) } + + conversions.each do |conversion| + hash.merge!(conversion.new(storage_device).convert) + end + end + + # Add nested devices like partitions or LVM logical volumes + # + # @param hash [Hash] the argument gets modified + def add_nested_devices(hash) + add_partitions(hash) + add_logical_volumes(hash) + end + + # @see #add_nested_devices + def add_partitions(hash) + return unless PartitionTable.apply?(storage_device) + + hash[:partitions] = storage_device.partition_table.partitions.map do |part| + self.class.new(part).convert + end + end + + # @see #add_nested_devices + def add_logical_volumes(hash) + return unless VolumeGroup.apply?(storage_device) + + hash[:logicalVolumes] = storage_device.lvm_lvs.map do |lv| + self.class.new(lv).convert + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb new file mode 100644 index 0000000000..a7db039974 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/drive.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for drive devices. + class Drive < Section + # Whether this section should be exported for the given device. + # + # Drive and disk device are very close concepts, but there are subtle differences. For + # example, a MD RAID is never considered as a drive. + # + # TODO: Revisit the defintion of drive. Maybe some MD devices could implement the drive + # interface if hwinfo provides useful information for them. + # + # @param storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(storage_device) + storage_device.is?(:disk, :dm_raid, :multipath, :dasd) && + storage_device.is?(:disk_device) + end + + private + + # @see Section#conversions + def conversions + { + type: drive_type, + vendor: drive_vendor, + model: drive_model, + bus: drive_bus, + busId: drive_bus_id, + driver: drive_driver, + transport: drive_transport, + info: drive_info + } + end + + # Drive type + # + # @return ["disk", "raid", "multipath", "dasd", nil] Nil if type is unknown. + def drive_type + if storage_device.is?(:disk) + "disk" + elsif storage_device.is?(:dm_raid) + "raid" + elsif storage_device.is?(:multipath) + "multipath" + elsif storage_device.is?(:dasd) + "dasd" + end + end + + # Vendor name + # + # @return [String, nil] + def drive_vendor + storage_device.vendor + end + + # Model name + # + # @return [String, nil] + def drive_model + storage_device.model + end + + # Bus name + # + # @return [String, nil] + def drive_bus + # FIXME: not sure whether checking for "none" is robust enough + return if storage_device.bus.nil? || storage_device.bus.casecmp?("none") + + storage_device.bus + end + + # Bus Id for DASD + # + # @return [String, nil] + def drive_bus_id + return unless storage_device.respond_to?(:bus_id) + + storage_device.bus_id + end + + # Kernel drivers used by the device + # + # @return [Array] + def drive_driver + storage_device.driver + end + + # Data transport layer, if any + # + # @return [String, nil] + def drive_transport + return unless storage_device.respond_to?(:transport) + + transport = storage_device.transport + return if transport.nil? || transport.is?(:unknown) + + # FIXME: transport does not have proper i18n support at yast2-storage-ng, so we are + # just duplicating some logic from yast2-storage-ng here + return "USB" if transport.is?(:usb) + return "IEEE 1394" if transport.is?(:sbp) + + transport.to_s + end + + # More info about the device + # + # @return [Hash] + def drive_info + { + sdCard: storage_device.sd_card?, + dellBoss: storage_device.boss? + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb new file mode 100644 index 0000000000..a1718c5fcf --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/filesystem.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "y2storage/filesystem_label" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for formatted devices. + class Filesystem < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) && !storage_device.filesystem.nil? + end + + private + + # @see Section#conversions + def conversions + { + sid: filesystem_sid, + type: filesystem_type, + mountPath: filesystem_mount_path, + label: filesystem_label + } + end + + # SID of the file system. + # + # It is useful to detect whether a file system is new. + # + # @return [Integer] + def filesystem_sid + storage_device.filesystem.sid + end + + # File system type. + # + # @return [String] e.g., "ext4" + def filesystem_type + storage_device.filesystem.type.to_s + end + + # Mount path of the file system. + # + # @return [String, nil] Nil if not mounted. + def filesystem_mount_path + storage_device.filesystem.mount_path + end + + # Label of the file system. + # + # @return [String, nil] Nil if it has no label. + def filesystem_label + label = Y2Storage::FilesystemLabel.new(storage_device).to_s + return if label.empty? + + label + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb new file mode 100644 index 0000000000..805b15fa13 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/md.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with the properties of MD RAID devices. + class Md < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:md) + end + + private + + # @see Section#conversions + def conversions + { + uuid: md_uuid, + level: md_level, + devices: md_devices + } + end + + # UUID of the MD RAID + # + # @return [String, nil] + def md_uuid + storage_device.uuid + end + + # RAID level + # + # @return [String] + def md_level + storage_device.md_level.to_s + end + + # SIDs of the objects representing the devices of the MD RAID. + # + # @return [Array] + def md_devices + storage_device.plain_devices.map(&:sid) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb new file mode 100644 index 0000000000..8a57f2e964 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/multipath.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for multipath devices. + class Multipath < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:multipath) + end + + private + + # @see Section#conversions + def conversions + { wireNames: multipath_wire_names } + end + + # Name of the multipath wires. + # + # @note: The multipath wires are not exported yet. + # + # @return [Array] + def multipath_wire_names + storage_device.parents.map(&:name) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb new file mode 100644 index 0000000000..7280cf25c4 --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with properties for partitions. + class Partition < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:partition) + end + + private + + # @see Section#conversions + def conversions + { efi: partition_efi } + end + + # Whether it is a (valid) EFI System partition + # + # @return [Boolean] + def partition_efi + storage_device.efi_system? + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb new file mode 100644 index 0000000000..a97a70a9db --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section for devices that contain a partition table. + class PartitionTable < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:blk_device) && + storage_device.respond_to?(:partition_table?) && + storage_device.partition_table? + end + + private + + # @see Section.conversions + def conversions + { + type: partition_table_type, + unusedSlots: partition_table_unused_slots + } + end + + # Type of the partition table + # + # @return [String] + def partition_table_type + storage_device.partition_table.type.to_s + end + + # Available slots within a partition table, that is, the spaces that can be used to + # create a new partition. + # + # @return [Array] The first block and the size of each slot. + def partition_table_unused_slots + storage_device.partition_table.unused_partition_slots.map do |slot| + [slot.region.start, slot.region.size.to_i] + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb new file mode 100644 index 0000000000..1f9758531d --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/section.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Base class for all the sub-sections that are only included for certain types of devices. + class Section + # Whether it makes sense to export this section as part of the hash. + # + # To be redefined by every subclass. + # + # @param _storage_device [Y2Storage::Device] device to describe + # @return [Boolean] + def self.apply?(_storage_device) + false + end + + # @param storage_device [Y2Storage::Device] + def initialize(storage_device) + @storage_device = storage_device + end + + # Hash representing the section with information about the Y2Storage device. + # + # @return [Hash] + def convert + { section_name => conversions.compact } + end + + private + + # Device to convert + # @return [Y2Storage::Device] + attr_reader :storage_device + + # Name of the section + # + # @return [Symbol] + def section_name + name = class_basename + (name[0].downcase + name[1..-1]).to_sym + end + + # Properties included in the section + # + # To be defined by every subclass + # + # @return [Hash] + def conversions + {} + end + + # @see #section_name + def class_basename + self.class.name.split("::").last + end + end + end + end + end +end diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb new file mode 100644 index 0000000000..90b779d42c --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/sections.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" +require "agama/storage/devicegraph_conversions/to_json_conversions/block" +require "agama/storage/devicegraph_conversions/to_json_conversions/drive" +require "agama/storage/devicegraph_conversions/to_json_conversions/filesystem" +require "agama/storage/devicegraph_conversions/to_json_conversions/md" +require "agama/storage/devicegraph_conversions/to_json_conversions/multipath" +require "agama/storage/devicegraph_conversions/to_json_conversions/partition" +require "agama/storage/devicegraph_conversions/to_json_conversions/partition_table" +require "agama/storage/devicegraph_conversions/to_json_conversions/volume_group" diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb new file mode 100644 index 0000000000..0bc8b47f3e --- /dev/null +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/storage/devicegraph_conversions/to_json_conversions/section" + +module Agama + module Storage + module DevicegraphConversions + module ToJSONConversions + # Section with the properties of an LVM Volume Group. + class VolumeGroup < Section + # @see Section.apply? + def self.apply?(storage_device) + storage_device.is?(:lvm_vg) + end + + private + + # @see Section#conversions + def conversions + { + size: lvm_vg_size, + physicalVolumes: lvm_vg_pvs + } + end + + # Size of the volume group in bytes + # + # @return [Integer] + def lvm_vg_size + storage_device.size.to_i + end + + # D-Bus paths of the objects representing the physical volumes. + # + # @return [Array] + def lvm_vg_pvs + storage_device.lvm_pvs.map(&:sid) + end + end + end + end + end +end diff --git a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb new file mode 100644 index 0000000000..b8ec92c336 --- /dev/null +++ b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../storage_helpers" +require "agama/storage/devicegraph_conversions" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::DevicegraphConversions::ToJSON do + include Agama::RSpec::StorageHelpers + + before do + mock_storage(devicegraph: scenario) + allow_any_instance_of(Y2Storage::Partition).to receive(:resize_info).and_return(resize_info) + end + + subject { described_class.new(devicegraph) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + let(:resize_info) do + instance_double( + Y2Storage::ResizeInfo, resize_ok?: true, reasons: [], + min_size: Y2Storage::DiskSize.GiB(20), max_size: Y2Storage::DiskSize.GiB(40) + ) + end + + describe "#convert" do + describe "for a devicegraph with several disks" do + let(:scenario) { "disks.yaml" } + + it "generates an entry for each disk" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/vdc") + end + + it "exports the block device sizes in bytes" do + json = subject.convert + expect(json.map { |e| e[:block][:size] }).to all eq(50 * (1024**3)) + end + + it "generates the :partitions and :partitionTable entries only for partitioned disks" do + json = subject.convert + + vda = json.find { |d| d[:name] == "/dev/vda" } + expect(vda[:partitions].size).to eq 3 + expect(vda[:partitionTable][:type]).to eq "gpt" + + vdb = json.find { |d| d[:name] == "/dev/vdb" } + expect(vdb.keys).to_not include :partitions + expect(vdb.keys).to_not include :partitionTable + + vdc = json.find { |d| d[:name] == "/dev/vdc" } + expect(vdc.keys).to_not include :partitions + expect(vdc.keys).to_not include :partitionTable + end + + it "generates the :filesystem entry only for formatted disks" do + json = subject.convert + + vda = json.find { |d| d[:name] == "/dev/vda" } + expect(vda.keys).to_not include :filesystem + + vdb = json.find { |d| d[:name] == "/dev/vdb" } + expect(vdb.keys).to_not include :filesystem + + vdc = json.find { |d| d[:name] == "/dev/vdc" } + expect(vdc.keys).to include :filesystem + expect(vdc[:filesystem][:type]).to eq "ext4" + end + end + + describe "for a devicegraph with LVM" do + let(:scenario) { "trivial_lvm.yml" } + + it "generates an entry for each disk and volume group" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/sda", "/dev/vg0") + end + + it "exports the size and physical volumes of the LVM volume group" do + json = subject.convert + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + expect(vg0[:volumeGroup][:size]).to eq (100 * (1024**3)) - (4 * (1024**2)) + pvs = vg0[:volumeGroup][:physicalVolumes] + expect(pvs).to be_a Array + expect(pvs.size).to eq 1 + end + + it "generates the :logicalVolumes entries only for LVM volume groups" do + json = subject.convert + + sda = json.find { |d| d[:name] == "/dev/sda" } + expect(sda.keys).to_not include :logicalVolumes + + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + lvs = vg0[:logicalVolumes] + expect(lvs.map { |lv| lv[:name] }).to eq ["/dev/vg0/lv1"] + expect(lvs.first[:block].keys).to include :size + end + + it "generates the :filesystem entry for formatted logical volumes" do + json = subject.convert + vg0 = json.find { |d| d[:name] == "/dev/vg0" } + lv = vg0[:logicalVolumes].first + + expect(lv.keys).to include :filesystem + expect(lv[:filesystem][:type]).to eq "btrfs" + end + end + + describe "for a devicegraph with MD RAIDs" do + let(:scenario) { "md_disks.yaml" } + + it "generates an entry for each disk and MD RAID" do + json = subject.convert + expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/md0") + end + + it "exports the level and members of the MD RAIDs" do + json = subject.convert + md0 = json.find { |d| d[:name] == "/dev/md0" } + expect(md0[:md][:level]).to eq "raid0" + members = md0[:md][:devices] + expect(members).to be_a Array + expect(members.size).to eq 2 + end + end + + describe "for a devicegraph with multipath devices" do + let(:scenario) { "multipath-formatted.xml" } + + it "generates an entry for each multipath device" do + json = subject.convert + expect(json.map { |e| e[:name] }).to eq ["/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1"] + end + + it "exports the name of the multipath wires" do + json = subject.convert + wires = json.first[:multipath][:wireNames] + expect(wires).to contain_exactly("/dev/sda", "/dev/sdb") + end + end + end +end From dbf050343a189c82228697bd0f3142f082b338a5 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 22 Oct 2025 15:38:00 +0200 Subject: [PATCH 248/917] service: First versions of D-Bus GetProposal and GetSystem --- service/lib/agama/dbus/storage/manager.rb | 141 +++-- .../storage/volume_conversions/to_json.rb | 91 ++-- .../test/agama/dbus/storage/manager_test.rb | 498 +++++++++--------- .../volume_conversions/to_json_test.rb | 78 +-- 4 files changed, 406 insertions(+), 402 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index e880a15e5a..d01d7fd42a 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -31,13 +31,14 @@ require "agama/dbus/storage/iscsi_nodes_tree" require "agama/dbus/storage/proposal" require "agama/dbus/storage/proposal_settings_conversion" -require "agama/dbus/storage/volume_conversion" require "agama/dbus/with_service_status" require "agama/storage/config_conversions" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings" require "agama/storage/volume_templates_builder" require "agama/with_progress" +require "agama/storage/devicegraph_conversions" +require "agama/storage/volume_conversions" Yast.import "Arch" @@ -67,14 +68,11 @@ def initialize(backend, service_status: nil, logger: nil) super(PATH, logger: logger) @backend = backend @service_status = service_status - @encryption_methods = read_encryption_methods - @actions = read_actions register_storage_callbacks register_progress_callbacks register_service_status_callbacks register_iscsi_callbacks - register_software_callbacks add_s390_interfaces if Yast::Arch.s390 end @@ -241,13 +239,13 @@ def solve_config_model(serialized_model) dbus_method(:SetLocale, "in locale:s") {} # TODO: receive a product_config instead of an id. dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } - dbus_method(:GetSystem, "out system:s") {} + dbus_method(:GetSystem, "out system:s") { recover_system } dbus_method(:GetConfig, "out config:s") { recover_config } dbus_method(:SetConfig, "in config:s") { |c| configure(c) } dbus_method(:GetConfigModel, "out model:s") { recover_config_model } dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } - dbus_method(:GetProposal, "out proposal:s") {} + dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") {} dbus_method(:GetProgress, "out progress:s") { progress.to_json } dbus_signal(:SystemChanged) @@ -299,67 +297,46 @@ def bootloader_config_as_json # List of sorted actions. # - # @return [Hash] - # * "Device" [Integer] - # * "Text" [String] - # * "Subvol" [Boolean] - # * "Delete" [Boolean] - # * "Resize" [Boolean] - def read_actions + # @return [Hash] + # * :device [Integer] + # * :text [String] + # * :subvol [Boolean] + # * :delete [Boolean] + # * :resize [Boolean] + def actions backend.actions.map do |action| { - "Device" => action.device_sid, - "Text" => action.text, - "Subvol" => action.on_btrfs_subvolume?, - "Delete" => action.delete?, - "Resize" => action.resize? + device: action.device_sid, + text: action.text, + subvol: action.on_btrfs_subvolume?, + delete: action.delete?, + resize: action.resize? } end end - # A PropertiesChanged signal is emitted (see ::DBus::Object.dbus_reader_attr_accessor). - def update_actions - self.actions = read_actions - end - # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] + # @return [Array] def available_drives - proposal.storage_system.available_drives.map { |d| system_device_path(d) } + proposal.storage_system.available_drives.map(&:sid) end # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] + # @return [Array] def candidate_drives - proposal.storage_system.candidate_drives.map { |d| system_device_path(d) } + proposal.storage_system.candidate_drives.map(&:sid) end # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] + # @return [Array] def available_md_raids - proposal.storage_system.available_md_raids.map { |d| system_device_path(d) } + proposal.storage_system.available_md_raids.map(&:sid) end # @see Storage::System#available_drives - # @return [Array<::DBus::ObjectPath>] + # @return [Array] def candidate_md_raids - proposal.storage_system.candidate_md_raids.map { |d| system_device_path(d) } - end - - # @param device [Y2Storage::Device] - # @return [::DBus::ObjectPath] - def system_device_path(device) - system_devices_tree.path_for(device) - end - - dbus_interface STORAGE_DEVICES_INTERFACE do - # PropertiesChanged signal if storage is configured, see {#register_callbacks}. - dbus_reader_attr_accessor :actions, "aa{sv}" - - dbus_reader :available_drives, "ao" - dbus_reader :candidate_drives, "ao" - dbus_reader :available_md_raids, "ao" - dbus_reader :candidate_md_raids, "ao" + proposal.storage_system.candidate_md_raids.map(&:sid) end PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" @@ -392,36 +369,46 @@ def product_mount_points # Reads the list of possible encryption methods for the current system and product. # # @return [Array] - def read_encryption_methods + def encryption_methods Agama::Storage::EncryptionSettings .available_methods .map { |m| m.id.to_s } end - # Default volume used as template + # Default volumes to be used as templates # - # @return [Hash] - def default_volume(mount_path) - volume = volume_templates_builder.for(mount_path) - VolumeConversion.to_dbus(volume) - end - - dbus_interface PROPOSAL_CALCULATOR_INTERFACE do - dbus_reader :product_mount_points, "as" - - # PropertiesChanged signal if software is probed, see {#register_software_callbacks}. - dbus_reader_attr_accessor :encryption_methods, "as" + # @return [Array] + def volume_templates + volumes = volume_templates_builder.all + volumes << volume_templates_builder.for("") unless volumes.map(&:mount_path).include?("") - dbus_method :DefaultVolume, "in mount_path:s, out volume:a{sv}" do |mount_path| - [default_volume(mount_path)] + volumes.map do |vol| + Agama::Storage::VolumeConversions::ToJSON.new(vol).convert end + end - # @deprecated Use #Storage1.SetConfig - # - # result: 0 success; 1 error - dbus_method(:Calculate, "in settings_dbus:a{sv}, out result:u") do |settings_dbus| - busy_while { calculate_guided_proposal(settings_dbus) } - end + # NOTE: memoization of the values? + def recover_proposal + json = { + devices: json_devices(:staging), + actions: actions + } + JSON.pretty_generate(json) + end + + # NOTE: memoization of the values? + def recover_system + json = { + devices: json_devices(:probed), + availableDrives: available_drives, + availableMdRaids: available_md_raids, + candidateDrives: candidate_drives, + candidateMdRaids: candidate_md_raids, + productMountPoints: product_mount_points, + encryptionMethods: encryption_methods, + volumeTemplates: volume_templates + } + JSON.pretty_generate(json) end ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" @@ -520,12 +507,20 @@ def iscsi_delete(path) # @return [DBus::Storage::Proposal, nil] attr_reader :dbus_proposal - def register_progress_callbacks on_progress_change { self.ProgressChanged(progress.to_json) } on_progress_finish { self.ProgressFinished } end + # JSON representation of the given devicegraph from StorageManager + # + # @param meth [Symbol] method used to get the devicegraph from StorageManager + # @return [Hash] + def json_devices(meth) + devicegraph = Y2Storage::StorageManager.instance.send(meth) + Agama::Storage::DevicegraphConversions::ToJSON.new(devicegraph).convert + end + def add_s390_interfaces require "agama/dbus/storage/interfaces/dasd_manager" require "agama/dbus/storage/interfaces/zfcp_manager" @@ -550,7 +545,6 @@ def register_storage_callbacks export_proposal proposal_properties_changed refresh_staging_devices - update_actions end end @@ -571,13 +565,6 @@ def register_iscsi_callbacks end end - def register_software_callbacks - backend.software.on_probe_finished do - # A PropertiesChanged signal is emitted (see ::DBus::Object.dbus_reader_attr_accessor). - self.encryption_methods = read_encryption_methods - end - end - def storage_properties_changed properties = interfaces_and_properties[STORAGE_INTERFACE] dbus_properties_changed(STORAGE_INTERFACE, properties, []) diff --git a/service/lib/agama/storage/volume_conversions/to_json.rb b/service/lib/agama/storage/volume_conversions/to_json.rb index 4915ae68c0..09b270d627 100644 --- a/service/lib/agama/storage/volume_conversions/to_json.rb +++ b/service/lib/agama/storage/volume_conversions/to_json.rb @@ -27,6 +27,15 @@ module Agama module Storage module VolumeConversions # Volume conversion to JSON hash according to schema. + # + # This class was in the past meant to convert the 'volumes' section of the ProposalSettings, + # when we used to have a Guided strategy. So each conversion represented a volume that was + # meant to be part of the proposal (as a new partition, LV, etc.). That Guided strategy does + # not exist anymore. + # + # Now the volumes are only used to describe the templates used by the product to represent + # the suggested/acceptable settings for each mount point, since the class Volume is still + # (ab)used for that purpose. Thus, this conversion now serves that purpose. class ToJSON # @param volume [Volume] def initialize(volume) @@ -38,12 +47,17 @@ def initialize(volume) # @return [Hash] def convert { - mount: mount_conversion, - size: size_conversion, - target: target_conversion + mountPath: volume.mount_path.to_s, + mountOptions: volume.mount_options, + fsType: volume.fs_type&.to_s || "", + minSize: min_size_conversion, + autoSize: volume.auto_size?, + snapshots: volume.btrfs.snapshots?, + transactional: volume.btrfs.read_only?, + outline: outline_conversion }.tap do |volume_json| - filesystem_json = filesystem_conversion - volume_json[:filesystem] = filesystem_json if filesystem_json + # Some volumes could not have "MaxSize". + max_size_conversion(volume_json) end end @@ -52,47 +66,44 @@ def convert # @return [Volume] attr_reader :volume - def mount_conversion - { - path: volume.mount_path.to_s, - options: volume.mount_options - } + # @return [Integer] + def min_size_conversion + min_size = volume.min_size + min_size = volume.outline.base_min_size if volume.auto_size? + min_size.to_i end - def filesystem_conversion - return unless volume.fs_type - return volume.fs_type.to_s if volume.fs_type != Y2Storage::Filesystems::Type::BTRFS + # @param json [Hash] + def max_size_conversion(json) + max_size = volume.max_size + max_size = volume.outline.base_max_size if volume.auto_size? + return if max_size.unlimited? - { - btrfs: { - snapshots: volume.btrfs.snapshots? - } - } + json[:maxSize] = max_size.to_i end - def size_conversion - return "auto" if volume.auto_size? - - size = { min: volume.min_size.to_i } - size[:max] = volume.max_size.to_i if volume.max_size != Y2Storage::DiskSize.unlimited - size - end - - def target_conversion - location = volume.location + # Converts volume outline to D-Bus. + # + # @return [Hash] + # * required [Boolean] + # * fsTypes [Array] + # * supportAutoSize [Boolean] + # * adjustByRam [Boolean] + # * snapshotsConfigurable [Boolean] + # * snapshotsAffectSizes [Boolean] + # * sizeRelevantVolumes [Array] + def outline_conversion + outline = volume.outline - case location.target - when :default - "default" - when :new_partition - { newPartition: location.device } - when :new_vg - { newVg: location.device } - when :device - { device: location.device } - when :filesystem - { filesystem: location.device } - end + { + required: outline.required?, + fsTypes: outline.filesystems.map(&:to_s), + supportAutoSize: outline.adaptive_sizes?, + adjustByRam: outline.adjust_by_ram?, + snapshotsConfigurable: outline.snapshots_configurable?, + snapshotsAffectSizes: outline.snapshots_affect_sizes?, + sizeRelevantVolumes: outline.size_relevant_volumes + } end end end diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 19d4a119ab..e3fcf1c634 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -40,6 +40,10 @@ def serialize(value) JSON.pretty_generate(value) end +def parse(string) + JSON.parse(string, symbolize_names: true) +end + describe Agama::DBus::Storage::Manager do include Agama::RSpec::StorageHelpers @@ -101,328 +105,312 @@ def serialize(value) end end - describe "#read_actions" do - before do - allow(backend).to receive(:actions).and_return(actions) - end + describe "#recover_proposal" do + describe "recover_proposal[:actions]" do + before do + allow(backend).to receive(:actions).and_return(actions) + end - context "if there are no actions" do - let(:actions) { [] } + context "if there are no actions" do + let(:actions) { [] } - it "returns an empty list" do - expect(subject.actions).to eq([]) + it "returns an empty list" do + expect(parse(subject.recover_proposal)[:actions]).to eq([]) + end end - end - context "if there are actions" do - let(:actions) { [action1, action2, action3, action4] } - - let(:action1) do - instance_double(Agama::Storage::Action, - text: "test1", - device_sid: 1, - on_btrfs_subvolume?: false, - delete?: false, - resize?: false) - end - - let(:action2) do - instance_double(Agama::Storage::Action, - text: "test2", - device_sid: 2, - on_btrfs_subvolume?: false, - delete?: true, - resize?: false) - end - - let(:action3) do - instance_double(Agama::Storage::Action, - text: "test3", - device_sid: 3, - on_btrfs_subvolume?: false, - delete?: false, - resize?: true) - end - - let(:action4) do - instance_double(Agama::Storage::Action, - text: "test4", - device_sid: 4, - on_btrfs_subvolume?: true, - delete?: false, - resize?: false) - end - - it "returns a list with a hash for each action" do - expect(subject.actions.size).to eq(4) - expect(subject.actions).to all(be_a(Hash)) - - action1, action2, action3, action4 = subject.actions - - expect(action1).to eq({ - "Device" => 1, - "Text" => "test1", - "Subvol" => false, - "Delete" => false, - "Resize" => false - }) + context "if there are actions" do + let(:actions) { [action1, action2, action3, action4] } - expect(action2).to eq({ - "Device" => 2, - "Text" => "test2", - "Subvol" => false, - "Delete" => true, - "Resize" => false - }) + let(:action1) do + instance_double(Agama::Storage::Action, + text: "test1", + device_sid: 1, + on_btrfs_subvolume?: false, + delete?: false, + resize?: false) + end - expect(action3).to eq({ - "Device" => 3, - "Text" => "test3", - "Subvol" => false, - "Delete" => false, - "Resize" => true - }) - expect(action4).to eq({ - "Device" => 4, - "Text" => "test4", - "Subvol" => true, - "Delete" => false, - "Resize" => false - }) - end - end - end + let(:action2) do + instance_double(Agama::Storage::Action, + text: "test2", + device_sid: 2, + on_btrfs_subvolume?: false, + delete?: true, + resize?: false) + end - describe "#available_drives" do - before do - allow(proposal.storage_system).to receive(:available_drives).and_return(drives) - end + let(:action3) do + instance_double(Agama::Storage::Action, + text: "test3", + device_sid: 3, + on_btrfs_subvolume?: false, + delete?: false, + resize?: true) + end - context "if there is no available drives" do - let(:drives) { [] } + let(:action4) do + instance_double(Agama::Storage::Action, + text: "test4", + device_sid: 4, + on_btrfs_subvolume?: true, + delete?: false, + resize?: false) + end - it "returns an empty list" do - expect(subject.available_drives).to eq([]) - end - end + it "returns a list with a hash for each action" do + all_actions = parse(subject.recover_proposal)[:actions] + expect(all_actions.size).to eq(4) + expect(all_actions).to all(be_a(Hash)) - context "if there are available drives" do - let(:drives) { [drive1, drive2, drive3] } + action1, action2, action3, action4 = all_actions - let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } - let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } - let(:drive3) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 97) } + expect(action1).to eq({ + device: 1, + text: "test1", + subvol: false, + delete: false, + resize: false + }) - it "retuns the path of each drive" do - result = subject.available_drives + expect(action2).to eq({ + device: 2, + text: "test2", + subvol: false, + delete: true, + resize: false + }) - expect(result).to contain_exactly( - /system\/95/, - /system\/96/, - /system\/97/ - ) + expect(action3).to eq({ + device: 3, + text: "test3", + subvol: false, + delete: false, + resize: true + }) + expect(action4).to eq({ + device: 4, + text: "test4", + subvol: true, + delete: false, + resize: false + }) + end end end end - describe "#candidate_drives" do + describe "#recover_system" do before do - allow(proposal.storage_system).to receive(:candidate_drives).and_return(drives) + allow(proposal.storage_system).to receive(:available_drives).and_return(available_drives) + allow(proposal.storage_system).to receive(:candidate_drives).and_return(candidate_drives) + allow(proposal.storage_system).to receive(:available_md_raids).and_return(available_raids) + allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(candidate_raids) end - context "if there is no candidate drives" do - let(:drives) { [] } + let(:available_drives) { [] } + let(:candidate_drives) { [] } + let(:available_raids) { [] } + let(:candidate_raids) { [] } - it "returns an empty list" do - expect(subject.candidate_drives).to eq([]) - end - end + describe "recover_system[:availableDrives]" do + context "if there is no available drives" do + let(:available_drives) { [] } - context "if there are candidate drives" do - let(:drives) { [drive1, drive2] } + it "returns an empty list" do + expect(parse(subject.recover_system)[:availableDrives]).to eq([]) + end + end - let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } - let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } + context "if there are available drives" do + let(:available_drives) { [drive1, drive2, drive3] } - it "retuns the path of each drive" do - result = subject.candidate_drives + let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } + let(:drive3) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 97) } - expect(result).to contain_exactly( - /system\/95/, - /system\/96/ - ) + it "retuns the id of each drive" do + result = parse(subject.recover_system)[:availableDrives] + expect(result).to contain_exactly(95, 96, 97) + end end end - end - - describe "#available_md_raids" do - before do - allow(proposal.storage_system).to receive(:available_md_raids).and_return(md_raids) - end - context "if there is no available MD RAIDs" do - let(:md_raids) { [] } + describe "recover_system[:candidateDrives]" do + context "if there is no candidate drives" do + let(:candidate_drives) { [] } - it "returns an empty list" do - expect(subject.available_md_raids).to eq([]) + it "returns an empty list" do + expect(parse(subject.recover_system)[:candidateDrives]).to eq([]) + end end - end - - context "if there are available MD RAIDs" do - let(:md_raids) { [md_raid1, md_raid2, md_raid3] } - let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } - let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } - let(:md_raid3) { instance_double(Y2Storage::Md, name: "/dev/md2", sid: 102) } + context "if there are candidate drives" do + let(:candidate_drives) { [drive1, drive2] } - it "retuns the path of each MD RAID" do - result = subject.available_md_raids + let(:drive1) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + let(:drive2) { instance_double(Y2Storage::Disk, name: "/dev/vdb", sid: 96) } - expect(result).to contain_exactly( - /system\/100/, - /system\/101/, - /system\/102/ - ) + it "retuns the id of each drive" do + result = parse(subject.recover_system)[:candidateDrives] + expect(result).to contain_exactly(95, 96) + end end end - end - describe "#candidate_md_raids" do - before do - allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(md_raids) - end - - context "if there is no candidate MD RAIDs" do - let(:md_raids) { [] } + describe "recover_system[:availableMdRaids]" do + context "if there is no available MD RAIDs" do + let(:available_raids) { [] } - it "returns an empty list" do - expect(subject.candidate_md_raids).to eq([]) + it "returns an empty list" do + expect(parse(subject.recover_system)[:availableMdRaids]).to eq([]) + end end - end - - context "if there are candidate MD RAIDs" do - let(:md_raids) { [md_raid1, md_raid2] } - let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } - let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } + context "if there are available MD RAIDs" do + let(:available_raids) { [md_raid1, md_raid2, md_raid3] } - it "retuns the path of each MD RAID" do - result = subject.candidate_md_raids + let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } + let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } + let(:md_raid3) { instance_double(Y2Storage::Md, name: "/dev/md2", sid: 102) } - expect(result).to contain_exactly( - /system\/100/, - /system\/101/ - ) + it "returns the id of each MD RAID" do + result = parse(subject.recover_system)[:availableMdRaids] + expect(result).to contain_exactly(100, 101, 102) + end end end - end - describe "#product_mount_points" do - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } - end + describe "recover_system[:candidateMdRaids]" do + context "if there is no candidate MD RAIDs" do + let(:candidate_raids) { [] } - context "with no storage section in the configuration" do - let(:cfg_templates) { [] } + it "returns an empty list" do + expect(parse(subject.recover_system)[:candidateMdRaids]).to eq([]) + end + end + + context "if there are candidate MD RAIDs" do + let(:candidate_raids) { [md_raid1, md_raid2] } + + let(:md_raid1) { instance_double(Y2Storage::Md, name: "/dev/md0", sid: 100) } + let(:md_raid2) { instance_double(Y2Storage::Md, name: "/dev/md1", sid: 101) } - it "returns an empty list" do - expect(subject.product_mount_points).to eq([]) + it "retuns the path of each MD RAID" do + result = parse(subject.recover_system)[:candidateMdRaids] + expect(result).to contain_exactly(100, 101) + end end end - context "with a set of volume templates in the configuration" do - let(:cfg_templates) do - [ - { "mount_path" => "/" }, - { "mount_path" => "swap" }, - { "mount_path" => "/home" }, - { "filesystem" => "ext4" } - ] + describe "recover_system[:productMountPoints]" do + let(:config_data) do + { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } end - it "returns the mount points of each volume template" do - expect(subject.product_mount_points).to contain_exactly("/", "swap", "/home") + context "with no storage section in the configuration" do + let(:cfg_templates) { [] } + + it "contains an empty list" do + expect(parse(subject.recover_system)[:productMountPoints]).to eq([]) + end end - end - end - describe "#default_volume" do - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } + context "with a set of volume templates in the configuration" do + let(:cfg_templates) do + [ + { "mount_path" => "/" }, + { "mount_path" => "swap" }, + { "mount_path" => "/home" }, + { "filesystem" => "ext4" } + ] + end + + it "contains the mount points of each volume template" do + result = parse(subject.recover_system) + expect(result[:productMountPoints]).to contain_exactly("/", "swap", "/home") + end + end end - context "with no storage section in the configuration" do - let(:cfg_templates) { [] } + describe "recover_system[:volumeTemplates]" do + let(:config_data) do + { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } + end - it "returns the same generic default volume for any path" do - generic = { - "FsType" => "ext4", "MountOptions" => [], - "MinSize" => 0, "AutoSize" => false - } - generic_outline = { "Required" => false, "FsTypes" => [], "SupportAutoSize" => false } + context "with no storage section in the configuration" do + let(:cfg_templates) { [] } - expect(subject.default_volume("/")).to include(generic) - expect(subject.default_volume("/")["Outline"]).to include(generic_outline) + it "contains only a generic default template with empty path" do + generic = { fsType: "ext4", mountOptions: [], minSize: 0, autoSize: false } + generic_outline = { required: false, fsTypes: [], supportAutoSize: false } - expect(subject.default_volume("swap")).to include(generic) - expect(subject.default_volume("swap")["Outline"]).to include(generic_outline) + templates = parse(subject.recover_system)[:volumeTemplates] + expect(templates.size).to eq 1 - expect(subject.default_volume("/foo")).to include(generic) - expect(subject.default_volume("/foo")["Outline"]).to include(generic_outline) + expect(templates.first).to include(generic) + expect(templates.first[:outline]).to include(generic_outline) + end end - end - context "with a set of volume templates in the configuration" do - let(:cfg_templates) do - [ - { - "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, - "outline" => { - "required" => true, - "filesystems" => ["btrfs"], - "auto_size" => { - "base_min" => "5 GiB", "base_max" => "20 GiB", "min_fallback_for" => "/home" + context "with a set of volume templates in the configuration" do + let(:cfg_templates) do + [ + { + "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, + "outline" => { + "required" => true, + "filesystems" => ["btrfs"], + "auto_size" => { + "base_min" => "5 GiB", "base_max" => "20 GiB", "min_fallback_for" => "/home" + } } + }, + { + "mount_path" => "swap", "filesystem" => "swap", + "size" => { "auto" => false, "min" => "1 GiB", "max" => "2 GiB" }, + "outline" => { "required" => false, "filesystems" => ["swap"] } + }, + { + "mount_path" => "/home", "filesystem" => "xfs", + "size" => { "auto" => false, "min" => "10 GiB" }, + "outline" => { "required" => false, "filesystems" => ["xfs", "ext2"] } + }, + { + "filesystem" => "ext4", "size" => { "auto" => false, "min" => "10 GiB" }, + "outline" => { "filesystems" => ["ext3", "ext4", "xfs"] } } - }, - { - "mount_path" => "swap", "filesystem" => "swap", - "size" => { "auto" => false, "min" => "1 GiB", "max" => "2 GiB" }, - "outline" => { "required" => false, "filesystems" => ["swap"] } - }, - { - "mount_path" => "/home", "filesystem" => "xfs", - "size" => { "auto" => false, "min" => "10 GiB" }, - "outline" => { "required" => false, "filesystems" => ["xfs", "ext2"] } - }, - { - "filesystem" => "ext4", "size" => { "auto" => false, "min" => "10 GiB" }, - "outline" => { "filesystems" => ["ext3", "ext4", "xfs"] } - } - ] - end + ] + end - it "returns the appropriate volume if there is a corresponding template" do - expect(subject.default_volume("/")).to include("FsType" => "btrfs", "AutoSize" => true) - expect(subject.default_volume("/")["Outline"]).to include( - "Required" => true, "FsTypes" => ["btrfs"], - "SupportAutoSize" => true, "SizeRelevantVolumes" => ["/home"] - ) + it "contains a template for every relevant mount path" do + templates = parse(subject.recover_system)[:volumeTemplates] - expect(subject.default_volume("swap")).to include( - "FsType" => "swap", "AutoSize" => false, "MinSize" => 1024**3, "MaxSize" => 2 * (1024**3) - ) - expect(subject.default_volume("swap")["Outline"]).to include( - "Required" => false, "FsTypes" => ["swap"], "SupportAutoSize" => false - ) - end + root = templates.find { |v| v[:mountPath] == "/" } + expect(root).to include(fsType: "btrfs", autoSize: true) + expect(root[:outline]).to include( + required: true, fsTypes: ["btrfs"], + supportAutoSize: true, sizeRelevantVolumes: ["/home"] + ) + + swap = templates.find { |v| v[:mountPath] == "swap" } + expect(swap).to include( + fsType: "swap", autoSize: false, minSize: 1024**3, maxSize: 2 * (1024**3) + ) + expect(swap[:outline]).to include( + required: false, fsTypes: ["swap"], supportAutoSize: false + ) + end - it "returns the default volume for any path without a template" do - default = { "FsType" => "ext4", "AutoSize" => false, "MinSize" => 10 * (1024**3) } - default_outline = { "FsTypes" => ["ext3", "ext4", "xfs"], "SupportAutoSize" => false } + it "constains the expected default template" do + default = { fsType: "ext4", autoSize: false, minSize: 10 * (1024**3) } + default_outline = { fsTypes: ["ext3", "ext4", "xfs"], supportAutoSize: false } - expect(subject.default_volume("/foo")).to include(default) - expect(subject.default_volume("/foo")["Outline"]).to include(default_outline) + templates = parse(subject.recover_system)[:volumeTemplates] + template = templates.find { |v| v[:mountPath] == "" } + expect(template).to include(default) + expect(template[:outline]).to include(default_outline) + end end end end diff --git a/service/test/agama/storage/volume_conversions/to_json_test.rb b/service/test/agama/storage/volume_conversions/to_json_test.rb index 11cb600328..81b52c904f 100644 --- a/service/test/agama/storage/volume_conversions/to_json_test.rb +++ b/service/test/agama/storage/volume_conversions/to_json_test.rb @@ -52,42 +52,60 @@ # @todo Check whether the result matches the JSON schema. expect(described_class.new(default_volume).convert).to eq( - mount: { - path: "/test", - options: [] - }, - size: { - min: 0 - }, - target: "default" + mountPath: "/test", + mountOptions: [], + fsType: "", + minSize: 0, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } ) expect(described_class.new(custom_volume1).convert).to eq( - mount: { - path: "/test", - options: [] - }, - filesystem: "xfs", - size: "auto", - target: "default" + mountPath: "/test", + mountOptions: [], + fsType: "xfs", + minSize: 0, + autoSize: true, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } ) expect(described_class.new(custom_volume2).convert).to eq( - mount: { - path: "/test", - options: ["rw", "default"] - }, - size: { - min: 1024, - max: 2048 - }, - target: { - newPartition: "/dev/sda" - }, - filesystem: { - btrfs: { - snapshots: true - } + mountPath: "/test", + mountOptions: ["rw", "default"], + fsType: "btrfs", + minSize: 1024, + maxSize: 2048, + autoSize: false, + snapshots: true, + transactional: false, + outline: { + required: false, + fsTypes: [], + supportAutoSize: false, + adjustByRam: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] } ) end From c7ec8d922028c7191344dfec0bb216819f241562 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 23 Oct 2025 15:31:27 +0200 Subject: [PATCH 249/917] Delete some dead code --- service/lib/agama/dbus/manager_service.rb | 1 - service/lib/agama/dbus/storage/device.rb | 97 ----- .../lib/agama/dbus/storage/devices_tree.rb | 107 ------ .../agama/dbus/storage/interfaces/device.rb | 45 --- .../dbus/storage/interfaces/device/block.rb | 132 ------- .../storage/interfaces/device/component.rb | 99 ----- .../dbus/storage/interfaces/device/device.rb | 82 ---- .../dbus/storage/interfaces/device/drive.rb | 156 -------- .../storage/interfaces/device/filesystem.rb | 93 ----- .../dbus/storage/interfaces/device/lvm_lv.rb | 66 ---- .../dbus/storage/interfaces/device/lvm_vg.rb | 82 ---- .../dbus/storage/interfaces/device/md.rb | 82 ---- .../storage/interfaces/device/multipath.rb | 68 ---- .../storage/interfaces/device/partition.rb | 74 ---- .../interfaces/device/partition_table.rb | 87 ----- .../dbus/storage/interfaces/device/raid.rb | 68 ---- service/lib/agama/dbus/storage/manager.rb | 63 ---- service/lib/agama/dbus/storage/proposal.rb | 98 ----- .../storage/proposal_settings_conversion.rb | 51 --- .../proposal_settings_conversion/from_dbus.rb | 279 -------------- .../proposal_settings_conversion/to_dbus.rb | 174 --------- .../agama/dbus/storage/volume_conversion.rb | 51 --- .../storage/volume_conversion/from_dbus.rb | 224 ----------- .../dbus/storage/volume_conversion/to_dbus.rb | 113 ------ service/lib/agama/storage/proposal.rb | 47 +-- .../storage/proposal_settings_conversions.rb | 3 - .../from_json.rb | 161 -------- .../from_y2storage.rb | 94 ----- .../proposal_settings_conversions/to_json.rb | 113 ------ .../lib/agama/storage/proposal_strategies.rb | 1 - .../storage/proposal_strategies/guided.rb | 165 -------- .../lib/agama/storage/volume_conversions.rb | 2 - .../storage/volume_conversions/from_json.rb | 155 -------- .../volume_conversions/from_y2storage.rb | 92 ----- .../test/agama/dbus/storage/device_test.rb | 282 -------------- .../agama/dbus/storage/devices_tree_test.rb | 153 -------- .../test/agama/dbus/storage/manager_test.rb | 221 ----------- .../from_dbus_test.rb | 280 -------------- .../to_dbus_test.rb | 164 -------- .../proposal_settings_conversion_test.rb | 49 --- .../test/agama/dbus/storage/proposal_test.rb | 193 ---------- .../volume_conversion/from_dbus_test.rb | 264 ------------- .../storage/volume_conversion/to_dbus_test.rb | 138 ------- .../dbus/storage/volume_conversion_test.rb | 49 --- service/test/agama/storage/manager_test.rb | 21 +- .../from_json_test.rb | 293 --------------- .../from_y2storage_test.rb | 81 ---- .../to_json_test.rb | 119 ------ .../agama/storage/proposal_settings_test.rb | 26 -- service/test/agama/storage/proposal_test.rb | 286 -------------- .../agama/storage/proposal_volumes_test.rb | 351 ------------------ .../volume_conversions/from_json_test.rb | 279 -------------- .../volume_conversions/from_y2storage_test.rb | 153 -------- service/test/agama/storage/volume_test.rb | 18 - 54 files changed, 18 insertions(+), 6627 deletions(-) delete mode 100644 service/lib/agama/dbus/storage/device.rb delete mode 100644 service/lib/agama/dbus/storage/devices_tree.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/block.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/component.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/device.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/drive.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/filesystem.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/md.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/multipath.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/partition.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/partition_table.rb delete mode 100644 service/lib/agama/dbus/storage/interfaces/device/raid.rb delete mode 100644 service/lib/agama/dbus/storage/proposal.rb delete mode 100644 service/lib/agama/dbus/storage/proposal_settings_conversion.rb delete mode 100644 service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb delete mode 100644 service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb delete mode 100644 service/lib/agama/dbus/storage/volume_conversion.rb delete mode 100644 service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb delete mode 100644 service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb delete mode 100644 service/lib/agama/storage/proposal_settings_conversions/from_json.rb delete mode 100644 service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb delete mode 100644 service/lib/agama/storage/proposal_settings_conversions/to_json.rb delete mode 100644 service/lib/agama/storage/proposal_strategies/guided.rb delete mode 100644 service/lib/agama/storage/volume_conversions/from_json.rb delete mode 100644 service/lib/agama/storage/volume_conversions/from_y2storage.rb delete mode 100644 service/test/agama/dbus/storage/device_test.rb delete mode 100644 service/test/agama/dbus/storage/devices_tree_test.rb delete mode 100644 service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb delete mode 100644 service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb delete mode 100644 service/test/agama/dbus/storage/proposal_settings_conversion_test.rb delete mode 100644 service/test/agama/dbus/storage/proposal_test.rb delete mode 100644 service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb delete mode 100644 service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb delete mode 100644 service/test/agama/dbus/storage/volume_conversion_test.rb delete mode 100644 service/test/agama/storage/proposal_settings_conversions/from_json_test.rb delete mode 100644 service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb delete mode 100644 service/test/agama/storage/proposal_settings_conversions/to_json_test.rb delete mode 100644 service/test/agama/storage/proposal_volumes_test.rb delete mode 100644 service/test/agama/storage/volume_conversions/from_json_test.rb delete mode 100644 service/test/agama/storage/volume_conversions/from_y2storage_test.rb diff --git a/service/lib/agama/dbus/manager_service.rb b/service/lib/agama/dbus/manager_service.rb index 7e3d8c12ce..5804d9623b 100644 --- a/service/lib/agama/dbus/manager_service.rb +++ b/service/lib/agama/dbus/manager_service.rb @@ -25,7 +25,6 @@ require "agama/dbus/bus" require "agama/dbus/manager" require "agama/dbus/users" -require "agama/dbus/storage/proposal" module Agama module DBus diff --git a/service/lib/agama/dbus/storage/device.rb b/service/lib/agama/dbus/storage/device.rb deleted file mode 100644 index 17ecbe167e..0000000000 --- a/service/lib/agama/dbus/storage/device.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "agama/dbus/base_object" -require "agama/dbus/storage/interfaces/device" - -module Agama - module DBus - module Storage - # Class for D-Bus objects representing a storage device (e.g., Disk, Partition, VG, etc). - # - # The D-Bus object includes the required interfaces for the storage object that it represents. - class Device < BaseObject - # sid of the Y2Storage device. - # - # @note A Y2Storage device is a wrapper over a libstorage-ng object. If the source - # devicegraph does not exist anymore (e.g., after reprobing), then the Y2Storage device - # object cannot be used (memory error). The device sid is stored to avoid accessing to - # the old Y2Storage device when updating the represented device, see {#storage_device=}. - # - # @return [Integer] - attr_reader :sid - - # Constructor - # - # @param storage_device [Y2Storage::Device] Storage device - # @param path [::DBus::ObjectPath] Path for the D-Bus object - # @param tree [DevicesTree] D-Bus tree in which the device is exported - # @param logger [Logger, nil] - def initialize(storage_device, path, tree, logger: nil) - super(path, logger: logger) - - @storage_device = storage_device - @sid = storage_device.sid - @tree = tree - add_interfaces - end - - # Sets the represented storage device. - # - # @note A properties changed signal is emitted for each interface. - # @raise [RuntimeError] If the given device has a different sid. - # - # @param value [Y2Storage::Device] - def storage_device=(value) - if value.sid != sid - raise "Cannot update the D-Bus object because the given device has a different sid: " \ - "#{value} instead of #{sid}" - end - - @storage_device = value - @sid = value.sid - - interfaces_and_properties.each do |interface, properties| - dbus_properties_changed(interface, properties, []) - end - end - - private - - # @return [DevicesTree] - attr_reader :tree - - # @return [Y2Storage::Device] - attr_reader :storage_device - - # Adds the required interfaces according to the storage object. - def add_interfaces - interfaces = Interfaces::Device.constants - .map { |c| Interfaces::Device.const_get(c) } - .select { |c| c.is_a?(Module) && c.respond_to?(:apply?) && c.apply?(storage_device) } - - interfaces.each { |i| singleton_class.include(i) } - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/devices_tree.rb b/service/lib/agama/dbus/storage/devices_tree.rb deleted file mode 100644 index 5963971e3d..0000000000 --- a/service/lib/agama/dbus/storage/devices_tree.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/base_tree" -require "agama/dbus/storage/device" -require "dbus/object_path" - -module Agama - module DBus - module Storage - # Class representing a storage devices tree exported on D-Bus - class DevicesTree < BaseTree - # Object path for the D-Bus object representing the given device - # - # @param device [Y2Storage::Device] - # @return [::DBus::ObjectPath] - def path_for(device) - ::DBus::ObjectPath.new(File.join(root_path, device.sid.to_s)) - end - - # Updates the D-Bus tree according to the given devicegraph. - # - # @note In the devices tree it is important to avoid updating D-Bus nodes. Note that an - # already exported D-Bus object could require to add or remove interfaces (e.g., an - # existing partition needs to add the Filesystem interface after formatting the - # partition). Dynamically adding or removing interfaces is not possible with ruby-dbus - # once the object is exported on D-Bus. - # - # Updating the currently exported D-Bus objects is avoided by calling to {#clean} first. - # - # @param devicegraph [Y2Storage::Devicegraph] - def update(devicegraph) - clean - self.objects = devices(devicegraph) - end - - private - - # @see BaseTree - # @param device [Y2Storage::Device] - def create_dbus_object(device) - Device.new(device, path_for(device), self, logger: logger) - end - - # @see BaseTree - # - # @note D-Bus objects representing devices cannot be safely updated, see {#update}. - def update_dbus_object(_dbus_object, _device) - nil - end - - # @see BaseTree - # @param dbus_object [Device] - # @param device [Y2Storage::Device] - def dbus_object?(dbus_object, device) - dbus_object.sid == device.sid - end - - # Devices to be exported. - # - # Right now, only the required information for calculating a proposal is exported, that is: - # * Potential candidate devices (i.e., disk devices, MDs). - # * Partitions of the candidate devices in order to indicate how to find free space. - # * LVM volume groups and logical volumes. - # - # @param devicegraph [Y2Storage::Devicegraph] - # @return [Array] - def devices(devicegraph) - devices = devicegraph.disk_devices + - devicegraph.stray_blk_devices + - devicegraph.software_raids + - devicegraph.lvm_vgs + - devicegraph.lvm_lvs - - devices + partitions_from(devices) - end - - # All partitions of the given devices. - # - # @param devices [Array] - # @return [Array] - def partitions_from(devices) - devices.select { |d| d.is?(:blk_device) && d.respond_to?(:partitions) } - .flat_map(&:partitions) - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device.rb b/service/lib/agama/dbus/storage/interfaces/device.rb deleted file mode 100644 index 6376f6a0c4..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -module Agama - module DBus - module Storage - module Interfaces - # Module for D-Bus interfaces of a device. - module Device - end - end - end - end -end - -require "agama/dbus/storage/interfaces/device/block" -require "agama/dbus/storage/interfaces/device/component" -require "agama/dbus/storage/interfaces/device/device" -require "agama/dbus/storage/interfaces/device/drive" -require "agama/dbus/storage/interfaces/device/filesystem" -require "agama/dbus/storage/interfaces/device/lvm_lv" -require "agama/dbus/storage/interfaces/device/lvm_vg" -require "agama/dbus/storage/interfaces/device/md" -require "agama/dbus/storage/interfaces/device/multipath" -require "agama/dbus/storage/interfaces/device/partition" -require "agama/dbus/storage/interfaces/device/partition_table" -require "agama/dbus/storage/interfaces/device/raid" diff --git a/service/lib/agama/dbus/storage/interfaces/device/block.rb b/service/lib/agama/dbus/storage/interfaces/device/block.rb deleted file mode 100644 index d9f3f5ad8f..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/block.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "agama/storage/device_shrinking" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for block devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Block - # Whether this interface should be implemented for the given device. - # - # @note Block devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) - end - - BLOCK_INTERFACE = "org.opensuse.Agama.Storage1.Block" - private_constant :BLOCK_INTERFACE - - # Position of the first block of the region. - # - # @return [Integer] - def block_start - storage_device.start - end - - # Whether the block device is currently active - # - # @return [Boolean] - def block_active - storage_device.active? - end - - # Whether the block device is encrypted. - # - # @return [Boolean] - def block_encrypted - storage_device.encrypted? - end - - # Name of the udev by-id links - # - # @return [Array] - def block_udev_ids - storage_device.udev_ids - end - - # Name of the udev by-path links - # - # @return [Array] - def block_udev_paths - storage_device.udev_paths - end - - # Size of the block device in bytes - # - # @return [Integer] - def block_size - storage_device.size.to_i - end - - # Shrinking information. - # - # @return [Hash] - def block_shrinking - shrinking = Agama::Storage::DeviceShrinking.new(storage_device) - - if shrinking.supported? - { "Supported" => shrinking.min_size.to_i } - else - { "Unsupported" => shrinking.unsupported_reasons } - end - end - - # Name of the currently installed systems - # - # @return [Array] - def block_systems - return @systems if @systems - - filesystems = storage_device.descendants.select { |d| d.is?(:filesystem) } - @systems = filesystems.map(&:system_name).compact - end - - def self.included(base) - base.class_eval do - dbus_interface BLOCK_INTERFACE do - dbus_reader :block_start, "t", dbus_name: "Start" - dbus_reader :block_active, "b", dbus_name: "Active" - dbus_reader :block_encrypted, "b", dbus_name: "Encrypted" - dbus_reader :block_udev_ids, "as", dbus_name: "UdevIds" - dbus_reader :block_udev_paths, "as", dbus_name: "UdevPaths" - dbus_reader :block_size, "t", dbus_name: "Size" - dbus_reader :block_shrinking, "a{sv}", dbus_name: "Shrinking" - dbus_reader :block_systems, "as", dbus_name: "Systems" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/component.rb b/service/lib/agama/dbus/storage/interfaces/device/component.rb deleted file mode 100644 index a4de9ce3e3..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/component.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for devices that are used as component of other device (e.g., physical volume, - # MD RAID device, etc). - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Component - # Whether this interface should be implemented for the given device. - # - # @note Components of other devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && storage_device.component_of.any? - end - - COMPONENT_INTERFACE = "org.opensuse.Agama.Storage1.Component" - private_constant :COMPONENT_INTERFACE - - # Type of component. - # - # @return ["physical_volume", "md_device", "raid_device", "multipath_wire", - # "bcache_device", "bcache_cset_device", "md_btrfs_device", ""] Empty if type is - # unknown. - def component_type - types = { - lvm_vg: "physical_volume", - md: "md_device", - dm_raid: "raid_device", - multipath: "multipath_wire", - bcache: "bcache_device", - bcache_cset: "bcache_cset_device", - btrfs: "md_btrfs_device" - } - - device = storage_device.component_of.first - - types.find { |k, _v| device.is?(k) }&.last || "" - end - - # Name of the devices for which this device is component of. - # - # @return [Array] - def component_device_names - storage_device.component_of.map(&:display_name).compact - end - - # Paths of the D-Bus objects representing the devices. - # - # @return [Array<::DBus::ObjectPath>] - def component_devices - storage_device.component_of.map { |p| tree.path_for(p) } - end - - def self.included(base) - base.class_eval do - dbus_interface COMPONENT_INTERFACE do - dbus_reader :component_type, "s", dbus_name: "Type" - # The names are provided just in case the device is component of a device that - # is not exported yet (e.g., Bcache devices). - dbus_reader :component_device_names, "as", dbus_name: "DeviceNames" - dbus_reader :component_devices, "ao", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/device.rb b/service/lib/agama/dbus/storage/interfaces/device/device.rb deleted file mode 100644 index 21ff432bb7..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/device.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "y2storage/device_description" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for a device. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device}. - module Device - # Whether this interface should be implemented for the given device. - # - # @note All devices implement this interface. - # - # @param _storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(_storage_device) - true - end - - DEVICE_INTERFACE = "org.opensuse.Agama.Storage1.Device" - private_constant :DEVICE_INTERFACE - - # sid of the device. - # - # @return [Integer] - def device_sid - storage_device.sid - end - - # Name to represent the device. - # - # @return [String] e.g., "/dev/sda". - def device_name - storage_device.display_name || "" - end - - # Description of the device. - # - # @return [String] e.g., "EXT4 Partition". - def device_description - Y2Storage::DeviceDescription.new(storage_device, include_encryption: true).to_s - end - - def self.included(base) - base.class_eval do - dbus_interface DEVICE_INTERFACE do - dbus_reader :device_sid, "u", dbus_name: "SID" - dbus_reader :device_name, "s", dbus_name: "Name" - dbus_reader :device_description, "s", dbus_name: "Description" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/drive.rb b/service/lib/agama/dbus/storage/interfaces/device/drive.rb deleted file mode 100644 index 51c2f0d7c0..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/drive.rb +++ /dev/null @@ -1,156 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for drive devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Drive - # Whether this interface should be implemented for the given device. - # - # @note Drive devices implement this interface. - # Drive and disk device are very close concepts, but there are subtle differences. For - # example, a MD RAID is never considered as a drive. - # - # TODO: Revisit the defintion of drive. Maybe some MD devices could implement the drive - # interface if hwinfo provides useful information for them. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:disk, :dm_raid, :multipath, :dasd) && - storage_device.is?(:disk_device) - end - - DRIVE_INTERFACE = "org.opensuse.Agama.Storage1.Drive" - private_constant :DRIVE_INTERFACE - - # Drive type - # - # @return ["disk", "raid", "multipath", "dasd", ""] Empty if type is unknown. - def drive_type - if storage_device.is?(:disk) - "disk" - elsif storage_device.is?(:dm_raid) - "raid" - elsif storage_device.is?(:multipath) - "multipath" - elsif storage_device.is?(:dasd) - "dasd" - else - "" - end - end - - # Vendor name - # - # @return [String] - def drive_vendor - storage_device.vendor || "" - end - - # Model name - # - # @return [String] - def drive_model - storage_device.model || "" - end - - # Bus name - # - # @return [String] - def drive_bus - # FIXME: not sure whether checking for "none" is robust enough - return "" if storage_device.bus.nil? || storage_device.bus.casecmp?("none") - - storage_device.bus - end - - # Bus Id for DASD - # - # @return [String] - def drive_bus_id - return "" unless storage_device.respond_to?(:bus_id) - - storage_device.bus_id - end - - # Kernel drivers used by the device - # - # @return [Array] - def drive_driver - storage_device.driver - end - - # Data transport layer, if any - # - # @return [String] - def drive_transport - return "" unless storage_device.respond_to?(:transport) - - transport = storage_device.transport - return "" if transport.nil? || transport.is?(:unknown) - - # FIXME: transport does not have proper i18n support at yast2-storage-ng, so we are - # just duplicating some logic from yast2-storage-ng here - return "USB" if transport.is?(:usb) - return "IEEE 1394" if transport.is?(:sbp) - - transport.to_s - end - - # More info about the device - # - # @return [Hash] - def drive_info - { - "SDCard" => storage_device.sd_card?, - "DellBOSS" => storage_device.boss? - } - end - - def self.included(base) - base.class_eval do - dbus_interface DRIVE_INTERFACE do - dbus_reader :drive_type, "s", dbus_name: "Type" - dbus_reader :drive_vendor, "s", dbus_name: "Vendor" - dbus_reader :drive_model, "s", dbus_name: "Model" - dbus_reader :drive_bus, "s", dbus_name: "Bus" - dbus_reader :drive_bus_id, "s", dbus_name: "BusId" - dbus_reader :drive_driver, "as", dbus_name: "Driver" - dbus_reader :drive_transport, "s", dbus_name: "Transport" - dbus_reader :drive_info, "a{sv}", dbus_name: "Info" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb deleted file mode 100644 index 5f2a4a1b56..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" -require "y2storage/filesystem_label" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for file systems. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Filesystem - # Whether this interface should be implemented for the given device. - # - # @note Formatted devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && !storage_device.filesystem.nil? - end - - FILESYSTEM_INTERFACE = "org.opensuse.Agama.Storage1.Filesystem" - private_constant :FILESYSTEM_INTERFACE - - # SID of the file system. - # - # It is useful to detect whether a file system is new. - # - # @return [Integer] - def filesystem_sid - storage_device.filesystem.sid - end - - # File system type. - # - # @return [String] e.g., "ext4" - def filesystem_type - storage_device.filesystem.type.to_s - end - - # Mount path of the file system. - # - # @return [String] Empty if not mounted. - def filesystem_mount_path - storage_device.filesystem.mount_path || "" - end - - # Label of the file system. - # - # @return [String] Empty if it has no label. - def filesystem_label - Y2Storage::FilesystemLabel.new(storage_device).to_s - end - - def self.included(base) - base.class_eval do - dbus_interface FILESYSTEM_INTERFACE do - dbus_reader :filesystem_sid, "u", dbus_name: "SID" - dbus_reader :filesystem_type, "s", dbus_name: "Type" - dbus_reader :filesystem_mount_path, "s", dbus_name: "MountPath" - dbus_reader :filesystem_label, "s", dbus_name: "Label" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb deleted file mode 100644 index 93757ffdfc..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for LVM logical volume. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module LvmLv - # Whether this interface should be implemented for the given device. - # - # @note LVM logical volumes implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:lvm_lv) - end - - LOGICAL_VOLUME_INTERFACE = "org.opensuse.Agama.Storage1.LVM.LogicalVolume" - private_constant :LOGICAL_VOLUME_INTERFACE - - # LVM volume group hosting the this logical volume. - # - # @return [Array<::DBus::ObjectPath>] - def lvm_lv_vg - tree.path_for(storage_device.lvm_vg) - end - - def self.included(base) - base.class_eval do - dbus_interface LOGICAL_VOLUME_INTERFACE do - dbus_reader :lvm_lv_vg, "o", dbus_name: "VolumeGroup" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb deleted file mode 100644 index 8d219fc6dd..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for a LVM Volume Group. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module LvmVg - # Whether this interface should be implemented for the given device. - # - # @note LVM Volume Groups implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:lvm_vg) - end - - VOLUME_GROUP_INTERFACE = "org.opensuse.Agama.Storage1.LVM.VolumeGroup" - private_constant :VOLUME_GROUP_INTERFACE - - # Size of the volume group in bytes - # - # @return [Integer] - def lvm_vg_size - storage_device.size.to_i - end - - # D-Bus paths of the objects representing the physical volumes. - # - # @return [Array] - def lvm_vg_pvs - storage_device.lvm_pvs.map { |p| tree.path_for(p.plain_blk_device) } - end - - # D-Bus paths of the objects representing the logical volumes. - # - # @return [Array] - def lvm_vg_lvs - storage_device.lvm_lvs.map { |l| tree.path_for(l) } - end - - def self.included(base) - base.class_eval do - dbus_interface VOLUME_GROUP_INTERFACE do - dbus_reader :lvm_vg_size, "t", dbus_name: "Size" - dbus_reader :lvm_vg_pvs, "ao", dbus_name: "PhysicalVolumes" - dbus_reader :lvm_vg_lvs, "ao", dbus_name: "LogicalVolumes" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/md.rb b/service/lib/agama/dbus/storage/interfaces/device/md.rb deleted file mode 100644 index 6cf144489d..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/md.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for MD RAID devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Md - # Whether this interface should be implemented for the given device. - # - # @note MD RAIDs implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:md) - end - - MD_INTERFACE = "org.opensuse.Agama.Storage1.MD" - private_constant :MD_INTERFACE - - # UUID of the MD RAID - # - # @return [String] - def md_uuid - storage_device.uuid - end - - # RAID level - # - # @return [String] - def md_level - storage_device.md_level.to_s - end - - # Paths of the D-Bus objects representing the devices of the MD RAID. - # - # @return [Array] - def md_devices - storage_device.plain_devices.map { |p| tree.path_for(p) } - end - - def self.included(base) - base.class_eval do - dbus_interface MD_INTERFACE do - dbus_reader :md_uuid, "s", dbus_name: "UUID" - dbus_reader :md_level, "s", dbus_name: "Level" - dbus_reader :md_devices, "ao", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/multipath.rb b/service/lib/agama/dbus/storage/interfaces/device/multipath.rb deleted file mode 100644 index 40037fcc75..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/multipath.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for Multipath devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Multipath - # Whether this interface should be implemented for the given device. - # - # @note Multipath devices implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:multipath) - end - - MULTIPATH_INTERFACE = "org.opensuse.Agama.Storage1.Multipath" - private_constant :MULTIPATH_INTERFACE - - # Name of the multipath wires. - # - # @note: The multipath wires are not exported in D-Bus yet. - # - # @return [Array] - def multipath_wires - storage_device.parents.map(&:name) - end - - def self.included(base) - base.class_eval do - dbus_interface MULTIPATH_INTERFACE do - dbus_reader :multipath_wires, "as", dbus_name: "Wires" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition.rb b/service/lib/agama/dbus/storage/interfaces/device/partition.rb deleted file mode 100644 index 050d836663..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/partition.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for partition. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Partition - # Whether this interface should be implemented for the given device. - # - # @note Partitions implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:partition) - end - - PARTITION_INTERFACE = "org.opensuse.Agama.Storage1.Partition" - private_constant :PARTITION_INTERFACE - - # Device hosting the partition table of this partition. - # - # @return [Array<::DBus::ObjectPath>] - def partition_device - tree.path_for(storage_device.partitionable) - end - - # Whether it is a (valid) EFI System partition - # - # @return [Boolean] - def partition_efi - storage_device.efi_system? - end - - def self.included(base) - base.class_eval do - dbus_interface PARTITION_INTERFACE do - dbus_reader :partition_device, "o", dbus_name: "Device" - dbus_reader :partition_efi, "b", dbus_name: "EFI" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb b/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb deleted file mode 100644 index 97dcb90e25..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for devices that contain a partition table. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module PartitionTable - # Whether this interface should be implemented for the given device. - # - # @note Devices containing a partition table implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:blk_device) && - storage_device.respond_to?(:partition_table?) && - storage_device.partition_table? - end - - PARTITION_TABLE_INTERFACE = "org.opensuse.Agama.Storage1.PartitionTable" - private_constant :PARTITION_TABLE_INTERFACE - - # Type of the partition table - # - # @return [String] - def partition_table_type - storage_device.partition_table.type.to_s - end - - # Paths of the D-Bus objects representing the partitions. - # - # @return [Array<::DBus::ObjectPath>] - def partition_table_partitions - storage_device.partition_table.partitions.map { |p| tree.path_for(p) } - end - - # Available slots within a partition table, that is, the spaces that can be used to - # create a new partition. - # - # @return [Array] The first block and the size of each slot. - def partition_table_unused_slots - storage_device.partition_table.unused_partition_slots.map do |slot| - [slot.region.start, slot.region.size.to_i] - end - end - - def self.included(base) - base.class_eval do - dbus_interface PARTITION_TABLE_INTERFACE do - dbus_reader :partition_table_type, "s", dbus_name: "Type" - dbus_reader :partition_table_partitions, "ao", dbus_name: "Partitions" - dbus_reader :partition_table_unused_slots, "a(tt)", dbus_name: "UnusedSlots" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/interfaces/device/raid.rb b/service/lib/agama/dbus/storage/interfaces/device/raid.rb deleted file mode 100644 index ecbf3c62a0..0000000000 --- a/service/lib/agama/dbus/storage/interfaces/device/raid.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "dbus" - -module Agama - module DBus - module Storage - module Interfaces - module Device - # Interface for DM RAID devices. - # - # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if - # needed. - module Raid - # Whether this interface should be implemented for the given device. - # - # @note DM RAIDs implement this interface. - # - # @param storage_device [Y2Storage::Device] - # @return [Boolean] - def self.apply?(storage_device) - storage_device.is?(:dm_raid) - end - - RAID_INTERFACE = "org.opensuse.Agama.Storage1.RAID" - private_constant :RAID_INTERFACE - - # Name of the devices used by the DM RAID. - # - # @note: The devices used by a DM RAID are not exported in D-Bus yet. - # - # @return [Array] - def raid_devices - storage_device.parents.map(&:name) - end - - def self.included(base) - base.class_eval do - dbus_interface RAID_INTERFACE do - dbus_reader :raid_devices, "as", dbus_name: "Devices" - end - end - end - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index d01d7fd42a..28bb7cc546 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -27,14 +27,10 @@ require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/locale" require "agama/dbus/interfaces/service_status" -require "agama/dbus/storage/devices_tree" require "agama/dbus/storage/iscsi_nodes_tree" -require "agama/dbus/storage/proposal" -require "agama/dbus/storage/proposal_settings_conversion" require "agama/dbus/with_service_status" require "agama/storage/config_conversions" require "agama/storage/encryption_settings" -require "agama/storage/proposal_settings" require "agama/storage/volume_templates_builder" require "agama/with_progress" require "agama/storage/devicegraph_conversions" @@ -339,23 +335,6 @@ def candidate_md_raids proposal.storage_system.candidate_md_raids.map(&:sid) end - PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" - private_constant :PROPOSAL_CALCULATOR_INTERFACE - - # Calculates a guided proposal. - # - # @param settings_dbus [Hash] - # @return [Integer] 0 success; 1 error - def calculate_guided_proposal(settings_dbus) - logger.info("Calculating guided storage proposal from D-Bus: #{settings_dbus}") - - settings = ProposalSettingsConversion.from_dbus(settings_dbus, - config: product_config, logger: logger) - - proposal.calculate_guided(settings) - proposal.success? ? 0 : 1 - end - # Meaningful mount points for the current product. # # @return [Array] @@ -504,9 +483,6 @@ def iscsi_delete(path) # @return [Agama::Storage::Manager] attr_reader :backend - # @return [DBus::Storage::Proposal, nil] - attr_reader :dbus_proposal - def register_progress_callbacks on_progress_change { self.ProgressChanged(progress.to_json) } on_progress_finish { self.ProgressFinished } @@ -584,30 +560,6 @@ def deprecate_system backend.deprecated_system = true end - # @todo Do not export a separate proposal object. For now, the guided proposal is still - # exported to keep the current UI working. - def export_proposal - if dbus_proposal - @service.unexport(dbus_proposal) - @dbus_proposal = nil - end - - return unless proposal.guided? - - @dbus_proposal = DBus::Storage::Proposal.new(proposal, logger) - @service.export(@dbus_proposal) - end - - def refresh_system_devices - devicegraph = Y2Storage::StorageManager.instance.probed - system_devices_tree.update(devicegraph) - end - - def refresh_staging_devices - devicegraph = Y2Storage::StorageManager.instance.staging - staging_devices_tree.update(devicegraph) - end - def refresh_iscsi_nodes nodes = backend.iscsi.nodes iscsi_nodes_tree.update(nodes) @@ -617,21 +569,6 @@ def iscsi_nodes_tree @iscsi_nodes_tree ||= ISCSINodesTree.new(@service, backend.iscsi, logger: logger) end - # FIXME: D-Bus trees should not be created by the Manager D-Bus object. Note that the - # service (`@service`) is nil until the Manager object is exported. The service should - # have the responsibility of creating the trees and pass them to Manager if needed. - def system_devices_tree - @system_devices_tree ||= DevicesTree.new(@service, tree_path("system"), logger: logger) - end - - def staging_devices_tree - @staging_devices_tree ||= DevicesTree.new(@service, tree_path("staging"), logger: logger) - end - - def tree_path(tree_root) - File.join(PATH, tree_root) - end - # @return [Agama::Config] def product_config backend.product_config diff --git a/service/lib/agama/dbus/storage/proposal.rb b/service/lib/agama/dbus/storage/proposal.rb deleted file mode 100644 index 9050c8a057..0000000000 --- a/service/lib/agama/dbus/storage/proposal.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/base_object" -require "agama/dbus/storage/proposal_settings_conversion" -require "dbus" - -module Agama - module DBus - module Storage - # D-Bus object to manage the storage proposal. - class Proposal < BaseObject - PATH = "/org/opensuse/Agama/Storage1/Proposal" - private_constant :PATH - - # @param backend [Agama::Storage::Proposal] - # @param logger [Logger] - def initialize(backend, logger) - super(PATH, logger: logger) - @backend = backend - end - - STORAGE_PROPOSAL_INTERFACE = "org.opensuse.Agama.Storage1.Proposal" - private_constant :STORAGE_PROPOSAL_INTERFACE - - dbus_interface STORAGE_PROPOSAL_INTERFACE do - dbus_reader :settings, "a{sv}" - dbus_reader :actions, "aa{sv}" - end - - # Proposal settings. - # - # @see ProposalSettingsConversion::ToDBus - # - # @return [Hash] - def settings - return {} unless backend.guided? - - ProposalSettingsConversion.to_dbus(backend.guided_settings) - end - - # List of sorted actions in D-Bus format. - # - # @see #to_dbus_action - # - # @return [Array] - def actions - backend.actions.map { |a| to_dbus_action(a) } - end - - private - - # @return [Agama::Storage::Proposal] - attr_reader :backend - - # @return [Logger] - attr_reader :logger - - # Converts an action to D-Bus format. - # - # @param action [Y2Storage::CompoundAction] - # @return [Hash] - # * "Device" [Integer] - # * "Text" [String] - # * "Subvol" [Boolean] - # * "Delete" [Boolean] - # * "Resize" [Boolean] - def to_dbus_action(action) - { - "Device" => action.device_sid, - "Text" => action.text, - "Subvol" => action.on_btrfs_subvolume?, - "Delete" => action.delete?, - "Resize" => action.resize? - } - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion.rb deleted file mode 100644 index dc8e94ffc7..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/proposal_settings_conversion/from_dbus" -require "agama/dbus/storage/proposal_settings_conversion/to_dbus" - -module Agama - module DBus - module Storage - # Conversions for the proposal settings. - module ProposalSettingsConversion - # Performs conversion from D-Bus format. - # - # @param dbus_settings [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - # - # @return [Agama::Storage::ProposalSettings] - def self.from_dbus(dbus_settings, config:, logger: nil) - FromDBus.new(dbus_settings, config: config, logger: logger).convert - end - - # Performs conversion to D-Bus format. - # - # @param settings [Agama::Storage::ProposalSettings] - # @return [Hash] - def self.to_dbus(settings) - ToDBus.new(settings).convert - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb deleted file mode 100644 index c817076e53..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/hash_validator" -require "agama/dbus/storage/volume_conversion" -require "agama/dbus/types" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/proposal_settings_reader" -require "agama/storage/space_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -module Agama - module DBus - module Storage - module ProposalSettingsConversion - # Proposal settings conversion from D-Bus format. - class FromDBus - # @param dbus_settings [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - def initialize(dbus_settings, config:, logger: nil) - @dbus_settings = dbus_settings - @config = config - @logger = logger || Logger.new($stdout) - end - - # Performs the conversion from D-Bus format. - # - # @return [Agama::Storage::ProposalSettings] - def convert - logger.info("D-Bus settings: #{dbus_settings}") - - dbus_settings_issues.each { |i| logger.warn(i) } - - Agama::Storage::ProposalSettingsReader.new(config).read.tap do |target| - valid_dbus_properties.each { |p| conversion(target, p) } - end - end - - private - - # @return [Hash] - attr_reader :dbus_settings - - # @return [Agama::Config] - attr_reader :config - - # @return [Logger] - attr_reader :logger - - DBUS_PROPERTIES = [ - { - name: "Target", - type: String, - conversion: :device_conversion - }, - { - name: "TargetDevice", - type: String - }, - { - name: "TargetPVDevices", - type: Types::Array.new(String) - }, - { - name: "ConfigureBoot", - type: Types::BOOL, - conversion: :configure_boot_conversion - }, - { - name: "BootDevice", - type: String, - conversion: :boot_device_conversion - }, - { - name: "EncryptionPassword", - type: String, - conversion: :encryption_password_conversion - }, - { - name: "EncryptionMethod", - type: String, - conversion: :encryption_method_conversion - }, - { - name: "EncryptionPBKDFunction", - type: String, - conversion: :encryption_pbkd_function_conversion - }, - { - name: "SpacePolicy", - type: String, - conversion: :space_policy_conversion - }, - { - name: "SpaceActions", - type: Types::Array.new(Types::Hash.new(key: String, value: String)), - conversion: :space_actions_conversion - }, - { - name: "Volumes", - type: Types::Array.new(Types::Hash.new(key: String)), - conversion: :volumes_conversion - } - ].freeze - - private_constant :DBUS_PROPERTIES - - # Issues detected in the D-Bus settings, see {HashValidator#issues}. - # - # @return [Array] - def dbus_settings_issues - validator.issues - end - - # D-Bus properties with valid type, see {HashValidator#valid_keys}. - # - # @return [Array] - def valid_dbus_properties - validator.valid_keys - end - - # Validator for D-Bus settings. - # - # @return [HashValidator] - def validator - return @validator if @validator - - scheme = DBUS_PROPERTIES.map { |p| [p[:name], p[:type]] }.to_h - @validator = HashValidator.new(dbus_settings, scheme: scheme) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param dbus_property_name [String] - def conversion(target, dbus_property_name) - dbus_property = DBUS_PROPERTIES.find { |d| d[:name] == dbus_property_name } - conversion_method = dbus_property[:conversion] - - return unless conversion_method - - send(conversion_method, target, dbus_settings[dbus_property_name]) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def device_conversion(target, value) - device_settings = case value - when "disk" - disk_device_conversion - when "newLvmVg" - new_lvm_vg_device_conversion - when "reusedLvmVg" - reused_lvm_vg_device_conversion - end - - target.device = device_settings - end - - # @return [Agama::Storage::DeviceSettings::Disk] - def disk_device_conversion - device = dbus_settings["TargetDevice"] - Agama::Storage::DeviceSettings::Disk.new(device) - end - - # @return [Agama::Storage::DeviceSettings::NewLvmVg] - def new_lvm_vg_device_conversion - candidates = dbus_settings["TargetPVDevices"] || [] - Agama::Storage::DeviceSettings::NewLvmVg.new(candidates) - end - - # @return [Agama::Storage::DeviceSettings::ReusedLvmVg] - def reused_lvm_vg_device_conversion - device = dbus_settings["TargetDevice"] - Agama::Storage::DeviceSettings::ReusedLvmVg.new(device) - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Boolean] - def configure_boot_conversion(target, value) - target.boot.configure = value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def boot_device_conversion(target, value) - target.boot.device = value.empty? ? nil : value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_password_conversion(target, value) - target.encryption.password = value.empty? ? nil : value - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_method_conversion(target, value) - method = Y2Storage::EncryptionMethod.find(value.to_sym) - return unless method - - target.encryption.method = method - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def encryption_pbkd_function_conversion(target, value) - function = Y2Storage::PbkdFunction.find(value) - return unless function - - target.encryption.pbkd_function = function - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [String] - def space_policy_conversion(target, value) - policy = value.to_sym - return unless Agama::Storage::SpaceSettings.policies.include?(policy) - - target.space.policy = policy - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Array] - def space_actions_conversion(target, value) - target.space.actions = value.each_with_object({}) do |v, result| - result[v["Device"]] = v["Action"].to_sym - end - end - - # @param target [Agama::Storage::ProposalSettings] - # @param value [Array] - def volumes_conversion(target, value) - # Keep default volumes if no volumes are given - return if value.empty? - - required_volumes = target.volumes.select { |v| v.outline.required? } - volumes = value.map do |dbus_volume| - VolumeConversion.from_dbus(dbus_volume, config: config, logger: logger) - end - - target.volumes = volumes + missing_volumes(required_volumes, volumes) - end - - # Missing required volumes - # - # @param required_volumes [Array] - # @param volumes [Array] - # - # @return [Array] - def missing_volumes(required_volumes, volumes) - mount_paths = volumes.map(&:mount_path) - - required_volumes.reject { |v| mount_paths.include?(v.mount_path) } - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb deleted file mode 100644 index 1fc3ed3f09..0000000000 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/to_dbus.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/volume_conversion" -require "agama/storage/device_settings" - -module Agama - module DBus - module Storage - module ProposalSettingsConversion - # Proposal settings conversion to D-Bus format. - class ToDBus - # @param settings [Agama::Storage::ProposalSettings] - def initialize(settings) - @settings = settings - end - - # Performs the conversion to D-Bus format. - # - # @return [Hash] - # * "Target" [String] - # * "TargetDevice" [String] Optional - # * "TargetPVDevices" [Array] Optional - # * "ConfigureBoot" [Boolean] - # * "BootDevice" [String] - # * "DefaultBootDevice" [String] - # * "EncryptionPassword" [String] - # * "EncryptionMethod" [String] - # * "EncryptionPBKDFunction" [String] - # * "SpacePolicy" [String] - # * "SpaceActions" [Array] see {#space_actions_conversion} - # * "Volumes" [Array] see {#volumes_conversion} - def convert - target = device_conversion - - DBUS_PROPERTIES.each do |dbus_property, conversion| - target[dbus_property] = send(conversion) - end - - target - end - - private - - # @return [Agama::Storage::ProposalSettings] - attr_reader :settings - - DBUS_PROPERTIES = { - "ConfigureBoot" => :configure_boot_conversion, - "BootDevice" => :boot_device_conversion, - "DefaultBootDevice" => :default_boot_device_conversion, - "EncryptionPassword" => :encryption_password_conversion, - "EncryptionMethod" => :encryption_method_conversion, - "EncryptionPBKDFunction" => :encryption_pbkd_function_conversion, - "SpacePolicy" => :space_policy_conversion, - "SpaceActions" => :space_actions_conversion, - "Volumes" => :volumes_conversion - }.freeze - - private_constant :DBUS_PROPERTIES - - # @return [Hash] - def device_conversion - device_settings = settings.device - - case device_settings - when Agama::Storage::DeviceSettings::Disk - disk_device_conversion(device_settings) - when Agama::Storage::DeviceSettings::NewLvmVg - new_lvm_vg_device_conversion(device_settings) - when Agama::Storage::DeviceSettings::ReusedLvmVg - reused_lvm_vg_device_conversion(device_settings) - end - end - - # @param device_settings [Agama::Storage::DeviceSettings::Disk] - # @return [Hash] - def disk_device_conversion(device_settings) - { - "Target" => "disk", - "TargetDevice" => device_settings.name || "" - } - end - - # @param device_settings [Agama::Storage::DeviceSettings::NewLvmVg] - # @return [Hash] - def new_lvm_vg_device_conversion(device_settings) - { - "Target" => "newLvmVg", - "TargetPVDevices" => device_settings.candidate_pv_devices - } - end - - # @param device_settings [Agama::Storage::DeviceSettings::Disk] - # @return [Hash] - def reused_lvm_vg_device_conversion(device_settings) - { - "Target" => "reusedLvmVg", - "TargetDevice" => device_settings.name || "" - } - end - - # @return [Boolean] - def configure_boot_conversion - settings.boot.configure? - end - - # @return [String] - def boot_device_conversion - settings.boot.device || "" - end - - # @return [String] - def default_boot_device_conversion - settings.default_boot_device || "" - end - - # @return [String] - def encryption_password_conversion - settings.encryption.password.to_s - end - - # @return [String] - def encryption_method_conversion - settings.encryption.method.id.to_s - end - - # @return [String] - def encryption_pbkd_function_conversion - settings.encryption.pbkd_function&.value || "" - end - - # @return [String] - def space_policy_conversion - settings.space.policy.to_s - end - - # @return [Array>] - # For each action: - # * "Device" [String] - # * "Action" [String] - def space_actions_conversion - settings.space.actions.each_with_object([]) do |(device, action), actions| - actions << { "Device" => device, "Action" => action.to_s } - end - end - - # @return [Array] see {VolumeConversion::ToDBus}. - def volumes_conversion - settings.volumes.map { |v| VolumeConversion.to_dbus(v) } - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion.rb b/service/lib/agama/dbus/storage/volume_conversion.rb deleted file mode 100644 index 4c62d6b86f..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/storage/volume_conversion/from_dbus" -require "agama/dbus/storage/volume_conversion/to_dbus" - -module Agama - module DBus - module Storage - # Conversions for a volume. - module VolumeConversion - # Performs conversion from D-Bus format. - # - # @param dbus_volume [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - # - # @return [Agama::Storage::Volume] - def self.from_dbus(dbus_volume, config:, logger: nil) - FromDBus.new(dbus_volume, config: config, logger: logger).convert - end - - # Performs conversion to D-Bus format. - # - # @param volume [Agama::Storage::Volume] - # @return [Hash] - def self.to_dbus(volume) - ToDBus.new(volume).convert - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb deleted file mode 100644 index 6e6f7ecaef..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion/from_dbus.rb +++ /dev/null @@ -1,224 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/dbus/hash_validator" -require "agama/dbus/types" -require "agama/storage/volume" -require "agama/storage/volume_location" -require "agama/storage/volume_templates_builder" -require "y2storage/disk_size" -require "y2storage/filesystems/type" - -module Agama - module DBus - module Storage - module VolumeConversion - # Volume conversion from D-Bus format. - class FromDBus - # @param dbus_volume [Hash] - # @param config [Agama::Config] - # @param logger [Logger, nil] - def initialize(dbus_volume, config:, logger: nil) - @dbus_volume = dbus_volume - @config = config - @logger = logger || Logger.new($stdout) - end - - # Performs the conversion from D-Bus format. - # - # @return [Agama::Storage::Volume] - def convert - logger.info("D-Bus volume: #{dbus_volume}") - - dbus_volume_issues.each { |i| logger.warn(i) } - - builder = Agama::Storage::VolumeTemplatesBuilder.new_from_config(config) - builder.for(dbus_volume["MountPath"] || "").tap do |target| - valid_dbus_properties.each { |p| conversion(target, p) } - target.max_size = Y2Storage::DiskSize.unlimited unless dbus_volume.key?("MaxSize") - end - end - - private - - # @return [Hash] - attr_reader :dbus_volume - - # @return [Agama::Config] - attr_reader :config - - # @return [Logger] - attr_reader :logger - - DBUS_PROPERTIES = [ - { - name: "MountPath", - type: String, - conversion: :mount_path_conversion - }, - { - name: "MountOptions", - type: Types::Array.new(String), - conversion: :mount_options_conversion - }, - { - name: "Target", - type: String, - conversion: :target_conversion - }, - { - name: "TargetDevice", - type: String, - conversion: :target_device_conversion - }, - { - name: "FsType", - type: String, - conversion: :fs_type_conversion - }, - { - name: "MinSize", - type: Integer, - conversion: :min_size_conversion - }, - { - name: "MaxSize", - type: Integer, - conversion: :max_size_conversion - }, - { - name: "AutoSize", - type: Types::BOOL, - conversion: :auto_size_conversion - }, - { - name: "Snapshots", - type: Types::BOOL, - conversion: :snapshots_conversion - } - ].freeze - - private_constant :DBUS_PROPERTIES - - # Issues detected in the D-Bus volume, see {HashValidator#issues}. - # - # @return [Array] - def dbus_volume_issues - validator.issues - end - - # D-Bus properties with valid type, see {HashValidator#valid_keys}. - # - # @return [Array] - def valid_dbus_properties - validator.valid_keys - end - - # Validator for D-Bus volume. - # - # @return [HashValidator] - def validator - return @validator if @validator - - scheme = DBUS_PROPERTIES.map { |p| [p[:name], p[:type]] }.to_h - @validator = HashValidator.new(dbus_volume, scheme: scheme) - end - - # @param target [Agama::Storage::Volume] - # @param dbus_property_name [String] - def conversion(target, dbus_property_name) - dbus_property = DBUS_PROPERTIES.find { |d| d[:name] == dbus_property_name } - send(dbus_property[:conversion], target, dbus_volume[dbus_property_name]) - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def mount_path_conversion(target, value) - target.mount_path = value - end - - # @param target [Agama::Storage::Volume] - # @param value [Array] - def mount_options_conversion(target, value) - target.mount_options = value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def target_device_conversion(target, value) - target.location.device = value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def target_conversion(target, value) - target_value = value.downcase.to_sym - return unless Agama::Storage::VolumeLocation.targets.include?(target_value) - - target.location.target = target_value - end - - # @param target [Agama::Storage::Volume] - # @param value [String] - def fs_type_conversion(target, value) - downcase_value = value.downcase - - fs_type = target.outline.filesystems.find do |type| - type.to_human_string.downcase == downcase_value - end - - return unless fs_type - - target.fs_type = fs_type - end - - # @param target [Agama::Storage::Volume] - # @param value [Integer] Size in bytes. - def min_size_conversion(target, value) - target.min_size = Y2Storage::DiskSize.new(value) - end - - # @param target [Agama::Storage::Volume] - # @param value [Integer] Size in bytes. - def max_size_conversion(target, value) - target.max_size = Y2Storage::DiskSize.new(value) - end - - # @param target [Agama::Storage::Volume] - # @param value [Boolean] - def auto_size_conversion(target, value) - return unless target.auto_size_supported? - - target.auto_size = value - end - - # @param target [Agama::Storage::Volume] - # @param value [Booelan] - def snapshots_conversion(target, value) - return unless target.outline.snapshots_configurable? - - target.btrfs.snapshots = value - end - end - end - end - end -end diff --git a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb deleted file mode 100644 index 4133069abc..0000000000 --- a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -module Agama - module DBus - module Storage - module VolumeConversion - # Volume conversion to D-Bus format. - class ToDBus - # @param volume [Agama::Storage::Volume] - def initialize(volume) - @volume = volume - end - - # Performs the conversion to D-Bus format. - # - # @return [Hash] - # * "MountPath" [String] - # * "MountOptions" [Array] - # * "Target" [String] - # * "TargetDevice" [String] - # * "FsType" [String] - # * "MinSize" [Integer] - # * "MaxSize" [Integer] Optional - # * "AutoSize" [Boolean] - # * "Snapshots" [Booelan] - # * "Transactional" [Boolean] - # * "Outline" [Hash] see {#outline_conversion} - def convert - { - "MountPath" => volume.mount_path.to_s, - "MountOptions" => volume.mount_options, - "Target" => volume.location.target.to_s, - "TargetDevice" => volume.location.device.to_s, - "FsType" => volume.fs_type&.to_s || "", - "MinSize" => min_size_conversion, - "AutoSize" => volume.auto_size?, - "Snapshots" => volume.btrfs.snapshots?, - "Transactional" => volume.btrfs.read_only?, - "Outline" => outline_conversion - }.tap do |target| - # Some volumes could not have "MaxSize". - max_size_conversion(target) - end - end - - private - - # @return [Agama::Storage::Volume] - attr_reader :volume - - # @return [Integer] - def min_size_conversion - min_size = volume.min_size - min_size = volume.outline.base_min_size if volume.auto_size? - min_size.to_i - end - - # @param target [Hash] - def max_size_conversion(target) - max_size = volume.max_size - max_size = volume.outline.base_max_size if volume.auto_size? - return if max_size.unlimited? - - target["MaxSize"] = max_size.to_i - end - - # Converts volume outline to D-Bus. - # - # @return [Hash] - # * "Required" [Boolean] - # * "FsTypes" [Array] - # * "SupportAutoSize" [Boolean] - # * "AdjustByRam" [Boolean] - # * "SnapshotsConfigurable" [Boolean] - # * "SnapshotsAffectSizes" [Boolean] - # * "SizeRelevantVolumes" [Array] - def outline_conversion - outline = volume.outline - - { - "Required" => outline.required?, - "FsTypes" => outline.filesystems.map(&:to_s), - "SupportAutoSize" => outline.adaptive_sizes?, - "AdjustByRam" => outline.adjust_by_ram?, - "SnapshotsConfigurable" => outline.snapshots_configurable?, - "SnapshotsAffectSizes" => outline.snapshots_affect_sizes?, - "SizeRelevantVolumes" => outline.size_relevant_volumes - } - end - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 236537a72c..d21a2b199d 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -26,7 +26,6 @@ require "agama/storage/config_json_generator" require "agama/storage/config_solver" require "agama/storage/model_support_checker" -require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "agama/storage/system" require "json" @@ -79,12 +78,6 @@ def default_storage_json(device = nil) # @return [Hash, nil] nil if there is no proposal yet. def storage_json case strategy - when ProposalStrategies::Guided - { - storage: { - guided: strategy.settings.to_json_settings - } - } when ProposalStrategies::Agama source_json || { storage: ConfigConversions::ToJSON.new(config).convert } when ProposalStrategies::Autoyast @@ -130,13 +123,10 @@ def solve_model(model_json) def calculate_from_json(source_json) # @todo Validate source_json with JSON schema. - guided_json = source_json.dig(:storage, :guided) storage_json = source_json[:storage] autoyast_json = source_json[:legacyAutoyastStorage] - if guided_json - calculate_guided_from_json(guided_json) - elsif storage_json + if storage_json calculate_agama_from_json(storage_json) elsif autoyast_json calculate_autoyast(autoyast_json) @@ -148,17 +138,6 @@ def calculate_from_json(source_json) success? end - # Calculates a new proposal using the guided strategy. - # - # @param settings [Agama::Storage::ProposalSettings] - # @return [Boolean] Whether the proposal successes. - def calculate_guided(settings) - logger.info("Calculating proposal with guided strategy: #{settings.inspect}") - reset - @strategy = ProposalStrategies::Guided.new(product_config, storage_system, settings, logger) - calculate - end - # Calculates a new proposal using the agama strategy. # # @param config [Agama::Storage::Config] @@ -219,20 +198,9 @@ def issues # Whether the guided strategy was used for calculating the current proposal. # - # @return [Boolean] + # @return [Boolean] Always false because the guided strategy does not longer exists def guided? - return false unless calculated? - - strategy.is_a?(ProposalStrategies::Guided) - end - - # Settings used for calculating the guided proposal, if any. - # - # @return [ProposalSettings, nil] - def guided_settings - return unless guided? - - strategy.settings + false end # @return [Storage::System] @@ -286,15 +254,6 @@ def model_supported?(config) ModelSupportChecker.new(config).supported? end - # Calculates a proposal from guided JSON settings. - # - # @param guided_json [Hash] e.g., { "target": { "disk": "/dev/vda" } }. - # @return [Boolean] Whether the proposal successes. - def calculate_guided_from_json(guided_json) - settings = ProposalSettings.new_from_json(guided_json, config: product_config) - calculate_guided(settings) - end - # Calculates a proposal from storage JSON settings. # # @param config_json [Hash] e.g., { "drives": [] }. diff --git a/service/lib/agama/storage/proposal_settings_conversions.rb b/service/lib/agama/storage/proposal_settings_conversions.rb index 11517c4b58..602d4bce56 100644 --- a/service/lib/agama/storage/proposal_settings_conversions.rb +++ b/service/lib/agama/storage/proposal_settings_conversions.rb @@ -19,9 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/proposal_settings_conversions/from_json" -require "agama/storage/proposal_settings_conversions/from_y2storage" -require "agama/storage/proposal_settings_conversions/to_json" require "agama/storage/proposal_settings_conversions/to_y2storage" module Agama diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb deleted file mode 100644 index e709c98aa7..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/configs/boot" -require "agama/storage/device_settings" -require "agama/storage/encryption_settings" -require "agama/storage/proposal_settings_reader" -require "agama/storage/space_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion from JSON hash according to schema. - class FromJSON - # @param settings_json [Hash] - # @param config [Config] - def initialize(settings_json, config:) - @settings_json = settings_json - @config = config - end - - # Performs the conversion from Hash according to the JSON schema. - # - # @return [ProposalSettings] - def convert - # @todo Raise error if settings_json does not match the JSON schema. - device_settings = target_conversion - boot_settings = boot_conversion - encryption_settings = encryption_conversion - space_settings = space_conversion - volumes = volumes_conversion - - Agama::Storage::ProposalSettingsReader.new(config).read.tap do |settings| - settings.device = device_settings if device_settings - settings.boot = boot_settings if boot_settings - settings.encryption = encryption_settings if encryption_settings - settings.space = space_settings if space_settings - settings.volumes = add_required_volumes(volumes, settings.volumes) if volumes.any? - end - end - - private - - # @return [Hash] - attr_reader :settings_json - - # @return [Config] - attr_reader :config - - def target_conversion - target_json = settings_json[:target] - return unless target_json - - if target_json == "disk" - Agama::Storage::DeviceSettings::Disk.new - elsif target_json == "newLvmVg" - Agama::Storage::DeviceSettings::NewLvmVg.new - elsif (device = target_json[:disk]) - Agama::Storage::DeviceSettings::Disk.new(device) - elsif (devices = target_json[:newLvmVg]) - Agama::Storage::DeviceSettings::NewLvmVg.new(devices) - end - end - - def boot_conversion - boot_json = settings_json[:boot] - return unless boot_json - - Agama::Storage::Configs::Boot.new.tap do |boot_settings| - boot_settings.configure = boot_json[:configure] - boot_settings.device = boot_json[:device] - end - end - - def encryption_conversion - encryption_json = settings_json[:encryption] - return unless encryption_json - - Agama::Storage::EncryptionSettings.new.tap do |encryption_settings| - encryption_settings.password = encryption_json[:password] - - if (method_value = encryption_json[:method]) - method = Y2Storage::EncryptionMethod.find(method_value.to_sym) - encryption_settings.method = method - end - - if (function_value = encryption_json[:pbkdFunction]) - function = Y2Storage::PbkdFunction.find(function_value) - encryption_settings.pbkd_function = function - end - end - end - - def space_conversion - space_json = settings_json[:space] - return unless space_json - - Agama::Storage::SpaceSettings.new.tap do |space_settings| - space_settings.policy = space_json[:policy].to_sym - - actions_value = space_json[:actions] || [] - space_settings.actions = actions_value.map { |a| action_conversion(a) }.inject(:merge) - end - end - - # @param action [Hash] - def action_conversion(action) - return action.invert unless action[:forceDelete] - - { action[:forceDelete] => :force_delete } - end - - def volumes_conversion - volumes_json = settings_json[:volumes] - return [] unless volumes_json - - volumes_json.map do |volume_json| - Volume.new_from_json(volume_json, config: config) - end - end - - # Adds the missing required volumes to the list of volumes. - # - # @param volumes [Array] - # @param default_volumes [Array] Default volumes including the required ones. - # - # @return [Array] - def add_required_volumes(volumes, default_volumes) - mount_paths = volumes.map(&:mount_path) - - missing_required_volumes = default_volumes - .select { |v| v.outline.required? } - .reject { |v| mount_paths.include?(v.mount_path) } - - missing_required_volumes + volumes - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb deleted file mode 100644 index a9712086e7..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/from_y2storage.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/volume_conversions/from_y2storage" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion from Y2Storage. - # - # @note This class does not perform a real conversion from Y2Storage settings. Instead of - # that, it copies the given settings and recovers some values from Y2Storage. - # A real conversion is not needed because the original settings are always available. - # Moreover, Agama introduces some concepts that do not exist in the Y2Storage settings - # (e.g., target, boot device or space policy), which could be impossible to infer. - class FromY2Storage - # @param y2storage_settings [Y2Storage::ProposalSettings] - # @param settings [Agama::Storage::ProposalSettings] Settings to be copied and modified. - def initialize(y2storage_settings, settings) - @y2storage_settings = y2storage_settings - @settings = settings - end - - # Performs the conversion from Y2Storage. - # - # @return [Agama::Storage::ProposalSettings] - def convert - settings.dup.tap do |target| - space_actions_conversion(target) - volumes_conversion(target) - end - end - - private - - # @return [Y2Storage::ProposalSettings] - attr_reader :y2storage_settings - - # @return [Agama::Storage::ProposalSettings] - attr_reader :settings - - # Recovers space actions. - # - # @note Space actions are generated in the conversion of the settings to Y2Storage format, - # see {ProposalSettingsConversions::ToY2Storage}. - # - # @param target [Agama::Storage::ProposalSettings] - def space_actions_conversion(target) - target.space.actions = y2storage_settings.space_settings.actions.map do |action| - [action.device, action_to_symbol(action)] - end.to_h - end - - # @see #space_action_conversion - def action_to_symbol(action) - return :resize if action.is?(:resize) - - action.mandatory ? :force_delete : :delete - end - - # Some values of the volumes have to be recovered from Y2Storage proposal. - # - # @param target [Agama::Storage::ProposalSettings] - def volumes_conversion(target) - target.volumes = target.volumes.map { |v| volume_conversion(v) } - end - - # @param volume [Agama::Storage::Volume] - # @return [Agama::Storage::Volume] - def volume_conversion(volume) - VolumeConversions::FromY2Storage.new(volume).convert - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_settings_conversions/to_json.rb b/service/lib/agama/storage/proposal_settings_conversions/to_json.rb deleted file mode 100644 index a137412b51..0000000000 --- a/service/lib/agama/storage/proposal_settings_conversions/to_json.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/device_settings" - -module Agama - module Storage - module ProposalSettingsConversions - # Proposal settings conversion to JSON hash according to schema. - class ToJSON - # @param settings [ProposalSettings] - def initialize(settings) - @settings = settings - end - - # Performs the conversion to JSON. - # - # @return [Hash] - def convert - { - target: target_conversion, - boot: boot_conversion, - space: space_conversion, - volumes: volumes_conversion - }.tap do |settings_json| - encryption_json = encryption_conversion - settings_json[:encryption] = encryption_json if encryption_json - end - end - - private - - # @return [ProposalSettings] - attr_reader :settings - - def target_conversion - device_settings = settings.device - - case device_settings - when Agama::Storage::DeviceSettings::Disk - device = device_settings.name - device ? { disk: device } : "disk" - when Agama::Storage::DeviceSettings::NewLvmVg - candidates = device_settings.candidate_pv_devices - candidates.any? ? { newLvmVg: candidates } : "newLvmVg" - end - end - - def boot_conversion - { - configure: settings.boot.configure? - }.tap do |boot_json| - device = settings.boot.device - boot_json[:device] = device if device - end - end - - def encryption_conversion - return unless settings.encryption.encrypt? - - { - password: settings.encryption.password, - method: settings.encryption.method.id.to_s - }.tap do |encryption_json| - function = settings.encryption.pbkd_function - encryption_json[:pbkdFunction] = function.value if function - end - end - - def space_conversion - if settings.space.policy == :custom - { - policy: "custom", - actions: settings.space.actions.map { |d, a| { action_key(a) => d } } - } - else - { - policy: settings.space.policy.to_s - } - end - end - - def action_key(action) - return action.to_sym if action.to_s != "force_delete" - - :forceDelete - end - - def volumes_conversion - settings.volumes.map(&:to_json_settings) - end - end - end - end -end diff --git a/service/lib/agama/storage/proposal_strategies.rb b/service/lib/agama/storage/proposal_strategies.rb index 8bfe8ade59..65717e5c95 100644 --- a/service/lib/agama/storage/proposal_strategies.rb +++ b/service/lib/agama/storage/proposal_strategies.rb @@ -29,4 +29,3 @@ module ProposalStrategies require "agama/storage/proposal_strategies/agama" require "agama/storage/proposal_strategies/autoyast" -require "agama/storage/proposal_strategies/guided" diff --git a/service/lib/agama/storage/proposal_strategies/guided.rb b/service/lib/agama/storage/proposal_strategies/guided.rb deleted file mode 100644 index 926b8ea90c..0000000000 --- a/service/lib/agama/storage/proposal_strategies/guided.rb +++ /dev/null @@ -1,165 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/proposal_strategies/base" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings_conversions/from_y2storage" - -module Agama - module Storage - module ProposalStrategies - # Main strategy for the Agama proposal. - class Guided < Base - include Yast::I18n - - # @param product_config [Config] Product config - # @param storage_system [Storage::System] - # @param input_settings [ProposalSettings] - # @param logger [Logger] - def initialize(product_config, storage_system, input_settings, logger) - textdomain "agama" - - super(product_config, storage_system, logger) - @input_settings = input_settings - end - - # Settings used for calculating the proposal. - # - # @note Some values are recoverd from Y2Storage, see - # {ProposalSettingsConversions::FromY2Storage} - # - # @return [ProposalSettings] - attr_reader :settings - - # @see Base#calculate - def calculate - select_target_device(input_settings) if missing_target_device?(input_settings) - proposal = guided_proposal(input_settings) - proposal.propose - ensure - storage_manager.proposal = proposal - @settings = ProposalSettingsConversions::FromY2Storage - .new(proposal.settings, input_settings) - .convert - end - - # @see Base#issues - def issues - # Returning [] in case of a missing proposal is a workaround (the scenario should - # not happen). But this class is not expected to live long. - return [] unless storage_manager.proposal - return [] unless storage_manager.proposal.failed? - - [target_device_issue, missing_devices_issue].compact - end - - private - - # Initial set of proposal settings - # @return [ProposalSettings] - attr_reader :input_settings - - # Available devices for installation. - # - # @return [Array] - def available_devices - storage_system.analyzer&.candidate_disks || [] - end - - # Selects the first available device as target device for installation. - # - # @param settings [ProposalSettings] - def select_target_device(settings) - device = available_devices.first&.name - return unless device - - case settings.device - when DeviceSettings::Disk - settings.device.name = device - when DeviceSettings::NewLvmVg - settings.device.candidate_pv_devices = [device] - when DeviceSettings::ReusedLvmVg - # TODO: select an existing VG? - end - end - - # Whether the given settings has no target device for the installation. - # - # @param settings [ProposalSettings] - # @return [Boolean] - def missing_target_device?(settings) - case settings.device - when DeviceSettings::Disk, DeviceSettings::ReusedLvmVg - settings.device.name.nil? - when DeviceSettings::NewLvmVg - settings.device.candidate_pv_devices.empty? - end - end - - # Instance of the Y2Storage proposal to be used to run the calculation. - # - # @param settings [Y2Storage::ProposalSettings] - # @return [Y2Storage::GuidedProposal] - def guided_proposal(settings) - Y2Storage::MinGuidedProposal.new( - settings: settings.to_y2storage(config: product_config), - devicegraph: storage_system.devicegraph, - disk_analyzer: storage_system.analyzer - ) - end - - # Returns an issue if there is no target device. - # - # @return [Issue, nil] - def target_device_issue - return unless missing_target_device?(settings) - - Issue.new(_("No device selected for installation"), - source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR) - end - - # Returns an issue if any of the devices required for the proposal is not found - # - # @return [Issue, nil] - def missing_devices_issue - available = available_devices.map(&:name) - missing = settings.installation_devices.reject { |d| available.include?(d) } - - return if missing.none? - - Issue.new( - format( - n_( - "The following selected device is not found in the system: %{devices}", - "The following selected devices are not found in the system: %{devices}", - missing.size - ), - devices: missing.join(", ") - ), - source: Issue::Source::CONFIG, - severity: Issue::Severity::ERROR - ) - end - end - end - end -end diff --git a/service/lib/agama/storage/volume_conversions.rb b/service/lib/agama/storage/volume_conversions.rb index e8a2518a78..a83787bb93 100644 --- a/service/lib/agama/storage/volume_conversions.rb +++ b/service/lib/agama/storage/volume_conversions.rb @@ -19,8 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/volume_conversions/from_json" -require "agama/storage/volume_conversions/from_y2storage" require "agama/storage/volume_conversions/to_json" require "agama/storage/volume_conversions/to_y2storage" diff --git a/service/lib/agama/storage/volume_conversions/from_json.rb b/service/lib/agama/storage/volume_conversions/from_json.rb deleted file mode 100644 index a400d250a2..0000000000 --- a/service/lib/agama/storage/volume_conversions/from_json.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "agama/storage/volume" -require "agama/storage/volume_location" -require "agama/storage/volume_templates_builder" -require "y2storage" - -module Agama - module Storage - module VolumeConversions - # Volume conversion from JSON hash according schema. - class FromJSON - # @param volume_json [Hash] - # @param config [Config] - def initialize(volume_json, config:) - @volume_json = volume_json - @config = config - end - - # Performs the conversion from JSON Hash according to schema. - # - # @return [Volume] - def convert - # @todo Raise error if volume_json does not match the JSON schema. - - default_volume.tap do |volume| - mount_conversion(volume) - filesystem_conversion(volume) - size_conversion(volume) - target_conversion(volume) - end - end - - private - - # @return [Hash] - attr_reader :volume_json - - # @return [Agama::Config] - attr_reader :config - - # @param volume [Volume] - def mount_conversion(volume) - path_value = volume_json.dig(:mount, :path) - options_value = volume_json.dig(:mount, :options) - - volume.mount_path = path_value - volume.mount_options = options_value if options_value - end - - # @param volume [Volume] - def filesystem_conversion(volume) - filesystem_json = volume_json[:filesystem] - return unless filesystem_json - - if filesystem_json.is_a?(String) - filesystem_string_conversion(volume, filesystem_json) - else - filesystem_hash_conversion(volume, filesystem_json) - end - end - - # @param volume [Volume] - # @param filesystem_json [String] - def filesystem_string_conversion(volume, filesystem_json) - filesystems = volume.outline.filesystems - - fs_type = filesystems.find { |t| t.to_s == filesystem_json } - volume.fs_type = fs_type if fs_type - end - - # @param volume [Volume] - # @param filesystem_json [Hash] - def filesystem_hash_conversion(volume, filesystem_json) - filesystem_string_conversion(volume, "btrfs") - - snapshots_value = filesystem_json.dig(:btrfs, :snapshots) - return if !volume.outline.snapshots_configurable? || snapshots_value.nil? - - volume.btrfs.snapshots = snapshots_value - end - - # @todo Support array format ([min, max]) and string format ("2 GiB") - # @param volume [Volume] - def size_conversion(volume) - size_json = volume_json[:size] - return unless size_json - - if size_json == "auto" - volume.auto_size = true if volume.auto_size_supported? - else - volume.auto_size = false - - min_value = size_json[:min] - max_value = size_json[:max] - - volume.min_size = Y2Storage::DiskSize.new(min_value) - volume.max_size = if max_value - Y2Storage::DiskSize.new(max_value) - else - Y2Storage::DiskSize.unlimited - end - end - end - - def target_conversion(volume) - target_json = volume_json[:target] - return unless target_json - - if target_json == "default" - volume.location.target = :default - volume.location.device = nil - elsif (device = target_json[:newPartition]) - volume.location.target = :new_partition - volume.location.device = device - elsif (device = target_json[:newVg]) - volume.location.target = :new_vg - volume.location.device = device - elsif (device = target_json[:device]) - volume.location.target = :device - volume.location.device = device - elsif (device = target_json[:filesystem]) - volume.location.target = :filesystem - volume.location.device = device - end - end - - def default_volume - Agama::Storage::VolumeTemplatesBuilder - .new_from_config(config) - .for(volume_json.dig(:mount, :path)) - end - end - end - end -end diff --git a/service/lib/agama/storage/volume_conversions/from_y2storage.rb b/service/lib/agama/storage/volume_conversions/from_y2storage.rb deleted file mode 100644 index 0ad025eb1b..0000000000 --- a/service/lib/agama/storage/volume_conversions/from_y2storage.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require "y2storage/storage_manager" - -module Agama - module Storage - module VolumeConversions - # Volume conversion from Y2Storage. - # - # @note This class does not perform a real conversion from Y2Storage. Instead of that, it - # copies the given volume and recovers some values from Y2Storage. - class FromY2Storage - # @param volume [Agama::Storage::ProposalSettings] - def initialize(volume) - @volume = volume - end - - # Performs the conversion from Y2Storage. - # - # @return [Agama::Storage::Volume] - def convert - volume.dup.tap do |target| - sizes_conversion(target) - end - end - - private - - # @return [Agama::Storage::ProposalSettings] - attr_reader :volume - - # Recovers the range of sizes used by the Y2Storage proposal, if needed. - # - # If the volume is configured to use auto sizes, then the final range of sizes used by the - # Y2Storage proposal depends on the fallback sizes (if this volume is fallback for other - # volume) and the size for snapshots (if snapshots is active). The planned device contains - # the real range of sizes used by the Y2Storage proposal. - # - # FIXME: Recovering the sizes from the planned device is done to know the range of sizes - # assigned to the volume and to present that information in the UI. But such information - # should be provided in a different way, for example as part of the proposal result - # reported on D-Bus: { success:, settings:, strategy:, computed_sizes: }. - # - # @param target [Agama::Storage::Volume] - def sizes_conversion(target) - return unless target.auto_size? - - planned = planned_device_for(target.mount_path) - return unless planned - - target.min_size = planned.min if planned.respond_to?(:min) - target.max_size = planned.max if planned.respond_to?(:max) - end - - # Planned device for the given mount path. - # - # @param mount_path [String] - # @return [Y2Storage::Planned::Device, nil] - def planned_device_for(mount_path) - planned_devices = proposal&.planned_devices || [] - planned_devices.find { |d| d.respond_to?(:mount_point) && d.mount_point == mount_path } - end - - # Current proposal. - # - # @return [Y2Storage::Proposal, nil] - def proposal - Y2Storage::StorageManager.instance.proposal - end - end - end - end -end diff --git a/service/test/agama/dbus/storage/device_test.rb b/service/test/agama/dbus/storage/device_test.rb deleted file mode 100644 index efb5ffe720..0000000000 --- a/service/test/agama/dbus/storage/device_test.rb +++ /dev/null @@ -1,282 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../storage/storage_helpers" -require_relative "./interfaces/device/block_examples" -require_relative "./interfaces/device/component_examples" -require_relative "./interfaces/device/device_examples" -require_relative "./interfaces/device/drive_examples" -require_relative "./interfaces/device/filesystem_examples" -require_relative "./interfaces/device/lvm_lv_examples" -require_relative "./interfaces/device/lvm_vg_examples" -require_relative "./interfaces/device/md_examples" -require_relative "./interfaces/device/multipath_examples" -require_relative "./interfaces/device/partition_examples" -require_relative "./interfaces/device/partition_table_examples" -require_relative "./interfaces/device/raid_examples" -require "agama/dbus/storage/device" -require "agama/dbus/storage/devices_tree" -require "dbus" - -describe Agama::DBus::Storage::Device do - include Agama::RSpec::StorageHelpers - - RSpec::Matchers.define(:include_dbus_interface) do |interface| - match do |dbus_object| - !dbus_object.interfaces_and_properties[interface].nil? - end - - failure_message do |dbus_object| - "D-Bus interface #{interface} is not included.\n" \ - "Interfaces: #{dbus_object.interfaces_and_properties.keys.join(", ")}" - end - end - - subject { described_class.new(device, "/test", tree) } - - let(:tree) { Agama::DBus::Storage::DevicesTree.new(service, "/agama/devices") } - - let(:service) { instance_double(::DBus::ObjectServer) } - - before do - mock_storage(devicegraph: scenario) - end - - let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - - describe ".new" do - context "when the given device is a disk" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Drive interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the device is a DM RAID" do - let(:scenario) { "empty-dm_raids.xml" } - - let(:device) { devicegraph.dm_raids.first } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Drive interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the RAID interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.RAID") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the device is a MD RAID" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.md_raids.first } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the MD interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.MD") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - end - - context "when the given device is a LVM volume group" do - let(:scenario) { "trivial_lvm.yml" } - - let(:device) { devicegraph.find_by_name("/dev/vg0") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the LVM.VolumeGroup interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.VolumeGroup") - end - end - - context "when the given device is a LVM logical volume" do - let(:scenario) { "trivial_lvm.yml" } - - let(:device) { devicegraph.find_by_name("/dev/vg0/lv1") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the LVM.LogicalVolume interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.LogicalVolume") - end - end - - context "when the given device is a partition" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda1") } - - it "defines the Device interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") - end - - it "defines the Block interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") - end - - it "does not define the Drive interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") - end - - it "defines the Partition interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Partition") - end - end - - context "when the given device has a partition table" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "defines the PartitionTable interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.PartitionTable") - end - end - - context "when the given device has no partition table" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sdb") } - - it "does not define the PartitionTable interface" do - expect(subject) - .to_not include_dbus_interface("org.opensuse.Agama.Storage1.PartitionTable") - end - end - - context "when the device is formatted" do - let(:scenario) { "multipath-formatted.xml" } - - let(:device) { devicegraph.find_by_name("/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1") } - - it "defines the Filesystem interface" do - expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") - end - end - - context "when the device is no formatted" do - let(:scenario) { "partitioned_md.yml" } - - let(:device) { devicegraph.find_by_name("/dev/sda") } - - it "does not define the Filesystem interface" do - expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") - end - end - end - - include_examples "Device interface" - - include_examples "Drive interface" - - include_examples "RAID interface" - - include_examples "Multipath interface" - - include_examples "MD interface" - - include_examples "Block interface" - - include_examples "LVM.VolumeGroup interface" - - include_examples "LVM.LogicalVolume interface" - - include_examples "Partition interface" - - include_examples "PartitionTable interface" - - include_examples "Filesystem interface" - - include_examples "Component interface" - - describe "#storage_device=" do - before do - allow(subject).to receive(:dbus_properties_changed) - end - - let(:scenario) { "partitioned_md.yml" } - let(:device) { devicegraph.find_by_name("/dev/sda") } - - context "if the given device has a different sid" do - let(:new_device) { devicegraph.find_by_name("/dev/sdb") } - - it "raises an error" do - expect { subject.storage_device = new_device } - .to raise_error(RuntimeError, /Cannot update the D-Bus object/) - end - end - - context "if the given device has the same sid" do - let(:new_device) { devicegraph.find_by_name("/dev/sda") } - - it "emits a properties changed signal for each interface" do - subject.interfaces_and_properties.each_key do |interface| - expect(subject).to receive(:dbus_properties_changed).with(interface, anything, anything) - end - - subject.storage_device = new_device - end - end - end -end diff --git a/service/test/agama/dbus/storage/devices_tree_test.rb b/service/test/agama/dbus/storage/devices_tree_test.rb deleted file mode 100644 index a876c2f77d..0000000000 --- a/service/test/agama/dbus/storage/devices_tree_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../storage/storage_helpers" -require "agama/dbus/storage/devices_tree" -require "dbus" -require "y2storage" - -describe Agama::DBus::Storage::DevicesTree do - include Agama::RSpec::StorageHelpers - - RSpec::Matchers.define(:export_object) do |object_path| - match do |service| - expect(service).to receive(:export) do |dbus_object| - expect(dbus_object.path).to eq(object_path) - end - end - - failure_message do |_| - "The object #{object_path} is not exported." - end - - match_when_negated do |service| - expect(service).to receive(:export) do |dbus_object| - expect(dbus_object.path).to_not eq(object_path) - end - end - - failure_message_when_negated do |_| - "The object #{object_path} is exported." - end - end - - RSpec::Matchers.define(:unexport_object) do |object_path| - match do |service| - expect(service).to receive(:unexport) do |dbus_object| - expect(dbus_object.path).to eq(object_path) - end - end - - failure_message do |_| - "The object #{object_path} is not unexported." - end - end - - subject { described_class.new(service, root_path, logger: logger) } - - let(:service) { instance_double(::DBus::ObjectServer) } - - let(:root_path) { "/test/system" } - - let(:logger) { Logger.new($stdout, level: :warn) } - - describe "#path_for" do - let(:device) { instance_double(Y2Storage::Device, sid: 50) } - - it "returns a D-Bus object path" do - expect(subject.path_for(device)).to be_a(::DBus::ObjectPath) - end - - it "uses the device sid as basename" do - expect(subject.path_for(device)).to eq("#{root_path}/50") - end - end - - describe "#update" do - before do - mock_storage(devicegraph: scenario) - - allow(service).to receive(:get_node).with(root_path, anything).and_return(root_node) - # Returning an empty list for the second call to mock the effect of calling to #clear. - allow(root_node).to receive(:descendant_objects).and_return(dbus_objects, []) - - allow(service).to receive(:export) - allow(service).to receive(:unexport) - - allow_any_instance_of(::DBus::Object).to receive(:interfaces_and_properties).and_return({}) - allow_any_instance_of(::DBus::Object).to receive(:dbus_properties_changed) - end - - let(:scenario) { "partitioned_md.yml" } - - let(:root_node) { instance_double(::DBus::Node) } - - let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - - let(:dbus_objects) { [dbus_object1, dbus_object2] } - let(:dbus_object1) { Agama::DBus::Storage::Device.new(sda, subject.path_for(sda), subject) } - let(:dbus_object2) { Agama::DBus::Storage::Device.new(sdb, subject.path_for(sdb), subject) } - let(:sda) { devicegraph.find_by_name("/dev/sda") } - let(:sdb) { devicegraph.find_by_name("/dev/sdb") } - - it "unexports the current D-Bus objects" do - expect(service).to unexport_object("#{root_path}/#{sda.sid}") - expect(service).to unexport_object("#{root_path}/#{sdb.sid}") - - subject.update(devicegraph) - end - - it "exports disk devices and partitions" do - md0 = devicegraph.find_by_name("/dev/md0") - sda1 = devicegraph.find_by_name("/dev/sda1") - sda2 = devicegraph.find_by_name("/dev/sda2") - md0p1 = devicegraph.find_by_name("/dev/md0p1") - - expect(service).to export_object("#{root_path}/#{sda.sid}") - expect(service).to export_object("#{root_path}/#{sdb.sid}") - expect(service).to export_object("#{root_path}/#{md0.sid}") - expect(service).to export_object("#{root_path}/#{sda1.sid}") - expect(service).to export_object("#{root_path}/#{sda2.sid}") - expect(service).to export_object("#{root_path}/#{md0p1.sid}") - expect(service).to_not receive(:export) - - subject.update(devicegraph) - end - - context "if there are LVM volume groups" do - let(:scenario) { "trivial_lvm.yml" } - - let(:dbus_objects) { [] } - - it "exports the LVM volume groups and the logical volumes" do - vg0 = devicegraph.find_by_name("/dev/vg0") - lv1 = devicegraph.find_by_name("/dev/vg0/lv1") - - expect(service).to receive(:export) - expect(service).to export_object("#{root_path}/#{vg0.sid}") - expect(service).to export_object("#{root_path}/#{lv1.sid}") - - subject.update(devicegraph) - end - end - end -end diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index e3fcf1c634..6d49a0887a 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -22,7 +22,6 @@ require_relative "../../../test_helper" require_relative "../../storage/storage_helpers" require "agama/dbus/storage/manager" -require "agama/dbus/storage/proposal" require "agama/storage/config" require "agama/storage/device_settings" require "agama/storage/manager" @@ -418,169 +417,6 @@ def parse(string) describe "#apply_config" do let(:serialized_config) { config_json.to_json } - context "if the serialized config contains guided proposal settings" do - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - }, - boot: { - device: "/dev/vdb" - }, - encryption: { - password: "notsecret" - }, - volumes: volumes_settings - } - } - } - end - - let(:volumes_settings) do - [ - { - mount: { - path: "/" - } - }, - { - mount: { - path: "swap" - } - } - ] - end - - it "calculates a guided proposal with the given settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq "/dev/vda" - expect(settings.boot.device).to eq "/dev/vdb" - expect(settings.encryption).to be_a(Agama::Storage::EncryptionSettings) - expect(settings.encryption.password).to eq("notsecret") - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - - subject.apply_config(serialized_config) - end - - context "when the serialized config omits some settings" do - let(:config_json) do - { - storage: { - guided: {} - } - } - end - - it "calculates a proposal with default values for the missing settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.device).to be_nil - expect(settings.encryption).to be_a(Agama::Storage::EncryptionSettings) - expect(settings.encryption.password).to be_nil - expect(settings.volumes).to eq([]) - end - - subject.apply_config(serialized_config) - end - end - - context "when the serialized config includes a volume" do - let(:volumes_settings) { [volume1_settings] } - - let(:volume1_settings) do - { - mount: { - path: "/" - }, - size: { - min: 1024, - max: 2048 - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end - - let(:config_data) do - { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } - end - - let(:cfg_templates) do - [ - { - "mount_path" => "/", - "outline" => { - "snapshots_configurable" => true - } - } - ] - end - - it "calculates a proposal with the given volume settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - volume = settings.volumes.first - - expect(volume.mount_path).to eq("/") - expect(volume.auto_size).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(true) - end - - subject.apply_config(serialized_config) - end - - context "and the volume settings omits some values" do - let(:volume1_settings) do - { - mount: { - path: "/" - } - } - end - - let(:cfg_templates) do - [ - { - "mount_path" => "/", "filesystem" => "btrfs", - "size" => { "auto" => false, "min" => "5 GiB", "max" => "20 GiB" }, - "outline" => { - "filesystems" => ["btrfs"] - } - } - ] - end - - it "calculates a proposal with default settings for the missing volume settings" do - expect(proposal).to receive(:calculate_guided) do |settings| - volume = settings.volumes.first - - expect(volume.mount_path).to eq("/") - expect(volume.auto_size).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - expect(volume.max_size.to_i).to eq(20 * (1024**3)) - expect(volume.btrfs.snapshots).to eq(false) - end - - subject.apply_config(serialized_config) - end - end - end - end - context "if the serialized config contains storage settings" do let(:config_json) do { @@ -675,43 +511,6 @@ def parse(string) end end - context "if a guided proposal has been calculated" do - before do - proposal.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns serialized solved guided storage config" do - expect(subject.recover_config).to eq( - serialize({ - storage: { - guided: { - target: { - disk: "/dev/vda" - }, - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - } - } - }) - ) - end - end - context "if an agama proposal has been calculated" do before do proposal.calculate_from_json(config_json) @@ -764,26 +563,6 @@ def parse(string) end end - context "if a guided proposal has been calculated" do - before do - proposal.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns 'null'" do - expect(subject.recover_model).to eq("null") - end - end - context "if an agama proposal has been calculated" do before do proposal.calculate_from_json(config_json) diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb deleted file mode 100644 index b65221e7c7..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb +++ /dev/null @@ -1,280 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/proposal_settings_conversion/from_dbus" -require "agama/config" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::DBus::Storage::ProposalSettingsConversion::FromDBus do - subject { described_class.new(dbus_settings, config: config, logger: logger) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "lvm" => false, - "space_policy" => "delete", - "encryption" => { - "method" => "luks2", - "pbkd_function" => "argon2id" - }, - "volumes" => ["/", "swap"], - "volume_templates" => [ - { - "mount_path" => "/", - "outline" => { "required" => true } - }, - { - "mount_path" => "/home", - "outline" => { "required" => false } - }, - { - "mount_path" => "swap", - "outline" => { "required" => false } - } - ] - } - } - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - before do - allow(Agama::Storage::EncryptionSettings) - .to receive(:available_methods).and_return( - [ - Y2Storage::EncryptionMethod::LUKS1, - Y2Storage::EncryptionMethod::LUKS2 - ] - ) - end - - describe "#convert" do - let(:dbus_settings) do - { - "Target" => "disk", - "TargetDevice" => "/dev/sda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/sdb", - "EncryptionPassword" => "notsecret", - "EncryptionMethod" => "luks1", - "EncryptionPBKDFunction" => "pbkdf2", - "SpacePolicy" => "custom", - "SpaceActions" => [ - { - "Device" => "/dev/sda", - "Action" => "force_delete" - }, - { - "Device" => "/dev/sdb1", - "Action" => "resize" - } - ], - "Volumes" => [ - { "MountPath" => "/" }, - { "MountPath" => "/test" } - ] - } - end - - it "generates proposal settings with the values provided from D-Bus" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::PBKDF2) - expect(settings.space.policy).to eq(:custom) - expect(settings.space.actions).to eq({ - "/dev/sda" => :force_delete, "/dev/sdb1" => :resize - }) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "/test") - ) - end - - context "when some values are not provided from D-Bus" do - let(:dbus_settings) { {} } - - it "completes missing values with default values from config" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to be_nil - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - end - - context "when 'Target' is not provided from D-Bus" do - let(:dbus_settings) { {} } - - it "sets device settings to create partitions" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when 'Target' has 'disk' value" do - let(:dbus_settings) do - { - "Target" => "disk", - "TargetDevice" => "/dev/vda" - } - end - - it "sets device settings to create partitions in the indicated device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/vda") - end - end - - context "when 'Target' has 'newLvmVg' value" do - let(:dbus_settings) do - { - "Target" => "newLvmVg", - "TargetPVDevices" => ["/dev/vda", "/dev/vdb"] - } - end - - it "sets device settings to create a new LVM volume group in the indicated devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to contain_exactly("/dev/vda", "/dev/vdb") - end - end - - context "when 'Target' has 'reusedLvmVg' value" do - let(:dbus_settings) do - { - "Target" => "reusedLvmVg", - "TargetDevice" => "/dev/vg0" - } - end - - it "sets device settings to reuse the indicated LVM volume group" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::ReusedLvmVg) - expect(settings.device.name).to eq("/dev/vg0") - end - end - - context "when some value provided from D-Bus has unexpected type" do - let(:dbus_settings) { { "BootDevice" => 1 } } - - it "ignores the value" do - settings = subject.convert - - expect(settings.boot.device).to be_nil - end - end - - context "when some unexpected setting is provided from D-Bus" do - let(:dbus_settings) { { "Foo" => 1 } } - - it "does not fail" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - end - end - - context "when volumes are not provided from D-Bus" do - let(:dbus_settings) { { "Volumes" => [] } } - - it "completes the volumes with the default volumes from config" do - settings = subject.convert - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates of non-default volumes" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - - context "when a mandatory volume is not provided from D-Bus" do - let(:dbus_settings) do - { - "Volumes" => [ - { "MountPath" => "/test" } - ] - } - end - - it "completes the volumes with the mandatory volumes" do - settings = subject.convert - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/") - ) - end - - it "includes the volumes provided from D-Bus" do - settings = subject.convert - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "ignores default volumes that are not mandatory" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates for excluded volumes" do - settings = subject.convert - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb deleted file mode 100644 index 76700c39c5..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/proposal_settings_conversion/to_dbus" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::DBus::Storage::ProposalSettingsConversion::ToDBus do - let(:default_settings) { Agama::Storage::ProposalSettings.new } - - let(:custom_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :custom - settings.space.actions = { "/dev/sda" => :force_delete, "/dev/sdb1" => "resize" } - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "converts the settings to a D-Bus hash" do - expect(described_class.new(default_settings).convert).to eq( - "Target" => "disk", - "TargetDevice" => "", - "ConfigureBoot" => true, - "BootDevice" => "", - "DefaultBootDevice" => "", - "EncryptionPassword" => "", - "EncryptionMethod" => "luks2", - "EncryptionPBKDFunction" => "pbkdf2", - "SpacePolicy" => "keep", - "SpaceActions" => [], - "Volumes" => [] - ) - - expect(described_class.new(custom_settings).convert).to eq( - "Target" => "disk", - "TargetDevice" => "/dev/sda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/sdb", - "DefaultBootDevice" => "/dev/sda", - "EncryptionPassword" => "notsecret", - "EncryptionMethod" => "luks2", - "EncryptionPBKDFunction" => "argon2id", - "SpacePolicy" => "custom", - "SpaceActions" => [ - { - "Device" => "/dev/sda", - "Action" => "force_delete" - }, - { - "Device" => "/dev/sdb1", - "Action" => "resize" - } - ], - "Volumes" => [ - { - "MountPath" => "/test", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 0, - "AutoSize" => false, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "AdjustByRam" => false, - "SizeRelevantVolumes" => [] - } - } - ] - ) - end - - context "when the device is set to create partitions" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/vda") - end - end - - it "generates settings to use a disk as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "disk", - "TargetDevice" => "/dev/vda" - ) - - expect(dbus_settings).to_not include("TargetPVDevices") - end - end - - context "when the device is set to create a new LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new(["/dev/vda"]) - end - end - - it "generates settings to create a LVM volume group as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "newLvmVg", - "TargetPVDevices" => ["/dev/vda"] - ) - - expect(dbus_settings).to_not include("TargetDevice") - end - end - - context "when the device is set to reuse a LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::ReusedLvmVg.new("/dev/vg0") - end - end - - it "generates settings to reuse a LVM volume group as target device" do - dbus_settings = described_class.new(settings).convert - - expect(dbus_settings).to include( - "Target" => "reusedLvmVg", - "TargetDevice" => "/dev/vg0" - ) - - expect(dbus_settings).to_not include("TargetPVDevices") - end - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb deleted file mode 100644 index 236e98db35..0000000000 --- a/service/test/agama/dbus/storage/proposal_settings_conversion_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/proposal_settings" -require "agama/dbus/storage/proposal_settings_conversion" - -describe Agama::DBus::Storage::ProposalSettingsConversion do - describe "#from_dbus" do - let(:config) { Agama::Config.new } - - let(:dbus_settings) { {} } - - let(:logger) { Logger.new($stdout, level: :warn) } - - it "generates proposal settings from D-Bus settings" do - result = described_class.from_dbus(dbus_settings, config: config, logger: logger) - expect(result).to be_a(Agama::Storage::ProposalSettings) - end - end - - describe "#to_dbus" do - let(:proposal_settings) { Agama::Storage::ProposalSettings.new } - - it "generates D-Bus settings from proposal settings" do - result = described_class.to_dbus(proposal_settings) - expect(result).to be_a(Hash) - end - end -end diff --git a/service/test/agama/dbus/storage/proposal_test.rb b/service/test/agama/dbus/storage/proposal_test.rb deleted file mode 100644 index 49237c240c..0000000000 --- a/service/test/agama/dbus/storage/proposal_test.rb +++ /dev/null @@ -1,193 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/dbus/storage/proposal" -require "agama/storage/action" -require "agama/storage/device_settings" -require "agama/storage/proposal" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage" - -describe Agama::DBus::Storage::Proposal do - subject { described_class.new(backend, logger) } - - let(:backend) do - instance_double(Agama::Storage::Proposal, guided_settings: settings, guided?: guided) - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - let(:settings) { nil } - - let(:guided) { false } - - describe "#settings" do - context "if a guided proposal has not been calculated yet" do - let(:guided) { false } - - it "returns an empty hash" do - expect(subject.settings).to eq({}) - end - end - - context "if a guided proposal has been calculated" do - let(:guided) { true } - - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/vda") - settings.boot.device = "/dev/vdb" - settings.encryption.password = "n0ts3cr3t" - settings.encryption.method = luks2 - settings.encryption.pbkd_function = argon2id - settings.space.policy = :custom - settings.space.actions = { - "/dev/vda1" => :force_delete, - "/dev/vda2" => :resize - } - settings.volumes = [ - Agama::Storage::Volume.new("/test1"), - Agama::Storage::Volume.new("/test2") - ] - end - end - - let(:luks2) { Y2Storage::EncryptionMethod::LUKS2 } - - let(:argon2id) { Y2Storage::PbkdFunction::ARGON2ID } - - it "returns the proposal settings in D-Bus format" do - expect(subject.settings).to include( - "Target" => "disk", - "TargetDevice" => "/dev/vda", - "ConfigureBoot" => true, - "BootDevice" => "/dev/vdb", - "EncryptionPassword" => "n0ts3cr3t", - "EncryptionMethod" => luks2.id.to_s, - "EncryptionPBKDFunction" => argon2id.value, - "SpacePolicy" => "custom", - "SpaceActions" => [ - { "Device" => "/dev/vda1", "Action" => "force_delete" }, - { "Device" => "/dev/vda2", "Action" => "resize" } - ], - "Volumes" => [ - include("MountPath" => "/test1"), - include("MountPath" => "/test2") - ] - ) - end - end - end - - describe "#actions" do - before do - allow(backend).to receive(:actions).and_return(actions) - end - - context "if there are no actions" do - let(:actions) { [] } - - it "returns an empty list" do - expect(subject.actions).to eq([]) - end - end - - context "if there are actions" do - let(:actions) { [action1, action2, action3, action4] } - - let(:action1) do - instance_double(Agama::Storage::Action, - text: "test1", - device_sid: 1, - on_btrfs_subvolume?: false, - delete?: false, - resize?: false) - end - - let(:action2) do - instance_double(Agama::Storage::Action, - text: "test2", - device_sid: 2, - on_btrfs_subvolume?: false, - delete?: true, - resize?: false) - end - - let(:action3) do - instance_double(Agama::Storage::Action, - text: "test3", - device_sid: 3, - on_btrfs_subvolume?: false, - delete?: false, - resize?: true) - end - - let(:action4) do - instance_double(Agama::Storage::Action, - text: "test4", - device_sid: 4, - on_btrfs_subvolume?: true, - delete?: false, - resize?: false) - end - - it "returns a list with a hash for each action" do - expect(subject.actions.size).to eq(4) - expect(subject.actions).to all(be_a(Hash)) - - action1, action2, action3, action4 = subject.actions - - expect(action1).to eq({ - "Device" => 1, - "Text" => "test1", - "Subvol" => false, - "Delete" => false, - "Resize" => false - }) - - expect(action2).to eq({ - "Device" => 2, - "Text" => "test2", - "Subvol" => false, - "Delete" => true, - "Resize" => false - }) - - expect(action3).to eq({ - "Device" => 3, - "Text" => "test3", - "Subvol" => false, - "Delete" => false, - "Resize" => true - }) - expect(action4).to eq({ - "Device" => 4, - "Text" => "test4", - "Subvol" => true, - "Delete" => false, - "Resize" => false - }) - end - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb deleted file mode 100644 index a5e584a1ef..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion/from_dbus_test.rb +++ /dev/null @@ -1,264 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require_relative "../../../rspec/matchers/storage" -require "agama/config" -require "agama/storage/volume" -require "agama/storage/volume_templates_builder" -require "agama/dbus/storage/volume_conversion/from_dbus" - -describe Agama::DBus::Storage::VolumeConversion::FromDBus do - subject { described_class.new(dbus_volume, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "volume_templates" => [ - { - "mount_path" => "/test", - "mount_options" => ["data=ordered"], - "filesystem" => "btrfs", - "size" => { - "auto" => false, - "min" => "5 GiB", - "max" => "10 GiB" - }, - "btrfs" => { - "snapshots" => false - }, - "outline" => outline - } - ] - } - } - end - - let(:outline) do - { - "filesystems" => ["xfs", "ext3", "ext4"], - "snapshots_configurable" => true - } - end - - let(:logger) { Logger.new($stdout, level: :warn) } - - describe "#convert" do - let(:dbus_volume) do - { - "MountPath" => "/test", - "MountOptions" => ["rw", "default"], - "TargetDevice" => "/dev/sda", - "Target" => "new_vg", - "FsType" => "Ext4", - "MinSize" => 1024, - "MaxSize" => 2048, - "AutoSize" => false, - "Snapshots" => true - } - end - - it "generates a volume with the expected outline from the config" do - volume = subject.convert - default_volume = Agama::Storage::VolumeTemplatesBuilder.new_from_config(config).for("/test") - - expect(volume.outline).to eq_outline(default_volume.outline) - end - - it "generates a volume with the values provided from D-Bus" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("rw", "default") - expect(volume.location.device).to eq("/dev/sda") - expect(volume.location.target).to eq(:new_vg) - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(true) - end - - context "when some values are not provided from D-Bus" do - let(:dbus_volume) { { "MountPath" => "/test" } } - - it "completes missing values with default values from the config" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("data=ordered") - expect(volume.location.target).to eq :default - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - # missing maximum value means unlimited size - expect(volume.max_size.to_i).to eq(-1) - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the D-Bus settings include changes in the volume outline" do - let(:outline) { { "required" => true } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Outline" => { - "Required" => false - } - } - end - - it "ignores the outline values provided from D-Bus" do - volume = subject.convert - - expect(volume.outline.required?).to eq(true) - end - end - - context "when the D-Bus settings provide AutoSize value for a supported volume" do - let(:outline) do - { - "auto_size" => { - "min_fallback_for" => ["/"] - } - } - end - - let(:dbus_volume) do - { - "MountPath" => "/test", - "AutoSize" => true - } - end - - it "sets the AutoSize value provided from D-Bus" do - volume = subject.convert - - expect(volume.auto_size?).to eq(true) - end - end - - context "when the D-Bus settings provide AutoSize for an unsupported volume" do - let(:outline) { {} } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "AutoSize" => true - } - end - - it "ignores the AutoSize value provided from D-Bus" do - volume = subject.convert - - expect(volume.auto_size?).to eq(false) - end - end - - context "when the D-Bus settings provide a FsType listed in the outline" do - let(:outline) { { "filesystems" => ["btrfs", "ext4"] } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "FsType" => "ext4" - } - end - - it "sets the FsType value provided from D-Bus" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - end - end - - context "when the D-Bus settings provide a FsType not listed in the outline" do - let(:outline) { { "filesystems" => ["btrfs"] } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "FsType" => "ext4" - } - end - - it "ignores the FsType value provided from D-Bus" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - end - end - - context "when the D-Bus settings provide Snapshots for a supported volume" do - let(:outline) { { "snapshots_configurable" => true } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Snapshots" => true - } - end - - it "sets the Snapshots value provided from D-Bus" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(true) - end - end - - context "when the D-Bus settings provide Snapshots for an unsupported volume" do - let(:outline) { { "snapshots_configurable" => false } } - - let(:dbus_volume) do - { - "MountPath" => "/test", - "Snapshots" => true - } - end - - it "ignores the Snapshots value provided from D-Bus" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the D-Bus settings provide a Target that makes no sense" do - let(:dbus_volume) do - { - "MountPath" => "/test", - "Target" => "new_disk" - } - end - - it "ignores the Target value provided from D-Bus and uses :default" do - volume = subject.convert - - expect(volume.location.target).to eq :default - end - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb deleted file mode 100644 index 321cf0ed47..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2025] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../../test_helper" -require "agama/dbus/storage/volume_conversion/to_dbus" -require "agama/storage/volume" -require "y2storage/filesystems/type" -require "y2storage/disk_size" - -describe Agama::DBus::Storage::VolumeConversion::ToDBus do - let(:volume1) { Agama::Storage::Volume.new("/test1") } - - let(:volume2) do - Agama::Storage::Volume.new("/test2").tap do |volume| - volume.min_size = nil - volume.max_size = nil - volume.auto_size = true - volume.outline.base_min_size = Y2Storage::DiskSize.new(1024) - volume.outline.base_max_size = Y2Storage::DiskSize.new(4096) - end - end - - let(:volume3) do - volume_outline = Agama::Storage::VolumeOutline.new.tap do |outline| - outline.required = true - outline.filesystems = [Y2Storage::Filesystems::Type::EXT3, Y2Storage::Filesystems::Type::EXT4] - outline.adjust_by_ram = true - outline.min_size_fallback_for = ["/", "/home"] - outline.max_size_fallback_for = ["swap"] - outline.snapshots_configurable = true - outline.snapshots_size = Y2Storage::DiskSize.new(1000) - outline.snapshots_percentage = 10 - outline.adjust_by_ram = true - outline.base_min_size = Y2Storage::DiskSize.new(2048) - outline.base_max_size = Y2Storage::DiskSize.new(4096) - end - - Agama::Storage::Volume.new("/test3").tap do |volume| - volume.outline = volume_outline - volume.fs_type = Y2Storage::Filesystems::Type::EXT4 - volume.btrfs.snapshots = true - volume.btrfs.read_only = true - volume.mount_options = ["rw", "default"] - volume.location.device = "/dev/sda" - volume.location.target = :new_partition - volume.min_size = Y2Storage::DiskSize.new(1024) - volume.max_size = Y2Storage::DiskSize.new(2048) - volume.auto_size = true - end - end - - describe "#convert" do - it "converts the volume to a D-Bus hash" do - expect(described_class.new(volume1).convert).to eq( - "MountPath" => "/test1", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 0, - "AutoSize" => false, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "AdjustByRam" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "SizeRelevantVolumes" => [] - } - ) - - expect(described_class.new(volume2).convert).to eq( - "MountPath" => "/test2", - "MountOptions" => [], - "TargetDevice" => "", - "Target" => "default", - "FsType" => "", - "MinSize" => 1024, - "MaxSize" => 4096, - "AutoSize" => true, - "Snapshots" => false, - "Transactional" => false, - "Outline" => { - "Required" => false, - "FsTypes" => [], - "SupportAutoSize" => false, - "AdjustByRam" => false, - "SnapshotsConfigurable" => false, - "SnapshotsAffectSizes" => false, - "SizeRelevantVolumes" => [] - } - ) - - expect(described_class.new(volume3).convert).to eq( - "MountPath" => "/test3", - "MountOptions" => ["rw", "default"], - "TargetDevice" => "/dev/sda", - "Target" => "new_partition", - "FsType" => "ext4", - "MinSize" => 2048, - "MaxSize" => 4096, - "AutoSize" => true, - "Snapshots" => true, - "Transactional" => true, - "Outline" => { - "Required" => true, - "FsTypes" => ["ext3", "ext4"], - "AdjustByRam" => true, - "SupportAutoSize" => true, - "SnapshotsConfigurable" => true, - "SnapshotsAffectSizes" => true, - "SizeRelevantVolumes" => ["/", "/home", "swap"] - } - ) - end - end -end diff --git a/service/test/agama/dbus/storage/volume_conversion_test.rb b/service/test/agama/dbus/storage/volume_conversion_test.rb deleted file mode 100644 index 5dd15d7a98..0000000000 --- a/service/test/agama/dbus/storage/volume_conversion_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/volume" -require "agama/dbus/storage/volume_conversion" - -describe Agama::DBus::Storage::VolumeConversion do - describe "#from_dbus" do - let(:config) { Agama::Config.new } - - let(:dbus_volume) { {} } - - let(:logger) { Logger.new($stdout, level: :warn) } - - it "generates a volume from D-Bus settings" do - result = described_class.from_dbus(dbus_volume, config: config, logger: logger) - expect(result).to be_a(Agama::Storage::Volume) - end - end - - describe "#to_dbus" do - let(:volume) { Agama::Storage::Volume.new("/test") } - - it "generates D-Bus settings from a volume" do - result = described_class.to_dbus(volume) - expect(result).to be_a(Hash) - end - end -end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index ed590db62e..1bc089e81e 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -549,14 +549,23 @@ before do mock_storage(devicegraph: "partitioned_md.yml") - subject.proposal.calculate_guided(settings) + subject.proposal.calculate_from_json(config_json) end - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [Agama::Storage::Volume.new("/")] - end + let(:config_json) do + { + storage: { + drives: [ + { + search: "/dev/sdb", + partitions: [ + { search: "*", delete: true }, + { filesystem: { path: "/" } } + ] + } + ] + } + } end it "returns the list of actions" do diff --git a/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb b/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb deleted file mode 100644 index 3426264077..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/from_json_test.rb +++ /dev/null @@ -1,293 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/storage/proposal_settings_conversions/from_json" -require "agama/config" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::Storage::ProposalSettingsConversions::FromJSON do - subject { described_class.new(settings_json, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "lvm" => false, - "space_policy" => "delete", - "encryption" => { - "method" => "luks2", - "pbkd_function" => "argon2id" - }, - "volumes" => ["/", "swap"], - "volume_templates" => [ - { - "mount_path" => "/", - "outline" => { "required" => true } - }, - { - "mount_path" => "/home", - "outline" => { "required" => false } - }, - { - "mount_path" => "swap", - "outline" => { "required" => false } - } - ] - } - } - end - - before do - allow(Agama::Storage::EncryptionSettings) - .to receive(:available_methods).and_return( - [ - Y2Storage::EncryptionMethod::LUKS1, - Y2Storage::EncryptionMethod::LUKS2 - ] - ) - end - - describe "#convert" do - let(:settings_json) do - { - target: { - disk: "/dev/sda" - }, - boot: { - configure: true, - device: "/dev/sdb" - }, - encryption: { - password: "notsecret", - method: "luks1", - pbkdFunction: "pbkdf2" - }, - space: { - policy: "custom", - actions: [ - { forceDelete: "/dev/sda" }, - { resize: "/dev/sdb1" } - ] - }, - volumes: [ - { - mount: { - path: "/" - } - }, - { - mount: { - path: "/test" - } - } - ] - } - end - - it "generates settings with the values provided from JSON" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::PBKDF2) - expect(settings.space.policy).to eq(:custom) - expect(settings.space.actions).to eq({ - "/dev/sda" => :force_delete, "/dev/sdb1" => :resize - }) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "/test") - ) - end - - context "when the JSON is missing some values" do - let(:settings_json) { {} } - - it "completes the missing values with default values from the config" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - expect(settings.boot.configure?).to eq(true) - expect(settings.boot.device).to be_nil - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - end - - context "when the JSON does not indicate the target" do - let(:settings_json) { {} } - - it "generates settings with disk target and without specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when the JSON indicates disk target without device" do - let(:settings_json) do - { - target: "disk" - } - end - - it "generates settings with disk target and without specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to be_nil - end - end - - context "when the JSON indicates disk target with a device" do - let(:settings_json) do - { - target: { - disk: "/dev/vda" - } - } - end - - it "generates settings with disk target and with specific device" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/vda") - end - end - - context "when the JSON indicates newLvmVg target without devices" do - let(:settings_json) do - { - target: "newLvmVg" - } - end - - it "generates settings with newLvmVg target and without specific devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to eq([]) - end - end - - context "when the JSON indicates newLvmVg target with devices" do - let(:settings_json) do - { - target: { - newLvmVg: ["/dev/vda", "/dev/vdb"] - } - } - end - - it "generates settings with newLvmVg target and with specific devices" do - settings = subject.convert - - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::NewLvmVg) - expect(settings.device.candidate_pv_devices).to contain_exactly("/dev/vda", "/dev/vdb") - end - end - - context "when the JSON does not indicate volumes" do - let(:settings_json) { { volumes: [] } } - - it "generates settings with the default volumes from config" do - settings = subject.convert - - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/"), - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates of non-default volumes" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - - context "when the JSON does not contain a required volume" do - let(:settings_json) do - { - volumes: [ - { - mount: { - path: "/test" - } - } - ] - } - end - - it "generates settings including the required volumes" do - settings = subject.convert - - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/") - ) - end - - it "generates settings including the given volumes" do - settings = subject.convert - - expect(settings.volumes).to include( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "ignores default volumes that are not required" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "swap") - ) - end - - it "ignores templates for excluded volumes" do - settings = subject.convert - - expect(settings.volumes).to_not include( - an_object_having_attributes(mount_path: "/home") - ) - end - end - end -end diff --git a/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb b/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb deleted file mode 100644 index b33b737bf0..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/from_y2storage_test.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/config" -require "agama/storage/proposal_settings" -require "agama/storage/proposal_settings_conversions/from_y2storage" -require "y2storage" - -describe Agama::Storage::ProposalSettingsConversions::FromY2Storage do - subject { described_class.new(y2storage_settings, original_settings) } - - let(:y2storage_settings) do - Y2Storage::ProposalSettings.new.tap do |settings| - settings.space_settings.actions = [ - Y2Storage::SpaceActions::Delete.new("/dev/sda", mandatory: true), - Y2Storage::SpaceActions::Resize.new("/dev/sdb1"), - Y2Storage::SpaceActions::Delete.new("/dev/sdb2") - ] - end - end - - let(:original_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :delete - settings.space.actions = {} - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "generates settings with the same values as the given settings" do - settings = subject.convert - - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device).to be_a(Agama::Storage::DeviceSettings::Disk) - expect(settings.device.name).to eq("/dev/sda") - expect(settings.boot.device).to eq("/dev/sdb") - expect(settings.encryption.password).to eq("notsecret") - expect(settings.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS2) - expect(settings.encryption.pbkd_function).to eq(Y2Storage::PbkdFunction::ARGON2ID) - expect(settings.space.policy).to eq(:delete) - expect(settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/test") - ) - end - - it "restores the space actions from Y2Storage" do - settings = subject.convert - - expect(settings.space.actions).to eq( - "/dev/sda" => :force_delete, - "/dev/sdb1" => :resize, - "/dev/sdb2" => :delete - ) - end - end -end diff --git a/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb b/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb deleted file mode 100644 index 79f6080eb6..0000000000 --- a/service/test/agama/storage/proposal_settings_conversions/to_json_test.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require "agama/storage/proposal_settings_conversions/to_json" -require "agama/storage/device_settings" -require "agama/storage/proposal_settings" -require "agama/storage/volume" -require "y2storage/encryption_method" -require "y2storage/pbkd_function" - -describe Agama::Storage::ProposalSettingsConversions::ToJSON do - let(:default_settings) { Agama::Storage::ProposalSettings.new } - - let(:custom_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sda" - settings.boot.device = "/dev/sdb" - settings.encryption.password = "notsecret" - settings.encryption.method = Y2Storage::EncryptionMethod::LUKS2 - settings.encryption.pbkd_function = Y2Storage::PbkdFunction::ARGON2ID - settings.space.policy = :custom - settings.space.actions = { "/dev/sda" => :force_delete, "/dev/sdb1" => "resize" } - settings.volumes = [Agama::Storage::Volume.new("/test")] - end - end - - describe "#convert" do - it "converts the settings to a JSON hash according to schema" do - # @todo Check whether the result matches the JSON schema. - - expect(described_class.new(default_settings).convert).to eq( - target: "disk", - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - ) - - expect(described_class.new(custom_settings).convert).to eq( - target: { - disk: "/dev/sda" - }, - boot: { - configure: true, - device: "/dev/sdb" - }, - encryption: { - password: "notsecret", - method: "luks2", - pbkdFunction: "argon2id" - }, - space: { - policy: "custom", - actions: [ - { forceDelete: "/dev/sda" }, - { resize: "/dev/sdb1" } - ] - }, - volumes: [ - { - mount: { - path: "/test", - options: [] - }, - size: { - min: 0 - }, - target: "default" - } - ] - ) - end - - context "when the target is a new LVM volume group" do - let(:settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new(["/dev/vda"]) - end - end - - it "converts the settings to a JSON hash according to schema" do - expect(described_class.new(settings).convert).to eq( - target: { - newLvmVg: ["/dev/vda"] - }, - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - ) - end - end - end -end diff --git a/service/test/agama/storage/proposal_settings_test.rb b/service/test/agama/storage/proposal_settings_test.rb index 2d509f4fd4..53d4e6ae7a 100644 --- a/service/test/agama/storage/proposal_settings_test.rb +++ b/service/test/agama/storage/proposal_settings_test.rb @@ -173,32 +173,6 @@ end end - describe ".new_from_json" do - let(:config) { Agama::Config.new } - - let(:settings_json) do - { - target: { - disk: "/dev/vda" - } - } - end - - it "generates a proposal settings from JSON according to schema" do - result = described_class.new_from_json(settings_json, config: config) - expect(result).to be_a(Agama::Storage::ProposalSettings) - end - end - - describe "#to_json_settings" do - let(:proposal_settings) { Agama::Storage::ProposalSettings.new } - - it "generates a JSON hash according to schema" do - result = proposal_settings.to_json_settings - expect(result).to be_a(Hash) - end - end - describe "#to_y2storage" do let(:config) { Agama::Config.new } diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 0d1a3f5f34..e45bf8d8f6 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -166,33 +166,6 @@ def drive(partitions) end end - context "if a proposal was calculated with the guided strategy" do - before do - subject.calculate_guided(Agama::Storage::ProposalSettings.new) - end - - it "returns the solved guided JSON config" do - expected_json = { - storage: { - guided: { - boot: { - configure: true - }, - space: { - policy: "keep" - }, - target: { - disk: "/dev/sda" - }, - volumes: [] - } - } - } - - expect(subject.storage_json).to eq(expected_json) - end - end - context "if a proposal was calculated with the agama strategy" do before do subject.calculate_agama(achivable_config) @@ -261,45 +234,6 @@ def drive(partitions) end end - context "if a proposal was calculated from guided JSON config" do - before do - subject.calculate_from_json(config_json) - end - - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - } - } - } - } - end - - it "returns the solved guided JSON config" do - expected_json = { - storage: { - guided: { - boot: { - configure: true - }, - space: { - policy: "keep" - }, - target: { - disk: "/dev/vda" - }, - volumes: [] - } - } - } - - expect(subject.storage_json).to eq(expected_json) - end - end - context "if a proposal was calculated from storage JSON config" do before do subject.calculate_from_json(config_json) @@ -358,26 +292,6 @@ def drive(partitions) end end - context "if a guided proposal has been calculated" do - before do - subject.calculate_from_json(settings_json) - end - - let(:settings_json) do - { - storage: { - guided: { - target: { disk: "/dev/vda" } - } - } - } - end - - it "returns nil" do - expect(subject.model_json).to be_nil - end - end - context "if an AutoYaST proposal has been calculated" do before do subject.calculate_from_json(autoyast_json) @@ -620,88 +534,6 @@ def drive(partitions) end end - describe "#calculate_guided" do - before do - mock_storage(devicegraph: "partitioned_md.yml") - end - - let(:achivable_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.boot.device = "/dev/sda" - settings.volumes = [Agama::Storage::Volume.new("/")] - end - end - - let(:impossible_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [ - # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. - Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } - ] - end - end - - it "calculates a proposal with the guided strategy and with the given settings" do - expect(Y2Storage::StorageManager.instance.proposal).to be_nil - - subject.calculate_guided(achivable_settings) - - expect(Y2Storage::StorageManager.instance.proposal).to_not be_nil - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - expect(y2storage_settings.root_device).to eq("/dev/sda") - expect(y2storage_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_point: "/", device: "/dev/sdb") - ) - end - - include_examples "check proposal return", - :calculate_guided, :achivable_settings, :impossible_settings - - include_examples "check early proposal", :calculate_guided, :achivable_settings - - context "if the given device settings sets a disk as target" do - before do - achivable_settings.device = Agama::Storage::DeviceSettings::Disk.new - end - - context "and the target disk is not indicated" do - before do - achivable_settings.device.name = nil - end - - it "sets the first available device as target device for volumes" do - subject.calculate_guided(achivable_settings) - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - - expect(y2storage_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_point: "/", device: "/dev/sda") - ) - end - end - end - - context "if the given device settings sets a new LVM volume group as target" do - before do - achivable_settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new - end - - context "and the target disks for physical volumes are not indicated" do - before do - achivable_settings.device.candidate_pv_devices = [] - end - - it "sets the first available device as candidate device" do - subject.calculate_guided(achivable_settings) - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - - expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") - end - end - end - end - describe "#calculate_agama" do it "calculates a proposal with the agama strategy and with the given config" do expect(Y2Storage::StorageManager.instance.proposal).to be_nil @@ -759,29 +591,6 @@ def drive(partitions) end describe "#calculate_from_json" do - context "if the JSON contains storage guided settings" do - let(:config_json) do - { - storage: { - guided: { - target: { - disk: "/dev/vda" - } - } - } - } - end - - it "calculates a proposal with the guided strategy and with the expected settings" do - expect(subject).to receive(:calculate_guided) do |settings| - expect(settings).to be_a(Agama::Storage::ProposalSettings) - expect(settings.device.name).to eq("/dev/vda") - end - - subject.calculate_from_json(config_json) - end - end - context "if the JSON contains storage settings" do let(:config_json) do { @@ -894,60 +703,6 @@ def drive(partitions) ) end end - - context "if the proposal was calculated with the guided strategy" do - before do - mock_storage(devicegraph: "partitioned_md.yml") - end - - let(:impossible_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [ - # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. - Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } - ] - end - end - - context "and the settings does not indicate a target device" do - before do - # Avoid to automatically set the first device - allow(Y2Storage::StorageManager.instance.probed_disk_analyzer) - .to receive(:candidate_disks).and_return([]) - end - - let(:settings) { impossible_settings.tap { |s| s.device.name = nil } } - - it "includes an error because a device is not selected" do - subject.calculate_guided(settings) - - expect(subject.issues).to include( - an_object_having_attributes(description: /No device selected/) - ) - - expect(subject.issues).to_not include( - an_object_having_attributes(description: /is not found/) - ) - - expect(subject.issues).to_not include( - an_object_having_attributes(description: /are not found/) - ) - end - end - - context "and some installation device is missing in the system" do - let(:settings) { impossible_settings.tap { |s| s.device.name = "/dev/vdz" } } - - it "includes an error because the device is not found" do - subject.calculate_guided(settings) - - expect(subject.issues).to include( - an_object_having_attributes(description: /is not found/) - ) - end - end - end end describe "#guided?" do @@ -958,17 +713,6 @@ def drive(partitions) end end - context "if the proposal was calculated with the guided strategy" do - before do - settings = Agama::Storage::ProposalSettings.new - subject.calculate_guided(settings) - end - - it "returns true" do - expect(subject.guided?).to eq(true) - end - end - context "if the proposal was calculated with any other strategy" do before do subject.calculate_agama(achivable_config) @@ -979,34 +723,4 @@ def drive(partitions) end end end - - describe "#guided_settings" do - context "if no proposal has been calculated yet" do - it "returns nil" do - expect(subject.calculated?).to eq(false) - expect(subject.guided_settings).to be_nil - end - end - - context "if the proposal was calculated with the guided strategy" do - before do - settings = Agama::Storage::ProposalSettings.new - subject.calculate_guided(settings) - end - - it "returns the guided settings" do - expect(subject.guided_settings).to be_a(Agama::Storage::ProposalSettings) - end - end - - context "if the proposal was calculated with any other strategy" do - before do - subject.calculate_agama(achivable_config) - end - - it "returns nil" do - expect(subject.guided_settings).to be_nil - end - end - end end diff --git a/service/test/agama/storage/proposal_volumes_test.rb b/service/test/agama/storage/proposal_volumes_test.rb deleted file mode 100644 index 1638d997d1..0000000000 --- a/service/test/agama/storage/proposal_volumes_test.rb +++ /dev/null @@ -1,351 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2022-2023] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../test_helper" -require_relative "storage_helpers" -require "agama/storage/proposal" -require "agama/storage/proposal_settings" -require "agama/storage/volume_templates_builder" -require "agama/config" -require "y2storage" - -describe Agama::Storage::Proposal do - include Agama::RSpec::StorageHelpers - before do - allow(Yast::SCR).to receive(:Read).and_call_original - allow(Yast::SCR).to receive(:Read).with(path(".proc.meminfo")) - .and_return("memtotal" => 8388608) - - mock_storage - end - - subject(:proposal) { described_class.new(config, logger: logger) } - - let(:logger) { Logger.new($stdout, level: :warn) } - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { "storage" => { "volumes" => cfg_volumes, "volume_templates" => cfg_templates } } - end - - let(:cfg_volumes) { ["/", "swap"] } - - let(:cfg_templates) { [root_template, swap_template, other_template] } - let(:root_template) do - { - "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, - "outline" => { - "snapshots_configurable" => true, - "auto_size" => { - "base_min" => "10 GiB", "base_max" => "20 GiB", - "min_fallback_for" => ["/two"], "snapshots_increment" => "300%" - } - } - } - end - let(:swap_template) do - { - "mount_path" => "swap", "filesystem" => "swap", "size" => { "auto" => true }, - "outline" => { - "auto_size" => { "base_min" => "1 GiB", "base_max" => "2 GiB", "adjust_by_ram" => true } - } - } - end - let(:other_template) do - { - "mount_path" => "/two", "filesystem" => "xfs", - "size" => { "auto" => false, "min" => "5 GiB" } - } - end - - let(:settings) do - settings = Agama::Storage::ProposalSettings.new - settings.volumes = volumes - settings - end - - let(:y2storage_proposal) do - instance_double(Y2Storage::MinGuidedProposal, - propose: true, failed?: false, settings: y2storage_settings, planned_devices: []) - end - - let(:vol_builder) { Agama::Storage::VolumeTemplatesBuilder.new_from_config(config) } - - let(:y2storage_settings) { Y2Storage::ProposalSettings.new } - - # Constructs a Agama volume with the given set of attributes - # - # @param attrs [Hash] set of attributes and their values (sizes can be provided as strings) - # @return [Agama::Storage::Volume] - def test_vol(path, attrs = {}) - vol = vol_builder.for(path) - attrs.each do |attr, value| - if [:min_size, :max_size].include?(attr.to_sym) - # DiskSize.new can take a DiskSize, a string or a number - value = Y2Storage::DiskSize.new(value) - end - if attr.to_sym == :snapshots - vol.btrfs.snapshots = value - else - vol.public_send(:"#{attr}=", value) - end - end - vol - end - - # Sets the expectation for a Y2Storage::MinGuidedProposal to be created with the - # given set of Y2Storage::VolumeSpecification objects and returns proposal mocked as - # 'y2storage_proposal' - # - # @param specs [Hash] arguments to check on each VolumeSpecification object - def expect_proposal_with_specs(*specs) - expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| - expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) - expect(args[:settings].volumes).to all(be_a(Y2Storage::VolumeSpecification)) - expect(args[:settings].volumes).to contain_exactly( - *specs.map { |spec| an_object_having_attributes(spec) } - ) - - y2storage_proposal - end - end - - context "when auto size is used and the size is affected by other volumes" do - let(:volumes) { [test_vol("/", snapshots: false, auto_size: true, min_size: "4 GiB")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: false, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - auto_size: true, - min_size: Y2Storage::DiskSize.GiB(15), - btrfs: an_object_having_attributes(snapshots?: false) - ) - ) - end - end - end - - context "when auto size is used and it is affected by snapshots" do - let(:volumes) { [test_vol("/", snapshots: true), test_vol("/two")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: true, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: true, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - auto_size: true, - min_size: Y2Storage::DiskSize.GiB(40), - btrfs: an_object_having_attributes(snapshots?: true) - ), - an_object_having_attributes(mount_path: "/two") - ) - end - end - end - - context "when auto size is used and it is affected by snapshots and other volumes" do - let(:volumes) { [test_vol("/", auto_size: true, snapshots: true, min_size: "6 GiB")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: true, - ignore_fallback_sizes: false, ignore_snapshots_sizes: false, - min_size: Y2Storage::DiskSize.GiB(10) - }, - { mount_point: "swap", proposed: false }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with fixed limits and adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - btrfs: an_object_having_attributes(snapshots?: true), - auto_size?: true, - min_size: Y2Storage::DiskSize.GiB(60), - outline: an_object_having_attributes(min_size_fallback_for: ["/two"]) - ) - ) - end - end - end - - context "when auto size is used and it is affected by RAM size" do - let(:volumes) { [test_vol("/"), test_vol("swap")] } - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { mount_point: "/", proposed: true }, - { mount_point: "/two", proposed: false }, - { - mount_point: "swap", proposed: true, ignore_adjust_by_ram: false, - min_size: Y2Storage::DiskSize.GiB(1) - } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_path: "/", auto_size: true), - an_object_having_attributes( - mount_path: "swap", - auto_size?: true, - min_size: Y2Storage::DiskSize.GiB(8) - ) - ) - end - end - end - - context "when fixed sizes are enforced" do - let(:volumes) do - [ - test_vol("/", auto_size: false, min_size: "6 GiB"), - test_vol("swap", auto_size: false, min_size: "1 GiB") - ] - end - - describe "#calculate" do - before do - allow(Y2Storage::StorageManager.instance) - .to receive(:proposal).and_return(y2storage_proposal) - - allow(Y2Storage::StorageManager.instance).to receive(:proposal=) - end - - it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do - expect_proposal_with_specs( - { - mount_point: "/", proposed: true, snapshots: false, - ignore_fallback_sizes: true, ignore_snapshots_sizes: true, - min_size: Y2Storage::DiskSize.GiB(6) - }, - { - mount_point: "swap", proposed: true, ignore_adjust_by_ram: true, - min_size: Y2Storage::DiskSize.GiB(1) - }, - { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } - ) - proposal.calculate_guided(settings) - end - end - - describe "#settings" do - it "returns settings with a set of volumes with fixed limits and adjusted sizes" do - proposal.calculate_guided(settings) - - expect(proposal.guided_settings.volumes).to contain_exactly( - an_object_having_attributes( - mount_path: "/", - btrfs: an_object_having_attributes(snapshots?: false), - auto_size?: false, - min_size: Y2Storage::DiskSize.GiB(6), - outline: an_object_having_attributes(min_size_fallback_for: ["/two"]) - ), - an_object_having_attributes( - mount_path: "swap", - auto_size?: false, - min_size: Y2Storage::DiskSize.GiB(1), - outline: an_object_having_attributes(adjust_by_ram: true) - ) - ) - end - end - end -end diff --git a/service/test/agama/storage/volume_conversions/from_json_test.rb b/service/test/agama/storage/volume_conversions/from_json_test.rb deleted file mode 100644 index 579da8505b..0000000000 --- a/service/test/agama/storage/volume_conversions/from_json_test.rb +++ /dev/null @@ -1,279 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../../rspec/matchers/storage" -require "agama/config" -require "agama/storage/volume" -require "agama/storage/volume_templates_builder" -require "agama/storage/volume_conversions/from_json" -require "y2storage/disk_size" - -def default_volume(mount_path) - Agama::Storage::VolumeTemplatesBuilder.new_from_config(config).for(mount_path) -end - -describe Agama::Storage::VolumeConversions::FromJSON do - subject { described_class.new(volume_json, config: config) } - - let(:config) { Agama::Config.new(config_data) } - - let(:config_data) do - { - "storage" => { - "volume_templates" => [ - { - "mount_path" => "/test", - "mount_options" => ["data=ordered"], - "filesystem" => "btrfs", - "size" => { - "auto" => false, - "min" => "5 GiB", - "max" => "10 GiB" - }, - "btrfs" => { - "snapshots" => false - }, - "outline" => outline - } - ] - } - } - end - - let(:outline) do - { - "filesystems" => ["xfs", "ext3", "ext4"], - "snapshots_configurable" => true - } - end - - describe "#convert" do - let(:volume_json) do - { - mount: { - path: "/test", - options: ["rw", "default"] - }, - target: { - newVg: "/dev/sda" - }, - filesystem: "ext4", - size: { - min: 1024, - max: 2048 - } - } - end - - it "generates a volume with the expected outline from JSON" do - volume = subject.convert - - expect(volume.outline).to eq_outline(default_volume("/test").outline) - end - - it "generates a volume with the values provided from JSON" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("rw", "default") - expect(volume.location.device).to eq("/dev/sda") - expect(volume.location.target).to eq(:new_vg) - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(1024) - expect(volume.max_size.to_i).to eq(2048) - expect(volume.btrfs.snapshots).to eq(false) - end - - context "when the JSON is missing some values" do - let(:volume_json) do - { - mount: { - path: "/test" - } - } - end - - it "completes the missing values with default values from the config" do - volume = subject.convert - - expect(volume).to be_a(Agama::Storage::Volume) - expect(volume.mount_path).to eq("/test") - expect(volume.mount_options).to contain_exactly("data=ordered") - expect(volume.location.target).to eq :default - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - expect(volume.auto_size?).to eq(false) - expect(volume.min_size.to_i).to eq(5 * (1024**3)) - expect(volume.max_size.to_i).to eq(10 * (1024**3)) - expect(volume.btrfs.snapshots?).to eq(false) - end - end - - context "when the JSON does not indicate max size" do - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: { - min: 1024 - } - } - end - - it "generates a volume with unlimited max size" do - volume = subject.convert - - expect(volume.max_size).to eq(Y2Storage::DiskSize.unlimited) - end - end - - context "when the JSON indicates auto size for a supported volume" do - let(:outline) do - { - "auto_size" => { - "min_fallback_for" => ["/"] - } - } - end - - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: "auto" - } - end - - it "generates a volume with auto size" do - volume = subject.convert - - expect(volume.auto_size?).to eq(true) - end - end - - context "when the JSON indicates auto size for an unsupported volume" do - let(:outline) { {} } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - size: "auto" - } - end - - it "ignores the auto size setting" do - volume = subject.convert - - expect(volume.auto_size?).to eq(false) - end - end - - context "when the JSON indicates a filesystem included in the outline" do - let(:outline) { { "filesystems" => ["btrfs", "ext4"] } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: "ext4" - } - end - - it "generates a volume with the indicated filesystem" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::EXT4) - end - end - - context "when the JSON indicates a filesystem not included in the outline" do - let(:outline) { { "filesystems" => ["btrfs"] } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: "ext4" - } - end - - it "ignores the filesystem setting" do - volume = subject.convert - - expect(volume.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) - end - end - - context "when the JSON indicates snapshots for a supported volume" do - let(:outline) { { "snapshots_configurable" => true } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end - - it "generates a volume with snapshots" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(true) - end - end - - context "when the JSON indicates snapshots for an unsupported volume" do - let(:outline) { { "snapshots_configurable" => false } } - - let(:volume_json) do - { - mount: { - path: "/test" - }, - filesystem: { - btrfs: { - snapshots: true - } - } - } - end - - it "ignores the snapshots setting" do - volume = subject.convert - - expect(volume.btrfs.snapshots?).to eq(false) - end - end - end -end diff --git a/service/test/agama/storage/volume_conversions/from_y2storage_test.rb b/service/test/agama/storage/volume_conversions/from_y2storage_test.rb deleted file mode 100644 index 7fa66d558b..0000000000 --- a/service/test/agama/storage/volume_conversions/from_y2storage_test.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2023-2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, contact SUSE LLC. -# -# To contact SUSE LLC about this file by physical or electronic mail, you may -# find current contact information at www.suse.com. - -require_relative "../../../test_helper" -require_relative "../storage_helpers" -require_relative "../../rspec/matchers/storage" -require "agama/storage/volume" -require "agama/storage/volume_conversions/from_y2storage" -require "y2storage" - -describe Agama::Storage::VolumeConversions::FromY2Storage do - include Agama::RSpec::StorageHelpers - - before { mock_storage } - - subject { described_class.new(volume) } - - let(:btrfs) { Y2Storage::Filesystems::Type::BTRFS } - let(:ext4) { Y2Storage::Filesystems::Type::EXT4 } - let(:xfs) { Y2Storage::Filesystems::Type::XFS } - - let(:volume) do - Agama::Storage::Volume.new("/").tap do |volume| - volume.location.target = :new_vg - volume.location.device = "/dev/sda" - volume.mount_options = ["defaults"] - volume.fs_type = btrfs - volume.auto_size = false - volume.min_size = Y2Storage::DiskSize.GiB(5) - volume.max_size = Y2Storage::DiskSize.GiB(20) - volume.btrfs.snapshots = true - volume.btrfs.subvolumes = ["@/home", "@/var"] - volume.btrfs.default_subvolume = "@" - volume.btrfs.read_only = true - volume.outline.required = true - volume.outline.filesystems = [btrfs, ext4, xfs] - volume.outline.adjust_by_ram = false - volume.outline.snapshots_configurable = true - volume.outline.snapshots_size = Y2Storage::DiskSize.GiB(10) - volume.outline.snapshots_percentage = 20 - end - end - - describe "#convert" do - it "generates a volume with the same values as the given volume" do - result = subject.convert - - expect(result).to be_a(Agama::Storage::Volume) - expect(result).to_not equal(volume) - expect(result.location.target).to eq(:new_vg) - expect(result.location.device).to eq("/dev/sda") - expect(result.mount_path).to eq("/") - expect(result.mount_options).to contain_exactly("defaults") - expect(result.fs_type).to eq(btrfs) - expect(result.auto_size).to eq(false) - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - expect(result.btrfs.snapshots).to eq(true) - expect(result.btrfs.subvolumes).to contain_exactly("@/home", "@/var") - expect(result.btrfs.default_subvolume).to eq("@") - expect(result.btrfs.read_only).to eq(true) - expect(result.outline).to eq_outline(volume.outline) - end - - context "sizes conversion" do - before do - allow(Y2Storage::StorageManager.instance).to receive(:proposal).and_return(proposal) - end - - let(:proposal) do - instance_double(Y2Storage::MinGuidedProposal, planned_devices: planned_devices) - end - - let(:planned_devices) { [planned_volume] } - - context "if the volume is configured with auto size" do - before do - volume.auto_size = true - end - - context "if there is a planned device for the volume" do - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "sets the min and max sizes according to the planned device" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(10)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(40)) - end - end - - context "if there is no planned device for the volume" do - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/home").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "keeps the sizes of the given volume" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - end - end - end - - context "if the volume is not configured with auto size" do - before do - volume.auto_size = false - end - - let(:planned_volume) do - Y2Storage::Planned::LvmLv.new("/").tap do |planned| - planned.min = Y2Storage::DiskSize.GiB(10) - planned.max = Y2Storage::DiskSize.GiB(40) - end - end - - it "keeps the sizes of the given volume" do - result = subject.convert - - expect(result.min_size).to eq(Y2Storage::DiskSize.GiB(5)) - expect(result.max_size).to eq(Y2Storage::DiskSize.GiB(20)) - end - end - end - end -end diff --git a/service/test/agama/storage/volume_test.rb b/service/test/agama/storage/volume_test.rb index 2c46c68acc..0dc8dc7c38 100644 --- a/service/test/agama/storage/volume_test.rb +++ b/service/test/agama/storage/volume_test.rb @@ -25,24 +25,6 @@ require "y2storage/volume_specification" describe Agama::Storage::Volume do - describe ".new_from_json" do - let(:config) { Agama::Config.new } - - let(:volume_json) do - { - mount: { - path: "/test" - } - } - end - - it "generates a volume from JSON according to schema" do - result = described_class.new_from_json(volume_json, config: config) - expect(result).to be_a(Agama::Storage::Volume) - expect(result.mount_path).to eq("/test") - end - end - describe "#to_json_settngs" do let(:volume) { Agama::Storage::Volume.new("/test") } From 77d3f73177262533dad82a6051ac43eaeb0c0e7a Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 23 Oct 2025 17:18:13 +0200 Subject: [PATCH 250/917] Delete some calls to deleted methods --- service/lib/agama/dbus/storage/manager.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 28bb7cc546..fd9ea48682 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -516,11 +516,8 @@ def proposal def register_storage_callbacks backend.on_issues_change { issues_properties_changed } backend.on_deprecated_system_change { storage_properties_changed } - backend.on_probe { refresh_system_devices } backend.on_configure do - export_proposal proposal_properties_changed - refresh_staging_devices end end From f8089dca2e85eb438533a5516017f1eb2e10a49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 07:15:58 +0100 Subject: [PATCH 251/917] Implement SetLocale API method --- service/lib/agama/dbus/storage/manager.rb | 21 ++++++++------------- service/lib/agama/storage/manager.rb | 11 ++++------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index fd9ea48682..e0466d4b49 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -25,7 +25,6 @@ require "y2storage/storage_manager" require "agama/dbus/base_object" require "agama/dbus/interfaces/issues" -require "agama/dbus/interfaces/locale" require "agama/dbus/interfaces/service_status" require "agama/dbus/storage/iscsi_nodes_tree" require "agama/dbus/with_service_status" @@ -49,7 +48,6 @@ class Manager < BaseObject # rubocop:disable Metrics/ClassLength include WithServiceStatus include ::DBus::ObjectManager include DBus::Interfaces::Issues - include DBus::Interfaces::Locale include DBus::Interfaces::ServiceStatus PATH = "/org/opensuse/Agama/Storage1" @@ -73,10 +71,6 @@ def initialize(backend, service_status: nil, logger: nil) add_s390_interfaces if Yast::Arch.s390 end - def locale=(locale) - backend.locale = locale - end - # List of issues, see {DBus::Interfaces::Issues} # # @return [Array] @@ -192,9 +186,12 @@ def recover_config_model # @param serialized_config [String] Serialized storage config. # @return [Integer] 0 success; 1 error def configure(serialized_config) - logger.info("Setting storage config from D-Bus: #{serialized_config}") + start_progress(1, CONFIGURING_STEP) + config_json = JSON.parse(serialized_config, symbolize_names: true) backend.configure(config_json) + + finish_progress end # Applies the given serialized config model according to the JSON schema. @@ -202,7 +199,7 @@ def configure(serialized_config) # @param serialized_model [String] Serialized storage config model. # @return [Integer] 0 success; 1 error def configure_with_model(serialized_model) - logger.info("Setting storage config model from D-Bus: #{serialized_model}") + start_progress(1, CONFIGURING_STEP) model_json = JSON.parse(serialized_model, symbolize_names: true) config = Agama::Storage::ConfigConversions::FromModel.new( @@ -211,8 +208,9 @@ def configure_with_model(serialized_model) storage_system: proposal.storage_system ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } - backend.configure(config_json) + + finish_progress end # Solves the given serialized config model. @@ -220,8 +218,6 @@ def configure_with_model(serialized_model) # @param serialized_model [String] Serialized storage config model. # @return [String] Serialized solved model. def solve_config_model(serialized_model) - logger.info("Solving storage config model from D-Bus: #{serialized_model}") - model_json = JSON.parse(serialized_model, symbolize_names: true) solved_model_json = proposal.solve_model(model_json) JSON.pretty_generate(solved_model_json) @@ -232,7 +228,7 @@ def solve_config_model(serialized_model) dbus_method(:Probe) { probe } dbus_method(:Install) { install } dbus_method(:Finish) { finish } - dbus_method(:SetLocale, "in locale:s") {} + dbus_method(:SetLocale, "in locale:s") { |locale| backend.configure_locale(locale) } # TODO: receive a product_config instead of an id. dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } dbus_method(:GetSystem, "out system:s") { recover_system } @@ -243,7 +239,6 @@ def solve_config_model(serialized_model) dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") {} - dbus_method(:GetProgress, "out progress:s") { progress.to_json } dbus_signal(:SystemChanged) dbus_signal(:ConfigChanged) dbus_signal(:ProposalChanged) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index cf67d0d276..e1d9e03143 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -33,6 +33,7 @@ require "agama/storage/proposal_settings" require "agama/with_issues" require "agama/with_locale" +require "agama/with_progress_manager" require "yast" require "y2storage/clients/inst_prepdisk" require "y2storage/luks" @@ -47,6 +48,7 @@ module Storage class Manager include WithLocale include WithIssues + include WithProgressManager # @return [Agama::Config] attr_reader :product_config @@ -62,8 +64,6 @@ def initialize(product_config, logger: nil) @product_config = product_config @logger = logger || Logger.new($stdout) @bootloader = Bootloader.new(logger) - - register_progress_callbacks end # Whether the system is in a deprecated status @@ -159,6 +159,7 @@ def probe # the default config is applied. # @return [Boolean] Whether storage was successfully configured. def configure(config_json = nil) + logger.info("Configuring storage: #{config_json}") result = Configurator.new(proposal).configure(config_json) update_issues @on_configure_callbacks&.each(&:call) @@ -235,7 +236,7 @@ def actions # Changes the service's locale # # @param locale [String] new locale - def locale=(locale) + def configure_locale(locale) change_process_locale(locale) update_issues end @@ -261,10 +262,6 @@ def prohibit_bls_boot Y2Storage::StorageEnv.instance.reset_cache end - def register_progress_callbacks - on_progress_change { logger.info(progress.to_s) } - end - # Whether iSCSI is needed in the target system. # # @return [Boolean] From 18925a42492d43e451356a088330b394fa14f2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 07:28:00 +0100 Subject: [PATCH 252/917] Remove deprecated system --- service/lib/agama/dbus/storage/manager.rb | 48 ++--------------------- service/lib/agama/storage/manager.rb | 46 ---------------------- 2 files changed, 4 insertions(+), 90 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index e0466d4b49..86f5923632 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -19,9 +19,6 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "dbus" -require "json" -require "yast" require "y2storage/storage_manager" require "agama/dbus/base_object" require "agama/dbus/interfaces/issues" @@ -31,9 +28,12 @@ require "agama/storage/config_conversions" require "agama/storage/encryption_settings" require "agama/storage/volume_templates_builder" -require "agama/with_progress" require "agama/storage/devicegraph_conversions" require "agama/storage/volume_conversions" +require "agama/with_progress" +require "dbus" +require "json" +require "yast" Yast.import "Arch" @@ -81,13 +81,6 @@ def issues STORAGE_INTERFACE = "org.opensuse.Agama.Storage1" private_constant :STORAGE_INTERFACE - # Whether the system is in a deprecated status - # - # @return [Boolean] - def deprecated_system - backend.deprecated_system? - end - # Implementation for the API method #Activate. def activate start_progress(3, ACTIVATING_STEP) @@ -281,11 +274,6 @@ def bootloader_config_as_json end end - # @todo Move device related properties here, for example, the list of system and staging - # devices, dirty, etc. - STORAGE_DEVICES_INTERFACE = "org.opensuse.Agama.Storage1.Devices" - private_constant :STORAGE_DEVICES_INTERFACE - # List of sorted actions. # # @return [Hash] @@ -510,10 +498,6 @@ def proposal def register_storage_callbacks backend.on_issues_change { issues_properties_changed } - backend.on_deprecated_system_change { storage_properties_changed } - backend.on_configure do - proposal_properties_changed - end end def register_iscsi_callbacks @@ -521,26 +505,6 @@ def register_iscsi_callbacks iscsi_initiator_properties_changed refresh_iscsi_nodes end - - backend.iscsi.on_sessions_change do - # Currently, the system is set as deprecated instead of reprobing automatically. This - # is done so to avoid a reprobing after each single session change performed by the UI. - # Clients are expected to request a reprobing if they detect a deprecated system. - # - # If the UI is adapted to use the new iSCSI API (i.e., #SetConfig), then this behaviour - # should be reevaluated. Ideally, the system would be reprobed if the sessions change. - deprecate_system - end - end - - def storage_properties_changed - properties = interfaces_and_properties[STORAGE_INTERFACE] - dbus_properties_changed(STORAGE_INTERFACE, properties, []) - end - - def proposal_properties_changed - properties = interfaces_and_properties[PROPOSAL_CALCULATOR_INTERFACE] - dbus_properties_changed(PROPOSAL_CALCULATOR_INTERFACE, properties, []) end def iscsi_initiator_properties_changed @@ -548,10 +512,6 @@ def iscsi_initiator_properties_changed dbus_properties_changed(ISCSI_INITIATOR_INTERFACE, properties, []) end - def deprecate_system - backend.deprecated_system = true - end - def refresh_iscsi_nodes nodes = backend.iscsi.nodes iscsi_nodes_tree.update(nodes) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index e1d9e03143..db108e687f 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -66,40 +66,6 @@ def initialize(product_config, logger: nil) @bootloader = Bootloader.new(logger) end - # Whether the system is in a deprecated status - # - # The system is usually set as deprecated as effect of managing some kind of devices, for - # example, when iSCSI sessions are created. - # - # A deprecated system means that the probed system could not match with the current system. - # - # @return [Boolean] - def deprecated_system? - !!@deprecated_system - end - - # Sets whether the system is deprecated - # - # If the deprecated status changes, then callbacks are executed and the issues are - # recalculated, see {#on_deprecated_system_change}. - # - # @param value [Boolean] - def deprecated_system=(value) - return if deprecated_system? == value - - @deprecated_system = value - @on_deprecated_system_change_callbacks&.each(&:call) - update_issues - end - - # Registers a callback to be called when the system is set as deprecated - # - # @param block [Proc] - def on_deprecated_system_change(&block) - @on_deprecated_system_change_callbacks ||= [] - @on_deprecated_system_change_callbacks << block - end - # Registers a callback to be called when the system is probed # # @param block [Proc] @@ -286,7 +252,6 @@ def update_issues # @return [Array] def system_issues issues = probing_issues + [ - deprecated_system_issue, candidate_devices_issue ] @@ -308,17 +273,6 @@ def probing_issues end end - # Returns an issue if the system is deprecated - # - # @return [Issue, nil] - def deprecated_system_issue - return unless deprecated_system? - - Issue.new("The system devices have changed", - source: Issue::Source::SYSTEM, - severity: Issue::Severity::ERROR) - end - # Returns an issue if there is no candidate device for installation # # @return [Issue, nil] From 9e5459e54f8db0381b05b5908cae5d6b702d2efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 08:46:37 +0100 Subject: [PATCH 253/917] Remove service status --- service/lib/agama/dbus/clients/storage.rb | 2 -- service/lib/agama/dbus/storage/manager.rb | 11 ++--------- service/lib/agama/dbus/storage_service.rb | 19 ++----------------- service/lib/agama/manager.rb | 6 +----- service/lib/agama/storage/manager.rb | 17 ----------------- 5 files changed, 5 insertions(+), 50 deletions(-) diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index 0d041c15a1..cd09aca414 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/base" -require "agama/dbus/clients/with_service_status" require "agama/dbus/clients/with_locale" require "agama/dbus/clients/with_progress" require "agama/dbus/clients/with_issues" @@ -32,7 +31,6 @@ module Clients # D-Bus client for storage configuration class Storage < Base include WithLocale - include WithServiceStatus include WithProgress include WithIssues diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 86f5923632..99226e5ca3 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -22,9 +22,7 @@ require "y2storage/storage_manager" require "agama/dbus/base_object" require "agama/dbus/interfaces/issues" -require "agama/dbus/interfaces/service_status" require "agama/dbus/storage/iscsi_nodes_tree" -require "agama/dbus/with_service_status" require "agama/storage/config_conversions" require "agama/storage/encryption_settings" require "agama/storage/volume_templates_builder" @@ -45,27 +43,22 @@ class Manager < BaseObject # rubocop:disable Metrics/ClassLength extend Yast::I18n include WithProgress - include WithServiceStatus include ::DBus::ObjectManager include DBus::Interfaces::Issues - include DBus::Interfaces::ServiceStatus PATH = "/org/opensuse/Agama/Storage1" private_constant :PATH # @param backend [Agama::Storage::Manager] - # @param service_status [Agama::DBus::ServiceStatus, nil] # @param logger [Logger, nil] - def initialize(backend, service_status: nil, logger: nil) + def initialize(backend, logger: nil) textdomain "agama" super(PATH, logger: logger) @backend = backend - @service_status = service_status register_storage_callbacks register_progress_callbacks - register_service_status_callbacks register_iscsi_callbacks add_s390_interfaces if Yast::Arch.s390 @@ -446,7 +439,7 @@ def iscsi_delete(path) dbus_method :Discover, "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| - busy_while { iscsi_discover(address, port, options) } + iscsi_discover(address, port, options) end dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index 5bf37c9aaf..e858aea3ef 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -21,7 +21,6 @@ require "dbus" require "agama/dbus/bus" -require "agama/dbus/service_status" require "agama/dbus/storage/iscsi" require "agama/dbus/storage/manager" require "agama/storage" @@ -97,32 +96,18 @@ def dbus_objects # @return [Agama::DBus::Storage::Manager] def manager_object - @manager_object ||= Agama::DBus::Storage::Manager.new( - manager, - service_status: service_status, - logger: logger - ) + @manager_object ||= Agama::DBus::Storage::Manager.new(manager, logger: logger) end # @return [Agama::DBus::Storage::ISCSI] def iscsi_object - # Uses the same service status as the manager D-Bus object. - @iscsi_object ||= Agama::DBus::Storage::ISCSI.new( - manager.iscsi, - service_status: service_status, - logger: logger - ) + @iscsi_object ||= Agama::DBus::Storage::ISCSI.new(manager.iscsi, logger: logger) end # @return [Agama::Storage::Manager] def manager @manager ||= Agama::Storage::Manager.new(config, logger: logger) end - - # @return [Agama::DBus::ServiceStatus] - def service_status - @service_status ||= Agama::DBus::ServiceStatus.new - end end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index a99ff8035a..d41c00a608 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -208,11 +208,7 @@ def network # # @return [DBus::Clients::Storage] def storage - @storage ||= DBus::Clients::Storage.new.tap do |client| - client.on_service_status_change do |status| - service_status_recorder.save(client.service.name, status) - end - end + @storage ||= DBus::Clients::Storage.new end # Name of busy services diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index db108e687f..22738e50d7 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -66,22 +66,6 @@ def initialize(product_config, logger: nil) @bootloader = Bootloader.new(logger) end - # Registers a callback to be called when the system is probed - # - # @param block [Proc] - def on_probe(&block) - @on_probe_callbacks ||= [] - @on_probe_callbacks << block - end - - # Registers a callback to be called when storage is configured. - # - # @param block [Proc] - def on_configure(&block) - @on_configure_callbacks ||= [] - @on_configure_callbacks << block - end - # TODO: move to storage_service def setup # Underlying yast-storage-ng has own mechanism for proposing boot strategies. @@ -128,7 +112,6 @@ def configure(config_json = nil) logger.info("Configuring storage: #{config_json}") result = Configurator.new(proposal).configure(config_json) update_issues - @on_configure_callbacks&.each(&:call) result end From e8e7cf1482276ce7d35daf2902fc75dc96b25912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 10:12:29 +0100 Subject: [PATCH 254/917] Emit signals --- service/lib/agama/dbus/storage/manager.rb | 28 +++++++++++++++++++---- service/lib/agama/storage/manager.rb | 8 ------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 99226e5ca3..4f9aa60cb7 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -41,7 +41,7 @@ module Storage # D-Bus object to manage storage installation class Manager < BaseObject # rubocop:disable Metrics/ClassLength extend Yast::I18n - + include Yast::I18n include WithProgress include ::DBus::ObjectManager include DBus::Interfaces::Issues @@ -82,9 +82,10 @@ def activate next_progress_step(PROBING_STEP) backend.probe + self.SystemChanged next_progress_step(CONFIGURING_STEP) - backend.configure_with_current + configure_with_current finish_progress end @@ -96,9 +97,10 @@ def probe next_progress_step(PROBING_STEP) backend.probe + self.SystemChanged next_progress_step(CONFIGURING_STEP) - backend.configure_with_current + configure_with_current finish_progress end @@ -111,10 +113,14 @@ def configure_product(id) backend.activate unless backend.activated? next_progress_step(PROBING_STEP) - backend.probe unless backend.probed? + if !backend.probed? + backend.probe + self.SystemChanged + end next_progress_step(CONFIGURING_STEP) backend.configure + self.ProposalChanged finish_progress end @@ -176,6 +182,7 @@ def configure(serialized_config) config_json = JSON.parse(serialized_config, symbolize_names: true) backend.configure(config_json) + self.ProposalChanged finish_progress end @@ -195,6 +202,7 @@ def configure_with_model(serialized_model) ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } backend.configure(config_json) + self.ProposalChanged finish_progress end @@ -226,7 +234,6 @@ def solve_config_model(serialized_model) dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") {} dbus_signal(:SystemChanged) - dbus_signal(:ConfigChanged) dbus_signal(:ProposalChanged) dbus_signal(:IssuesChanged) dbus_signal(:ProgressChanged, "progress:s") @@ -464,6 +471,17 @@ def register_progress_callbacks on_progress_finish { self.ProgressFinished } end + # Configures storage using the current config. + # + # @note The proposal is not calculated if there is not a config yet. + def configure_with_current + config_json = proposal.storage_json + return unless config_json + + configure(config_json) + self.ProposalChanged + end + # JSON representation of the given devicegraph from StorageManager # # @param meth [Symbol] method used to get the devicegraph from StorageManager diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 22738e50d7..5cf6de8287 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -115,14 +115,6 @@ def configure(config_json = nil) result end - # Configures storage using the current config. - # - # @note The proposal is not calculated if there is not a config yet. - def configure_with_current - config_json = proposal.storage_json - configure(config_json) if config_json - end - # Commits the storage changes. # # @return [Boolean] true if the all actions were successful. From ac87a6089fc6206b9419898e5bbd1011c692a5b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 11:41:46 +0100 Subject: [PATCH 255/917] Reorder manager code --- service/lib/agama/dbus/storage/manager.rb | 310 +++++++++++----------- service/lib/agama/storage/manager.rb | 10 - 2 files changed, 151 insertions(+), 169 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 4f9aa60cb7..a0c684b782 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -64,16 +64,29 @@ def initialize(backend, logger: nil) add_s390_interfaces if Yast::Arch.s390 end - # List of issues, see {DBus::Interfaces::Issues} - # - # @return [Array] - def issues - backend.issues + dbus_interface "org.opensuse.Agama.Storage1" do + dbus_method(:Activate) { activate } + dbus_method(:Probe) { probe } + dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } + dbus_method(:Install) { install } + dbus_method(:Finish) { finish } + dbus_method(:SetLocale, "in locale:s") { |locale| backend.configure_locale(locale) } + # TODO: receive a product_config instead of an id. + dbus_method(:GetSystem, "out system:s") { recover_system } + dbus_method(:GetConfig, "out config:s") { recover_config } + dbus_method(:SetConfig, "in config:s") { |c| configure(c) } + dbus_method(:GetConfigModel, "out model:s") { recover_config_model } + dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} + dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } + dbus_method(:GetProposal, "out proposal:s") { recover_proposal } + dbus_method(:GetIssues, "out issues:s") {} + dbus_signal(:SystemChanged) + dbus_signal(:ProposalChanged) + dbus_signal(:IssuesChanged) + dbus_signal(:ProgressChanged, "progress:s") + dbus_signal(:ProgressFinished) end - STORAGE_INTERFACE = "org.opensuse.Agama.Storage1" - private_constant :STORAGE_INTERFACE - # Implementation for the API method #Activate. def activate start_progress(3, ACTIVATING_STEP) @@ -150,6 +163,22 @@ def finish finish_progress end + # NOTE: memoization of the values? + # @return [String] + def recover_system + json = { + devices: json_devices(:probed), + availableDrives: available_drives, + availableMdRaids: available_md_raids, + candidateDrives: candidate_drives, + candidateMdRaids: candidate_md_raids, + productMountPoints: product_mount_points, + encryptionMethods: encryption_methods, + volumeTemplates: volume_templates + } + JSON.pretty_generate(json) + end + # Gets and serializes the storage config used for calculating the current proposal. # # @return [String] @@ -166,8 +195,6 @@ def recover_config_model JSON.pretty_generate(json) end - # @todo Drop support for the guided settings. - # # Applies the given serialized config according to the JSON schema. # # The JSON schema supports two different variants: @@ -176,7 +203,6 @@ def recover_config_model # @raise If the config is not valid. # # @param serialized_config [String] Serialized storage config. - # @return [Integer] 0 success; 1 error def configure(serialized_config) start_progress(1, CONFIGURING_STEP) @@ -190,7 +216,6 @@ def configure(serialized_config) # Applies the given serialized config model according to the JSON schema. # # @param serialized_model [String] Serialized storage config model. - # @return [Integer] 0 success; 1 error def configure_with_model(serialized_model) start_progress(1, CONFIGURING_STEP) @@ -217,31 +242,31 @@ def solve_config_model(serialized_model) JSON.pretty_generate(solved_model_json) end - dbus_interface STORAGE_INTERFACE do - dbus_method(:Activate) { activate } - dbus_method(:Probe) { probe } - dbus_method(:Install) { install } - dbus_method(:Finish) { finish } - dbus_method(:SetLocale, "in locale:s") { |locale| backend.configure_locale(locale) } - # TODO: receive a product_config instead of an id. - dbus_method(:SetProduct, "in id:s") { |id| configure_product(id) } - dbus_method(:GetSystem, "out system:s") { recover_system } - dbus_method(:GetConfig, "out config:s") { recover_config } - dbus_method(:SetConfig, "in config:s") { |c| configure(c) } - dbus_method(:GetConfigModel, "out model:s") { recover_config_model } - dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} - dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } - dbus_method(:GetProposal, "out proposal:s") { recover_proposal } - dbus_method(:GetIssues, "out issues:s") {} - dbus_signal(:SystemChanged) - dbus_signal(:ProposalChanged) - dbus_signal(:IssuesChanged) - dbus_signal(:ProgressChanged, "progress:s") - dbus_signal(:ProgressFinished) + # NOTE: memoization of the values? + # @return [String] + def recover_proposal + json = { + devices: json_devices(:staging), + actions: actions + } + JSON.pretty_generate(json) end - BOOTLOADER_INTERFACE = "org.opensuse.Agama.Storage1.Bootloader" - private_constant :BOOTLOADER_INTERFACE + # List of issues, see {DBus::Interfaces::Issues} + # + # @return [Array] + def issues + backend.issues + end + + dbus_interface "org.opensuse.Agama.Storage1.Bootloader" do + dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| + load_bootloader_config_from_json(serialized_config) + end + dbus_method(:GetConfig, "out serialized_config:s") do + bootloader_config_as_json + end + end # Applies the given serialized config according to the JSON schema. # @@ -265,117 +290,6 @@ def bootloader_config_as_json backend.bootloader.config.to_json end - dbus_interface BOOTLOADER_INTERFACE do - dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| - load_bootloader_config_from_json(serialized_config) - end - dbus_method(:GetConfig, "out serialized_config:s") do - bootloader_config_as_json - end - end - - # List of sorted actions. - # - # @return [Hash] - # * :device [Integer] - # * :text [String] - # * :subvol [Boolean] - # * :delete [Boolean] - # * :resize [Boolean] - def actions - backend.actions.map do |action| - { - device: action.device_sid, - text: action.text, - subvol: action.on_btrfs_subvolume?, - delete: action.delete?, - resize: action.resize? - } - end - end - - # @see Storage::System#available_drives - # @return [Array] - def available_drives - proposal.storage_system.available_drives.map(&:sid) - end - - # @see Storage::System#available_drives - # @return [Array] - def candidate_drives - proposal.storage_system.candidate_drives.map(&:sid) - end - - # @see Storage::System#available_drives - # @return [Array] - def available_md_raids - proposal.storage_system.available_md_raids.map(&:sid) - end - - # @see Storage::System#available_drives - # @return [Array] - def candidate_md_raids - proposal.storage_system.candidate_md_raids.map(&:sid) - end - - # Meaningful mount points for the current product. - # - # @return [Array] - def product_mount_points - volume_templates_builder - .all - .map(&:mount_path) - .reject(&:empty?) - end - - # Reads the list of possible encryption methods for the current system and product. - # - # @return [Array] - def encryption_methods - Agama::Storage::EncryptionSettings - .available_methods - .map { |m| m.id.to_s } - end - - # Default volumes to be used as templates - # - # @return [Array] - def volume_templates - volumes = volume_templates_builder.all - volumes << volume_templates_builder.for("") unless volumes.map(&:mount_path).include?("") - - volumes.map do |vol| - Agama::Storage::VolumeConversions::ToJSON.new(vol).convert - end - end - - # NOTE: memoization of the values? - def recover_proposal - json = { - devices: json_devices(:staging), - actions: actions - } - JSON.pretty_generate(json) - end - - # NOTE: memoization of the values? - def recover_system - json = { - devices: json_devices(:probed), - availableDrives: available_drives, - availableMdRaids: available_md_raids, - candidateDrives: candidate_drives, - candidateMdRaids: candidate_md_raids, - productMountPoints: product_mount_points, - encryptionMethods: encryption_methods, - volumeTemplates: volume_templates - } - JSON.pretty_generate(json) - end - - ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" - private_constant :ISCSI_INITIATOR_INTERFACE - # Gets the iSCSI initiator name # # @return [String] @@ -397,6 +311,22 @@ def ibft backend.iscsi.initiator.ibft_name? end + ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" + private_constant :ISCSI_INITIATOR_INTERFACE + + dbus_interface ISCSI_INITIATOR_INTERFACE do + dbus_accessor :initiator_name, "s" + + dbus_reader :ibft, "b", dbus_name: "IBFT" + + dbus_method :Discover, + "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| + iscsi_discover(address, port, options) + end + + dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } + end + # Performs an iSCSI discovery # # @param address [String] IP address of the iSCSI server @@ -439,19 +369,6 @@ def iscsi_delete(path) 2 # Error code end - dbus_interface ISCSI_INITIATOR_INTERFACE do - dbus_accessor :initiator_name, "s" - - dbus_reader :ibft, "b", dbus_name: "IBFT" - - dbus_method :Discover, - "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| - iscsi_discover(address, port, options) - end - - dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } - end - private ACTIVATING_STEP = N_("Activating storage devices") @@ -491,6 +408,81 @@ def json_devices(meth) Agama::Storage::DevicegraphConversions::ToJSON.new(devicegraph).convert end + # List of sorted actions. + # + # @return [Hash] + # * :device [Integer] + # * :text [String] + # * :subvol [Boolean] + # * :delete [Boolean] + # * :resize [Boolean] + def actions + backend.actions.map do |action| + { + device: action.device_sid, + text: action.text, + subvol: action.on_btrfs_subvolume?, + delete: action.delete?, + resize: action.resize? + } + end + end + + # @see Storage::System#available_drives + # @return [Array] + def available_drives + proposal.storage_system.available_drives.map(&:sid) + end + + # @see Storage::System#available_drives + # @return [Array] + def candidate_drives + proposal.storage_system.candidate_drives.map(&:sid) + end + + # @see Storage::System#available_drives + # @return [Array] + def available_md_raids + proposal.storage_system.available_md_raids.map(&:sid) + end + + # @see Storage::System#available_drives + # @return [Array] + def candidate_md_raids + proposal.storage_system.candidate_md_raids.map(&:sid) + end + + # Meaningful mount points for the current product. + # + # @return [Array] + def product_mount_points + volume_templates_builder + .all + .map(&:mount_path) + .reject(&:empty?) + end + + # Reads the list of possible encryption methods for the current system and product. + # + # @return [Array] + def encryption_methods + Agama::Storage::EncryptionSettings + .available_methods + .map { |m| m.id.to_s } + end + + # Default volumes to be used as templates + # + # @return [Array] + def volume_templates + volumes = volume_templates_builder.all + volumes << volume_templates_builder.for("") unless volumes.map(&:mount_path).include?("") + + volumes.map do |vol| + Agama::Storage::VolumeConversions::ToJSON.new(vol).convert + end + end + def add_s390_interfaces require "agama/dbus/storage/interfaces/dasd_manager" require "agama/dbus/storage/interfaces/zfcp_manager" diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 5cf6de8287..e7c010b1f9 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -20,7 +20,6 @@ # find current contact information at www.suse.com. require "agama/http/clients" -require "agama/dbus/clients/software" require "agama/issue" require "agama/security" require "agama/storage/actions_generator" @@ -56,8 +55,6 @@ class Manager # @return [Bootloader] attr_reader :bootloader - # Constructor - # # @param product_config [Agama::Config] # @param logger [Logger, nil] def initialize(product_config, logger: nil) @@ -156,13 +153,6 @@ def iscsi @iscsi ||= ISCSI::Manager.new(progress_manager: progress_manager, logger: logger) end - # Returns the client to ask the software service - # - # @return [Agama::DBus::Clients::Software] - def software - @software ||= DBus::Clients::Software.instance - end - # Storage actions. # # @return [Array] From e16bc853b55b0cc2411a22e99d9c061e231adb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 12:05:27 +0100 Subject: [PATCH 256/917] Move code to service starter --- service/lib/agama/dbus/storage_service.rb | 33 ++++++++++++++++++++++- service/lib/agama/storage/manager.rb | 33 ----------------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index e858aea3ef..dd68576ae5 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -25,6 +25,7 @@ require "agama/dbus/storage/manager" require "agama/storage" require "y2storage/inhibitors" +require "y2storage/storage_env" module Agama module DBus @@ -55,7 +56,13 @@ def start # Inhibits various storage subsystem (udisk, systemd mounts, raid auto-assembly) that # interfere with the operation of yast-storage-ng and libstorage-ng. Y2Storage::Inhibitors.new.inhibit - manager.setup + + # Underlying yast-storage-ng has own mechanism for proposing boot strategies. + # However, we don't always want to use BLS when it proposes so. Currently + # we want to use BLS only for Tumbleweed / Slowroll + prohibit_bls_boot if !config.boot_strategy&.casecmp("BLS") + + check_multipath export end @@ -84,6 +91,30 @@ def dispatch # @return [Logger] attr_reader :logger + def prohibit_bls_boot + ENV["YAST_NO_BLS_BOOT"] = "1" + # avoiding problems with cached values + Y2Storage::StorageEnv.instance.reset_cache + end + + MULTIPATH_CONFIG = "/etc/multipath.conf" + private_constant :MULTIPATH_CONFIG + + # Checks if all requirement for multipath probing is correct and if not then log it. + def check_multipath + # check if kernel module is loaded + mods = `lsmod`.lines.grep(/dm_multipath/) + logger.warn("dm_multipath modules is not loaded") if mods.empty? + + binary = system("which multipath") + if binary + conf = `multipath -t`.lines.grep(/find_multipaths "smart"/) + logger.warn("multipath: find_multipaths is not set to 'smart'") if conf.empty? + else + logger.warn("multipath is not installed.") + end + end + # @return [::DBus::ObjectServer] def service @service ||= bus.request_service(SERVICE_NAME) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index e7c010b1f9..ee3362e25f 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -36,7 +36,6 @@ require "yast" require "y2storage/clients/inst_prepdisk" require "y2storage/luks" -require "y2storage/storage_env" require "y2storage/storage_manager" Yast.import "PackagesProposal" @@ -63,15 +62,6 @@ def initialize(product_config, logger: nil) @bootloader = Bootloader.new(logger) end - # TODO: move to storage_service - def setup - # Underlying yast-storage-ng has own mechanism for proposing boot strategies. - # However, we don't always want to use BLS when it proposes so. Currently - # we want to use BLS only for Tumbleweed / Slowroll - prohibit_bls_boot if !product_config.boot_strategy&.casecmp("BLS") - check_multipath - end - def activated? !!@activated end @@ -187,12 +177,6 @@ def security # @return [Logger] attr_reader :logger - def prohibit_bls_boot - ENV["YAST_NO_BLS_BOOT"] = "1" - # avoiding problems with cached values - Y2Storage::StorageEnv.instance.reset_cache - end - # Whether iSCSI is needed in the target system. # # @return [Boolean] @@ -255,23 +239,6 @@ def candidate_devices_issue def questions_client @questions_client ||= Agama::HTTP::Clients::Questions.new(logger) end - - MULTIPATH_CONFIG = "/etc/multipath.conf" - # Checks if all requirement for multipath probing is correct and if not - # then log it - def check_multipath - # check if kernel module is loaded - mods = `lsmod`.lines.grep(/dm_multipath/) - logger.warn("dm_multipath modules is not loaded") if mods.empty? - - binary = system("which multipath") - if binary - conf = `multipath -t`.lines.grep(/find_multipaths "smart"/) - logger.warn("multipath: find_multipaths is not set to 'smart'") if conf.empty? - else - logger.warn("multipath is not installed.") - end - end end end end From 97103b93e6a49cb1628cbe15a3232f92e914067a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 24 Oct 2025 15:14:42 +0100 Subject: [PATCH 257/917] Add methods to dbus client --- rust/agama-storage/Cargo.toml | 1 + rust/agama-storage/src/dbus/client.rs | 105 +++++++++++++++++++------- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml index d97f839915..3ac46e90bb 100644 --- a/rust/agama-storage/Cargo.toml +++ b/rust/agama-storage/Cargo.toml @@ -9,4 +9,5 @@ agama-utils = { path = "../agama-utils" } thiserror = "2.0.16" async-trait = "0.1.89" zbus = "5.7.1" +serde = { version = "1.0.228" } serde_json = { version = "1.0.140", features = ["raw_value"] } diff --git a/rust/agama-storage/src/dbus/client.rs b/rust/agama-storage/src/dbus/client.rs index 35016abc7f..72ad5ef810 100644 --- a/rust/agama-storage/src/dbus/client.rs +++ b/rust/agama-storage/src/dbus/client.rs @@ -21,12 +21,7 @@ //! Implements a client to access Agama's storage service. use serde_json::value::RawValue; -use std::collections::HashMap; -use zbus::{ - names::BusName, - zvariant::{self, OwnedObjectPath}, - Connection, -}; +use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; const OBJECT_PATH: &str = "/org/opensuse/Agama/Storage1"; @@ -55,40 +50,92 @@ impl Client { Self { connection } } + pub async fn activate(&self) -> Result<(), Error> { + self.call("Activate", &()).await?; + Ok(()) + } + + pub async fn probe(&self) -> Result<(), Error> { + self.call("Probe", &()).await?; + Ok(()) + } + + pub async fn install(&self) -> Result<(), Error> { + self.call("Install", &()).await?; + Ok(()) + } + + pub async fn finish(&self) -> Result<(), Error> { + self.call("Finish", &()).await?; + Ok(()) + } + + pub async fn get_system(&self) -> Result, Error> { + let message = self.call("GetSystem", &()).await?; + self.json_from(message) + } + + pub async fn get_config(&self) -> Result, Error> { + let message = self.call("GetConfig", &()).await?; + self.json_from(message) + } + pub async fn get_config_model(&self) -> Result, Error> { - self.get_json("GetConfigModel").await + let message = self.call("GetConfigModel", &()).await?; + self.json_from(message) } - pub async fn set_config_model(&self, model: Box) -> Result<(), Error> { - self.set_json("SetConfigModel", model).await + pub async fn get_proposal(&self) -> Result, Error> { + let message = self.call("GetProposal", &()).await?; + self.json_from(message) } - async fn get_json(&self, method: &str) -> Result, Error> { - let bus = BusName::try_from(SERVICE_NAME.to_string())?; - let path = OwnedObjectPath::try_from(OBJECT_PATH)?; - let message = self - .connection - .call_method(Some(&bus), &path, Some(INTERFACE), method, &()) - .await?; + pub async fn get_issues(&self) -> Result, Error> { + let message = self.call("GetIssues", &()).await?; + self.json_from(message) + } - let value: String = message.body().deserialize()?; - RawValue::from_string(value).map_err(|e| e.into()) + pub async fn set_config(&self, config: Box) -> Result<(), Error> { + self.call("SetConfig", &(config.to_string())).await?; + Ok(()) + } + + //TODO: send a product config instead of an id. + pub async fn set_product(&self, product_id: &str) -> Result<(), Error> { + self.call("SetProduct", &(product_id)).await?; + Ok(()) } - async fn set_json(&self, method: &str, json: Box) -> Result<(), Error> { + pub async fn set_locale(&self, locale: &str) -> Result<(), Error> { + self.call("SetLocale", &(locale)).await?; + Ok(()) + } + + pub async fn set_config_model(&self, model: Box) -> Result<(), Error> { + self.call("SetConfigModel", &(model.to_string())).await?; + Ok(()) + } + + pub async fn solve_config_model(&self, model: Box) -> Result, Error> { + let message = self.call("SolveConfigModel", &(model.to_string())).await?; + self.json_from(message) + } + + async fn call( + &self, + method: &str, + body: &T, + ) -> Result { let bus = BusName::try_from(SERVICE_NAME.to_string())?; let path = OwnedObjectPath::try_from(OBJECT_PATH)?; - let data: HashMap<&str, &zvariant::Value> = HashMap::new(); self.connection - .call_method( - Some(&bus), - &path, - Some(INTERFACE), - method, - &(json.to_string(), data), - ) - .await?; + .call_method(Some(&bus), &path, Some(INTERFACE), method, body) + .await + .map_err(|e| e.into()) + } - Ok(()) + fn json_from(&self, message: Message) -> Result, Error> { + let value: String = message.body().deserialize()?; + RawValue::from_string(value).map_err(|e| e.into()) } } From 8301745cd8156485d1df37d1e19d16575d36aea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 27 Oct 2025 15:04:12 +0000 Subject: [PATCH 258/917] Allow setting a specific progress --- rust/agama-utils/src/api/event.rs | 1 - rust/agama-utils/src/progress/message.rs | 14 ++++ rust/agama-utils/src/progress/service.rs | 39 ++++++---- rust/agama-utils/src/progress/start.rs | 98 +++++++++++++++++------- 4 files changed, 106 insertions(+), 46 deletions(-) diff --git a/rust/agama-utils/src/api/event.rs b/rust/agama-utils/src/api/event.rs index 21f520ebc2..31973df78d 100644 --- a/rust/agama-utils/src/api/event.rs +++ b/rust/agama-utils/src/api/event.rs @@ -30,7 +30,6 @@ pub enum Event { StateChanged, /// Progress changed. ProgressChanged { - scope: Scope, progress: Progress, }, /// Progress finished. diff --git a/rust/agama-utils/src/progress/message.rs b/rust/agama-utils/src/progress/message.rs index 1d3c5b6fc5..2ca5b8e5db 100644 --- a/rust/agama-utils/src/progress/message.rs +++ b/rust/agama-utils/src/progress/message.rs @@ -28,6 +28,20 @@ impl Message for Get { type Reply = Vec; } +pub struct Set { + pub progress: Progress, +} + +impl Set { + pub fn new(progress: Progress) -> Self { + Self { progress } + } +} + +impl Message for Set { + type Reply = (); +} + pub struct Start { pub scope: Scope, pub size: usize, diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index 76fed67da7..c43989a885 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -64,6 +64,11 @@ impl Service { fn get_progress_index(&self, scope: Scope) -> Option { self.progresses.iter().position(|p| p.scope == scope) } + + fn send_progress_changed(&self, progress: Progress) -> Result<(), Error> { + self.events.send(Event::ProgressChanged { progress })?; + Ok(()) + } } impl Actor for Service { @@ -77,6 +82,20 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Set) -> Result<(), Error> { + let progress = message.progress; + if let Some(index) = self.get_progress_index(progress.scope) { + self.progresses[index] = progress.clone(); + } else { + self.progresses.push(progress.clone()); + } + self.send_progress_changed(progress)?; + Ok(()) + } +} + #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::Start) -> Result<(), Error> { @@ -85,10 +104,7 @@ impl MessageHandler for Service { } let progress = Progress::new(message.scope, message.size, message.step); self.progresses.push(progress.clone()); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -101,10 +117,7 @@ impl MessageHandler for Service { } let progress = Progress::new_with_steps(message.scope, message.steps); self.progresses.push(progress.clone()); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -117,10 +130,7 @@ impl MessageHandler for Service { }; progress.next()?; let progress = progress.clone(); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } @@ -133,10 +143,7 @@ impl MessageHandler for Service { }; progress.next_with_step(message.step)?; let progress = progress.clone(); - self.events.send(Event::ProgressChanged { - scope: message.scope, - progress, - })?; + self.send_progress_changed(progress)?; Ok(()) } } diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs index 6074a1fd6a..bde71fc274 100644 --- a/rust/agama-utils/src/progress/start.rs +++ b/rust/agama-utils/src/progress/start.rs @@ -39,12 +39,18 @@ pub async fn start(events: event::Sender) -> Result, Error> { #[cfg(test)] mod tests { - use crate::actor::{self, Handler}; - use crate::api::event::{self, Event}; - use crate::api::progress; - use crate::api::scope::Scope; - use crate::progress::message; - use crate::progress::service::{self, Service}; + use crate::{ + actor::{self, Handler}, + api::{ + event::{self, Event}, + progress::{self, Progress}, + scope::Scope, + }, + progress::{ + message, + service::{self, Service}, + }, + }; use tokio::sync::broadcast; fn start_testing_service() -> (event::Receiver, Handler) { @@ -65,16 +71,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); - let Event::ProgressChanged { - scope: _, progress: event_progress, } = event else { @@ -99,13 +96,7 @@ mod tests { .await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -119,13 +110,7 @@ mod tests { handler.call(message::Next::new(Scope::L10n)).await?; let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressChanged { - scope: Scope::L10n, - progress: _ - } - )); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); let progresses = handler.call(message::Get).await.unwrap(); let progress = progresses.first().unwrap(); @@ -150,6 +135,61 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_set_progress() -> Result<(), Box> { + let (mut receiver, handler) = start_testing_service(); + + // Set first progress. + let progress = Progress::new(Scope::Storage, 3, "first step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "first step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + // Set second progress + let progress = Progress::new(Scope::Storage, 3, "second step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "second step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + Ok(()) + } + #[tokio::test] async fn test_progress_with_steps() -> Result<(), Box> { let (_receiver, handler) = start_testing_service(); From 734ed477a1f92bfd8ca44e20f0a4c10c5f136b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 27 Oct 2025 14:28:05 +0000 Subject: [PATCH 259/917] Add storage monitor --- rust/Cargo.lock | 3 + rust/agama-manager/src/start.rs | 4 +- rust/agama-storage/Cargo.toml | 2 + rust/agama-storage/src/lib.rs | 1 + rust/agama-storage/src/monitor.rs | 230 ++++++++++++++++++++++++++++++ rust/agama-storage/src/start.rs | 25 +++- 6 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 rust/agama-storage/src/monitor.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 662f3329eb..9d07e9219b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -224,8 +224,11 @@ version = "0.1.0" dependencies = [ "agama-utils", "async-trait", + "serde", "serde_json", "thiserror 2.0.16", + "tokio", + "tokio-stream", "zbus", ] diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 62897a29b7..41ab1b03c3 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -50,9 +50,9 @@ pub async fn start( let issues = issue::start(events.clone(), dbus.clone()).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let storage = storage::start(dbus).await?; + let storage = storage::start(progress.clone(), events.clone(), dbus).await?; - let service = Service::new(l10n, storage, issues, progress, questions, events.clone()); + let service = Service::new(l10n, storage, issues, progress, questions, events); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml index 3ac46e90bb..9924a1f1bb 100644 --- a/rust/agama-storage/Cargo.toml +++ b/rust/agama-storage/Cargo.toml @@ -9,5 +9,7 @@ agama-utils = { path = "../agama-utils" } thiserror = "2.0.16" async-trait = "0.1.89" zbus = "5.7.1" +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.16" serde = { version = "1.0.228" } serde_json = { version = "1.0.140", features = ["raw_value"] } diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index ed78b72576..43d02a32cb 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -23,6 +23,7 @@ pub use service::Service; mod dbus; pub mod message; +mod monitor; pub mod start; pub use start::start; diff --git a/rust/agama-storage/src/monitor.rs b/rust/agama-storage/src/monitor.rs new file mode 100644 index 0000000000..7e65ed8cf9 --- /dev/null +++ b/rust/agama-storage/src/monitor.rs @@ -0,0 +1,230 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_utils::{ + actor::Handler, + api::{ + event::{self, Event}, + Progress, Scope, + }, + progress::{self, message}, +}; +use serde::Deserialize; +use serde_json; +use std::pin::Pin; +use tokio::sync::broadcast; +use tokio_stream::{Stream, StreamExt, StreamMap}; +use zbus::{proxy, Connection}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Wrong signal arguments")] + ProgressChangedArgs, + #[error("Wrong signal data")] + ProgressChangedData, + #[error(transparent)] + Progress(#[from] progress::service::Error), + #[error(transparent)] + DBus(#[from] zbus::Error), + #[error(transparent)] + Event(#[from] broadcast::error::SendError), +} + +#[proxy( + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1", + interface = "org.opensuse.Agama.Storage1", + assume_defaults = true +)] +pub trait Storage1 { + #[zbus(signal)] + fn system_changed(&self) -> zbus::Result<()>; + + #[zbus(signal)] + fn proposal_changed(&self) -> zbus::Result<()>; + + #[zbus(signal)] + fn progress_changed(&self, progress: &str) -> zbus::Result<()>; + + #[zbus(signal)] + fn progress_finished(&self) -> zbus::Result<()>; +} + +#[derive(Debug)] +enum Signal { + SystemChanged(SystemChanged), + ProposalChanged(ProposalChanged), + ProgressChanged(ProgressChanged), + ProgressFinished(ProgressFinished), +} + +#[derive(Debug, Deserialize)] +struct ProgressData { + pub size: usize, + pub steps: Vec, + pub step: String, + pub index: usize, +} + +impl From for Progress { + fn from(data: ProgressData) -> Self { + Progress { + scope: Scope::Storage, + size: data.size, + steps: data.steps, + step: data.step, + index: data.index, + } + } +} + +pub struct Monitor { + progress: Handler, + events: event::Sender, + connection: Connection, +} + +impl Monitor { + pub fn new( + progress: Handler, + events: event::Sender, + connection: Connection, + ) -> Self { + Self { + progress, + events, + connection, + } + } + + async fn run(&self) -> Result<(), Error> { + let mut streams = StreamMap::new(); + streams.insert("SystemChanged", self.system_changed_stream().await?); + streams.insert("ProposalChanged", self.proposal_changed_stream().await?); + streams.insert("ProgressChanged", self.progress_changed_stream().await?); + streams.insert("ProgressFinished", self.progress_finished_stream().await?); + + tokio::pin!(streams); + + while let Some((_, signal)) = streams.next().await { + self.handle_signal(signal)?; + } + + Ok(()) + } + + fn handle_signal(&self, signal: Signal) -> Result<(), Error> { + match signal { + Signal::SystemChanged(signal) => self.handle_system_changed(signal)?, + Signal::ProposalChanged(signal) => self.handle_proposal_changed(signal)?, + Signal::ProgressChanged(signal) => self.handle_progress_changed(signal)?, + Signal::ProgressFinished(signal) => self.handle_progress_finished(signal)?, + } + Ok(()) + } + + fn handle_system_changed(&self, _signal: SystemChanged) -> Result<(), Error> { + self.events.send(Event::SystemChanged { + scope: Scope::Storage, + })?; + Ok(()) + } + + fn handle_proposal_changed(&self, _signal: ProposalChanged) -> Result<(), Error> { + self.events.send(Event::ProposalChanged { + scope: Scope::Storage, + })?; + Ok(()) + } + + fn handle_progress_changed(&self, signal: ProgressChanged) -> Result<(), Error> { + let Ok(args) = signal.args() else { + return Err(Error::ProgressChangedArgs); + }; + let Ok(progress_data) = serde_json::from_str::(args.progress) else { + return Err(Error::ProgressChangedData); + }; + self.progress + .cast(message::Set::new(progress_data.into()))?; + + Ok(()) + } + + fn handle_progress_finished(&self, _signal: ProgressFinished) -> Result<(), Error> { + self.progress.cast(message::Finish::new(Scope::Storage))?; + Ok(()) + } + + async fn system_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_system_changed() + .await? + .map(|signal| Signal::SystemChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn proposal_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_proposal_changed() + .await? + .map(|signal| Signal::ProposalChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn progress_changed_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_progress_changed() + .await? + .map(|signal| Signal::ProgressChanged(signal)); + Ok(Box::pin(stream)) + } + + async fn progress_finished_stream( + &self, + ) -> Result + Send>>, Error> { + let proxy = Storage1Proxy::new(&self.connection).await?; + let stream = proxy + .receive_progress_finished() + .await? + .map(|signal| Signal::ProgressFinished(signal)); + Ok(Box::pin(stream)) + } +} + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(monitor: Monitor) -> Result<(), Error> { + tokio::spawn(async move { + if let Err(e) = monitor.run().await { + println!("Error running the storage monitor: {e:?}"); + } + }); + Ok(()) +} diff --git a/rust/agama-storage/src/start.rs b/rust/agama-storage/src/start.rs index 1afae71369..e9b3982a7b 100644 --- a/rust/agama-storage/src/start.rs +++ b/rust/agama-storage/src/start.rs @@ -18,16 +18,33 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::service::Service; -use agama_utils::actor::{self, Handler}; +use crate::{ + monitor::{self, Monitor}, + service::Service, +}; +use agama_utils::{ + actor::{self, Handler}, + api::event, + progress, +}; #[derive(thiserror::Error, Debug)] -pub enum Error {} +pub enum Error { + #[error(transparent)] + Monitor(#[from] monitor::Error), +} /// Starts the storage service. /// /// * `dbus`: connection to Agama's D-Bus server. -pub async fn start(dbus: zbus::Connection) -> Result, Error> { +pub async fn start( + progress: Handler, + events: event::Sender, + dbus: zbus::Connection, +) -> Result, Error> { + let monitor = Monitor::new(progress, events, dbus.clone()); + monitor::spawn(monitor)?; + let service = Service::new(dbus); let handler = actor::spawn(service); Ok(handler) From 5aa6373f2e8ad661014705a65e02680d1b46902e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 12:02:25 +0000 Subject: [PATCH 260/917] Move dbus client --- rust/agama-storage/src/{dbus => }/client.rs | 0 rust/agama-storage/src/dbus.rs | 21 --------------------- rust/agama-storage/src/lib.rs | 2 +- rust/agama-storage/src/service.rs | 2 +- 4 files changed, 2 insertions(+), 23 deletions(-) rename rust/agama-storage/src/{dbus => }/client.rs (100%) delete mode 100644 rust/agama-storage/src/dbus.rs diff --git a/rust/agama-storage/src/dbus/client.rs b/rust/agama-storage/src/client.rs similarity index 100% rename from rust/agama-storage/src/dbus/client.rs rename to rust/agama-storage/src/client.rs diff --git a/rust/agama-storage/src/dbus.rs b/rust/agama-storage/src/dbus.rs deleted file mode 100644 index 904a5d2a38..0000000000 --- a/rust/agama-storage/src/dbus.rs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod client; diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index 43d02a32cb..c5cbe488af 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -21,7 +21,7 @@ pub mod service; pub use service::Service; -mod dbus; +mod client; pub mod message; mod monitor; diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index fb5b918fe1..3bd8a1419c 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::dbus::client::{self, Client}; +use crate::client::{self, Client}; use crate::message; use agama_utils::actor::{self, Actor, MessageHandler}; use async_trait::async_trait; From d3324b00a79f7093b10e350643acea6f4f87a090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Oct 2025 11:00:29 +0000 Subject: [PATCH 261/917] Add an incomplete SoftwareState struct * Represents the wanted state for the software configuration. --- rust/Cargo.lock | 7 +- rust/agama-software/Cargo.toml | 5 +- rust/agama-software/src/model.rs | 1 + rust/agama-software/src/model/state.rs | 325 +++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 rust/agama-software/src/model/state.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4b1c9a9925..0aa35fe639 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2569,7 +2569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.3", ] [[package]] @@ -3915,14 +3915,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index 04da840905..d069c05d6e 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -19,4 +19,7 @@ tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" tracing = "0.1.41" utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } -zypp-agama = { path = "../zypp-agama" } \ No newline at end of file +zypp-agama = { path = "../zypp-agama" } + +[dev-dependencies] +serde_yaml = "0.9.34" diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index a131b7a013..f583aa29f2 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -42,6 +42,7 @@ pub mod product; pub mod products; pub mod registration; pub mod software_selection; +pub mod state; /// Abstract the software-related configuration from the underlying system. /// diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs new file mode 100644 index 0000000000..6e1e523c52 --- /dev/null +++ b/rust/agama-software/src/model/state.rs @@ -0,0 +1,325 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +#![allow(dead_code)] + +use agama_utils::api::software::{Config, PatternsConfig}; + +use crate::model::products::{ProductSpec, UserPattern}; + +/// Represents the wanted software configuration. +#[derive(Debug)] +pub struct SoftwareState { + pub product: String, + pub repositories: Vec, + // TODO: consider implementing a list to make easier working with them. + pub patterns: Vec, + pub packages: Vec, + pub options: SoftwareOptions, +} + +pub struct SoftwareStateBuilder<'a> { + product: &'a ProductSpec, + config: Option<&'a Config>, +} + +impl<'a> SoftwareStateBuilder<'a> { + pub fn for_product(product: &'a ProductSpec) -> Self { + Self { + product, + config: None, + } + } + + pub fn with_config(mut self, config: &'a Config) -> Self { + self.config = Some(config); + self + } + + pub fn build(self) -> SoftwareState { + let mut state = self.from_product_spec(); + + if let Some(config) = self.config { + self.add_user_config(&mut state, &config); + } + + state + } + + fn add_user_config(&self, state: &mut SoftwareState, config: &Config) { + let Some(software) = &config.software else { + return; + }; + + if let Some(repositories) = &software.extra_repositories { + let extra = repositories.iter().map(|r| + // TODO: implement From + Repository { + name: r.name.as_ref().unwrap_or(&r.alias).clone(), + alias: r.alias.clone(), + url: r.url.clone(), + enabled: r.enabled.unwrap_or(true), + }); + state.repositories.extend(extra); + } + + if let Some(patterns) = &software.patterns { + match patterns { + PatternsConfig::PatternsList(list) => { + state.patterns.retain(|p| p.optional == false); + state + .patterns + .extend(list.iter().map(|n| Resolvable::new(n, false))); + } + PatternsConfig::PatternsMap(map) => { + if let Some(add) = &map.add { + state + .patterns + .extend(add.iter().map(|n| Resolvable::new(n, false))); + } + + if let Some(remove) = &map.remove { + state.patterns.retain(|p| !remove.contains(&p.name)) + } + } + } + } + } + + fn from_product_spec(&self) -> SoftwareState { + let software = &self.product.software; + let repositories = software + .repositories() + .into_iter() + .enumerate() + .map(|(i, r)| { + let alias = format!("agama-{}", i); + Repository { + name: alias.clone(), + alias, + url: r.url.clone(), + enabled: true, + } + }) + .collect(); + + let mut patterns: Vec = software + .mandatory_patterns + .iter() + .map(|p| Resolvable::new(p, false)) + .collect(); + + patterns.extend( + software + .optional_patterns + .iter() + .map(|p| Resolvable::new(p, true)), + ); + + patterns.extend(software.user_patterns.iter().filter_map(|p| match p { + UserPattern::Plain(_) => None, + UserPattern::Preselected(pattern) => { + if pattern.selected { + Some(Resolvable::new(&pattern.name, true)) + } else { + None + } + } + })); + + SoftwareState { + product: software.base_product.clone(), + repositories, + patterns, + packages: vec![], + options: Default::default(), + } + } +} + +impl SoftwareState { + // TODO: Add SoftwareSelection as additional argument. + pub fn build_from(product: &ProductSpec, config: &Config) -> Self { + SoftwareStateBuilder::for_product(product) + .with_config(config) + .build() + } +} + +#[derive(Debug)] +pub struct Repository { + pub alias: String, + pub name: String, + pub url: String, + pub enabled: bool, +} + +#[derive(Debug, PartialEq)] +pub struct Resolvable { + pub name: String, + pub optional: bool, +} + +impl Resolvable { + pub fn new(name: &str, optional: bool) -> Self { + Self { + name: name.to_string(), + optional, + } + } +} + +#[derive(Default, Debug)] +pub struct SoftwareOptions { + only_required: bool, +} + +#[cfg(test)] +mod tests { + use agama_utils::api::software::{ + PatternsConfig, PatternsMap, RepositoryParams, SoftwareConfig, + }; + + use super::*; + + fn build_user_config(patterns: Option) -> Config { + let repo = RepositoryParams { + alias: "user-repo-0".to_string(), + url: "http://example.net/repo".to_string(), + name: None, + product_dir: None, + enabled: Some(true), + priority: None, + allow_unsigned: None, + gpg_fingerprints: None, + }; + + let software = SoftwareConfig { + patterns, + extra_repositories: Some(vec![repo]), + ..Default::default() + }; + + Config { + software: Some(software), + ..Default::default() + } + } + + fn build_product_spec() -> ProductSpec { + let product = std::fs::read_to_string("test/share/products.d/tumbleweed.yaml").unwrap(); + serde_yaml::from_str(&product).unwrap() + } + + #[test] + fn test_build_state() { + let product = build_product_spec(); + let config = Config::default(); + let state = SoftwareState::build_from(&product, &config); + + assert_eq!(state.repositories.len(), 3); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + + assert_eq!(state.product, "openSUSE".to_string()); + + assert_eq!( + state.patterns, + vec![ + Resolvable::new("enhanced_base", false), + Resolvable::new("selinux", true), + ] + ); + } + + #[test] + fn test_add_user_repositories() { + let product = build_product_spec(); + let config = build_user_config(None); + let state = SoftwareState::build_from(&product, &config); + + assert_eq!(state.repositories.len(), 4); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + "user-repo-0".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + } + + #[test] + fn test_add_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: Some(vec!["gnome".to_string()]), + remove: None, + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareState::build_from(&product, &config); + assert_eq!( + state.patterns, + vec![ + Resolvable::new("enhanced_base", false), + Resolvable::new("selinux", true), + Resolvable::new("gnome", false) + ] + ); + } + + #[test] + fn test_remove_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: Some(vec!["selinux".to_string()]), + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareState::build_from(&product, &config); + assert_eq!( + state.patterns, + vec![Resolvable::new("enhanced_base", false),] + ); + } + + #[test] + fn test_replace_patterns_list() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsList(vec!["gnome".to_string()]); + let config = build_user_config(Some(patterns)); + + let state = SoftwareState::build_from(&product, &config); + assert_eq!( + state.patterns, + vec![ + Resolvable::new("enhanced_base", false), + Resolvable::new("gnome", false) + ] + ); + } +} From f484f85b474d230764ace39aef9158033763c6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Oct 2025 14:24:40 +0000 Subject: [PATCH 262/917] Use a approach based on SoftwareStage in SetConfig --- rust/agama-software/src/model.rs | 16 ++++ rust/agama-software/src/service.rs | 42 +++++----- rust/agama-software/src/zypp_server.rs | 107 ++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 22 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index f583aa29f2..5c7565ec0e 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -29,6 +29,7 @@ use crate::{ products::{ProductSpec, UserPattern}, registration::{AddonProperties, RegistrationInfo}, software_selection::SoftwareSelection, + state::SoftwareState, }, service, zypp_server::SoftwareAction, @@ -92,6 +93,12 @@ pub trait ModelAdapter: Send + Sync + 'static { /// Finalizes system like disabling local repositories async fn finish(&self) -> Result<(), service::Error>; + + /// Applies the configuration to the system. + /// + /// It does not perform the installation, just update the repositories and + /// the software selection. + async fn write(&mut self, software: SoftwareState) -> Result<(), service::Error>; } /// [ModelAdapter] implementation for libzypp systems. @@ -115,6 +122,15 @@ impl Model { #[async_trait] impl ModelAdapter for Model { + async fn write(&mut self, software: SoftwareState) -> Result<(), service::Error> { + let (tx, rx) = oneshot::channel(); + self.zypp_sender.send(SoftwareAction::Write { + state: software, + tx, + })?; + Ok(rx.await??) + } + async fn patterns(&self) -> Result, service::Error> { let Some(product) = &self.selected_product else { return Err(service::Error::MissingProduct); diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 4f0754ee43..92e75cba24 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -24,8 +24,10 @@ use crate::{ message, model::{ license::{Error as LicenseError, LicensesRepo}, - packages::ResolvableType, - products::{ProductsRegistry, ProductsRegistryError}, + packages::{Repository, ResolvableType}, + products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, + software_selection::SoftwareSelection, + state::SoftwareState, ModelAdapter, }, proposal::Proposal, @@ -36,10 +38,10 @@ use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ event::{self, Event}, - software::Config, + software::{Config, ProductConfig, RepositoryParams}, Scope, }, - issue::{self}, + issue, }; use async_trait::async_trait; use tokio::sync::{broadcast, Mutex, RwLock}; @@ -183,31 +185,29 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let new_product = message.config.product.as_ref().and_then(|c| c.id.as_ref()); - let need_probe = new_product - != self - .state - .config - .product - .as_ref() - .and_then(|c| c.id.as_ref()); - let new_product_spec = - new_product.and_then(|id| self.products.find(id).and_then(|p| Some(p.clone()))); + let product = message.config.product.as_ref(); + + // handle product + let Some(new_product_id) = &product.and_then(|p| p.id.as_ref()) else { + return Ok(()); + }; + + let Some(new_product) = self.products.find(new_product_id.as_str()) else { + // FIXME: return an error. + return Ok(()); + }; self.state.config = message.config.clone(); self.events.send(Event::ConfigChanged { scope: Scope::Software, })?; + + let software = SoftwareState::build_from(new_product, &message.config); + let model = self.model.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; - // FIXME: convert unwraps to sending issues - if need_probe { - my_model.probe(&new_product_spec.unwrap()).await.unwrap(); - } - Self::apply_config(&message.config, my_model.deref_mut()) - .await - .unwrap(); + my_model.write(software).await.unwrap(); }); Ok(()) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 796208189f..a21ed143ce 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -29,7 +29,7 @@ use zypp_agama::ZyppError; use crate::model::{ packages::{Repository, ResolvableType}, pattern::Pattern, - products::RepositorySpec, + state::{self, SoftwareState}, }; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -102,6 +102,10 @@ pub enum SoftwareAction { r#type: ResolvableType, optional: bool, }, + Write { + state: SoftwareState, + tx: oneshot::Sender>, + }, } /// Software service server. @@ -167,6 +171,9 @@ impl ZyppServer { zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { match action { + SoftwareAction::Write { state, tx } => { + self.write(state, tx, zypp).await?; + } SoftwareAction::AddRepositories(repos, tx) => { self.add_repositories(repos, tx, zypp).await?; } @@ -281,6 +288,104 @@ impl ZyppServer { Ok(result) } + fn read(&self, zypp: &zypp_agama::Zypp) -> Result { + let repositories = zypp + .list_repositories() + .unwrap() + .into_iter() + .map(|repo| state::Repository { + name: repo.user_name, + alias: repo.alias, + url: repo.url, + enabled: repo.enabled, + }) + .collect(); + + let state = SoftwareState { + // FIXME: read the real product. + product: "SLES".to_string(), + repositories, + patterns: vec![], + packages: vec![], + options: Default::default(), + }; + Ok(state) + } + + async fn write( + &self, + state: SoftwareState, + tx: oneshot::Sender>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + // 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?; + + let old_state = self.read(zypp).unwrap(); + let old_aliases: Vec<_> = old_state + .repositories + .iter() + .map(|r| r.alias.clone()) + .collect(); + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + + let to_add: Vec<_> = state + .repositories + .iter() + .filter(|r| !old_aliases.contains(&r.alias)) + .collect(); + + let to_remove: Vec<_> = state + .repositories + .iter() + .filter(|r| !aliases.contains(&r.alias)) + .collect(); + + for repo in &to_add { + _ = zypp.add_repository(&repo.alias, &repo.url, |percent, alias| { + tracing::info!("Adding repository {} ({}%)", alias, percent); + true + }); + // Add an issue if it was not possible to add the repository. + } + + for repo in &to_remove { + _ = zypp.remove_repository(&repo.alias, |percent, alias| { + tracing::info!("Removing repository {} ({}%)", alias, percent); + true + }); + } + + if to_add.is_empty() || to_remove.is_empty() { + _ = zypp.load_source(|percent, alias| { + tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); + true + }); + } + + for pattern in &state.patterns { + // FIXME: we need to distinguish who is selecting the pattern. + // and register an issue if it is not found and it was not optional. + _ = zypp.select_resolvable( + &pattern.name, + zypp_agama::ResolvableKind::Pattern, + zypp_agama::ResolvableSelected::Installation, + ); + } + + match zypp.run_solver() { + Ok(result) => println!("Solver result: {result}"), + Err(error) => println!("Solver failed: {error}"), + }; + + tx.send(Ok(())).unwrap(); + Ok(()) + } + async fn add_repositories( &self, repos: Vec, From 90ef720df015a479d6b22aa92c3228242117cac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 28 Oct 2025 14:25:21 +0000 Subject: [PATCH 263/917] Fix SoftwareSpec::repositories --- rust/agama-software/src/model/products.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index 3b1b381e6c..761ec29295 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -173,7 +173,7 @@ impl SoftwareSpec { let arch = std::env::consts::ARCH.to_string(); self.installation_repositories .iter() - .filter(|r| r.archs.contains(&arch)) + .filter(|r| r.archs.is_empty() || r.archs.contains(&arch)) .collect() } } From 56f1085405ebe2e06dda500f83adc2925dfcffc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:39:43 +0000 Subject: [PATCH 264/917] Use option to track null json --- rust/agama-storage/src/client.rs | 56 ++++++++++++++-------- rust/agama-utils/src/api/storage.rs | 25 ++++++++++ rust/agama-utils/src/api/storage/config.rs | 35 ++++++++++++++ 3 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 rust/agama-utils/src/api/storage.rs create mode 100644 rust/agama-utils/src/api/storage/config.rs diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs index 72ad5ef810..2a28a02e89 100644 --- a/rust/agama-storage/src/client.rs +++ b/rust/agama-storage/src/client.rs @@ -20,7 +20,8 @@ //! Implements a client to access Agama's storage service. -use serde_json::value::RawValue; +use agama_utils::api::storage::Config; +use serde_json::{value::RawValue, Value}; use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; @@ -70,44 +71,41 @@ impl Client { Ok(()) } - pub async fn get_system(&self) -> Result, Error> { + pub async fn get_system(&self) -> Result>, Error> { let message = self.call("GetSystem", &()).await?; self.json_from(message) } - pub async fn get_config(&self) -> Result, Error> { + pub async fn get_config(&self) -> Result, Error> { let message = self.call("GetConfig", &()).await?; - self.json_from(message) + let value: String = message.body().deserialize()?; + let config = serde_json::from_str(value.as_str())?; + Ok(config) } - pub async fn get_config_model(&self) -> Result, Error> { + pub async fn get_config_model(&self) -> Result>, Error> { let message = self.call("GetConfigModel", &()).await?; self.json_from(message) } - pub async fn get_proposal(&self) -> Result, Error> { + pub async fn get_proposal(&self) -> Result>, Error> { let message = self.call("GetProposal", &()).await?; self.json_from(message) } - pub async fn get_issues(&self) -> Result, Error> { + pub async fn get_issues(&self) -> Result>, Error> { let message = self.call("GetIssues", &()).await?; self.json_from(message) } - pub async fn set_config(&self, config: Box) -> Result<(), Error> { - self.call("SetConfig", &(config.to_string())).await?; - Ok(()) - } - //TODO: send a product config instead of an id. - pub async fn set_product(&self, product_id: &str) -> Result<(), Error> { - self.call("SetProduct", &(product_id)).await?; + pub async fn set_product(&self, id: String) -> Result<(), Error> { + self.call("SetProduct", &(id)).await?; Ok(()) } - pub async fn set_locale(&self, locale: &str) -> Result<(), Error> { - self.call("SetLocale", &(locale)).await?; + pub async fn set_config(&self, config: Box) -> Result<(), Error> { + self.call("SetConfig", &(config.to_string())).await?; Ok(()) } @@ -116,11 +114,19 @@ impl Client { Ok(()) } - pub async fn solve_config_model(&self, model: Box) -> Result, Error> { + pub async fn solve_config_model( + &self, + model: Box, + ) -> Result>, Error> { let message = self.call("SolveConfigModel", &(model.to_string())).await?; self.json_from(message) } + pub async fn set_locale(&self, locale: String) -> Result<(), Error> { + self.call("SetLocale", &(locale)).await?; + Ok(()) + } + async fn call( &self, method: &str, @@ -134,8 +140,20 @@ impl Client { .map_err(|e| e.into()) } - fn json_from(&self, message: Message) -> Result, Error> { + fn json_from(&self, message: Message) -> Result>, Error> { let value: String = message.body().deserialize()?; - RawValue::from_string(value).map_err(|e| e.into()) + if self.is_null(value.as_str()) { + return Ok(None); + } + let json = RawValue::from_string(value)?; + Ok(Some(json)) + } + + fn is_null(&self, value: &str) -> bool { + match serde_json::from_str::(value) { + Ok(Value::Null) => true, + Ok(_) => false, + Err(_) => false, + } } } diff --git a/rust/agama-utils/src/api/storage.rs b/rust/agama-utils/src/api/storage.rs new file mode 100644 index 0000000000..a77e14a141 --- /dev/null +++ b/rust/agama-utils/src/api/storage.rs @@ -0,0 +1,25 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. + +mod config; +pub use config::Config; diff --git a/rust/agama-utils/src/api/storage/config.rs b/rust/agama-utils/src/api/storage/config.rs new file mode 100644 index 0000000000..d89ced778c --- /dev/null +++ b/rust/agama-utils/src/api/storage/config.rs @@ -0,0 +1,35 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub storage: Option>, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub legacy_autoyast_storage: Option>, +} From fb445a3235ce1ddd77365f9ab3588c329c6420fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:47:27 +0000 Subject: [PATCH 265/917] Add storage messages --- rust/agama-storage/src/message.rs | 125 ++++++++++++++++++++++++++++-- rust/agama-storage/src/service.rs | 111 ++++++++++++++++++++++++-- 2 files changed, 223 insertions(+), 13 deletions(-) diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index defb37929d..6038fa94c6 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -18,27 +18,138 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::actor::Message; +use agama_utils::{actor::Message, api::storage::Config}; use serde_json::value::RawValue; #[derive(Clone)] -pub struct GetModel; +pub struct Activate; -impl Message for GetModel { - type Reply = Box; +impl Message for Activate { + type Reply = (); +} + +#[derive(Clone)] +pub struct Probe; + +impl Message for Probe { + type Reply = (); +} + +#[derive(Clone)] +pub struct Install; + +impl Message for Install { + type Reply = (); +} + +#[derive(Clone)] +pub struct Finish; + +impl Message for Finish { + type Reply = (); +} + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = Option>; +} + +#[derive(Clone)] +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Option; +} + +#[derive(Clone)] +pub struct GetConfigModel; + +impl Message for GetConfigModel { + type Reply = Option>; +} + +#[derive(Clone)] +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option>; +} + +#[derive(Clone)] +pub struct SetProduct { + pub id: String, +} + +impl SetProduct { + pub fn new(id: &str) -> Self { + Self { id: id.to_string() } + } +} + +impl Message for SetProduct { + type Reply = (); +} + +#[derive(Clone)] +pub struct SetConfig { + pub config: Box, +} + +impl SetConfig { + pub fn new(config: Box) -> Self { + Self { config } + } +} + +impl Message for SetConfig { + type Reply = (); } #[derive(Clone)] -pub struct SetModel { +pub struct SetConfigModel { pub model: Box, } -impl SetModel { +impl SetConfigModel { pub fn new(model: Box) -> Self { Self { model } } } -impl Message for SetModel { +impl Message for SetConfigModel { + type Reply = (); +} + +#[derive(Clone)] +pub struct SolveConfigModel { + pub model: Box, +} + +impl SolveConfigModel { + pub fn new(model: Box) -> Self { + Self { model } + } +} + +impl Message for SolveConfigModel { + type Reply = Option>; +} + +#[derive(Clone)] +pub struct SetLocale { + pub locale: String, +} + +impl SetLocale { + pub fn new(locale: &str) -> Self { + Self { + locale: locale.to_string(), + } + } +} + +impl Message for SetLocale { type Reply = (); } diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index 3bd8a1419c..9eedf72edc 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -20,7 +20,10 @@ use crate::client::{self, Client}; use crate::message; -use agama_utils::actor::{self, Actor, MessageHandler}; +use agama_utils::{ + actor::{self, Actor, MessageHandler}, + api::storage::Config, +}; use async_trait::async_trait; use serde_json::value::RawValue; @@ -50,18 +53,114 @@ impl Actor for Service { } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetModel) -> Result, Error> { +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Activate) -> Result<(), Error> { + self.client.activate().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { + self.client.probe().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + self.client.install().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + self.client.finish().await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetSystem, + ) -> Result>, Error> { + self.client.get_system().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result, Error> { + self.client.get_config().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetConfigModel, + ) -> Result>, Error> { self.client.get_config_model().await.map_err(|e| e.into()) } } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, message: message::SetModel) -> Result<(), Error> { +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetProposal, + ) -> Result>, Error> { + self.client.get_proposal().await.map_err(|e| e.into()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetProduct) -> Result<(), Error> { + self.client.set_product(message.id).await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + self.client.set_config(message.config).await?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetConfigModel) -> Result<(), Error> { + self.client.set_config_model(message.model).await?; + Ok(()) + } +} +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + message: message::SolveConfigModel, + ) -> Result>, Error> { self.client - .set_config_model(message.model) + .solve_config_model(message.model) .await .map_err(|e| e.into()) } } + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetLocale) -> Result<(), Error> { + self.client.set_locale(message.locale).await?; + Ok(()) + } +} From b32d2384033a2b658abdb82364f927f8899d9690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:50:59 +0000 Subject: [PATCH 266/917] Adapt manager --- rust/agama-manager/src/message.rs | 2 +- rust/agama-manager/src/service.rs | 9 ++++++--- rust/agama-server/src/server/web.rs | 4 +++- rust/agama-utils/src/api.rs | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index cf08ce2eb7..e008904845 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -124,7 +124,7 @@ impl Message for RunAction { pub struct GetStorageModel; impl Message for GetStorageModel { - type Reply = Box; + type Reply = Option>; } // Sets the storage model. diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 64bcfccefd..e4988c7ae9 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -227,8 +227,11 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// It returns the storage model. - async fn handle(&mut self, _message: message::GetStorageModel) -> Result, Error> { - Ok(self.storage.call(storage::message::GetModel).await?) + async fn handle( + &mut self, + _message: message::GetStorageModel, + ) -> Result>, Error> { + Ok(self.storage.call(storage::message::GetConfigModel).await?) } } @@ -238,7 +241,7 @@ impl MessageHandler for Service { async fn handle(&mut self, message: message::SetStorageModel) -> Result<(), Error> { Ok(self .storage - .call(storage::message::SetModel::new(message.model)) + .call(storage::message::SetConfigModel::new(message.model)) .await?) } } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index f7f1a20f40..6017475292 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -346,7 +346,9 @@ async fn run_action( (status = 400, description = "Not possible to retrieve the storage model.") ) )] -async fn get_storage_model(State(state): State) -> ServerResult>> { +async fn get_storage_model( + State(state): State, +) -> ServerResult>>> { let model = state.manager.call(message::GetStorageModel).await?; Ok(Json(model)) } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 0664716790..02dc8a8ef6 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -49,5 +49,5 @@ mod action; pub use action::Action; pub mod l10n; - pub mod question; +pub mod storage; From 54b50990c82aa2e29b64df6a8947502366c2edf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:51:59 +0000 Subject: [PATCH 267/917] Recover storage system info --- rust/agama-manager/src/service.rs | 3 ++- rust/agama-utils/src/api/system_info.rs | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index e4988c7ae9..6d7823472c 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -124,7 +124,8 @@ impl MessageHandler for Service { /// It returns the information of the underlying system. async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; - Ok(SystemInfo { l10n }) + let storage = self.storage.call(storage::message::GetSystem).await?; + Ok(SystemInfo { l10n, storage }) } } diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index e609126aa2..b23fb0c091 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -20,8 +20,14 @@ use crate::api::l10n; use serde::Serialize; +use serde_json::value::RawValue; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct SystemInfo { pub l10n: l10n::SystemInfo, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub storage: Option>, } From ba5c24eea10f336e4c0faeb57e96ba89e4dc2e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:53:17 +0000 Subject: [PATCH 268/917] Recover storage extended config --- rust/agama-manager/src/service.rs | 2 ++ rust/agama-utils/src/api/config.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 6d7823472c..727f44aca7 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -137,9 +137,11 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; + let storage = self.storage.call(storage::message::GetConfig).await?; Ok(Config { l10n: Some(l10n), questions: Some(questions), + storage, }) } } diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 7b244c8fc6..4faa294568 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,20 +18,24 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question}; +use crate::api::{l10n, question, storage}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] pub l10n: Option, #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, + #[serde(flatten)] + pub storage: Option, } /// Patch for the config. #[derive(Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Patch { /// Update for the current config. pub update: Option, From 893934761189be38f4558c3e8762f0cb06805a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 17:53:40 +0000 Subject: [PATCH 269/917] Recover storage proposal --- rust/agama-manager/src/service.rs | 3 ++- rust/agama-utils/src/api/l10n/config.rs | 1 - rust/agama-utils/src/api/proposal.rs | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 727f44aca7..9951dd4c10 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -198,7 +198,8 @@ impl MessageHandler for Service { /// It returns the current proposal, if any. async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { let l10n = self.l10n.call(l10n::message::GetProposal).await?; - Ok(Some(Proposal { l10n })) + let storage = self.storage.call(storage::message::GetProposal).await?; + Ok(Some(Proposal { l10n, storage })) } } diff --git a/rust/agama-utils/src/api/l10n/config.rs b/rust/agama-utils/src/api/l10n/config.rs index 9a06c5c185..19a149eb1a 100644 --- a/rust/agama-utils/src/api/l10n/config.rs +++ b/rust/agama-utils/src/api/l10n/config.rs @@ -22,7 +22,6 @@ use serde::{Deserialize, Serialize}; /// Localization config. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[schema(as = l10n::UserConfig)] #[serde(rename_all = "camelCase")] pub struct Config { /// Locale (e.g., "en_US.UTF-8"). diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index d66e151167..ccdb85be0f 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -20,9 +20,16 @@ use crate::api::l10n; use serde::Serialize; +use serde_json::value::RawValue; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] pub struct Proposal { + #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub storage: Option>, } From 854886f85164eb64cf93af3083f437860f9cf4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 28 Oct 2025 18:14:13 +0000 Subject: [PATCH 270/917] Configure storage locale --- rust/agama-manager/src/service.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 9951dd4c10..5aa3ff8ab4 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -22,7 +22,8 @@ use crate::{l10n, message, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ - event, status::State, Action, Config, Event, IssueMap, Proposal, Scope, Status, SystemInfo, + self, event, status::State, Action, Config, Event, IssueMap, Proposal, Scope, Status, + SystemInfo, }, issue, progress, question, }; @@ -101,6 +102,17 @@ impl Service { self.events.send(Event::StateChanged)?; Ok(()) } + + async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { + self.l10n + .call(l10n::message::SetSystem::new(config.clone())) + .await?; + if let Some(locale) = config.locale { + self.storage + .cast(storage::message::SetLocale::new(locale.as_str()))?; + } + Ok(()) + } } impl Actor for Service { @@ -217,8 +229,7 @@ impl MessageHandler for Service { async fn handle(&mut self, message: message::RunAction) -> Result<(), Error> { match message.action { Action::ConfigureL10n(config) => { - let l10n_message = l10n::message::SetSystem::new(config); - self.l10n.call(l10n_message).await?; + self.configure_l10n(config).await?; } Action::Install => { self.install().await?; From 31da4584f94cdb27293afc7719cc6de619c15342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Oct 2025 07:29:44 +0000 Subject: [PATCH 271/917] Report issues when writing the software configuration --- rust/agama-software/src/model.rs | 6 ++-- rust/agama-software/src/service.rs | 6 +++- rust/agama-software/src/zypp_server.rs | 49 +++++++++++++++++++++----- rust/agama-utils/src/api/issue.rs | 25 +++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 5c7565ec0e..eb5f5f0545 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::software::RepositoryParams; +use agama_utils::api::{software::RepositoryParams, Issue}; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; @@ -98,7 +98,7 @@ pub trait ModelAdapter: Send + Sync + 'static { /// /// It does not perform the installation, just update the repositories and /// the software selection. - async fn write(&mut self, software: SoftwareState) -> Result<(), service::Error>; + async fn write(&mut self, software: SoftwareState) -> Result, service::Error>; } /// [ModelAdapter] implementation for libzypp systems. @@ -122,7 +122,7 @@ impl Model { #[async_trait] impl ModelAdapter for Model { - async fn write(&mut self, software: SoftwareState) -> Result<(), service::Error> { + async fn write(&mut self, software: SoftwareState) -> Result, service::Error> { let (tx, rx) = oneshot::channel(); self.zypp_sender.send(SoftwareAction::Write { state: software, diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 92e75cba24..757d7e9209 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -205,9 +205,13 @@ impl MessageHandler> for Service { let software = SoftwareState::build_from(new_product, &message.config); let model = self.model.clone(); + let issues = self.issues.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; - my_model.write(software).await.unwrap(); + let found_issues = my_model.write(software).await.unwrap(); + if !found_issues.is_empty() { + issues.cast(issue::message::Update::new(Scope::Software, found_issues)); + } }); Ok(()) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index a21ed143ce..b097c66cce 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::software::RepositoryParams; +use agama_utils::api::{issue, software::RepositoryParams, Issue, IssueSeverity, IssueSource}; use std::path::Path; use tokio::sync::{ mpsc::{self, UnboundedSender}, @@ -104,7 +104,7 @@ pub enum SoftwareAction { }, Write { state: SoftwareState, - tx: oneshot::Sender>, + tx: oneshot::Sender>>, }, } @@ -315,9 +315,10 @@ impl ZyppServer { async fn write( &self, state: SoftwareState, - tx: oneshot::Sender>, + tx: oneshot::Sender>>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { + let mut issues: Vec = vec![]; // FIXME: // 1. add and remove the repositories. // 2. select the patterns. @@ -346,35 +347,67 @@ impl ZyppServer { .collect(); for repo in &to_add { - _ = zypp.add_repository(&repo.alias, &repo.url, |percent, alias| { + let result = zypp.add_repository(&repo.alias, &repo.url, |percent, alias| { tracing::info!("Adding repository {} ({}%)", alias, percent); true }); + + if let Err(error) = result { + let message = format!("Could not add the repository {}", repo.alias); + issues.push( + Issue::new("software.add_repo", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } // Add an issue if it was not possible to add the repository. } for repo in &to_remove { - _ = zypp.remove_repository(&repo.alias, |percent, alias| { + let result = zypp.remove_repository(&repo.alias, |percent, alias| { tracing::info!("Removing repository {} ({}%)", alias, percent); true }); + + if let Err(error) = result { + let message = format!("Could not remove the repository {}", repo.alias); + issues.push( + Issue::new("software.remove_repo", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } } if to_add.is_empty() || to_remove.is_empty() { - _ = zypp.load_source(|percent, alias| { + let result = zypp.load_source(|percent, alias| { tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); true }); + + if let Err(error) = result { + let message = format!("Could not read the repositories"); + issues.push( + Issue::new("software.load_source", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } } for pattern in &state.patterns { // FIXME: we need to distinguish who is selecting the pattern. // and register an issue if it is not found and it was not optional. - _ = zypp.select_resolvable( + let result = zypp.select_resolvable( &pattern.name, zypp_agama::ResolvableKind::Pattern, zypp_agama::ResolvableSelected::Installation, ); + + if let Err(error) = result { + let message = format!("Could not select pattern '{}'", &pattern.name); + issues.push( + Issue::new("software.select_pattern", &message, IssueSeverity::Error) + .with_details(&error.to_string()), + ); + } } match zypp.run_solver() { @@ -382,7 +415,7 @@ impl ZyppServer { Err(error) => println!("Solver failed: {error}"), }; - tx.send(Ok(())).unwrap(); + tx.send(Ok(issues)).unwrap(); Ok(()) } diff --git a/rust/agama-utils/src/api/issue.rs b/rust/agama-utils/src/api/issue.rs index 0a3035d9ef..c8bb3a9e63 100644 --- a/rust/agama-utils/src/api/issue.rs +++ b/rust/agama-utils/src/api/issue.rs @@ -47,6 +47,31 @@ pub struct Issue { pub kind: String, } +impl Issue { + /// Creates a new issue. + pub fn new(kind: &str, description: &str, severity: IssueSeverity) -> Self { + Self { + description: description.to_string(), + kind: kind.to_string(), + source: IssueSource::Config, + severity, + details: None, + } + } + + /// Sets the details for the issue. + pub fn with_details(mut self, details: &str) -> Self { + self.details = Some(details.to_string()); + self + } + + /// Sets the source for the issue. + pub fn with_source(mut self, source: IssueSource) -> Self { + self.source = source; + self + } +} + #[derive( Clone, Copy, Debug, Deserialize, Serialize, FromRepr, PartialEq, Eq, Hash, utoipa::ToSchema, )] From 0ea2c9d24bfd1723ec5d69fca4a5ab30075a3a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Oct 2025 07:39:10 +0000 Subject: [PATCH 272/917] Improve SoftwareState documentation --- rust/agama-software/src/model/state.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 6e1e523c52..3982cebedd 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -18,13 +18,18 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -#![allow(dead_code)] +//! This module implements a mechanism to build the wanted software +//! configuration and a mechanism to build it starting from the product +//! definition, the user configuration, etc. use agama_utils::api::software::{Config, PatternsConfig}; use crate::model::products::{ProductSpec, UserPattern}; /// Represents the wanted software configuration. +/// +/// It includes the list of repositories, selected resolvables, configuration +/// options, etc. This configuration is later applied by a model adapter. #[derive(Debug)] pub struct SoftwareState { pub product: String, @@ -35,12 +40,15 @@ pub struct SoftwareState { pub options: SoftwareOptions, } +/// Builder to create a [SoftwareState] struct from the other sources like the +/// product specification, the user configuration, etc. pub struct SoftwareStateBuilder<'a> { product: &'a ProductSpec, config: Option<&'a Config>, } impl<'a> SoftwareStateBuilder<'a> { + /// Creates a builder for the given product specification. pub fn for_product(product: &'a ProductSpec) -> Self { Self { product, @@ -48,11 +56,14 @@ impl<'a> SoftwareStateBuilder<'a> { } } + /// Adds the user configuration to use. pub fn with_config(mut self, config: &'a Config) -> Self { self.config = Some(config); self } + /// Builds the [SoftwareState] by merging the product specification and the + /// user configuration. pub fn build(self) -> SoftwareState { let mut state = self.from_product_spec(); @@ -163,6 +174,7 @@ impl SoftwareState { } } +/// Defines a repository. #[derive(Debug)] pub struct Repository { pub alias: String, @@ -171,9 +183,12 @@ pub struct Repository { pub enabled: bool, } +/// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] pub struct Resolvable { + /// Resolvable name. pub name: String, + /// Whether this resolvable is optional or not. pub optional: bool, } @@ -186,8 +201,10 @@ impl Resolvable { } } +/// Software system options. #[derive(Default, Debug)] pub struct SoftwareOptions { + /// Install only required packages (not recommended ones). only_required: bool, } From 442b606025e436e546dd8f982dc48b30e5880173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Oct 2025 07:39:49 +0000 Subject: [PATCH 273/917] Implement From for Repository --- rust/agama-software/src/model/state.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 3982cebedd..54ee8e400c 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -22,7 +22,7 @@ //! configuration and a mechanism to build it starting from the product //! definition, the user configuration, etc. -use agama_utils::api::software::{Config, PatternsConfig}; +use agama_utils::api::software::{Config, PatternsConfig, RepositoryParams}; use crate::model::products::{ProductSpec, UserPattern}; @@ -80,14 +80,7 @@ impl<'a> SoftwareStateBuilder<'a> { }; if let Some(repositories) = &software.extra_repositories { - let extra = repositories.iter().map(|r| - // TODO: implement From - Repository { - name: r.name.as_ref().unwrap_or(&r.alias).clone(), - alias: r.alias.clone(), - url: r.url.clone(), - enabled: r.enabled.unwrap_or(true), - }); + let extra = repositories.iter().map(Repository::from); state.repositories.extend(extra); } @@ -183,6 +176,17 @@ pub struct Repository { pub enabled: bool, } +impl From<&RepositoryParams> for Repository { + fn from(value: &RepositoryParams) -> Self { + Repository { + name: value.name.as_ref().unwrap_or(&value.alias).clone(), + alias: value.alias.clone(), + url: value.url.clone(), + enabled: value.enabled.unwrap_or(true), + } + } +} + /// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] pub struct Resolvable { From fa8ea49f51d9c9ffdfbe23dd483c7b5149052073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Oct 2025 07:40:37 +0000 Subject: [PATCH 274/917] Prevent unselecting mandatory patterns --- rust/agama-software/src/model/state.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 54ee8e400c..21d52956e2 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -100,7 +100,11 @@ impl<'a> SoftwareStateBuilder<'a> { } if let Some(remove) = &map.remove { - state.patterns.retain(|p| !remove.contains(&p.name)) + // NOTE: should we notify when a user wants to remove a + // pattern which is not optional? + state + .patterns + .retain(|p| !(p.optional && remove.contains(&p.name))); } } } @@ -328,6 +332,25 @@ mod tests { ); } + #[test] + fn test_remove_mandatory_patterns() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: Some(vec!["enhanced_base".to_string()]), + }); + let config = build_user_config(Some(patterns)); + + let state = SoftwareState::build_from(&product, &config); + assert_eq!( + state.patterns, + vec![ + Resolvable::new("enhanced_base", false), + Resolvable::new("selinux", true) + ] + ); + } + #[test] fn test_replace_patterns_list() { let product = build_product_spec(); From 6d56948c04f05d092268f847952aa762dde2b16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 29 Oct 2025 07:40:56 +0000 Subject: [PATCH 275/917] Consider only_required in SoftwareState * The setting, however, is not being applied. --- rust/agama-software/src/model/state.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 21d52956e2..a6c10c333a 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -109,6 +109,10 @@ impl<'a> SoftwareStateBuilder<'a> { } } } + + if let Some(only_required) = software.only_required { + state.options.only_required = only_required; + } } fn from_product_spec(&self) -> SoftwareState { From 58515fddfaf073f12c34599f503c2d6310baf7e1 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 13:44:08 +0100 Subject: [PATCH 276/917] service: Report both storage issues and storage probing issues --- service/lib/agama/dbus/storage/manager.rb | 35 ++++++-- service/lib/agama/storage/manager.rb | 20 ++--- .../test/agama/dbus/storage/manager_test.rb | 90 +++++++++++++++++++ 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index a0c684b782..629e21f0e6 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -79,7 +79,7 @@ def initialize(backend, logger: nil) dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") { recover_proposal } - dbus_method(:GetIssues, "out issues:s") {} + dbus_method(:GetIssues, "out issues:s") { recover_issues } dbus_signal(:SystemChanged) dbus_signal(:ProposalChanged) dbus_signal(:IssuesChanged) @@ -172,6 +172,7 @@ def recover_system availableMdRaids: available_md_raids, candidateDrives: candidate_drives, candidateMdRaids: candidate_md_raids, + issues: system_issues, productMountPoints: product_mount_points, encryptionMethods: encryption_methods, volumeTemplates: volume_templates @@ -252,11 +253,12 @@ def recover_proposal JSON.pretty_generate(json) end - # List of issues, see {DBus::Interfaces::Issues} + # Gets and serializes the list of issues. # - # @return [Array] - def issues - backend.issues + # @return [String] + def recover_issues + json = backend.issues.map { |i| json_issue(i) } + JSON.pretty_generate(json) end dbus_interface "org.opensuse.Agama.Storage1.Bootloader" do @@ -408,6 +410,20 @@ def json_devices(meth) Agama::Storage::DevicegraphConversions::ToJSON.new(devicegraph).convert end + # JSON representation of the given Agama issue + # + # @param issue [Array] + # @return [Hash] + def json_issue(issue) + { + description: issue.description, + class: issue.kind&.to_s, + details: issue.details&.to_s, + source: issue.source&.to_s, + severity: issue.severity&.to_s + }.compact + end + # List of sorted actions. # # @return [Hash] @@ -452,6 +468,15 @@ def candidate_md_raids proposal.storage_system.candidate_md_raids.map(&:sid) end + # Problems found during system probing + # + # @see #recover_system + # + # @return [Hash] + def system_issues + backend.system_issues.map { |i| json_issue(i) } + end + # Meaningful mount points for the current product. # # @return [Array] diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index ee3362e25f..7a76805acf 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -169,6 +169,13 @@ def security @security ||= Security.new(logger, product_config) end + # Issues from the system + # + # @return [Array] + def system_issues + probing_issues + [candidate_devices_issue].compact + end + private PROPOSAL_ID = "storage_proposal" @@ -193,18 +200,7 @@ def devicegraph # Recalculates the list of issues def update_issues - self.issues = system_issues + proposal.issues - end - - # Issues from the system - # - # @return [Array] - def system_issues - issues = probing_issues + [ - candidate_devices_issue - ] - - issues.compact + self.issues = proposal.issues end # Issues from the probing phase diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 6d49a0887a..b62d96e17d 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -205,6 +205,8 @@ def parse(string) allow(proposal.storage_system).to receive(:candidate_drives).and_return(candidate_drives) allow(proposal.storage_system).to receive(:available_md_raids).and_return(available_raids) allow(proposal.storage_system).to receive(:candidate_md_raids).and_return(candidate_raids) + allow(proposal.storage_system).to receive(:candidate_devices) + .and_return(candidate_drives + candidate_raids) end let(:available_drives) { [] } @@ -302,6 +304,30 @@ def parse(string) end end + describe "recover_system[:issues]" do + context "if there is no candidate drives" do + let(:candidate_drives) { [] } + + it "contains a issue about the absence of disks" do + result = parse(subject.recover_system)[:issues] + expect(result).to contain_exactly( + a_hash_including(description: /no suitable device for installation/i) + ) + end + end + + context "if there are candidate drives" do + let(:candidate_drives) { [drive] } + + let(:drive) { instance_double(Y2Storage::Disk, name: "/dev/vda", sid: 95) } + + it "retuns an empty array" do + result = parse(subject.recover_system)[:issues] + expect(result).to eq [] + end + end + end + describe "recover_system[:productMountPoints]" do let(:config_data) do { "storage" => { "volumes" => [], "volume_templates" => cfg_templates } } @@ -713,6 +739,70 @@ def parse(string) end end + describe "#recover_issues" do + context "if no proposal has been calculated" do + it "returns an empty array" do + expect(subject.recover_issues).to eq "[]" + end + end + + context "if an agama proposal has been succesfully calculated" do + before do + backend.configure(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { size: "10 GiB", filesystem: { path: "/" } } + ] + } + ] + } + } + end + + it "returns an empty array" do + expect(subject.recover_issues).to eq "[]" + end + end + + context "if an agama proposal failed to be calculated" do + before do + backend.configure(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { size: "60 TiB", filesystem: { path: "/home" } } + ] + } + ] + } + } + end + + it "returns an empty array" do + result = parse(subject.recover_issues) + expect(result).to include( + a_hash_including( + description: /cannot calculate a valid storage setup/i, severity: "error" + ), + a_hash_including( + description: /boot device cannot be automatically/i, severity: "error" + ) + ) + end + end + end + describe "#iscsi_discover" do it "performs an iSCSI discovery" do expect(iscsi).to receive(:discover).with("192.168.100.90", 3260, anything) From b076228764bba0bc4fafdd3713c51c2b13d329d6 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 15:16:50 +0100 Subject: [PATCH 277/917] Remove reference to deleted file --- service/lib/agama/dbus/storage.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/service/lib/agama/dbus/storage.rb b/service/lib/agama/dbus/storage.rb index 8f743fe45c..fcd33d4f6a 100644 --- a/service/lib/agama/dbus/storage.rb +++ b/service/lib/agama/dbus/storage.rb @@ -29,4 +29,3 @@ module Storage require "agama/dbus/storage/iscsi" require "agama/dbus/storage/manager" -require "agama/dbus/storage/proposal" From 4dd55280468fc90f7b7fb305e9e94823ce42805c Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 28 Oct 2025 13:25:00 +0100 Subject: [PATCH 278/917] Fix ambiguous mixin name --- service/lib/agama/dbus/storage/manager.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 629e21f0e6..cf4137fc36 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -42,7 +42,7 @@ module Storage class Manager < BaseObject # rubocop:disable Metrics/ClassLength extend Yast::I18n include Yast::I18n - include WithProgress + include Agama::WithProgress include ::DBus::ObjectManager include DBus::Interfaces::Issues From 6715b0ea79154e36cd69dcab4d1751601f34ac7f Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 14:52:00 +0100 Subject: [PATCH 279/917] Improve yardoc --- service/lib/agama/dbus/manager.rb | 1 - service/lib/agama/progress.rb | 1 + service/lib/agama/with_progress.rb | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/dbus/manager.rb b/service/lib/agama/dbus/manager.rb index 444f8a0afb..b319e04f21 100644 --- a/service/lib/agama/dbus/manager.rb +++ b/service/lib/agama/dbus/manager.rb @@ -78,7 +78,6 @@ def initialize(backend, logger) # Runs the config phase # # @param reprobe [Boolean] Whether a reprobe should be done instead of a probe. - # @param data [Hash] Extra data provided to the D-Bus calls. def config_phase(reprobe: false) safe_run do busy_while { backend.config_phase(reprobe: reprobe) } diff --git a/service/lib/agama/progress.rb b/service/lib/agama/progress.rb index 3474168f64..7877b2a38f 100644 --- a/service/lib/agama/progress.rb +++ b/service/lib/agama/progress.rb @@ -22,6 +22,7 @@ require "json" module Agama + # Class to keep track of a process divided in a set of steps. class Progress class MissingStep < StandardError; end diff --git a/service/lib/agama/with_progress.rb b/service/lib/agama/with_progress.rb index 4863d0b9af..dd130f9913 100644 --- a/service/lib/agama/with_progress.rb +++ b/service/lib/agama/with_progress.rb @@ -22,6 +22,7 @@ require "agama/progress" module Agama + # Mixin to use Agama::Progress to track the status of an object. module WithProgress attr_reader :progress From 1550f3a3a11d8b851046ae4099768eebf6e36c53 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 15:00:32 +0100 Subject: [PATCH 280/917] Please rubocop --- service/lib/agama/dbus/storage/manager.rb | 2 +- service/lib/agama/progress.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index cf4137fc36..a722674cbb 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -76,7 +76,7 @@ def initialize(backend, logger: nil) dbus_method(:GetConfig, "out config:s") { recover_config } dbus_method(:SetConfig, "in config:s") { |c| configure(c) } dbus_method(:GetConfigModel, "out model:s") { recover_config_model } - dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m)} + dbus_method(:SetConfigModel, "in model:s") { |m| configure_with_model(m) } dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") { recover_issues } diff --git a/service/lib/agama/progress.rb b/service/lib/agama/progress.rb index 7877b2a38f..0b67f1177b 100644 --- a/service/lib/agama/progress.rb +++ b/service/lib/agama/progress.rb @@ -53,7 +53,7 @@ def self.new_with_steps(steps) def initialize(size, step) @size = size @steps = [] - @step= step + @step = step @index = 1 end From 4662ac6ead5151bf003c0fb5bf57a5e5954aaa5e Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 15:17:18 +0100 Subject: [PATCH 281/917] Remove unit tests for deprecated_system --- .../test/agama/dbus/storage/manager_test.rb | 22 ------ service/test/agama/storage/manager_test.rb | 67 +------------------ 2 files changed, 1 insertion(+), 88 deletions(-) diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index b62d96e17d..b8a4948a51 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -82,28 +82,6 @@ def parse(string) mock_storage(devicegraph: "empty-hd-50GiB.yaml") end - describe "#deprecated_system" do - before do - allow(backend).to receive(:deprecated_system?).and_return(deprecated) - end - - context "if the system is set as deprecated" do - let(:deprecated) { true } - - it "returns true" do - expect(subject.deprecated_system).to eq(true) - end - end - - context "if the system is not set as deprecated" do - let(:deprecated) { false } - - it "returns false" do - expect(subject.deprecated_system).to eq(false) - end - end - end - describe "#recover_proposal" do describe "recover_proposal[:actions]" do before do diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 1bc089e81e..5e4dcde7bb 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -33,6 +33,7 @@ require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/volume" +require "agama/dbus" require "y2storage/issue" require "y2storage/luks" require "yast2/fs_snapshot" @@ -87,72 +88,6 @@ let(:scenario) { "empty-hd-50GiB.yaml" } - describe "#deprecated_system=" do - let(:callback) { proc {} } - - context "if the current value is changed" do - before do - storage.deprecated_system = true - end - - it "executes the on_deprecated_system_change callbacks" do - storage.on_deprecated_system_change(&callback) - - expect(callback).to receive(:call) - - storage.deprecated_system = false - end - end - - context "if the current value is not changed" do - before do - storage.deprecated_system = true - end - - it "does not execute the on_deprecated_system_change callbacks" do - storage.on_deprecated_system_change(&callback) - - expect(callback).to_not receive(:call) - - storage.deprecated_system = true - end - end - - context "when the system is set as deprecated" do - it "marks the system as deprecated" do - storage.deprecated_system = true - - expect(storage.deprecated_system?).to eq(true) - end - - it "adds a deprecated system issue" do - expect(storage.issues).to be_empty - - storage.deprecated_system = true - - expect(storage.issues).to include( - an_object_having_attributes(description: /system devices have changed/) - ) - end - end - - context "when the system is set as not deprecated" do - it "marks the system as not deprecated" do - storage.deprecated_system = false - - expect(storage.deprecated_system?).to eq(false) - end - - it "does not add a deprecated system issue" do - storage.deprecated_system = false - - expect(storage.issues).to_not include( - an_object_having_attributes(description: /system devices have changed/) - ) - end - end - end - describe "#probe" do before do allow(Agama::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) From 075ffeefa947b582f97d1d0457cdfc41bdb5958c Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 27 Oct 2025 16:33:57 +0100 Subject: [PATCH 282/917] Fix tests for DBus::Storage::Manager --- .../test/agama/dbus/storage/manager_test.rb | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index b8a4948a51..238d06d840 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -418,7 +418,13 @@ def parse(string) end end - describe "#apply_config" do + describe "#configure" do + before do + allow(subject).to receive(:ProposalChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + end + let(:serialized_config) { config_json.to_json } context "if the serialized config contains storage settings" do @@ -454,7 +460,17 @@ def parse(string) expect(partition.filesystem.path).to eq("/") end - subject.apply_config(serialized_config) + subject.configure(serialized_config) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_agama) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure(serialized_config) end end @@ -472,12 +488,28 @@ def parse(string) expect(settings).to eq(config_json[:legacyAutoyastStorage]) end - subject.apply_config(serialized_config) + subject.configure(serialized_config) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_autoyast) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure(serialized_config) end end end - describe "#apply_config_model" do + describe "#configure_with_model" do + before do + allow(subject).to receive(:ProposalChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + end + let(:serialized_model) { model_json.to_json } let(:model_json) do @@ -504,7 +536,17 @@ def parse(string) expect(partition.filesystem.path).to eq("/") end - subject.apply_config_model(serialized_model) + subject.configure_with_model(serialized_model) + end + + it "emits signals for ProposalChanged, ProgressChanged and ProgressFinished" do + allow(proposal).to receive(:calculate_agama) + + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.configure_with_model(serialized_model) end end @@ -560,10 +602,10 @@ def parse(string) end end - describe "#recover_model" do + describe "#recover_config_model" do context "if a proposal has not been calculated" do it "returns 'null'" do - expect(subject.recover_model).to eq("null") + expect(subject.recover_config_model).to eq("null") end end @@ -590,7 +632,7 @@ def parse(string) end it "returns the serialized config model" do - expect(subject.recover_model).to eq( + expect(subject.recover_config_model).to eq( serialize({ boot: { configure: true, @@ -644,12 +686,12 @@ def parse(string) end it "returns 'null'" do - expect(subject.recover_model).to eq("null") + expect(subject.recover_config_model).to eq("null") end end end - describe "#solve_model" do + describe "#solve_config_model" do let(:model) do { drives: [ @@ -664,7 +706,7 @@ def parse(string) end it "returns the serialized solved model" do - result = subject.solve_model(model.to_json) + result = subject.solve_config_model(model.to_json) expect(result).to eq( serialize({ @@ -711,7 +753,7 @@ def parse(string) end it "returns 'null'" do - result = subject.solve_model(model.to_json) + result = subject.solve_config_model(model.to_json) expect(result).to eq("null") end end From 628bfc3011115fc6ee4942f70a0efa021581edb6 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 28 Oct 2025 12:38:22 +0100 Subject: [PATCH 283/917] Fix tests for Agama::Storage::Manager --- .../test/agama/dbus/storage_service_test.rb | 81 +++++++ service/test/agama/storage/manager_test.rb | 217 ++++++------------ 2 files changed, 146 insertions(+), 152 deletions(-) create mode 100644 service/test/agama/dbus/storage_service_test.rb diff --git a/service/test/agama/dbus/storage_service_test.rb b/service/test/agama/dbus/storage_service_test.rb new file mode 100644 index 0000000000..f27c44a628 --- /dev/null +++ b/service/test/agama/dbus/storage_service_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "agama/dbus/storage_service" + +describe Agama::DBus::StorageService do + subject(:service) { described_class.new(config, logger) } + + let(:config) { Agama::Config.new } + let(:logger) { Logger.new($stdout, level: :warn) } + let(:manager) { Agama::Storage::Manager.new(config, logger: logger) } + let(:inhibitors) { instance_double(Y2Storage::Inhibitors, inhibit: nil, uninhibit: nil) } + + let(:object_server) { instance_double(DBus::ObjectServer, export: nil) } + let(:bus) { instance_double(Agama::DBus::Bus, request_name: nil) } + + let(:manager_obj) do + instance_double(Agama::DBus::Storage::Manager, path: "/org/opensuse/Agama/Storage1") + end + + before do + allow(Agama::DBus::Bus).to receive(:current).and_return(bus) + allow(bus).to receive(:request_service).with("org.opensuse.Agama.Storage1") + .and_return(object_server) + allow(Y2Storage::Inhibitors).to receive(:new).and_return inhibitors + allow(Agama::Storage::Manager).to receive(:new).with(config, logger: logger) + .and_return(manager) + allow(Agama::DBus::Storage::Manager).to receive(:new).with(manager, logger: logger) + .and_return(manager_obj) + end + + describe "#start" do + before { allow(ENV).to receive(:[]=) } + + it "sets env YAST_NO_BLS_BOOT to yes if product doesn't requires bls boot explicitly" do + expect(config).to receive(:boot_strategy).and_return(nil) + expect(ENV).to receive(:[]=).with("YAST_NO_BLS_BOOT", "1") + + service.start + end + + it "activates the Y2Storage inhibitors" do + expect(inhibitors).to receive(:inhibit) + + service.start + end + end + + describe "#export" do + it "exports the storage manager" do + expect(object_server).to receive(:export).with(manager_obj) + service.export + end + end + + describe "#dispatch" do + it "dispatches the messages from the bus" do + expect(bus).to receive(:dispatch_message_queue) + service.dispatch + end + end +end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 5e4dcde7bb..eaa1ca3724 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -59,7 +59,6 @@ mock_storage(devicegraph: scenario) allow(Agama::Storage::Proposal).to receive(:new).and_return(proposal) allow(Agama::HTTP::Clients::Questions).to receive(:new).and_return(questions_client) - allow(Agama::DBus::Clients::Software).to receive(:instance).and_return(software) allow(Bootloader::FinishClient).to receive(:new).and_return(bootloader_finish) allow(Agama::Security).to receive(:new).and_return(security) # mock writting config as proposal call can do storage probing, which fails in CI @@ -79,166 +78,87 @@ let(:y2storage_manager) { Y2Storage::StorageManager.instance } let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } let(:questions_client) { instance_double(Agama::HTTP::Clients::Questions) } - let(:software) do - instance_double(Agama::DBus::Clients::Software, selected_product: "ALP") - end let(:network) { instance_double(Agama::Network, link_resolv: nil, unlink_resolv: nil) } let(:bootloader_finish) { instance_double(Bootloader::FinishClient, write: nil) } let(:security) { instance_double(Agama::Security, write: nil) } - let(:scenario) { "empty-hd-50GiB.yaml" } - describe "#probe" do + describe "#activate" do before do allow(Agama::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) - allow(y2storage_manager).to receive(:raw_probed).and_return(raw_devicegraph) - allow(proposal).to receive(:issues).and_return(proposal_issues) - allow(proposal.storage_system).to receive(:candidate_devices).and_return(devices) - allow(proposal).to receive(:calculate_from_json).and_return(true) - allow(proposal).to receive(:success?).and_return(true) - allow(proposal).to receive(:storage_json).and_return(current_config) - allow_any_instance_of(Agama::Storage::Configurator) - .to receive(:generate_configs).and_return([default_config]) - allow(config).to receive(:pick_product) allow(iscsi).to receive(:activate) allow(y2storage_manager).to receive(:activate) - allow(iscsi).to receive(:probe) - allow(y2storage_manager).to receive(:probe) - end - - let(:raw_devicegraph) do - instance_double(Y2Storage::Devicegraph, probing_issues: probing_issues) - end - - let(:proposal) { Agama::Storage::Proposal.new(config, logger: logger) } - - let(:default_config) do - { - storage: { - drives: [ - search: "/dev/vda1" - ] - } - } - end - - let(:current_config) do - { - storage: { - drives: [ - search: "/dev/vda2" - ] - } - } end let(:iscsi) { Agama::Storage::ISCSI::Manager.new } - let(:devices) { [disk1, disk2] } - - let(:disk1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } - let(:disk2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } - - let(:probing_issues) { [Y2Storage::Issue.new("probing issue")] } - - let(:proposal_issues) { [Agama::Issue.new("proposal issue")] } - - let(:callback) { proc {} } - - it "sets env YAST_NO_BLS_BOOT to yes if product doesn't requires bls boot explicitly" do - expect(config).to receive(:pick_product) - expect(config).to receive(:boot_strategy).and_return(nil) - expect(ENV).to receive(:[]=).with("YAST_NO_BLS_BOOT", "1") - - storage.probe - end - - it "probes the storage devices and calculates a proposal" do - expect(config).to receive(:pick_product).with("ALP") + it "activates iSCSI and devices managed by Y2Storage" do expect(iscsi).to receive(:activate) expect(y2storage_manager).to receive(:activate) do |callbacks| expect(callbacks).to be_a(Agama::Storage::Callbacks::Activate) end - expect(iscsi).to receive(:probe) - expect(y2storage_manager).to receive(:probe) - expect(proposal).to receive(:calculate_from_json) - storage.probe + storage.activate end - it "sets the system as non deprecated" do - storage.deprecated_system = true - storage.probe - - expect(storage.deprecated_system?).to eq(false) + it "does not reset information from previous activation" do + expect(Y2Storage::Luks).to_not receive(:reset_activation_infos) + storage.activate end + end - it "adds the probing issues" do - storage.probe - - expect(storage.issues).to include( - an_object_having_attributes(description: /probing issue/) - ) + describe "#reset_activation" do + it "resets information from previous activation" do + expect(Y2Storage::Luks).to receive(:reset_activation_infos) + storage.reset_activation end + end - it "adds the proposal issues" do - storage.probe - - expect(storage.issues).to include( - an_object_having_attributes(description: /proposal issue/) - ) + describe "#probe" do + before do + allow(Agama::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) + allow(proposal).to receive(:calculate_from_json).and_return(true) + allow(proposal).to receive(:success?).and_return(true) end - it "executes the on_probe callbacks" do - storage.on_probe(&callback) - - expect(callback).to receive(:call) + let(:iscsi) { Agama::Storage::ISCSI::Manager.new } + it "probes the storage devices" do + expect(iscsi).to receive(:probe) + expect(y2storage_manager).to receive(:probe) do |callbacks| + expect(callbacks).to be_a(Y2Storage::Callbacks::UserProbe) + end storage.probe end + end - context "if :keep_config is false" do - let(:keep_config) { false } - - it "calculates a proposal using the default product config" do - expect(proposal).to receive(:calculate_from_json).with(default_config) - storage.probe(keep_config: keep_config) - end + describe "#system_issues" do + before do + allow(y2storage_manager).to receive(:raw_probed).and_return(raw_devicegraph) + allow(proposal.storage_system).to receive(:candidate_devices).and_return(devices) end - context "if :keep_config is true" do - let(:keep_config) { true } - - it "calculates a proposal using the current config" do - expect(proposal).to receive(:calculate_from_json).with(current_config) - storage.probe(keep_config: keep_config) - end + let(:raw_devicegraph) do + instance_double(Y2Storage::Devicegraph, probing_issues: probing_issues) end - context "if :keep_activation is false" do - let(:keep_activation) { false } + let(:devices) { [disk1, disk2] } - it "resets information from previous activation" do - expect(Y2Storage::Luks).to receive(:reset_activation_infos) - storage.probe(keep_activation: keep_activation) - end - end + let(:disk1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } + let(:disk2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } - context "if :keep_activation is true" do - let(:keep_activation) { true } + let(:probing_issues) { [Y2Storage::Issue.new("probing issue")] } - it "does not reset information from previous activation" do - expect(Y2Storage::Luks).to_not receive(:reset_activation_infos) - storage.probe(keep_activation: keep_activation) - end + it "includes the probing issues" do + expect(storage.system_issues).to include( + an_object_having_attributes(description: /probing issue/) + ) end context "if there are available devices" do let(:devices) { [disk1] } - it "does not add an issue for available devices" do - storage.probe - - expect(storage.issues).to_not include( + it "does not an issue for available devices" do + expect(storage.system_issues).to_not include( an_object_having_attributes(description: /no suitable device/) ) end @@ -247,10 +167,8 @@ context "if there are not available devices" do let(:devices) { [] } - it "adds an issue for available devices" do - storage.probe - - expect(storage.issues).to include( + it "includes an issue for available devices" do + expect(storage.system_issues).to include( an_object_having_attributes(description: /no suitable device/) ) end @@ -290,8 +208,6 @@ let(:proposal_issues) { [Agama::Issue.new("proposal issue")] } - let(:callback) { proc {} } - it "calculates a proposal using the default config if no config is given" do expect(proposal).to receive(:calculate_from_json).with(default_config) storage.configure @@ -310,12 +226,6 @@ ) end - it "executes the on_configure callbacks" do - storage.on_configure(&callback) - expect(callback).to receive(:call) - storage.configure - end - context "if the proposal was correctly calculated" do before do allow(proposal).to receive(:success?).and_return(true) @@ -339,16 +249,33 @@ describe "#install" do before do - allow(y2storage_manager).to receive(:staging).and_return(proposed_devicegraph) - allow(Yast::WFM).to receive(:CallFunction).with("inst_prepdisk", []) allow(Yast::WFM).to receive(:CallFunction).with("inst_bootloader", []) - allow(Yast::PackagesProposal).to receive(:SetResolvables) allow(Bootloader::ProposalClient).to receive(:new) .and_return(bootloader_proposal) allow(Y2Storage::Clients::InstPrepdisk).to receive(:new).and_return(client) end + let(:bootloader_proposal) { instance_double(Bootloader::ProposalClient, make_proposal: nil) } + let(:client) { instance_double(Y2Storage::Clients::InstPrepdisk, run: nil) } + + it "runs the inst_prepdisk client" do + expect(Y2Storage::Clients::InstPrepdisk).to receive(:new) do |params| + expect(params[:commit_callbacks]).to be_a(Agama::Storage::Callbacks::Commit) + end.and_return(client) + + expect(client).to receive(:run) + + storage.install + end + end + + describe "#add_packages" do + before do + allow(y2storage_manager).to receive(:staging).and_return(proposed_devicegraph) + allow(Yast::PackagesProposal).to receive(:SetResolvables) + end + let(:proposed_devicegraph) do instance_double(Y2Storage::Devicegraph, used_features: used_features) end @@ -361,26 +288,12 @@ ) end - let(:bootloader_proposal) { instance_double(Bootloader::ProposalClient, make_proposal: nil) } - - let(:client) { instance_double(Y2Storage::Clients::InstPrepdisk, run: nil) } - it "adds storage software to install" do expect(Yast::PackagesProposal).to receive(:SetResolvables) do |_, _, packages| expect(packages).to contain_exactly("btrfsprogs", "snapper") end - storage.install - end - - it "runs the inst_prepdisk client" do - expect(Y2Storage::Clients::InstPrepdisk).to receive(:new) do |params| - expect(params[:commit_callbacks]).to be_a(Agama::Storage::Callbacks::Commit) - end.and_return(client) - - expect(client).to receive(:run) - - storage.install + storage.add_packages end context "if iSCSI was configured" do @@ -394,7 +307,7 @@ expect(packages).to include("open-iscsi", "iscsiuio") end - storage.install + storage.add_packages end end @@ -413,7 +326,7 @@ expect(packages).to include("open-iscsi", "iscsiuio") end - storage.install + storage.add_packages end end end From 1c19bb3b0503eb30a8ce7d658a24db78194cb9ab Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 28 Oct 2025 13:43:55 +0100 Subject: [PATCH 284/917] Fix tests for DBus::Clients::Storage --- service/test/agama/dbus/clients/storage_test.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/service/test/agama/dbus/clients/storage_test.rb b/service/test/agama/dbus/clients/storage_test.rb index 10fa7aad33..8c63d7366b 100644 --- a/service/test/agama/dbus/clients/storage_test.rb +++ b/service/test/agama/dbus/clients/storage_test.rb @@ -31,22 +31,19 @@ allow(bus).to receive(:service).with("org.opensuse.Agama.Storage1").and_return(service) allow(service).to receive(:[]).with("/org/opensuse/Agama/Storage1").and_return(dbus_object) allow(dbus_object).to receive(:introspect) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Storage1").and_return(storage_iface) end let(:bus) { instance_double(Agama::DBus::Bus) } let(:service) { instance_double(::DBus::ProxyService) } - let(:dbus_object) { instance_double(::DBus::ProxyObject) } - let(:storage_iface) { instance_double(::DBus::ProxyObjectInterface) } subject { described_class.new } describe "#probe" do - let(:storage_iface) { double(::DBus::ProxyObjectInterface, Probe: nil) } + let(:dbus_object) { double(::DBus::ProxyObject, Probe: nil) } it "calls the D-Bus Probe method" do - expect(storage_iface).to receive(:Probe) + expect(dbus_object).to receive(:Probe) subject.probe end @@ -54,7 +51,7 @@ context "when a block is given" do it "passes the block to the Probe method (async)" do callback = proc {} - expect(storage_iface).to receive(:Probe) do |&block| + expect(dbus_object).to receive(:Probe) do |&block| expect(block).to be(callback) end From bbcf2b851026b4b433da66526a6e7bcd4385648a Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 28 Oct 2025 14:05:01 +0100 Subject: [PATCH 285/917] Fix tests for Agama::Manager --- service/test/agama/manager_test.rb | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 0c666f6cc7..95b8afb977 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -57,8 +57,8 @@ let(:network) { instance_double(Agama::Network, install: nil, startup: nil) } let(:storage) do instance_double( - Agama::DBus::Clients::Storage, probe: nil, reprobe: nil, install: nil, finish: nil, - on_service_status_change: nil, errors?: false + Agama::DBus::Clients::Storage, probe: nil, install: nil, finish: nil, + :product= => nil, errors?: false ) end let(:scripts) do @@ -115,27 +115,28 @@ end describe "#config_phase" do + let(:product) { "Geecko" } + it "sets the installation phase to config" do subject.config_phase expect(subject.installation_phase.config?).to eq(true) end - it "calls #probe method of each module" do - expect(storage).to receive(:probe) - expect(software).to receive(:probe) + it "sets the product for the storage module" do + expect(storage).to receive(:product=).with product subject.config_phase end - context "if reprobe is requested" do - it "calls #reprobe method of the storage module" do - expect(storage).to receive(:reprobe) - subject.config_phase(reprobe: true) - end + it "calls #probe method for both software and storage modules if reprobe is requested" do + expect(storage).to receive(:probe) + expect(software).to receive(:probe) + subject.config_phase(reprobe: true) + end - it "calls #probe method of the software module" do - expect(software).to receive(:probe) - subject.config_phase(reprobe: true) - end + it "calls #probe method only for the software module if reprobe is not requested" do + expect(software).to receive(:probe) + expect(storage).to_not receive(:probe) + subject.config_phase(reprobe: false) end end From e2fba000d2e9994b156f21abbb0ec83426665080 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 28 Oct 2025 16:41:21 +0100 Subject: [PATCH 286/917] Return null in some cases for GetSystem and GetProposal --- service/lib/agama/dbus/storage/manager.rb | 4 + .../test/agama/dbus/storage/manager_test.rb | 173 ++++++++++-------- 2 files changed, 104 insertions(+), 73 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index a722674cbb..4fb101ec65 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -166,6 +166,8 @@ def finish # NOTE: memoization of the values? # @return [String] def recover_system + return nil.to_json unless backend.probed? + json = { devices: json_devices(:probed), availableDrives: available_drives, @@ -246,6 +248,8 @@ def solve_config_model(serialized_model) # NOTE: memoization of the values? # @return [String] def recover_proposal + return nil.to_json unless backend.proposal.calculated? + json = { devices: json_devices(:staging), actions: actions diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 238d06d840..bee27da786 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -83,102 +83,129 @@ def parse(string) end describe "#recover_proposal" do - describe "recover_proposal[:actions]" do + context "if no proposal has been calculated" do before do - allow(backend).to receive(:actions).and_return(actions) + allow(proposal).to receive(:calculated?).and_return false end - context "if there are no actions" do - let(:actions) { [] } - - it "returns an empty list" do - expect(parse(subject.recover_proposal)[:actions]).to eq([]) - end + it "returns 'null'" do + expect(subject.recover_proposal).to eq("null") end + end - context "if there are actions" do - let(:actions) { [action1, action2, action3, action4] } + context "if a proposal was already calculated" do + before do + allow(proposal).to receive(:calculated?).and_return true + end - let(:action1) do - instance_double(Agama::Storage::Action, - text: "test1", - device_sid: 1, - on_btrfs_subvolume?: false, - delete?: false, - resize?: false) + describe "recover_proposal[:actions]" do + before do + allow(backend).to receive(:actions).and_return(actions) end - let(:action2) do - instance_double(Agama::Storage::Action, - text: "test2", - device_sid: 2, - on_btrfs_subvolume?: false, - delete?: true, - resize?: false) - end + context "if there are no actions" do + let(:actions) { [] } - let(:action3) do - instance_double(Agama::Storage::Action, - text: "test3", - device_sid: 3, - on_btrfs_subvolume?: false, - delete?: false, - resize?: true) + it "returns an empty list" do + expect(parse(subject.recover_proposal)[:actions]).to eq([]) + end end - let(:action4) do - instance_double(Agama::Storage::Action, - text: "test4", - device_sid: 4, - on_btrfs_subvolume?: true, - delete?: false, - resize?: false) - end + context "if there are actions" do + let(:actions) { [action1, action2, action3, action4] } - it "returns a list with a hash for each action" do - all_actions = parse(subject.recover_proposal)[:actions] - expect(all_actions.size).to eq(4) - expect(all_actions).to all(be_a(Hash)) + let(:action1) do + instance_double(Agama::Storage::Action, + text: "test1", + device_sid: 1, + on_btrfs_subvolume?: false, + delete?: false, + resize?: false) + end - action1, action2, action3, action4 = all_actions + let(:action2) do + instance_double(Agama::Storage::Action, + text: "test2", + device_sid: 2, + on_btrfs_subvolume?: false, + delete?: true, + resize?: false) + end - expect(action1).to eq({ - device: 1, - text: "test1", - subvol: false, - delete: false, - resize: false - }) + let(:action3) do + instance_double(Agama::Storage::Action, + text: "test3", + device_sid: 3, + on_btrfs_subvolume?: false, + delete?: false, + resize?: true) + end - expect(action2).to eq({ - device: 2, - text: "test2", - subvol: false, - delete: true, - resize: false - }) + let(:action4) do + instance_double(Agama::Storage::Action, + text: "test4", + device_sid: 4, + on_btrfs_subvolume?: true, + delete?: false, + resize?: false) + end - expect(action3).to eq({ - device: 3, - text: "test3", - subvol: false, - delete: false, - resize: true - }) - expect(action4).to eq({ - device: 4, - text: "test4", - subvol: true, - delete: false, - resize: false - }) + it "returns a list with a hash for each action" do + all_actions = parse(subject.recover_proposal)[:actions] + expect(all_actions.size).to eq(4) + expect(all_actions).to all(be_a(Hash)) + + action1, action2, action3, action4 = all_actions + + expect(action1).to eq({ + device: 1, + text: "test1", + subvol: false, + delete: false, + resize: false + }) + + expect(action2).to eq({ + device: 2, + text: "test2", + subvol: false, + delete: true, + resize: false + }) + + expect(action3).to eq({ + device: 3, + text: "test3", + subvol: false, + delete: false, + resize: true + }) + expect(action4).to eq({ + device: 4, + text: "test4", + subvol: true, + delete: false, + resize: false + }) + end end end end end describe "#recover_system" do + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "returns 'null'" do + expect(subject.recover_system).to eq("null") + end + end + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(true) allow(proposal.storage_system).to receive(:available_drives).and_return(available_drives) allow(proposal.storage_system).to receive(:candidate_drives).and_return(candidate_drives) allow(proposal.storage_system).to receive(:available_md_raids).and_return(available_raids) From d4315741d5a1aa09aeeca4d338772bb31e1bd60d Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 29 Oct 2025 10:25:04 +0100 Subject: [PATCH 287/917] Some fixes from code review --- service/lib/agama/dbus/storage/manager.rb | 3 +-- service/test/agama/dbus/storage/manager_test.rb | 10 +++++----- service/test/agama/storage/manager_test.rb | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 4fb101ec65..4f0468fd87 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -44,7 +44,6 @@ class Manager < BaseObject # rubocop:disable Metrics/ClassLength include Yast::I18n include Agama::WithProgress include ::DBus::ObjectManager - include DBus::Interfaces::Issues PATH = "/org/opensuse/Agama/Storage1" private_constant :PATH @@ -248,7 +247,7 @@ def solve_config_model(serialized_model) # NOTE: memoization of the values? # @return [String] def recover_proposal - return nil.to_json unless backend.proposal.calculated? + return nil.to_json unless backend.proposal.success? json = { devices: json_devices(:staging), diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index bee27da786..315ba9b2a4 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -83,9 +83,9 @@ def parse(string) end describe "#recover_proposal" do - context "if no proposal has been calculated" do + context "if no proposal has been successfully calculated" do before do - allow(proposal).to receive(:calculated?).and_return false + allow(proposal).to receive(:success?).and_return false end it "returns 'null'" do @@ -93,9 +93,9 @@ def parse(string) end end - context "if a proposal was already calculated" do + context "if a proposal was successfully calculated" do before do - allow(proposal).to receive(:calculated?).and_return true + allow(proposal).to receive(:success?).and_return true end describe "recover_proposal[:actions]" do @@ -836,7 +836,7 @@ def parse(string) } end - it "returns an empty array" do + it "returns the list of proposal issues" do result = parse(subject.recover_issues) expect(result).to include( a_hash_including( diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index eaa1ca3724..0f51f119e7 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -157,7 +157,7 @@ context "if there are available devices" do let(:devices) { [disk1] } - it "does not an issue for available devices" do + it "does not include an issue for available devices" do expect(storage.system_issues).to_not include( an_object_having_attributes(description: /no suitable device/) ) From 5fce2a13fc78b618d43777576afe1fa2cb16d41c Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 29 Oct 2025 10:45:09 +0100 Subject: [PATCH 288/917] Small modification to Manager#reset_activation --- service/lib/agama/storage/manager.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index 7a76805acf..92c8e427eb 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -66,9 +66,11 @@ def activated? !!@activated end + # Resets any information regarding activation of devices that may be cached by Y2Storage. + # + # Note this does NOT deactivate any device. There is not way to revert a previous activation. def reset_activation Y2Storage::Luks.reset_activation_infos - @activated = false end # Activates the devices. From 50d58794a527d28dbdc864dd60f16adf0808e80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 29 Oct 2025 12:39:34 +0000 Subject: [PATCH 289/917] Allow to reset config --- rust/agama-l10n/src/message.rs | 10 ++++- rust/agama-l10n/src/service.rs | 14 +++++-- rust/agama-l10n/src/start.rs | 31 ++++++++++++--- rust/agama-manager/src/service.rs | 48 ++++++++++++++---------- rust/agama-utils/src/question/message.rs | 10 ++++- rust/agama-utils/src/question/service.rs | 6 ++- rust/agama-utils/src/question/start.rs | 4 +- 7 files changed, 86 insertions(+), 37 deletions(-) diff --git a/rust/agama-l10n/src/message.rs b/rust/agama-l10n/src/message.rs index 31145c86a8..f64d7a2a75 100644 --- a/rust/agama-l10n/src/message.rs +++ b/rust/agama-l10n/src/message.rs @@ -55,7 +55,7 @@ impl Message for GetConfig { } pub struct SetConfig { - pub config: T, + pub config: Option, } impl Message for SetConfig { @@ -63,9 +63,15 @@ impl Message for SetConfig { } impl SetConfig { - pub fn new(config: T) -> Self { + pub fn new(config: Option) -> Self { Self { config } } + + pub fn with(config: T) -> Self { + Self { + config: Some(config), + } + } } pub struct GetProposal; diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index beed1fadc2..bb73dfa99b 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -195,13 +195,19 @@ impl MessageHandler> for Service { &mut self, message: message::SetConfig, ) -> Result<(), Error> { - let config = Config::new_from(&self.system); - let merged = config.merge(&message.config)?; - if merged == self.config { + let base_config = Config::new_from(&self.system); + + let config = if let Some(config) = &message.config { + base_config.merge(config)? + } else { + base_config + }; + + if config == self.config { return Ok(()); } - self.config = merged; + self.config = config; let issues = self.find_issues(); self.issues .cast(issue::message::Update::new(Scope::L10n, issues))?; diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs index b1fba9f4e7..40c78a40dc 100644 --- a/rust/agama-l10n/src/start.rs +++ b/rust/agama-l10n/src/start.rs @@ -172,7 +172,7 @@ mod tests { timezone: Some("Atlantic/Canary".to_string()), }; handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await?; let updated = handler.call(message::GetConfig).await?; @@ -195,7 +195,7 @@ mod tests { // Use system info for missing values. handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await?; let updated = handler.call(message::GetConfig).await?; @@ -211,6 +211,25 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_reset_config() -> Result<(), Box> { + let (mut _events_rx, handler, _issues) = start_testing_service().await; + + handler.call(message::SetConfig::new(None)).await?; + + let config = handler.call(message::GetConfig).await?; + assert_eq!( + config, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("us".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + #[tokio::test] async fn test_set_invalid_config() -> Result<(), Box> { let (_events_rx, handler, _issues) = start_testing_service().await; @@ -221,7 +240,7 @@ mod tests { }; let result = handler - .call(message::SetConfig::new(input_config.clone())) + .call(message::SetConfig::with(input_config.clone())) .await; assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); Ok(()) @@ -233,7 +252,7 @@ mod tests { let config = handler.call(message::GetConfig).await?; assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - let message = message::SetConfig::new(config.clone()); + let message = message::SetConfig::with(config.clone()); handler.call(message).await?; // Wait until the action is dispatched. let _ = handler.call(message::GetConfig).await?; @@ -252,7 +271,7 @@ mod tests { locale: Some("xx_XX.UTF-8".to_string()), timezone: Some("Unknown/Unknown".to_string()), }; - let _ = handler.call(message::SetConfig::new(config)).await?; + let _ = handler.call(message::SetConfig::with(config)).await?; let found_issues = issues.call(issue::message::Get).await?; let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); @@ -282,7 +301,7 @@ mod tests { keymap: Some("es".to_string()), timezone: Some("Atlantic/Canary".to_string()), }; - let message = message::SetConfig::new(input_config.clone()); + let message = message::SetConfig::with(input_config.clone()); handler.call(message).await?; let proposal = handler diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 5aa3ff8ab4..208f51eedc 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -170,25 +170,18 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { - /// Sets the user configuration with the given values. - /// - /// It merges the values in the top-level. Therefore, if the configuration - /// for a scope is not given, it keeps the previous one. - /// - /// FIXME: We should replace not given sections with the default ones. - /// After all, now we have config/user/:scope URLs. + /// Sets the config. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - if let Some(l10n) = &message.config.l10n { - self.l10n - .call(l10n::message::SetConfig::new(l10n.clone())) - .await?; - } + self.l10n + .call(l10n::message::SetConfig::new(message.config.l10n.clone())) + .await?; + + self.questions + .call(question::message::SetConfig::new( + message.config.questions.clone(), + )) + .await?; - if let Some(questions) = &message.config.questions { - self.questions - .call(question::message::SetConfig::new(questions.clone())) - .await?; - } self.config = message.config; Ok(()) } @@ -196,12 +189,27 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { - /// Patches the user configuration with the given values. + /// Patches the config. /// - /// It merges the current configuration with the given one. + /// It merges the current config with the given one. If some scope is missing in the given + /// config, then it keeps the values from the current config. async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; - self.handle(message::SetConfig::new(config)).await + + if let Some(l10n) = &config.l10n { + self.l10n + .call(l10n::message::SetConfig::with(l10n.clone())) + .await?; + } + + if let Some(questions) = &config.questions { + self.questions + .call(question::message::SetConfig::with(questions.clone())) + .await?; + } + + self.config = config; + Ok(()) } } diff --git a/rust/agama-utils/src/question/message.rs b/rust/agama-utils/src/question/message.rs index 8605b7ca4e..46abddb210 100644 --- a/rust/agama-utils/src/question/message.rs +++ b/rust/agama-utils/src/question/message.rs @@ -32,13 +32,19 @@ impl Message for GetConfig { /// Sets questions configuration (policy, pre-defined answers, etc.). pub struct SetConfig { - pub config: Config, + pub config: Option, } impl SetConfig { - pub fn new(config: Config) -> Self { + pub fn new(config: Option) -> Self { Self { config } } + + pub fn with(config: Config) -> Self { + Self { + config: Some(config), + } + } } impl Message for SetConfig { diff --git a/rust/agama-utils/src/question/service.rs b/rust/agama-utils/src/question/service.rs index dcf2d01d54..a1265c7916 100644 --- a/rust/agama-utils/src/question/service.rs +++ b/rust/agama-utils/src/question/service.rs @@ -97,7 +97,11 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - self.config = message.config; + if let Some(config) = message.config { + self.config = config; + } else { + self.config = Config::default(); + } Ok(()) } } diff --git a/rust/agama-utils/src/question/start.rs b/rust/agama-utils/src/question/start.rs index 7411d86e74..21283da721 100644 --- a/rust/agama-utils/src/question/start.rs +++ b/rust/agama-utils/src/question/start.rs @@ -99,7 +99,7 @@ mod tests { policy: Some(Policy::Auto), ..Default::default() }; - questions.call(message::SetConfig::new(config)).await?; + questions.call(message::SetConfig::with(config)).await?; // Ask the question let question = questions @@ -134,7 +134,7 @@ mod tests { policy: Some(Policy::User), answers: vec![rule_by_class], }; - questions.call(message::SetConfig::new(config)).await?; + questions.call(message::SetConfig::with(config)).await?; // Ask the question let question = questions From e4c32e61ab72c4f28854dba7ca3c40ae37279240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 29 Oct 2025 16:26:50 +0000 Subject: [PATCH 290/917] Fix dbus service --- service/lib/agama/dbus/storage/manager.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 4f0468fd87..cbfcc19355 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -56,7 +56,6 @@ def initialize(backend, logger: nil) super(PATH, logger: logger) @backend = backend - register_storage_callbacks register_progress_callbacks register_iscsi_callbacks @@ -527,10 +526,6 @@ def proposal backend.proposal end - def register_storage_callbacks - backend.on_issues_change { issues_properties_changed } - end - def register_iscsi_callbacks backend.iscsi.on_probe do iscsi_initiator_properties_changed From 2ea9e8f319e41ac31b5eedd206542033bf6bae21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 29 Oct 2025 14:50:06 +0000 Subject: [PATCH 291/917] Set storage config --- rust/agama-manager/src/service.rs | 23 +++++++++++---- rust/agama-storage/src/client.rs | 22 +++++++++------ .../storage => agama-storage/src}/config.rs | 28 +++++++++++++++---- rust/agama-storage/src/lib.rs | 9 ++++-- rust/agama-storage/src/message.rs | 13 +++++++-- rust/agama-storage/src/service.rs | 10 +++---- rust/agama-utils/src/api.rs | 1 - rust/agama-utils/src/api/config.rs | 11 ++++++-- rust/agama-utils/src/api/storage.rs | 25 ----------------- 9 files changed, 82 insertions(+), 60 deletions(-) rename rust/{agama-utils/src/api/storage => agama-storage/src}/config.rs (64%) delete mode 100644 rust/agama-utils/src/api/storage.rs diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 208f51eedc..ee5d6ccc04 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -153,7 +153,8 @@ impl MessageHandler for Service { Ok(Config { l10n: Some(l10n), questions: Some(questions), - storage, + storage: storage.as_ref().and_then(|c| c.storage.clone()), + legacy_autoyast_storage: storage.and_then(|c| c.legacy_autoyast_storage), }) } } @@ -172,17 +173,21 @@ impl MessageHandler for Service { impl MessageHandler for Service { /// Sets the config. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + let config = message.config; + self.l10n - .call(l10n::message::SetConfig::new(message.config.l10n.clone())) + .call(l10n::message::SetConfig::new(config.l10n.clone())) .await?; self.questions - .call(question::message::SetConfig::new( - message.config.questions.clone(), - )) + .call(question::message::SetConfig::new(config.questions.clone())) + .await?; + + self.storage + .call(storage::message::SetConfig::new((&config).try_into().ok())) .await?; - self.config = message.config; + self.config = config; Ok(()) } } @@ -208,6 +213,12 @@ impl MessageHandler for Service { .await?; } + if let Some(storage) = (&config).try_into().ok() { + self.storage + .call(storage::message::SetConfig::with(storage)) + .await?; + } + self.config = config; Ok(()) } diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs index 2a28a02e89..35c6194eae 100644 --- a/rust/agama-storage/src/client.rs +++ b/rust/agama-storage/src/client.rs @@ -20,7 +20,7 @@ //! Implements a client to access Agama's storage service. -use agama_utils::api::storage::Config; +use crate::config::Config; use serde_json::{value::RawValue, Value}; use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; @@ -78,9 +78,7 @@ impl Client { pub async fn get_config(&self) -> Result, Error> { let message = self.call("GetConfig", &()).await?; - let value: String = message.body().deserialize()?; - let config = serde_json::from_str(value.as_str())?; - Ok(config) + self.json_from(message) } pub async fn get_config_model(&self) -> Result>, Error> { @@ -104,8 +102,10 @@ impl Client { Ok(()) } - pub async fn set_config(&self, config: Box) -> Result<(), Error> { - self.call("SetConfig", &(config.to_string())).await?; + pub async fn set_config(&self, config: Option) -> Result<(), Error> { + let config = config.filter(|c| c.is_some()); + let json = serde_json::to_string(&config)?; + self.call("SetConfig", &(json)).await?; Ok(()) } @@ -140,17 +140,21 @@ impl Client { .map_err(|e| e.into()) } - fn json_from(&self, message: Message) -> Result>, Error> { + fn json_from( + &self, + message: Message, + ) -> Result, Error> { let value: String = message.body().deserialize()?; if self.is_null(value.as_str()) { return Ok(None); } - let json = RawValue::from_string(value)?; + let json = serde_json::from_str(value.as_str())?; Ok(Some(json)) } fn is_null(&self, value: &str) -> bool { - match serde_json::from_str::(value) { + let value = serde_json::from_str::(value); + match value { Ok(Value::Null) => true, Ok(_) => false, Err(_) => false, diff --git a/rust/agama-utils/src/api/storage/config.rs b/rust/agama-storage/src/config.rs similarity index 64% rename from rust/agama-utils/src/api/storage/config.rs rename to rust/agama-storage/src/config.rs index d89ced778c..ff8bfa83da 100644 --- a/rust/agama-utils/src/api/storage/config.rs +++ b/rust/agama-storage/src/config.rs @@ -18,18 +18,36 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_utils::api; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Config { - #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] pub storage: Option>, - #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] pub legacy_autoyast_storage: Option>, } + +impl Config { + pub fn is_some(&self) -> bool { + self.storage.is_some() || self.legacy_autoyast_storage.is_some() + } +} + +impl TryFrom<&api::Config> for Config { + type Error = (); + + fn try_from(config: &api::Config) -> Result { + if config.storage.is_none() && config.legacy_autoyast_storage.is_none() { + Err(()) + } else { + Ok(Config { + storage: config.storage.clone(), + legacy_autoyast_storage: config.legacy_autoyast_storage.clone(), + }) + } + } +} diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index c5cbe488af..8c99137849 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -18,12 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +pub mod start; +pub use start::start; + pub mod service; pub use service::Service; +mod config; +pub use config::Config; + mod client; pub mod message; mod monitor; - -pub mod start; -pub use start::start; diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index 6038fa94c6..7ba113cda8 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -18,7 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::{actor::Message, api::storage::Config}; +use crate::config::Config; +use agama_utils::actor::Message; use serde_json::value::RawValue; #[derive(Clone)] @@ -94,13 +95,19 @@ impl Message for SetProduct { #[derive(Clone)] pub struct SetConfig { - pub config: Box, + pub config: Option, } impl SetConfig { - pub fn new(config: Box) -> Self { + pub fn new(config: Option) -> Self { Self { config } } + + pub fn with(config: Config) -> Self { + Self { + config: Some(config), + } + } } impl Message for SetConfig { diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index 9eedf72edc..ae33cc65e3 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -18,12 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::client::{self, Client}; -use crate::message; -use agama_utils::{ - actor::{self, Actor, MessageHandler}, - api::storage::Config, +use crate::{ + client::{self, Client}, + config::Config, + message, }; +use agama_utils::actor::{self, Actor, MessageHandler}; use async_trait::async_trait; use serde_json::value::RawValue; diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 02dc8a8ef6..4782b464af 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -50,4 +50,3 @@ pub use action::Action; pub mod l10n; pub mod question; -pub mod storage; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 4faa294568..a85ebcc605 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,8 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question, storage}; +use crate::api::{l10n, question}; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] @@ -29,8 +30,12 @@ pub struct Config { pub l10n: Option, #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, - #[serde(flatten)] - pub storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub storage: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub legacy_autoyast_storage: Option>, } /// Patch for the config. diff --git a/rust/agama-utils/src/api/storage.rs b/rust/agama-utils/src/api/storage.rs deleted file mode 100644 index a77e14a141..0000000000 --- a/rust/agama-utils/src/api/storage.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module contains all Agama public types that might be available over -//! the HTTP and WebSocket API. - -mod config; -pub use config::Config; From b64ce449e0f3a414665b8308bef5feaf3f4e8ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Oct 2025 12:10:09 +0000 Subject: [PATCH 292/917] Add storage actions --- rust/agama-manager/src/service.rs | 38 +++++++++++++++++++++--------- rust/agama-utils/src/api/action.rs | 4 ++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index ee5d6ccc04..dade367800 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -84,6 +84,27 @@ impl Service { } } + async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { + self.l10n + .call(l10n::message::SetSystem::new(config.clone())) + .await?; + if let Some(locale) = config.locale { + self.storage + .cast(storage::message::SetLocale::new(locale.as_str()))?; + } + Ok(()) + } + + async fn activate_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Activate).await?; + Ok(()) + } + + async fn probe_storage(&self) -> Result<(), Error> { + self.storage.call(storage::message::Probe).await?; + Ok(()) + } + async fn install(&mut self) -> Result<(), Error> { self.state = State::Installing; self.events.send(Event::StateChanged)?; @@ -102,17 +123,6 @@ impl Service { self.events.send(Event::StateChanged)?; Ok(()) } - - async fn configure_l10n(&self, config: api::l10n::SystemConfig) -> Result<(), Error> { - self.l10n - .call(l10n::message::SetSystem::new(config.clone())) - .await?; - if let Some(locale) = config.locale { - self.storage - .cast(storage::message::SetLocale::new(locale.as_str()))?; - } - Ok(()) - } } impl Actor for Service { @@ -250,6 +260,12 @@ impl MessageHandler for Service { Action::ConfigureL10n(config) => { self.configure_l10n(config).await?; } + Action::ActivateStorage => { + self.activate_storage().await?; + } + Action::ProbeStorage => { + self.probe_storage().await?; + } Action::Install => { self.install().await?; } diff --git a/rust/agama-utils/src/api/action.rs b/rust/agama-utils/src/api/action.rs index 858f1aaac8..983b3e46ae 100644 --- a/rust/agama-utils/src/api/action.rs +++ b/rust/agama-utils/src/api/action.rs @@ -23,6 +23,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize, utoipa::ToSchema)] pub enum Action { + #[serde(rename = "activateStorage")] + ActivateStorage, + #[serde(rename = "probeStorage")] + ProbeStorage, #[serde(rename = "configureL10n")] ConfigureL10n(l10n::SystemConfig), #[serde(rename = "install")] From faf95b3586e85b347618f2e3bd5a6001a0509941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Oct 2025 12:11:12 +0000 Subject: [PATCH 293/917] Fix tests depending on eula and products.d --- rust/agama-manager/src/start.rs | 4 + rust/agama-server/src/products.rs | 14 +- rust/agama-server/tests/server_service.rs | 4 + rust/agama-software/src/model/license.rs | 6 +- rust/agama-software/src/model/products.rs | 15 +- rust/agama-software/src/model/state.rs | 6 +- .../test/share/products.d/kalpa.yaml | 100 ------ .../test/share/products.d/leap_160.yaml | 178 ----------- .../test/share/products.d/leap_micro_62.yaml | 111 ------- .../test/share/products.d/microos.yaml | 198 ------------ .../test/share/products.d/sles_160.yaml | 200 ------------ .../test/share/products.d/sles_sap_160.yaml | 174 ----------- .../test/share/products.d/slowroll.yaml | 169 ---------- .../test/share/products.d/tumbleweed.yaml | 224 ------------- .../share/eula/license.final/license.es.txt | 295 ++++++++++++++++++ .../test/share/eula/license.final/license.txt | 263 ++++++++++++++++ .../eula/license.final/license.zh_CN.txt | 187 +++++++++++ .../share/products.d/kalpa.yaml | 0 .../share/products.d/leap_160.yaml | 0 .../share/products.d/leap_micro_62.yaml | 0 .../share/products.d/microos.yaml | 0 .../share/products.d/sles_160.yaml | 0 .../share/products.d/sles_sap_160.yaml | 0 .../share/products.d/slowroll.yaml | 0 .../share/products.d/tumbleweed.yaml | 0 25 files changed, 774 insertions(+), 1374 deletions(-) delete mode 100644 rust/agama-software/test/share/products.d/kalpa.yaml delete mode 100644 rust/agama-software/test/share/products.d/leap_160.yaml delete mode 100644 rust/agama-software/test/share/products.d/leap_micro_62.yaml delete mode 100644 rust/agama-software/test/share/products.d/microos.yaml delete mode 100644 rust/agama-software/test/share/products.d/sles_160.yaml delete mode 100644 rust/agama-software/test/share/products.d/sles_sap_160.yaml delete mode 100644 rust/agama-software/test/share/products.d/slowroll.yaml delete mode 100644 rust/agama-software/test/share/products.d/tumbleweed.yaml create mode 100644 rust/test/share/eula/license.final/license.es.txt create mode 100644 rust/test/share/eula/license.final/license.txt create mode 100644 rust/test/share/eula/license.final/license.zh_CN.txt rename rust/{agama-server/tests => test}/share/products.d/kalpa.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/leap_160.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/leap_micro_62.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/microos.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/sles_160.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/sles_sap_160.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/slowroll.yaml (100%) rename rust/{agama-server/tests => test}/share/products.d/tumbleweed.yaml (100%) diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index be2ec96126..718e46c6b6 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -74,6 +74,7 @@ mod test { use agama_utils::api::l10n; use agama_utils::api::{Config, Event}; use agama_utils::question; + use std::path::PathBuf; use tokio::sync::broadcast; async fn start_service() -> Handler { @@ -94,6 +95,9 @@ mod test { #[tokio::test] #[cfg(not(ci))] async fn test_update_config() -> Result<(), Box> { + let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); + let handler = start_service().await; let input_config = Config { diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs index bea4190cbe..27a1a962ac 100644 --- a/rust/agama-server/src/products.rs +++ b/rust/agama-server/src/products.rs @@ -39,7 +39,8 @@ pub enum ProductsRegistryError { /// Products registry. /// /// It holds the products specifications. At runtime it is possible to change the `products.d` -/// location by setting the `AGAMA_PRODUCTS_DIR` environment variable. +/// location by setting the `AGAMA_SHARE_DIR` environment variable. This variable points to +/// the parent of `products.d`. /// /// Dynamic behavior, like filtering by architecture, is not supported yet. #[derive(Clone, Default, Debug, Deserialize)] @@ -50,11 +51,8 @@ pub struct ProductsRegistry { impl ProductsRegistry { /// Creates a registry loading the products from the default location. pub fn load() -> Result { - let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { - PathBuf::from(dir) - } else { - PathBuf::from("/usr/share/agama/products.d") - }; + let share_dir = std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); + let products_dir = PathBuf::from(share_dir).join("products.d"); if !products_dir.exists() { return Err(ProductsRegistryError::IO(std::io::Error::new( @@ -182,7 +180,7 @@ mod test { #[test] fn test_load_registry() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); let config = ProductsRegistry::load_from(path.as_path()).unwrap(); // ensuring that we can load all products from tests assert_eq!(config.products.len(), 8); @@ -190,7 +188,7 @@ mod test { #[test] fn test_find_product() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); let products = ProductsRegistry::load_from(path.as_path()).unwrap(); let tw = products.find("Tumbleweed").unwrap(); assert_eq!(tw.id, "Tumbleweed"); diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 78275fc12b..e21df0f34f 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -29,10 +29,14 @@ use axum::{ }; use common::body_to_string; use std::error::Error; +use std::path::PathBuf; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; async fn build_server_service() -> Result { + let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); + let (tx, mut rx) = channel(16); tokio::spawn(async move { diff --git a/rust/agama-software/src/model/license.rs b/rust/agama-software/src/model/license.rs index 79b12a74ad..47f0bdeedc 100644 --- a/rust/agama-software/src/model/license.rs +++ b/rust/agama-software/src/model/license.rs @@ -237,11 +237,13 @@ impl LicensesRepo { impl Default for LicensesRepo { fn default() -> Self { - let relative_path = Path::new("share/eula"); + let relative_path = PathBuf::from("share/eula"); let path = if relative_path.exists() { relative_path } else { - Path::new("/usr/share/agama/eula") + let share_dir = + std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); + PathBuf::from(share_dir).join("eula") }; Self::new(path) } diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index 761ec29295..e13961be80 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -41,7 +41,8 @@ pub enum ProductsRegistryError { /// Products registry. /// /// It holds the products specifications. At runtime it is possible to change the `products.d` -/// location by setting the `AGAMA_PRODUCTS_DIR` environment variable. +/// location by setting the `AGAMA_SHARE_DIR` environment variable. This variable points to +/// the parent of `products.d`. /// /// Dynamic behavior, like filtering by architecture, is not supported yet. #[derive(Clone, Debug, Deserialize)] @@ -110,12 +111,8 @@ impl ProductsRegistry { impl Default for ProductsRegistry { fn default() -> Self { - let products_dir = if let Ok(dir) = std::env::var("AGAMA_PRODUCTS_DIR") { - PathBuf::from(dir) - } else { - PathBuf::from("/usr/share/agama/products.d") - }; - + let share_dir = std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); + let products_dir = PathBuf::from(share_dir).join("products.d"); Self::new(products_dir) } } @@ -216,7 +213,7 @@ mod test { #[test] fn test_load_registry() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); let mut repo = ProductsRegistry::new(path.as_path()); repo.read().unwrap(); // ensuring that we can load all products from tests @@ -225,7 +222,7 @@ mod test { #[test] fn test_find_product() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/share/products.d"); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); let mut repo = ProductsRegistry::new(path.as_path()); repo.read().unwrap(); let tw = repo.find("Tumbleweed").unwrap(); diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index a6c10c333a..429e6891ed 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -222,6 +222,8 @@ pub struct SoftwareOptions { #[cfg(test)] mod tests { + use std::path::PathBuf; + use agama_utils::api::software::{ PatternsConfig, PatternsMap, RepositoryParams, SoftwareConfig, }; @@ -253,7 +255,9 @@ mod tests { } fn build_product_spec() -> ProductSpec { - let product = std::fs::read_to_string("test/share/products.d/tumbleweed.yaml").unwrap(); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../test/share/products.d/tumbleweed.yaml"); + let product = std::fs::read_to_string(&path).unwrap(); serde_yaml::from_str(&product).unwrap() } diff --git a/rust/agama-software/test/share/products.d/kalpa.yaml b/rust/agama-software/test/share/products.d/kalpa.yaml deleted file mode 100644 index 0298a97754..0000000000 --- a/rust/agama-software/test/share/products.d/kalpa.yaml +++ /dev/null @@ -1,100 +0,0 @@ -id: Kalpa -name: Kalpa Desktop -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: "A rolling release immutable desktop product, using the Plasma - Desktop, leveraging Flatpak for Application Delivery, a Read-Only base, and - automatic and atomic updates of your system" -icon: Kalpa.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: -software: - installation_repositories: - - url: https://download.opensuse.org/tumbleweed/repo/oss/ - archs: x86_64 - - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ - archs: x86_64 - - url: https://download.opensuse.org/update/tumbleweed/ - archs: x86_64 - # device labels for offline installation media - installation_labels: - - label: Kalpa-desktop-DVD-x86_64 - archs: x86_64 - mandatory_patterns: - - microos_base - - microos_base_zypper - - microos_defaults - - microos_hardware - - microos_kde_desktop - - microos_selinux - optional_patterns: null - user_patterns: - - container_runtime - mandatory_packages: - - NetworkManager - - openSUSE-repos-MicroOS - optional_packages: null - base_product: Kalpa - -security: - lsm: selinux - available_lsms: - selinux: - patterns: - - microos_selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "/var" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: true - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - - path: boot/writable - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/x86_64-efi - archs: x86_64 - size: - auto: true - outline: - required: true - snapshots_configurable: false - filesystems: - - btrfs - auto_size: - base_min: 5 GiB - base_max: 25 GiB - max_fallback_for: - - "/var" - - mount_path: "/var" - filesystem: btrfs - mount_options: - - "x-initrd.mount" - - "nodatacow" - size: - auto: false - min: 5 GiB - outline: - required: false - filesystems: - - btrfs diff --git a/rust/agama-software/test/share/products.d/leap_160.yaml b/rust/agama-software/test/share/products.d/leap_160.yaml deleted file mode 100644 index 2a627eb6b0..0000000000 --- a/rust/agama-software/test/share/products.d/leap_160.yaml +++ /dev/null @@ -1,178 +0,0 @@ -id: Leap_16.0 -name: Leap 16.0 -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: 'The latest version of a community distribution based on the latest - SUSE Linux Enterprise Server.' -# Do not manually change any translations! See README.md for more details. -icon: Leap16.svg -translations: - description: - ca: La darrera versió d'una distribució comunitària basada en l'últim SUSE Linux - Enterprise Server. - cs: Nejnovější verze komunitní distribuce založené na nejnovějším SUSE Linux - Enterprise Serveru. - de: Die neueste Version einer Community-Distribution, die auf dem aktuellen SUSE - Linux Enterprise Server basiert. - es: La última versión de una distribución comunitaria basada en el último SUSE - Linux Enterprise Server. - ja: 最新のSUSE Linux Enterprise Server をベースにした、コミュニティディストリビューションの最新版です。 - nb_NO: Leap 16.0 er den nyeste versjonen av den fellesskapte distribusjon basert - på den nyeste SUSE Linux Enterprise Server. - pt_BR: A versão mais recente de uma distribuição comunitária baseada no mais - recente SUSE Linux Enterprise Server. - ru: Leap 16.0 - это последняя версия дистрибутива от сообщества, основанного на - последней версии SUSE Linux Enterprise Server. - sv: Den senaste versionen av en gemenskapsdistribution baserad på den senaste - SUSE Linux Enterprise Server. - tr: En son SUSE Linux Enterprise Server'ı temel alan bir topluluk dağıtımının en - son sürümü. - zh_Hans: Leap 16.0 是基于 SUSE Linux Enterprise Server 构建的社区发行版的最新版本。 -software: - installation_repositories: - - url: https://download.opensuse.org/distribution/leap/16.0/repo/oss/$basearch - installation_labels: - - label: Leap-DVD-x86_64 - archs: x86_64 - - label: Leap-DVD-aarch64 - archs: aarch64 - - label: Leap-DVD-s390x - archs: s390 - - label: Leap-DVD-ppc64le - archs: ppc - mandatory_patterns: - - enhanced_base # only pattern that is shared among all roles on Leap - optional_patterns: null # no optional pattern shared - user_patterns: - - gnome - - kde - - xfce_wayland - - multimedia - - office - - cockpit - - fips - - name: selinux - selected: true - - documentation - - sw_management - - container_runtime_podman - - dhcp_dns_server - - directory_server - - file_server - - gateway_server - - kvm_server - - kvm_tools - - lamp_server - - mail_server - - printing - mandatory_packages: - - NetworkManager - - openSUSE-repos-Leap - - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model - optional_packages: null - base_product: Leap - -security: - lsm: selinux - available_lsms: - apparmor: - patterns: - - apparmor - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "swap" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: false - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - auto_size: - base_min: 5 GiB - base_max: 15 GiB - snapshots_increment: 150% - max_fallback_for: - - "/home" - snapshots_configurable: true - - mount_path: "swap" - filesystem: swap - size: - min: 1 GiB - max: 2 GiB - outline: - required: false - filesystems: - - swap - - mount_path: "/home" - filesystem: xfs - size: - auto: false - min: 5 GiB - max: unlimited - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - filesystem: xfs - size: - auto: false - min: 512 MiB - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - vfat diff --git a/rust/agama-software/test/share/products.d/leap_micro_62.yaml b/rust/agama-software/test/share/products.d/leap_micro_62.yaml deleted file mode 100644 index e38012b577..0000000000 --- a/rust/agama-software/test/share/products.d/leap_micro_62.yaml +++ /dev/null @@ -1,111 +0,0 @@ -id: LeapMicro_6.2 -name: openSUSE Leap Micro 6.2 Beta -archs: x86_64,aarch64 -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: 'Leap Micro is an ultra-reliable, lightweight operating system - built for containerized and virtualized workloads.' -icon: LeapMicro.svg -software: - installation_repositories: - - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-x86_64 - archs: x86_64 - - url: https://download.opensuse.org/distribution/leap-micro/6.2/product/repo/openSUSE-Leap-Micro-6.2-aarch64 - archs: aarch64 - # device labels for offline installation media - installation_labels: - - label: openSUSE-Leap-Micro-DVD-x86_64 - archs: x86_64 - - label: openSUSE-Leap-Micro-DVD-aarch64 - archs: aarch64 - - mandatory_patterns: - - cockpit - - base - - transactional - - traditional - - hardware - - selinux - - optional_patterns: null - - user_patterns: - - cloud - - container_runtime - - fips - - ima_evm - - kvm_host - - ra_agent - - ra_verifier - - salt_minion - - sssd_ldap - - mandatory_packages: - - NetworkManager - - openSUSE-repos-LeapMicro - optional_packages: null - base_product: Leap-Micro - -security: - lsm: selinux - available_lsms: - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "/var" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: true - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - - path: boot/writable - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/x86_64-efi - archs: x86_64 - size: - auto: true - outline: - required: true - snapshots_configurable: false - filesystems: - - btrfs - auto_size: - base_min: 5 GiB - base_max: 25 GiB - max_fallback_for: - - "/var" - - mount_path: "/var" - filesystem: btrfs - mount_options: - - "x-initrd.mount" - - "nodatacow" - size: - auto: false - min: 5 GiB - outline: - required: false - filesystems: - - btrfs diff --git a/rust/agama-software/test/share/products.d/microos.yaml b/rust/agama-software/test/share/products.d/microos.yaml deleted file mode 100644 index ac8bbc7c48..0000000000 --- a/rust/agama-software/test/share/products.d/microos.yaml +++ /dev/null @@ -1,198 +0,0 @@ -id: MicroOS -name: openSUSE MicroOS -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: 'A quick, small distribution designed to host container workloads - with automated administration & patching. openSUSE MicroOS provides - transactional (atomic) updates upon a read-only btrfs root file system. As - rolling release distribution the software is always up-to-date.' -icon: MicroOS.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: - ca: Una distribució ràpida i petita dissenyada per allotjar càrregues de treball - de contenidors amb administració i pedaços automatitzats. L'openSUSE - MicroSO proporciona actualitzacions transaccionals (atòmiques) en un - sistema de fitxers d'arrel btrfs només de lectura. Com a distribució - contínua, el programari està sempre actualitzat. - cs: Rychlá, malá distribuce určená pro úlohy hostitelského kontejneru s - automatizovanou správou a záplatováním. openSUSE MicroOS poskytuje - transakční (atomické) aktualizace na kořenovém souborovém systému btrfs - určeném pouze pro čtení. Jako distribuce s průběžným vydáváním je software - vždy aktuální. - de: Eine schnelle, kleine Distribution, die für den Betrieb von - Container-Arbeitslasten mit automatischer Verwaltung und automatisiertem - Patching entwickelt wurde. openSUSE MicroOS bietet transaktionale - (atomare) Aktualisierungen auf einem schreibgeschützten - btrfs-Wurzeldateisystem. Als Distribution mit rollierenden - Veröffentlichungen ist die Software immer auf dem neuesten Stand. - es: Una distribución pequeña y rápida diseñada para alojar cargas de trabajo de - contenedores con administración y parches automatizados. openSUSE MicroOS - proporciona actualizaciones transaccionales (atómicas) en un sistema de - archivos raíz btrfs de solo lectura. Como distribución de actualización - continua, el software siempre está actualizado. - fr: Une petite distribution rapide conçue pour héberger des charges de travail - de conteneurs avec une administration et des correctifs automatisés. - openSUSE MicroOS fournit des mises à jour transactionnelles (atomiques) - sur un système de fichiers racine btrfs en lecture seule. En tant que - distribution continue, le logiciel est toujours à jour. - id: Distribusi cepat dan ramping yang dirancang untuk menampung beban kerja - kontainer dengan administrasi & penambalan otomatis. openSUSE MicroOS - menyediakan pembaruan transaksional (atomik) pada sistem berkas root btrfs - yang hanya dapat dibaca. Sebagai distribusi rilis bergulir, perangkat - lunak didalamnya selalu diperbarui. - ja: 高速で小型のディストリビューションで、管理やパッチ適用の自動化のようなコンテナ処理を賄うのに最適な仕組みです。 openSUSE MicroOS - はトランザクション型の (不可分の) 更新機構が提供されており、 btrfs - のルートファイルシステムを読み込み専用にすることができます。こちらもローリングリリース型のディストリビューションであるため、常に最新を維持することができます。 - nb_NO: En rask, liten distribusjon laget for å være vert til container - arbeidsoppgaver med automatisk administrasjon & lapping. openSUSE MicroOS - gir transaksjonelle (atomisk) oppdateringer oppå en skrivebeskyttet btrfs - rotfilsystem. Som rullerende distribusjon er programvaren alltid - oppdatert. - pt_BR: Uma distribuição pequena e rápida projetada para hospedar cargas de - trabalho de contêiner com administração e aplicação de patches - automatizadas. O openSUSE MicroOS fornece atualizações transacionais - (atômicas) em um sistema de arquivos raiz btrfs somente leitura. Como - distribuição contínua, o software está sempre atualizado. - ru: Быстрый, минималистичный дистрибутив, предназначенный для размещения - контейнерных рабочих нагрузок с автоматизированным администрированием и - исправлениями. openSUSE MicroOS обеспечивает транзакционные (атомарные) - обновления на корневой файловой системе btrfs, доступной только для - чтения. Так как дистрибутив использует плавающий выпуск обновлений, - программное обеспечение всегда актуально. - sv: En snabb, liten distribution utformad för att vara värd för - arbetsbelastningar i behållare med automatiserad administration och - patchning. openSUSE MicroOS tillhandahåller transaktionella (atomära) - uppdateringar på ett skrivskyddat btrfs-rootfilsystem. Som rullande - releasedistribution är mjukvaran alltid uppdaterad. - tr: Otomatik yönetim ve yama uygulamayla konteyner iş yüklerini barındırmak için - tasarlanmış hızlı, küçük bir dağıtım. openSUSE MicroOS, salt okunur bir - btrfs kök dosya sistemi üzerinde işlemsel (atomik) güncellemeler sağlar. - Sürekli sürüm dağıtımı olarak yazılım her zaman günceldir. - zh_Hans: 一个快速、小型的发行版,旨在通过自动化管理和修补来托管容器工作负载。openSUSE MicroOS 提供基于只读 Btrfs - 根文件系统之上的事务性(原子)更新。作为滚动发行版,它的软件始终保持最新。 -software: - installation_repositories: - - url: https://download.opensuse.org/tumbleweed/repo/oss/ - archs: x86_64 - - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ - archs: aarch64 - - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ - archs: ppc - - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ - archs: x86_64 - # aarch64 does not have non-oss ports. Keep eye if it change - - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ - archs: ppc - - url: https://download.opensuse.org/update/tumbleweed/ - archs: x86_64 - - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ - archs: aarch64 - - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ - archs: ppc - # device labels for offline installation media - installation_labels: - - label: openSUSE-MicroOS-DVD-x86_64 - archs: x86_64 - - label: openSUSE-MicroOS-DVD-aarch64 - archs: aarch64 - - label: openSUSE-MicroOS-DVD-s390x - archs: s390 - - label: openSUSE-MicroOS-DVD-ppc64le - archs: ppc - mandatory_patterns: - - microos_base - - microos_base_zypper - - microos_defaults - - microos_hardware - - microos_selinux - optional_patterns: null - user_patterns: - - container_runtime - - microos_ra_agent - - microos_ra_verifier - mandatory_packages: - - NetworkManager - - openSUSE-repos-MicroOS - optional_packages: null - base_product: MicroOS - -security: - lsm: selinux - available_lsms: - selinux: - patterns: - - microos_selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "/var" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: true - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - - path: boot/writable - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - snapshots_configurable: false - filesystems: - - btrfs - auto_size: - base_min: 5 GiB - base_max: 25 GiB - max_fallback_for: - - "/var" - - mount_path: "/var" - filesystem: btrfs - mount_options: - - "x-initrd.mount" - - "nodatacow" - size: - auto: false - min: 5 GiB - outline: - required: false - filesystems: - - btrfs diff --git a/rust/agama-software/test/share/products.d/sles_160.yaml b/rust/agama-software/test/share/products.d/sles_160.yaml deleted file mode 100644 index 8e018535b7..0000000000 --- a/rust/agama-software/test/share/products.d/sles_160.yaml +++ /dev/null @@ -1,200 +0,0 @@ -id: SLES -name: SUSE Linux Enterprise Server 16.0 -registration: true -version: "16.0" -license: "license.final" -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: "An open, reliable, compliant, and future-proof Linux Server choice - that ensures the enterprise's business continuity. It is the secure and - adaptable OS for long-term supported, innovation-ready infrastructure running - business-critical workloads on-premises, in the cloud, and at the edge." -icon: SUSE.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: - ca: Una opció de servidor de Linux oberta, fiable, compatible i a prova del - futur que garanteix la continuïtat del negoci de l'empresa. És el sistema - operatiu segur i adaptable per a una infraestructura amb suport a llarg - termini i preparada per a la innovació que executa càrregues de treball - crítiques per a l'empresa a les instal·lacions, al núvol i a l'última. - cs: Otevřená, spolehlivá, kompatibilní a perspektivní volba linuxového serveru, - která zajišťuje kontinuitu podnikání podniku. Je to bezpečný a - přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu - připravenou na inovace, na které běží kritické podnikové úlohy v lokálním - prostředí, v cloudu i na okraji sítě. - de: Ein offener, zuverlässiger, kompatibler und zukunftssicherer Linux-Server, - der die Geschäftskontinuität des Unternehmens gewährleistet. Es ist das - sichere und anpassungsfähige Betriebssystem für eine langfristig - unterstützte, innovationsbereite Infrastruktur, auf der geschäftskritische - Arbeitslasten vor Ort, in der Cloud und am Netzwerkrand ausgeführt werden. - es: Una opción de servidor Linux abierta, confiable, compatible y preparada para - el futuro que garantiza la continuidad del negocio de la empresa. Es el - sistema operativo seguro y adaptable para una infraestructura lista para - la innovación y con soporte a largo plazo que ejecuta cargas de trabajo - críticas para el negocio en las instalaciones, en la nube y en el borde. - ja: オープンで信頼性が高く、各種の標準にも準拠し、将来性とビジネスの継続性を支援する Linux - サーバです。長期のサポートが提供されていることから、安全性と順応性に優れ、オンプレミスからクラウド、エッジ環境に至るまで、様々な場所で重要なビジネス処理をこなすことのできる革新性の高いインフラストラクチャです。 - pt_BR: Uma escolha de servidor Linux aberta, confiável, compatível e à prova do - futuro que garante a continuidade dos negócios da empresa. É o SO seguro e - adaptável para infraestrutura com suporte de longo prazo e pronta para - inovação, executando cargas de trabalho críticas para os negócios no - local, na nuvem e na borda. - sv: Ett öppet, pålitligt, kompatibelt och framtidssäkert Linux-serverval som - säkerställer företagets affärskontinuitet. Det är det säkra och - anpassningsbara operativsystemet för långsiktigt stödd, innovationsfärdig - infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet - och vid kanten. - tr: İşletmenin iş sürekliliğini garanti eden açık, güvenilir, uyumlu ve geleceğe - dönük bir Linux Sunucu seçeneği. Uzun vadeli desteklenen, inovasyona hazır - altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, - bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. -software: - installation_repositories: [] - installation_labels: - - label: SLES160-x86_64 - archs: x86_64 - - label: SLES160-arch64 - archs: aarch64 - - label: SLES160-s390x - archs: s390 - - label: SLES160-ppc64 - archs: ppc - - mandatory_patterns: - - enhanced_base - - bootloader - optional_patterns: null # no optional pattern shared - user_patterns: - - cockpit - - sles_sap_minimal_sap - - fips - - name: selinux - selected: true - - documentation - - sw_management - - container_runtime_docker - - container_runtime_podman - - dhcp_dns_server - - directory_server - - file_server - - gateway_server - - kvm_server - - kvm_tools - - lamp_server - - mail_server - - gnome - - gnome_internet - - devel_basis - - devel_kernel - - oracle_server - - print_server - mandatory_packages: - - NetworkManager - # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier - - NetworkManager-config-server - - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model - optional_packages: null - base_product: SLES - -security: - lsm: selinux - available_lsms: - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "swap" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: false - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - auto_size: - base_min: 5 GiB - base_max: 15 GiB - snapshots_increment: 150% - max_fallback_for: - - "/home" - snapshots_configurable: true - - mount_path: "swap" - filesystem: swap - size: - min: 1 GiB - max: 2 GiB - outline: - required: false - filesystems: - - swap - - mount_path: "/home" - filesystem: xfs - size: - auto: false - min: 5 GiB - max: unlimited - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - filesystem: xfs - size: - auto: false - min: 512 MiB - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - vfat diff --git a/rust/agama-software/test/share/products.d/sles_sap_160.yaml b/rust/agama-software/test/share/products.d/sles_sap_160.yaml deleted file mode 100644 index a11ff9bc9a..0000000000 --- a/rust/agama-software/test/share/products.d/sles_sap_160.yaml +++ /dev/null @@ -1,174 +0,0 @@ -id: SLES_SAP -name: SUSE Linux Enterprise Server for SAP applications 16.0 -archs: x86_64,ppc -registration: true -version: "16.0" -license: "license.final" -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: "The leading OS for a secure and reliable SAP platform. - Endorsed for SAP deployments, SUSE Linux Enterprise Server for SAP applications - futureproofs the SAP project, offers uninterrupted business, and minimizes - operational risks and costs." -icon: SUSE.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: -software: - installation_repositories: [] - installation_labels: - - label: S4SAP160-x86_64 - archs: x86_64 - - label: S4SAP160-ppc64 - archs: ppc - - mandatory_patterns: - - base - - enhanced_base - - bootloader - - sles_sap_base_sap_server - optional_patterns: null # no optional pattern shared - user_patterns: - # First all patterns from file sles_160.yaml - - cockpit - - sles_sap_minimal_sap - - fips - - name: selinux - selected: true - - documentation - - sw_management - - container_runtime_docker - - container_runtime_podman - - dhcp_dns_server - - directory_server - - file_server - - gateway_server - - kvm_server - - kvm_tools - - lamp_server - - mail_server - - gnome - - gnome_internet - - devel_basis - - devel_kernel - - oracle_server - - print_server - # Second, all patterns for SAP only - - sles_sap_DB - - sles_sap_HADB - - sles_sap_APP - - sles_sap_HAAPP - - sles_sap_trento_server - - sles_sap_trento_agent - - sles_sap_automation - - sles_sap_monitoring - - sles_sap_gui - mandatory_packages: - - NetworkManager - # bsc#1241224, bsc#1224868 avoid probe DHCP over all ethernet devices and ignore carrier - - NetworkManager-config-server - - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model - optional_packages: null - base_product: SLES_SAP - -security: - lsm: selinux - available_lsms: - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - space_policy: delete - volumes: - - "/" - - "swap" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: false - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - auto_size: - base_min: 5 GiB - base_max: 15 GiB - snapshots_increment: 150% - max_fallback_for: - - "/home" - snapshots_configurable: true - - mount_path: "swap" - filesystem: swap - size: - min: 1 GiB - max: 2 GiB - outline: - required: false - filesystems: - - swap - - mount_path: "/home" - filesystem: xfs - size: - auto: false - min: 5 GiB - max: unlimited - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - filesystem: xfs - size: - auto: false - min: 512 MiB - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - vfat diff --git a/rust/agama-software/test/share/products.d/slowroll.yaml b/rust/agama-software/test/share/products.d/slowroll.yaml deleted file mode 100644 index 9ff192fd4a..0000000000 --- a/rust/agama-software/test/share/products.d/slowroll.yaml +++ /dev/null @@ -1,169 +0,0 @@ -id: Slowroll -name: Slowroll -archs: x86_64 -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: 'An experimental and slightly slower rolling release of openSUSE - designed to update less often than Tumbleweed but more often than Leap without - forcing users to choose between "stable" and newer packages.' -icon: Slowroll.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: - ca: Una versió experimental d'openSUSE però lleugerament més lenta quant a la - continuïtat, dissenyada per actualitzar-se amb menys freqüència que el - Tumbleweed però més sovint que el Leap, sense obligar els usuaris a triar - entre paquets estables i nous. - cs: Experimentální a mírně zpomalené rolující vydání openSUSE, které je navržené - tak, aby se aktualizovalo méně často než Tumbleweed. Zároveň se však - aktualizuje častěji než Leap, aby se uživatelé nemuseli rozhodovat mezi - "stabilními" a novějšími balíčky. - de: Ein experimentelles und etwas langsameres Rolling Release von openSUSE, das - darauf ausgelegt ist, weniger häufig als Tumbleweed, aber häufiger als - Leap zu aktualisieren, ohne die Benutzer zu zwingen, zwischen „stabilen“ - und neueren Paketen zu wählen. - es: Una versión experimental y de actualización contínua ligeramente más lenta - de openSUSE, diseñada para actualizarse con menos frecuencia que - Tumbleweed pero más a menudo que Leap, sin obligar a los usuarios a elegir - entre paquetes "estables" y más nuevos. - ja: 実験的なディストリビューションではありますが、 Tumbleweed よりは比較的ゆっくりした、かつ Leap よりは速いペースで公開される - openSUSE ローリングリリース型ディストリビューションです。 "安定性" と最新パッケージの中間を目指しています。 - pt_BR: Uma versão experimental e um pouco mais lenta do openSUSE, projetada para - atualizar com menos frequência que o Tumbleweed, mas com mais frequência - que o Leap, sem forçar os usuários a escolher entre pacotes "estáveis" e - mais novos. - sv: En experimentell och något långsammare rullande utgåva av openSUSE utformad - för att få nya paketuppdateringar mer sällan än Tumbleweed men oftare än - Leap utan att tvinga användarna att välja mellan "stabila" eller nyare - paket. -software: - installation_repositories: - - url: https://download.opensuse.org/slowroll/repo/oss/ - archs: x86_64 - - url: https://download.opensuse.org/slowroll/repo/non-oss/ - archs: x86_64 - - mandatory_patterns: - - enhanced_base - optional_patterns: null - user_patterns: - - basic-desktop - - gnome - - kde - - yast2_basis - - yast2_desktop - - yast2_server - - multimedia - - office - mandatory_packages: - - NetworkManager - - openSUSE-repos-Slowroll - - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model - optional_packages: null - base_product: openSUSE - -security: - lsm: apparmor - available_lsms: - apparmor: - patterns: - - apparmor - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - boot_strategy: BLS - space_policy: delete - volumes: - - "/" - - "swap" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: false - default_subvolume: "0" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - auto_size: - base_min: 5 GiB - base_max: 15 GiB - snapshots_increment: 250% - max_fallback_for: - - "/home" - snapshots_configurable: true - - mount_path: "swap" - filesystem: swap - size: - min: 1 GiB - max: 2 GiB - outline: - required: false - filesystems: - - swap - - mount_path: "/home" - filesystem: xfs - size: - auto: false - min: 5 GiB - max: unlimited - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - filesystem: xfs - size: - auto: false - min: 512 MiB - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - vfat diff --git a/rust/agama-software/test/share/products.d/tumbleweed.yaml b/rust/agama-software/test/share/products.d/tumbleweed.yaml deleted file mode 100644 index 561ff8aea7..0000000000 --- a/rust/agama-software/test/share/products.d/tumbleweed.yaml +++ /dev/null @@ -1,224 +0,0 @@ -id: Tumbleweed -name: openSUSE Tumbleweed -# ------------------------------------------------------------------------------ -# WARNING: When changing the product description delete the translations located -# at the at translations/description key below to avoid using obsolete -# translations!! -# ------------------------------------------------------------------------------ -description: 'A pure rolling release version of openSUSE containing the latest - "stable" versions of all software instead of relying on rigid periodic release - cycles. The project does this for users that want the newest stable software.' -icon: Tumbleweed.svg -# Do not manually change any translations! See README.md for more details. -translations: - description: - ca: Una versió de llançament continuada d'openSUSE que conté les darreres - versions estables de tot el programari en lloc de dependre de cicles de - llançament periòdics rígids. El projecte fa això per als usuaris que volen - el programari estable més nou. - cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze - veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. - Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. - de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ - Versionen der gesamten Software enthält, anstatt sich auf starre - periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für - Benutzer, die die neueste, stabile Software wünschen. - es: Una versión de actualización continua pura de openSUSE que contiene las - últimas versiones "estables" de todo el software en lugar de depender de - rígidos ciclos de publicaciones periódicas. El proyecto hace esto para - usuarios que desean el software estable más novedoso. - fr: La distribution Tumbleweed est une pure "rolling release" (publication - continue) d'openSUSE contenant les dernières versions "stables" de tous - les logiciels au lieu de se baser sur des cycles de publication - périodiques et fixes. Le projet fait cela pour les utilisateurs qui - veulent les logiciels stables les plus récents. - id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE - yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak - bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk - memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil - terbaru. - ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" - バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 - nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av - openSUSE som inneholder de siste "stabile" versjonene av all programvare i - stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet - gjør dette for brukere som vil ha de nyeste stabile programvarene. - pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas - versões "estáveis" de todos os softwares em vez de depender de ciclos de - lançamento periódicos rígidos. O projeto faz isso para usuários que querem - o software estável mais novo. - ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние - "стабильные" версии всего программного обеспечения, вместо того чтобы - полагаться на жесткие периодические циклы выпуска. Проект делает его для - пользователей, которым нужно самое новое стабильное программное - обеспечение. - sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" - versionerna av all programvara istället för att förlita sig på stela - periodiska släppcykler. Projektet gör detta för användare som vill ha den - senaste stabila mjukvaran. - tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son - "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje - bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. - zh_Hans: Tumbleweed 发行版是 openSUSE - 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 -software: - installation_repositories: - - url: https://download.opensuse.org/tumbleweed/repo/oss/ - archs: x86_64 - - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ - archs: aarch64 - - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ - archs: ppc - - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ - archs: x86_64 - - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ - archs: aarch64 - - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ - archs: ppc - - url: https://download.opensuse.org/update/tumbleweed/ - archs: x86_64 - - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ - archs: aarch64 - - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ - archs: s390 - - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ - archs: ppc - # device labels for offline installation media - installation_labels: - - label: openSUSE-Tumbleweed-DVD-x86_64 - archs: x86_64 - - label: openSUSE-Tumbleweed-DVD-aarch64 - archs: aarch64 - - label: openSUSE-Tumbleweed-DVD-s390x - archs: s390 - - label: openSUSE-Tumbleweed-DVD-ppc64le - archs: ppc - mandatory_patterns: - - enhanced_base # only pattern that is shared among all roles on TW - optional_patterns: null # no optional pattern shared - user_patterns: - - basic_desktop - - xfce - - kde - - gnome - - yast2_basis - - yast2_desktop - - yast2_server - - multimedia - - office - - name: selinux - selected: true - - apparmor - mandatory_packages: - - NetworkManager - - openSUSE-repos-Tumbleweed - - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model - optional_packages: null - base_product: openSUSE - -security: - lsm: selinux - available_lsms: - apparmor: - patterns: - - apparmor - selinux: - patterns: - - selinux - none: - patterns: null - -storage: - boot_strategy: BLS - space_policy: delete - volumes: - - "/" - - "swap" - volume_templates: - - mount_path: "/" - filesystem: btrfs - btrfs: - snapshots: true - read_only: false - default_subvolume: "@" - subvolumes: - - path: home - - path: opt - - path: root - - path: srv - - path: usr/local - # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html - - path: var - copy_on_write: false - # Architecture specific subvolumes - - path: boot/grub2/arm64-efi - archs: aarch64 - - path: boot/grub2/arm-efi - archs: arm - - path: boot/grub2/i386-pc - archs: x86_64 - - path: boot/grub2/powerpc-ieee1275 - archs: ppc,!board_powernv - - path: boot/grub2/s390x-emu - archs: s390 - - path: boot/grub2/x86_64-efi - archs: x86_64 - - path: boot/grub2/riscv64-efi - archs: riscv64 - size: - auto: true - outline: - required: true - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - auto_size: - base_min: 5 GiB - base_max: 15 GiB - snapshots_increment: 250% - max_fallback_for: - - "/home" - snapshots_configurable: true - - mount_path: "swap" - filesystem: swap - size: - min: 1 GiB - max: 2 GiB - outline: - required: false - filesystems: - - swap - - mount_path: "/home" - filesystem: xfs - size: - auto: false - min: 5 GiB - max: unlimited - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - filesystem: xfs - size: - auto: false - min: 512 MiB - outline: - required: false - filesystems: - - btrfs - - ext2 - - ext3 - - ext4 - - xfs - - vfat diff --git a/rust/test/share/eula/license.final/license.es.txt b/rust/test/share/eula/license.final/license.es.txt new file mode 100644 index 0000000000..a00e57fe5c --- /dev/null +++ b/rust/test/share/eula/license.final/license.es.txt @@ -0,0 +1,295 @@ +Acuerdo de licencia de usuario final +del software de SUSE + + +Acuerdo de licencia de usuario final del software de SUSE +LEA ESTE ACUERDO ATENTAMENTE. AL DESCARGAR, INSTALAR O ADQUIRIR DE +CUALQUIER OTRO MODO EL SOFTWARE (COMO SE DEFINE MÁS ABAJO E +INCLUIDOS SUS COMPONENTES), ESTARÁ ACEPTANDO LOS TÉRMINOS DE ESTE +ACUERDO. SI NO ESTÁ CONFORME CON ESTOS TÉRMINOS, NO TENDRÁ +AUTORIZACIÓN PARA DESCARGAR, INSTALAR NI UTILIZAR EL SOFTWARE. SI +UNA PERSONA ACTÚA EN NOMBRE DE UNA ENTIDAD, SE DETERMINA QUE ESA +PERSONA TIENE LA AUTORIDAD PARA ACEPTAR ESTE ACUERDO EN NOMBRE DE +DICHA ENTIDAD. + +SUSE LLC (el "Licenciador" o "SUSE") pone a disposición del usuario +los productos de software, que son una recopilación: (i) programas +de software desarrollados por SUSE y sus afiliados; (ii) programas +de software desarrollados por terceros; (iii) marcas comerciales +propiedad de SUSE y/o sus filiales ("Marcas de SUSE"); y (iv) los +medios o reproducciones (físicos o virtuales) y la documentación +adjunta que acompañe a dichos programas de software (la recopilación +de programas, marcas comerciales y documentación se denomina +conjuntamente como el "Software"). + +El Software está protegido por las leyes y los tratados de derechos +de autor de Estados Unidos y por las leyes de derechos de autor de +otros países. Este Acuerdo de licencia de usuario final ("EULA") es +un acuerdo legal entre el Usuario (una entidad o una persona) y SUSE +que rige el uso del Software. Si las leyes de la ubicación principal +del negocio del Usuario requieren que los contratos estén en el +idioma local para ser ejecutables, dicha versión en el idioma local +se puede obtener del Licenciador previa solicitud por escrito y se +considerará que rige el uso que haga el Usuario del Software. +Cualquier complemento, extensión, actualización, aplicación móvil, +módulo, adaptador o versión de asistencia del Software que pueda +descargar o recibir el Usuario y no esté acompañado por un acuerdo +de licencia que reemplace expresamente al presente, se considerará +como Software y se regirá por este EULA. + +Términos de la licencia +Código abierto +El Software contiene muchos componentes individuales que son +software de código abierto, y la licencia de código abierto de cada +componente, que según el programa de software puede ser la Licencia +pública general de GNU versión 2 +(https://www.gnu.org/licenses/oldlicenses/gpl-2.0.en.html) o Apache +2.0 (https://www.apache.org/licenses/LICENSE-2.0) u otra licencia de +código abierto (cada una de estas licencias se denomina "Licencia de +código abierto"). Estas Licencias de código abierto se encuentran en +la documentación y/o en el código fuente del componente. + +Este EULA rige el uso del Software, incluidas las Marcas de SUSE, y +no limita, sustituye ni modifica los derechos del Usuario expresados +en la Licencia de código abierto aplicable al uso de cualquier +código de código abierto incluido en el Software sin las Marcas de +SUSE. + +El Software puede incluir o estar incluido en un paquete con otros +programas de software cuya licencia contenga términos distintos o +haya sido otorgada por otros fabricantes distintos al Licenciador. +El uso de cualquier programa de software acompañado de un acuerdo de +licencia independiente se regirá por dicho acuerdo de licencia. + +Licencia para utilizar el Software +Siempre que se cumplan los términos y condiciones de este EULA, el +Licenciador otorga al Usuario una licencia mundial perpetua, no +exclusiva, no transferible y revocable para reproducir y usar copias +del Software dentro de su Organización para uso interno en la +Organización del Usuario. "Organización" significa una entidad legal +y sus Afiliadas. "Afiliadas" hace referencia a las entidades que +controla el Usuario, las que tienen control sobre el Usuario y las +que están bajo control común del Usuario. La licencia anterior está +condicionada a que el Usuario sea responsable de cualquier +incumplimiento de las disposiciones de este EULA por parte de sus +Afiliadas. + +Este EULA no le permite distribuir el Software o sus componentes que +usen las marcas de SUSE aunque la copia haya sido modificada. El +Usuario puede realizar una redistribución fuera de su Organización: +(a) del Software, solo si se permite en virtud de un acuerdo por +escrito independiente con el Licenciador que autorice dicha +redistribución, o (b) de los componentes que constituyen el +Software, solo si el Usuario elimina y reemplaza todas las +apariciones de cualquier Marca de SUSE. + +Si el Usuario ha recibido de SUSE, ya sea directa o indirectamente, +hardware, software u otro dispositivo que utilice o integre el +Software, puede utilizar el Software únicamente con el fin de +ejecutar dicho hardware, software o dispositivo, y no de forma +independiente. + +Propiedad +No se le transfiere ningún título o propiedad del Software. El +Licenciador y sus licenciadores terceros mantienen íntegramente el +derecho, la titularidad y el interés sobre todos los derechos de +propiedad intelectual especificados en el Software, incluidas sus +copias o adaptaciones. El Software no se le vende al Usuario, el +Usuario adquiere únicamente una licencia condicional de uso del +Software. La titularidad, los derechos de propiedad y los derechos +de propiedad intelectual del contenido al que se accede a través del +Software son propiedad de los propietarios del contenido aplicable y +deben estar protegidos por derechos de autor u otras leyes +aplicables. Este EULA no da derecho alguno al Usuario sobre dicho +contenido. + +Marcas de SUSE +En virtud de este EULA no se otorga ningún derecho o licencia, ni +expreso ni implícito, para utilizar cualquier Marca de SUSE, nombre +comercial o marca de servicio del Licenciador o sus afiliados ni +licenciadores de otro modo que no sea necesario para utilizar el +Software según lo permitido por este EULA + +Servicios de suscripciones y Asistencia técnica +El Licenciador no tiene la obligación de proporcionar mantenimiento +o asistencia a menos que el Usuario adquiera una oferta de +suscripción, de conformidad con un contrato adicional con el +Licenciador o sus afiliados, que incluya expresamente dichos +servicios. + +Garantía y responsabilidad +Garantía limitada +El Licenciador garantiza que el medio en el que se entrega el +software está libre de defectos en materiales y manufacturado bajo +un uso normal para un periodo de sesenta (60) días desde la fecha de +entrega. LA ANTERIOR GARANTÍA ES LA ÚNICA Y EXCLUSIVA COMPENSACIÓN +DEL USUARIO Y SUSTITUYE A CUALQUIER OTRA GARANTÍA, YA SEA EXPLÍCITA +O IMPLÍCITA. SALVO POR LA PRESENTE GARANTÍA, EL SOFTWARE SE ENTREGA +"TAL CUAL", SIN GARANTÍA DE NINGÚN TIPO. +EL SOFTWARE NO ESTÁ DISEÑADO, FABRICADO NI PREVISTO PARA SU USO O +DISTRIBUCIÓN, Y NO SE DEBEN USAR, CON EQUIPOS DE CONTROL EN LÍNEA EN +ENTORNOS PELIGROSOS QUE REQUIERAN UN RENDIMIENTO A PRUEBA DE FALLOS, +COMO EL FUNCIONAMIENTO DE INSTALACIONES NUCLEARES, SISTEMAS DE +NAVEGACIÓN, COMUNICACIONES O CONTROL DE AVIONES, EQUIPOS DE SOPORTE +VITAL DIRECTO, SISTEMAS DE ARMAMENTO O CUALQUIER OTRO USO EN EL QUE +LOS FALLOS EN EL SOFTWARE PUEDAN PROVOCAR DIRECTAMENTE MUERTES, +DAÑOS PERSONALES O FÍSICOS O AL MEDIOAMBIENTE DE GRAVEDAD. +Productos que no sean del Licenciador +El Software puede incluir hardware u otros programas de software o +servicios, o bien formar parte de estos, que hayan sido vendidos o +cuya licencia haya sido otorgada por otra entidad distinta del +Licenciador. EL LICENCIADOR NO GARANTIZA LOS PRODUCTOS O SERVICIOS +NO PERTENECIENTES AL MISMO. ESTOS PRODUCTOS O SERVICIOS SE +DISTRIBUYEN "TAL CUAL". CUALQUIER SERVICIO DE GARANTÍA PARA LOS +PRODUCTOS NO PERTENECIENTES AL LICENCIADOR SERÁ PRESTADO POR EL +LICENCIADOR DEL PRODUCTO, DE CONFORMIDAD CON LO DISPUESTO EN LA +GARANTÍA DEL LICENCIADOR CORRESPONDIENTE. +CON LA EXCEPCIÓN DE LAS RESTRICCIONES LEGALES, EL LICENCIADOR +RECHAZA Y EXCLUYE TODAS LAS GARANTÍAS IMPLÍCITAS, INCLUIDAS LAS +GARANTÍAS DE COMERCIALIZACIÓN, IDONEIDAD PARA UN PROPÓSITO +PARTICULAR, TÍTULO O NO INFRACCIÓN; ASIMISMO TAMPOCO EXISTEN +GARANTÍAS CREADAS EN EL TRASCURSO DE LA NEGOCIACIÓN, EL RENDIMIENTO +O EL USO COMERCIAL. EL LICENCIADOR NO OFRECE NINGUNA GARANTÍA, +REPRESENTACIÓN NI PROMESA NO INCLUIDA DE FORMA EXPLÍCITA EN ESTA +GARANTÍA LIMITADA. EL LICENCIADOR NO GARANTIZA QUE EL SOFTWARE O LOS +SERVICIOS SATISFAGAN LAS NECESIDADES DEL USUARIO, SEAN COMPATIBLES +CON TODOS LOS SISTEMAS OPERATIVOS, O QUE EL FUNCIONAMIENTO DEL +SOFTWARE O LOS SERVICIOS SEA ININTERRUMPIDO O ESTÉ LIBRE DE ERRORES. +LAS EXCLUSIONES Y RENUNCIAS ANTERIORES SON UNA PARTE ESENCIAL DE +ESTE ACUERDO. Algunas jurisdicciones no permiten ciertas exclusiones +y limitaciones de garantías, por lo que algunas de las limitaciones +anteriores pueden no ser aplicables en el caso del Usuario. Esta +garantía limitada le otorga al Usuario derechos específicos. Además, +es posible que le asistan otros derechos, que pueden variar en +función del estado o la jurisdicción. +Limitación de responsabilidad +NI EL LICENCIADOR, NI SUS LICENCIADORES TERCEROS, SUBSIDIARIOS O +EMPLEADOS SERÁN RESPONSABLES EN FORMA ALGUNA DE NINGÚN DAÑO +CONSECUENTE O INDIRECTO, YA SE BASE EN UN CONTRATO, NEGLIGENCIA, +AGRAVIO U OTRA TEORÍA DE RESPONSABILIDAD, NI DE NINGUNA PÉRDIDA DE +BENEFICIOS, NEGOCIO O PÉRDIDA DE DATOS, INCLUSO AUNQUE SE LES +ADVIERTA DE LA POSIBILIDAD DE DICHOS DAÑOS. + +EN CASO DE QUE SE PRODUZCA, EN NINGÚN CASO LA RESPONSABILIDAD +CONJUNTA DEL LICENCIADOR EN RELACIÓN CON ESTE EULA (YA SEA EN UNA +INSTANCIA O EN UNA SERIE DE INSTANCIAS) EXCEDERÁ LA CANTIDAD PAGADA +POR EL USUARIO POR EL SOFTWARE (O 50 DÓLARES DE ESTADOS UNIDOS SI EL +USUARIO NO PAGÓ EL SOFTWARE), DURANTE LOS 12 MESES ANTERIORES A LA +PRIMERA RECLAMACIÓN AMPARADA POR ESTE EULA. +Las exclusiones y limitaciones anteriores no serán de aplicación a +las reclamaciones relacionadas con la muerte o daños personales +causados por la negligencia del Licenciador o de sus empleados, +agentes o contratistas. En las jurisdicciones donde no se permita +la exclusión o limitación de daños y perjuicios, incluyendo, sin +limitación, daños por incumplimiento de cualquiera de las +condiciones implícitas en cuanto al título o disfrute pacífico de +cualquier software obtenido de conformidad con el presente EULA o +por mala interpretación fraudulenta, la responsabilidad del +Licenciador se limitará o excluirá hasta el máximo permitido en +dichas jurisdicciones. + +Condiciones generales +Duración +Este EULA entrará en vigor en la fecha en que el Usuario descargue +el Software y finalizará automáticamente si el Usuario incumple +alguno de sus términos. +Transferencia +Este EULA no se puede transferir ni ceder sin el consentimiento +previo por escrito del Licenciador. Cualquier intento de cesión será +nulo y sin efecto alguno. +Legislación +Todas las cuestiones que surjan o estén relacionadas con el EULA se +regirán por las leyes de Estados Unidos y el estado de Nueva York, +excluyendo cualquier disposición de selección de fuero. Cualquier +pleito, acción o procedimiento que surja de este EULA o que esté +relacionado con él, solo podrá ser llevado ante un tribunal federal +de Estados Unidos o estatal de jurisdicción competente del estado de +Nueva York. Si una parte inicia procedimientos legales relacionados +con el EULA, la parte ganadora tendrá derecho a recuperar los +honorarios razonables de abogados. Sin embargo, si la ubicación +principal del negocio del Usuario se encuentra en un estado miembro +de la Unión Europea o de la Asociación Europea de Libre Comercio, +(1) los tribunales de Inglaterra y Gales tendrán jurisdicción +exclusiva sobre cualquier acción legal relacionada con este EULA y +(2) se aplicarán las leyes de Inglaterra excepto cuando sea +obligatorio que las leyes del país de dicha ubicación principal del +negocio se apliquen a cualquier acción legal, en cuyo caso se +aplicarán las leyes de ese país. Ni la Convención de las Naciones +Unidas sobre los Contratos para la Venta Internacional de +Mercaderías ni las reglas de conflicto de leyes de Nueva York o +Inglaterra y Gales se aplican a este EULA o su contenido. +Acuerdo completo +Este EULA, junto con cualquier otro documento de compra u otro +acuerdo entre el Usuario y el Licenciador o sus Afiliadas, +constituye la totalidad del entendimiento y acuerdo entre el Usuario +y el Licenciador y solo puede ser enmendado o modificado mediante un +acuerdo por escrito firmado por el Usuario y un representante +autorizado del Licenciador. NINGÚN LICENCIADOR EXTERNO, +DISTRIBUIDOR, PROVEEDOR, MINORISTA, REVENDEDOR, COMERCIAL NI +EMPLEADO ESTÁ AUTORIZADO A MODIFICAR ESTE ACUERDO NI A REALIZAR +NINGUNA DECLARACIÓN NI PROMESA QUE CONTRADIGA O AMPLÍE LOS TÉRMINOS +DE ESTE ACUERDO. +Renuncia +Ninguna renuncia voluntaria a los derechos otorgados en virtud de +este EULA será efectiva, a menos que se realice por escrito y esté +firmada por un representante debidamente autorizado de la parte +vinculada. Ninguna renuncia voluntaria a derechos presentes o +pasados obtenidos como consecuencia de infracciones o +incumplimientos se considerará una renuncia voluntaria de ningún +derecho futuro que pueda emanar de este EULA. +Omisión +Si cualquier disposición de este EULA no es válida o no es +aplicable, se interpretará, limitará, modificará o, si es necesario, +recortará en la medida en que sea necesario para eliminar su falta +de validez o imposibilidad de aplicación. El resto de disposiciones +del EULA no se verán afectadas. +Cumplimiento de normativas de exportación +El Usuario reconoce que los productos y/o la tecnología del +Licenciador pueden estar sujetos a las Regulaciones de la +Administración de Exportación de Estados Unidos ("EAR") y a las +leyes comerciales de otros países. El Usuario se compromete a +cumplir con las EAR y con las leyes y normativas locales que puedan +ser aplicables y afectar al derecho del Usuario a importar, exportar +o utilizar los productos y/o la tecnología del Licenciador. El +Usuario no exportará ni reexportará productos del Licenciador, +directa o indirectamente, a: (1) entidades incluidas en las listas +de exclusión a las exportaciones de Estados Unidos o que estén +sometidas a embargos, ni a países que apoyen el terrorismo según se +especifica en las EAR, (2) cualquier usuario final que el Usuario +sepa o tenga razones para saber que utilizará los productos del +Licenciador en el diseño, desarrollo o producción de sistemas de +armas nucleares, químicas o biológicas, sistemas de cohetes, +lanzadores espaciales y cohetes de sondeo o vehículos aéreos no +tripulados, salvo autorización de la agencia pública relevante por +normativas o licencias específicas, o (3) cualquier usuario final al +que se haya prohibido participar en las operaciones de exportación +de los Estados Unidos por cualquier agencia federal del Gobierno de +Estados Unidos. El Usuario no utilizará los productos y/o la +tecnología del Licenciador para fines prohibidos aplicados a +armamento nuclear, misilístico o biológico, tal como se especifica +en las EAR. Al descargar o utilizar el Software, el Usuario está de +acuerdo con lo anterior, y afirma y garantiza que no se encuentra +en, bajo el control de un nacional o residente de dichos países o en +ninguna de dichas listas. Además, el Usuario es responsable de +cumplir con las leyes locales en su jurisdicción que puedan afectar +a su derecho a la importación, exportación o uso de productos del +Licenciador. Consulte la página Web de la Oficina de Industria y +Seguridad de Estados Unidos https://www.bis.doc.gov antes de +exportar productos sujetos al as EAR. Para obtener más información +sobre la exportación del Software, incluyendo el Número de +Clasificación de Control de la Exportación (ECCN) aplicable y la +excepción de licencia asociada (según corresponda), consulte: +https://www.suse.com/company/legal/. Previa solicitud, el +Departamento de Servicios de Comercio Internacional del Licenciador +puede proporcionar información con respecto a las restricciones de +exportación aplicables a los productos del Licenciador. El +Licenciador no asume ninguna responsabilidad en el caso de que no +pueda obtener las aprobaciones de exportación necesarias. + +:versión:2024-02-01:001 +SUSE.com + +Copyright (c) SUSE 2024 + +SUSE Legal +Febrero de 2024 diff --git a/rust/test/share/eula/license.final/license.txt b/rust/test/share/eula/license.final/license.txt new file mode 100644 index 0000000000..5e9b4affe4 --- /dev/null +++ b/rust/test/share/eula/license.final/license.txt @@ -0,0 +1,263 @@ +End User License Agreement +for SUSE Software + + +End User License Agreement for SUSE Software +PLEASE READ THIS AGREEMENT CAREFULLY. BY PURCHASING, INSTALLING, +DOWNLOADING OR OTHERWISE USING THE SOFTWARE (AS DEFINED BELOW AND +INCLUDING ITS COMPONENTS), YOU AGREE TO THE TERMS OF THIS AGREEMENT. +IF YOU DO NOT AGREE WITH THESE TERMS, YOU ARE NOT PERMITTED TO +DOWNLOAD, INSTALL OR USE THE SOFTWARE. AN INDIVIDUAL ACTING ON +BEHALF OF AN ENTITY REPRESENTS THAT HE OR SHE HAS THE AUTHORITY TO +ENTER INTO THIS AGREEMENT ON BEHALF OF THAT ENTITY. + +SUSE LLC ("Licensor" or "SUSE") makes available software products, +being a compilation of: (i) software programs developed by SUSE and +is affiliates; (ii) software programs developed by third parties; +(iii) trade marks owned by SUSE and/or its affiliates ("SUSE +Marks"); and (iv) media or reproductions (physical or virtual) and +accompanying documentation accompanying such software programs (such +compilation of programs, trade marks and documentation being the +"Software"). + +The Software is protected by the copyright laws and treaties of the +United States and copyright laws in other countries worldwide. This +End User License Agreement ("EULA") is a legal agreement between You +(an entity or a person) and SUSE governing Your use of the Software. +If the laws of Your principal place of business require contracts to +be in the local language to be enforceable, such local language +version may be obtained from Licensor upon written request and shall +be deemed to govern Your use of the Software. Any add-on, extension, +update, mobile application, module, adapter or support release to +the Software that You may download or receive that is not +accompanied by a license agreement is Software and is governed by +this EULA. + +License Terms +Open Source +The Software contains many individual components that are open +source software and the open source license for each component, +which, depending on the software program, may be the GNU General +Public License v.2 +(https://www.gnu.org/licenses/oldlicenses/gpl-2.0.en.html) or Apache +2.0 (https://www.apache.org/licenses/LICENSE-2.0) or other open +source license (each such license being the "OSS License"), is +located in the licensing documentation and/or in the component's +source code. + +This EULA governs Your use of the Software, including SUSE Marks, +and does not limit, supersede or modify your rights under the OSS +License applicable to Your use of any open source code contained in +the Software without the SUSE Marks. + +The Software may include or be bundled with other software programs +licensed under different terms and/or licensed by a third party +other than Licensor. Use of any software programs accompanied by a +separate license agreement is governed by that separate license +agreement. + +License to use the Software +Subject to compliance with the terms and conditions of this EULA, +Licensor grants to You a perpetual, non- exclusive, +non-transferable, revocable, worldwide license to reproduce and use +copies of the Software within Your Organization for Your +Organization's internal use. "Organization" means a legal entity and +its Affiliates. "Affiliates" means entities that control, are +controlled by, or are under common control with You. The above +license is conditioned upon You being responsible and liable for any +breach of the provisions of this EULA by Your Affiliates. + +This EULA does not permit you to distribute the Software or its +components using the SUSE Marks regardless of whether the copy has +been modified. You may make a redistribution outside of Your +Organization: (a) of the Software, only if permitted under a +separate written agreement with Licensor authorizing such +redistribution, or (b) of the constituent components of the +Software, only if You remove and replace all occurrences of any SUSE +Mark. + +If You have received, whether directly or indirectly from SUSE, +hardware, software or other appliance that uses or embeds the +Software, You may use the Software solely for the purpose of running +that hardware, software or appliance and not on a stand-alone basis. + +Ownership +No title to or ownership of the Software is transferred to You. +Licensor and/or its third party licensors retain all right, title +and interest in and to all intellectual property rights in the +Software, including any adaptations or copies thereof. The Software +is not sold to You, You acquire only a conditional license to use +the Software. Title, ownership rights and intellectual property +rights in and to the content accessed through the Software are the +property of the applicable content owner and may be protected by +applicable copyright or other law. This EULA gives You no rights to +such content. + +SUSE Marks +No right or license, express or implied, is granted under this EULA +to use any SUSE Mark, trade name or service mark of Licensor or its +affiliates or licensors otherwise than is necessary to use the +Software as permitted by this EULA. + +Subscription Services and Support +Licensor has no obligation to provide maintenance or support unless +You purchase a subscription offering, pursuant to an additional +contract with Licensor or its affiliates, which expressly includes +such services. + +Warranty and Liability +Limited Warranty +Licensor warrants that the media that the Software is delivered on +will be free from defects in materials and manufacture under normal +use for a period of sixty (60) days from the date of delivery to +you. THE FOREGOING WARRANTY IS YOUR SOLE AND EXCLUSIVE REMEDY AND IS +IN LIEU OF ALL OTHER WARRANTIES, EXPRESS OR IMPLIED. SAVE FOR THE +FOREGOING WARRANTY, THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY +WARRANTIES OF ANY KIND. +THE SOFTWARE IS NOT DESIGNED, MANUFACTURED OR INTENDED FOR USE OR +DISTRIBUTION WITH, AND MUST NOT BE USED FOR, ON-LINE CONTROL +EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE PERFORMANCE, +SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT NAVIGATION, +COMMUNICATION, OR CONTROL SYSTEMS, DIRECT LIFE SUPPORT MACHINES, +WEAPONS SYSTEMS, OR OTHER USES IN WHICH FAILURE OF THE SOFTWARE +COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE PHYSICAL OR +ENVIRONMENTAL DAMAGE. +Non-Licensor Products +The Software may include or be bundled with hardware or other +software programs or services licensed or sold by an entity other +than Licensor. LICENSOR DOES NOT WARRANT NON-LICENSOR PRODUCTS OR +SERVICES. ANY SUCH PRODUCTS OR SERVICES ARE PROVIDED ON AN "AS IS" +BASIS. WARRANTY SERVICE IF ANY FOR NON-LICENSOR PRODUCTS IS PROVIDED +BY THE PRODUCT LICENSOR IN ACCORDANCE WITH THEIR APPLICABLE +WARRANTY. +EXCEPT AS OTHERWISE RESTRICTED BY LAW, LICENSOR DISCLAIMS AND +EXCLUDES ANY AND ALL IMPLIED WARRANTIES INCLUDING ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR +NON-INFRINGEMENT NOR ARE THERE ANY WARRANTIES CREATED BY COURSE OF +DEALING, COURSE OF PERFORMANCE OR TRADE USAGE. LICENSOR MAKES NO +WARRANTY, REPRESENTATION OR PROMISE NOT EXPRESSLY SET FORTH IN THIS +LIMITED WARRANTY. LICENSOR DOES NOT WARRANT THAT THE SOFTWARE OR +SERVICES WILL SATISFY YOUR REQUIREMENTS, BE COMPATIBLE WITH ALL +OPERATING SYSTEMS, OR THAT THE OPERATION OF THE SOFTWARE OR SERVICES +WILL BE UNINTERRUPTED OR ERROR-FREE. THE FOREGOING EXCLUSIONS AND +DISCLAIMERS ARE AN ESSENTIAL PART OF THIS AGREEMENT. Some +jurisdictions do not allow certain disclaimers and limitations of +warranties, so portions of the above limitations may not apply to +You. This limited warranty gives You specific rights and You may +also have other rights which vary by state or jurisdiction. +Limitation of Liability +NEITHER LICENSOR NOR ANY OF ITS THIRD PARTY LICENSORS, SUBSIDIARIES, +OR EMPLOYEES WILL IN ANY CASE BE LIABLE FOR ANY CONSEQUENTIAL OR +INDIRECT DAMAGES, WHETHER BASED ON CONTRACT, NEGLIGENCE, TORT OR +OTHER THEORY OF LIABILITY, OR FOR ANY LOSS OF PROFITS, BUSINESS OR +LOSS OR CORRUPTION OF DATA, IN EACH CASE, EVEN IF ADVISED OF THE +POSSIBILITY OF THOSE DAMAGES. + +IN NO EVENT WILL LICENSOR'S AGGREGATE LIABILITY UNDER OR IN +CONNECTION WITH THIS EULA (WHETHER IN ONE INSTANCE OR A SERIES OF +INSTANCES) EXCEED THE AMOUNT PAID BY YOU FOR THE SOFTWARE OUT OF +WHICH SUCH CLAIM AROSE (OR $50 (U.S.) IF YOU DID NOT PAY FOR THE +SOFTWARE), IN THE 12 MONTHS PRECEDING THE FIRST CLAIM UNDER THIS +EULA. +The above exclusions and limitations will not apply to claims +relating to death or personal injury caused by the negligence of +Licensor or its employees, agents or contractors. In those +jurisdictions that do not allow the exclusion or limitation of +damages, including, without limitation, damages for breach of any +implied terms as to title or quiet enjoyment of any Software +obtained pursuant to this EULA or for fraudulent misrepresentation, +Licensor's liability shall be limited or excluded to the maximum +extent allowed within those jurisdictions. + +General Terms +Term +This EULA becomes effective on the date You download the Software +and will automatically terminate if You breach any of its terms. +Transfer +This EULA may not be transferred or assigned without the prior +written approval of Licensor. Any such attempted transfer or +assignment shall be void and of no effect. +Law +All matters arising out of or relating to this EULA will be governed +by the substantive laws of the United States and the State ofNew +York without regard to its choice of law provisions. Any suit, +action or proceeding arising out of or relating to this EULA may +only be brought before a federal or state court of appropriate +jurisdiction in New York. If a party initiates EULA-related legal +proceedings, the prevailing party will be entitled to recover +reasonable attorneys' fees. If, however, Your principal place of +business is a member state of the European Union or the European +Free Trade Association, (1) the courts of England and Wales shall +have exclusive jurisdiction over any action of law relating to this +EULA; and (2) the laws of England shall apply except where the laws +of such country of Your principal place of business are required to +be applied to any such action of law, in which case the laws of that +country shall apply. Neither the United Nations Convention of +Contracts for the International Sale of Goods nor the New York or +England and Wales conflict of law rules apply to this EULA or its +subject matter. +Entire Agreement +This EULA, together with any other purchase documents or other +written agreement between You and Licensor or its Affiliates, sets +forth the entire understanding and agreement between You and +Licensor and may be amended or modified only by a written agreement +agreed to by You and an authorized representative of Licensor. NO +THIRD PARTY LICENSOR, DISTRIBUTOR, DEALER, RETAILER, RESELLER, SALES +PERSON, OR EMPLOYEE IS AUTHORIZED TO MODIFY THIS AGREEMENT OR TO +MAKE ANY REPRESENTATION OR PROMISE THAT IS DIFFERENT FROM, OR IN +ADDITION TO, THE TERMS OF THIS AGREEMENT. +Waiver +No waiver of any right under this EULA will be effective unless in +writing, signed by a duly authorized representative of the party to +be bound. No waiver of any past or present right arising from any +breach or failure to perform will be deemed to be a waiver of any +future right arising under this EULA. +Severability +If any provision in this EULA is invalid or unenforceable, that +provision will be construed, limited, modified or, if necessary, +severed, to the extent necessary, to eliminate its invalidity or +unenforceability, and the other provisions of this EULA will remain +unaffected. +Export Compliance +You acknowledge that Licensor's products and/or technology may be +subject to the U.S. Export Administration Regulations (the "EAR") +and the trade laws of other countries. You agree to comply with the +EAR and local laws and regulations which may be applicable to and +impact Your right to import, export or use Licensor's products +and/or technology. You will not export or re-export Licensor's +products, directly or indirectly, to (1) entities on the current +U.S. export exclusion lists or to any embargoed or terrorist +supporting countries as specified in the EAR; (2) any end user who +You know or have reason to know will utilize Licensor's products in +the design, development or production of nuclear, chemical or +biological weapons, or rocket systems, space launch vehicles, and +sounding rockets, or unmanned air vehicle systems, except as +authorized by the relevant government agency by regulation or +specific license; or (3) any end user who has been prohibited from +participating in the US export transactions by any federal agency of +the US government. You will not use Licensor's products and/or +technology for prohibited nuclear, missile, or chemical biological +weaponry end uses as specified in the EAR. By downloading or using +the Software, You are agreeing to the foregoing and You are +representing and warranting that You are not located in, under the +control of, or a national or resident of any such country or on any +such list. In addition, You are responsible for complying with any +local laws in Your jurisdiction which may impact Your right to +import, export or use Licensor's products. Please consult the Bureau +of Industry and Security web page https://www.bis.doc.gov before +exporting items subject to the EAR. For more information on +exporting Software, including the applicable Export Control +Classification Number (ECCN) and associated license exception (as +applicable), see https://www.suse.com/company/legal/. Upon request, +Licensor's International Trade Services Department can provide +information regarding applicable export restrictions for Licensor +products. Licensor assumes no responsibility for Your failure to +obtain any necessary export approvals. + +:version:2024-02-01:001 +SUSE.com + +Copyright (c) SUSE 2024 + +SUSE Legal +February 2024 diff --git a/rust/test/share/eula/license.final/license.zh_CN.txt b/rust/test/share/eula/license.final/license.zh_CN.txt new file mode 100644 index 0000000000..fd05df1624 --- /dev/null +++ b/rust/test/share/eula/license.final/license.zh_CN.txt @@ -0,0 +1,187 @@ +SUSE 软件 +最终用户许可协议 + + +SUSE 软件最终用户许可协议 +请仔细阅读本协议。一旦购买、安装、下载或以其他方式使用本软件(如下 +文定义,包括其组件),即表示您同意本协议的条款。如不同意以下条款, +您将不能下载、安装或使用本软件。代表某实体行事的个人表示其有权代表 +该实体签署本协议。 + +SUSE LLC(以下简称"许可证颁发者"或"SUSE")所提供的 +软件产品合集包含以下各项:(i) 由 SUSE 及其关联公司开发的 +软件程序;(ii) 第三方开发的软件程序;(iii) SUSE 及 +/或其关联公司拥有的商标(以下简称"SUSE 商标");以及 +(iv) 媒介或复制品(实体或虚拟格式)以及此类软件程序随附的相关 +文档(此类程序、商标和文档的合集统称为"软件")。 + +本软件受美国版权法和条约以及世界其他国家/地区版权法的保护。本最终 +用户许可协议(以下简称为"EULA")是您(作为个人或实体)与 +SUSE 之间就软件使用达成的法律协议。如果您所在的主要营业地的法 +律要求合同必须使用本地语言才能实施,则此类本地语言版本可按照书面请 +求从许可证颁发者处获得,并且应视为对您使用本软件的行为具有约束力。 +对于您可能下载或接收本软件的任何附加内容、扩展、更新、移动应用程序、 +模块、适配器或支持版本,如果没有随附许可协议,则均视为本软件并受 +本 EULA 的约束。 + +许可条款 +开放源代码 +本本软件包含许多独立组件,这些组件都是开源软件,每个组件的开源许可 +证(取决于软件程序)可能是 GNU 通用公共许可证 v.2 +(https://www.gnu.org/licenses/old +licenses/gpl-2.0.en.html) 或 +Apache 2.0 +(https://www.apache.org/licenses/ +LICENSE-2.0)或其他开放源代码许可(此类任一许可均 +为"OSS 许可"),位于许可文档和/或组件的开放源代码。 + +本 EULA 约束您使用本软件(包括 SUSE 商标)的权利,并不 +限制、替代或修改您根据 OSS 许可证对软件中的开放源代码(不包含 +SUSE 商标)的使用权利。 + +本软件可能包含或捆绑有其他软件程序,这些软件程序使用不同的条款许可, +并/或由许可证颁发者之外的第三方许可。使用任何附带有单独许可协议 +的软件程序的行为受该单独许可协议的约束。 + +本软件的使用许可证 +在遵守本 EULA 条款和条件的前提下,许可证颁发者授予您永久、非 +排他性、不可转让和可撤销的全球范围内的许可,允许在您组织内部复制和 +使用本软件的副本。"组织"指法律实体及其关联公司。"关联公司"指控 +制您、受您控制或受您共同控制的实体。上述许可的前提条件是,如果您的 +关联公司违反本 EULA 的任何条款,您将对此负有责任。 + +本 EULA 不允许您分发带有 SUSE 商标的软件或其组件,无论 +其副本有无改动。但在下列情况,您可以在您的组织范围外进行再分发: +(a) 只有在您与许可证颁发者签署的独立书面协议授权进行软件再分发 +的情况下,方可进行软件再分发;或 (b) 只有在您移除并替换所有 +SUSE 商标的情况下, 方可进行软件组件的再分发。 + +如果您直接或间接从 SUSE 收到使用或嵌入本软件的硬件、软件或其 +他设备,您只能将本软件用于运行该硬件、软件或设备,而不能单独使用本 +软件。 + +所有权 +本软件的所有权并未转让给您。许可证颁发者和/或其第三方许可证颁发者 +对本软件(包括软件的任何改编版本或副本)中的所有知识产权,保留全部 +权利、所有权和权益。本软件并非出售给您,您获得的只是使用本软件的有 +条件许可证。通过本软件访问的内容的相关权利、所有权和知识产权是相应 +内容所有者的财产,并可能受相应的版权法或其他法律的保护。本 +EULA 未授予您对此类内容的任何权利。 + +SUSE 商标 +除非根据本 EULA 的允许,必须使用本软件,否则本 EULA 并 +未以明示或暗示的方式,授予您使用许可证颁发者或其关联公司或其他许可 +证颁发者的任何 SUSE 商标、商号或服务商标的权利或许可。 + +订阅服务和支持 +除非您根据与许可证颁发者或其关联公司签署的附加合同购买的订阅产品中 +明确包含维护或支持服务,否则许可证颁发者无义务提供此类服务。 + +担保和责任 +有限担保 +自产品送达之日起六十 (60) 天内,许可证颁发者担保寄送软件所使 +用的任何介质在正常使用的情况下没有物理缺陷和制造缺陷。上述担保是您 +唯一的和独有的补救措施,它将取代所有其他明示或暗示的担保。除前述担 +保条款之外,本软件按"原样"提供,不提供任何形式的任何担保。 +本软件在设计、制造或使用目的方面,并非用于、分发于且不得用于在危险 +环境中使用的、需要故障自动防护性能的在线控制设备,例如核设备、飞机 +导航或通讯系统、空中交通控制、直接生命保障系统或武器系统。不适用的 +环境还包括由于本软件故障就会导致人员伤亡或严重的人身或环境损害的情 +况。 +非许可证颁发者产品 +本软件可能包含或捆绑着由许可证颁发者之外的实体许可或销售的硬件或其 +他软件程序或服务。对于非许可证颁发者的产品或服务,许可证颁发者不提 +供担保。任何此类产品和服务均按"原样"提供。对于非许可证颁发者的产 +品,如果有担保服务,则该担保服务由该产品的许可证颁发者依据其适用的 +担保提供。 +除非法律另行禁止,否则许可证颁发者不作任何暗示担保,包括对适销性、 +针对特定目的的适用性、所有权或不侵权的任何担保,交易过程、履约过程 +或贸易惯例也不会产生任何担保。除在本有限担保中所作的明示担保外,许 +可证颁发者不作任何担保、陈述或承诺。许可证颁发者不担保本软件或服务 +能满足您的要求并与所有操作系统兼容,也不担保本软件或服务的运行不会 +中断或没有错误。前述免除和免责声明构成本协议的核心部分。部分司法管 +辖区不允许特定免责声明和对担保的限制,因此,上述部分限制对您未必适 +用。本有限担保授予您特定的权利,您可能还拥有其他权利(因各州或司法 +辖区而异)。 +有限责任 +在任何情况下,无论是因合同、疏忽、侵权或其他责任原因,许可证颁发者 +或其任何第三方许可证颁发者、子公司或雇员均不对任何形式的间接或非直 +接损害承担责任,也不对任何利润损失、业务损失或数据丢失或损坏承担责 +任,即便已被告知可能发生此类损害。 + +在任何情况下,许可证颁发者在本 EULA 项下或与本 EULA 相 +关的累计责任(无论是单一事件还是系列事件)均不会超过您根据本 +EULA 在首次提出索赔前 12 个月内支付的与此类索赔有关的软件 +费用(如果您未支付任何软件费用,则或为 50 美元)。 +上述免除和限制不适用于与许可证颁发者或其雇员、代理或订约人所导致的 +死亡或人身伤害有关的索赔。对于不允许免除或限制损失(包括但不限于违 +反与所有权有关的任何隐含条款、安静享用依照本 EULA 获得的任何 +软件或欺诈性陈述所带来的损失)责任的司法管辖区,许可证颁发者的责任 +应在这些司法管辖区允许的最大范围内予以限制或免除。 + +通则 +术语 +本 EULA 自您下载本软件之日起生效,如果您违反了本协议的任何条 +款,本协议将自动终止。 +转移 +未经许可证颁发者的事先书面许可,不得转移或转让本 EULA。尝试进 +行任何此类转移或转让均属无用和无效。 +法律 +因本 EULA 产生或与本 EULA 相关的所有事宜均应受美国和纽 +约州实体法的约束,与所选的法律条款无关。因本 EULA 产生或与本 +EULA 相关的任何诉讼、行动或程序,只能呈交纽约州具有相应司法管 +辖权的联邦或州法庭裁决。如果某方提起与本 EULA 相关的法律诉讼, +则胜诉方有权获得合理的律师费。但是,如果您的主要营业地是欧盟或欧 +洲自由贸易联盟的成员国,则:(1) 英格兰和威尔士法庭将对与本 +EULA 相关的任何法律诉讼具有专属司法管辖权;以及 (2) 除非 +需要依据此类主要营业地所在的国家/地区的法律处理任何此类法律诉讼, +否则英格兰法律将适用。《联合国国际货物销售合同公约》、纽约或英格兰 +及威尔士的法律冲突规则对本 EULA 或其标的均不适用。 +完整协议 +本 EULA 连同其他任何购买单据或您与许可证颁发者或其关联公司之 +间签署的其他书面协议,构成您与许可证颁发者之间的完整理解与协议。未 +经您与许可证颁发者的授权代表的书面同意,不得修正或修改本协议。任何 +第三方许可证颁发者、分销商、经销商、零售商、转售商、销售人员或雇员, +均无权修改本协议,或做出与协议条款不一致或本协议条款之外的任何陈 +述或承诺。 +放弃 +对于本 EULA 中任何权利的放弃,必须以书面形式经受约束方正式授 +权代表签字,方可生效。对违约或未履约引发的任何过往、当前权利的弃权, +不得视为对未来依照本 EULA 而应具有的权利的弃权。 +可分割性 +如本 EULA 中的任何条款无效或不可执行,应在必要的范围内对该条 +款加以解释、限制、修改,如果必要的话,还可删除无效、不可执行的部分。 +本 EULA 的其他条款不受影响。 +符合出口法规 +您确认许可证颁发者的产品和/或技术可能受到《美国出口管理条例》(以 +下简称"EAR")及其他国家/地区贸易法的管辖。您同意遵循 EAR +及可能适用于您或影响您进口、出口或使用许可证颁发者产品和/或技术的 +当地法律和法规。您不得向以下国家/地区或用户直接或间接出口或再出口 +许可证颁发者的产品:(1) 列入美国出口排除名单的实体或 EAR +中规定的任何禁运或支持恐怖主义的其他国家/地区;(2) 任何您知晓 +或有理由知晓的将利用许可证颁发者的产品设计、开发或生产核武器、化学 +武器或生物武器、火箭系统、太空运载火箭和探测火箭或无人飞行器系统的 +最终用户,除非根据条例或特定许可证获得相关政府机构的授权;或者 +(3) 任何遭到美国政府的任何联邦机构禁止参与美国出口交易的最终用 +户。您还不得将许可证颁发者的产品和/或技术用于 EAR 所禁止的任 +何核武器、导弹或化学生物武器的最终用途。下载或使用本软件,即表示您 +同意上述条款并声明和保证,您不在上述任何国家/地区内,不受上述任何 +国家/地区的控制,不是上述任何国家/地区的公民或居民,也不在上述任 +何名单中。此外,您有义务遵守您所在司法管辖区内任何可能会影响您进口、 +出口或使用许可证颁发者产品的权利的当地法律。在依据 EAR 出口 +商品之前,请查阅美国商务部工业安全局网页 +https://www.bis.doc.gov。有关软件出口的更多 +信息,包括适用的出口管制分类号 (ECCN) 及相关的许可证异常 +(如果适用),请访问 +https://www.suse.com/company/lega +l/。如有必要,许可证颁发者的国际贸易服务部可以提供适用于许可证颁 +发者产品的出口限制方面的信息。如果您未能获得任何必要的出口许可,则 +许可证颁发者对此概不负责。 + +:版本:2024-02-01:001 +SUSE.com + +版权所有 (c) SUSE 2024 + +SUSE 法务 +2024 年 2 月 diff --git a/rust/agama-server/tests/share/products.d/kalpa.yaml b/rust/test/share/products.d/kalpa.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/kalpa.yaml rename to rust/test/share/products.d/kalpa.yaml diff --git a/rust/agama-server/tests/share/products.d/leap_160.yaml b/rust/test/share/products.d/leap_160.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/leap_160.yaml rename to rust/test/share/products.d/leap_160.yaml diff --git a/rust/agama-server/tests/share/products.d/leap_micro_62.yaml b/rust/test/share/products.d/leap_micro_62.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/leap_micro_62.yaml rename to rust/test/share/products.d/leap_micro_62.yaml diff --git a/rust/agama-server/tests/share/products.d/microos.yaml b/rust/test/share/products.d/microos.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/microos.yaml rename to rust/test/share/products.d/microos.yaml diff --git a/rust/agama-server/tests/share/products.d/sles_160.yaml b/rust/test/share/products.d/sles_160.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/sles_160.yaml rename to rust/test/share/products.d/sles_160.yaml diff --git a/rust/agama-server/tests/share/products.d/sles_sap_160.yaml b/rust/test/share/products.d/sles_sap_160.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/sles_sap_160.yaml rename to rust/test/share/products.d/sles_sap_160.yaml diff --git a/rust/agama-server/tests/share/products.d/slowroll.yaml b/rust/test/share/products.d/slowroll.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/slowroll.yaml rename to rust/test/share/products.d/slowroll.yaml diff --git a/rust/agama-server/tests/share/products.d/tumbleweed.yaml b/rust/test/share/products.d/tumbleweed.yaml similarity index 100% rename from rust/agama-server/tests/share/products.d/tumbleweed.yaml rename to rust/test/share/products.d/tumbleweed.yaml From d0ebbaf793f8d4a723c1b7402862e5f5d8804988 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 30 Oct 2025 17:30:15 +0100 Subject: [PATCH 294/917] Speed up tests --- service/test/agama/dbus/storage/manager_test.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 315ba9b2a4..e2458aabd8 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -70,8 +70,11 @@ def parse(string) end before do - # Speed up tests by avoding real check of TPM presence. + # Speed up tests by avoiding real check of TPM presence. allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + # Speed up tests by avoiding looking up by name in the system + allow(Y2Storage::BlkDevice).to receive(:find_by_any_name) + allow(Yast::Arch).to receive(:s390).and_return false allow(backend).to receive(:on_configure) allow(backend).to receive(:on_issues_change) From 3854894910a60fdbca73af3b13656d92e0edb4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 30 Oct 2025 16:19:18 +0000 Subject: [PATCH 295/917] Report storage issues --- rust/agama-l10n/src/service.rs | 8 ++--- rust/agama-manager/src/start.rs | 2 +- rust/agama-storage/src/client.rs | 52 +++++++++++++-------------- rust/agama-storage/src/message.rs | 9 ++++- rust/agama-storage/src/monitor.rs | 35 ++++++++++++++---- rust/agama-storage/src/service.rs | 27 ++++++++++++-- rust/agama-storage/src/start.rs | 16 +++++---- rust/agama-utils/src/api/issue.rs | 6 ++-- rust/agama-utils/src/issue/message.rs | 6 ++-- rust/agama-utils/src/issue/monitor.rs | 2 +- rust/agama-utils/src/issue/service.rs | 4 +-- rust/agama-utils/src/issue/start.rs | 10 +++--- 12 files changed, 115 insertions(+), 62 deletions(-) diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index bb73dfa99b..b6699362c3 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -123,7 +123,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_locale".to_string(), + class: "unknown_locale".to_string(), }); } @@ -133,7 +133,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_keymap".to_string(), + class: "unknown_keymap".to_string(), }); } @@ -143,7 +143,7 @@ impl Service { details: None, source: IssueSource::Config, severity: IssueSeverity::Error, - kind: "unknown_timezone".to_string(), + class: "unknown_timezone".to_string(), }); } @@ -210,7 +210,7 @@ impl MessageHandler> for Service { self.config = config; let issues = self.find_issues(); self.issues - .cast(issue::message::Update::new(Scope::L10n, issues))?; + .cast(issue::message::Set::new(Scope::L10n, issues))?; self.events .send(Event::ProposalChanged { scope: Scope::L10n })?; Ok(()) diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 41ab1b03c3..98acd79976 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -50,7 +50,7 @@ pub async fn start( let issues = issue::start(events.clone(), dbus.clone()).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let storage = storage::start(progress.clone(), events.clone(), dbus).await?; + let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; let service = Service::new(l10n, storage, issues, progress, questions, events); let handler = actor::spawn(service); diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs index 35c6194eae..1d96f2a1fe 100644 --- a/rust/agama-storage/src/client.rs +++ b/rust/agama-storage/src/client.rs @@ -21,6 +21,7 @@ //! Implements a client to access Agama's storage service. use crate::config::Config; +use agama_utils::api::Issue; use serde_json::{value::RawValue, Value}; use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; @@ -73,27 +74,27 @@ impl Client { pub async fn get_system(&self) -> Result>, Error> { let message = self.call("GetSystem", &()).await?; - self.json_from(message) + try_from_message(message) } pub async fn get_config(&self) -> Result, Error> { let message = self.call("GetConfig", &()).await?; - self.json_from(message) + try_from_message(message) } pub async fn get_config_model(&self) -> Result>, Error> { let message = self.call("GetConfigModel", &()).await?; - self.json_from(message) + try_from_message(message) } pub async fn get_proposal(&self) -> Result>, Error> { let message = self.call("GetProposal", &()).await?; - self.json_from(message) + try_from_message(message) } - pub async fn get_issues(&self) -> Result>, Error> { + pub async fn get_issues(&self) -> Result, Error> { let message = self.call("GetIssues", &()).await?; - self.json_from(message) + try_from_message(message) } //TODO: send a product config instead of an id. @@ -119,7 +120,7 @@ impl Client { model: Box, ) -> Result>, Error> { let message = self.call("SolveConfigModel", &(model.to_string())).await?; - self.json_from(message) + try_from_message(message) } pub async fn set_locale(&self, locale: String) -> Result<(), Error> { @@ -139,25 +140,24 @@ impl Client { .await .map_err(|e| e.into()) } +} - fn json_from( - &self, - message: Message, - ) -> Result, Error> { - let value: String = message.body().deserialize()?; - if self.is_null(value.as_str()) { - return Ok(None); - } - let json = serde_json::from_str(value.as_str())?; - Ok(Some(json)) - } - - fn is_null(&self, value: &str) -> bool { - let value = serde_json::from_str::(value); - match value { - Ok(Value::Null) => true, - Ok(_) => false, - Err(_) => false, - } +fn try_from_message( + message: Message, +) -> Result { + let json: String = message.body().deserialize()?; + if is_json_null(&json) { + return Ok(T::default()); + } + let value = serde_json::from_str(&json)?; + Ok(value) +} + +fn is_json_null(value: &str) -> bool { + let value = serde_json::from_str::(value); + match value { + Ok(Value::Null) => true, + Ok(_) => false, + Err(_) => false, } } diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index 7ba113cda8..7da362ea44 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::config::Config; -use agama_utils::actor::Message; +use agama_utils::{actor::Message, api::Issue}; use serde_json::value::RawValue; #[derive(Clone)] @@ -78,6 +78,13 @@ impl Message for GetProposal { type Reply = Option>; } +#[derive(Clone)] +pub struct GetIssues; + +impl Message for GetIssues { + type Reply = Vec; +} + #[derive(Clone)] pub struct SetProduct { pub id: String, diff --git a/rust/agama-storage/src/monitor.rs b/rust/agama-storage/src/monitor.rs index 7e65ed8cf9..e95c595bf1 100644 --- a/rust/agama-storage/src/monitor.rs +++ b/rust/agama-storage/src/monitor.rs @@ -18,13 +18,17 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::{ + message, + service::{self, Service}, +}; use agama_utils::{ actor::Handler, api::{ event::{self, Event}, Progress, Scope, }, - progress::{self, message}, + issue, progress, }; use serde::Deserialize; use serde_json; @@ -40,6 +44,10 @@ pub enum Error { #[error("Wrong signal data")] ProgressChangedData, #[error(transparent)] + Service(#[from] service::Error), + #[error(transparent)] + Issue(#[from] issue::service::Error), + #[error(transparent)] Progress(#[from] progress::service::Error), #[error(transparent)] DBus(#[from] zbus::Error), @@ -96,19 +104,25 @@ impl From for Progress { } pub struct Monitor { + storage: Handler, progress: Handler, + issues: Handler, events: event::Sender, connection: Connection, } impl Monitor { pub fn new( + storage: Handler, progress: Handler, + issues: Handler, events: event::Sender, connection: Connection, ) -> Self { Self { + storage, progress, + issues, events, connection, } @@ -124,16 +138,16 @@ impl Monitor { tokio::pin!(streams); while let Some((_, signal)) = streams.next().await { - self.handle_signal(signal)?; + self.handle_signal(signal).await?; } Ok(()) } - fn handle_signal(&self, signal: Signal) -> Result<(), Error> { + async fn handle_signal(&self, signal: Signal) -> Result<(), Error> { match signal { Signal::SystemChanged(signal) => self.handle_system_changed(signal)?, - Signal::ProposalChanged(signal) => self.handle_proposal_changed(signal)?, + Signal::ProposalChanged(signal) => self.handle_proposal_changed(signal).await?, Signal::ProgressChanged(signal) => self.handle_progress_changed(signal)?, Signal::ProgressFinished(signal) => self.handle_progress_finished(signal)?, } @@ -147,10 +161,16 @@ impl Monitor { Ok(()) } - fn handle_proposal_changed(&self, _signal: ProposalChanged) -> Result<(), Error> { + async fn handle_proposal_changed(&self, _signal: ProposalChanged) -> Result<(), Error> { self.events.send(Event::ProposalChanged { scope: Scope::Storage, })?; + + let issues = self.storage.call(message::GetIssues).await?; + self.issues + .call(issue::message::Set::new(Scope::Storage, issues)) + .await?; + Ok(()) } @@ -162,13 +182,14 @@ impl Monitor { return Err(Error::ProgressChangedData); }; self.progress - .cast(message::Set::new(progress_data.into()))?; + .cast(progress::message::Set::new(progress_data.into()))?; Ok(()) } fn handle_progress_finished(&self, _signal: ProgressFinished) -> Result<(), Error> { - self.progress.cast(message::Finish::new(Scope::Storage))?; + self.progress + .cast(progress::message::Finish::new(Scope::Storage))?; Ok(()) } diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index ae33cc65e3..94e839f958 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -23,7 +23,11 @@ use crate::{ config::Config, message, }; -use agama_utils::actor::{self, Actor, MessageHandler}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{Issue, Scope}, + issue, +}; use async_trait::async_trait; use serde_json::value::RawValue; @@ -33,19 +37,31 @@ pub enum Error { Actor(#[from] actor::Error), #[error(transparent)] Client(#[from] client::Error), + #[error(transparent)] + Issue(#[from] issue::service::Error), } /// Storage service. pub struct Service { + issues: Handler, client: Client, } impl Service { - pub fn new(connection: zbus::Connection) -> Service { + pub fn new(issues: Handler, connection: zbus::Connection) -> Service { Self { + issues, client: Client::new(connection), } } + + pub async fn start(self) -> Result { + let issues = self.client.get_issues().await?; + self.issues + .call(issue::message::Set::new(Scope::Storage, issues)) + .await?; + Ok(self) + } } impl Actor for Service { @@ -121,6 +137,13 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetIssues) -> Result, Error> { + self.client.get_issues().await.map_err(|e| e.into()) + } +} + #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::SetProduct) -> Result<(), Error> { diff --git a/rust/agama-storage/src/start.rs b/rust/agama-storage/src/start.rs index e9b3982a7b..018bb889e0 100644 --- a/rust/agama-storage/src/start.rs +++ b/rust/agama-storage/src/start.rs @@ -20,32 +20,34 @@ use crate::{ monitor::{self, Monitor}, - service::Service, + service::{self, Service}, }; use agama_utils::{ actor::{self, Handler}, api::event, - progress, + issue, progress, }; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] Monitor(#[from] monitor::Error), + #[error(transparent)] + Service(#[from] service::Error), } /// Starts the storage service. -/// -/// * `dbus`: connection to Agama's D-Bus server. pub async fn start( progress: Handler, + issues: Handler, events: event::Sender, dbus: zbus::Connection, ) -> Result, Error> { - let monitor = Monitor::new(progress, events, dbus.clone()); + let service = Service::new(issues.clone(), dbus.clone()).start().await?; + let handler = actor::spawn(service); + + let monitor = Monitor::new(handler.clone(), progress, issues, events, dbus); monitor::spawn(monitor)?; - let service = Service::new(dbus); - let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-utils/src/api/issue.rs b/rust/agama-utils/src/api/issue.rs index 0a3035d9ef..6017be4bdd 100644 --- a/rust/agama-utils/src/api/issue.rs +++ b/rust/agama-utils/src/api/issue.rs @@ -44,7 +44,7 @@ pub struct Issue { pub details: Option, pub source: IssueSource, pub severity: IssueSeverity, - pub kind: String, + pub class: String, } #[derive( @@ -95,7 +95,7 @@ impl TryFrom<&zbus::zvariant::Value<'_>> for Issue { Ok(Issue { description, - kind, + class: kind, details: if details.is_empty() { None } else { @@ -125,7 +125,7 @@ mod tests { let issue = Issue::try_from(&Value::Structure(dbus_issue)).unwrap(); assert_eq!(&issue.description, "Product not selected"); - assert_eq!(&issue.kind, "missing_product"); + assert_eq!(&issue.class, "missing_product"); assert_eq!(issue.details, Some("A product is required.".to_string())); assert_eq!(issue.source, IssueSource::System); assert_eq!(issue.severity, IssueSeverity::Warn); diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index abd7e3e90a..fcc69d89e5 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -30,13 +30,13 @@ impl Message for Get { // FIXME: consider an alternative approach to avoid pub(crate), // making it only visible to the service. -pub struct Update { +pub struct Set { pub(crate) scope: Scope, pub(crate) issues: Vec, pub(crate) notify: bool, } -impl Update { +impl Set { pub fn new(scope: Scope, issues: Vec) -> Self { Self { scope, @@ -51,6 +51,6 @@ impl Update { } } -impl Message for Update { +impl Message for Set { type Reply = (); } diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 85df4ff667..6b39402de4 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -165,7 +165,7 @@ impl Monitor { match Self::scope_from_path(path) { Some(scope) => { self.handler - .cast(message::Update::new(scope, issues).notify(notify))?; + .cast(message::Set::new(scope, issues).notify(notify))?; } None => { eprintln!("Unknown issues object {}", path); diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 97c59431f9..91399ad502 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -64,8 +64,8 @@ impl MessageHandler for Service { } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, message: message::Update) -> Result<(), Error> { +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Set) -> Result<(), Error> { // Compare whether the issues has changed. let old_issues_hash: HashSet<_> = self .issues diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 5728c4bcc1..9cfcdf4f23 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -62,7 +62,7 @@ mod tests { fn build_issue() -> Issue { Issue { description: "Product not selected".to_string(), - kind: "missing_product".to_string(), + class: "missing_product".to_string(), details: Some("A product is required.".to_string()), source: IssueSource::Config, severity: IssueSeverity::Error, @@ -79,7 +79,7 @@ mod tests { let issue = build_issue(); _ = issues - .cast(message::Update::new(Scope::Manager, vec![issue])) + .cast(message::Set::new(Scope::Manager, vec![issue])) .unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -99,7 +99,7 @@ mod tests { assert!(issues_list.is_empty()); let issue = build_issue(); - let update = message::Update::new(Scope::Manager, vec![issue]).notify(false); + let update = message::Set::new(Scope::Manager, vec![issue]).notify(false); _ = issues.cast(update).unwrap(); let issues_list = issues.call(message::Get).await.unwrap(); @@ -116,11 +116,11 @@ mod tests { let issues = issue::start(events_tx, dbus).await.unwrap(); let issue = build_issue(); - let update = message::Update::new(Scope::Manager, vec![issue.clone()]); + let update = message::Set::new(Scope::Manager, vec![issue.clone()]); issues.call(update).await.unwrap(); assert!(events_rx.try_recv().is_ok()); - let update = message::Update::new(Scope::Manager, vec![issue]); + let update = message::Set::new(Scope::Manager, vec![issue]); issues.call(update).await.unwrap(); assert!(matches!(events_rx.try_recv(), Err(TryRecvError::Empty))); Ok(()) From de0a85888598e835c339f0a3f247cc0558e2c053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Oct 2025 22:18:45 +0000 Subject: [PATCH 296/917] Use the repositories from the full medium --- rust/agama-software/src/model/packages.rs | 18 ++-- rust/agama-software/src/model/state.rs | 117 ++++++++++++++++++++-- rust/agama-software/src/service.rs | 72 +++++++++++-- rust/agama-software/src/start.rs | 2 +- rust/agama-software/src/zypp_server.rs | 3 +- 5 files changed, 187 insertions(+), 25 deletions(-) diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 6fd3e5d611..28a96c15a7 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -75,18 +75,20 @@ pub struct ResolvableParams { #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Repository { - /// repository identifier - pub id: i32, - /// repository alias. Has to be unique + /// Repository identifier + pub id: Option, + /// Repository alias. It has to be unique. pub alias: String, - /// repository name + /// Repository name pub name: String, - /// Repository url (raw format without expanded variables) + /// Repository URL (raw format without expanded variables) pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - pub product_dir: String, + /// Product directory (currently not used, valid only for multiproduct DVDs) + pub product_dir: String, // FIXME: needed? /// Whether the repository is enabled pub enabled: bool, /// Whether the repository is loaded - pub loaded: bool, + pub loaded: bool, // FIXME: needed? + /// Whether the repository is mandatory (offline base repo, DUD repositories, etc.) + pub mandatory: bool, } diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 429e6891ed..5c73a04249 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -24,7 +24,13 @@ use agama_utils::api::software::{Config, PatternsConfig, RepositoryParams}; -use crate::model::products::{ProductSpec, UserPattern}; +use crate::{ + model::{ + packages, + products::{ProductSpec, UserPattern}, + }, + SystemInfo, +}; /// Represents the wanted software configuration. /// @@ -45,6 +51,7 @@ pub struct SoftwareState { pub struct SoftwareStateBuilder<'a> { product: &'a ProductSpec, config: Option<&'a Config>, + system: Option<&'a SystemInfo>, } impl<'a> SoftwareStateBuilder<'a> { @@ -53,6 +60,7 @@ impl<'a> SoftwareStateBuilder<'a> { Self { product, config: None, + system: None, } } @@ -62,11 +70,20 @@ impl<'a> SoftwareStateBuilder<'a> { self } + pub fn with_system(mut self, system: &'a SystemInfo) -> Self { + self.system = Some(system); + self + } + /// Builds the [SoftwareState] by merging the product specification and the /// user configuration. pub fn build(self) -> SoftwareState { let mut state = self.from_product_spec(); + if let Some(system) = self.system { + self.add_system_config(&mut state, &system); + } + if let Some(config) = self.config { self.add_user_config(&mut state, &config); } @@ -74,6 +91,15 @@ impl<'a> SoftwareStateBuilder<'a> { state } + fn add_system_config(&self, state: &mut SoftwareState, system: &SystemInfo) { + let repositories = system + .repositories + .iter() + .filter(|r| r.mandatory) + .map(Repository::from); + state.repositories.extend(repositories); + } + fn add_user_config(&self, state: &mut SoftwareState, config: &Config) { let Some(software) = &config.software else { return; @@ -168,9 +194,10 @@ impl<'a> SoftwareStateBuilder<'a> { impl SoftwareState { // TODO: Add SoftwareSelection as additional argument. - pub fn build_from(product: &ProductSpec, config: &Config) -> Self { + pub fn build_from(product: &ProductSpec, config: &Config, system: &SystemInfo) -> Self { SoftwareStateBuilder::for_product(product) .with_config(config) + .with_system(system) .build() } } @@ -195,6 +222,17 @@ impl From<&RepositoryParams> for Repository { } } +impl From<&packages::Repository> for Repository { + fn from(value: &packages::Repository) -> Self { + Repository { + name: value.name.clone(), + alias: value.alias.clone(), + url: value.url.clone(), + enabled: value.enabled, + } + } +} + /// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] pub struct Resolvable { @@ -228,6 +266,8 @@ mod tests { PatternsConfig, PatternsMap, RepositoryParams, SoftwareConfig, }; + use crate::model::packages::Repository; + use super::*; fn build_user_config(patterns: Option) -> Config { @@ -265,7 +305,9 @@ mod tests { fn test_build_state() { let product = build_product_spec(); let config = Config::default(); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!(state.repositories.len(), 3); let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); @@ -291,7 +333,9 @@ mod tests { fn test_add_user_repositories() { let product = build_product_spec(); let config = build_user_config(None); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!(state.repositories.len(), 4); let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); @@ -313,7 +357,9 @@ mod tests { }); let config = build_user_config(Some(patterns)); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!( state.patterns, vec![ @@ -333,7 +379,9 @@ mod tests { }); let config = build_user_config(Some(patterns)); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!( state.patterns, vec![Resolvable::new("enhanced_base", false),] @@ -349,7 +397,9 @@ mod tests { }); let config = build_user_config(Some(patterns)); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!( state.patterns, vec![ @@ -365,7 +415,9 @@ mod tests { let patterns = PatternsConfig::PatternsList(vec!["gnome".to_string()]); let config = build_user_config(Some(patterns)); - let state = SoftwareState::build_from(&product, &config); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); assert_eq!( state.patterns, vec![ @@ -374,4 +426,53 @@ mod tests { ] ); } + + #[test] + fn test_use_base_repositories() { + let product = build_product_spec(); + let patterns = PatternsConfig::PatternsList(vec!["gnome".to_string()]); + let config = build_user_config(Some(patterns)); + + let base_repo = Repository { + id: None, + alias: "install".to_string(), + name: "install".to_string(), + url: "hd:/run/initramfs/install".to_string(), + enabled: false, + loaded: false, + mandatory: true, + product_dir: "".to_string(), + }; + + let another_repo = Repository { + id: None, + alias: "another".to_string(), + name: "another".to_string(), + url: "https://example.lan/SLES/".to_string(), + enabled: false, + loaded: false, + mandatory: false, + product_dir: "".to_string(), + }; + + let system = SystemInfo { + repositories: vec![base_repo, another_repo], + ..Default::default() + }; + + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .with_system(&system) + .build(); + + let aliases: Vec<_> = state.repositories.iter().map(|r| r.alias.clone()).collect(); + let expected_aliases = vec![ + "agama-0".to_string(), + "agama-1".to_string(), + "agama-2".to_string(), + "install".to_string(), + "user-repo-0".to_string(), + ]; + assert_eq!(expected_aliases, aliases); + } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 757d7e9209..e69c691873 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -18,16 +18,16 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::{ops::DerefMut, sync::Arc}; +use std::{ops::DerefMut, process::Command, sync::Arc}; use crate::{ message, model::{ license::{Error as LicenseError, LicensesRepo}, - packages::{Repository, ResolvableType}, + packages::{self, Repository, ResolvableType}, products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, software_selection::SoftwareSelection, - state::SoftwareState, + state::{self, SoftwareState}, ModelAdapter, }, proposal::Proposal, @@ -113,9 +113,16 @@ impl Service { } } - pub fn read(&mut self) -> Result<(), Error> { + pub async fn read(&mut self) -> Result<(), Error> { self.licenses.read()?; self.products.read()?; + let mut system = self.state.system.write().await; + system.licenses = self.licenses.licenses().into_iter().cloned().collect(); + system.products = self.products.products(); + if let Some(install_repo) = find_install_repository() { + tracing::info!("Found repository at {}", install_repo.url); + system.repositories.push(install_repo); + } Ok(()) } @@ -202,7 +209,10 @@ impl MessageHandler> for Service { scope: Scope::Software, })?; - let software = SoftwareState::build_from(new_product, &message.config); + let system = self.state.system.read().await.clone(); + + // NOTE: we should read the system to get the local repositories. + let software = SoftwareState::build_from(new_product, &message.config, &system); let model = self.model.clone(); let issues = self.issues.clone(); @@ -210,7 +220,7 @@ impl MessageHandler> for Service { let mut my_model = model.lock().await; let found_issues = my_model.write(software).await.unwrap(); if !found_issues.is_empty() { - issues.cast(issue::message::Update::new(Scope::Software, found_issues)); + _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); } }); @@ -237,7 +247,7 @@ impl MessageHandler for Service { }; self.model.lock().await.probe(product).await?; - self.update_system(); + self.update_system().await?; Ok(()) } } @@ -256,3 +266,51 @@ impl MessageHandler for Service { Ok(()) } } + +const LIVE_REPO_DIR: &str = "/run/initramfs/live/install"; + +fn find_install_repository() -> Option { + if !std::fs::exists(LIVE_REPO_DIR).is_ok_and(|e| e) { + return None; + } + + normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| packages::Repository { + id: None, + alias: "install".to_string(), + name: "install".to_string(), + url, + product_dir: "".to_string(), + loaded: false, + enabled: true, + mandatory: true, + }) +} + +fn normalize_repository_url(mount_point: &str, path: &str) -> Option { + let live_device = Command::new("findmnt") + .args(["-o", "SOURCE", "--noheadings", "--target", mount_point]) + .output() + .ok()?; + let live_device = String::from_utf8(live_device.stdout) + .map(|d| d.trim().to_string()) + .ok()?; + + // check against /\A/dev/sr[0-9]+\z/ + if live_device.starts_with("/dev/sr") { + return Some(format!("dvd:{path}?devices={live_device}")); + } + + let by_id_devices = Command::new("find") + .args(["-L", "/dev/disk/by-id", "-samefile", &live_device]) + .output() + .ok()?; + let by_id_devices = String::from_utf8(by_id_devices.stdout).ok()?; + let mut by_id_devices = by_id_devices.trim().split("\n"); + + let device = by_id_devices.next().unwrap_or_default(); + if device.is_empty() { + Some(format!("hd:{mount_point}?device={live_device}")) + } else { + Some(format!("hd:{mount_point}?device={device}")) + } +} diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index bcca2f57a6..ff0424a35e 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -54,7 +54,7 @@ pub async fn start( let zypp_sender = ZyppServer::start()?; let model = Model::new(zypp_sender)?; let mut service = Service::new(model, issues, events); - service.read()?; + service.read().await?; let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index b097c66cce..77f7b0a3bb 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -261,12 +261,13 @@ impl ZyppServer { .map(|(index, repo)| Repository { url: repo.url, // unwrap here is ok, as number of repos are low - id: index.try_into().unwrap(), // TODO: remove it when not needed, DBus relict, alias should be always unique + id: Some(index.try_into().unwrap()), // TODO: remove it when not needed, DBus relict, alias should be always unique alias: repo.alias, name: repo.user_name, product_dir: "/".to_string(), // TODO: get it from zypp enabled: repo.enabled, loaded: true, + mandatory: false, }) .collect() }) From 88e8c80ca3eb533305e798e76d31d78317ace0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Oct 2025 22:28:34 +0000 Subject: [PATCH 297/917] Remove the RwLock from software SystemInfo --- rust/agama-software/src/service.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index e69c691873..db5d61dd0b 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -94,7 +94,7 @@ pub struct Service { #[derive(Default)] struct State { config: Config, - system: Arc>, + system: SystemInfo, } impl Service { @@ -116,23 +116,21 @@ impl Service { pub async fn read(&mut self) -> Result<(), Error> { self.licenses.read()?; self.products.read()?; - let mut system = self.state.system.write().await; - system.licenses = self.licenses.licenses().into_iter().cloned().collect(); - system.products = self.products.products(); + self.state.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); + self.state.system.products = self.products.products(); if let Some(install_repo) = find_install_repository() { tracing::info!("Found repository at {}", install_repo.url); - system.repositories.push(install_repo); + self.state.system.repositories.push(install_repo); } Ok(()) } - async fn update_system(&self) -> Result<(), Error> { + async fn update_system(&mut self) -> Result<(), Error> { let licenses = self.licenses.licenses().into_iter().cloned().collect(); let products = self.products.products(); - let mut system = self.state.system.write().await; - system.licenses = licenses; - system.products = products; + self.state.system.licenses = licenses; + self.state.system.products = products; self.events.send(Event::SystemChanged { scope: Scope::Software, @@ -178,7 +176,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { - Ok(self.state.system.read().await.clone()) + Ok(self.state.system.clone()) } } @@ -209,10 +207,7 @@ impl MessageHandler> for Service { scope: Scope::Software, })?; - let system = self.state.system.read().await.clone(); - - // NOTE: we should read the system to get the local repositories. - let software = SoftwareState::build_from(new_product, &message.config, &system); + let software = SoftwareState::build_from(new_product, &message.config, &self.state.system); let model = self.model.clone(); let issues = self.issues.clone(); From fc0613f38f1cb6c048fa8b00dc3d12bcaf967b3c Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 30 Oct 2025 13:51:25 +0100 Subject: [PATCH 298/917] Include system information in the SystemChanged signal --- service/lib/agama/dbus/storage/manager.rb | 13 ++- .../test/agama/dbus/storage/manager_test.rb | 98 +++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index cbfcc19355..42495fa8f2 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -78,7 +78,7 @@ def initialize(backend, logger: nil) dbus_method(:SolveConfigModel, "in model:s, out result:s") { |m| solve_config_model(m) } dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") { recover_issues } - dbus_signal(:SystemChanged) + dbus_signal(:SystemChanged, "system:s") dbus_signal(:ProposalChanged) dbus_signal(:IssuesChanged) dbus_signal(:ProgressChanged, "progress:s") @@ -93,7 +93,7 @@ def activate next_progress_step(PROBING_STEP) backend.probe - self.SystemChanged + emit_system_changed next_progress_step(CONFIGURING_STEP) configure_with_current @@ -108,7 +108,7 @@ def probe next_progress_step(PROBING_STEP) backend.probe - self.SystemChanged + emit_system_changed next_progress_step(CONFIGURING_STEP) configure_with_current @@ -126,7 +126,7 @@ def configure_product(id) next_progress_step(PROBING_STEP) if !backend.probed? backend.probe - self.SystemChanged + emit_system_changed end next_progress_step(CONFIGURING_STEP) @@ -510,6 +510,11 @@ def volume_templates end end + # Emits the SystemChanged signal + def emit_system_changed + self.SystemChanged(recover_system) + end + def add_s390_interfaces require "agama/dbus/storage/interfaces/dasd_manager" require "agama/dbus/storage/interfaces/zfcp_manager" diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index e2458aabd8..0454f740c1 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -789,6 +789,104 @@ def parse(string) end end + describe "#probe" do + before do + allow(subject).to receive(:SystemChanged) + allow(subject).to receive(:ProgressChanged) + allow(subject).to receive(:ProgressFinished) + + allow(backend).to receive(:activated?).and_return activated + allow(backend).to receive(:probe) + end + + let(:activated) { true } + + it "triggers a new probing" do + expect(backend).to receive(:probe) + subject.probe + end + + context "when storage devices are already activated" do + it "does not activate devices" do + expect(backend).to_not receive(:activate) + subject.probe + end + end + + context "when storage devices are not yet activated" do + let(:activated) { false } + + it "activates the devices" do + expect(backend).to receive(:activate) + subject.probe + end + end + + context "when no storage configuration has been set" do + it "does not calculate a new proposal" do + expect(backend).to_not receive(:configure) + subject.probe + end + + it "does not emit a ProposalChanged signal" do + expect(subject).to_not receive(:ProposalChanged) + subject.probe + end + + it "emits signals for SystemChanged, ProgressChanged and ProgressFinished" do + expect(subject).to receive(:SystemChanged) do |system_str| + system = parse(system_str) + device = system[:devices].first + expect(device[:name]).to eq "/dev/sda" + expect(system[:availableDrives]).to eq [device[:sid]] + end + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.probe + end + end + + context "when a storage configuration was previously set" do + before do + allow(proposal).to receive(:storage_json).and_return config_json.to_json + allow(subject).to receive(:ProposalChanged) + allow(backend).to receive(:configure) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [{ generate: "defaults" }] + } + ] + } + } + end + + it "re-calculates the proposal" do + expect(backend).to receive(:configure).with(config_json) + subject.probe + end + + it "emits signals for ProposalChanged, SystemChanged, ProgressChanged and ProgressFinished" do + expect(subject).to receive(:SystemChanged) do |system_str| + system = parse(system_str) + device = system[:devices].first + expect(device[:name]).to eq "/dev/sda" + expect(system[:availableDrives]).to eq [device[:sid]] + end + expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) + expect(subject).to receive(:ProgressFinished) + + subject.probe + end + end + end + describe "#recover_issues" do context "if no proposal has been calculated" do it "returns an empty array" do From e282c16bdcc76573ee88fbee3143d1fcc8dd6fad Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 29 Oct 2025 15:45:28 +0100 Subject: [PATCH 299/917] Include proposal information in the ProposalChanged signal --- service/lib/agama/dbus/storage/manager.rb | 28 +++++++++---------- .../test/agama/dbus/storage/manager_test.rb | 12 ++++++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 42495fa8f2..9b6712942c 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -79,7 +79,7 @@ def initialize(backend, logger: nil) dbus_method(:GetProposal, "out proposal:s") { recover_proposal } dbus_method(:GetIssues, "out issues:s") { recover_issues } dbus_signal(:SystemChanged, "system:s") - dbus_signal(:ProposalChanged) + dbus_signal(:ProposalChanged, "proposal:s") dbus_signal(:IssuesChanged) dbus_signal(:ProgressChanged, "progress:s") dbus_signal(:ProgressFinished) @@ -130,10 +130,7 @@ def configure_product(id) end next_progress_step(CONFIGURING_STEP) - backend.configure - self.ProposalChanged - - finish_progress + calculate_proposal end # Implementation for the API method #Install. @@ -208,10 +205,7 @@ def configure(serialized_config) start_progress(1, CONFIGURING_STEP) config_json = JSON.parse(serialized_config, symbolize_names: true) - backend.configure(config_json) - self.ProposalChanged - - finish_progress + calculate_proposal(config_json) end # Applies the given serialized config model according to the JSON schema. @@ -227,10 +221,7 @@ def configure_with_model(serialized_model) storage_system: proposal.storage_system ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } - backend.configure(config_json) - self.ProposalChanged - - finish_progress + calculate_proposal(config_json) end # Solves the given serialized config model. @@ -400,7 +391,16 @@ def configure_with_current return unless config_json configure(config_json) - self.ProposalChanged + end + + # @see #configure + # @see #configure_with_model + # + # @param config_json [Hash, nil] see Agama::Storage::Manager#configure + def calculate_proposal(config_json = nil) + backend.configure(config_json) + self.ProposalChanged(recover_proposal) + finish_progress end # JSON representation of the given devicegraph from StorageManager diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 0454f740c1..4e46f03755 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -851,7 +851,6 @@ def parse(string) before do allow(proposal).to receive(:storage_json).and_return config_json.to_json allow(subject).to receive(:ProposalChanged) - allow(backend).to receive(:configure) end let(:config_json) do @@ -859,7 +858,10 @@ def parse(string) storage: { drives: [ { - partitions: [{ generate: "defaults" }] + partitions: [ + { search: "*", delete: true }, + { filesystem: { path: "/" }, size: { min: "5 GiB" } } + ] } ] } @@ -878,7 +880,11 @@ def parse(string) expect(device[:name]).to eq "/dev/sda" expect(system[:availableDrives]).to eq [device[:sid]] end - expect(subject).to receive(:ProposalChanged) + expect(subject).to receive(:ProposalChanged) do |proposal_str| + proposal = parse(proposal_str) + expect(proposal[:devices]).to be_a Array + expect(proposal[:actions]).to be_a Array + end expect(subject).to receive(:ProgressChanged).with(/storage configuration/i) expect(subject).to receive(:ProgressFinished) From 5280dc47b351d23f75df34c29f2b2b5c0e75f761 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 30 Oct 2025 15:29:42 +0100 Subject: [PATCH 300/917] Remove wrong signal declaration --- service/lib/agama/dbus/storage/manager.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 9b6712942c..9bcd472cee 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -80,7 +80,6 @@ def initialize(backend, logger: nil) dbus_method(:GetIssues, "out issues:s") { recover_issues } dbus_signal(:SystemChanged, "system:s") dbus_signal(:ProposalChanged, "proposal:s") - dbus_signal(:IssuesChanged) dbus_signal(:ProgressChanged, "progress:s") dbus_signal(:ProgressFinished) end From 0f10897eb398abfcbb8ec180d4f2b730a0d892b0 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Fri, 31 Oct 2025 09:54:40 +0100 Subject: [PATCH 301/917] Change from code review --- service/lib/agama/dbus/storage/manager.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 9bcd472cee..539c92547f 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -130,6 +130,8 @@ def configure_product(id) next_progress_step(CONFIGURING_STEP) calculate_proposal + + finish_progress end # Implementation for the API method #Install. @@ -205,6 +207,8 @@ def configure(serialized_config) config_json = JSON.parse(serialized_config, symbolize_names: true) calculate_proposal(config_json) + + finish_progress end # Applies the given serialized config model according to the JSON schema. @@ -221,6 +225,8 @@ def configure_with_model(serialized_model) ).convert config_json = { storage: Agama::Storage::ConfigConversions::ToJSON.new(config).convert } calculate_proposal(config_json) + + finish_progress end # Solves the given serialized config model. @@ -399,7 +405,6 @@ def configure_with_current def calculate_proposal(config_json = nil) backend.configure(config_json) self.ProposalChanged(recover_proposal) - finish_progress end # JSON representation of the given devicegraph from StorageManager From 022b0ba6c9aab544a97d232ee4bd33d755f8f18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 09:13:44 +0000 Subject: [PATCH 302/917] Adapt signals --- rust/agama-storage/src/monitor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/agama-storage/src/monitor.rs b/rust/agama-storage/src/monitor.rs index e95c595bf1..2eb3f992f9 100644 --- a/rust/agama-storage/src/monitor.rs +++ b/rust/agama-storage/src/monitor.rs @@ -63,10 +63,10 @@ pub enum Error { )] pub trait Storage1 { #[zbus(signal)] - fn system_changed(&self) -> zbus::Result<()>; + fn system_changed(&self, system: &str) -> zbus::Result<()>; #[zbus(signal)] - fn proposal_changed(&self) -> zbus::Result<()>; + fn proposal_changed(&self, proposal: &str) -> zbus::Result<()>; #[zbus(signal)] fn progress_changed(&self, progress: &str) -> zbus::Result<()>; @@ -154,6 +154,7 @@ impl Monitor { Ok(()) } + // TODO: add system info to the event. fn handle_system_changed(&self, _signal: SystemChanged) -> Result<(), Error> { self.events.send(Event::SystemChanged { scope: Scope::Storage, @@ -161,6 +162,7 @@ impl Monitor { Ok(()) } + // TODO: add proposal to the event. async fn handle_proposal_changed(&self, _signal: ProposalChanged) -> Result<(), Error> { self.events.send(Event::ProposalChanged { scope: Scope::Storage, From 330c427e1e8b2332ad308a3e89385fe65c334d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 09:25:02 +0000 Subject: [PATCH 303/917] Drop storage from issues monitor --- rust/agama-utils/src/issue/monitor.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rust/agama-utils/src/issue/monitor.rs b/rust/agama-utils/src/issue/monitor.rs index 6b39402de4..c95a4d4546 100644 --- a/rust/agama-utils/src/issue/monitor.rs +++ b/rust/agama-utils/src/issue/monitor.rs @@ -62,7 +62,6 @@ const STORAGE_SERVICE: &str = "org.opensuse.Agama.Storage1"; const ISCSI_PATH: &str = "/org/opensuse/Agama/Storage1/ISCSI"; const PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; const SOFTWARE_PATH: &str = "/org/opensuse/Agama/Software1"; -const STORAGE_PATH: &str = "/org/opensuse/Agama/Storage1"; const USERS_PATH: &str = "/org/opensuse/Agama/Users1"; impl Monitor { @@ -79,8 +78,6 @@ impl Monitor { .await?; self.initialize_issues(SOFTWARE_SERVICE, PRODUCT_PATH) .await?; - self.initialize_issues(STORAGE_SERVICE, STORAGE_PATH) - .await?; self.initialize_issues(STORAGE_SERVICE, ISCSI_PATH).await?; while let Some(Ok(message)) = messages.next().await { @@ -179,7 +176,6 @@ impl Monitor { match path { SOFTWARE_PATH => Some(Scope::Software), PRODUCT_PATH => Some(Scope::Product), - STORAGE_PATH => Some(Scope::Storage), USERS_PATH => Some(Scope::Users), ISCSI_PATH => Some(Scope::Iscsi), _ => None, From 34352fa45dee59a1d2958f32b1afcac6ffdca0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 10:36:48 +0000 Subject: [PATCH 304/917] Improve questions CLI set_mode function --- rust/agama-cli/src/questions.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 87d09b2531..6892293e8f 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -63,10 +63,9 @@ pub enum Modes { } async fn set_mode(client: HTTPClient, value: Modes) -> anyhow::Result<()> { - let policy = if value == Modes::Interactive { - Policy::User - } else { - Policy::Auto + let policy = match value { + Modes::Interactive => Policy::User, + Modes::NonInteractive => Policy::Auto, }; client.set_mode(policy).await?; From 9b97bba46a0812818bb253ae5c83d86165b38b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 10:37:16 +0000 Subject: [PATCH 305/917] Drop unneeded fields from Repository --- rust/agama-software/src/model/packages.rs | 6 ------ rust/agama-software/src/model/state.rs | 6 ------ rust/agama-software/src/service.rs | 3 --- rust/agama-software/src/zypp_server.rs | 3 --- 4 files changed, 18 deletions(-) diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 28a96c15a7..7225df2c3d 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -75,20 +75,14 @@ pub struct ResolvableParams { #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Repository { - /// Repository identifier - pub id: Option, /// Repository alias. It has to be unique. pub alias: String, /// Repository name pub name: String, /// Repository URL (raw format without expanded variables) pub url: String, - /// Product directory (currently not used, valid only for multiproduct DVDs) - pub product_dir: String, // FIXME: needed? /// Whether the repository is enabled pub enabled: bool, - /// Whether the repository is loaded - pub loaded: bool, // FIXME: needed? /// Whether the repository is mandatory (offline base repo, DUD repositories, etc.) pub mandatory: bool, } diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 5c73a04249..7004aae822 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -434,25 +434,19 @@ mod tests { let config = build_user_config(Some(patterns)); let base_repo = Repository { - id: None, alias: "install".to_string(), name: "install".to_string(), url: "hd:/run/initramfs/install".to_string(), enabled: false, - loaded: false, mandatory: true, - product_dir: "".to_string(), }; let another_repo = Repository { - id: None, alias: "another".to_string(), name: "another".to_string(), url: "https://example.lan/SLES/".to_string(), enabled: false, - loaded: false, mandatory: false, - product_dir: "".to_string(), }; let system = SystemInfo { diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index db5d61dd0b..c0fa872260 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -270,12 +270,9 @@ fn find_install_repository() -> Option { } normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| packages::Repository { - id: None, alias: "install".to_string(), name: "install".to_string(), url, - product_dir: "".to_string(), - loaded: false, enabled: true, mandatory: true, }) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 77f7b0a3bb..33aa45ebd0 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -261,12 +261,9 @@ impl ZyppServer { .map(|(index, repo)| Repository { url: repo.url, // unwrap here is ok, as number of repos are low - id: Some(index.try_into().unwrap()), // TODO: remove it when not needed, DBus relict, alias should be always unique alias: repo.alias, name: repo.user_name, - product_dir: "/".to_string(), // TODO: get it from zypp enabled: repo.enabled, - loaded: true, mandatory: false, }) .collect() From 014c4c1ad417d7e9083396dadd406241c7e7dbdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 10:52:44 +0000 Subject: [PATCH 306/917] Rename Resolvable to ResolvableName in the context of SoftwareStage --- rust/agama-cli/src/questions.rs | 2 +- rust/agama-software/src/model/state.rs | 40 +++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 6892293e8f..f15f018b42 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -37,7 +37,7 @@ pub enum QuestionsCommands { /// mode or change the answer in automatic mode. /// /// Please check Agama documentation for more details and examples: - /// https://github.com/openSUSE/agama/blob/master/doc/questions.md + /// https://github.com/openSUSE/agama/blob/master/doc/questions. Answers { /// Path to a file containing the answers in JSON format. path: String, diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 7004aae822..54dc1a113c 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -41,8 +41,8 @@ pub struct SoftwareState { pub product: String, pub repositories: Vec, // TODO: consider implementing a list to make easier working with them. - pub patterns: Vec, - pub packages: Vec, + pub patterns: Vec, + pub packages: Vec, pub options: SoftwareOptions, } @@ -116,13 +116,13 @@ impl<'a> SoftwareStateBuilder<'a> { state.patterns.retain(|p| p.optional == false); state .patterns - .extend(list.iter().map(|n| Resolvable::new(n, false))); + .extend(list.iter().map(|n| ResolvableName::new(n, false))); } PatternsConfig::PatternsMap(map) => { if let Some(add) = &map.add { state .patterns - .extend(add.iter().map(|n| Resolvable::new(n, false))); + .extend(add.iter().map(|n| ResolvableName::new(n, false))); } if let Some(remove) = &map.remove { @@ -158,24 +158,24 @@ impl<'a> SoftwareStateBuilder<'a> { }) .collect(); - let mut patterns: Vec = software + let mut patterns: Vec = software .mandatory_patterns .iter() - .map(|p| Resolvable::new(p, false)) + .map(|p| ResolvableName::new(p, false)) .collect(); patterns.extend( software .optional_patterns .iter() - .map(|p| Resolvable::new(p, true)), + .map(|p| ResolvableName::new(p, true)), ); patterns.extend(software.user_patterns.iter().filter_map(|p| match p { UserPattern::Plain(_) => None, UserPattern::Preselected(pattern) => { if pattern.selected { - Some(Resolvable::new(&pattern.name, true)) + Some(ResolvableName::new(&pattern.name, true)) } else { None } @@ -235,14 +235,14 @@ impl From<&packages::Repository> for Repository { /// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] -pub struct Resolvable { +pub struct ResolvableName { /// Resolvable name. pub name: String, /// Whether this resolvable is optional or not. pub optional: bool, } -impl Resolvable { +impl ResolvableName { pub fn new(name: &str, optional: bool) -> Self { Self { name: name.to_string(), @@ -323,8 +323,8 @@ mod tests { assert_eq!( state.patterns, vec![ - Resolvable::new("enhanced_base", false), - Resolvable::new("selinux", true), + ResolvableName::new("enhanced_base", false), + ResolvableName::new("selinux", true), ] ); } @@ -363,9 +363,9 @@ mod tests { assert_eq!( state.patterns, vec![ - Resolvable::new("enhanced_base", false), - Resolvable::new("selinux", true), - Resolvable::new("gnome", false) + ResolvableName::new("enhanced_base", false), + ResolvableName::new("selinux", true), + ResolvableName::new("gnome", false) ] ); } @@ -384,7 +384,7 @@ mod tests { .build(); assert_eq!( state.patterns, - vec![Resolvable::new("enhanced_base", false),] + vec![ResolvableName::new("enhanced_base", false),] ); } @@ -403,8 +403,8 @@ mod tests { assert_eq!( state.patterns, vec![ - Resolvable::new("enhanced_base", false), - Resolvable::new("selinux", true) + ResolvableName::new("enhanced_base", false), + ResolvableName::new("selinux", true) ] ); } @@ -421,8 +421,8 @@ mod tests { assert_eq!( state.patterns, vec![ - Resolvable::new("enhanced_base", false), - Resolvable::new("gnome", false) + ResolvableName::new("enhanced_base", false), + ResolvableName::new("gnome", false) ] ); } From bbde91fd6261575e77716f4ab09459acdf7be795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 10:58:38 +0000 Subject: [PATCH 307/917] Improve GIT_DIR detection Co-authored-by: Josef Reidinger --- service/lib/agama/config_reader.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/config_reader.rb b/service/lib/agama/config_reader.rb index c67590d387..87f42d0297 100644 --- a/service/lib/agama/config_reader.rb +++ b/service/lib/agama/config_reader.rb @@ -137,7 +137,7 @@ def remote_config end def default_path - Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end def config_paths From 8494fc1792a080b8065828aad35f70a8209bc9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 11:02:43 +0000 Subject: [PATCH 308/917] Improve GIT_DIR detection Co-authored-by: Josef Reidinger --- service/lib/agama/product_reader.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/product_reader.rb b/service/lib/agama/product_reader.rb index 66ccdd779c..18e020e9d7 100644 --- a/service/lib/agama/product_reader.rb +++ b/service/lib/agama/product_reader.rb @@ -58,7 +58,7 @@ def load_products private def default_path - Dir.exist?(GIT_DIR) || File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH + File.exist?(GIT_DIR) ? GIT_PATH : SYSTEM_PATH end end end From b7f1adfaddc3816a8c0658dd2b902fa407e6af95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 30 Oct 2025 23:11:44 +0000 Subject: [PATCH 309/917] Drop the old software and product handling code --- rust/Cargo.lock | 1 + rust/agama-lib/src/http/event.rs | 9 - rust/agama-lib/src/install_settings.rs | 8 +- rust/agama-lib/src/lib.rs | 4 +- rust/agama-lib/src/product.rs | 32 - rust/agama-lib/src/product/client.rs | 216 ----- rust/agama-lib/src/product/http_client.rs | 164 ---- rust/agama-lib/src/product/proxies.rs | 94 -- rust/agama-lib/src/product/settings.rs | 54 -- rust/agama-lib/src/product/store.rs | 301 ------- rust/agama-lib/src/scripts/store.rs | 12 +- rust/agama-lib/src/software.rs | 33 - rust/agama-lib/src/software/client.rs | 362 -------- rust/agama-lib/src/software/http_client.rs | 108 --- rust/agama-lib/src/software/model.rs | 152 ---- rust/agama-lib/src/software/model/conflict.rs | 104 --- rust/agama-lib/src/software/model/license.rs | 347 -------- rust/agama-lib/src/software/model/packages.rs | 119 --- .../src/software/model/registration.rs | 88 -- rust/agama-lib/src/software/proxies.rs | 28 - .../agama-lib/src/software/proxies/product.rs | 72 -- .../src/software/proxies/proposal.rs | 79 -- .../src/software/proxies/software.rs | 135 --- rust/agama-lib/src/software/settings.rs | 98 -- rust/agama-lib/src/software/store.rs | 236 ----- rust/agama-lib/src/store.rs | 46 - rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/software.rs | 22 - rust/agama-server/src/software/web.rs | 836 ------------------ rust/agama-server/src/web/docs.rs | 2 - rust/agama-server/src/web/docs/config.rs | 25 +- rust/agama-server/src/web/docs/software.rs | 73 -- rust/agama-software/src/lib.rs | 2 +- rust/xtask/src/main.rs | 3 +- 35 files changed, 19 insertions(+), 3848 deletions(-) delete mode 100644 rust/agama-lib/src/product.rs delete mode 100644 rust/agama-lib/src/product/client.rs delete mode 100644 rust/agama-lib/src/product/http_client.rs delete mode 100644 rust/agama-lib/src/product/proxies.rs delete mode 100644 rust/agama-lib/src/product/settings.rs delete mode 100644 rust/agama-lib/src/product/store.rs delete mode 100644 rust/agama-lib/src/software.rs delete mode 100644 rust/agama-lib/src/software/client.rs delete mode 100644 rust/agama-lib/src/software/http_client.rs delete mode 100644 rust/agama-lib/src/software/model.rs delete mode 100644 rust/agama-lib/src/software/model/conflict.rs delete mode 100644 rust/agama-lib/src/software/model/license.rs delete mode 100644 rust/agama-lib/src/software/model/packages.rs delete mode 100644 rust/agama-lib/src/software/model/registration.rs delete mode 100644 rust/agama-lib/src/software/proxies.rs delete mode 100644 rust/agama-lib/src/software/proxies/product.rs delete mode 100644 rust/agama-lib/src/software/proxies/proposal.rs delete mode 100644 rust/agama-lib/src/software/proxies/software.rs delete mode 100644 rust/agama-lib/src/software/settings.rs delete mode 100644 rust/agama-lib/src/software/store.rs delete mode 100644 rust/agama-server/src/software.rs delete mode 100644 rust/agama-server/src/software/web.rs delete mode 100644 rust/agama-server/src/web/docs/software.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0aa35fe639..798f2ba3ab 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -175,6 +175,7 @@ dependencies = [ "agama-lib", "agama-locale-data", "agama-manager", + "agama-software", "agama-utils", "anyhow", "async-trait", diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index 048c06e22e..378e0ea320 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -24,7 +24,6 @@ use crate::{ manager::InstallationPhase, network::model::NetworkChange, progress::Progress, - software::{model::Conflict, SelectedBy}, storage::{ model::{ dasd::{DASDDevice, DASDFormatSummary}, @@ -104,14 +103,6 @@ pub enum EventPayload { change: NetworkChange, }, StorageChanged, - // TODO: it should include the full software proposal or, at least, - // all the relevant changes. - SoftwareProposalChanged { - patterns: HashMap, - }, - ConflictsChanged { - conflicts: Vec, - }, QuestionsChanged, InstallationPhaseChanged { phase: InstallationPhase, diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index ffeded93e8..fa7be43229 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -29,8 +29,8 @@ use crate::hostname::model::HostnameSettings; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ - network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, - software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, + network::NetworkSettings, scripts::ScriptsConfig, + storage::settings::dasd::DASDConfig, users::UserSettings, }; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; @@ -71,10 +71,6 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub security: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub software: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub product: Option, - #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Object)] pub storage: Option>, #[serde(rename = "legacyAutoyastStorage")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 3c3e3094c7..b859d77ba1 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -30,7 +30,7 @@ //! //! Let's have a look to the components that are involved when dealing with the installation //! settings, as it is the most complex part of the library. The code is organized in a set of -//! modules, one for each topic, like [network], [software], and so on. +//! modules, one for each topic. //! //! Each of those modules contains, at least: //! @@ -58,14 +58,12 @@ pub mod logs; pub mod manager; pub mod monitor; pub mod network; -pub mod product; pub mod profile; pub mod progress; pub mod proxies; pub mod questions; pub mod scripts; pub mod security; -pub mod software; pub mod storage; mod store; pub mod users; diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs deleted file mode 100644 index cc8974480a..0000000000 --- a/rust/agama-lib/src/product.rs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for handling the product settings - -mod client; -mod http_client; -pub mod proxies; -mod settings; -mod store; - -pub use client::{Product, ProductClient}; -pub use http_client::ProductHTTPClient; -pub use settings::{AddonSettings, ProductSettings}; -pub use store::{ProductStore, ProductStoreError}; diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs deleted file mode 100644 index e9b33a2331..0000000000 --- a/rust/agama-lib/src/product/client.rs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::error::ServiceError; -use crate::software::model::{AddonParams, AddonProperties}; -use crate::software::proxies::SoftwareProductProxy; -use agama_utils::dbus::{get_optional_property, get_property}; -use serde::Serialize; -use std::collections::HashMap; -use zbus::Connection; - -use super::proxies::RegistrationProxy; - -/// Represents a software product -#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Product { - /// Product ID (eg., "ALP", "Tumbleweed", etc.) - pub id: String, - /// Product name (e.g., "openSUSE Tumbleweed") - pub name: String, - /// Product description - pub description: String, - /// Product icon (e.g., "default.svg") - pub icon: String, - /// Registration requirement - pub registration: bool, - /// License ID - pub license: Option, -} - -/// D-Bus client for the software service -#[derive(Clone)] -pub struct ProductClient<'a> { - product_proxy: SoftwareProductProxy<'a>, - registration_proxy: RegistrationProxy<'a>, -} - -impl<'a> ProductClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - let product_proxy = SoftwareProductProxy::builder(&connection) - .cache_properties(zbus::proxy::CacheProperties::No) - .build() - .await?; - Ok(Self { - product_proxy, - registration_proxy: RegistrationProxy::new(&connection).await?, - }) - } - - /// Returns the available products - pub async fn products(&self) -> Result, ServiceError> { - let products: Vec = self - .product_proxy - .available_products() - .await? - .into_iter() - .map(|(id, name, data)| { - let description = match data.get("description") { - Some(value) => value.try_into().unwrap(), - None => "", - }; - let icon = match data.get("icon") { - Some(value) => value.try_into().unwrap(), - None => "default.svg", - }; - - let registration = get_property::(&data, "registration").unwrap_or(false); - - let license = get_optional_property::(&data, "license").unwrap_or_default(); - - Product { - id, - name, - description: description.to_string(), - icon: icon.to_string(), - registration, - license, - } - }) - .collect(); - Ok(products) - } - - /// Returns the id of the selected product to install - pub async fn product(&self) -> Result { - Ok(self.product_proxy.selected_product().await?) - } - - /// Selects the product to install - pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { - let result = self.product_proxy.select_product(product_id).await?; - - match result { - (0, _) => Ok(()), - (3, description) => { - let products = self.products().await?; - let ids: Vec = products.into_iter().map(|p| p.id).collect(); - let error = format!("{0}. Available products: '{1:?}'", description, ids); - Err(ServiceError::UnsuccessfulAction(error)) - } - (_, description) => Err(ServiceError::UnsuccessfulAction(description)), - } - } - - /// flag if base product is registered - pub async fn registered(&self) -> Result { - Ok(self.registration_proxy.registered().await?) - } - - /// registration code used to register product - pub async fn registration_code(&self) -> Result { - Ok(self.registration_proxy.reg_code().await?) - } - - /// email used to register product - pub async fn email(&self) -> Result { - Ok(self.registration_proxy.email().await?) - } - - /// URL of the registration server - pub async fn registration_url(&self) -> Result { - Ok(self.registration_proxy.url().await?) - } - - /// set registration url - pub async fn set_registration_url(&self, url: &str) -> Result<(), ServiceError> { - Ok(self.registration_proxy.set_url(url).await?) - } - - /// list of already registered addons - pub async fn registered_addons(&self) -> Result, ServiceError> { - let addons: Vec = self - .registration_proxy - .registered_addons() - .await? - .into_iter() - .map(|(id, version, code)| AddonParams { - id, - version: if version.is_empty() { - None - } else { - Some(version) - }, - registration_code: if code.is_empty() { None } else { Some(code) }, - }) - .collect(); - Ok(addons) - } - - // details of available addons - pub async fn available_addons(&self) -> Result, ServiceError> { - self.registration_proxy - .available_addons() - .await? - .into_iter() - .map(|hash| { - Ok(AddonProperties { - id: get_property(&hash, "id")?, - version: get_property(&hash, "version")?, - label: get_property(&hash, "label")?, - available: get_property(&hash, "available")?, - free: get_property(&hash, "free")?, - recommended: get_property(&hash, "recommended")?, - description: get_property(&hash, "description")?, - release: get_property(&hash, "release")?, - r#type: get_property(&hash, "type")?, - }) - }) - .collect() - } - - /// register product - pub async fn register(&self, code: &str, email: &str) -> Result<(u32, String), ServiceError> { - let mut options: HashMap<&str, &zbus::zvariant::Value> = HashMap::new(); - let value = zbus::zvariant::Value::from(email); - if !email.is_empty() { - options.insert("Email", &value); - } - Ok(self.registration_proxy.register(code, options).await?) - } - - /// register addon - pub async fn register_addon(&self, addon: &AddonParams) -> Result<(u32, String), ServiceError> { - Ok(self - .registration_proxy - .register_addon( - &addon.id, - &addon.version.clone().unwrap_or_default(), - &addon.registration_code.clone().unwrap_or_default(), - ) - .await?) - } - - /// de-register product - pub async fn deregister(&self) -> Result<(u32, String), ServiceError> { - Ok(self.registration_proxy.deregister().await?) - } -} diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs deleted file mode 100644 index 7f9d7fc5d2..0000000000 --- a/rust/agama-lib/src/product/http_client.rs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use crate::software::model::{ - AddonParams, RegistrationError, RegistrationInfo, RegistrationParams, SoftwareConfig, -}; - -use super::settings::AddonSettings; - -#[derive(Debug, thiserror::Error)] -pub enum ProductHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), - // If present, the number is already printed in the String part - #[error("Registration failed: {0}")] - FailedRegistration(String, Option), -} - -pub struct ProductHTTPClient { - client: BaseHTTPClient, -} - -impl ProductHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_software(&self) -> Result { - Ok(self.client.get("/software/config").await?) - } - - pub async fn set_software( - &self, - config: &SoftwareConfig, - ) -> Result<(), ProductHTTPClientError> { - Ok(self.client.put_void("/software/config", config).await?) - } - - /// Returns the id of the selected product to install - pub async fn product(&self) -> Result { - let config = self.get_software().await?; - if let Some(product) = config.product { - Ok(product) - } else { - Ok("".to_owned()) - } - } - - /// Selects the product to install - pub async fn select_product(&self, product_id: &str) -> Result<(), ProductHTTPClientError> { - let config = SoftwareConfig { - product: Some(product_id.to_owned()), - patterns: None, - packages: None, - extra_repositories: None, - only_required: None, - }; - self.set_software(&config).await - } - - pub async fn get_registration(&self) -> Result { - Ok(self.client.get("/software/registration").await?) - } - - pub async fn set_registration_url(&self, url: &String) -> Result<(), ProductHTTPClientError> { - self.client - .put_void("/software/registration/url", url) - .await?; - Ok(()) - } - - // get list of registered addons - pub async fn get_registered_addons( - &self, - ) -> Result, ProductHTTPClientError> { - let addons = self - .client - .get("/software/registration/addons/registered") - .await?; - Ok(addons) - } - - /// register product - pub async fn register(&self, key: &str, email: &str) -> Result<(), ProductHTTPClientError> { - // note RegistrationParams != RegistrationInfo, fun! - let params = RegistrationParams { - key: key.to_owned(), - email: email.to_owned(), - }; - let result = self - .client - .post_void("/software/registration", ¶ms) - .await; - - let Err(error) = result else { - return Ok(()); - }; - - let mut id: Option = None; - - let message = match error { - BaseHTTPClientError::BackendError(_, details) => { - let details: RegistrationError = serde_json::from_str(&details).unwrap(); - id = Some(details.id); - format!("{} (error code: {})", details.message, details.id) - } - _ => format!("Could not register the product: #{error:?}"), - }; - - Err(ProductHTTPClientError::FailedRegistration(message, id)) - } - - /// register addon - pub async fn register_addon( - &self, - addon: &AddonSettings, - ) -> Result<(), ProductHTTPClientError> { - let addon_params = AddonParams { - id: addon.id.to_owned(), - version: addon.version.to_owned(), - registration_code: addon.registration_code.to_owned(), - }; - let result = self - .client - .post_void("/software/registration/addons/register", &addon_params) - .await; - - let Err(error) = result else { - return Ok(()); - }; - - let mut id: Option = None; - - let message = match error { - BaseHTTPClientError::BackendError(_, details) => { - println!("Details: {:?}", details); - let details: RegistrationError = serde_json::from_str(&details).unwrap(); - id = Some(details.id); - format!("{} (error code: {})", details.message, details.id) - } - _ => format!("Could not register the addon: #{error:?}"), - }; - - Err(ProductHTTPClientError::FailedRegistration(message, id)) - } -} diff --git a/rust/agama-lib/src/product/proxies.rs b/rust/agama-lib/src/product/proxies.rs deleted file mode 100644 index 97d7c4d3a9..0000000000 --- a/rust/agama-lib/src/product/proxies.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama1.Registration` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Product.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Product", - interface = "org.opensuse.Agama1.Registration", - assume_defaults = true -)] -pub trait Registration { - /// Deregister method - fn deregister(&self) -> zbus::Result<(u32, String)>; - - /// Register method - fn register( - &self, - reg_code: &str, - options: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, - ) -> zbus::Result<(u32, String)>; - - /// Register addon method - fn register_addon( - &self, - name: &str, - version: &str, - reg_code: &str, - ) -> zbus::Result<(u32, String)>; - - /// Email property - #[zbus(property)] - fn email(&self) -> zbus::Result; - - /// RegCode property - #[zbus(property)] - fn reg_code(&self) -> zbus::Result; - - /// Registered property - #[zbus(property)] - fn registered(&self) -> zbus::Result; - - /// Url property - #[zbus(property)] - fn url(&self) -> zbus::Result; - #[zbus(property)] - fn set_url(&self, value: &str) -> zbus::Result<()>; - - /// registered addons property, list of tuples (name, version, reg_code)) - #[zbus(property)] - fn registered_addons(&self) -> zbus::Result>; - - /// available addons property, a hash with string key - #[zbus(property)] - fn available_addons( - &self, - ) -> zbus::Result>>; -} diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs deleted file mode 100644 index ed284e17d5..0000000000 --- a/rust/agama-lib/src/product/settings.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Representation of the product settings - -use serde::{Deserialize, Serialize}; - -/// Addon settings for registration -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonSettings { - pub id: String, - /// Optional version of the addon, if not specified the version is found - /// from the available addons - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Free extensions do not require a registration code - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, -} - -/// Software settings for installation -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProductSettings { - /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub addons: Option>, -} diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs deleted file mode 100644 index 4aaee1beda..0000000000 --- a/rust/agama-lib/src/product/store.rs +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the product settings. -use super::{http_client::ProductHTTPClientError, ProductHTTPClient, ProductSettings}; -use crate::{ - http::BaseHTTPClient, - manager::http_client::{ManagerHTTPClient, ManagerHTTPClientError}, -}; -use std::time; -use tokio::time::sleep; - -// registration retry attempts -const RETRY_ATTEMPTS: u32 = 4; -// initial delay for exponential backoff in seconds, it doubles after every retry (2,4,8,16) -const INITIAL_RETRY_DELAY: u64 = 2; - -#[derive(Debug, thiserror::Error)] -pub enum ProductStoreError { - #[error("Error processing product settings: {0}")] - Product(#[from] ProductHTTPClientError), - #[error("Error reading software repositories: {0}")] - Probe(#[from] ManagerHTTPClientError), -} - -type ProductStoreResult = Result; - -/// Loads and stores the product settings from/to the D-Bus service. -pub struct ProductStore { - product_client: ProductHTTPClient, - manager_client: ManagerHTTPClient, -} - -impl ProductStore { - pub fn new(client: BaseHTTPClient) -> ProductStore { - Self { - product_client: ProductHTTPClient::new(client.clone()), - manager_client: ManagerHTTPClient::new(client), - } - } - - fn non_empty_string(s: String) -> Option { - if s.is_empty() { - None - } else { - Some(s) - } - } - - pub async fn load(&self) -> ProductStoreResult { - let product = self.product_client.product().await?; - let registration_info = self.product_client.get_registration().await?; - let registered_addons = self.product_client.get_registered_addons().await?; - - let addons = if registered_addons.is_empty() { - None - } else { - Some(registered_addons) - }; - Ok(ProductSettings { - id: Some(product), - registration_code: Self::non_empty_string(registration_info.key), - registration_email: Self::non_empty_string(registration_info.email), - registration_url: Self::non_empty_string(registration_info.url), - addons, - }) - } - - pub async fn store(&self, settings: &ProductSettings) -> ProductStoreResult<()> { - let mut probe = false; - let mut reprobe = false; - if let Some(product) = &settings.id { - let existing_product = self.product_client.product().await?; - if *product != existing_product { - // avoid selecting same product and unnecessary probe - self.product_client.select_product(product).await?; - probe = true; - } - } - // register system if either URL or reg code is provided as RMT does not need reg code and SCC uses default url - // bsc#1246069 - if settings.registration_code.is_some() || settings.registration_url.is_some() { - if let Some(url) = &settings.registration_url { - self.product_client.set_registration_url(url).await?; - } - // lets use empty string if not defined - let reg_code = settings.registration_code.as_deref().unwrap_or(""); - let email = settings.registration_email.as_deref().unwrap_or(""); - - self.retry_registration(|| self.product_client.register(reg_code, email)) - .await?; - // TODO: avoid reprobing if the system has been already registered with the same code? - reprobe = true; - } - - // register the addons in the order specified in the profile - if let Some(addons) = &settings.addons { - for addon in addons.iter() { - self.retry_registration(|| self.product_client.register_addon(addon)) - .await?; - } - } - - if probe { - self.manager_client.probe().await?; - } else if reprobe { - self.manager_client.reprobe().await?; - } - - Ok(()) - } - - // shared retry logic for base product and addon registration - async fn retry_registration(&self, block: F) -> Result<(), ProductHTTPClientError> - where - F: AsyncFn() -> Result<(), ProductHTTPClientError>, - { - // retry counter - let mut attempt = 0; - loop { - // call the passed block - let result = block().await; - - match result { - // success, leave the loop - Ok(()) => return result, - Err(ref error) => { - match error { - ProductHTTPClientError::FailedRegistration(_msg, code) => { - match code { - // see service/lib/agama/dbus/software/product.rb - // 4 => network error, 5 => timeout error - Some(4) | Some(5) => { - if attempt >= RETRY_ATTEMPTS { - // still failing, report the error - return result; - } - - // wait a bit then retry (run the loop again) - let delay = INITIAL_RETRY_DELAY << attempt; - eprintln!("Retrying registration in {} seconds...", delay); - sleep(time::Duration::from_secs(delay)).await; - attempt += 1; - } - // fail for other or unknown problems, retry very likely won't help - _ => return result, - } - } - // an HTTP error, fail - _ => return result, - } - } - } - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - fn product_store(mock_server_url: String) -> ProductStore { - let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); - let p_client = ProductHTTPClient::new(bhc.clone()); - let m_client = ManagerHTTPClient::new(bhc); - ProductStore { - product_client: p_client, - manager_client: m_client, - } - } - - #[test] - async fn test_getting_product() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {"xfce":true}, - "product": "Tumbleweed" - }"#, - ); - }); - let registration_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/registration"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "registered": false, - "key": "", - "email": "", - "url": "" - }"#, - ); - }); - let addons_mock = server.mock(|when, then| { - when.method(GET) - .path("/api/software/registration/addons/registered"); - then.status(200) - .header("content-type", "application/json") - .body("[]"); - }); - let url = server.url("/api"); - - let store = product_store(url); - let settings = store.load().await?; - - let expected = ProductSettings { - id: Some("Tumbleweed".to_owned()), - registration_code: None, - registration_email: None, - registration_url: None, - addons: None, - }; - // main assertion - assert_eq!(settings, expected); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - registration_mock.assert(); - addons_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_product_ok() -> Result<(), Box> { - let server = MockServer::start(); - // no product selected at first - let get_software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {}, - "packages": [], - "product": "" - }"#, - ); - }); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":null,"packages":null,"product":"Tumbleweed","extraRepositories":null,"onlyRequired":null}"#); - then.status(200); - }); - let manager_mock = server.mock(|when, then| { - when.method(POST) - .path("/api/manager/probe_sync") - .header("content-type", "application/json") - .body("null"); - then.status(200); - }); - let url = server.url("/api"); - - let store = product_store(url); - let settings = ProductSettings { - id: Some("Tumbleweed".to_owned()), - registration_code: None, - registration_email: None, - registration_url: None, - addons: None, - }; - - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - get_software_mock.assert(); - software_mock.assert(); - manager_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 5ab57ebff4..a832b6919f 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -21,7 +21,6 @@ use crate::{ file_source::FileSourceError, http::BaseHTTPClient, - software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, }; use super::{ @@ -34,8 +33,6 @@ use super::{ pub enum ScriptsStoreError { #[error("Error processing script settings: {0}")] Script(#[from] ScriptsClientError), - #[error("Error selecting software: {0}")] - Software(#[from] SoftwareHTTPClientError), #[error(transparent)] FileSourceError(#[from] FileSourceError), } @@ -44,14 +41,12 @@ type ScriptStoreResult = Result; pub struct ScriptsStore { scripts: ScriptsClient, - software: SoftwareHTTPClient, } impl ScriptsStore { pub fn new(client: BaseHTTPClient) -> Self { Self { scripts: ScriptsClient::new(client.clone()), - software: SoftwareHTTPClient::new(client), } } @@ -94,9 +89,10 @@ impl ScriptsStore { } packages.push("agama-scripts"); } - self.software - .set_resolvables("agama-scripts", ResolvableType::Package, &packages, true) - .await?; + // TODO: use the new API. + // self.software + // .set_resolvables("agama-scripts", ResolvableType::Package, &packages, true) + // .await?; Ok(()) } diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs deleted file mode 100644 index b04e09f7be..0000000000 --- a/rust/agama-lib/src/software.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for handling the software settings - -mod client; -mod http_client; -pub mod model; -pub mod proxies; -mod settings; -mod store; - -pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; -pub use http_client::{SoftwareHTTPClient, SoftwareHTTPClientError}; -pub use settings::{PatternsMap, PatternsSettings, SoftwareSettings}; -pub use store::{SoftwareStore, SoftwareStoreError}; diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs deleted file mode 100644 index 20c7728677..0000000000 --- a/rust/agama-lib/src/software/client.rs +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use super::{ - model::{Conflict, ConflictSolve, Repository, ResolvableType}, - proxies::{ProposalProxy, Software1Proxy}, -}; -use crate::{error::ServiceError, software::model::RepositoryParams}; -use agama_utils::dbus::{get_optional_property, get_property}; -use serde::Serialize; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::collections::HashMap; -use zbus::Connection; - -const USER_RESOLVABLES_LIST: &str = "user"; - -// TODO: move it to model? -/// Represents a software product -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct Pattern { - /// Pattern name (eg., "aaa_base", "gnome") - pub name: String, - /// Pattern category (e.g., "Production") - pub category: String, - /// Pattern icon path locally on system - pub icon: String, - /// Pattern description - pub description: String, - /// Pattern summary - pub summary: String, - /// Pattern order - pub order: String, -} - -/// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Deserialize_repr, Serialize_repr, utoipa::ToSchema)] -#[repr(u8)] -pub enum SelectedBy { - /// The pattern was selected by the user. - User = 0, - /// The pattern was selected automatically. - Auto = 1, - /// The pattern has not be selected. - None = 2, -} - -#[derive(Debug, thiserror::Error)] -#[error("Unknown selected by value: '{0}'")] -pub struct UnknownSelectedBy(u8); - -impl TryFrom for SelectedBy { - type Error = UnknownSelectedBy; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Self::User), - 1 => Ok(Self::Auto), - _ => Err(UnknownSelectedBy(value)), - } - } -} - -/// D-Bus client for the software service -#[derive(Clone)] -pub struct SoftwareClient<'a> { - software_proxy: Software1Proxy<'a>, - proposal_proxy: ProposalProxy<'a>, -} - -impl<'a> SoftwareClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - Ok(Self { - software_proxy: Software1Proxy::new(&connection).await?, - proposal_proxy: ProposalProxy::new(&connection).await?, - }) - } - - /// Returns list of defined repositories - pub async fn repositories(&self) -> Result, ServiceError> { - let repositories: Vec = self - .software_proxy - .list_repositories() - .await? - .into_iter() - .map( - |(id, alias, name, url, product_dir, enabled, loaded)| Repository { - id, - alias, - name, - url, - product_dir, - enabled, - loaded, - }, - ) - .collect(); - Ok(repositories) - } - - /// Returns list of user defined repositories - pub async fn user_repositories(&self) -> Result, ServiceError> { - self.software_proxy - .list_user_repositories() - .await? - .into_iter() - .map(|params| - // unwrapping below is OK as it is our own dbus API, so we know what is in variants - Ok(RepositoryParams { - priority: get_optional_property(¶ms, "priority")?, - alias: get_property(¶ms, "alias")?, - name: get_optional_property(¶ms, "name")?, - url: get_property(¶ms, "url")?, - product_dir: get_optional_property(¶ms, "product_dir")?, - enabled: get_optional_property(¶ms, "enabled")?, - allow_unsigned: get_optional_property(¶ms, "allow_unsigned")?, - gpg_fingerprints: get_optional_property(¶ms, "gpg_fingerprints")?, - })) - .collect() - } - - pub async fn set_user_repositories( - &self, - repos: Vec, - ) -> Result<(), ServiceError> { - let dbus_repos: Vec>> = repos - .into_iter() - .map(|params| { - let mut result: HashMap<&str, zbus::zvariant::Value<'_>> = HashMap::new(); - result.insert("alias", params.alias.into()); - result.insert("url", params.url.into()); - if let Some(priority) = params.priority { - result.insert("priority", priority.into()); - } - if let Some(name) = params.name { - result.insert("name", name.into()); - } - if let Some(product_dir) = params.product_dir { - result.insert("product_dir", product_dir.into()); - } - if let Some(enabled) = params.enabled { - result.insert("enabled", enabled.into()); - } - if let Some(allow_unsigned) = params.allow_unsigned { - result.insert("allow_unsigned", allow_unsigned.into()); - } - if let Some(gpg_fingerprints) = params.gpg_fingerprints { - result.insert("gpg_fingerprints", gpg_fingerprints.into()); - } - result - }) - .collect(); - self.software_proxy - .set_user_repositories(&dbus_repos) - .await?; - Ok(()) - } - - /// Returns the available patterns - pub async fn patterns(&self, filtered: bool) -> Result, ServiceError> { - let patterns: Vec = self - .software_proxy - .list_patterns(filtered) - .await? - .into_iter() - .map( - |(name, (category, description, icon, summary, order))| Pattern { - name, - category, - icon, - description, - summary, - order, - }, - ) - .collect(); - Ok(patterns) - } - - /// Returns the ids of patterns selected by user - pub async fn user_selected_patterns(&self) -> Result, ServiceError> { - let patterns: Vec = self - .software_proxy - .selected_patterns() - .await? - .into_iter() - .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { - Ok(SelectedBy::User) => Some(id), - Ok(_reason) => None, - Err(e) => { - log::warn!("Ignoring pattern {}. Error: {}", &id, e); - None - } - }) - .collect(); - Ok(patterns) - } - - /// Returns the selected pattern and the reason each one selected. - pub async fn selected_patterns(&self) -> Result, ServiceError> { - let patterns = self.software_proxy.selected_patterns().await?; - let patterns = patterns - .into_iter() - .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { - Ok(reason) => Some((id, reason)), - Err(e) => { - log::warn!("Ignoring pattern {}. Error: {}", &id, e); - None - } - }) - .collect(); - Ok(patterns) - } - - /// returns current list of conflicts - pub async fn get_conflicts(&self) -> Result, ServiceError> { - let conflicts = self.software_proxy.conflicts().await?; - let conflicts = conflicts - .into_iter() - .map(|c| Conflict::from_dbus(c)) - .collect(); - - Ok(conflicts) - } - - /// Sets solutions ( not necessary for all conflicts ) and recompute conflicts - pub async fn solve_conflicts(&self, solutions: Vec) -> Result<(), ServiceError> { - let solutions: Vec<(u32, u32)> = solutions.into_iter().map(|s| s.into()).collect(); - - Ok(self.software_proxy.solve_conflicts(&solutions).await?) - } - - /// Selects patterns by user - pub async fn select_patterns( - &self, - patterns: HashMap, - ) -> Result<(), ServiceError> { - let (add, remove): (Vec<_>, Vec<_>) = - patterns.into_iter().partition(|(_, install)| *install); - - let add: Vec<_> = add.iter().map(|(name, _)| name.as_ref()).collect(); - let remove: Vec<_> = remove.iter().map(|(name, _)| name.as_ref()).collect(); - - let wrong_patterns = self - .software_proxy - .set_user_patterns(add.as_slice(), remove.as_slice()) - .await?; - if !wrong_patterns.is_empty() { - Err(ServiceError::UnknownPatterns(wrong_patterns)) - } else { - Ok(()) - } - } - - /// Selects packages by user - /// - /// Adds the given packages to the proposal. - /// - /// * `names`: package names. - pub async fn select_packages(&self, names: Vec) -> Result<(), ServiceError> { - let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect(); - self.set_resolvables( - USER_RESOLVABLES_LIST, - ResolvableType::Package, - names.as_slice(), - true, - ) - .await?; - Ok(()) - } - - pub async fn user_selected_packages(&self) -> Result, ServiceError> { - self.get_resolvables(USER_RESOLVABLES_LIST, ResolvableType::Package, true) - .await - } - - /// Returns the required space for installing the selected patterns. - /// - /// It returns a formatted string including the size and the unit. - pub async fn used_disk_space(&self) -> Result { - Ok(self.software_proxy.used_disk_space().await?) - } - - /// Starts the process to read the repositories data. - pub async fn probe(&self) -> Result<(), ServiceError> { - Ok(self.software_proxy.probe().await?) - } - - /// Updates the resolvables list. - /// - /// * `id`: resolvable list ID. - /// * `r#type`: type of the resolvables. - /// * `resolvables`: resolvables to add. - /// * `optional`: whether the resolvables are optional. - pub async fn set_resolvables( - &self, - id: &str, - r#type: ResolvableType, - resolvables: &[&str], - optional: bool, - ) -> Result<(), ServiceError> { - self.proposal_proxy - .set_resolvables(id, r#type as u8, resolvables, optional) - .await?; - Ok(()) - } - - /// Gets a resolvables list. - /// - /// * `id`: resolvable list ID. - /// * `r#type`: type of the resolvables. - /// * `optional`: whether the resolvables are optional. - pub async fn get_resolvables( - &self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> Result, ServiceError> { - let packages = self - .proposal_proxy - .get_resolvables(id, r#type as u8, optional) - .await?; - Ok(packages) - } - - /// Sets onlyRequired flag for proposal. - /// - /// * `value`: if flag is enabled or not. - pub async fn set_only_required(&self, value: bool) -> Result<(), ServiceError> { - let dbus_value = if value { 2 } else { 1 }; - self.software_proxy.set_only_required(dbus_value).await?; - Ok(()) - } - - /// Gets onlyRequired flag for proposal. - pub async fn get_only_required(&self) -> Result, ServiceError> { - let dbus_value = self.software_proxy.only_required().await?; - let res = match dbus_value { - 0 => None, - 1 => Some(false), - 2 => Some(true), - _ => None, // should not happen - }; - Ok(res) - } -} diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs deleted file mode 100644 index 7b919fab1c..0000000000 --- a/rust/agama-lib/src/software/http_client.rs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; -use crate::software::model::SoftwareConfig; -use std::collections::HashMap; - -use super::model::{ResolvableParams, ResolvableType}; - -#[derive(Debug, thiserror::Error)] -pub enum SoftwareHTTPClientError { - #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), - #[error("Registration failed: {0}")] - FailedRegistration(String), -} - -pub struct SoftwareHTTPClient { - client: BaseHTTPClient, -} - -impl SoftwareHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_config(&self) -> Result { - Ok(self.client.get("/software/config").await?) - } - - pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), SoftwareHTTPClientError> { - // FIXME: test how errors come out: - // unknown pattern name, - // D-Bus client returns - // Err(SoftwareHTTPClientError::UnknownPatterns(wrong_patterns)) - // CLI prints: - // Anyhow(Backend call failed with status 400 and text '{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}') - Ok(self.client.put_void("/software/config", config).await?) - } - - /// Returns the ids of patterns selected by user - pub async fn user_selected_patterns(&self) -> Result, SoftwareHTTPClientError> { - // TODO: this way we unnecessarily ask D-Bus (via web.rs) also for the product and then ignore it - let config = self.get_config().await?; - - let Some(patterns_map) = config.patterns else { - return Ok(vec![]); - }; - - let patterns: Vec = patterns_map - .into_iter() - .filter_map(|(name, is_selected)| if is_selected { Some(name) } else { None }) - .collect(); - - Ok(patterns) - } - - /// Selects patterns by user - pub async fn select_patterns( - &self, - patterns: HashMap, - ) -> Result<(), SoftwareHTTPClientError> { - let config = SoftwareConfig { - product: None, - // TODO: SoftwareStore only passes true bools, false branch is untested - patterns: Some(patterns), - packages: None, - extra_repositories: None, - only_required: None, - }; - self.set_config(&config).await - } - - /// Sets a resolvable list - pub async fn set_resolvables( - &self, - name: &str, - r#type: ResolvableType, - names: &[&str], - optional: bool, - ) -> Result<(), SoftwareHTTPClientError> { - let path = format!("/software/resolvables/{}", name); - let options = ResolvableParams { - names: names.iter().map(|n| n.to_string()).collect(), - r#type, - optional, - }; - self.client.put_void(&path, &options).await?; - Ok(()) - } -} diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs deleted file mode 100644 index 216abbfb63..0000000000 --- a/rust/agama-lib/src/software/model.rs +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -mod conflict; -mod license; -mod packages; -mod registration; - -pub use conflict::*; -pub use license::*; -pub use packages::*; -pub use registration::*; - -pub struct ResolvablesSelection { - id: String, - optional: bool, - resolvables: Vec, - r#type: ResolvableType, -} - -/// A selection of resolvables to be installed. -/// -/// It holds a selection of patterns and packages to be installed and whether they are optional or -/// not. This class is similar to the `PackagesProposal` YaST module. -#[derive(Default)] -pub struct SoftwareSelection { - selections: Vec, -} - -impl SoftwareSelection { - pub fn new() -> Self { - Default::default() - } - - /// Updates a set of resolvables. - /// - /// * `zypp` - pointer to libzypp to do real action - /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). - /// * `optional` - Whether the selection is optional or not. - /// * `resolvables` - The resolvables included in the set. - pub fn set( - &mut self, - zypp: &zypp_agama::Zypp, - id: &str, - r#type: ResolvableType, - optional: bool, - resolvables: &[&str], - ) -> Result<(), zypp_agama::ZyppError> { - let list = self.find_or_create_selection(id, r#type, optional); - // FIXME: use reference counting here, if multiple ids require some package, to not unselect it - for res in &list.resolvables { - zypp.unselect_resolvable( - &res, - r#type.into(), - zypp_agama::ResolvableSelected::Installation, - )?; - } - let new_resolvables: Vec<_> = resolvables.iter().map(|r| r.to_string()).collect(); - list.resolvables = new_resolvables; - for res in &list.resolvables { - zypp.select_resolvable( - &res, - r#type.into(), - zypp_agama::ResolvableSelected::Installation, - )?; - } - Ok(()) - } - - /// Returns a set of resolvables. - /// - /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). - /// * `optional` - Whether the selection is optional or not. - pub fn get(&self, id: &str, r#type: ResolvableType, optional: bool) -> Option> { - self.selections - .iter() - .find(|l| l.id == id && l.r#type == r#type && l.optional == optional) - .map(|l| l.resolvables.clone()) - } - - fn find_or_create_selection( - &mut self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> &mut ResolvablesSelection { - let found = self - .selections - .iter() - .position(|l| l.id == id && l.r#type == r#type && l.optional == optional); - - if let Some(index) = found { - &mut self.selections[index] - } else { - let selection = ResolvablesSelection { - id: id.to_string(), - r#type, - optional, - resolvables: vec![], - }; - self.selections.push(selection); - self.selections.last_mut().unwrap() - } - } -} - -/* TODO: Fix tests with real mock of libzypp -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_set_selection() { - let mut selection = SoftwareSelection::new(); - selection.add("agama", ResolvableType::Package, false, &["agama-scripts"]); - selection.set("agama", ResolvableType::Package, false, &["suse"]); - - let packages = selection - .get("agama", ResolvableType::Package, false) - .unwrap(); - assert_eq!(packages.len(), 1); - } - - #[test] - fn test_remove_selection() { - let mut selection = SoftwareSelection::new(); - selection.add("agama", ResolvableType::Package, true, &["agama-scripts"]); - selection.remove("agama", ResolvableType::Package, true); - let packages = selection.get("agama", ResolvableType::Package, true); - assert_eq!(packages, None); - } -} - */ diff --git a/rust/agama-lib/src/software/model/conflict.rs b/rust/agama-lib/src/software/model/conflict.rs deleted file mode 100644 index 527fea41a7..0000000000 --- a/rust/agama-lib/src/software/model/conflict.rs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -/// Information about conflict when resolving software -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConflictSolve { - /// conflict id - pub conflict_id: u32, - /// selected solution id - pub solution_id: u32, -} - -impl From for (u32, u32) { - fn from(solve: ConflictSolve) -> Self { - (solve.conflict_id, solve.solution_id) - } -} - -/// Information about possible solution for conflict -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Solution { - /// conflict id - pub id: u32, - /// localized description of solution - pub description: String, - /// localized details about solution. Can be missing - pub details: Option, -} - -/// Information about conflict when resolving software -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Conflict { - /// conflict id - pub id: u32, - /// localized description of conflict - pub description: String, - /// localized details about conflict. Can be missing - pub details: Option, - /// list of possible solutions - pub solutions: Vec, -} - -impl Solution { - pub fn from_dbus(dbus_solution: (u32, String, String)) -> Self { - let details = dbus_solution.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - id: dbus_solution.0, - description: dbus_solution.1, - details, - } - } -} - -impl Conflict { - pub fn from_dbus(dbus_conflict: (u32, String, String, Vec<(u32, String, String)>)) -> Self { - let details = dbus_conflict.2; - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - let solutions = dbus_conflict.3; - let solutions = solutions - .into_iter() - .map(|s| Solution::from_dbus(s)) - .collect(); - - Self { - id: dbus_conflict.0, - description: dbus_conflict.1, - details, - solutions, - } - } -} diff --git a/rust/agama-lib/src/software/model/license.rs b/rust/agama-lib/src/software/model/license.rs deleted file mode 100644 index b0e9350954..0000000000 --- a/rust/agama-lib/src/software/model/license.rs +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright (c) [2024-2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements support for reading software licenses. - -use agama_locale_data::get_territories; -use regex::Regex; -use serde::Serialize; -use serde_with::{serde_as, DisplayFromStr}; -use std::{ - collections::HashMap, - fmt::Display, - fs::read_dir, - path::{Path, PathBuf}, -}; -use thiserror::Error; - -/// Represents a product license. -/// -/// It contains the license ID and the list of languages that with a translation. -#[serde_as] -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct License { - /// License ID. - pub id: String, - /// Languages in which the license is translated. - #[serde_as(as = "Vec")] - pub languages: Vec, -} - -/// Represents a license content. -/// -/// It contains the license ID and the body. -/// -/// TODO: in the future it might contain a title, extracted from the text. -#[serde_as] -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct LicenseContent { - /// License ID. - pub id: String, - /// License text. - pub body: String, - /// License language. - #[serde_as(as = "DisplayFromStr")] - pub language: LanguageTag, -} - -/// Represents a repository of software licenses. -/// -/// The repository consists of a directory in the file system which contains the licenses in -/// different languages. -/// -/// Each license is stored on a separate directory (e.g., "/usr/share/agama/eula/license.beta"). -/// The license diectory contains the default text (license.txt) and a set of translations (e.g., -/// "license.es.txt", "license.zh_CH.txt", etc.). -#[derive(Clone)] -pub struct LicensesRepo { - /// Repository path. - pub path: std::path::PathBuf, - /// Licenses in the repository. - pub licenses: Vec, - /// Fallback languages per territory. - fallback: HashMap, -} - -impl LicensesRepo { - pub fn new>(path: P) -> Self { - Self { - path: path.as_ref().to_owned(), - licenses: vec![], - fallback: HashMap::new(), - } - } - - /// Reads the licenses from the repository. - pub fn read(&mut self) -> Result<(), std::io::Error> { - let entries = read_dir(self.path.as_path())?; - - for entry in entries { - let entry = entry?; - if entry.file_type()?.is_dir() { - let Ok(id) = entry.file_name().into_string() else { - continue; - }; - let license = License { - id, - languages: Self::find_translations(&entry.path())?, - }; - self.licenses.push(license); - } - } - - self.fallback.clear(); - - let territories = get_territories().map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Cannot read the territories list: {}", e), - ) - })?; - - for territory in territories.territory { - if let Some(language) = territory.languages.language.first() { - let fallback = LanguageTag { - language: language.id.to_string(), - territory: None, - }; - self.fallback.insert(territory.id, fallback); - } - } - - Ok(()) - } - - /// Finds a license with the given ID and language. - /// - /// If a translation is not found for the given language, it returns the default text. - pub fn find(&self, id: &str, language: &LanguageTag) -> Option { - let license = self.licenses.iter().find(|l| l.id.as_str() == id)?; - let license_language = self.find_language(&license, &language).unwrap_or_default(); - self.read_license_content(id, &license_language).ok() - } - - /// Finds translations in the given directory. - /// - /// * `path`: directory to search translations. - fn find_translations(path: &PathBuf) -> Result, std::io::Error> { - let entries = read_dir(path).unwrap().filter_map(|entry| entry.ok()); - - let files = entries - .filter(|entry| entry.file_type().is_ok_and(|f| f.is_file())) - .filter_map(|entry| { - let path = entry.path(); - let file = path.file_name()?; - file.to_owned().into_string().ok() - }); - - Ok(files - .filter_map(|f| Self::language_tag_from_file(&f)) - .collect()) - } - - /// Returns the language tag for the given file. - /// - /// The language is inferred from the file name (e.g., "es-ES" for license.es_ES.txt"). - fn language_tag_from_file(name: &str) -> Option { - if !name.starts_with("license") { - log::warn!("Unexpected file in the licenses directory: {}", &name); - return None; - } - let mut parts = name.split("."); - let mut code = parts.nth(1)?; - - if code == "txt" { - code = "en" - } - - code.try_into().ok() - } - - /// Read a license content for a given language. - fn read_license_content( - &self, - id: &str, - language: &LanguageTag, - ) -> std::io::Result { - let file_name = if *language == LanguageTag::default() { - "license.txt".to_string() - } else if let Some(territory) = &language.territory { - format!("license.{}_{}.txt", language.language, territory) - } else { - format!("license.{}.txt", language.language) - }; - - let license_path = self.path.join(id).join(file_name); - let body = std::fs::read_to_string(license_path)?; - Ok(LicenseContent { - id: id.to_string(), - body, - language: language.clone(), - }) - } - - /// It search for an available language for the translation. - /// - /// If translated to the given language, it returns that language. If that's - /// not the case, it searches for a "compatible" language (the main language - /// on the same territory, if given). - fn find_language(&self, license: &License, candidate: &LanguageTag) -> Option { - let mut candidates: Vec = vec![candidate.clone()]; - candidates.push(LanguageTag { - language: candidate.language.clone(), - territory: None, - }); - - if let Some(territory) = &candidate.territory { - if let Some(fallback) = self.fallback.get(territory) { - candidates.push(fallback.clone()); - } - } - - candidates - .into_iter() - .find(|c| license.languages.contains(&c)) - } -} - -impl Default for LicensesRepo { - fn default() -> Self { - let relative_path = Path::new("share/eula"); - let path = if relative_path.exists() { - relative_path - } else { - Path::new("/usr/share/agama/eula") - }; - Self::new(path) - } -} - -/// Simplified representation of the RFC 5646 language code. -/// -/// It only considers xx and xx-XX formats. -#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] -pub struct LanguageTag { - // ISO-639 - pub language: String, - // ISO-3166 - pub territory: Option, -} - -impl Default for LanguageTag { - fn default() -> Self { - LanguageTag { - language: "en".to_string(), - territory: None, - } - } -} - -impl Display for LanguageTag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(territory) = &self.territory { - write!(f, "{}-{}", &self.language, territory) - } else { - write!(f, "{}", &self.language) - } - } -} - -#[derive(Error, Debug)] -#[error("Not a valid language code: {0}")] -pub struct InvalidLanguageCode(String); - -impl TryFrom<&str> for LanguageTag { - type Error = InvalidLanguageCode; - - fn try_from(value: &str) -> Result { - let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); - - let captures = language_regexp - .captures(value) - .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; - - Ok(Self { - language: captures.get(1).unwrap().as_str().to_string(), - territory: captures.get(2).map(|e| e.as_str().to_string()), - }) - } -} - -#[cfg(test)] -mod test { - use super::{LanguageTag, LicensesRepo}; - use std::path::Path; - - fn build_repo() -> LicensesRepo { - let mut repo = LicensesRepo::new(Path::new("../share/eula")); - repo.read().unwrap(); - repo - } - - #[test] - fn test_read_licenses_repository() { - let repo = build_repo(); - let license = repo.licenses.first().unwrap(); - assert_eq!(&license.id, "license.final"); - } - - #[test] - fn test_find_license() { - let repo = build_repo(); - let es_language: LanguageTag = "es".try_into().unwrap(); - let license = repo.find("license.final", &es_language).unwrap(); - assert!(license.body.starts_with("Acuerdo de licencia")); - assert_eq!(license.language, es_language); - - let language: LanguageTag = "es-ES".try_into().unwrap(); - let license = repo.find("license.final", &language).unwrap(); - assert!(license.body.starts_with("Acuerdo de licencia")); - assert_eq!(license.language, es_language); - - let language: LanguageTag = "zh-CN".try_into().unwrap(); - let license = repo.find("license.final", &language).unwrap(); - assert!(license.body.starts_with("SUSE 软件")); - assert_eq!(license.language, language); - - let language: LanguageTag = "xx".try_into().unwrap(); - let license = repo.find("license.final", &language).unwrap(); - assert!(license.body.starts_with("End User License")); - assert_eq!(license.language, LanguageTag::default()); - } - - #[test] - fn test_find_alternate_license() { - let repo = build_repo(); - - // Tries to use the main language for the territory. - let ca_language: LanguageTag = "ca-ES".try_into().unwrap(); - let es_language: LanguageTag = "es".try_into().unwrap(); - let license = repo.find("license.final", &ca_language).unwrap(); - assert_eq!(license.language, es_language); - } - - #[test] - fn test_language_tag() { - let tag: LanguageTag = "zh-CH".try_into().unwrap(); - assert_eq!(tag.language, "zh"); - assert_eq!(tag.territory, Some("CH".to_string())); - } -} diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs deleted file mode 100644 index 6ed94a64ea..0000000000 --- a/rust/agama-lib/src/software/model/packages.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareConfig { - /// A map where the keys are the pattern names and the values whether to install them or not. - pub patterns: Option>, - /// Packages to install. - pub packages: Option>, - /// Name of the product to install. - pub product: Option, - /// Extra repositories defined by user. - pub extra_repositories: Option>, - /// Flag if solver should use only hard dependencies. - pub only_required: Option, -} - -/// Software resolvable type (package or pattern). -#[derive( - Clone, Copy, Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ResolvableType { - Package = 0, - Pattern = 1, - Product = 2, -} - -impl From for zypp_agama::ResolvableKind { - fn from(value: ResolvableType) -> Self { - match value { - ResolvableType::Package => zypp_agama::ResolvableKind::Package, - ResolvableType::Product => zypp_agama::ResolvableKind::Product, - ResolvableType::Pattern => zypp_agama::ResolvableKind::Pattern, - } - } -} - -/// Resolvable list specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -pub struct ResolvableParams { - /// List of resolvables. - pub names: Vec, - /// Resolvable type. - pub r#type: ResolvableType, - /// Whether the resolvables are optional or not. - pub optional: bool, -} - -/// Repository specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Repository { - /// repository identifier - pub id: i32, - /// repository alias. Has to be unique - pub alias: String, - /// repository name - pub name: String, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - pub product_dir: String, - /// Whether the repository is enabled - pub enabled: bool, - /// Whether the repository is loaded - pub loaded: bool, -} - -/// Parameters for creating new a repository -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RepositoryParams { - /// repository alias. Has to be unique - pub alias: String, - /// repository name, if not specified the alias is used - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - #[serde(skip_serializing_if = "Option::is_none")] - pub product_dir: Option, - /// Whether the repository is enabled, if missing the repository is enabled - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - /// Repository priority, lower number means higher priority, the default priority is 99 - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - /// Whenever repository can be unsigned. Default is false - #[serde(skip_serializing_if = "Option::is_none")] - pub allow_unsigned: Option, - /// List of fingerprints for GPG keys used for repository signing. By default empty - #[serde(skip_serializing_if = "Option::is_none")] - pub gpg_fingerprints: Option>, -} diff --git a/rust/agama-lib/src/software/model/registration.rs b/rust/agama-lib/src/software/model/registration.rs deleted file mode 100644 index 29b05b62dd..0000000000 --- a/rust/agama-lib/src/software/model/registration.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationParams { - /// Registration key. - pub key: String, - /// Registration email. - pub email: String, -} - -/// Addon registration -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonParams { - // Addon identifier - pub id: String, - // Addon version, if not specified the version is found from the available addons - pub version: Option, - // Optional registration code, not required for free extensions - pub registration_code: Option, -} - -/// Addon registration -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonProperties { - /// Addon identifier - pub id: String, - /// Version of the addon - pub version: String, - /// User visible name - pub label: String, - /// Whether the addon is mirrored on the RMT server, on SCC it is always `true` - pub available: bool, - /// Whether a registration code is required for registering the addon - pub free: bool, - /// Whether the addon is recommended for the users - pub recommended: bool, - /// Short description of the addon (translated) - pub description: String, - /// Type of the addon, like "extension" or "module" - pub r#type: String, - /// Release status of the addon, e.g. "beta" - pub release: String, -} - -/// Information about registration configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RegistrationInfo { - /// Registration status. True if base system is already registered. - pub registered: bool, - /// Registration key. Empty value mean key not used or not registered. - pub key: String, - /// Registration email. Empty value mean email not used or not registered. - pub email: String, - /// Registration URL. Empty value mean that de default value is used. - pub url: String, -} - -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationError { - /// ID of error. See dbus API for possible values - pub id: u32, - /// human readable error string intended to be displayed to user - pub message: String, -} diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs deleted file mode 100644 index 3eff9fd819..0000000000 --- a/rust/agama-lib/src/software/proxies.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -mod software; -pub use software::Software1Proxy; - -mod product; -pub use product::{Product, ProductProxy as SoftwareProductProxy}; - -mod proposal; -pub use proposal::ProposalProxy; diff --git a/rust/agama-lib/src/software/proxies/product.rs b/rust/agama-lib/src/software/proxies/product.rs deleted file mode 100644 index 199ece0186..0000000000 --- a/rust/agama-lib/src/software/proxies/product.rs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1.Product` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Product.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, -use zbus::proxy; - -/// Product definition. -/// -/// It is composed of the following elements: -/// -/// * Product ID. -/// * Display name. -/// * Some additional data which includes a "description" key. -pub type Product = ( - String, - String, - std::collections::HashMap, -); - -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Product", - interface = "org.opensuse.Agama.Software1.Product", - assume_defaults = true -)] -pub trait Product { - /// AvailableProducts method - fn available_products(&self) -> zbus::Result>; - - /// SelectProduct method - fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; - - /// SelectedProduct property - #[zbus(property)] - fn selected_product(&self) -> zbus::Result; -} diff --git a/rust/agama-lib/src/software/proxies/proposal.rs b/rust/agama-lib/src/software/proxies/proposal.rs deleted file mode 100644 index bc88a686c0..0000000000 --- a/rust/agama-lib/src/software/proxies/proposal.rs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1.Proposal` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.Proposal.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, - -use zbus::proxy; -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1/Proposal", - interface = "org.opensuse.Agama.Software1.Proposal", - assume_defaults = true -)] -pub trait Proposal { - /// AddResolvables method - fn add_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; - - /// GetResolvables method - fn get_resolvables(&self, id: &str, type_: u8, optional: bool) -> zbus::Result>; - - /// RemoveResolvables method - fn remove_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; - - /// SetResolvables method - fn set_resolvables( - &self, - id: &str, - r#type: u8, - resolvables: &[&str], - optional: bool, - ) -> zbus::Result<()>; -} diff --git a/rust/agama-lib/src/software/proxies/software.rs b/rust/agama-lib/src/software/proxies/software.rs deleted file mode 100644 index f76ea97ffd..0000000000 --- a/rust/agama-lib/src/software/proxies/software.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! # D-Bus interface proxy for: `org.opensuse.Agama.Software1` -//! -//! This code was generated by `zbus-xmlgen` `5.0.0` from D-Bus introspection data. -//! Source: `org.opensuse.Agama.Software1.bus.xml`. -//! -//! You may prefer to adapt it, instead of using it verbatim. -//! -//! More information can be found in the [Writing a client proxy] section of the zbus -//! documentation. -//! -//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the -//! following zbus API can be used: -//! -//! * [`zbus::fdo::PropertiesProxy`] -//! * [`zbus::fdo::IntrospectableProxy`] -//! -//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. -//! -//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html -//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, - -use zbus::proxy; - -/// Software patterns map. -/// -/// It uses the pattern name as key and a tuple containing the following information as value: -/// -/// * Category. -/// * Description. -/// * Icon. -/// * Summary. -/// * Order. -pub type PatternsMap = std::collections::HashMap; - -pub type Repository = (i32, String, String, String, String, bool, bool); - -#[proxy( - default_service = "org.opensuse.Agama.Software1", - default_path = "/org/opensuse/Agama/Software1", - interface = "org.opensuse.Agama.Software1", - assume_defaults = true -)] -pub trait Software1 { - /// AddPattern method - fn add_pattern(&self, id: &str) -> zbus::Result; - - /// Finish method - fn finish(&self) -> zbus::Result<()>; - - /// Install method - fn install(&self) -> zbus::Result<()>; - - /// IsPackageAvailable method - fn is_package_available(&self, name: &str) -> zbus::Result; - - /// IsPackageInstalled method - fn is_package_installed(&self, name: &str) -> zbus::Result; - - /// ListPatterns method - fn list_patterns(&self, filtered: bool) -> zbus::Result; - - /// ListRepositories method - fn list_repositories(&self) -> zbus::Result>; - - /// ListUserRepositories method - fn list_user_repositories( - &self, - ) -> zbus::Result>>; - - /// Probe method - fn probe(&self) -> zbus::Result<()>; - - /// Propose method - fn propose(&self) -> zbus::Result<()>; - - /// ProvisionsSelected method - fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result>; - - /// RemovePattern method - fn remove_pattern(&self, id: &str) -> zbus::Result; - - /// SetUserPatterns method - fn set_user_patterns(&self, add: &[&str], remove: &[&str]) -> zbus::Result>; - - /// SetUserRepositories method - fn set_user_repositories( - &self, - repos: &[std::collections::HashMap<&str, zbus::zvariant::Value<'_>>], - ) -> zbus::Result<()>; - - /// SolveConflicts method - fn solve_conflicts(&self, solutions: &[(u32, u32)]) -> zbus::Result<()>; - - /// UsedDiskSpace method - fn used_disk_space(&self) -> zbus::Result; - - /// ProbeFinished signal - #[zbus(signal)] - fn probe_finished(&self) -> zbus::Result<()>; - - /// Conflicts property - #[zbus(property)] - #[allow(clippy::type_complexity)] - fn conflicts(&self) -> zbus::Result)>>; - - /// OnlyRequired property - #[zbus(property)] - fn only_required(&self) -> zbus::Result; - #[zbus(property)] - fn set_only_required(&self, value: u32) -> zbus::Result<()>; - - /// SelectedPatterns property - #[zbus(property)] - fn selected_patterns(&self) -> zbus::Result>; -} diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs deleted file mode 100644 index dbae2ea3d8..0000000000 --- a/rust/agama-lib/src/software/settings.rs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Representation of the software settings - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::software::model::RepositoryParams; - -/// Software settings for installation -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareSettings { - /// List of user selected patterns to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub patterns: Option, - /// List of user selected packages to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub packages: Option>, - /// List of user specified repositories to use on top of default ones. - #[serde(skip_serializing_if = "Option::is_none")] - pub extra_repositories: Option>, - /// Flag indicating if only hard requirements should be used by solver. - #[serde(skip_serializing_if = "Option::is_none")] - pub only_required: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(untagged)] -pub enum PatternsSettings { - PatternsList(Vec), - PatternsMap(PatternsMap), -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -pub struct PatternsMap { - #[serde(skip_serializing_if = "Option::is_none")] - pub add: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remove: Option>, -} - -impl From> for PatternsSettings { - fn from(list: Vec) -> Self { - Self::PatternsList(list) - } -} - -impl From>> for PatternsSettings { - fn from(map: HashMap>) -> Self { - let add = if let Some(to_add) = map.get("add") { - Some(to_add.to_owned()) - } else { - None - }; - - let remove = if let Some(to_remove) = map.get("remove") { - Some(to_remove.to_owned()) - } else { - None - }; - - Self::PatternsMap(PatternsMap { add, remove }) - } -} - -impl SoftwareSettings { - pub fn to_option(self) -> Option { - if self.patterns.is_none() - && self.packages.is_none() - && self.extra_repositories.is_none() - && self.only_required.is_none() - { - None - } else { - Some(self) - } - } -} diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs deleted file mode 100644 index 8e9f688735..0000000000 --- a/rust/agama-lib/src/software/store.rs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the software settings. - -use std::collections::HashMap; - -use super::{ - http_client::SoftwareHTTPClientError, model::SoftwareConfig, settings::PatternsSettings, - SoftwareHTTPClient, SoftwareSettings, -}; -use crate::http::BaseHTTPClient; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing software settings: {0}")] -pub struct SoftwareStoreError(#[from] SoftwareHTTPClientError); - -type SoftwareStoreResult = Result; - -/// Loads and stores the software settings from/to the HTTP API. -pub struct SoftwareStore { - software_client: SoftwareHTTPClient, -} - -impl SoftwareStore { - pub fn new(client: BaseHTTPClient) -> SoftwareStore { - Self { - software_client: SoftwareHTTPClient::new(client), - } - } - - pub async fn load(&self) -> SoftwareStoreResult { - let patterns = self.software_client.user_selected_patterns().await?; - // FIXME: user_selected_patterns is calling get_config too. - let config = self.software_client.get_config().await?; - Ok(SoftwareSettings { - patterns: if patterns.is_empty() { - None - } else { - Some(PatternsSettings::from(patterns)) - }, - packages: config.packages, - extra_repositories: config.extra_repositories, - only_required: config.only_required, - }) - } - - pub async fn store(&self, settings: &SoftwareSettings) -> SoftwareStoreResult<()> { - let patterns: Option> = - if let Some(patterns) = settings.patterns.clone() { - let mut current_patterns: Vec; - - match patterns { - PatternsSettings::PatternsList(list) => current_patterns = list, - PatternsSettings::PatternsMap(map) => { - current_patterns = self.software_client.user_selected_patterns().await?; - - if let Some(patterns_add) = map.add { - for pattern in patterns_add { - if !current_patterns.contains(&pattern) { - current_patterns.push(pattern); - } - } - } - - if let Some(patterns_remove) = map.remove { - let mut new_patterns: Vec = vec![]; - - for pattern in current_patterns { - if !patterns_remove.contains(&pattern) { - new_patterns.push(pattern) - } - } - - current_patterns = new_patterns; - } - } - } - - Some( - current_patterns - .iter() - .map(|n| (n.to_owned(), true)) - .collect(), - ) - } else { - None - }; - - let config = SoftwareConfig { - // do not change the product - product: None, - patterns, - packages: settings.packages.clone(), - extra_repositories: settings.extra_repositories.clone(), - only_required: settings.only_required, - }; - self.software_client.set_config(&config).await?; - - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - fn software_store(mock_server_url: String) -> SoftwareStore { - let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); - let client = SoftwareHTTPClient::new(bhc); - SoftwareStore { - software_client: client, - } - } - - #[test] - async fn test_getting_software() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(GET).path("/api/software/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "patterns": {"xfce":true}, - "packages": ["vim"], - "product": "Tumbleweed" - }"#, - ); - }); - let url = server.url("/api"); - - let store = software_store(url); - let settings = store.load().await?; - let patterns_settings = PatternsSettings::from(vec!["xfce".to_owned()]); - - let expected = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - // main assertion - assert_eq!(settings, expected); - - // FIXME: at this point it is calling the method twice - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert_hits(2); - Ok(()) - } - - #[test] - async fn test_setting_software_ok() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":{"xfce":true},"packages":["vim"],"product":null,"extraRepositories":null,"onlyRequired":null}"#); - then.status(200); - }); - let url = server.url("/api"); - - let store = software_store(url); - let patterns_settings = PatternsSettings::from(vec!["xfce".to_owned()]); - - let settings = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_software_err() -> Result<(), Box> { - let server = MockServer::start(); - let software_mock = server.mock(|when, then| { - when.method(PUT) - .path("/api/software/config") - .header("content-type", "application/json") - .body(r#"{"patterns":{"no_such_pattern":true},"packages":["vim"],"product":null,"extraRepositories":null,"onlyRequired":null}"#); - then.status(400) - .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#); - }); - let url = server.url("/api"); - - let store = software_store(url); - let patterns_settings = PatternsSettings::from(vec!["no_such_pattern".to_owned()]); - let settings = SoftwareSettings { - patterns: Some(patterns_settings), - packages: Some(vec!["vim".to_owned()]), - extra_repositories: None, - only_required: None, - }; - - let result = store.store(&settings).await; - - // main assertion - assert!(result.is_err()); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - software_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 1774654c59..f858ec633b 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -29,10 +29,8 @@ use crate::{ install_settings::InstallSettings, manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, - product::{ProductHTTPClient, ProductStore, ProductStoreError}, scripts::{ScriptsClient, ScriptsClientError, ScriptsGroup, ScriptsStore, ScriptsStoreError}, security::store::{SecurityStore, SecurityStoreError}, - software::{SoftwareStore, SoftwareStoreError}, storage::{ http_client::{ iscsi::{ISCSIHTTPClient, ISCSIHTTPClientError}, @@ -62,12 +60,8 @@ pub enum StoreError { #[error(transparent)] Network(#[from] NetworkStoreError), #[error(transparent)] - Product(#[from] ProductStoreError), - #[error(transparent)] Security(#[from] SecurityStoreError), #[error(transparent)] - Software(#[from] SoftwareStoreError), - #[error(transparent)] Storage(#[from] StorageStoreError), #[error(transparent)] ISCSI(#[from] ISCSIHTTPClientError), @@ -82,8 +76,6 @@ pub enum StoreError { ZFCP(#[from] ZFCPStoreError), #[error("Could not calculate the context")] InvalidStoreContext, - #[error("Cannot proceed with profile without specified product")] - MissingProduct, } /// Struct that loads/stores the settings from/to the D-Bus services. @@ -99,9 +91,7 @@ pub struct Store { hostname: HostnameStore, users: UsersStore, network: NetworkStore, - product: ProductStore, security: SecurityStore, - software: SoftwareStore, storage: StorageStore, scripts: ScriptsStore, iscsi_client: ISCSIHTTPClient, @@ -119,9 +109,7 @@ impl Store { hostname: HostnameStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::new(http_client.clone()), - product: ProductStore::new(http_client.clone()), security: SecurityStore::new(http_client.clone()), - software: SoftwareStore::new(http_client.clone()), storage: StorageStore::new(http_client.clone()), scripts: ScriptsStore::new(http_client.clone()), manager_client: ManagerHTTPClient::new(http_client.clone()), @@ -140,9 +128,7 @@ impl Store { hostname: Some(self.hostname.load().await?), network: Some(self.network.load().await?), security: self.security.load().await?.to_option(), - software: self.software.load().await?.to_option(), user: Some(self.users.load().await?), - product: Some(self.product.load().await?), scripts: self.scripts.load().await?.to_option(), zfcp: self.zfcp.load().await?, ..Default::default() @@ -184,33 +170,18 @@ impl Store { if let Some(user) = &settings.user { self.users.store(user).await?; } - // order is important here as network can be critical for connection - // to registration server and selecting product is important for rest - if let Some(product) = &settings.product { - self.product.store(product).await?; - } - // here detect if product is properly selected, so later it can be checked - let is_product_selected = self.detect_selected_product().await?; - if let Some(software) = &settings.software { - Store::ensure_selected_product(is_product_selected)?; - self.software.store(software).await?; - } let mut dirty_flag_set = false; // iscsi has to be done before storage if let Some(iscsi) = &settings.iscsi { - Store::ensure_selected_product(is_product_selected)?; - dirty_flag_set = true; self.iscsi_client.set_config(iscsi).await? } // dasd devices has to be activated before storage if let Some(dasd) = &settings.dasd { - Store::ensure_selected_product(is_product_selected)?; dirty_flag_set = true; self.dasd.store(dasd).await? } // zfcp devices has to be activated before storage if let Some(zfcp) = &settings.zfcp { - Store::ensure_selected_product(is_product_selected)?; dirty_flag_set = true; self.zfcp.store(zfcp).await? } @@ -219,19 +190,16 @@ impl Store { // reprobe here before loading the storage settings. Otherwise, the new storage devices are // not used. if dirty_flag_set { - Store::ensure_selected_product(is_product_selected)?; self.reprobe_storage().await?; } if settings.storage.is_some() || settings.storage_autoyast.is_some() { - Store::ensure_selected_product(is_product_selected)?; self.storage.store(&settings.into()).await? } if let Some(bootloader) = &settings.bootloader { self.bootloader.store(bootloader).await?; } if let Some(hostname) = &settings.hostname { - Store::ensure_selected_product(is_product_selected)?; self.hostname.store(hostname).await?; } if let Some(files) = &settings.files { @@ -250,20 +218,6 @@ impl Store { Ok(()) } - async fn detect_selected_product(&self) -> Result { - let product_client = ProductHTTPClient::new(self.http_client.clone()); - let product = product_client.product().await?; - Ok(!product.is_empty()) - } - - fn ensure_selected_product(selected: bool) -> Result<(), StoreError> { - if selected { - Ok(()) - } else { - Err(StoreError::MissingProduct) - } - } - /// Runs the pre-installation scripts and forces a probe if the installation phase is "config". async fn run_pre_scripts(&self) -> Result<(), StoreError> { let scripts_client = ScriptsClient::new(self.http_client.clone()); diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index b4f50842b2..80139a10e2 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -13,6 +13,7 @@ agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } agama-locale-data = { path = "../agama-locale-data" } agama-manager = { path = "../agama-manager" } +agama-software = { path = "../agama-software" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 1bd62e6411..e8a657dcf5 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -31,7 +31,6 @@ pub mod products; pub mod profile; pub mod scripts; pub mod security; -pub mod software; pub mod storage; pub mod users; pub mod web; diff --git a/rust/agama-server/src/software.rs b/rust/agama-server/src/software.rs deleted file mode 100644 index b363de6ad1..0000000000 --- a/rust/agama-server/src/software.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod web; -pub use web::{software_service, software_streams}; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs deleted file mode 100644 index a45ab10a7e..0000000000 --- a/rust/agama-server/src/software/web.rs +++ /dev/null @@ -1,836 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the software service. -//! -//! The module offers two public functions: -//! -//! * `software_service` which returns the Axum service. -//! * `software_stream` which offers an stream that emits the software events coming from D-Bus. - -use crate::{ - error::Error, - web::common::{service_status_router, EventStreams, ProgressClient, ProgressRouterBuilder}, -}; - -use agama_lib::{ - error::ServiceError, - event, - http::{self, EventPayload, OldEvent}, - product::{proxies::RegistrationProxy, Product, ProductClient}, - software::{ - model::{ - AddonParams, AddonProperties, Conflict, ConflictSolve, License, LicenseContent, - LicensesRepo, RegistrationError, RegistrationInfo, RegistrationParams, Repository, - ResolvableParams, SoftwareConfig, - }, - proxies::{Software1Proxy, SoftwareProductProxy}, - Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, - }, -}; -use anyhow::Context; -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post, put}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; -use tokio_stream::{Stream, StreamExt}; - -#[derive(Clone)] -struct SoftwareState<'a> { - product: ProductClient<'a>, - software: SoftwareClient<'a>, - licenses: LicensesRepo, - // cache the software values, during installation the software service is - // not responsive (blocked in a libzypp call) - products: Arc>>, - config: Arc>>, -} - -/// Returns an stream that emits software related events coming from D-Bus. -/// -/// It emits the Event::ProductChanged and Event::PatternsChanged events. -/// -/// * `connection`: D-Bus connection to listen for events. -pub async fn software_streams(dbus: zbus::Connection) -> Result { - let result: EventStreams = vec![ - ( - "patterns_changed", - Box::pin(patterns_changed_stream(dbus.clone()).await?), - ), - ( - "conflicts_changed", - Box::pin(conflicts_changed_stream(dbus.clone()).await?), - ), - ( - "product_changed", - Box::pin(product_changed_stream(dbus.clone()).await?), - ), - ( - "registration_code_changed", - Box::pin(registration_code_changed_stream(dbus.clone()).await?), - ), - ( - "registration_email_changed", - Box::pin(registration_email_changed_stream(dbus.clone()).await?), - ), - ]; - - Ok(result) -} - -async fn product_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = SoftwareProductProxy::new(&dbus).await?; - let stream = proxy - .receive_selected_product_changed() - .await - .then(|change| async move { - if let Ok(id) = change.get().await { - return Some(event!(ProductChanged { id })); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -async fn patterns_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = Software1Proxy::new(&dbus).await?; - let stream = proxy - .receive_selected_patterns_changed() - .await - .then(|change| async move { - if let Ok(patterns) = change.get().await { - return match reason_to_selected_by(patterns) { - Ok(patterns) => Some(patterns), - Err(error) => { - tracing::warn!("Ignoring the list of changed patterns. Error: {}", error); - None - } - }; - } - None - }) - .filter_map(|e| e.map(|patterns| event!(SoftwareProposalChanged { patterns }))); - Ok(stream) -} - -async fn conflicts_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = Software1Proxy::new(&dbus).await?; - let stream = proxy - .receive_conflicts_changed() - .await - .then(|change| async move { - if let Ok(conflicts) = change.get().await { - return Some( - conflicts - .into_iter() - .map(|c| Conflict::from_dbus(c)) - .collect(), - ); - } - None - }) - .filter_map(|e| e.map(|conflicts| event!(ConflictsChanged { conflicts }))); - Ok(stream) -} - -async fn registration_email_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = RegistrationProxy::new(&dbus).await?; - let stream = proxy - .receive_email_changed() - .await - .then(|change| async move { - if let Ok(_id) = change.get().await { - // TODO: add to stream also proxy and return whole cached registration info - return Some(event!(RegistrationChanged)); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -async fn registration_code_changed_stream( - dbus: zbus::Connection, -) -> Result, Error> { - let proxy = RegistrationProxy::new(&dbus).await?; - let stream = proxy - .receive_reg_code_changed() - .await - .then(|change| async move { - if let Ok(_id) = change.get().await { - return Some(event!(RegistrationChanged)); - } - None - }) - .filter_map(|e| e); - Ok(stream) -} - -// Returns a hash replacing the selection "reason" from D-Bus with a SelectedBy variant. -fn reason_to_selected_by( - patterns: HashMap, -) -> Result, UnknownSelectedBy> { - let mut selected: HashMap = HashMap::new(); - for (id, reason) in patterns { - match SelectedBy::try_from(reason) { - Ok(selected_by) => selected.insert(id, selected_by), - Err(e) => return Err(e), - }; - } - Ok(selected) -} - -/// Process incoming events. -/// -/// * `events`: channel to listen for events. -/// * `products`: list of products (shared behind a mutex). -pub async fn receive_events( - mut events: http::event::OldReceiver, - products: Arc>>, - config: Arc>>, - client: ProductClient<'_>, -) { - while let Ok(event) = events.recv().await { - match event.payload { - EventPayload::LocaleChanged { locale: _ } => { - let mut cached_products = products.write().await; - if let Ok(products) = client.products().await { - *cached_products = products; - } else { - tracing::error!("Could not update the products cached"); - } - } - - EventPayload::SoftwareProposalChanged { patterns } => { - let mut cached_config = config.write().await; - if let Some(config) = cached_config.as_mut() { - tracing::debug!( - "Updating the patterns list in the software configuration cache" - ); - let user_patterns: HashMap = patterns - .into_iter() - .filter_map(|(p, s)| { - if s == SelectedBy::User { - Some((p, true)) - } else { - None - } - }) - .collect(); - config.patterns = Some(user_patterns); - } - } - - _ => {} - } - } -} - -/// Sets up and returns the axum service for the software module. -pub async fn software_service( - dbus: zbus::Connection, - events: http::event::OldReceiver, - progress: ProgressClient, -) -> Result { - const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; - const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; - - let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; - - // FIXME: use anyhow temporarily until we adapt all these methods to return - // the crate::error::Error instead of ServiceError. - let progress_router = ProgressRouterBuilder::new(DBUS_SERVICE, DBUS_PATH, progress) - .build() - .context("Could not build the progress router")?; - - let mut licenses_repo = LicensesRepo::default(); - if let Err(error) = licenses_repo.read() { - tracing::error!("Could not read the licenses repository: {:?}", error); - } - - let product = ProductClient::new(dbus.clone()).await?; - let software = SoftwareClient::new(dbus).await?; - let all_products = product.products().await?; - - let state = SoftwareState { - product, - software, - licenses: licenses_repo, - products: Arc::new(RwLock::new(all_products)), - config: Arc::new(RwLock::new(None)), - }; - - let cached_products = Arc::clone(&state.products); - let cached_config = Arc::clone(&state.config); - let products_client = state.product.clone(); - tokio::spawn(async move { - receive_events(events, cached_products, cached_config, products_client).await - }); - - let router = Router::new() - .route("/patterns", get(patterns)) - .route("/conflicts", get(get_conflicts).patch(solve_conflicts)) - .route("/repositories", get(repositories)) - .route("/products", get(products)) - .route("/licenses", get(licenses)) - .route("/licenses/:id", get(license)) - .route( - "/registration", - get(get_registration).post(register).delete(deregister), - ) - .route("/registration/url", put(set_reg_url)) - .route("/registration/addons/register", post(register_addon)) - .route( - "/registration/addons/registered", - get(get_registered_addons), - ) - .route("/registration/addons/available", get(get_available_addons)) - .route("/proposal", get(proposal)) - .route("/config", put(set_config).get(get_config)) - .route("/probe", post(probe)) - .route("/resolvables/:id", put(set_resolvables)) - .merge(status_router) - .merge(progress_router) - .with_state(state); - Ok(router) -} - -/// Returns the list of available products. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/products", - context_path = "/api/software", - responses( - (status = 200, description = "List of known products", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn products(State(state): State>) -> Result>, Error> { - let products = state.products.read().await.clone(); - Ok(Json(products)) -} - -/// Returns the list of defined repositories. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/repositories", - context_path = "/api/software", - responses( - (status = 200, description = "List of known repositories", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn repositories( - State(state): State>, -) -> Result>, Error> { - let repositories = state.software.repositories().await?; - Ok(Json(repositories)) -} - -/// Returns the list of conflicts that proposal found. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/conflicts", - context_path = "/api/software", - responses( - (status = 200, description = "List of software conflicts", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_conflicts( - State(state): State>, -) -> Result>, Error> { - let conflicts = state.software.get_conflicts().await?; - Ok(Json(conflicts)) -} - -/// Solve conflicts. Not all conflicts needs to be solved at once. -/// -/// * `state`: service state. -#[utoipa::path( - patch, - path = "/conflicts", - context_path = "/api/software", - request_body = Vec, - responses( - (status = 200, description = "Operation success"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn solve_conflicts( - State(state): State>, - Json(solutions): Json>, -) -> Result<(), Error> { - let ret = state.software.solve_conflicts(solutions).await?; - - // refresh the config cache - let config = read_config(&state).await?; - tracing::info!("Caching product configuration: {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config); - - Ok(ret) -} - -/// returns registration info -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration", - context_path = "/api/software", - responses( - (status = 200, description = "registration configuration", body = RegistrationInfo), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_registration( - State(state): State>, -) -> Result, Error> { - let result = RegistrationInfo { - registered: state.product.registered().await?, - key: state.product.registration_code().await?, - email: state.product.email().await?, - url: state.product.registration_url().await?, - }; - Ok(Json(result)) -} - -/// sets registration server url -/// -/// * `state`: service state. -#[utoipa::path( - put, - path = "/registration/url", - context_path = "/api/software", - responses( - (status = 200, description = "registration server set"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_reg_url( - State(state): State>, - Json(config): Json, -) -> Result<(), Error> { - state.product.set_registration_url(&config).await?; - Ok(()) -} - -/// Register product -/// -/// * `state`: service state. -#[utoipa::path( - post, - path = "/registration", - context_path = "/api/software", - responses( - (status = 204, description = "registration successful"), - (status = 422, description = "Registration failed. Details are in body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn register( - State(state): State>, - Json(config): Json, -) -> Result { - let (id, message) = state.product.register(&config.key, &config.email).await?; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - let details = RegistrationError { id, message }; - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// returns list of registered addons -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration/addons/registered", - context_path = "/api/software", - responses( - (status = 200, description = "List of registered addons", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_registered_addons( - State(state): State>, -) -> Result>, Error> { - let result = state.product.registered_addons().await?; - - Ok(Json(result)) -} - -/// returns list of available addons -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/registration/addons/available", - context_path = "/api/software", - responses( - (status = 200, description = "List of available addons", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_available_addons( - State(state): State>, -) -> Result>, Error> { - let result = state.product.available_addons().await?; - - Ok(Json(result)) -} - -/// Register an addon -/// -/// * `state`: service state. -#[utoipa::path( - post, - path = "/registration/addons/register", - context_path = "/api/software", - responses( - (status = 204, description = "registration successful"), - (status = 422, description = "Registration failed. Details are in the body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn register_addon( - State(state): State>, - Json(addon): Json, -) -> Result { - let (id, message) = state.product.register_addon(&addon).await?; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - let details = RegistrationError { id, message }; - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// Deregister product -/// -/// * `state`: service state. -#[utoipa::path( - delete, - path = "/registration", - context_path = "/api/software", - responses( - (status = 200, description = "deregistration successful"), - (status = 422, description = "De-registration failed. Details are in body", body = RegistrationError), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn deregister(State(state): State>) -> Result { - let (id, message) = state.product.deregister().await?; - let details = RegistrationError { id, message }; - if id == 0 { - Ok((StatusCode::NO_CONTENT, ().into_response())) - } else { - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Json(details).into_response(), - )) - } -} - -/// Returns the list of software patterns. -/// -/// * `state`: service state. -#[utoipa::path( - get, - path = "/patterns", - context_path = "/api/software", - responses( - (status = 200, description = "List of known software patterns", body = Vec), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn patterns(State(state): State>) -> Result>, Error> { - let patterns = state.software.patterns(true).await?; - Ok(Json(patterns)) -} - -/// Sets the software configuration. -/// -/// * `state`: service state. -/// * `config`: software configuration. -#[utoipa::path( - put, - path = "/config", - context_path = "/api/software", - operation_id = "set_software_config", - responses( - (status = 200, description = "Set the software configuration"), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn set_config( - State(state): State>, - Json(config): Json, -) -> Result<(), Error> { - { - // first invalidate cache, so if it fails later, we know we need to re-read recent data - // use minimal context so it is released soon. - tracing::debug!("Invalidating product configuration cache"); - let mut cached_config_invalidate = state.config.write().await; - *cached_config_invalidate = None; - } - - // first set only require flag to ensure that it is used for later computing of solver - if let Some(only_required) = config.only_required { - state.software.set_only_required(only_required).await?; - } - - if let Some(product) = config.product { - state.product.select_product(&product).await?; - } - - if let Some(patterns) = config.patterns { - state.software.select_patterns(patterns).await?; - } - - if let Some(packages) = config.packages { - state.software.select_packages(packages).await?; - } - - if let Some(repositories) = config.extra_repositories { - state.software.set_user_repositories(repositories).await?; - } - - // load the config cache - let config = read_config(&state).await?; - tracing::debug!("Caching software configuration (set_config): {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config); - - Ok(()) -} - -/// Returns the software configuration. -/// -/// * `state` : service state. -#[utoipa::path( - get, - path = "/config", - context_path = "/api/software", - operation_id = "get_software_config", - responses( - (status = 200, description = "Software configuration", body = SoftwareConfig), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] - -async fn get_config(State(state): State>) -> Result, Error> { - let cached_config = state.config.read().await.clone(); - - if let Some(config) = cached_config { - tracing::debug!("Returning cached software config: {:?}", &config); - return Ok(Json(config)); - } - - let config = read_config(&state).await?; - tracing::debug!("Caching software configuration (get_config): {:?}", &config); - let mut cached_config_write = state.config.write().await; - *cached_config_write = Some(config.clone()); - - Ok(Json(config)) -} - -/// Helper function -/// * `state` : software service state -async fn read_config(state: &SoftwareState<'_>) -> Result { - let product = state.product.product().await?; - let product = if product.is_empty() { - None - } else { - Some(product) - }; - let patterns = state - .software - .user_selected_patterns() - .await? - .into_iter() - .map(|p| (p, true)) - .collect(); - let packages = state.software.user_selected_packages().await?; - let repos = state.software.user_repositories().await?; - - Ok(SoftwareConfig { - patterns: Some(patterns), - packages: Some(packages), - product, - extra_repositories: if repos.is_empty() { None } else { Some(repos) }, - only_required: state.software.get_only_required().await?, - }) -} - -#[derive(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, - /// 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, -} - -/// Returns the proposal information. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - get, - path = "/proposal", - context_path = "/api/software", - responses( - (status = 200, description = "Software proposal", body = SoftwareProposal) - ) -)] -async fn proposal(State(state): State>) -> Result, Error> { - let size = state.software.used_disk_space().await?; - let patterns = state.software.selected_patterns().await?; - let proposal = SoftwareProposal { size, patterns }; - Ok(Json(proposal)) -} - -/// Returns the proposal information. -/// -/// At this point, only the required space is reported. -#[utoipa::path( - post, - path = "/probe", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The D-Bus service could not perform the action -") - ), - operation_id = "software_probe" -)] -async fn probe(State(state): State>) -> Result, Error> { - state.software.probe().await?; - Ok(Json(())) -} - -/// Updates the resolvables list with the given `id`. -#[utoipa::path( - put, - path = "/resolvables/:id", - context_path = "/api/software", - responses( - (status = 200, description = "Read repositories data"), - (status = 400, description = "The D-Bus service could not perform the action -") - ) -)] -async fn set_resolvables( - State(state): State>, - Path(id): Path, - Json(params): Json, -) -> Result, Error> { - let names: Vec<_> = params.names.iter().map(|n| n.as_str()).collect(); - state - .software - .set_resolvables(&id, params.r#type, &names, params.optional) - .await?; - Ok(Json(())) -} - -/// Returns the list of known licenses. -/// -/// It includes the license ID and the languages in which it is available. -#[utoipa::path( - get, - path = "/licenses", - context_path = "/api/software", - responses( - (status = 200, description = "List of known licenses", body = Vec) - ) -)] -async fn licenses(State(state): State>) -> Result>, Error> { - Ok(Json(state.licenses.licenses.clone())) -} - -#[derive(Deserialize, utoipa::IntoParams)] -struct LicenseQuery { - lang: Option, -} - -/// Returns the license content. -/// -/// Optionally it can receive a language tag (RFC 5646). Otherwise, it returns -/// the license in English. -#[utoipa::path( - get, - path = "/licenses/:id", - context_path = "/api/software", - params(LicenseQuery), - responses( - (status = 200, description = "License with the given ID", body = LicenseContent), - (status = 400, description = "The specified language tag is not valid"), - (status = 404, description = "There is not license with the given ID") - ) -)] -async fn license( - State(state): State>, - Path(id): Path, - Query(query): Query, -) -> Result { - let lang = query.lang.unwrap_or("en".to_string()); - - let Ok(lang) = lang.as_str().try_into() else { - return Ok(StatusCode::BAD_REQUEST.into_response()); - }; - - if let Some(license) = state.licenses.find(&id, &lang) { - Ok(Json(license).into_response()) - } else { - Ok(StatusCode::NOT_FOUND.into_response()) - } -} diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 219a476ed3..3610e75332 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -30,8 +30,6 @@ mod storage; pub use storage::StorageApiDocBuilder; mod bootloader; pub use bootloader::BootloaderApiDocBuilder; -mod software; -pub use software::SoftwareApiDocBuilder; mod profile; pub use profile::ProfileApiDocBuilder; mod manager; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 23ae59ccd6..4451b79d10 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -115,9 +115,8 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -128,19 +127,12 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -182,7 +174,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .build() } } diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs deleted file mode 100644 index 52deb45745..0000000000 --- a/rust/agama-server/src/web/docs/software.rs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use utoipa::openapi::{Components, ComponentsBuilder, OpenApi, Paths, PathsBuilder}; - -use super::{common::ServiceStatusApiDocBuilder, ApiDocBuilder}; - -pub struct SoftwareApiDocBuilder; - -impl ApiDocBuilder for SoftwareApiDocBuilder { - fn title(&self) -> String { - "Software HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .build() - } - - fn nested(&self) -> Option { - let status = ServiceStatusApiDocBuilder::new("/api/storage/status").build(); - Some(status) - } -} diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index a70bfd8e14..a54bcc6f6b 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -41,7 +41,7 @@ pub use start::start; pub mod service; pub use service::Service; -mod model; +pub mod model; pub use model::{Model, ModelAdapter}; mod event; diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 92cf1d7a1f..2bf6d9a16b 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -7,7 +7,7 @@ mod tasks { use agama_server::web::docs::{ ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, - SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, + StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -71,7 +71,6 @@ mod tasks { write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; write_openapi(ScriptsApiDocBuilder {}, out_dir.join("scripts.json"))?; - write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; write_openapi(StorageApiDocBuilder {}, out_dir.join("storage.json"))?; write_openapi(UsersApiDocBuilder {}, out_dir.join("users.json"))?; println!( From 02a02853eaf3223db47238982164c18418069cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 11:16:39 +0000 Subject: [PATCH 310/917] Simplify new software code --- rust/agama-lib/src/install_settings.rs | 4 +- rust/agama-lib/src/scripts/store.rs | 5 +- rust/agama-software/src/model.rs | 142 ++----------------------- rust/agama-software/src/service.rs | 39 +------ rust/agama-software/src/zypp_server.rs | 132 +---------------------- 5 files changed, 13 insertions(+), 309 deletions(-) diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index fa7be43229..676b40a490 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -29,8 +29,8 @@ use crate::hostname::model::HostnameSettings; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ - network::NetworkSettings, scripts::ScriptsConfig, - storage::settings::dasd::DASDConfig, users::UserSettings, + network::NetworkSettings, scripts::ScriptsConfig, storage::settings::dasd::DASDConfig, + users::UserSettings, }; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index a832b6919f..767ce7ca99 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -18,10 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - file_source::FileSourceError, - http::BaseHTTPClient, -}; +use crate::{file_source::FileSourceError, http::BaseHTTPClient}; use super::{ client::{ScriptsClient, ScriptsClientError}, diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index eb5f5f0545..6e8c8cca29 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,16 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{software::RepositoryParams, Issue}; +use agama_utils::api::Issue; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ - packages::{Repository, ResolvableType}, + packages::ResolvableType, pattern::Pattern, products::{ProductSpec, UserPattern}, - registration::{AddonProperties, RegistrationInfo}, software_selection::SoftwareSelection, state::SoftwareState, }, @@ -55,24 +54,6 @@ pub trait ModelAdapter: Send + Sync + 'static { /// List of available patterns. async fn patterns(&self) -> Result, service::Error>; - /// List of available repositories. - async fn repositories(&self) -> Result, service::Error>; - - /// Adds given list of repositories and loads them - async fn add_repositories(&self, list: Vec) -> Result<(), service::Error>; - - /// List of available addons. - fn addons(&self) -> Result, service::Error>; - - /// info about registration - fn registration_info(&self) -> Result; - - /// check if package is available - async fn is_package_available(&self, tag: String) -> Result; - - /// check if package is selected for installation - async fn is_package_selected(&self, tag: String) -> Result; - /// Gets resolvables set for given combination of id, type and optional flag fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec; @@ -86,7 +67,7 @@ pub trait ModelAdapter: Send + Sync + 'static { ) -> Result<(), service::Error>; /// Probes system and updates info about it. - async fn probe(&mut self, product: &ProductSpec) -> Result<(), service::Error>; + async fn probe(&mut self) -> Result<(), service::Error>; /// install rpms to target system async fn install(&self) -> Result; @@ -152,26 +133,16 @@ impl ModelAdapter for Model { Ok(rx.await??) } - async fn is_package_available(&self, tag: String) -> Result { - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::PackageAvailable(tag, tx))?; - Ok(rx.await??) - } - - async fn is_package_selected(&self, tag: String) -> Result { - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::PackageSelected(tag, tx))?; - Ok(rx.await??) - } - fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec { self.software_selection .get(id, r#type, optional) .unwrap_or_default() } + async fn probe(&mut self) -> Result<(), service::Error> { + unimplemented!() + } + async fn set_resolvables( &mut self, id: &str, @@ -185,88 +156,6 @@ impl ModelAdapter for Model { Ok(()) } - async fn add_repositories(&self, list: Vec) -> Result<(), service::Error> { - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::AddRepositories(list, tx))?; - Ok(rx.await??) - } - - async fn probe(&mut self, product: &ProductSpec) -> Result<(), service::Error> { - let (tx, rx) = oneshot::channel(); - // TODO: create own repository registry that will hold all sources of repositories - // like manual ones, product ones or ones from kernel cmdline - let repositories = product - .software - .repositories() - .into_iter() - .enumerate() - .map(|(i, r)| - // we need to get somehow better names and aliases - RepositoryParams { alias: format!("{}_{}", product.id, i), name: Some(format!("{} {}", product.name, i)), url: r.url.clone(), product_dir: None, enabled: Some(true), priority: None, allow_unsigned: Some(false), gpg_fingerprints: None } - ).collect(); - self.zypp_sender - .send(SoftwareAction::AddRepositories(repositories, tx))?; - rx.await??; - - let installer_id = "Installer"; - self.software_selection - .set( - &self.zypp_sender, - installer_id, - ResolvableType::Product, - false, - vec![product.id.clone()], - ) - .await?; - - let resolvables: Vec<_> = product.software.mandatory_patterns.clone(); - self.software_selection - .set( - &self.zypp_sender, - installer_id, - ResolvableType::Pattern, - false, - resolvables, - ) - .await?; - - let resolvables: Vec<_> = product.software.mandatory_packages.clone(); - self.software_selection - .set( - &self.zypp_sender, - installer_id, - ResolvableType::Package, - false, - resolvables, - ) - .await?; - - let resolvables: Vec<_> = product.software.optional_patterns.clone(); - self.software_selection - .set( - &self.zypp_sender, - installer_id, - ResolvableType::Pattern, - true, - resolvables, - ) - .await?; - - let resolvables: Vec<_> = product.software.optional_packages.clone(); - self.software_selection - .set( - &self.zypp_sender, - installer_id, - ResolvableType::Package, - true, - resolvables, - ) - .await?; - - Ok(()) - } - async fn finish(&self) -> Result<(), service::Error> { let (tx, rx) = oneshot::channel(); self.zypp_sender.send(SoftwareAction::Finish(tx))?; @@ -278,21 +167,4 @@ impl ModelAdapter for Model { self.zypp_sender.send(SoftwareAction::Install(tx))?; Ok(rx.await??) } - - // FIXME: do we want to store here only user specified repos or also ones e.g. get from registration server? - // now we query libzypp to get all of them - async fn repositories(&self) -> Result, service::Error> { - let (tx, rx) = oneshot::channel(); - self.zypp_sender - .send(SoftwareAction::ListRepositories(tx))?; - Ok(rx.await??) - } - - fn addons(&self) -> Result, service::Error> { - todo!() - } - - fn registration_info(&self) -> Result { - todo!() - } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index c0fa872260..27928301ac 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -138,35 +138,6 @@ impl Service { Ok(()) } - - async fn apply_config(config: &Config, model: &mut dyn ModelAdapter) -> Result<(), Error> { - if let Some(software) = &config.software { - let user_id = "user"; - let patterns = software.patterns.clone().unwrap_or_default(); - let packages = software.packages.clone().unwrap_or_default(); - let extra_repositories = software.extra_repositories.clone().unwrap_or_default(); - // TODO: patterns as it as it can be either set or add/remove set - model - .set_resolvables(user_id, ResolvableType::Package, packages, false) - .await?; - // for repositories we should allow also to remove previously defined one, but now for simplicity just check if it there and if not, then add it - // TODO: replace it with future repository registry - let existing_repositories = model.repositories().await?; - let new_repos = extra_repositories - .iter() - .filter(|r| { - existing_repositories - .iter() - .find(|repo| repo.alias == r.alias) - .is_none() - }) - .cloned() - .collect(); - model.add_repositories(new_repos).await?; - } - - Ok(()) - } } impl Actor for Service { @@ -233,15 +204,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { - let Some(product_id) = self.state.config.product.clone().and_then(|c| c.id) else { - return Err(Error::MissingProduct); - }; - - let Some(product) = self.products.find(&product_id) else { - return Err(Error::WrongProduct(product_id)); - }; - - self.model.lock().await.probe(product).await?; + self.model.lock().await.probe().await?; self.update_system().await?; Ok(()) } diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 33aa45ebd0..62394a9f5a 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{issue, software::RepositoryParams, Issue, IssueSeverity, IssueSource}; +use agama_utils::api::{Issue, IssueSeverity}; use std::path::Path; use tokio::sync::{ mpsc::{self, UnboundedSender}, @@ -27,7 +27,7 @@ use tokio::sync::{ use zypp_agama::ZyppError; use crate::model::{ - packages::{Repository, ResolvableType}, + packages::ResolvableType, pattern::Pattern, state::{self, SoftwareState}, }; @@ -81,15 +81,9 @@ pub type ZyppServerResult = Result; #[derive(Debug)] pub enum SoftwareAction { - AddRepositories(Vec, oneshot::Sender>), - RemoveRepositories(Vec, oneshot::Sender>), Install(oneshot::Sender>), Finish(oneshot::Sender>), - ListRepositories(oneshot::Sender>>), GetPatternsMetadata(Vec, oneshot::Sender>>), - PackageAvailable(String, oneshot::Sender>), - PackageSelected(String, oneshot::Sender>), - Solve(oneshot::Sender>), SetResolvables { tx: oneshot::Sender>, resolvables: Vec, @@ -174,26 +168,11 @@ impl ZyppServer { SoftwareAction::Write { state, tx } => { self.write(state, tx, zypp).await?; } - SoftwareAction::AddRepositories(repos, tx) => { - self.add_repositories(repos, tx, zypp).await?; - } - - SoftwareAction::RemoveRepositories(repos, tx) => { - self.remove_repositories(repos, tx, zypp).await?; - } SoftwareAction::GetPatternsMetadata(names, tx) => { self.get_patterns(names, tx, zypp).await?; } - SoftwareAction::PackageSelected(tag, tx) => { - self.package_selected(zypp, tag, tx).await?; - } - - SoftwareAction::PackageAvailable(tag, tx) => { - self.package_available(zypp, tag, tx).await?; - } - SoftwareAction::Install(tx) => { tx.send(self.install(zypp)) .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; @@ -244,35 +223,6 @@ impl ZyppServer { } } } - - SoftwareAction::Solve(tx) => { - let res = zypp.run_solver(); - tx.send(res) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - } - - SoftwareAction::ListRepositories(tx) => { - let repos_res = zypp.list_repositories(); - let result = repos_res - .map(|repos| { - repos - .into_iter() - .enumerate() - .map(|(index, repo)| Repository { - url: repo.url, - // unwrap here is ok, as number of repos are low - alias: repo.alias, - name: repo.user_name, - enabled: repo.enabled, - mandatory: false, - }) - .collect() - }) - .map_err(|e| e.into()); - - tx.send(result) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - } } Ok(()) } @@ -417,60 +367,6 @@ impl ZyppServer { Ok(()) } - async fn add_repositories( - &self, - repos: Vec, - tx: oneshot::Sender>, - zypp: &zypp_agama::Zypp, - ) -> Result<(), ZyppDispatchError> { - for (idx, repo) in repos.iter().enumerate() { - // TODO: we should add a repository ID in the configuration file. - let name = format!("agama-{}", idx); - let res = zypp - .add_repository(&name, &repo.url, |percent, alias| { - tracing::info!("Adding repository {} ({}%)", alias, percent); - true - }) - .map_err(ZyppServerError::AddRepositoryFailed); - if res.is_err() { - tx.send(res) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - return Ok(()); - } - } - - let res = zypp - .load_source(|percent, alias| { - tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); - true - }) - .map_err(ZyppServerError::LoadSourcesFailed); - if res.is_err() { - tx.send(res) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - } - - Ok(()) - } - - async fn remove_repositories( - &self, - repos: Vec, - tx: oneshot::Sender>, - zypp: &zypp_agama::Zypp, - ) -> Result<(), ZyppDispatchError> { - for repo in repos { - let res = zypp.remove_repository(&repo, |_, _| true); - if res.is_err() { - tx.send(res.map_err(|e| e.into())) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - return Ok(()); - } - } - - Ok(()) - } - async fn finish( &mut self, zypp: &zypp_agama::Zypp, @@ -539,30 +435,6 @@ impl ZyppServer { Ok(()) } - async fn package_available( - &self, - zypp: &zypp_agama::Zypp, - tag: String, - tx: oneshot::Sender>, - ) -> Result<(), ZyppDispatchError> { - let result = zypp.is_package_available(&tag); - tx.send(result) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - Ok(()) - } - - async fn package_selected( - &self, - zypp: &zypp_agama::Zypp, - tag: String, - tx: oneshot::Sender>, - ) -> Result<(), ZyppDispatchError> { - let result = zypp.is_package_selected(&tag); - tx.send(result) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - Ok(()) - } - async fn get_patterns( &self, names: Vec, From 045ddc11e4461bb9fb6241fd27132090efc5aa46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 13:09:01 +0000 Subject: [PATCH 311/917] Check config schema --- rust/agama-lib/src/questions/http_client.rs | 6 ++- rust/agama-server/src/server.rs | 2 + rust/agama-server/src/server/config_schema.rs | 45 +++++++++++++++++++ rust/agama-server/src/server/web.rs | 22 +++++---- rust/agama-utils/src/api/config.rs | 4 +- 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 rust/agama-server/src/server/config_schema.rs diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 642f58b75e..4e05e6011b 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -38,6 +38,8 @@ pub enum QuestionsHTTPClientError { HTTP(#[from] BaseHTTPClientError), #[error("Unknown question with ID {0}")] UnknownQuestion(u32), + #[error(transparent)] + Json(#[from] serde_json::Error), } pub struct HTTPClient { @@ -100,7 +102,7 @@ impl HTTPClient { }; let patch = Patch { - update: Some(config), + update: Some(serde_json::to_value(config)?), }; _ = self.client.patch_void("/v2/config", &patch).await?; @@ -121,7 +123,7 @@ impl HTTPClient { }; let patch = Patch { - update: Some(config), + update: Some(serde_json::to_value(config)?), }; self.client.patch_void("/v2/config", &patch).await?; Ok(()) diff --git a/rust/agama-server/src/server.rs b/rust/agama-server/src/server.rs index a429170043..e06d2546f7 100644 --- a/rust/agama-server/src/server.rs +++ b/rust/agama-server/src/server.rs @@ -20,3 +20,5 @@ pub mod web; pub use web::server_service; + +mod config_schema; diff --git a/rust/agama-server/src/server/config_schema.rs b/rust/agama-server/src/server/config_schema.rs new file mode 100644 index 0000000000..d291176b80 --- /dev/null +++ b/rust/agama-server/src/server/config_schema.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module provides utilities to check the config schema. + +use agama_lib::{ + error::ProfileError, + profile::{ProfileValidator, ValidationOutcome}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The config does not match the schema: {0}")] + Schema(String), + #[error(transparent)] + ProfileValidator(#[from] ProfileError), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub fn check(json: &serde_json::Value) -> Result<(), Error> { + let raw_json = serde_json::to_string(json)?; + let result = ProfileValidator::default_schema()?.validate_str(&raw_json)?; + match result { + ValidationOutcome::Valid => Ok(()), + ValidationOutcome::NotValid(reasons) => Err(Error::Schema(reasons.join(", "))), + } +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 6017475292..0cca3f71bb 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -20,6 +20,7 @@ //! This module implements Agama's HTTP API. +use crate::server::config_schema; use agama_lib::error::ServiceError; use agama_manager::{self as manager, message}; use agama_utils::{ @@ -39,7 +40,7 @@ use axum::{ }; use hyper::StatusCode; use serde::Serialize; -use serde_json::{json, value::RawValue}; +use serde_json::{json, value::RawValue, Value}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -47,6 +48,10 @@ pub enum Error { Manager(#[from] manager::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), + #[error(transparent)] + ConfigSchema(#[from] config_schema::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), } impl IntoResponse for Error { @@ -179,13 +184,12 @@ async fn get_config(State(state): State) -> ServerResult, - Json(config): Json, -) -> ServerResult<()> { +async fn put_config(State(state): State, Json(json): Json) -> ServerResult<()> { + config_schema::check(&json)?; + let config = serde_json::from_value(json)?; state.manager.call(message::SetConfig::new(config)).await?; Ok(()) } @@ -202,14 +206,16 @@ async fn put_config( (status = 400, description = "Not possible to patch the configuration.") ), params( - ("config" = Config, description = "Changes in the configuration.") + ("config" = config::Patch, description = "Changes in the configuration.") ) )] async fn patch_config( State(state): State, Json(patch): Json, ) -> ServerResult<()> { - if let Some(config) = patch.update { + if let Some(json) = patch.update { + config_schema::check(&json)?; + let config = serde_json::from_value(json)?; state .manager .call(message::UpdateConfig::new(config)) diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index a85ebcc605..e13e50e8db 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -20,7 +20,7 @@ use crate::api::{l10n, question}; use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; +use serde_json::{value::RawValue, Value}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] @@ -43,5 +43,5 @@ pub struct Config { #[serde(rename_all = "camelCase")] pub struct Patch { /// Update for the current config. - pub update: Option, + pub update: Option, } From 5e798bb4d76d9a8c46eee81820198fe267565957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 15:46:36 +0000 Subject: [PATCH 312/917] Extend schema --- rust/agama-lib/share/profile.schema.json | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index c0d314f34a..33e06eab27 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -766,9 +766,33 @@ } } }, - "localization": { + "l10n": { "title": "Localization settings", "type": "object", + "additionalProperties": false, + "properties": { + "locale": { + "title": "Locale ID", + "type": "string", + "examples": ["en_US.UTF-8", "en_US"] + }, + "keymap": { + "title": "Keymap ID", + "type": "string", + "examples": ["us", "en", "es"] + }, + "timezone": { + "title": "Time zone ID", + "type": "string", + "examples": ["Europe/Berlin"] + } + } + }, + "localization": { + "deprecated": true, + "title": "Localization settings (old schema)", + "type": "object", + "additionalProperties": false, "properties": { "language": { "title": "System language ID", From 204c18fa41dbb1500c35fd56f4a9665880bbc3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 16:05:31 +0000 Subject: [PATCH 313/917] Extract patch --- rust/agama-lib/src/questions/http_client.rs | 12 ++---- rust/agama-server/src/server/web.rs | 8 ++-- rust/agama-server/src/web/docs/config.rs | 2 +- rust/agama-utils/src/api.rs | 3 ++ rust/agama-utils/src/api/config.rs | 10 +---- rust/agama-utils/src/api/patch.rs | 45 +++++++++++++++++++++ 6 files changed, 58 insertions(+), 22 deletions(-) create mode 100644 rust/agama-utils/src/api/patch.rs diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 4e05e6011b..b4f3227f5f 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -21,7 +21,7 @@ use std::time::Duration; use agama_utils::api::{ - config::Patch, + patch::{self, Patch}, question::{ Answer, AnswerRule, Config as QuestionsConfig, Policy, Question, QuestionSpec, UpdateQuestion, @@ -39,7 +39,7 @@ pub enum QuestionsHTTPClientError { #[error("Unknown question with ID {0}")] UnknownQuestion(u32), #[error(transparent)] - Json(#[from] serde_json::Error), + Patch(#[from] patch::Error), } pub struct HTTPClient { @@ -101,9 +101,7 @@ impl HTTPClient { ..Default::default() }; - let patch = Patch { - update: Some(serde_json::to_value(config)?), - }; + let patch = Patch::with_update(&config)?; _ = self.client.patch_void("/v2/config", &patch).await?; Ok(()) @@ -122,9 +120,7 @@ impl HTTPClient { ..Default::default() }; - let patch = Patch { - update: Some(serde_json::to_value(config)?), - }; + let patch = Patch::with_update(&config)?; self.client.patch_void("/v2/config", &patch).await?; Ok(()) } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 0cca3f71bb..8136cb4d0c 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -26,9 +26,9 @@ use agama_manager::{self as manager, message}; use agama_utils::{ actor::Handler, api::{ - config, event, + event, question::{Question, QuestionSpec, UpdateQuestion}, - Action, Config, IssueMap, Status, SystemInfo, + Action, Config, IssueMap, Patch, Status, SystemInfo, }, question, }; @@ -206,12 +206,12 @@ async fn put_config(State(state): State, Json(json): Json) - (status = 400, description = "Not possible to patch the configuration.") ), params( - ("config" = config::Patch, description = "Changes in the configuration.") + ("patch" = Patch, description = "Changes in the configuration.") ) )] async fn patch_config( State(state): State, - Json(patch): Json, + Json(patch): Json, ) -> ServerResult<()> { if let Some(json) = patch.update { config_schema::check(&json)?; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ff37ec93a8..ef0c136a18 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -164,7 +164,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 4782b464af..548a407d26 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -42,6 +42,9 @@ pub use system_info::SystemInfo; pub mod config; pub use config::Config; +pub mod patch; +pub use patch::Patch; + mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index e13e50e8db..0c1f9e56fb 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -20,7 +20,7 @@ use crate::api::{l10n, question}; use serde::{Deserialize, Serialize}; -use serde_json::{value::RawValue, Value}; +use serde_json::value::RawValue; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] @@ -37,11 +37,3 @@ pub struct Config { #[schema(value_type = Object)] pub legacy_autoyast_storage: Option>, } - -/// Patch for the config. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Patch { - /// Update for the current config. - pub update: Option, -} diff --git a/rust/agama-utils/src/api/patch.rs b/rust/agama-utils/src/api/patch.rs new file mode 100644 index 0000000000..6c401ce7cf --- /dev/null +++ b/rust/agama-utils/src/api/patch.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::config::Config; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +/// Patch for the config. +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Patch { + /// Update for the current config. + pub update: Option, +} + +impl Patch { + pub fn with_update(config: &Config) -> Result { + Ok(Self { + update: Some(serde_json::to_value(config)?), + }) + } +} From bdfd2b199dd54d29c25616c2a1963e8b90823c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 31 Oct 2025 16:53:07 +0000 Subject: [PATCH 314/917] Fix example --- rust/agama-lib/share/examples/profile_tw.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/share/examples/profile_tw.json b/rust/agama-lib/share/examples/profile_tw.json index 2921de1c48..0240823880 100644 --- a/rust/agama-lib/share/examples/profile_tw.json +++ b/rust/agama-lib/share/examples/profile_tw.json @@ -2,7 +2,7 @@ "localization": { "keyboard": "es", "language": "es_ES.UTF-8", - "keymap": "es_ES.UTF-8" + "timezone": "Europe/Berlin" }, "software": { "patterns": ["gnome"], From 5c2c6c59c6c65f4ab95f0b6f80cb8033afa9e791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 14:41:18 +0000 Subject: [PATCH 315/917] Drop the unused extended_config module --- rust/agama-software/src/extended_config.rs | 94 ---------------------- rust/agama-software/src/lib.rs | 1 - 2 files changed, 95 deletions(-) delete mode 100644 rust/agama-software/src/extended_config.rs diff --git a/rust/agama-software/src/extended_config.rs b/rust/agama-software/src/extended_config.rs deleted file mode 100644 index 2fab02c7a7..0000000000 --- a/rust/agama-software/src/extended_config.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_utils::api::software::{ - Config, PatternsConfig, ProductConfig, RepositoryParams, SoftwareConfig, -}; -use serde::Serialize; - -#[derive(Clone, PartialEq, Serialize)] -pub struct ExtendedConfig { - /// Product related configuration - #[serde(skip_serializing_if = "ProductConfig::is_empty")] - pub product: ProductConfig, - /// Software related configuration - pub software: ExtendedSoftwareSettings, -} - -/// Software settings for installation -#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ExtendedSoftwareSettings { - /// List of user selected patterns to install. - pub patterns: PatternsConfig, - /// List of user selected packages to install. - pub packages: Vec, - /// List of user specified repositories to use on top of default ones. - pub extra_repositories: Vec, - /// Flag indicating if only hard requirements should be used by solver. - pub only_required: bool, -} - -impl ExtendedSoftwareSettings { - pub fn merge(&mut self, config: &SoftwareConfig) -> &Self { - if let Some(patterns) = &config.patterns { - self.patterns = patterns.clone(); - } - - if let Some(packages) = &config.packages { - self.packages = packages.clone(); - } - - if let Some(extra_repositories) = &config.extra_repositories { - self.extra_repositories = extra_repositories.clone(); - } - - if let Some(only_required) = config.only_required { - self.only_required = only_required; - } - - self - } -} - -impl Default for ExtendedSoftwareSettings { - fn default() -> Self { - Self { - patterns: PatternsConfig::default(), - packages: Default::default(), - extra_repositories: Default::default(), - only_required: false, - } - } -} - -impl ExtendedConfig { - pub fn merge(&mut self, config: &Config) -> &Self { - if let Some(product_settings) = &config.product { - self.product = product_settings.clone(); - } - - if let Some(software) = &config.software { - self.software.merge(software); - } - - self - } -} diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index a54bcc6f6b..020d519d17 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -53,6 +53,5 @@ pub use system_info::SystemInfo; mod proposal; pub use proposal::Proposal; -mod extended_config; pub mod message; mod zypp_server; From a1f4264821510c573cab3d22ce0fc17026903d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 31 Oct 2025 16:38:54 +0000 Subject: [PATCH 316/917] Move software types to the agama-utils package --- rust/Cargo.lock | 15 +- rust/agama-server/src/web/docs/config.rs | 8 +- rust/agama-software/src/lib.rs | 3 - rust/agama-software/src/message.rs | 7 +- rust/agama-software/src/model.rs | 5 +- rust/agama-software/src/model/license.rs | 73 +------ rust/agama-software/src/model/packages.rs | 34 ---- rust/agama-software/src/model/pattern.rs | 17 -- rust/agama-software/src/model/product.rs | 19 -- rust/agama-software/src/model/products.rs | 3 +- rust/agama-software/src/model/registration.rs | 24 --- rust/agama-software/src/model/state.rs | 34 ++-- rust/agama-software/src/service.rs | 17 +- rust/agama-software/src/system_info.rs | 44 ---- rust/agama-software/src/zypp_server.rs | 3 +- rust/agama-utils/Cargo.toml | 1 + rust/agama-utils/src/api/software.rs | 177 +---------------- rust/agama-utils/src/api/software/config.rs | 188 ++++++++++++++++++ rust/agama-utils/src/api/software/license.rs | 109 ++++++++++ .../src/api/software/system_info.rs | 112 +++++++++++ 20 files changed, 465 insertions(+), 428 deletions(-) delete mode 100644 rust/agama-software/src/model/pattern.rs delete mode 100644 rust/agama-software/src/model/product.rs delete mode 100644 rust/agama-software/src/system_info.rs create mode 100644 rust/agama-utils/src/api/software/config.rs create mode 100644 rust/agama-utils/src/api/software/license.rs create mode 100644 rust/agama-utils/src/api/software/system_info.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 798f2ba3ab..0bf1d1b8e4 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -251,6 +251,7 @@ dependencies = [ "agama-locale-data", "async-trait", "gettext-rs", + "regex", "serde", "serde_json", "serde_with", @@ -1579,7 +1580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ "bit-set 0.8.0", - "regex-automata 0.4.9", + "regex-automata 0.4.13", "regex-syntax 0.8.5", ] @@ -2536,7 +2537,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.9", + "regex-automata 0.4.13", ] [[package]] @@ -3572,13 +3573,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", + "regex-automata 0.4.13", "regex-syntax 0.8.5", ] @@ -3593,9 +3594,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 4451b79d10..4b298968d1 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -130,9 +130,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -172,7 +172,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .build() } diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index 020d519d17..0b8c615bb6 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -47,9 +47,6 @@ pub use model::{Model, ModelAdapter}; mod event; pub use event::Event; -mod system_info; -pub use system_info::SystemInfo; - mod proposal; pub use proposal::Proposal; diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 7236872926..e7fa68016a 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -18,8 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{proposal::Proposal, system_info::SystemInfo}; -use agama_utils::{actor::Message, api::software::Config}; +use crate::proposal::Proposal; +use agama_utils::{ + actor::Message, + api::software::{Config, SystemInfo}, +}; #[derive(Clone)] pub struct GetSystem; diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 6e8c8cca29..d6e3aacefc 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,14 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::Issue; +use agama_utils::api::{software::Pattern, Issue}; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ packages::ResolvableType, - pattern::Pattern, products::{ProductSpec, UserPattern}, software_selection::SoftwareSelection, state::SoftwareState, @@ -37,8 +36,6 @@ use crate::{ pub mod conflict; pub mod license; pub mod packages; -pub mod pattern; -pub mod product; pub mod products; pub mod registration; pub mod software_selection; diff --git a/rust/agama-software/src/model/license.rs b/rust/agama-software/src/model/license.rs index 47f0bdeedc..abcc0c04ea 100644 --- a/rust/agama-software/src/model/license.rs +++ b/rust/agama-software/src/model/license.rs @@ -21,12 +21,11 @@ //! Implements support for reading software licenses. use agama_locale_data::get_territories; -use regex::Regex; +use agama_utils::api::software::{InvalidLanguageCode, LanguageTag, License}; use serde::Serialize; use serde_with::{serde_as, DisplayFromStr}; use std::{ collections::HashMap, - fmt::Display, fs::read_dir, path::{Path, PathBuf}, }; @@ -35,24 +34,11 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("Not a valid language code: {0}")] - InvalidLanguageCode(String), + InvalidLanguageCode(#[from] InvalidLanguageCode), #[error("I/O error")] IO(#[from] std::io::Error), } -/// Represents a product license. -/// -/// It contains the license ID and the list of languages that with a translation. -#[serde_as] -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct License { - /// License ID. - pub id: String, - /// Languages in which the license is translated. - #[serde_as(as = "Vec")] - pub languages: Vec, -} - /// Represents a license content. /// /// It contains the license ID and the body. @@ -249,60 +235,11 @@ impl Default for LicensesRepo { } } -/// Simplified representation of the RFC 5646 language code. -/// -/// It only considers xx and xx-XX formats. -#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] -pub struct LanguageTag { - // ISO-639 - pub language: String, - // ISO-3166 - pub territory: Option, -} - -impl Default for LanguageTag { - fn default() -> Self { - LanguageTag { - language: "en".to_string(), - territory: None, - } - } -} - -impl Display for LanguageTag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(territory) = &self.territory { - write!(f, "{}-{}", &self.language, territory) - } else { - write!(f, "{}", &self.language) - } - } -} - -#[derive(Error, Debug)] -#[error("Not a valid language code: {0}")] -pub struct InvalidLanguageCode(String); - -impl TryFrom<&str> for LanguageTag { - type Error = Error; - - fn try_from(value: &str) -> Result { - let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); - - let captures = language_regexp - .captures(value) - .ok_or_else(|| Error::InvalidLanguageCode(value.to_string()))?; - - Ok(Self { - language: captures.get(1).unwrap().as_str().to_string(), - territory: captures.get(2).map(|e| e.as_str().to_string()), - }) - } -} - #[cfg(test)] mod test { - use super::{LanguageTag, LicensesRepo}; + use agama_utils::api::software::LanguageTag; + + use super::LicensesRepo; use std::path::Path; fn build_repo() -> LicensesRepo { diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 7225df2c3d..1c2df708e7 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -18,25 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::software::RepositoryParams; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareConfig { - /// A map where the keys are the pattern names and the values whether to install them or not. - pub patterns: Option>, - /// Packages to install. - pub packages: Option>, - /// Name of the product to install. - pub product: Option, - /// Extra repositories defined by user. - pub extra_repositories: Option>, - /// Flag if solver should use only hard dependencies. - pub only_required: Option, -} /// Software resolvable type (package or pattern). #[derive( @@ -70,19 +52,3 @@ pub struct ResolvableParams { /// Whether the resolvables are optional or not. pub optional: bool, } - -/// Repository specification. -#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Repository { - /// Repository alias. It has to be unique. - pub alias: String, - /// Repository name - pub name: String, - /// Repository URL (raw format without expanded variables) - 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, -} diff --git a/rust/agama-software/src/model/pattern.rs b/rust/agama-software/src/model/pattern.rs deleted file mode 100644 index f40e1acc5f..0000000000 --- a/rust/agama-software/src/model/pattern.rs +++ /dev/null @@ -1,17 +0,0 @@ -use serde::Serialize; - -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct Pattern { - /// Pattern name (eg., "aaa_base", "gnome") - pub name: String, - /// Pattern category (e.g., "Production") - pub category: String, - /// Pattern icon path locally on system - pub icon: String, - /// Pattern description - pub description: String, - /// Pattern summary - pub summary: String, - /// Pattern order - pub order: String, -} diff --git a/rust/agama-software/src/model/product.rs b/rust/agama-software/src/model/product.rs deleted file mode 100644 index b8ab6ae424..0000000000 --- a/rust/agama-software/src/model/product.rs +++ /dev/null @@ -1,19 +0,0 @@ -use serde::Serialize; - -/// Represents a software product -#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Product { - /// Product ID (eg., "ALP", "Tumbleweed", etc.) - pub id: String, - /// Product name (e.g., "openSUSE Tumbleweed") - pub name: String, - /// Product description - pub description: String, - /// Product icon (e.g., "default.svg") - pub icon: String, - /// Registration requirement - pub registration: bool, - /// License ID - pub license: Option, -} diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index e13961be80..36998b972e 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -24,12 +24,11 @@ //! It reads the list of products from the `products.d` directory (usually, //! `/usr/share/agama/products.d`). +use agama_utils::api::software::Product; use serde::{Deserialize, Deserializer}; use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use std::path::{Path, PathBuf}; -use crate::model::product::Product; - #[derive(thiserror::Error, Debug)] pub enum ProductsRegistryError { #[error("Could not read the products registry: {0}")] diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs index 8b84342a2a..acac8ff6ba 100644 --- a/rust/agama-software/src/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -41,30 +41,6 @@ pub struct AddonParams { pub registration_code: Option, } -/// Addon registration -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonProperties { - /// Addon identifier - pub id: String, - /// Version of the addon - pub version: String, - /// User visible name - pub label: String, - /// Whether the addon is mirrored on the RMT server, on SCC it is always `true` - pub available: bool, - /// Whether a registration code is required for registering the addon - pub free: bool, - /// Whether the addon is recommended for the users - pub recommended: bool, - /// Short description of the addon (translated) - pub description: String, - /// Type of the addon, like "extension" or "module" - pub r#type: String, - /// Release status of the addon, e.g. "beta" - pub release: String, -} - /// Information about registration configuration (product, patterns, etc.). #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 54dc1a113c..2757515f28 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -22,15 +22,9 @@ //! configuration and a mechanism to build it starting from the product //! definition, the user configuration, etc. -use agama_utils::api::software::{Config, PatternsConfig, RepositoryParams}; +use agama_utils::api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo}; -use crate::{ - model::{ - packages, - products::{ProductSpec, UserPattern}, - }, - SystemInfo, -}; +use crate::model::products::{ProductSpec, UserPattern}; /// Represents the wanted software configuration. /// @@ -81,11 +75,11 @@ impl<'a> SoftwareStateBuilder<'a> { let mut state = self.from_product_spec(); if let Some(system) = self.system { - self.add_system_config(&mut state, &system); + self.add_system_config(&mut state, system); } if let Some(config) = self.config { - self.add_user_config(&mut state, &config); + self.add_user_config(&mut state, config); } state @@ -211,8 +205,8 @@ pub struct Repository { pub enabled: bool, } -impl From<&RepositoryParams> for Repository { - fn from(value: &RepositoryParams) -> Self { +impl From<&RepositoryConfig> for Repository { + fn from(value: &RepositoryConfig) -> Self { Repository { name: value.name.as_ref().unwrap_or(&value.alias).clone(), alias: value.alias.clone(), @@ -222,8 +216,8 @@ impl From<&RepositoryParams> for Repository { } } -impl From<&packages::Repository> for Repository { - fn from(value: &packages::Repository) -> Self { +impl From<&agama_utils::api::software::Repository> for Repository { + fn from(value: &agama_utils::api::software::Repository) -> Self { Repository { name: value.name.clone(), alias: value.alias.clone(), @@ -263,15 +257,17 @@ mod tests { use std::path::PathBuf; use agama_utils::api::software::{ - PatternsConfig, PatternsMap, RepositoryParams, SoftwareConfig, + Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, + SystemInfo, }; - use crate::model::packages::Repository; - - use super::*; + use crate::model::{ + products::ProductSpec, + state::{ResolvableName, SoftwareStateBuilder}, + }; fn build_user_config(patterns: Option) -> Config { - let repo = RepositoryParams { + let repo = RepositoryConfig { alias: "user-repo-0".to_string(), url: "http://example.net/repo".to_string(), name: None, diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 27928301ac..88c21705d9 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -18,33 +18,30 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::{ops::DerefMut, process::Command, sync::Arc}; +use std::{process::Command, sync::Arc}; use crate::{ message, model::{ license::{Error as LicenseError, LicensesRepo}, - packages::{self, Repository, ResolvableType}, - products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, - software_selection::SoftwareSelection, - state::{self, SoftwareState}, + products::{ProductsRegistry, ProductsRegistryError}, + state::SoftwareState, ModelAdapter, }, proposal::Proposal, - system_info::SystemInfo, zypp_server::{self, SoftwareAction}, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ event::{self, Event}, - software::{Config, ProductConfig, RepositoryParams}, + software::{Config, Repository, SystemInfo}, Scope, }, issue, }; use async_trait::async_trait; -use tokio::sync::{broadcast, Mutex, RwLock}; +use tokio::sync::{broadcast, Mutex}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -227,12 +224,12 @@ impl MessageHandler for Service { const LIVE_REPO_DIR: &str = "/run/initramfs/live/install"; -fn find_install_repository() -> Option { +fn find_install_repository() -> Option { if !std::fs::exists(LIVE_REPO_DIR).is_ok_and(|e| e) { return None; } - normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| packages::Repository { + normalize_repository_url(LIVE_REPO_DIR, "/install").map(|url| Repository { alias: "install".to_string(), name: "install".to_string(), url, diff --git a/rust/agama-software/src/system_info.rs b/rust/agama-software/src/system_info.rs deleted file mode 100644 index 09ec7c2d6a..0000000000 --- a/rust/agama-software/src/system_info.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::{ - model::{ - license::License, packages::Repository, pattern::Pattern, product::Product, - registration::AddonProperties, ModelAdapter, - }, - service, -}; -use serde::Serialize; - -/// Localization-related information of the system where the installer -/// is running. -#[derive(Clone, Debug, Default, Serialize)] -pub struct SystemInfo { - /// List of known patterns. - pub patterns: Vec, - /// List of known repositories. - pub repositories: Vec, - /// List of known products. - pub products: Vec, - /// List of known licenses - pub licenses: Vec, - /// List of available addons to register - pub addons: Vec, -} diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 62394a9f5a..d8ff0934de 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{Issue, IssueSeverity}; +use agama_utils::api::{software::Pattern, Issue, IssueSeverity}; use std::path::Path; use tokio::sync::{ mpsc::{self, UnboundedSender}, @@ -28,7 +28,6 @@ use zypp_agama::ZyppError; use crate::model::{ packages::ResolvableType, - pattern::Pattern, state::{self, SoftwareState}, }; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 1f1b3ef9e6..09d9e813cf 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -18,6 +18,7 @@ zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } agama-locale-data = { path = "../agama-locale-data" } +regex = "1.12.2" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api/software.rs b/rust/agama-utils/src/api/software.rs index 793e429dbc..8729f3b0fa 100644 --- a/rust/agama-utils/src/api/software.rs +++ b/rust/agama-utils/src/api/software.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -17,173 +17,12 @@ // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +// +mod config; +pub use config::*; -//! Representation of the software settings - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// User configuration for the localization of the target system. -/// -/// This configuration is provided by the user, so all the values are optional. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[schema(as = software::UserConfig)] -#[serde(rename_all = "camelCase")] -pub struct Config { - /// Product related configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub product: Option, - /// Software related configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub software: Option, -} - -/// Addon settings for registration -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonSettings { - pub id: String, - /// Optional version of the addon, if not specified the version is found - /// from the available addons - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Free extensions do not require a registration code - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, -} - -/// Software settings for installation -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProductConfig { - /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub registration_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub addons: Option>, -} - -impl ProductConfig { - pub fn is_empty(&self) -> bool { - self.id.is_none() - && self.registration_code.is_none() - && self.registration_email.is_none() - && self.registration_url.is_none() - && self.addons.is_none() - } -} - -/// Software settings for installation -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SoftwareConfig { - /// List of user selected patterns to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub patterns: Option, - /// List of user selected packages to install. - #[serde(skip_serializing_if = "Option::is_none")] - pub packages: Option>, - /// List of user specified repositories to use on top of default ones. - #[serde(skip_serializing_if = "Option::is_none")] - pub extra_repositories: Option>, - /// Flag indicating if only hard requirements should be used by solver. - #[serde(skip_serializing_if = "Option::is_none")] - pub only_required: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -#[serde(untagged)] -pub enum PatternsConfig { - PatternsList(Vec), - PatternsMap(PatternsMap), -} - -impl Default for PatternsConfig { - fn default() -> Self { - PatternsConfig::PatternsMap(PatternsMap { - add: None, - remove: None, - }) - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] -pub struct PatternsMap { - #[serde(skip_serializing_if = "Option::is_none")] - pub add: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remove: Option>, -} - -impl From> for PatternsConfig { - fn from(list: Vec) -> Self { - Self::PatternsList(list) - } -} - -impl From>> for PatternsConfig { - fn from(map: HashMap>) -> Self { - let add = if let Some(to_add) = map.get("add") { - Some(to_add.to_owned()) - } else { - None - }; - - let remove = if let Some(to_remove) = map.get("remove") { - Some(to_remove.to_owned()) - } else { - None - }; - - Self::PatternsMap(PatternsMap { add, remove }) - } -} - -impl SoftwareConfig { - pub fn to_option(self) -> Option { - if self.patterns.is_none() - && self.packages.is_none() - && self.extra_repositories.is_none() - && self.only_required.is_none() - { - None - } else { - Some(self) - } - } -} +mod system_info; +pub use system_info::*; -/// Parameters for creating new a repository -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RepositoryParams { - /// repository alias. Has to be unique - pub alias: String, - /// repository name, if not specified the alias is used - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Repository url (raw format without expanded variables) - pub url: String, - /// product directory (currently not used, valid only for multiproduct DVDs) - #[serde(skip_serializing_if = "Option::is_none")] - pub product_dir: Option, - /// Whether the repository is enabled, if missing the repository is enabled - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - /// Repository priority, lower number means higher priority, the default priority is 99 - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - /// Whenever repository can be unsigned. Default is false - #[serde(skip_serializing_if = "Option::is_none")] - pub allow_unsigned: Option, - /// List of fingerprints for GPG keys used for repository signing. By default empty - #[serde(skip_serializing_if = "Option::is_none")] - pub gpg_fingerprints: Option>, -} +mod license; +pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; diff --git a/rust/agama-utils/src/api/software/config.rs b/rust/agama-utils/src/api/software/config.rs new file mode 100644 index 0000000000..0fcb36cee7 --- /dev/null +++ b/rust/agama-utils/src/api/software/config.rs @@ -0,0 +1,188 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. +//! Representation of the software settings + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// User configuration for the localization of the target system. +/// +/// This configuration is provided by the user, so all the values are optional. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[schema(as = software::UserConfig)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Product related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub product: Option, + /// Software related configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub software: Option, +} + +/// Addon settings for registration +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddonSettings { + pub id: String, + /// Optional version of the addon, if not specified the version is found + /// from the available addons + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Free extensions do not require a registration code + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_code: Option, +} + +/// Software settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProductConfig { + /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub addons: Option>, +} + +impl ProductConfig { + pub fn is_empty(&self) -> bool { + self.id.is_none() + && self.registration_code.is_none() + && self.registration_email.is_none() + && self.registration_url.is_none() + && self.addons.is_none() + } +} + +/// Software settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SoftwareConfig { + /// List of user selected patterns to install. + #[serde(skip_serializing_if = "Option::is_none")] + pub patterns: Option, + /// List of user selected packages to install. + #[serde(skip_serializing_if = "Option::is_none")] + pub packages: Option>, + /// List of user specified repositories to use on top of default ones. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_repositories: Option>, + /// Flag indicating if only hard requirements should be used by solver. + #[serde(skip_serializing_if = "Option::is_none")] + pub only_required: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(untagged)] +pub enum PatternsConfig { + PatternsList(Vec), + PatternsMap(PatternsMap), +} + +impl Default for PatternsConfig { + fn default() -> Self { + PatternsConfig::PatternsMap(PatternsMap { + add: None, + remove: None, + }) + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +pub struct PatternsMap { + #[serde(skip_serializing_if = "Option::is_none")] + pub add: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub remove: Option>, +} + +impl From> for PatternsConfig { + fn from(list: Vec) -> Self { + Self::PatternsList(list) + } +} + +impl From>> for PatternsConfig { + fn from(map: HashMap>) -> Self { + let add = if let Some(to_add) = map.get("add") { + Some(to_add.to_owned()) + } else { + None + }; + + let remove = if let Some(to_remove) = map.get("remove") { + Some(to_remove.to_owned()) + } else { + None + }; + + Self::PatternsMap(PatternsMap { add, remove }) + } +} + +impl SoftwareConfig { + pub fn to_option(self) -> Option { + if self.patterns.is_none() + && self.packages.is_none() + && self.extra_repositories.is_none() + && self.only_required.is_none() + { + None + } else { + Some(self) + } + } +} + +/// Parameters for creating new a repository +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryConfig { + /// repository alias. Has to be unique + pub alias: String, + /// repository name, if not specified the alias is used + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Repository url (raw format without expanded variables) + pub url: String, + /// product directory (currently not used, valid only for multiproduct DVDs) + #[serde(skip_serializing_if = "Option::is_none")] + pub product_dir: Option, + /// Whether the repository is enabled, if missing the repository is enabled + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Repository priority, lower number means higher priority, the default priority is 99 + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Whenever repository can be unsigned. Default is false + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_unsigned: Option, + /// List of fingerprints for GPG keys used for repository signing. By default empty + #[serde(skip_serializing_if = "Option::is_none")] + pub gpg_fingerprints: Option>, +} diff --git a/rust/agama-utils/src/api/software/license.rs b/rust/agama-utils/src/api/software/license.rs new file mode 100644 index 0000000000..ef188aa472 --- /dev/null +++ b/rust/agama-utils/src/api/software/license.rs @@ -0,0 +1,109 @@ +// Copyright (c) [2024-2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements support for reading software licenses. + +use std::fmt::Display; + +use regex::Regex; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; +use thiserror::Error; + +/// Represents a product license. +/// +/// It contains the license ID and the list of languages that with a translation. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct License { + /// License ID. + pub id: String, + /// Languages in which the license is translated. + #[serde_as(as = "Vec")] + pub languages: Vec, +} + +/// Represents a license content. +/// +/// It contains the license ID and the body. +/// +/// TODO: in the future it might contain a title, extracted from the text. +#[serde_as] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct LicenseContent { + /// License ID. + pub id: String, + /// License text. + pub body: String, + /// License language. + #[serde_as(as = "DisplayFromStr")] + pub language: LanguageTag, +} + +/// Simplified representation of the RFC 5646 language code. +/// +/// It only considers xx and xx-XX formats. +#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema)] +pub struct LanguageTag { + // ISO-639 + pub language: String, + // ISO-3166 + pub territory: Option, +} + +impl Default for LanguageTag { + fn default() -> Self { + LanguageTag { + language: "en".to_string(), + territory: None, + } + } +} + +impl Display for LanguageTag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(territory) = &self.territory { + write!(f, "{}-{}", &self.language, territory) + } else { + write!(f, "{}", &self.language) + } + } +} + +#[derive(Error, Debug)] +#[error("Not a valid language code: {0}")] +pub struct InvalidLanguageCode(String); + +impl TryFrom<&str> for LanguageTag { + type Error = InvalidLanguageCode; + + fn try_from(value: &str) -> Result { + let language_regexp: Regex = Regex::new(r"^([[:alpha:]]+)(?:[_-]([A-Z]+))?").unwrap(); + + let captures = language_regexp + .captures(value) + .ok_or_else(|| InvalidLanguageCode(value.to_string()))?; + + Ok(Self { + language: captures.get(1).unwrap().as_str().to_string(), + territory: captures.get(2).map(|e| e.as_str().to_string()), + }) + } +} diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs new file mode 100644 index 0000000000..35675b8621 --- /dev/null +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -0,0 +1,112 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::software::License; +use serde::Serialize; + +/// Localization-related information of the system where the installer +/// is running. +#[derive(Clone, Debug, Default, Serialize)] +pub struct SystemInfo { + /// List of known patterns. + pub patterns: Vec, + /// List of known repositories. + pub repositories: Vec, + /// List of known products. + pub products: Vec, + /// List of known licenses + pub licenses: Vec, + /// List of available addons to register + pub addons: Vec, +} + +/// Repository specification. +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Repository { + /// Repository alias. It has to be unique. + pub alias: String, + /// Repository name + pub name: String, + /// Repository URL (raw format without expanded variables) + 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, +} + +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct Pattern { + /// Pattern name (eg., "aaa_base", "gnome") + pub name: String, + /// Pattern category (e.g., "Production") + pub category: String, + /// Pattern icon path locally on system + pub icon: String, + /// Pattern description + pub description: String, + /// Pattern summary + pub summary: String, + /// Pattern order + pub order: String, +} + +/// Represents a software product +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Product { + /// Product ID (eg., "ALP", "Tumbleweed", etc.) + pub id: String, + /// Product name (e.g., "openSUSE Tumbleweed") + pub name: String, + /// Product description + pub description: String, + /// Product icon (e.g., "default.svg") + pub icon: String, + /// Registration requirement + pub registration: bool, + /// License ID + pub license: Option, +} + +/// Addon registration +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddonProperties { + /// Addon identifier + pub id: String, + /// Version of the addon + pub version: String, + /// User visible name + pub label: String, + /// Whether the addon is mirrored on the RMT server, on SCC it is always `true` + pub available: bool, + /// Whether a registration code is required for registering the addon + pub free: bool, + /// Whether the addon is recommended for the users + pub recommended: bool, + /// Short description of the addon (translated) + pub description: String, + /// Type of the addon, like "extension" or "module" + pub r#type: String, + /// Release status of the addon, e.g. "beta" + pub release: String, +} From c83592fe83c9cc5ffd03886259be55b4b475dda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Nov 2025 09:41:22 +0000 Subject: [PATCH 317/917] Use serde Value instead of RawValue - Value works with 'flatten' while deserializing. - Value allows to directly check whether the json is null. --- rust/agama-manager/Cargo.toml | 2 +- rust/agama-manager/src/message.rs | 8 ++--- rust/agama-manager/src/service.rs | 16 ++++----- rust/agama-server/src/server/web.rs | 8 ++--- rust/agama-storage/Cargo.toml | 2 +- rust/agama-storage/src/client.rs | 36 +++++++------------ rust/agama-storage/src/lib.rs | 3 -- rust/agama-storage/src/message.rs | 24 +++++++------ rust/agama-storage/src/service.rs | 25 ++++--------- rust/agama-utils/Cargo.toml | 2 +- rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/config.rs | 10 ++---- rust/agama-utils/src/api/proposal.rs | 7 ++-- rust/agama-utils/src/api/storage.rs | 22 ++++++++++++ .../src/api/storage}/config.rs | 26 +++----------- rust/agama-utils/src/api/system_info.rs | 5 ++- 16 files changed, 82 insertions(+), 115 deletions(-) create mode 100644 rust/agama-utils/src/api/storage.rs rename rust/{agama-storage/src => agama-utils/src/api/storage}/config.rs (62%) diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index c7978bfe4f..9738008b51 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -13,7 +13,7 @@ tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } async-trait = "0.1.83" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" -serde_json = { version = "1.0.140", features = ["raw_value"] } +serde_json = "1.0.140" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index e008904845..83676f56a6 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -22,7 +22,7 @@ use agama_utils::{ actor::Message, api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, }; -use serde_json::value::RawValue; +use serde_json::Value; /// Gets the installation status. pub struct GetStatus; @@ -124,16 +124,16 @@ impl Message for RunAction { pub struct GetStorageModel; impl Message for GetStorageModel { - type Reply = Option>; + type Reply = Option; } // Sets the storage model. pub struct SetStorageModel { - pub model: Box, + pub model: Value, } impl SetStorageModel { - pub fn new(model: Box) -> Self { + pub fn new(model: Value) -> Self { Self { model } } } diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index dade367800..78eb8e864c 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -29,7 +29,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; -use serde_json::value::RawValue; +use serde_json::Value; use tokio::sync::broadcast; #[derive(Debug, thiserror::Error)] @@ -163,8 +163,7 @@ impl MessageHandler for Service { Ok(Config { l10n: Some(l10n), questions: Some(questions), - storage: storage.as_ref().and_then(|c| c.storage.clone()), - legacy_autoyast_storage: storage.and_then(|c| c.legacy_autoyast_storage), + storage, }) } } @@ -194,7 +193,7 @@ impl MessageHandler for Service { .await?; self.storage - .call(storage::message::SetConfig::new((&config).try_into().ok())) + .call(storage::message::SetConfig::new(config.storage.clone())) .await?; self.config = config; @@ -223,9 +222,9 @@ impl MessageHandler for Service { .await?; } - if let Some(storage) = (&config).try_into().ok() { + if let Some(storage) = &config.storage { self.storage - .call(storage::message::SetConfig::with(storage)) + .call(storage::message::SetConfig::with(storage.clone())) .await?; } @@ -277,10 +276,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// It returns the storage model. - async fn handle( - &mut self, - _message: message::GetStorageModel, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::GetStorageModel) -> Result, Error> { Ok(self.storage.call(storage::message::GetConfigModel).await?) } } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 8136cb4d0c..389b686cc7 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -40,7 +40,7 @@ use axum::{ }; use hyper::StatusCode; use serde::Serialize; -use serde_json::{json, value::RawValue, Value}; +use serde_json::{json, Value}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -352,9 +352,7 @@ async fn run_action( (status = 400, description = "Not possible to retrieve the storage model.") ) )] -async fn get_storage_model( - State(state): State, -) -> ServerResult>>> { +async fn get_storage_model(State(state): State) -> ServerResult>> { let model = state.manager.call(message::GetStorageModel).await?; Ok(Json(model)) } @@ -371,7 +369,7 @@ async fn get_storage_model( )] async fn set_storage_model( State(state): State, - Json(model): Json>, + Json(model): Json, ) -> ServerResult<()> { state .manager diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml index 9924a1f1bb..ed7794abf6 100644 --- a/rust/agama-storage/Cargo.toml +++ b/rust/agama-storage/Cargo.toml @@ -12,4 +12,4 @@ zbus = "5.7.1" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" serde = { version = "1.0.228" } -serde_json = { version = "1.0.140", features = ["raw_value"] } +serde_json = "1.0.140" diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs index 1d96f2a1fe..d1f291c4ff 100644 --- a/rust/agama-storage/src/client.rs +++ b/rust/agama-storage/src/client.rs @@ -20,9 +20,8 @@ //! Implements a client to access Agama's storage service. -use crate::config::Config; -use agama_utils::api::Issue; -use serde_json::{value::RawValue, Value}; +use agama_utils::api::{storage::Config, Issue}; +use serde_json::Value; use zbus::{names::BusName, zvariant::OwnedObjectPath, Connection, Message}; const SERVICE_NAME: &str = "org.opensuse.Agama.Storage1"; @@ -72,7 +71,7 @@ impl Client { Ok(()) } - pub async fn get_system(&self) -> Result>, Error> { + pub async fn get_system(&self) -> Result, Error> { let message = self.call("GetSystem", &()).await?; try_from_message(message) } @@ -82,12 +81,12 @@ impl Client { try_from_message(message) } - pub async fn get_config_model(&self) -> Result>, Error> { + pub async fn get_config_model(&self) -> Result, Error> { let message = self.call("GetConfigModel", &()).await?; try_from_message(message) } - pub async fn get_proposal(&self) -> Result>, Error> { + pub async fn get_proposal(&self) -> Result, Error> { let message = self.call("GetProposal", &()).await?; try_from_message(message) } @@ -104,21 +103,18 @@ impl Client { } pub async fn set_config(&self, config: Option) -> Result<(), Error> { - let config = config.filter(|c| c.is_some()); + let config = config.filter(|c| c.has_value()); let json = serde_json::to_string(&config)?; self.call("SetConfig", &(json)).await?; Ok(()) } - pub async fn set_config_model(&self, model: Box) -> Result<(), Error> { + pub async fn set_config_model(&self, model: Value) -> Result<(), Error> { self.call("SetConfigModel", &(model.to_string())).await?; Ok(()) } - pub async fn solve_config_model( - &self, - model: Box, - ) -> Result>, Error> { + pub async fn solve_config_model(&self, model: Value) -> Result, Error> { let message = self.call("SolveConfigModel", &(model.to_string())).await?; try_from_message(message) } @@ -145,19 +141,11 @@ impl Client { fn try_from_message( message: Message, ) -> Result { - let json: String = message.body().deserialize()?; - if is_json_null(&json) { + let raw_json: String = message.body().deserialize()?; + let json: Value = serde_json::from_str(&raw_json)?; + if json.is_null() { return Ok(T::default()); } - let value = serde_json::from_str(&json)?; + let value = serde_json::from_value(json)?; Ok(value) } - -fn is_json_null(value: &str) -> bool { - let value = serde_json::from_str::(value); - match value { - Ok(Value::Null) => true, - Ok(_) => false, - Err(_) => false, - } -} diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index 8c99137849..13c321cd2a 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -24,9 +24,6 @@ pub use start::start; pub mod service; pub use service::Service; -mod config; -pub use config::Config; - mod client; pub mod message; mod monitor; diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index 7da362ea44..6cdc47cb60 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -18,9 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::config::Config; -use agama_utils::{actor::Message, api::Issue}; -use serde_json::value::RawValue; +use agama_utils::{ + actor::Message, + api::{storage::Config, Issue}, +}; +use serde_json::Value; #[derive(Clone)] pub struct Activate; @@ -54,7 +56,7 @@ impl Message for Finish { pub struct GetSystem; impl Message for GetSystem { - type Reply = Option>; + type Reply = Option; } #[derive(Clone)] @@ -68,14 +70,14 @@ impl Message for GetConfig { pub struct GetConfigModel; impl Message for GetConfigModel { - type Reply = Option>; + type Reply = Option; } #[derive(Clone)] pub struct GetProposal; impl Message for GetProposal { - type Reply = Option>; + type Reply = Option; } #[derive(Clone)] @@ -123,11 +125,11 @@ impl Message for SetConfig { #[derive(Clone)] pub struct SetConfigModel { - pub model: Box, + pub model: Value, } impl SetConfigModel { - pub fn new(model: Box) -> Self { + pub fn new(model: Value) -> Self { Self { model } } } @@ -138,17 +140,17 @@ impl Message for SetConfigModel { #[derive(Clone)] pub struct SolveConfigModel { - pub model: Box, + pub model: Value, } impl SolveConfigModel { - pub fn new(model: Box) -> Self { + pub fn new(model: Value) -> Self { Self { model } } } impl Message for SolveConfigModel { - type Reply = Option>; + type Reply = Option; } #[derive(Clone)] diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index 94e839f958..e012d5dc4d 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -20,16 +20,15 @@ use crate::{ client::{self, Client}, - config::Config, message, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, - api::{Issue, Scope}, + api::{storage::Config, Issue, Scope}, issue, }; use async_trait::async_trait; -use serde_json::value::RawValue; +use serde_json::Value; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -102,10 +101,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { - async fn handle( - &mut self, - _message: message::GetSystem, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::GetSystem) -> Result, Error> { self.client.get_system().await.map_err(|e| e.into()) } } @@ -119,20 +115,14 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { - async fn handle( - &mut self, - _message: message::GetConfigModel, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::GetConfigModel) -> Result, Error> { self.client.get_config_model().await.map_err(|e| e.into()) } } #[async_trait] impl MessageHandler for Service { - async fn handle( - &mut self, - _message: message::GetProposal, - ) -> Result>, Error> { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { self.client.get_proposal().await.map_err(|e| e.into()) } } @@ -169,10 +159,7 @@ impl MessageHandler for Service { } #[async_trait] impl MessageHandler for Service { - async fn handle( - &mut self, - message: message::SolveConfigModel, - ) -> Result>, Error> { + async fn handle(&mut self, message: message::SolveConfigModel) -> Result, Error> { self.client .solve_config_model(message.model) .await diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 510ef8e037..15f4b79fb0 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -8,7 +8,7 @@ edition.workspace = true agama-locale-data = { path = "../agama-locale-data" } async-trait = "0.1.89" serde = { version = "1.0.228", features = ["derive"] } -serde_json = { version = "1.0.140", features = ["raw_value"] } +serde_json = "1.0.140" serde_with = "3.14.0" strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 548a407d26..89ccd79dcc 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -53,3 +53,4 @@ pub use action::Action; pub mod l10n; pub mod question; +pub mod storage; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 0c1f9e56fb..c648114f46 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,9 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question}; +use crate::api::{l10n, question, storage}; use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] @@ -31,9 +30,6 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] - pub storage: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] - pub legacy_autoyast_storage: Option>, + #[serde(flatten)] + pub storage: Option, } diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index ccdb85be0f..4b184c0913 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -20,16 +20,13 @@ use crate::api::l10n; use serde::Serialize; -use serde_json::value::RawValue; +use serde_json::Value; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Proposal { - #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, - #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] - pub storage: Option>, + pub storage: Option, } diff --git a/rust/agama-utils/src/api/storage.rs b/rust/agama-utils/src/api/storage.rs new file mode 100644 index 0000000000..ee18c8b42b --- /dev/null +++ b/rust/agama-utils/src/api/storage.rs @@ -0,0 +1,22 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod config; +pub use config::Config; diff --git a/rust/agama-storage/src/config.rs b/rust/agama-utils/src/api/storage/config.rs similarity index 62% rename from rust/agama-storage/src/config.rs rename to rust/agama-utils/src/api/storage/config.rs index ff8bfa83da..4fd8cb0196 100644 --- a/rust/agama-storage/src/config.rs +++ b/rust/agama-utils/src/api/storage/config.rs @@ -18,36 +18,20 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api; use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; +use serde_json::Value; -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] - pub storage: Option>, + pub storage: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub legacy_autoyast_storage: Option>, + pub legacy_autoyast_storage: Option, } impl Config { - pub fn is_some(&self) -> bool { + pub fn has_value(&self) -> bool { self.storage.is_some() || self.legacy_autoyast_storage.is_some() } } - -impl TryFrom<&api::Config> for Config { - type Error = (); - - fn try_from(config: &api::Config) -> Result { - if config.storage.is_none() && config.legacy_autoyast_storage.is_none() { - Err(()) - } else { - Ok(Config { - storage: config.storage.clone(), - legacy_autoyast_storage: config.legacy_autoyast_storage.clone(), - }) - } - } -} diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index b23fb0c091..0ae7e5b8b2 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -20,7 +20,7 @@ use crate::api::l10n; use serde::Serialize; -use serde_json::value::RawValue; +use serde_json::Value; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] @@ -28,6 +28,5 @@ pub struct SystemInfo { pub l10n: l10n::SystemInfo, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - #[schema(value_type = Object)] - pub storage: Option>, + pub storage: Option, } From 254dde03c1bf00fae126bf7e1cc259a0ca00ea32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Nov 2025 12:42:57 +0000 Subject: [PATCH 318/917] Make questions config optional --- rust/agama-manager/src/service.rs | 2 +- rust/agama-utils/src/question/message.rs | 2 +- rust/agama-utils/src/question/service.rs | 41 ++++++++++++------------ 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 78eb8e864c..29e03e6dfc 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -162,7 +162,7 @@ impl MessageHandler for Service { let storage = self.storage.call(storage::message::GetConfig).await?; Ok(Config { l10n: Some(l10n), - questions: Some(questions), + questions, storage, }) } diff --git a/rust/agama-utils/src/question/message.rs b/rust/agama-utils/src/question/message.rs index 46abddb210..af4dc6e141 100644 --- a/rust/agama-utils/src/question/message.rs +++ b/rust/agama-utils/src/question/message.rs @@ -27,7 +27,7 @@ use crate::{ pub struct GetConfig; impl Message for GetConfig { - type Reply = Config; + type Reply = Option; } /// Sets questions configuration (policy, pre-defined answers, etc.). diff --git a/rust/agama-utils/src/question/service.rs b/rust/agama-utils/src/question/service.rs index a1265c7916..2d18bd761f 100644 --- a/rust/agama-utils/src/question/service.rs +++ b/rust/agama-utils/src/question/service.rs @@ -44,7 +44,7 @@ pub enum Error { } pub struct Service { - config: Config, + config: Option, questions: Vec, current_id: u32, events: event::Sender, @@ -61,25 +61,28 @@ impl Service { } pub fn find_answer(&self, spec: &QuestionSpec) -> Option { - let answer = self - .config - .answers - .iter() - .find(|a| a.answers_to(&spec)) - .map(|r| r.answer.clone()); + let answer = self.config.as_ref().and_then(|config| { + config + .answers + .iter() + .find(|a| a.answers_to(&spec)) + .map(|r| r.answer.clone()) + }); if answer.is_some() { return answer; } - if let Some(Policy::Auto) = self.config.policy { - spec.default_action.clone().map(|action| Answer { - action, - value: None, - }) - } else { - None - } + self.config.as_ref().and_then(|config| { + if let Some(Policy::Auto) = config.policy { + spec.default_action.clone().map(|action| Answer { + action, + value: None, + }) + } else { + None + } + }) } } @@ -89,7 +92,7 @@ impl Actor for Service { #[async_trait] impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetConfig) -> Result { + async fn handle(&mut self, _message: message::GetConfig) -> Result, Error> { Ok(self.config.clone()) } } @@ -97,11 +100,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - if let Some(config) = message.config { - self.config = config; - } else { - self.config = Config::default(); - } + self.config = message.config; Ok(()) } } From be86d5f7f904cb54aa6299385ff9f925b9044363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Nov 2025 12:56:03 +0000 Subject: [PATCH 319/917] Remove unused mixins --- service/lib/agama/dbus/clients/storage.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index cd09aca414..f5fe91c34b 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -21,8 +21,6 @@ require "agama/dbus/clients/base" require "agama/dbus/clients/with_locale" -require "agama/dbus/clients/with_progress" -require "agama/dbus/clients/with_issues" require "json" module Agama @@ -31,8 +29,6 @@ module Clients # D-Bus client for storage configuration class Storage < Base include WithLocale - include WithProgress - include WithIssues STORAGE_IFACE = "org.opensuse.Agama.Storage1" private_constant :STORAGE_IFACE From 329467c6987aea66e0a0d3313c3800cc3814fd25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Nov 2025 14:38:47 +0000 Subject: [PATCH 320/917] Changelogs --- rust/package/agama.changes | 6 ++++++ service/package/rubygem-agama-yast.changes | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 3e3a352533..51b7d043ba 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Nov 3 14:36:55 UTC 2025 - José Iván López González + +- Add storage service to support the new HTTP API + (gh#agama-project/agama#2816). + ------------------------------------------------------------------- Fri Oct 17 13:14:31 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 728a7735ba..39ab434ca8 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Nov 3 14:34:26 UTC 2025 - José Iván López González + +- Adapt the storage D-Bus API to support the new HTTP API + (gh#agama-project/agama#2816). + ------------------------------------------------------------------- Fri Oct 17 13:15:00 UTC 2025 - Imobach Gonzalez Sosa From 034bece67bcd575d5fd0d32a1920a5b7f3b4e6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 4 Nov 2025 06:21:12 +0000 Subject: [PATCH 321/917] Fix tests --- service/lib/agama/manager.rb | 2 +- service/test/agama/dbus/clients/storage_test.rb | 5 ----- service/test/agama/manager_test.rb | 12 +----------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index d41c00a608..71f7b9fe1e 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -231,7 +231,7 @@ def on_services_status_change(&block) # # @return [Boolean] def valid? - users.issues.empty? && !software.errors? && !storage.errors? + users.issues.empty? && !software.errors? end # Collects the logs and stores them into an archive diff --git a/service/test/agama/dbus/clients/storage_test.rb b/service/test/agama/dbus/clients/storage_test.rb index 8c63d7366b..856f51f6e2 100644 --- a/service/test/agama/dbus/clients/storage_test.rb +++ b/service/test/agama/dbus/clients/storage_test.rb @@ -20,8 +20,6 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require_relative "with_issues_examples" -require_relative "with_progress_examples" require "agama/dbus/clients/storage" require "dbus" @@ -79,7 +77,4 @@ subject.finish end end - - include_examples "issues" - include_examples "progress" end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 95b8afb977..a4c26158a3 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -58,7 +58,7 @@ let(:storage) do instance_double( Agama::DBus::Clients::Storage, probe: nil, install: nil, finish: nil, - :product= => nil, errors?: false + :product= => nil ) end let(:scripts) do @@ -220,16 +220,6 @@ end end - context "when there are storage errors" do - before do - allow(storage).to receive(:errors?).and_return(true) - end - - it "returns false" do - expect(subject.valid?).to eq(false) - end - end - context "when the software configuration is not valid" do before do allow(software).to receive(:errors?).and_return(true) From 301fb67d587819ce7536c7472f368c073c1224f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 4 Nov 2025 06:56:37 +0000 Subject: [PATCH 322/917] Adjust software::model visibility --- rust/agama-software/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index 0b8c615bb6..ed316f9000 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -41,7 +41,7 @@ pub use start::start; pub mod service; pub use service::Service; -pub mod model; +mod model; pub use model::{Model, ModelAdapter}; mod event; From 7baafe86c56c809c3f450bdadd9b61220135bcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 4 Nov 2025 07:14:57 +0000 Subject: [PATCH 323/917] Rename software "Probe" to "Refresh" --- rust/agama-software/src/message.rs | 4 ++-- rust/agama-software/src/model.rs | 6 +++--- rust/agama-software/src/service.rs | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index e7fa68016a..8a7eaae423 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -77,9 +77,9 @@ impl Message for Install { type Reply = bool; } -pub struct Probe; +pub struct Refresh; -impl Message for Probe { +impl Message for Refresh { type Reply = (); } diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index d6e3aacefc..82a08c9b58 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -63,8 +63,8 @@ pub trait ModelAdapter: Send + Sync + 'static { optional: bool, ) -> Result<(), service::Error>; - /// Probes system and updates info about it. - async fn probe(&mut self) -> Result<(), service::Error>; + /// Refresh repositories information. + async fn refresh(&mut self) -> Result<(), service::Error>; /// install rpms to target system async fn install(&self) -> Result; @@ -136,7 +136,7 @@ impl ModelAdapter for Model { .unwrap_or_default() } - async fn probe(&mut self) -> Result<(), service::Error> { + async fn refresh(&mut self) -> Result<(), service::Error> { unimplemented!() } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 88c21705d9..49795f3392 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -199,9 +199,9 @@ impl MessageHandler for Service { } #[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, _message: message::Probe) -> Result<(), Error> { - self.model.lock().await.probe().await?; +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Refresh) -> Result<(), Error> { + self.model.lock().await.refresh().await?; self.update_system().await?; Ok(()) } From 282c0d3e2e6356a018695ccf94a12bed272c1f17 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 5 Nov 2025 10:41:11 +0100 Subject: [PATCH 324/917] initial implementation for disk usage --- .../zypp-agama-sys/c-layer/include/lib.h | 22 ++++++++++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 43 +++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index 6342eeea97..d36dc5c85e 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -67,6 +67,28 @@ void switch_target(struct Zypp *zypp, const char *root, /// @return true if there is no error bool commit(struct Zypp *zypp, struct Status *status) noexcept; +/// Represents a single mount point and its space usage. +/// The string pointers are not owned by this struct. +struct MountPoint { + const char *directory; ///< The path where the filesystem is mounted. + const char *filesystem; ///< The filesystem type (e.g., "btrfs", "xfs"). + bool grow_only; + long long + used_size; ///< The used space in kilobytes. This is an output field. +}; + +/// Calculates the space usage for a given list of mount points. +/// This function populates the `used_size` field for each element in the +/// provided `mount_points` array. +/// +/// @param zypp The Zypp context. +/// @param[out] status Output status object. +/// @param[in,out] mount_points An array of mount points to be evaluated. +/// @param mount_points_size The number of elements in the `mount_points` array. +void get_space_usage(struct Zypp *zypp, struct Status *status, + struct MountPoint *mount_points, + unsigned mount_points_size) noexcept; + enum RESOLVABLE_KIND { RESOLVABLE_PRODUCT, RESOLVABLE_PATCH, diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 9f9b458c6c..886217deff 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -199,9 +200,9 @@ struct Zypp *init_target(const char *root, struct Status *status, progress("Initializing the Target System", 0, 2, user_data); the_zypp.zypp_pointer = zypp_ptr(); if (the_zypp.zypp_pointer == NULL) { - STATUS_ERROR(status, "Failed to obtain zypp pointer. " - "See journalctl for details."); - return NULL; + STATUS_ERROR(status, "Failed to obtain zypp pointer. " + "See journalctl for details."); + return NULL; } zypp = &the_zypp; zypp->zypp_pointer->initializeTarget(root_str, false); @@ -628,4 +629,40 @@ void import_gpg_key(struct Zypp *zypp, const char *const pathname, STATUS_EXCEPT(status, excpt); } } + +void get_space_usage(struct Zypp *zypp, struct Status *status, + struct MountPoint *mount_points, + unsigned mount_points_size) noexcept { + try { + zypp::DiskUsageCounter::MountPointSet mount_points_set; + for (unsigned i = 0; i < mount_points_size; ++i) { + enum zypp::DiskUsageCounter::MountPoint::Hint hint = + mount_points[i].grow_only + ? zypp::DiskUsageCounter::MountPoint::Hint::Hint_growonly + : zypp::DiskUsageCounter::MountPoint::Hint::NoHint; + zypp::DiskUsageCounter::MountPoint mp(mount_points[i].directory, + mount_points[i].filesystem, 0, 0, 0, + 0, hint); + mount_points_set.insert(mp); + } + zypp->zypp_pointer->setPartitions(mount_points_set); + zypp::DiskUsageCounter::MountPointSet computed_set = + zypp->zypp_pointer->diskUsage(); + for (unsigned i = 0; i < mount_points_size; ++i) { + auto mp = + std::find_if(mount_points_set.begin(), mount_points_set.end(), + [mount_points, i](zypp::DiskUsageCounter::MountPoint m) { + m.dir == mount_points[i].directory; + }); + if (mp == mount_points_set.end()) { + // mount point not found. Should not happen. + STATUS_ERROR(status, "Internal Error:Mount point not found."); + return; + } + mount_points[i].used_size = mp->pkg_size; + } + STATUS_OK(status); + } catch (zypp::Exception &excpt) { + STATUS_EXCEPT(status, excpt); + } } From 9bb529dcba422640f2d95b437b28a65a64d62b1c Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 6 Nov 2025 09:57:41 +0100 Subject: [PATCH 325/917] initial proposal implementation --- rust/agama-software/src/model.rs | 17 ++++ rust/agama-software/src/model/products.rs | 9 ++ rust/agama-software/src/proposal.rs | 15 +++- rust/agama-software/src/service.rs | 15 +++- rust/agama-software/src/zypp_server.rs | 82 +++++++++++++++++-- rust/zypp-agama/src/lib.rs | 56 +++++++++++++ .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 4 +- .../zypp-agama/zypp-agama-sys/src/bindings.rs | 32 ++++++++ 8 files changed, 216 insertions(+), 14 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 82a08c9b58..42991b1e17 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -63,6 +63,10 @@ pub trait ModelAdapter: Send + Sync + 'static { optional: bool, ) -> Result<(), service::Error>; + async fn compute_proposal( + &self, + ) -> Result, service::Error>; + /// Refresh repositories information. async fn refresh(&mut self) -> Result<(), service::Error>; @@ -164,4 +168,17 @@ impl ModelAdapter for Model { self.zypp_sender.send(SoftwareAction::Install(tx))?; Ok(rx.await??) } + + async fn compute_proposal( + &self, + ) -> Result, service::Error> { + let Some(product_spec) = self.selected_product.clone() else { + return Ok(None); + }; + + let (tx, rx) = oneshot::channel(); + self.zypp_sender + .send(SoftwareAction::ComputeProposal(product_spec, tx))?; + Ok(Some(rx.await??)) + } } diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-software/src/model/products.rs index 36998b972e..5f0ba6c8c9 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-software/src/model/products.rs @@ -181,6 +181,15 @@ pub enum UserPattern { Preselected(PreselectedPattern), } +impl UserPattern { + pub fn name(&self) -> &str { + match self { + UserPattern::Plain(name) => name, + UserPattern::Preselected(pattern) => &pattern.name, + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct PreselectedPattern { pub name: String, diff --git a/rust/agama-software/src/proposal.rs b/rust/agama-software/src/proposal.rs index 30c185e0f8..9c430b0fb4 100644 --- a/rust/agama-software/src/proposal.rs +++ b/rust/agama-software/src/proposal.rs @@ -45,13 +45,22 @@ pub struct SoftwareProposal { } /// Describes what Agama proposes for the target system. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Default, Debug, Serialize)] pub struct Proposal { /// Software specific proposal #[serde(skip_serializing_if = "Option::is_none")] - software: Option, + pub software: Option, /// Registration proposal. Maybe same as config? /// TODO: implement it #[serde(skip_serializing_if = "Option::is_none")] - registration: Option<()>, + pub registration: Option<()>, +} + +impl Proposal { + pub fn into_option(self) -> Option { + if self.software.is_none() && self.registration.is_none() { + return None; + } + Some(self) + } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 49795f3392..44d9b8eda3 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -41,7 +41,7 @@ use agama_utils::{ issue, }; use async_trait::async_trait; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{broadcast, Mutex, RwLock}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -92,6 +92,7 @@ pub struct Service { struct State { config: Config, system: SystemInfo, + proposal: Arc>, } impl Service { @@ -179,12 +180,22 @@ impl MessageHandler> for Service { let model = self.model.clone(); let issues = self.issues.clone(); + let events = self.events.clone(); + let proposal = self.state.proposal.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; let found_issues = my_model.write(software).await.unwrap(); if !found_issues.is_empty() { _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); } + // update proposal with new config + // TODO: how to handle errors here? Own issue? + let software_proposal = my_model.compute_proposal().await.unwrap(); + proposal.write().await.software = software_proposal; + + _ = events.send(Event::ProposalChanged { + scope: Scope::Software, + }); }); Ok(()) @@ -194,7 +205,7 @@ impl MessageHandler> for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { - todo!(); + Ok(self.state.proposal.read().await.clone().into_option()) } } diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index d8ff0934de..9c1f02f677 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -26,9 +26,13 @@ use tokio::sync::{ }; use zypp_agama::ZyppError; -use crate::model::{ - packages::ResolvableType, - state::{self, SoftwareState}, +use crate::{ + model::{ + packages::ResolvableType, + products::ProductSpec, + state::{self, SoftwareState}, + }, + proposal::SelectedBy, }; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -83,6 +87,10 @@ pub enum SoftwareAction { Install(oneshot::Sender>), Finish(oneshot::Sender>), GetPatternsMetadata(Vec, oneshot::Sender>>), + ComputeProposal( + ProductSpec, + oneshot::Sender>, + ), SetResolvables { tx: oneshot::Sender>, resolvables: Vec, @@ -167,20 +175,16 @@ impl ZyppServer { SoftwareAction::Write { state, tx } => { self.write(state, tx, zypp).await?; } - SoftwareAction::GetPatternsMetadata(names, tx) => { self.get_patterns(names, tx, zypp).await?; } - SoftwareAction::Install(tx) => { tx.send(self.install(zypp)) .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; } - SoftwareAction::Finish(tx) => { self.finish(zypp, tx).await?; } - SoftwareAction::SetResolvables { tx, r#type, @@ -201,7 +205,6 @@ impl ZyppServer { } } } - SoftwareAction::UnsetResolvables { tx, r#type, @@ -222,6 +225,9 @@ impl ZyppServer { } } } + SoftwareAction::ComputeProposal(product_spec, sender) => { + self.compute_proposal(product_spec, sender, zypp).await? + } } Ok(()) } @@ -500,4 +506,64 @@ impl ZyppServer { } } } + + async fn compute_proposal( + &self, + product_spec: ProductSpec, + sender: oneshot::Sender>, + zypp: &zypp_agama::Zypp, + ) -> Result<(), ZyppDispatchError> { + // 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(), + filesystem: "btrfs".to_string(), + 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"); + + let selected_patterns: Result< + std::collections::HashMap, + ZyppServerError, + > = product_spec + .software + .user_patterns + .iter() + .map(|p| p.name()) + .map(|name| { + let selected = zypp.is_package_selected(name)?; + let tag = if selected { + SelectedBy::User + } else { + SelectedBy::None + }; + Ok((name.to_string(), tag)) + }) + .collect(); + let Ok(selected_patterns) = selected_patterns else { + sender + .send(Err(selected_patterns.unwrap_err())) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + return Ok(()); + }; + + let proposal = crate::proposal::SoftwareProposal { + size: size_str, + patterns: selected_patterns, + }; + + sender + .send(Ok(proposal)) + .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; + Ok(()) + } } diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 70bd6fbd33..dba7f8eebd 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -40,6 +40,14 @@ impl Repository { } } +#[derive(Debug)] +pub struct MountPoint { + pub directory: String, + pub filesystem: String, + pub grow_only: bool, + pub used_size: i64, +} + // TODO: should we add also e.g. serd serializers here? #[derive(Debug)] pub struct PatternInfo { @@ -154,6 +162,54 @@ impl Zypp { } } + pub fn count_disk_usage( + &self, + mut mount_points: Vec, + ) -> ZyppResult> { + let mut status: Status = Status::default(); + let status_ptr = &mut status as *mut _; + unsafe { + // we need to hold dirs and fss here to ensure that CString lives long enough + let dirs: Vec = mount_points + .iter() + .map(|mp| { + CString::new(mp.directory.as_str()) + .expect("CString must not contain internal NUL") + }) + .collect(); + let fss: Vec = mount_points + .iter() + .map(|mp| { + CString::new(mp.filesystem.as_str()) + .expect("CString must not contain internal NUL") + }) + .collect(); + let libzypp_mps: Vec<_> = mount_points + .iter() + .enumerate() + .map(|(i, mp)| zypp_agama_sys::MountPoint { + directory: dirs[i].as_ptr(), + filesystem: fss[i].as_ptr(), + grow_only: mp.grow_only, + used_size: 0, + }) + .collect(); + zypp_agama_sys::get_space_usage( + self.ptr, + status_ptr, + libzypp_mps.as_ptr() as *mut _, + libzypp_mps.len() as u32, + ); + helpers::status_to_result_void(status)?; + + libzypp_mps.iter().enumerate().for_each(|(i, mp)| { + mount_points[i].used_size = mp.used_size; + }); + + return Ok(mount_points); + } + } + pub fn list_repositories(&self) -> ZyppResult> { let mut repos_v = vec![]; diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 886217deff..4a36578c97 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -652,7 +652,7 @@ void get_space_usage(struct Zypp *zypp, struct Status *status, auto mp = std::find_if(mount_points_set.begin(), mount_points_set.end(), [mount_points, i](zypp::DiskUsageCounter::MountPoint m) { - m.dir == mount_points[i].directory; + return m.dir == mount_points[i].directory; }); if (mp == mount_points_set.end()) { // mount point not found. Should not happen. @@ -666,3 +666,5 @@ void get_space_usage(struct Zypp *zypp, struct Status *status, STATUS_EXCEPT(status, excpt); } } + +} \ No newline at end of file diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index 68294a9be9..b80b0faa5d 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -124,6 +124,31 @@ pub type ProgressCallback = ::std::option::Option< user_data: *mut ::std::os::raw::c_void, ), >; +#[doc = " Represents a single mount point and its space usage.\n The string pointers are not owned by this struct."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct MountPoint { + #[doc = "< The path where the filesystem is mounted."] + pub directory: *const ::std::os::raw::c_char, + #[doc = "< The filesystem type (e.g., \"btrfs\", \"xfs\")."] + pub filesystem: *const ::std::os::raw::c_char, + pub grow_only: bool, + #[doc = "< The used space in kilobytes. This is an output field."] + pub used_size: ::std::os::raw::c_longlong, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of MountPoint"][::std::mem::size_of::() - 32usize]; + ["Alignment of MountPoint"][::std::mem::align_of::() - 8usize]; + ["Offset of field: MountPoint::directory"] + [::std::mem::offset_of!(MountPoint, directory) - 0usize]; + ["Offset of field: MountPoint::filesystem"] + [::std::mem::offset_of!(MountPoint, filesystem) - 8usize]; + ["Offset of field: MountPoint::grow_only"] + [::std::mem::offset_of!(MountPoint, grow_only) - 16usize]; + ["Offset of field: MountPoint::used_size"] + [::std::mem::offset_of!(MountPoint, used_size) - 24usize]; +}; pub const RESOLVABLE_KIND_RESOLVABLE_PRODUCT: RESOLVABLE_KIND = 0; pub const RESOLVABLE_KIND_RESOLVABLE_PATCH: RESOLVABLE_KIND = 1; pub const RESOLVABLE_KIND_RESOLVABLE_PACKAGE: RESOLVABLE_KIND = 2; @@ -257,6 +282,13 @@ unsafe extern "C" { pub fn switch_target(zypp: *mut Zypp, root: *const ::std::os::raw::c_char, status: *mut Status); #[doc = " Commit zypp settings and install\n TODO: callbacks\n @param zypp\n @param status\n @return true if there is no error"] pub fn commit(zypp: *mut Zypp, status: *mut Status) -> bool; + #[doc = " Calculates the space usage for a given list of mount points.\n This function populates the `used_size` field for each element in the\n provided `mount_points` array.\n\n @param zypp The Zypp context.\n @param[out] status Output status object.\n @param[in,out] mount_points An array of mount points to be evaluated.\n @param mount_points_size The number of elements in the `mount_points` array."] + pub fn get_space_usage( + zypp: *mut Zypp, + status: *mut Status, + mount_points: *mut MountPoint, + mount_points_size: ::std::os::raw::c_uint, + ); #[doc = " Marks resolvable for installation\n @param zypp see \\ref init_target\n @param name resolvable name\n @param kind kind of resolvable\n @param who who do selection. If NOT_SELECTED is used, it will be empty\n operation.\n @param[out] status (will overwrite existing contents)"] pub fn resolvable_select( zypp: *mut Zypp, From ae50ab990b4b8b2b602d830613012f70707e8a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 11:28:05 +0000 Subject: [PATCH 326/917] Expose the software proposal through the API --- rust/agama-manager/src/service.rs | 3 ++- rust/agama-software/src/lib.rs | 3 --- rust/agama-software/src/message.rs | 3 +-- rust/agama-software/src/model.rs | 13 +++++------ rust/agama-software/src/service.rs | 3 +-- rust/agama-software/src/zypp_server.rs | 22 +++++++++---------- rust/agama-utils/src/api/proposal.rs | 4 +++- rust/agama-utils/src/api/software.rs | 3 +++ .../src/api/software}/proposal.rs | 2 +- 9 files changed, 28 insertions(+), 28 deletions(-) rename rust/{agama-software/src => agama-utils/src/api/software}/proposal.rs (97%) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index d0abf16a80..c90910363b 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -203,7 +203,8 @@ impl MessageHandler for Service { /// It returns the current proposal, if any. async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { let l10n = self.l10n.call(l10n::message::GetProposal).await?; - Ok(Some(Proposal { l10n })) + let software = self.software.call(software::message::GetProposal).await?; + Ok(Some(Proposal { l10n, software })) } } diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index ed316f9000..bdf7cf31ad 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -47,8 +47,5 @@ pub use model::{Model, ModelAdapter}; mod event; pub use event::Event; -mod proposal; -pub use proposal::Proposal; - pub mod message; mod zypp_server; diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 8a7eaae423..87d6bd8d7c 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -18,10 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::proposal::Proposal; use agama_utils::{ actor::Message, - api::software::{Config, SystemInfo}, + api::software::{Config, Proposal, SystemInfo}, }; #[derive(Clone)] diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 42991b1e17..5d93b59ae3 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,7 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{software::Pattern, Issue}; +use agama_utils::api::{ + software::{Pattern, SoftwareProposal}, + Issue, +}; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; @@ -63,9 +66,7 @@ pub trait ModelAdapter: Send + Sync + 'static { optional: bool, ) -> Result<(), service::Error>; - async fn compute_proposal( - &self, - ) -> Result, service::Error>; + async fn compute_proposal(&self) -> Result, service::Error>; /// Refresh repositories information. async fn refresh(&mut self) -> Result<(), service::Error>; @@ -169,9 +170,7 @@ impl ModelAdapter for Model { Ok(rx.await??) } - async fn compute_proposal( - &self, - ) -> Result, service::Error> { + async fn compute_proposal(&self) -> Result, service::Error> { let Some(product_spec) = self.selected_product.clone() else { return Ok(None); }; diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 44d9b8eda3..722bbc8c3a 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -28,14 +28,13 @@ use crate::{ state::SoftwareState, ModelAdapter, }, - proposal::Proposal, zypp_server::{self, SoftwareAction}, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ event::{self, Event}, - software::{Config, Repository, SystemInfo}, + software::{Config, Proposal, Repository, SystemInfo}, Scope, }, issue, diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 9c1f02f677..60b4b30d11 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,7 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{software::Pattern, Issue, IssueSeverity}; +use agama_utils::api::{ + software::{Pattern, SelectedBy, SoftwareProposal}, + Issue, IssueSeverity, +}; use std::path::Path; use tokio::sync::{ mpsc::{self, UnboundedSender}, @@ -26,13 +29,10 @@ use tokio::sync::{ }; use zypp_agama::ZyppError; -use crate::{ - model::{ - packages::ResolvableType, - products::ProductSpec, - state::{self, SoftwareState}, - }, - proposal::SelectedBy, +use crate::model::{ + packages::ResolvableType, + products::ProductSpec, + state::{self, SoftwareState}, }; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -89,7 +89,7 @@ pub enum SoftwareAction { GetPatternsMetadata(Vec, oneshot::Sender>>), ComputeProposal( ProductSpec, - oneshot::Sender>, + oneshot::Sender>, ), SetResolvables { tx: oneshot::Sender>, @@ -510,7 +510,7 @@ impl ZyppServer { async fn compute_proposal( &self, product_spec: ProductSpec, - sender: oneshot::Sender>, + sender: oneshot::Sender>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { // TODO: for now it just compute total size, but it can get info about partitions from storage and pass it to libzypp @@ -556,7 +556,7 @@ impl ZyppServer { return Ok(()); }; - let proposal = crate::proposal::SoftwareProposal { + let proposal = SoftwareProposal { size: size_str, patterns: selected_patterns, }; diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index d66e151167..20e42aecf7 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,11 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::l10n; +use crate::api::{l10n, software}; use serde::Serialize; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub software: Option, } diff --git a/rust/agama-utils/src/api/software.rs b/rust/agama-utils/src/api/software.rs index 8729f3b0fa..56fa02108f 100644 --- a/rust/agama-utils/src/api/software.rs +++ b/rust/agama-utils/src/api/software.rs @@ -26,3 +26,6 @@ pub use system_info::*; mod license; pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; + +mod proposal; +pub use proposal::{Proposal, SelectedBy, SoftwareProposal}; diff --git a/rust/agama-software/src/proposal.rs b/rust/agama-utils/src/api/software/proposal.rs similarity index 97% rename from rust/agama-software/src/proposal.rs rename to rust/agama-utils/src/api/software/proposal.rs index 9c430b0fb4..6e90d639d5 100644 --- a/rust/agama-software/src/proposal.rs +++ b/rust/agama-utils/src/api/software/proposal.rs @@ -45,7 +45,7 @@ pub struct SoftwareProposal { } /// Describes what Agama proposes for the target system. -#[derive(Clone, Default, Debug, Serialize)] +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] pub struct Proposal { /// Software specific proposal #[serde(skip_serializing_if = "Option::is_none")] From 2a9575022c31f585b856069ff98ae3623cb3e813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 4 Nov 2025 16:27:45 +0000 Subject: [PATCH 327/917] Emit progress during software SetConfig --- rust/agama-manager/src/start.rs | 2 +- rust/agama-software/src/model.rs | 23 ++++++++++++++---- rust/agama-software/src/service.rs | 8 +++++-- rust/agama-software/src/start.rs | 5 ++-- rust/agama-software/src/zypp_server.rs | 33 ++++++++++++++++++++------ 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 718e46c6b6..2c71a383d6 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -58,7 +58,7 @@ pub async fn start( let issues = issue::start(events.clone(), dbus).await?; let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; - let software = software::start(issues.clone(), events.clone()).await?; + let software = software::start(issues.clone(), progress.clone(), events.clone()).await?; let service = Service::new(l10n, software, issues, progress, questions, events.clone()); let handler = actor::spawn(service); diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 5d93b59ae3..348f853130 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,9 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{ - software::{Pattern, SoftwareProposal}, - Issue, +use agama_utils::{ + actor::Handler, + api::{ + software::{Pattern, SoftwareProposal}, + Issue, + }, + progress, }; use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; @@ -81,7 +85,11 @@ pub trait ModelAdapter: Send + Sync + 'static { /// /// It does not perform the installation, just update the repositories and /// the software selection. - async fn write(&mut self, software: SoftwareState) -> Result, service::Error>; + async fn write( + &mut self, + software: SoftwareState, + progress: Handler, + ) -> Result, service::Error>; } /// [ModelAdapter] implementation for libzypp systems. @@ -105,10 +113,15 @@ impl Model { #[async_trait] impl ModelAdapter for Model { - async fn write(&mut self, software: SoftwareState) -> Result, service::Error> { + async fn write( + &mut self, + software: SoftwareState, + progress: Handler, + ) -> Result, service::Error> { let (tx, rx) = oneshot::channel(); self.zypp_sender.send(SoftwareAction::Write { state: software, + progress, tx, })?; Ok(rx.await??) diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 722bbc8c3a..1325bbd30c 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -37,7 +37,7 @@ use agama_utils::{ software::{Config, Proposal, Repository, SystemInfo}, Scope, }, - issue, + issue, progress, }; use async_trait::async_trait; use tokio::sync::{broadcast, Mutex, RwLock}; @@ -83,6 +83,7 @@ pub struct Service { products: ProductsRegistry, licenses: LicensesRepo, issues: Handler, + progress: Handler, events: event::Sender, state: State, } @@ -98,11 +99,13 @@ impl Service { pub fn new( model: T, issues: Handler, + progress: Handler, events: event::Sender, ) -> Service { Self { model: Arc::new(Mutex::new(model)), issues, + progress, events, licenses: LicensesRepo::default(), products: ProductsRegistry::default(), @@ -180,10 +183,11 @@ impl MessageHandler> for Service { let model = self.model.clone(); let issues = self.issues.clone(); let events = self.events.clone(); + let progress = self.progress.clone(); let proposal = self.state.proposal.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; - let found_issues = my_model.write(software).await.unwrap(); + let found_issues = my_model.write(software, progress).await.unwrap(); if !found_issues.is_empty() { _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); } diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index ff0424a35e..0ce33fd5cc 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -26,7 +26,7 @@ use crate::{ use agama_utils::{ actor::{self, Handler}, api::event, - issue, + issue, progress, }; #[derive(thiserror::Error, Debug)] @@ -49,11 +49,12 @@ pub enum Error { /// * `issues`: handler to the issues service. pub async fn start( issues: Handler, + progress: Handler, events: event::Sender, ) -> Result, Error> { let zypp_sender = ZyppServer::start()?; let model = Model::new(zypp_sender)?; - let mut service = Service::new(model, issues, events); + let mut service = Service::new(model, issues, progress, events); service.read().await?; let handler = actor::spawn(service); Ok(handler) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 60b4b30d11..4e71de06c7 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,9 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_utils::api::{ - software::{Pattern, SelectedBy, SoftwareProposal}, - Issue, IssueSeverity, +use agama_utils::{ + actor::Handler, + api::{ + software::{Pattern, SelectedBy, SoftwareProposal}, + Issue, IssueSeverity, Scope, + }, + progress, }; use std::path::Path; use tokio::sync::{ @@ -82,7 +86,6 @@ pub enum ZyppServerError { pub type ZyppServerResult = Result; -#[derive(Debug)] pub enum SoftwareAction { Install(oneshot::Sender>), Finish(oneshot::Sender>), @@ -105,6 +108,7 @@ pub enum SoftwareAction { }, Write { state: SoftwareState, + progress: Handler, tx: oneshot::Sender>>, }, } @@ -151,7 +155,6 @@ impl ZyppServer { loop { let action = self.receiver.recv().await; - tracing::debug!("software dispatching action: {:?}", action); let Some(action) = action else { tracing::error!("Software action channel closed"); break; @@ -172,8 +175,12 @@ impl ZyppServer { zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { match action { - SoftwareAction::Write { state, tx } => { - self.write(state, tx, zypp).await?; + SoftwareAction::Write { + state, + progress, + tx, + } => { + self.write(state, progress, tx, zypp).await?; } SoftwareAction::GetPatternsMetadata(names, tx) => { self.get_patterns(names, tx, zypp).await?; @@ -268,6 +275,7 @@ impl ZyppServer { async fn write( &self, state: SoftwareState, + progress: Handler, tx: oneshot::Sender>>, zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { @@ -279,6 +287,14 @@ impl ZyppServer { // 4. return the proposal and the issues. // self.add_repositories(state.repositories, tx, &zypp).await?; + _ = progress.cast(progress::message::StartWithSteps::new( + Scope::Software, + &[ + "Updating the list of repositories", + "Refreshing metadata from the repositories", + "Calculating the software proposal", + ], + )); let old_state = self.read(zypp).unwrap(); let old_aliases: Vec<_> = old_state .repositories @@ -330,6 +346,7 @@ impl ZyppServer { } } + _ = progress.cast(progress::message::Next::new(Scope::Software)); if to_add.is_empty() || to_remove.is_empty() { let result = zypp.load_source(|percent, alias| { tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); @@ -345,6 +362,7 @@ impl ZyppServer { } } + _ = progress.cast(progress::message::Next::new(Scope::Software)); for pattern in &state.patterns { // FIXME: we need to distinguish who is selecting the pattern. // and register an issue if it is not found and it was not optional. @@ -363,6 +381,7 @@ impl ZyppServer { } } + _ = progress.cast(progress::message::Finish::new(Scope::Software)); match zypp.run_solver() { Ok(result) => println!("Solver result: {result}"), Err(error) => println!("Solver failed: {error}"), From 970e2d967a0845b0ff5f5724960b5472dd1b2d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 5 Nov 2025 14:09:58 +0000 Subject: [PATCH 328/917] Drop unused products module from agama-server --- rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/products.rs | 207 ------------------------------ rust/agama-server/src/web.rs | 6 +- 3 files changed, 1 insertion(+), 213 deletions(-) delete mode 100644 rust/agama-server/src/products.rs diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index e8a657dcf5..2b486dd2f4 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -27,7 +27,6 @@ pub mod hostname; pub mod logs; pub mod manager; pub mod network; -pub mod products; pub mod profile; pub mod scripts; pub mod security; diff --git a/rust/agama-server/src/products.rs b/rust/agama-server/src/products.rs deleted file mode 100644 index 27a1a962ac..0000000000 --- a/rust/agama-server/src/products.rs +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements a products registry. -//! -//! The products registry contains the specification of every known product. -//! It reads the list of products from the `products.d` directory (usually, -//! `/usr/share/agama/products.d`). - -use serde::{Deserialize, Deserializer}; -use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; -use std::path::{Path, PathBuf}; - -#[derive(thiserror::Error, Debug)] -pub enum ProductsRegistryError { - #[error("Could not read the products registry: {0}")] - IO(#[from] std::io::Error), - #[error("Could not deserialize a product specification: {0}")] - Deserialize(#[from] serde_yaml::Error), -} - -/// Products registry. -/// -/// It holds the products specifications. At runtime it is possible to change the `products.d` -/// location by setting the `AGAMA_SHARE_DIR` environment variable. This variable points to -/// the parent of `products.d`. -/// -/// Dynamic behavior, like filtering by architecture, is not supported yet. -#[derive(Clone, Default, Debug, Deserialize)] -pub struct ProductsRegistry { - pub products: Vec, -} - -impl ProductsRegistry { - /// Creates a registry loading the products from the default location. - pub fn load() -> Result { - let share_dir = std::env::var("AGAMA_SHARE_DIR").unwrap_or("/usr/share/agama".to_string()); - let products_dir = PathBuf::from(share_dir).join("products.d"); - - if !products_dir.exists() { - return Err(ProductsRegistryError::IO(std::io::Error::new( - std::io::ErrorKind::NotFound, - "products.d directory does not exist", - ))); - } - - Self::load_from(products_dir) - } - - /// Creates a registry loading the products from the given location. - pub fn load_from>(products_path: P) -> Result { - let entries = std::fs::read_dir(products_path)?; - let mut products = vec![]; - - for entry in entries { - let entry = entry?; - let path = entry.path(); - - let Some(ext) = path.extension() else { - continue; - }; - - if path.is_file() && (ext == "yaml" || ext == "yml") { - let product = ProductSpec::load_from(path)?; - products.push(product); - } - } - - Ok(Self { products }) - } - - /// Determines whether the are are multiple products. - pub fn is_multiproduct(&self) -> bool { - self.products.len() > 1 - } - - /// Finds a product by its ID. - /// - /// * `id`: product ID. - pub fn find(&self, id: &str) -> Option<&ProductSpec> { - self.products.iter().find(|p| p.id == id) - } -} - -// TODO: ideally, part of this code could be auto-generated from a JSON schema definition. -/// Product specification (e.g., Tumbleweed). -#[derive(Clone, Debug, Deserialize)] -pub struct ProductSpec { - pub id: String, - pub name: String, - pub description: String, - pub icon: String, - #[serde(default)] - pub registration: bool, - pub version: Option, - pub software: SoftwareSpec, -} - -impl ProductSpec { - pub fn load_from>(path: P) -> Result { - let contents = std::fs::read_to_string(path)?; - let product: ProductSpec = serde_yaml::from_str(&contents)?; - Ok(product) - } -} - -fn parse_optional<'de, D>(d: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Deserialize::deserialize(d).map(|x: Option<_>| x.unwrap_or_default()) -} - -#[derive(Clone, Debug, Deserialize)] -pub struct SoftwareSpec { - installation_repositories: Vec, - #[serde(default)] - pub installation_labels: Vec, - #[serde(default)] - pub mandatory_patterns: Vec, - #[serde(default)] - pub mandatory_packages: Vec, - #[serde(deserialize_with = "parse_optional")] - pub optional_patterns: Vec, - #[serde(deserialize_with = "parse_optional")] - pub optional_packages: Vec, - pub base_product: String, -} - -impl SoftwareSpec { - // NOTE: perhaps implementing our own iterator would be more efficient. - pub fn repositories(&self) -> Vec<&RepositorySpec> { - let arch = std::env::consts::ARCH.to_string(); - self.installation_repositories - .iter() - .filter(|r| r.archs.contains(&arch)) - .collect() - } -} - -#[serde_as] -#[derive(Clone, Debug, Deserialize)] -pub struct RepositorySpec { - pub url: String, - #[serde(default)] - #[serde_as(as = "StringWithSeparator::")] - pub archs: Vec, -} - -#[serde_as] -#[derive(Clone, Debug, Deserialize)] -pub struct LabelSpec { - pub label: String, - #[serde(default)] - #[serde_as(as = "StringWithSeparator::")] - pub archs: Vec, -} - -#[cfg(test)] -mod test { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_load_registry() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); - let config = ProductsRegistry::load_from(path.as_path()).unwrap(); - // ensuring that we can load all products from tests - assert_eq!(config.products.len(), 8); - } - - #[test] - fn test_find_product() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); - let products = ProductsRegistry::load_from(path.as_path()).unwrap(); - let tw = products.find("Tumbleweed").unwrap(); - assert_eq!(tw.id, "Tumbleweed"); - assert_eq!(tw.name, "openSUSE Tumbleweed"); - assert_eq!(tw.icon, "Tumbleweed.svg"); - assert_eq!(tw.registration, false); - assert_eq!(tw.version, None); - let software = &tw.software; - assert_eq!(software.installation_repositories.len(), 12); - assert_eq!(software.installation_labels.len(), 4); - assert_eq!(software.base_product, "openSUSE"); - - let missing = products.find("Missing"); - assert!(missing.is_none()); - } -} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 339b214bc6..9cd153dc1a 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -31,7 +31,6 @@ use crate::{ hostname::web::hostname_service, manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, - products::ProductsRegistry, profile::web::profile_service, scripts::web::scripts_service, security::security_service, @@ -58,8 +57,7 @@ use agama_lib::http::event::{OldEvent, OldSender}; use common::ProgressService; pub use config::ServiceConfig; pub use service::MainServiceBuilder; -use std::{path::Path, sync::Arc}; -use tokio::sync::Mutex; +use std::{path::Path}; use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. @@ -82,8 +80,6 @@ where .await .expect("Could not connect to NetworkManager to read the configuration"); - let products = ProductsRegistry::load().expect("Could not load the products registry."); - let products = Arc::new(Mutex::new(products)); let progress = ProgressService::start(dbus.clone(), old_events.clone()).await; let router = MainServiceBuilder::new(events.clone(), old_events.clone(), web_ui_dir) From f14ab1ff13dab8aca7d8c5cbfd54fa77bca8997a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 07:35:53 +0000 Subject: [PATCH 329/917] Move products and licenses handling to the manager service --- rust/Cargo.lock | 2 + rust/agama-manager/src/start.rs | 11 +++- rust/agama-server/src/web/docs/config.rs | 6 +-- rust/agama-software/Cargo.toml | 1 - rust/agama-software/src/model.rs | 7 +-- rust/agama-software/src/model/state.rs | 15 +++--- rust/agama-software/src/service.rs | 26 +--------- rust/agama-software/src/zypp_server.rs | 2 +- rust/agama-utils/Cargo.toml | 2 + rust/agama-utils/src/api/manager.rs | 25 ++++++++++ .../src/api/{software => manager}/license.rs | 0 .../src/api/manager/system_info.rs | 50 +++++++++++++++++++ rust/agama-utils/src/api/software.rs | 3 -- .../src/api/software/system_info.rs | 23 --------- rust/agama-utils/src/api/system_info.rs | 4 +- rust/agama-utils/src/lib.rs | 2 + .../src/model => agama-utils/src}/license.rs | 8 ++- .../src/model => agama-utils/src}/products.rs | 2 +- 18 files changed, 113 insertions(+), 76 deletions(-) create mode 100644 rust/agama-utils/src/api/manager.rs rename rust/agama-utils/src/api/{software => manager}/license.rs (100%) create mode 100644 rust/agama-utils/src/api/manager/system_info.rs rename rust/{agama-software/src/model => agama-utils/src}/license.rs (97%) rename rust/{agama-software/src/model => agama-utils/src}/products.rs (99%) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0bf1d1b8e4..f8dd9168b3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -255,11 +255,13 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "strum", "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-test", + "tracing", "utoipa", "zbus", "zvariant", diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 2c71a383d6..4629b0e8b9 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,7 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, service::Service, software}; +use crate::{ + l10n, + service::{self, Service}, + software, +}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -35,6 +39,8 @@ pub enum Error { Software(#[from] software::start::Error), #[error(transparent)] Issues(#[from] issue::start::Error), + #[error(transparent)] + Service(#[from] service::Error), } /// Starts the manager service. @@ -60,7 +66,8 @@ pub async fn start( let l10n = l10n::start(issues.clone(), events.clone()).await?; let software = software::start(issues.clone(), progress.clone(), events.clone()).await?; - let service = Service::new(l10n, software, issues, progress, questions, events.clone()); + let mut service = Service::new(l10n, software, issues, progress, questions, events.clone()); + service.read().await?; let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index 4b298968d1..a36c1c1211 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -130,9 +130,9 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index d069c05d6e..b10801571e 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -12,7 +12,6 @@ glob = "0.3.1" regex = "1.11.0" serde = { version = "1.0.210", features = ["derive"] } serde_with = "3.10.0" -serde_yaml = "0.9.34" strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 348f853130..8197ba3ced 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -31,19 +31,14 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ model::{ - packages::ResolvableType, - products::{ProductSpec, UserPattern}, - software_selection::SoftwareSelection, - state::SoftwareState, + packages::ResolvableType, software_selection::SoftwareSelection, state::SoftwareState, }, service, zypp_server::SoftwareAction, }; pub mod conflict; -pub mod license; pub mod packages; -pub mod products; pub mod registration; pub mod software_selection; pub mod state; diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 2757515f28..13847ebdb9 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -22,9 +22,10 @@ //! configuration and a mechanism to build it starting from the product //! definition, the user configuration, etc. -use agama_utils::api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo}; - -use crate::model::products::{ProductSpec, UserPattern}; +use agama_utils::{ + products::{ProductSpec, UserPattern}, + api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo} +}; /// Represents the wanted software configuration. /// @@ -256,13 +257,15 @@ pub struct SoftwareOptions { mod tests { use std::path::PathBuf; - use agama_utils::api::software::{ + use agama_utils::{ + products::ProductSpec, + api::software::{ Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, - SystemInfo, + SystemInfo + } }; use crate::model::{ - products::ProductSpec, state::{ResolvableName, SoftwareStateBuilder}, }; diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 1325bbd30c..e9198f486b 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -22,12 +22,7 @@ use std::{process::Command, sync::Arc}; use crate::{ message, - model::{ - license::{Error as LicenseError, LicensesRepo}, - products::{ProductsRegistry, ProductsRegistryError}, - state::SoftwareState, - ModelAdapter, - }, + model::{state::SoftwareState, ModelAdapter}, zypp_server::{self, SoftwareAction}, }; use agama_utils::{ @@ -61,10 +56,6 @@ pub enum Error { #[error("There is no {0} product")] WrongProduct(String), #[error(transparent)] - ProductsRegistry(#[from] ProductsRegistryError), - #[error(transparent)] - License(#[from] LicenseError), - #[error(transparent)] ZyppServerError(#[from] zypp_server::ZyppServerError), #[error(transparent)] ZyppError(#[from] zypp_agama::errors::ZyppError), @@ -80,8 +71,6 @@ pub enum Error { /// * Applies the user configuration at the end of the installation. pub struct Service { model: Arc>, - products: ProductsRegistry, - licenses: LicensesRepo, issues: Handler, progress: Handler, events: event::Sender, @@ -107,17 +96,11 @@ impl Service { issues, progress, events, - licenses: LicensesRepo::default(), - products: ProductsRegistry::default(), state: Default::default(), } } pub async fn read(&mut self) -> Result<(), Error> { - self.licenses.read()?; - self.products.read()?; - self.state.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); - self.state.system.products = self.products.products(); if let Some(install_repo) = find_install_repository() { tracing::info!("Found repository at {}", install_repo.url); self.state.system.repositories.push(install_repo); @@ -126,12 +109,7 @@ impl Service { } async fn update_system(&mut self) -> Result<(), Error> { - let licenses = self.licenses.licenses().into_iter().cloned().collect(); - let products = self.products.products(); - - self.state.system.licenses = licenses; - self.state.system.products = products; - + // TODO: add system information (repositories, patterns, etc.). self.events.send(Event::SystemChanged { scope: Scope::Software, })?; diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 4e71de06c7..3cc6ec563c 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -24,6 +24,7 @@ use agama_utils::{ software::{Pattern, SelectedBy, SoftwareProposal}, Issue, IssueSeverity, Scope, }, + products::ProductSpec, progress, }; use std::path::Path; @@ -35,7 +36,6 @@ use zypp_agama::ZyppError; use crate::model::{ packages::ResolvableType, - products::ProductSpec, state::{self, SoftwareState}, }; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 09d9e813cf..edb53ff26c 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -19,6 +19,8 @@ zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } agama-locale-data = { path = "../agama-locale-data" } regex = "1.12.2" +tracing = "0.1.41" +serde_yaml = "0.9.34" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api/manager.rs b/rust/agama-utils/src/api/manager.rs new file mode 100644 index 0000000000..d7749cd157 --- /dev/null +++ b/rust/agama-utils/src/api/manager.rs @@ -0,0 +1,25 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod license; +pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; + +mod system_info; +pub use system_info::{SystemInfo, Product}; diff --git a/rust/agama-utils/src/api/software/license.rs b/rust/agama-utils/src/api/manager/license.rs similarity index 100% rename from rust/agama-utils/src/api/software/license.rs rename to rust/agama-utils/src/api/manager/license.rs diff --git a/rust/agama-utils/src/api/manager/system_info.rs b/rust/agama-utils/src/api/manager/system_info.rs new file mode 100644 index 0000000000..6d903ce41a --- /dev/null +++ b/rust/agama-utils/src/api/manager/system_info.rs @@ -0,0 +1,50 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::api::manager::License; +use serde::Serialize; + +/// Global information of the system where the installer is running. +#[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] +pub struct SystemInfo { + /// List of known products. + pub products: Vec, + /// List of known licenses + pub licenses: Vec, +} + +/// Represents a software product +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Product { + /// Product ID (eg., "ALP", "Tumbleweed", etc.) + pub id: String, + /// Product name (e.g., "openSUSE Tumbleweed") + pub name: String, + /// Product description + pub description: String, + /// Product icon (e.g., "default.svg") + pub icon: String, + /// Registration requirement + pub registration: bool, + /// License ID + pub license: Option, +} + diff --git a/rust/agama-utils/src/api/software.rs b/rust/agama-utils/src/api/software.rs index 56fa02108f..58c7e44282 100644 --- a/rust/agama-utils/src/api/software.rs +++ b/rust/agama-utils/src/api/software.rs @@ -24,8 +24,5 @@ pub use config::*; mod system_info; pub use system_info::*; -mod license; -pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; - mod proposal; pub use proposal::{Proposal, SelectedBy, SoftwareProposal}; diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs index 35675b8621..75c04dda2b 100644 --- a/rust/agama-utils/src/api/software/system_info.rs +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -18,7 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::software::License; use serde::Serialize; /// Localization-related information of the system where the installer @@ -29,10 +28,6 @@ pub struct SystemInfo { pub patterns: Vec, /// List of known repositories. pub repositories: Vec, - /// List of known products. - pub products: Vec, - /// List of known licenses - pub licenses: Vec, /// List of available addons to register pub addons: Vec, } @@ -69,24 +64,6 @@ pub struct Pattern { pub order: String, } -/// Represents a software product -#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Product { - /// Product ID (eg., "ALP", "Tumbleweed", etc.) - pub id: String, - /// Product name (e.g., "openSUSE Tumbleweed") - pub name: String, - /// Product description - pub description: String, - /// Product icon (e.g., "default.svg") - pub icon: String, - /// Registration requirement - pub registration: bool, - /// License ID - pub license: Option, -} - /// Addon registration #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index e609126aa2..a8ffc25dc4 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -18,10 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::l10n; +use crate::api::{l10n, manager}; use serde::Serialize; #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct SystemInfo { + #[serde(flatten)] + pub manager: manager::SystemInfo, pub l10n: l10n::SystemInfo, } diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index 1319404b0c..980caf95aa 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -28,6 +28,8 @@ pub mod actor; pub mod api; pub mod dbus; pub mod issue; +pub mod license; pub mod openapi; +pub mod products; pub mod progress; pub mod question; diff --git a/rust/agama-software/src/model/license.rs b/rust/agama-utils/src/license.rs similarity index 97% rename from rust/agama-software/src/model/license.rs rename to rust/agama-utils/src/license.rs index abcc0c04ea..6270a70603 100644 --- a/rust/agama-software/src/model/license.rs +++ b/rust/agama-utils/src/license.rs @@ -20,8 +20,8 @@ //! Implements support for reading software licenses. +use crate::api::manager::{InvalidLanguageCode, LanguageTag, License}; use agama_locale_data::get_territories; -use agama_utils::api::software::{InvalidLanguageCode, LanguageTag, License}; use serde::Serialize; use serde_with::{serde_as, DisplayFromStr}; use std::{ @@ -84,7 +84,7 @@ impl LicensesRepo { } /// Reads the licenses from the repository. - pub fn read(&mut self) -> Result<(), std::io::Error> { + pub fn read(&mut self) -> Result<(), Error> { let entries = read_dir(self.path.as_path())?; for entry in entries { @@ -237,9 +237,7 @@ impl Default for LicensesRepo { #[cfg(test)] mod test { - use agama_utils::api::software::LanguageTag; - - use super::LicensesRepo; + use super::{LanguageTag, LicensesRepo}; use std::path::Path; fn build_repo() -> LicensesRepo { diff --git a/rust/agama-software/src/model/products.rs b/rust/agama-utils/src/products.rs similarity index 99% rename from rust/agama-software/src/model/products.rs rename to rust/agama-utils/src/products.rs index 5f0ba6c8c9..131c0401f5 100644 --- a/rust/agama-software/src/model/products.rs +++ b/rust/agama-utils/src/products.rs @@ -24,7 +24,7 @@ //! It reads the list of products from the `products.d` directory (usually, //! `/usr/share/agama/products.d`). -use agama_utils::api::software::Product; +use crate::api::manager::Product; use serde::{Deserialize, Deserializer}; use serde_with::{formats::CommaSeparator, serde_as, StringWithSeparator}; use std::path::{Path, PathBuf}; From 0cb4ee29ff3c2097c95ff1d5718cdc51f6260e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 10:14:18 +0000 Subject: [PATCH 330/917] Add an action to clear issues --- rust/agama-utils/src/issue/message.rs | 14 ++++++++++++++ rust/agama-utils/src/issue/service.rs | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/rust/agama-utils/src/issue/message.rs b/rust/agama-utils/src/issue/message.rs index abd7e3e90a..7db68ffa01 100644 --- a/rust/agama-utils/src/issue/message.rs +++ b/rust/agama-utils/src/issue/message.rs @@ -54,3 +54,17 @@ impl Update { impl Message for Update { type Reply = (); } + +pub struct Clear { + pub scope: Scope, +} + +impl Clear { + pub fn new(scope: Scope) -> Self { + Self { scope } + } +} + +impl Message for Clear { + type Reply = (); +} diff --git a/rust/agama-utils/src/issue/service.rs b/rust/agama-utils/src/issue/service.rs index 97c59431f9..8a7e48d457 100644 --- a/rust/agama-utils/src/issue/service.rs +++ b/rust/agama-utils/src/issue/service.rs @@ -91,3 +91,11 @@ impl MessageHandler for Service { Ok(()) } } + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::Clear) -> Result<(), Error> { + _ = self.issues.remove(&message.scope); + Ok(()) + } +} From 32a68e7b3c10b9002dc5fc108e6341ae922a759a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 10:16:07 +0000 Subject: [PATCH 331/917] Manager sends the selected product to the software service --- rust/agama-manager/src/service.rs | 94 +++++++++++++++++++++++++----- rust/agama-manager/src/start.rs | 2 +- rust/agama-software/src/message.rs | 10 +++- rust/agama-software/src/service.rs | 16 +---- rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/products.rs | 12 +++- 6 files changed, 101 insertions(+), 34 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index c90910363b..e898f525d4 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,18 +18,24 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::sync::Arc; + use crate::message; use crate::{l10n, software}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ - event, status::State, Action, Config, Event, IssueMap, Proposal, Scope, Status, SystemInfo, + event, manager, status::State, Action, Config, Event, Issue, IssueMap, IssueSeverity, + Proposal, Scope, Status, SystemInfo, }, - issue, progress, question, + issue, + license::{Error as LicenseError, LicensesRepo}, + products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, + progress, question, }; use async_trait::async_trait; use merge_struct::merge; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, RwLock}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -49,6 +55,10 @@ pub enum Error { Issues(#[from] issue::service::Error), #[error(transparent)] Questions(#[from] question::service::Error), + #[error(transparent)] + ProductsRegistry(#[from] ProductsRegistryError), + #[error(transparent)] + License(#[from] LicenseError), } pub struct Service { @@ -57,8 +67,12 @@ pub struct Service { issues: Handler, progress: Handler, questions: Handler, + products: ProductsRegistry, + licenses: LicensesRepo, + product: Option>>, state: State, config: Config, + system: manager::SystemInfo, events: event::Sender, } @@ -77,10 +91,35 @@ impl Service { issues, progress, questions, - events, + products: ProductsRegistry::default(), + licenses: LicensesRepo::default(), + // FIXME: state is already used for service state. state: State::Configuring, config: Config::default(), + system: manager::SystemInfo::default(), + product: None, + events, + } + } + + pub async fn setup(&mut self) -> Result<(), Error> { + self.read_registries().await?; + if let Some(product) = self.products.default_product() { + self.product = Some(Arc::new(RwLock::new(product.clone()))); + } + + if self.product.is_none() { + self.notify_no_product() } + Ok(()) + } + + pub async fn read_registries(&mut self) -> Result<(), Error> { + self.licenses.read()?; + self.products.read()?; + self.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); + self.system.products = self.products.products(); + Ok(()) } async fn install(&mut self) -> Result<(), Error> { @@ -101,6 +140,17 @@ impl Service { self.events.send(Event::StateChanged)?; Ok(()) } + + fn notify_no_product(&self) { + let issue = Issue::new( + "no_product", + "No product has been selected.", + IssueSeverity::Error, + ); + _ = self + .issues + .cast(issue::message::Update::new(Scope::Manager, vec![issue])); + } } impl Actor for Service { @@ -124,7 +174,8 @@ impl MessageHandler for Service { /// It returns the information of the underlying system. async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; - Ok(SystemInfo { l10n }) + let manager = self.system.clone(); + Ok(SystemInfo { manager, l10n }) } } @@ -158,13 +209,24 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// Sets the user configuration with the given values. - /// - /// It merges the values in the top-level. Therefore, if the configuration - /// for a scope is not given, it keeps the previous one. - /// - /// FIXME: We should replace not given sections with the default ones. - /// After all, now we have config/user/:scope URLs. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { + let product_id = message + .config + .software + .as_ref() + .and_then(|s| s.product.as_ref()) + .and_then(|p| p.id.as_ref()); + + if let Some(id) = product_id { + if let Some(product_spec) = self.products.find(&id) { + let product = RwLock::new(product_spec.clone()); + self.product = Some(Arc::new(product)); + _ = self.issues.cast(issue::message::Clear::new(Scope::Manager)); + } + } + + self.config = message.config.clone(); + if let Some(l10n) = &message.config.l10n { self.l10n .call(l10n::message::SetConfig::new(l10n.clone())) @@ -177,12 +239,16 @@ impl MessageHandler for Service { .await?; } - if let Some(software) = &message.config.software { + if let Some(product) = &self.product { self.software - .call(software::message::SetConfig::new(software.clone())) + .call(software::message::SetConfig::new( + message.config.software.clone(), + Arc::clone(&product), + )) .await?; + } else { + self.notify_no_product(); } - self.config = message.config; Ok(()) } } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 4629b0e8b9..a2dfbbd025 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -67,7 +67,7 @@ pub async fn start( let software = software::start(issues.clone(), progress.clone(), events.clone()).await?; let mut service = Service::new(l10n, software, issues, progress, questions, events.clone()); - service.read().await?; + service.setup().await?; let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 87d6bd8d7c..0fc82bf071 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -21,7 +21,10 @@ use agama_utils::{ actor::Message, api::software::{Config, Proposal, SystemInfo}, + products::ProductSpec, }; +use std::sync::Arc; +use tokio::sync::RwLock; #[derive(Clone)] pub struct GetSystem; @@ -51,7 +54,8 @@ impl Message for GetConfig { } pub struct SetConfig { - pub config: T, + pub config: Option, + pub product: Arc>, } impl Message for SetConfig { @@ -59,8 +63,8 @@ impl Message for SetConfig { } impl SetConfig { - pub fn new(config: T) -> Self { - Self { config } + pub fn new(config: Option, product: Arc>) -> Self { + Self { config, product } } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index e9198f486b..0a47d5276f 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -139,24 +139,14 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler> for Service { async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let product = message.config.product.as_ref(); + let product = message.product.read().await; - // handle product - let Some(new_product_id) = &product.and_then(|p| p.id.as_ref()) else { - return Ok(()); - }; - - let Some(new_product) = self.products.find(new_product_id.as_str()) else { - // FIXME: return an error. - return Ok(()); - }; - - self.state.config = message.config.clone(); + self.state.config = message.config.clone().unwrap_or_default(); self.events.send(Event::ConfigChanged { scope: Scope::Software, })?; - let software = SoftwareState::build_from(new_product, &message.config, &self.state.system); + let software = SoftwareState::build_from(&product, &self.state.config, &self.state.system); let model = self.model.clone(); let issues = self.issues.clone(); diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 4ee384ec22..f57d021700 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -49,5 +49,6 @@ mod action; pub use action::Action; pub mod l10n; +pub mod manager; pub mod question; pub mod software; diff --git a/rust/agama-utils/src/products.rs b/rust/agama-utils/src/products.rs index 131c0401f5..fe77382fb1 100644 --- a/rust/agama-utils/src/products.rs +++ b/rust/agama-utils/src/products.rs @@ -80,9 +80,15 @@ impl ProductsRegistry { Ok(()) } - /// Determines whether the are are multiple products. - pub fn is_multiproduct(&self) -> bool { - self.products.len() > 1 + /// Returns the default product. + /// + /// If there is a single product, it is considered the "default product". + pub fn default_product(&self) -> Option<&ProductSpec> { + if self.products.len() == 1 { + self.products.first() + } else { + None + } } /// Finds a product by its ID. From f9f3b7017da2eaeede07168d0ba0d3430f14de33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 10:19:08 +0000 Subject: [PATCH 332/917] Fix formatting --- rust/agama-server/src/web.rs | 2 +- rust/agama-software/src/model.rs | 1 + rust/agama-software/src/model/state.rs | 14 ++++++-------- rust/agama-utils/src/api/manager.rs | 2 +- rust/agama-utils/src/api/manager/system_info.rs | 1 - 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 9cd153dc1a..14ba58a4c3 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -57,7 +57,7 @@ use agama_lib::http::event::{OldEvent, OldSender}; use common::ProgressService; pub use config::ServiceConfig; pub use service::MainServiceBuilder; -use std::{path::Path}; +use std::path::Path; use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 8197ba3ced..0627404003 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -24,6 +24,7 @@ use agama_utils::{ software::{Pattern, SoftwareProposal}, Issue, }, + products::{ProductSpec, UserPattern}, progress, }; use async_trait::async_trait; diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 13847ebdb9..513b41bcfd 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -23,8 +23,8 @@ //! definition, the user configuration, etc. use agama_utils::{ + api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo}, products::{ProductSpec, UserPattern}, - api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo} }; /// Represents the wanted software configuration. @@ -258,16 +258,14 @@ mod tests { use std::path::PathBuf; use agama_utils::{ - products::ProductSpec, api::software::{ - Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, - SystemInfo - } + Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, + SystemInfo, + }, + products::ProductSpec, }; - use crate::model::{ - state::{ResolvableName, SoftwareStateBuilder}, - }; + use crate::model::state::{ResolvableName, SoftwareStateBuilder}; fn build_user_config(patterns: Option) -> Config { let repo = RepositoryConfig { diff --git a/rust/agama-utils/src/api/manager.rs b/rust/agama-utils/src/api/manager.rs index d7749cd157..51e060417d 100644 --- a/rust/agama-utils/src/api/manager.rs +++ b/rust/agama-utils/src/api/manager.rs @@ -22,4 +22,4 @@ mod license; pub use license::{InvalidLanguageCode, LanguageTag, License, LicenseContent}; mod system_info; -pub use system_info::{SystemInfo, Product}; +pub use system_info::{Product, SystemInfo}; diff --git a/rust/agama-utils/src/api/manager/system_info.rs b/rust/agama-utils/src/api/manager/system_info.rs index 6d903ce41a..bea28cf9ed 100644 --- a/rust/agama-utils/src/api/manager/system_info.rs +++ b/rust/agama-utils/src/api/manager/system_info.rs @@ -47,4 +47,3 @@ pub struct Product { /// License ID pub license: Option, } - From 32157e0689d2b2a19acbc2563783b54c317769b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 13:05:30 +0000 Subject: [PATCH 333/917] Set the product in the software model --- rust/agama-software/src/model.rs | 6 ++++++ rust/agama-software/src/service.rs | 2 ++ 2 files changed, 8 insertions(+) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 0627404003..7bb2d15817 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -77,6 +77,8 @@ pub trait ModelAdapter: Send + Sync + 'static { /// Finalizes system like disabling local repositories async fn finish(&self) -> Result<(), service::Error>; + fn set_product(&mut self, product_spec: ProductSpec); + /// Applies the configuration to the system. /// /// It does not perform the installation, just update the repositories and @@ -109,6 +111,10 @@ impl Model { #[async_trait] impl ModelAdapter for Model { + fn set_product(&mut self, product_spec: ProductSpec) { + self.selected_product = Some(product_spec); + } + async fn write( &mut self, software: SoftwareState, diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 0a47d5276f..8d24a22c3b 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -153,8 +153,10 @@ impl MessageHandler> for Service { let events = self.events.clone(); let progress = self.progress.clone(); let proposal = self.state.proposal.clone(); + let product_spec = product.clone(); tokio::task::spawn(async move { let mut my_model = model.lock().await; + my_model.set_product(product_spec); let found_issues = my_model.write(software, progress).await.unwrap(); if !found_issues.is_empty() { _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); From 1dac32413fbca07ade867e8c315decd83f8ddc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 13:05:48 +0000 Subject: [PATCH 334/917] Flatten SoftwareProposal --- rust/agama-utils/src/api/software/proposal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-utils/src/api/software/proposal.rs b/rust/agama-utils/src/api/software/proposal.rs index 6e90d639d5..1dd60da8b6 100644 --- a/rust/agama-utils/src/api/software/proposal.rs +++ b/rust/agama-utils/src/api/software/proposal.rs @@ -48,7 +48,7 @@ pub struct SoftwareProposal { #[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] pub struct Proposal { /// Software specific proposal - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(flatten, skip_serializing_if = "Option::is_none")] pub software: Option, /// Registration proposal. Maybe same as config? /// TODO: implement it From 827f1a5b53b1bc47ca0dc4f53aeaf68ded597644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 13:40:24 +0000 Subject: [PATCH 335/917] Improve error handling on software proposal --- rust/agama-software/src/model.rs | 8 ++--- rust/agama-software/src/service.rs | 47 +++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 7bb2d15817..0ca05c9a1f 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -66,7 +66,7 @@ pub trait ModelAdapter: Send + Sync + 'static { optional: bool, ) -> Result<(), service::Error>; - async fn compute_proposal(&self) -> Result, service::Error>; + async fn compute_proposal(&self) -> Result; /// Refresh repositories information. async fn refresh(&mut self) -> Result<(), service::Error>; @@ -185,14 +185,14 @@ impl ModelAdapter for Model { Ok(rx.await??) } - async fn compute_proposal(&self) -> Result, service::Error> { + async fn compute_proposal(&self) -> Result { let Some(product_spec) = self.selected_product.clone() else { - return Ok(None); + return Err(service::Error::MissingProduct); }; let (tx, rx) = oneshot::channel(); self.zypp_sender .send(SoftwareAction::ComputeProposal(product_spec, tx))?; - Ok(Some(rx.await??)) + Ok(rx.await??) } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 8d24a22c3b..6e88e30a7e 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -29,10 +29,12 @@ use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ event::{self, Event}, - software::{Config, Proposal, Repository, SystemInfo}, - Scope, + software::{Config, Proposal, Repository, SoftwareProposal, SystemInfo}, + Issue, IssueSeverity, Scope, }, - issue, progress, + issue, + products::ProductSpec, + progress, }; use async_trait::async_trait; use tokio::sync::{broadcast, Mutex, RwLock}; @@ -155,17 +157,21 @@ impl MessageHandler> for Service { let proposal = self.state.proposal.clone(); let product_spec = product.clone(); tokio::task::spawn(async move { - let mut my_model = model.lock().await; - my_model.set_product(product_spec); - let found_issues = my_model.write(software, progress).await.unwrap(); - if !found_issues.is_empty() { - _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); - } - // update proposal with new config - // TODO: how to handle errors here? Own issue? - let software_proposal = my_model.compute_proposal().await.unwrap(); - proposal.write().await.software = software_proposal; - + let (new_proposal, found_issues) = + match compute_proposal(model, product_spec, software, progress).await { + Ok((new_proposal, found_issues)) => (Some(new_proposal), found_issues), + Err(error) => { + let new_issue = Issue::new( + "software.proposal_failed", + "It was not possible to create a software proposal", + IssueSeverity::Error, + ) + .with_details(&error.to_string()); + (None, vec![new_issue]) + } + }; + proposal.write().await.software = new_proposal; + _ = issues.cast(issue::message::Update::new(Scope::Software, found_issues)); _ = events.send(Event::ProposalChanged { scope: Scope::Software, }); @@ -175,6 +181,19 @@ impl MessageHandler> for Service { } } +async fn compute_proposal( + model: Arc>, + product_spec: ProductSpec, + wanted: SoftwareState, + progress: Handler, +) -> Result<(SoftwareProposal, Vec), Error> { + 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?; + Ok((proposal, issues)) +} + #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { From 465278ae3de5e03cac324277f413f547611e9451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 14:53:14 +0000 Subject: [PATCH 336/917] Initialize the software service on single product scenarios --- rust/agama-manager/src/service.rs | 18 ++++++++++++------ rust/agama-software/src/message.rs | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index e898f525d4..a50c0ccb6f 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -102,19 +102,25 @@ impl Service { } } + /// Set up the service by reading the registries and determining the default product. + /// + /// If a default product is set, it asks the other services to initialize their configurations. pub async fn setup(&mut self) -> Result<(), Error> { self.read_registries().await?; if let Some(product) = self.products.default_product() { - self.product = Some(Arc::new(RwLock::new(product.clone()))); - } - - if self.product.is_none() { + let product = Arc::new(RwLock::new(product.clone())); + _ = self.software.cast(software::message::SetConfig::new( + Arc::clone(&product), + None, + )); + self.product = Some(product); + } else { self.notify_no_product() } Ok(()) } - pub async fn read_registries(&mut self) -> Result<(), Error> { + async fn read_registries(&mut self) -> Result<(), Error> { self.licenses.read()?; self.products.read()?; self.system.licenses = self.licenses.licenses().into_iter().cloned().collect(); @@ -242,8 +248,8 @@ impl MessageHandler for Service { if let Some(product) = &self.product { self.software .call(software::message::SetConfig::new( - message.config.software.clone(), Arc::clone(&product), + message.config.software.clone(), )) .await?; } else { diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 0fc82bf071..2d468bb389 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -63,7 +63,7 @@ impl Message for SetConfig { } impl SetConfig { - pub fn new(config: Option, product: Arc>) -> Self { + pub fn new(product: Arc>, config: Option) -> Self { Self { config, product } } } From 5c1b425cf2a1e278a7dbfbe6d919c8c15bd28d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 15:19:42 +0000 Subject: [PATCH 337/917] Add a test for ProductsRegistry::default_product --- rust/agama-utils/src/products.rs | 28 ++- .../share/products.d-single/tumbleweed.yaml | 224 ++++++++++++++++++ 2 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 rust/test/share/products.d-single/tumbleweed.yaml diff --git a/rust/agama-utils/src/products.rs b/rust/agama-utils/src/products.rs index fe77382fb1..a00102cc10 100644 --- a/rust/agama-utils/src/products.rs +++ b/rust/agama-utils/src/products.rs @@ -228,18 +228,18 @@ mod test { #[test] fn test_load_registry() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); - let mut repo = ProductsRegistry::new(path.as_path()); - repo.read().unwrap(); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); // ensuring that we can load all products from tests - assert_eq!(repo.products.len(), 8); + assert_eq!(registry.products.len(), 8); } #[test] fn test_find_product() { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); - let mut repo = ProductsRegistry::new(path.as_path()); - repo.read().unwrap(); - let tw = repo.find("Tumbleweed").unwrap(); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + let tw = registry.find("Tumbleweed").unwrap(); assert_eq!(tw.id, "Tumbleweed"); assert_eq!(tw.name, "openSUSE Tumbleweed"); assert_eq!(tw.icon, "Tumbleweed.svg"); @@ -264,7 +264,21 @@ mod test { Some(&UserPattern::Preselected(expected_pattern)) ); - let missing = repo.find("Missing"); + let missing = registry.find("Missing"); assert!(missing.is_none()); } + + #[test] + fn test_default_product() { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d-single"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + assert!(registry.default_product().is_some()); + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/products.d"); + let mut registry = ProductsRegistry::new(path.as_path()); + registry.read().unwrap(); + assert!(registry.default_product().is_none()); + } } diff --git a/rust/test/share/products.d-single/tumbleweed.yaml b/rust/test/share/products.d-single/tumbleweed.yaml new file mode 100644 index 0000000000..561ff8aea7 --- /dev/null +++ b/rust/test/share/products.d-single/tumbleweed.yaml @@ -0,0 +1,224 @@ +id: Tumbleweed +name: openSUSE Tumbleweed +# ------------------------------------------------------------------------------ +# WARNING: When changing the product description delete the translations located +# at the at translations/description key below to avoid using obsolete +# translations!! +# ------------------------------------------------------------------------------ +description: 'A pure rolling release version of openSUSE containing the latest + "stable" versions of all software instead of relying on rigid periodic release + cycles. The project does this for users that want the newest stable software.' +icon: Tumbleweed.svg +# Do not manually change any translations! See README.md for more details. +translations: + description: + ca: Una versió de llançament continuada d'openSUSE que conté les darreres + versions estables de tot el programari en lloc de dependre de cicles de + llançament periòdics rígids. El projecte fa això per als usuaris que volen + el programari estable més nou. + cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze + veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. + Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. + fr: La distribution Tumbleweed est une pure "rolling release" (publication + continue) d'openSUSE contenant les dernières versions "stables" de tous + les logiciels au lieu de se baser sur des cycles de publication + périodiques et fixes. Le projet fait cela pour les utilisateurs qui + veulent les logiciels stables les plus récents. + id: Distribusi Tumbleweed merupakan versi rilis bergulir murni dari openSUSE + yang berisi versi "stabil" terbaru dari semua perangkat lunak dan tidak + bergantung pada siklus rilis berkala yang kaku. Proyek ini dibuat untuk + memenuhi kebutuhan pengguna yang menginginkan perangkat lunak stabil + terbaru. + ja: openSUSE の純粋なローリングリリース版で、特定のリリースサイクルによることなく全てのソフトウエアを最新の "安定" + バージョンに維持し続ける取り組みです。このプロジェクトは特に、最新の安定バージョンを使いたいユーザにお勧めです。 + nb_NO: Tumbleweed distribusjonen er en ren rullerende utgivelsesversjon av + openSUSE som inneholder de siste "stabile" versjonene av all programvare i + stedet for å stole på et rigid periodisk utgivelsessykluser. Prosjektet + gjør dette for brukere som vil ha de nyeste stabile programvarene. + pt_BR: Uma versão de lançamento puro e contínuo do openSUSE contendo as últimas + versões "estáveis" de todos os softwares em vez de depender de ciclos de + lançamento periódicos rígidos. O projeto faz isso para usuários que querem + o software estável mais novo. + ru: Дистрибутив Tumbleweed - это плавающий выпуск openSUSE, содержащий последние + "стабильные" версии всего программного обеспечения, вместо того чтобы + полагаться на жесткие периодические циклы выпуска. Проект делает его для + пользователей, которым нужно самое новое стабильное программное + обеспечение. + sv: En ren rullande släppversion av openSUSE som innehåller de senaste "stabila" + versionerna av all programvara istället för att förlita sig på stela + periodiska släppcykler. Projektet gör detta för användare som vill ha den + senaste stabila mjukvaran. + tr: Katı periyodik sürüm döngülerine güvenmek yerine tüm yazılımların en son + "kararlı" sürümlerini içeren openSUSE'nin saf bir yuvarlanan sürümü. Proje + bunu en yeni kararlı yazılımı isteyen kullanıcılar için yapar. + zh_Hans: Tumbleweed 发行版是 openSUSE + 的纯滚动发布版本,其并不依赖于严格的定时发布周期,而是持续包含所有最新“稳定”版本的软件。该项目为追求最新稳定软件的用户而生。 +software: + installation_repositories: + - url: https://download.opensuse.org/tumbleweed/repo/oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + - url: https://download.opensuse.org/tumbleweed/repo/non-oss/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/tumbleweed/repo/non-oss/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/tumbleweed/repo/non-oss/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/non-oss/ + archs: ppc + - url: https://download.opensuse.org/update/tumbleweed/ + archs: x86_64 + - url: https://download.opensuse.org/ports/aarch64/update/tumbleweed/ + archs: aarch64 + - url: https://download.opensuse.org/ports/zsystems/update/tumbleweed/ + archs: s390 + - url: https://download.opensuse.org/ports/ppc/tumbleweed/repo/oss/ + archs: ppc + # device labels for offline installation media + installation_labels: + - label: openSUSE-Tumbleweed-DVD-x86_64 + archs: x86_64 + - label: openSUSE-Tumbleweed-DVD-aarch64 + archs: aarch64 + - label: openSUSE-Tumbleweed-DVD-s390x + archs: s390 + - label: openSUSE-Tumbleweed-DVD-ppc64le + archs: ppc + mandatory_patterns: + - enhanced_base # only pattern that is shared among all roles on TW + optional_patterns: null # no optional pattern shared + user_patterns: + - basic_desktop + - xfce + - kde + - gnome + - yast2_basis + - yast2_desktop + - yast2_server + - multimedia + - office + - name: selinux + selected: true + - apparmor + mandatory_packages: + - NetworkManager + - openSUSE-repos-Tumbleweed + - sudo-policy-wheel-auth-self # explicit wheel group policy to conform new auth model + optional_packages: null + base_product: openSUSE + +security: + lsm: selinux + available_lsms: + apparmor: + patterns: + - apparmor + selinux: + patterns: + - selinux + none: + patterns: null + +storage: + boot_strategy: BLS + space_policy: delete + volumes: + - "/" + - "swap" + volume_templates: + - mount_path: "/" + filesystem: btrfs + btrfs: + snapshots: true + read_only: false + default_subvolume: "@" + subvolumes: + - path: home + - path: opt + - path: root + - path: srv + - path: usr/local + # Unified var subvolume - https://lists.opensuse.org/opensuse-packaging/2017-11/msg00017.html + - path: var + copy_on_write: false + # Architecture specific subvolumes + - path: boot/grub2/arm64-efi + archs: aarch64 + - path: boot/grub2/arm-efi + archs: arm + - path: boot/grub2/i386-pc + archs: x86_64 + - path: boot/grub2/powerpc-ieee1275 + archs: ppc,!board_powernv + - path: boot/grub2/s390x-emu + archs: s390 + - path: boot/grub2/x86_64-efi + archs: x86_64 + - path: boot/grub2/riscv64-efi + archs: riscv64 + size: + auto: true + outline: + required: true + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + auto_size: + base_min: 5 GiB + base_max: 15 GiB + snapshots_increment: 250% + max_fallback_for: + - "/home" + snapshots_configurable: true + - mount_path: "swap" + filesystem: swap + size: + min: 1 GiB + max: 2 GiB + outline: + required: false + filesystems: + - swap + - mount_path: "/home" + filesystem: xfs + size: + auto: false + min: 5 GiB + max: unlimited + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - filesystem: xfs + size: + auto: false + min: 512 MiB + outline: + required: false + filesystems: + - btrfs + - ext2 + - ext3 + - ext4 + - xfs + - vfat From cdab39322afb264f4cca089713ed1af9fb5c68bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 15:23:19 +0000 Subject: [PATCH 338/917] Rename LicensesRepo to LicensesRegistry --- rust/agama-manager/src/service.rs | 6 +++--- rust/agama-utils/src/license.rs | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index a50c0ccb6f..23698dbaff 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -29,7 +29,7 @@ use agama_utils::{ Proposal, Scope, Status, SystemInfo, }, issue, - license::{Error as LicenseError, LicensesRepo}, + license::{Error as LicenseError, LicensesRegistry}, products::{ProductSpec, ProductsRegistry, ProductsRegistryError}, progress, question, }; @@ -68,7 +68,7 @@ pub struct Service { progress: Handler, questions: Handler, products: ProductsRegistry, - licenses: LicensesRepo, + licenses: LicensesRegistry, product: Option>>, state: State, config: Config, @@ -92,7 +92,7 @@ impl Service { progress, questions, products: ProductsRegistry::default(), - licenses: LicensesRepo::default(), + licenses: LicensesRegistry::default(), // FIXME: state is already used for service state. state: State::Configuring, config: Config::default(), diff --git a/rust/agama-utils/src/license.rs b/rust/agama-utils/src/license.rs index 6270a70603..5ac3a596d3 100644 --- a/rust/agama-utils/src/license.rs +++ b/rust/agama-utils/src/license.rs @@ -65,7 +65,7 @@ pub struct LicenseContent { /// The license diectory contains the default text (license.txt) and a set of translations (e.g., /// "license.es.txt", "license.zh_CH.txt", etc.). #[derive(Clone)] -pub struct LicensesRepo { +pub struct LicensesRegistry { /// Repository path. path: std::path::PathBuf, /// Licenses in the repository. @@ -74,7 +74,7 @@ pub struct LicensesRepo { fallback: HashMap, } -impl LicensesRepo { +impl LicensesRegistry { pub fn new>(path: P) -> Self { Self { path: path.as_ref().to_owned(), @@ -221,7 +221,7 @@ impl LicensesRepo { } } -impl Default for LicensesRepo { +impl Default for LicensesRegistry { fn default() -> Self { let relative_path = PathBuf::from("share/eula"); let path = if relative_path.exists() { @@ -237,25 +237,25 @@ impl Default for LicensesRepo { #[cfg(test)] mod test { - use super::{LanguageTag, LicensesRepo}; + use super::{LanguageTag, LicensesRegistry}; use std::path::Path; - fn build_repo() -> LicensesRepo { - let mut repo = LicensesRepo::new(Path::new("../share/eula")); + fn build_registry() -> LicensesRegistry { + let mut repo = LicensesRegistry::new(Path::new("../share/eula")); repo.read().unwrap(); repo } #[test] fn test_read_licenses_repository() { - let repo = build_repo(); + let repo = build_registry(); let license = repo.licenses.first().unwrap(); assert_eq!(&license.id, "license.final"); } #[test] fn test_find_license() { - let repo = build_repo(); + let repo = build_registry(); let es_language: LanguageTag = "es".try_into().unwrap(); let license = repo.find("license.final", &es_language).unwrap(); assert!(license.body.starts_with("Acuerdo de licencia")); @@ -279,7 +279,7 @@ mod test { #[test] fn test_find_alternate_license() { - let repo = build_repo(); + let repo = build_registry(); // Tries to use the main language for the territory. let ca_language: LanguageTag = "ca-ES".try_into().unwrap(); From eb10e4b1e4e21f3ba33c6d2e9fc0aba572fa6102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Thu, 6 Nov 2025 15:30:56 +0000 Subject: [PATCH 339/917] Rename software Service::read to ::setup --- rust/agama-software/src/service.rs | 2 +- rust/agama-software/src/start.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 6e88e30a7e..cf1dbdc906 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -102,7 +102,7 @@ impl Service { } } - pub async fn read(&mut self) -> Result<(), Error> { + pub async fn setup(&mut self) -> Result<(), Error> { if let Some(install_repo) = find_install_repository() { tracing::info!("Found repository at {}", install_repo.url); self.state.system.repositories.push(install_repo); diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs index 0ce33fd5cc..4e802835dd 100644 --- a/rust/agama-software/src/start.rs +++ b/rust/agama-software/src/start.rs @@ -55,7 +55,8 @@ pub async fn start( let zypp_sender = ZyppServer::start()?; let model = Model::new(zypp_sender)?; let mut service = Service::new(model, issues, progress, events); - service.read().await?; + // FIXME: this should happen after spawning the task. + service.setup().await?; let handler = actor::spawn(service); Ok(handler) } From fbdc70506e3d8e27103775dc89713ed565d06eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 7 Nov 2025 07:48:55 +0000 Subject: [PATCH 340/917] Update from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Iván López --- rust/agama-software/src/zypp_server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 3cc6ec563c..daecab0f63 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -346,7 +346,7 @@ impl ZyppServer { } } - _ = progress.cast(progress::message::Next::new(Scope::Software)); + progress.cast(progress::message::Next::new(Scope::Software))?; if to_add.is_empty() || to_remove.is_empty() { let result = zypp.load_source(|percent, alias| { tracing::info!("Refreshing repositories: {} ({}%)", alias, percent); From 497ba63d2e053b6ba62337e2af4dd1432a6500a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 7 Nov 2025 07:54:05 +0000 Subject: [PATCH 341/917] Fix progress error reporting in zypp_server --- rust/agama-software/src/zypp_server.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index daecab0f63..ff741496e8 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -49,6 +49,8 @@ pub enum ZyppDispatchError { ResponseChannelClosed, #[error("Target creation failed: {0}")] TargetCreationFailed(#[source] std::io::Error), + #[error(transparent)] + Progress(#[from] progress::service::Error), } #[derive(thiserror::Error, Debug)] From d9381bfe5afe68d02ad0cdcb8450a1f62ba2a8a7 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 4 Nov 2025 13:13:14 +0100 Subject: [PATCH 342/917] WIP: First version of the JSON schema for system/storage --- rust/share/system.storage.schema.json | 305 ++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 rust/share/system.storage.schema.json diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json new file mode 100644 index 0000000000..9cede24ee7 --- /dev/null +++ b/rust/share/system.storage.schema.json @@ -0,0 +1,305 @@ +{ + "title": "System", + "description": "API description of the system", + "type": "object", + "additionalProperties": false, + "properties": { + "devices": { + "description": "All relevant devices on the system", + "type": "array", + "items": { "$ref": "#/$defs/device" } + }, + "availableDrives": { + "description": "SIDs of the available drives", + "type": "array", + "items": { "type": "integer" } + }, + "availableMdRaids": { + "description": "SIDs of the available MD RAIDs", + "type": "array", + "items": { "type": "integer" } + }, + "candidateDrives": { + "description": "SIDs of the drives that are candidate for installation", + "type": "array", + "items": { "type": "integer" } + }, + "candidateMdRaids": { + "description": "SIDs of the MD RAIDs that are candidate for installation", + "type": "array", + "items": { "type": "integer" } + }, + "productMountPoints": { + "description": "Meaningful mount points for the current product", + "type": "array", + "items": { "type": "string" } + }, + "encryptionMethods": { + "description": "Possible encryption methods for the current system and product", + "type": "array", + "items": { "type": "string" } + }, + "volumeTemplates": { + "description": "Volumes defined by the product as templates", + "type": "array", + "items": { "$ref": "#/$defs/volume" } + }, + "issues": { + "type": "array", + "items": { "$ref": "#/$defs/issue" } + } + }, + "$defs": { + "device": { + "type": "object", + "additionalProperties": false, + "required": ["sid", "name"], + "properties": { + "sid": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "block": { "$ref": "#/$defs/block" }, + "drive": { "$ref": "#/$defs/drive" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "md": { "$ref": "#/$defs/md" }, + "multipath": { "$ref": "#/$defs/multipath" }, + "partitionTable": { "$ref": "#/$defs/partitionTable" }, + "partition": { "$ref": "#/$defs/partition" }, + "partitions": { + "type": "array", + "items": { "$ref": "#/$defs/device" } + }, + "volumeGroup": { "$ref": "#/$defs/volumeGroup" }, + "logicalVolumes": { + "type": "array", + "items": { "$ref": "#/$defs/device" } + } + } + }, + "block": { + "type": "object", + "additionalProperties": false, + "required": ["start", "size", "shrinking"], + "properties": { + "start": { "type": "integer" }, + "size": { "type": "integer" }, + "active": { "type": "boolean" }, + "encrypted": { "type": "boolean" }, + "udevIds": { + "type": "array", + "items": { "type": "string" } + }, + "udevPaths": { + "type": "array", + "items": { "type": "string" } + }, + "systems": { + "type": "array", + "items": { "type": "string" } + }, + "shrinking": { + "anyOf": [ + { "$ref": "#/$defs/shrinkingSupported" }, + { "$ref": "#/$defs/shrinkingUnsupported" } + ] + } + } + }, + "shrinkingSupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { "type": "integer" } + } + }, + "shrinkingUnsupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "unsupported": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "drive": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["disk", "raid", "multipath", "dasd"] }, + "vendor": { "type": "string" }, + "model": { "type": "string" }, + "transport": { "type": "string" }, + "bus": { "type": "string" }, + "busId": { "type": "string" }, + "driver": { + "type": "array", + "items": { "type": "string" } + }, + "info": { "$ref": "#/$defs/driveInfo" } + } + }, + "driveInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "sdCard": { "type": "boolean" }, + "dellBoss": { "type": "boolean" } + } + }, + "filesystem": { + "type": "object", + "additionalProperties": false, + "required": ["sid", "type"], + "properties": { + "sid": { "type": "integer" }, + "type": { "$ref": "#/$defs/filesystemType" }, + "mountPath": { "type": "string" }, + "label": { "type": "string" } + } + }, + "filesystemType": { + "enum": [ + "bcachefs", + "btrfs", + "exfat", + "ext2", + "ext3", + "ext4", + "f2fs", + "jfs", + "nfs", + "nilfs2", + "ntfs", + "reiserfs", + "swap", + "tmpfs", + "vfat", + "xfs" + ] + }, + "md": { + "type": "object", + "additionalProperties": false, + "required": ["level", "devices"], + "properties": { + "level": { "$ref": "#/$defs/mdRaidLevel" }, + "devices": { + "type": "array", + "items": { "type": "integer" } + }, + "uuid": { "type": "string" } + } + }, + "mdRaidLevel": { + "title": "MD level", + "enum": [ + "raid0", + "raid1", + "raid5", + "raid6", + "raid10" + ] + }, + "multipath": { + "type": "object", + "additionalProperties": false, + "required": ["wireNames"], + "properties": { + "wireNames": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "partitionTable": { + "type": "object", + "additionalProperties": false, + "required": ["type", "unusedSlots"], + "properties": { + "type": { "$ref": "#/$defs/ptableType" }, + "unusedSlots": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + } + }, + "ptableType": { + "enum": ["gpt", "msdos", "dasd"] + }, + "partition": { + "type": "object", + "additionalProperties": false, + "required": ["efi"], + "properties": { + "efi": { "type": "boolean" } + } + }, + "volumeGroup": { + "type": "object", + "additionalProperties": false, + "required": ["size", "physicalVolumes"], + "properties": { + "size": { "type": "integer" }, + "physicalVolumes": { + "type": "array", + "items": { "type": "integer" } + } + } + }, + "volume": { + "type": "object", + "additionalProperties": false, + "required": ["mountPath", "minSize", "autoSize"], + "properties": { + "mountPath": { "type": "string" }, + "mountOptions": { + "type": "array", + "items": { "type": "string" } + }, + "fsType": { "$ref": "#/$defs/filesystemType" }, + "autoSize": { "type": "boolean" }, + "minSize": { "type": "integer" }, + "maxSize": { "type": "integer" }, + "snapshots": { "type": "boolean" }, + "transactional": { "type": "boolean" }, + "outline": { "$ref": "#/$defs/volumeOutline" } + } + }, + "volumeOutline": { + "type": "object", + "additionalProperties": false, + "required": ["required", "supportAutoSize"], + "properties": { + "required": { "type": "boolean" }, + "supportAutoSize": { "type": "boolean" }, + "fsTypes": { + "type": "array", + "items": { "$ref": "#/$defs/filesystemType" } + }, + "adjustByRam": { "type": "boolean" }, + "snapshotsConfigurable": { "type": "boolean" }, + "snapshotsAffectSizes": { "type": "boolean" }, + "sizeRelevantVolumes": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "issue": { + "type": "object", + "additionalProperties": false, + "required": ["description"], + "properties": { + "description": { "type": "string" }, + "class": { "type": "string" }, + "details": { "type": "string" }, + "source": { "enum": ["config", "system"] }, + "severity": { "enum": ["warn", "error"] } + } + } + } +} From 8cd2988e0701773460d7af725d2b4750b58d0635 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 4 Nov 2025 14:27:40 +0000 Subject: [PATCH 343/917] web: Types based on the storage system schema --- web/src/api/storage/types.ts | 1 + web/src/api/storage/types/system.ts | 162 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 web/src/api/storage/types/system.ts diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index 09ad88d304..faaf3a1a8c 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -23,3 +23,4 @@ export * from "./types/openapi"; export * as config from "./types/config"; export * as apiModel from "./types/model"; +export * as system from "./types/system"; diff --git a/web/src/api/storage/types/system.ts b/web/src/api/storage/types/system.ts new file mode 100644 index 0000000000..f202030aa3 --- /dev/null +++ b/web/src/api/storage/types/system.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type FilesystemType = + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; +export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; +export type PtableType = "gpt" | "msdos" | "dasd"; + +/** + * API description of the system + */ +export interface System { + /** + * All relevant devices on the system + */ + devices?: Device[]; + /** + * SIDs of the available drives + */ + availableDrives?: number[]; + /** + * SIDs of the available MD RAIDs + */ + availableMdRaids?: number[]; + /** + * SIDs of the drives that are candidate for installation + */ + candidateDrives?: number[]; + /** + * SIDs of the MD RAIDs that are candidate for installation + */ + candidateMdRaids?: number[]; + /** + * Meaningful mount points for the current product + */ + productMountPoints?: string[]; + /** + * Possible encryption methods for the current system and product + */ + encryptionMethods?: string[]; + /** + * Volumes defined by the product as templates + */ + volumeTemplates?: Volume[]; + issues?: Issue[]; +} +export interface Device { + sid: number; + name: string; + description?: string; + block?: Block; + drive?: Drive; + filesystem?: Filesystem; + md?: Md; + multipath?: Multipath; + partitionTable?: PartitionTable; + partition?: Partition; + partitions?: Device[]; + volumeGroup?: VolumeGroup; + logicalVolumes?: Device[]; +} +export interface Block { + start: number; + size: number; + active?: boolean; + encrypted?: boolean; + udevIds?: string[]; + udevPaths?: string[]; + systems?: string[]; + shrinking: ShrinkingSupported | ShrinkingUnsupported; +} +export interface ShrinkingSupported { + supported?: number; +} +export interface ShrinkingUnsupported { + unsupported?: string[]; +} +export interface Drive { + type?: "disk" | "raid" | "multipath" | "dasd"; + vendor?: string; + model?: string; + transport?: string; + bus?: string; + busId?: string; + driver?: string[]; + info?: DriveInfo; +} +export interface DriveInfo { + sdCard?: boolean; + dellBoss?: boolean; +} +export interface Filesystem { + sid: number; + type: FilesystemType; + mountPath?: string; + label?: string; +} +export interface Md { + level: MDLevel; + devices: number[]; + uuid?: string; +} +export interface Multipath { + wireNames: string[]; +} +export interface PartitionTable { + type: PtableType; + unusedSlots: number[][]; +} +export interface Partition { + efi: boolean; +} +export interface VolumeGroup { + size: number; + physicalVolumes: number[]; +} +export interface Volume { + mountPath: string; + mountOptions?: string[]; + fsType?: FilesystemType; + autoSize: boolean; + minSize: number; + maxSize?: number; + snapshots?: boolean; + transactional?: boolean; + outline?: VolumeOutline; +} +export interface VolumeOutline { + required: boolean; + supportAutoSize: boolean; + fsTypes?: FilesystemType[]; + adjustByRam?: boolean; + snapshotsConfigurable?: boolean; + snapshotsAffectSizes?: boolean; + sizeRelevantVolumes?: string[]; +} +export interface Issue { + description: string; + class?: string; + details?: string; + source?: "config" | "system"; + severity?: "warn" | "error"; +} From def09e716378685eda6bb448eaf963be51ee504f Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 5 Nov 2025 11:19:02 +0000 Subject: [PATCH 344/917] Modularize system.storage.schema --- rust/share/device.storage.schema.json | 206 +++++++++++++++++++++++++ rust/share/system.storage.schema.json | 208 +------------------------- 2 files changed, 211 insertions(+), 203 deletions(-) create mode 100644 rust/share/device.storage.schema.json diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json new file mode 100644 index 0000000000..39ebbfcee6 --- /dev/null +++ b/rust/share/device.storage.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/device.storage.schema.json", + "title": "Storage device", + "description": "Schema to describe a device both in 'system' and 'proposal'.", + "type": "object", + "additionalProperties": false, + "required": ["sid", "name"], + "properties": { + "sid": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "block": { "$ref": "#/$defs/block" }, + "drive": { "$ref": "#/$defs/drive" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "md": { "$ref": "#/$defs/md" }, + "multipath": { "$ref": "#/$defs/multipath" }, + "partitionTable": { "$ref": "#/$defs/partitionTable" }, + "partition": { "$ref": "#/$defs/partition" }, + "partitions": { + "type": "array", + "items": { "$ref": "#" } + }, + "volumeGroup": { "$ref": "#/$defs/volumeGroup" }, + "logicalVolumes": { + "type": "array", + "items": { "$ref": "#" } + } + }, + "$defs": { + "block": { + "type": "object", + "additionalProperties": false, + "required": ["start", "size", "shrinking"], + "properties": { + "start": { "type": "integer" }, + "size": { "type": "integer" }, + "active": { "type": "boolean" }, + "encrypted": { "type": "boolean" }, + "udevIds": { + "type": "array", + "items": { "type": "string" } + }, + "udevPaths": { + "type": "array", + "items": { "type": "string" } + }, + "systems": { + "type": "array", + "items": { "type": "string" } + }, + "shrinking": { + "anyOf": [ + { "$ref": "#/$defs/shrinkingSupported" }, + { "$ref": "#/$defs/shrinkingUnsupported" } + ] + } + } + }, + "shrinkingSupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { "type": "integer" } + } + }, + "shrinkingUnsupported": { + "type": "object", + "additionalProperties": false, + "properties": { + "unsupported": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "drive": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["disk", "raid", "multipath", "dasd"] }, + "vendor": { "type": "string" }, + "model": { "type": "string" }, + "transport": { "type": "string" }, + "bus": { "type": "string" }, + "busId": { "type": "string" }, + "driver": { + "type": "array", + "items": { "type": "string" } + }, + "info": { "$ref": "#/$defs/driveInfo" } + } + }, + "driveInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "sdCard": { "type": "boolean" }, + "dellBoss": { "type": "boolean" } + } + }, + "filesystem": { + "type": "object", + "additionalProperties": false, + "required": ["sid", "type"], + "properties": { + "sid": { "type": "integer" }, + "type": { "$ref": "#/$defs/filesystemType" }, + "mountPath": { "type": "string" }, + "label": { "type": "string" } + } + }, + "filesystemType": { + "enum": [ + "bcachefs", + "btrfs", + "exfat", + "ext2", + "ext3", + "ext4", + "f2fs", + "jfs", + "nfs", + "nilfs2", + "ntfs", + "reiserfs", + "swap", + "tmpfs", + "vfat", + "xfs" + ] + }, + "md": { + "type": "object", + "additionalProperties": false, + "required": ["level", "devices"], + "properties": { + "level": { "$ref": "#/$defs/mdRaidLevel" }, + "devices": { + "type": "array", + "items": { "type": "integer" } + }, + "uuid": { "type": "string" } + } + }, + "mdRaidLevel": { + "title": "MD level", + "enum": [ + "raid0", + "raid1", + "raid5", + "raid6", + "raid10" + ] + }, + "multipath": { + "type": "object", + "additionalProperties": false, + "required": ["wireNames"], + "properties": { + "wireNames": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "partitionTable": { + "type": "object", + "additionalProperties": false, + "required": ["type", "unusedSlots"], + "properties": { + "type": { "$ref": "#/$defs/ptableType" }, + "unusedSlots": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" } + } + } + } + }, + "ptableType": { + "enum": ["gpt", "msdos", "dasd"] + }, + "partition": { + "type": "object", + "additionalProperties": false, + "required": ["efi"], + "properties": { + "efi": { "type": "boolean" } + } + }, + "volumeGroup": { + "type": "object", + "additionalProperties": false, + "required": ["size", "physicalVolumes"], + "properties": { + "size": { "type": "integer" }, + "physicalVolumes": { + "type": "array", + "items": { "type": "integer" } + } + } + } + } +} diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json index 9cede24ee7..96020738f2 100644 --- a/rust/share/system.storage.schema.json +++ b/rust/share/system.storage.schema.json @@ -1,4 +1,6 @@ { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/system.storage.schema.json", "title": "System", "description": "API description of the system", "type": "object", @@ -7,7 +9,7 @@ "devices": { "description": "All relevant devices on the system", "type": "array", - "items": { "$ref": "#/$defs/device" } + "items": { "$ref": "device.storage.schema.json" } }, "availableDrives": { "description": "SIDs of the available drives", @@ -50,206 +52,6 @@ } }, "$defs": { - "device": { - "type": "object", - "additionalProperties": false, - "required": ["sid", "name"], - "properties": { - "sid": { "type": "integer" }, - "name": { "type": "string" }, - "description": { "type": "string" }, - "block": { "$ref": "#/$defs/block" }, - "drive": { "$ref": "#/$defs/drive" }, - "filesystem": { "$ref": "#/$defs/filesystem" }, - "md": { "$ref": "#/$defs/md" }, - "multipath": { "$ref": "#/$defs/multipath" }, - "partitionTable": { "$ref": "#/$defs/partitionTable" }, - "partition": { "$ref": "#/$defs/partition" }, - "partitions": { - "type": "array", - "items": { "$ref": "#/$defs/device" } - }, - "volumeGroup": { "$ref": "#/$defs/volumeGroup" }, - "logicalVolumes": { - "type": "array", - "items": { "$ref": "#/$defs/device" } - } - } - }, - "block": { - "type": "object", - "additionalProperties": false, - "required": ["start", "size", "shrinking"], - "properties": { - "start": { "type": "integer" }, - "size": { "type": "integer" }, - "active": { "type": "boolean" }, - "encrypted": { "type": "boolean" }, - "udevIds": { - "type": "array", - "items": { "type": "string" } - }, - "udevPaths": { - "type": "array", - "items": { "type": "string" } - }, - "systems": { - "type": "array", - "items": { "type": "string" } - }, - "shrinking": { - "anyOf": [ - { "$ref": "#/$defs/shrinkingSupported" }, - { "$ref": "#/$defs/shrinkingUnsupported" } - ] - } - } - }, - "shrinkingSupported": { - "type": "object", - "additionalProperties": false, - "properties": { - "supported": { "type": "integer" } - } - }, - "shrinkingUnsupported": { - "type": "object", - "additionalProperties": false, - "properties": { - "unsupported": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "drive": { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { "enum": ["disk", "raid", "multipath", "dasd"] }, - "vendor": { "type": "string" }, - "model": { "type": "string" }, - "transport": { "type": "string" }, - "bus": { "type": "string" }, - "busId": { "type": "string" }, - "driver": { - "type": "array", - "items": { "type": "string" } - }, - "info": { "$ref": "#/$defs/driveInfo" } - } - }, - "driveInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "sdCard": { "type": "boolean" }, - "dellBoss": { "type": "boolean" } - } - }, - "filesystem": { - "type": "object", - "additionalProperties": false, - "required": ["sid", "type"], - "properties": { - "sid": { "type": "integer" }, - "type": { "$ref": "#/$defs/filesystemType" }, - "mountPath": { "type": "string" }, - "label": { "type": "string" } - } - }, - "filesystemType": { - "enum": [ - "bcachefs", - "btrfs", - "exfat", - "ext2", - "ext3", - "ext4", - "f2fs", - "jfs", - "nfs", - "nilfs2", - "ntfs", - "reiserfs", - "swap", - "tmpfs", - "vfat", - "xfs" - ] - }, - "md": { - "type": "object", - "additionalProperties": false, - "required": ["level", "devices"], - "properties": { - "level": { "$ref": "#/$defs/mdRaidLevel" }, - "devices": { - "type": "array", - "items": { "type": "integer" } - }, - "uuid": { "type": "string" } - } - }, - "mdRaidLevel": { - "title": "MD level", - "enum": [ - "raid0", - "raid1", - "raid5", - "raid6", - "raid10" - ] - }, - "multipath": { - "type": "object", - "additionalProperties": false, - "required": ["wireNames"], - "properties": { - "wireNames": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "partitionTable": { - "type": "object", - "additionalProperties": false, - "required": ["type", "unusedSlots"], - "properties": { - "type": { "$ref": "#/$defs/ptableType" }, - "unusedSlots": { - "type": "array", - "items": { - "type": "array", - "items": { "type": "integer" } - } - } - } - }, - "ptableType": { - "enum": ["gpt", "msdos", "dasd"] - }, - "partition": { - "type": "object", - "additionalProperties": false, - "required": ["efi"], - "properties": { - "efi": { "type": "boolean" } - } - }, - "volumeGroup": { - "type": "object", - "additionalProperties": false, - "required": ["size", "physicalVolumes"], - "properties": { - "size": { "type": "integer" }, - "physicalVolumes": { - "type": "array", - "items": { "type": "integer" } - } - } - }, "volume": { "type": "object", "additionalProperties": false, @@ -260,7 +62,7 @@ "type": "array", "items": { "type": "string" } }, - "fsType": { "$ref": "#/$defs/filesystemType" }, + "fsType": { "$ref": "device.storage.schema.json#/$defs/filesystemType" }, "autoSize": { "type": "boolean" }, "minSize": { "type": "integer" }, "maxSize": { "type": "integer" }, @@ -278,7 +80,7 @@ "supportAutoSize": { "type": "boolean" }, "fsTypes": { "type": "array", - "items": { "$ref": "#/$defs/filesystemType" } + "items": { "$ref": "device.storage.schema.json#/$defs/filesystemType" } }, "adjustByRam": { "type": "boolean" }, "snapshotsConfigurable": { "type": "boolean" }, From a3e1382bb3ff91323b5f20beec0eddd96e362f51 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 5 Nov 2025 11:27:52 +0000 Subject: [PATCH 345/917] web: Update and relocate system.ts types definition --- web/src/api/storage/{types => }/system.ts | 86 ++++++++++++++++------- web/src/api/storage/types.ts | 1 - 2 files changed, 60 insertions(+), 27 deletions(-) rename web/src/api/storage/{types => }/system.ts (76%) diff --git a/web/src/api/storage/types/system.ts b/web/src/api/storage/system.ts similarity index 76% rename from web/src/api/storage/types/system.ts rename to web/src/api/storage/system.ts index f202030aa3..f219eac34c 100644 --- a/web/src/api/storage/types/system.ts +++ b/web/src/api/storage/system.ts @@ -5,25 +5,7 @@ * and run json-schema-to-typescript to regenerate this file. */ -export type FilesystemType = - | "bcachefs" - | "btrfs" - | "exfat" - | "ext2" - | "ext3" - | "ext4" - | "f2fs" - | "jfs" - | "nfs" - | "nilfs2" - | "ntfs" - | "reiserfs" - | "swap" - | "tmpfs" - | "vfat" - | "xfs"; export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; -export type PtableType = "gpt" | "msdos" | "dasd"; /** * API description of the system @@ -32,7 +14,7 @@ export interface System { /** * All relevant devices on the system */ - devices?: Device[]; + devices?: StorageDevice[]; /** * SIDs of the available drives */ @@ -63,7 +45,10 @@ export interface System { volumeTemplates?: Volume[]; issues?: Issue[]; } -export interface Device { +/** + * Schema to describe a device both in 'system' and 'proposal'. + */ +export interface StorageDevice { sid: number; name: string; description?: string; @@ -74,9 +59,9 @@ export interface Device { multipath?: Multipath; partitionTable?: PartitionTable; partition?: Partition; - partitions?: Device[]; + partitions?: StorageDevice[]; volumeGroup?: VolumeGroup; - logicalVolumes?: Device[]; + logicalVolumes?: StorageDevice[]; } export interface Block { start: number; @@ -110,7 +95,23 @@ export interface DriveInfo { } export interface Filesystem { sid: number; - type: FilesystemType; + type: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; mountPath?: string; label?: string; } @@ -123,7 +124,7 @@ export interface Multipath { wireNames: string[]; } export interface PartitionTable { - type: PtableType; + type: "gpt" | "msdos" | "dasd"; unusedSlots: number[][]; } export interface Partition { @@ -136,7 +137,23 @@ export interface VolumeGroup { export interface Volume { mountPath: string; mountOptions?: string[]; - fsType?: FilesystemType; + fsType?: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; autoSize: boolean; minSize: number; maxSize?: number; @@ -147,7 +164,24 @@ export interface Volume { export interface VolumeOutline { required: boolean; supportAutoSize: boolean; - fsTypes?: FilesystemType[]; + fsTypes?: ( + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs" + )[]; adjustByRam?: boolean; snapshotsConfigurable?: boolean; snapshotsAffectSizes?: boolean; diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index faaf3a1a8c..09ad88d304 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -23,4 +23,3 @@ export * from "./types/openapi"; export * as config from "./types/config"; export * as apiModel from "./types/model"; -export * as system from "./types/system"; From 9516fae17b320d693857f62b73cc352da0d388e6 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 5 Nov 2025 11:48:50 +0000 Subject: [PATCH 346/917] First version of the JSON schema for proposal/storage --- rust/share/proposal.storage.schema.json | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 rust/share/proposal.storage.schema.json diff --git a/rust/share/proposal.storage.schema.json b/rust/share/proposal.storage.schema.json new file mode 100644 index 0000000000..876c9c0ea6 --- /dev/null +++ b/rust/share/proposal.storage.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/proposal.storage.schema.json", + "title": "Proposal", + "description": "API description of the storage proposal.", + "type": "object", + "additionalProperties": false, + "properties": { + "devices": { + "description": "Expected layout of the system after the commit phase.", + "type": "array", + "items": { "$ref": "device.storage.schema.json" } + }, + "actions": { + "description": "Sorted list of actions to execute during the commit phase.", + "type": "array", + "items": { "$ref": "#/$defs/action" } + } + }, + "$defs": { + "action": { + "type": "object", + "additionalProperties": false, + "required": ["device", "text"], + "properties": { + "device": { "type": "integer" }, + "text": { "type": "string" }, + "subvol": { "type": "boolean" }, + "delete": { "type": "boolean" }, + "resize": { "type": "boolean" } + } + } + } +} From 9ffd71e74c62ea58f1e0933fc9b9208b9792293b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 5 Nov 2025 14:56:04 +0000 Subject: [PATCH 347/917] web: Types based on the storage proposal schema --- web/src/api/storage/proposal.ts | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 web/src/api/storage/proposal.ts diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts new file mode 100644 index 0000000000..0973d1931d --- /dev/null +++ b/web/src/api/storage/proposal.ts @@ -0,0 +1,118 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; + +/** + * API description of the storage proposal. + */ +export interface Proposal { + /** + * Expected layout of the system after the commit phase. + */ + devices?: StorageDevice[]; + /** + * Sorted list of actions to execute during the commit phase. + */ + actions?: Action[]; +} +/** + * Schema to describe a device both in 'system' and 'proposal'. + */ +export interface StorageDevice { + sid: number; + name: string; + description?: string; + block?: Block; + drive?: Drive; + filesystem?: Filesystem; + md?: Md; + multipath?: Multipath; + partitionTable?: PartitionTable; + partition?: Partition; + partitions?: StorageDevice[]; + volumeGroup?: VolumeGroup; + logicalVolumes?: StorageDevice[]; +} +export interface Block { + start: number; + size: number; + active?: boolean; + encrypted?: boolean; + udevIds?: string[]; + udevPaths?: string[]; + systems?: string[]; + shrinking: ShrinkingSupported | ShrinkingUnsupported; +} +export interface ShrinkingSupported { + supported?: number; +} +export interface ShrinkingUnsupported { + unsupported?: string[]; +} +export interface Drive { + type?: "disk" | "raid" | "multipath" | "dasd"; + vendor?: string; + model?: string; + transport?: string; + bus?: string; + busId?: string; + driver?: string[]; + info?: DriveInfo; +} +export interface DriveInfo { + sdCard?: boolean; + dellBoss?: boolean; +} +export interface Filesystem { + sid: number; + type: + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; + mountPath?: string; + label?: string; +} +export interface Md { + level: MDLevel; + devices: number[]; + uuid?: string; +} +export interface Multipath { + wireNames: string[]; +} +export interface PartitionTable { + type: "gpt" | "msdos" | "dasd"; + unusedSlots: number[][]; +} +export interface Partition { + efi: boolean; +} +export interface VolumeGroup { + size: number; + physicalVolumes: number[]; +} +export interface Action { + device: number; + text: string; + subvol?: boolean; + delete?: boolean; + resize?: boolean; +} From f978ce3e9639c7fd21340d908a9411e10223a33b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Fri, 7 Nov 2025 09:17:07 +0000 Subject: [PATCH 348/917] Make encryptionMethod an enum in the schema for system/storage --- rust/share/system.storage.schema.json | 6 +++++- web/src/api/storage/system.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json index 96020738f2..7d7e30fe5c 100644 --- a/rust/share/system.storage.schema.json +++ b/rust/share/system.storage.schema.json @@ -39,7 +39,11 @@ "encryptionMethods": { "description": "Possible encryption methods for the current system and product", "type": "array", - "items": { "type": "string" } + "items": { + "enum": [ + "luks1", "luks2", "pervasiveLuks2", "tmpFde", "protectedSwap", "secureSwap", "randomSwap" + ] + } }, "volumeTemplates": { "description": "Volumes defined by the product as templates", diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index f219eac34c..88078a978e 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -38,7 +38,15 @@ export interface System { /** * Possible encryption methods for the current system and product */ - encryptionMethods?: string[]; + encryptionMethods?: ( + | "luks1" + | "luks2" + | "pervasiveLuks2" + | "tmpFde" + | "protectedSwap" + | "secureSwap" + | "randomSwap" + )[]; /** * Volumes defined by the product as templates */ From d76f36da2e98b668e612dd08a39584f14d14926f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 7 Nov 2025 15:04:21 +0000 Subject: [PATCH 349/917] Fix Ruby manager tests --- service/lib/agama/http/clients/software.rb | 6 +++++- service/lib/agama/manager.rb | 2 +- service/test/agama/manager_test.rb | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/http/clients/software.rb b/service/lib/agama/http/clients/software.rb index 5b6cc26539..caa1ba7b18 100644 --- a/service/lib/agama/http/clients/software.rb +++ b/service/lib/agama/http/clients/software.rb @@ -64,7 +64,11 @@ def locale=(value) end def config - JSON.parse(get("software/config")) + JSON.parse(get("v2/config")) + end + + def selected_product + config.dig("product", "id") end def errors? diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index aca1603cfb..9f69447aa0 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -83,7 +83,7 @@ def startup_phase installation_phase.startup # FIXME: hot-fix for decision taken at bsc#1224868 (RC1) network.startup - config_phase if software.config["product"] + config_phase if software.config.dig("product", "id") logger.info("Startup phase done") service_status.idle diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 376c56a2a5..539ac8e2b9 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -45,7 +45,8 @@ instance_double( Agama::HTTP::Clients::Software, probe: nil, install: nil, propose: nil, finish: nil, - config: { "product" => product }, errors?: false + config: { "product" => { "id" => product } }, errors?: false, + selected_product: product ) end let(:users) do From f3c634b6cb021c6202cc949f4d5d55a96784cf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 5 Nov 2025 15:49:40 +0000 Subject: [PATCH 350/917] Add solve_storage_model endpoint --- rust/agama-manager/src/message.rs | 15 +++++++++++++ rust/agama-manager/src/service.rs | 14 ++++++++++++ rust/agama-server/src/server/web.rs | 27 +++++++++++++++++++++-- rust/agama-server/src/web/docs/config.rs | 1 + rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/query.rs | 28 ++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 rust/agama-utils/src/api/query.rs diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 83676f56a6..ed78bc03f5 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -141,3 +141,18 @@ impl SetStorageModel { impl Message for SetStorageModel { type Reply = (); } + +#[derive(Clone)] +pub struct SolveStorageModel { + pub model: Value, +} + +impl SolveStorageModel { + pub fn new(model: Value) -> Self { + Self { model } + } +} + +impl Message for SolveStorageModel { + type Reply = Option; +} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 29e03e6dfc..8b6c37120a 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -291,3 +291,17 @@ impl MessageHandler for Service { .await?) } } + +#[async_trait] +impl MessageHandler for Service { + /// It solves the storage model. + async fn handle( + &mut self, + message: message::SolveStorageModel, + ) -> Result, Error> { + Ok(self + .storage + .call(storage::message::SolveConfigModel::new(message.model)) + .await?) + } +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 389b686cc7..2c7dcc8de5 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -26,14 +26,14 @@ use agama_manager::{self as manager, message}; use agama_utils::{ actor::Handler, api::{ - event, + event, query, question::{Question, QuestionSpec, UpdateQuestion}, Action, Config, IssueMap, Patch, Status, SystemInfo, }, question, }; use axum::{ - extract::State, + extract::{Query, State}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, @@ -109,6 +109,7 @@ pub async fn server_service( "/private/storage_model", get(get_storage_model).put(set_storage_model), ) + .route("/private/solve_storage_model", get(solve_storage_model)) .with_state(state)) } @@ -378,6 +379,28 @@ async fn set_storage_model( Ok(()) } +/// Solves a storage config model. +#[utoipa::path( + get, + path = "/private/solve_storage_model", + context_path = "/api/v2", + params(query::SolveStorageModel), + responses( + (status = 200, description = "Solve the storage model", body = String), + (status = 400, description = "Not possible to solve the storage model") + ) +)] +async fn solve_storage_model( + State(state): State, + Query(params): Query, +) -> Result>, Error> { + let solved_model = state + .manager + .call(message::SolveStorageModel::new(params.model)) + .await?; + Ok(Json(solved_model)) +} + fn to_option_response(value: Option) -> Response { match value { Some(inner) => Json(inner).into_response(), diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ef0c136a18..1b218aa7f1 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -182,6 +182,7 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() .schema_from::() .build() } diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 89ccd79dcc..567e6807d9 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -52,5 +52,6 @@ mod action; pub use action::Action; pub mod l10n; +pub mod query; pub mod question; pub mod storage; diff --git a/rust/agama-utils/src/api/query.rs b/rust/agama-utils/src/api/query.rs new file mode 100644 index 0000000000..7bcadf2e8b --- /dev/null +++ b/rust/agama-utils/src/api/query.rs @@ -0,0 +1,28 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)] +pub struct SolveStorageModel { + /// Serialized storage model. + pub model: Value, +} From 0dc8bc97b46282df95e40e1f15fdff27cfefe9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Nov 2025 15:06:00 +0000 Subject: [PATCH 351/917] Initial adaptation to the new API --- web/src/App.tsx | 5 +- web/src/api.ts | 105 +++++ web/src/api/{api.ts => action.ts} | 44 +- web/src/{types => api}/config.ts | 5 +- web/src/api/hostname.ts | 2 +- web/src/{types/system.ts => api/issue.ts} | 8 +- web/src/api/issues.ts | 2 +- web/src/{types => api/l10n}/proposal.ts | 6 +- web/src/{types/l10n.ts => api/l10n/system.ts} | 25 +- web/src/api/manager.ts | 2 +- web/src/api/network.ts | 2 +- web/src/api/progress.ts | 2 +- web/src/api/proposal.ts | 32 ++ web/src/api/{storage/types.ts => question.ts} | 8 +- web/src/api/questions.ts | 2 +- web/src/api/software.ts | 2 +- web/src/api/status.ts | 7 +- web/src/api/storage.ts | 92 +--- web/src/api/storage/{types => }/config.ts | 0 web/src/api/storage/dasd.ts | 2 +- web/src/api/storage/devices.ts | 2 +- web/src/api/storage/iscsi.ts | 2 +- web/src/api/storage/{types => }/model.ts | 0 web/src/api/storage/proposal.ts | 1 - web/src/api/storage/system.ts | 1 - web/src/api/storage/types/checks.ts | 2 +- web/src/api/storage/types/openapi.ts | 438 ------------------ web/src/api/storage/zfcp.ts | 2 +- web/src/api/system.ts | 32 ++ web/src/api/users.ts | 2 +- .../core/InstallationFinished.test.tsx | 2 +- .../components/core/InstallationFinished.tsx | 9 +- .../components/core/InstallerOptions.test.tsx | 2 +- web/src/components/core/InstallerOptions.tsx | 8 +- .../l10n/KeyboardSelection.test.tsx | 2 +- web/src/components/l10n/KeyboardSelection.tsx | 7 +- web/src/components/l10n/L10nPage.test.tsx | 5 +- web/src/components/l10n/L10nPage.tsx | 3 +- .../components/l10n/LocaleSelection.test.tsx | 2 +- web/src/components/l10n/LocaleSelection.tsx | 7 +- .../l10n/TimezoneSelection.test.tsx | 2 +- web/src/components/l10n/TimezoneSelection.tsx | 9 +- .../components/overview/L10nSection.test.tsx | 2 +- web/src/components/overview/L10nSection.tsx | 5 +- .../components/overview/StorageSection.tsx | 13 +- .../questions/LuksActivationQuestion.test.tsx | 2 +- .../questions/QuestionWithPassword.test.tsx | 2 +- web/src/components/storage/BootSelection.tsx | 5 +- web/src/components/storage/ConfigEditor.tsx | 7 +- .../components/storage/ConfigEditorMenu.tsx | 9 +- .../storage/EncryptionSettingsPage.tsx | 4 +- .../storage/FormattableDevicePage.tsx | 19 +- .../components/storage/LogicalVolumePage.tsx | 20 +- web/src/components/storage/PartitionPage.tsx | 28 +- web/src/components/storage/PartitionsMenu.tsx | 2 +- .../components/storage/ProposalFailedInfo.tsx | 4 +- web/src/components/storage/ProposalPage.tsx | 25 +- .../storage/ProposalResultSection.tsx | 3 +- .../storage/ProposalTransactionalInfo.tsx | 4 +- .../storage/SpacePolicySelection.tsx | 6 +- .../storage/UnsupportedModelInfo.tsx | 4 +- web/src/components/storage/utils.ts | 3 +- web/src/context/installerL10n.tsx | 41 +- web/src/helpers/l10n.ts | 30 ++ web/src/helpers/storage/system.ts | 36 ++ web/src/hooks/api.ts | 146 ++++++ web/src/hooks/l10n.ts | 39 ++ web/src/hooks/storage/api-model.ts | 61 --- web/src/hooks/storage/boot.ts | 18 +- web/src/hooks/storage/config.ts | 43 ++ web/src/hooks/storage/drive.ts | 18 +- web/src/hooks/storage/filesystem.ts | 13 +- web/src/hooks/storage/logical-volume.ts | 18 +- web/src/hooks/storage/md-raid.ts | 18 +- web/src/hooks/storage/model.ts | 40 +- web/src/hooks/storage/partition.ts | 18 +- web/src/hooks/storage/product.ts | 60 --- web/src/hooks/storage/proposal.ts | 39 ++ web/src/hooks/storage/space-policy.ts | 8 +- web/src/hooks/storage/system.ts | 216 ++++++--- web/src/hooks/storage/volume-group.ts | 23 +- web/src/{api => }/http.ts | 0 web/src/queries/proposal.ts | 57 --- web/src/queries/storage.ts | 299 ------------ web/src/queries/storage/config-model.ts | 37 +- web/src/queries/storage/dasd.ts | 6 +- web/src/queries/system.ts | 95 ---- web/src/test-utils.tsx | 19 +- 88 files changed, 931 insertions(+), 1527 deletions(-) create mode 100644 web/src/api.ts rename web/src/api/{api.ts => action.ts} (56%) rename web/src/{types => api}/config.ts (90%) rename web/src/{types/system.ts => api/issue.ts} (88%) rename web/src/{types => api/l10n}/proposal.ts (91%) rename web/src/{types/l10n.ts => api/l10n/system.ts} (76%) create mode 100644 web/src/api/proposal.ts rename web/src/api/{storage/types.ts => question.ts} (83%) rename web/src/api/storage/{types => }/config.ts (100%) rename web/src/api/storage/{types => }/model.ts (100%) delete mode 100644 web/src/api/storage/types/openapi.ts create mode 100644 web/src/api/system.ts create mode 100644 web/src/helpers/l10n.ts create mode 100644 web/src/helpers/storage/system.ts create mode 100644 web/src/hooks/api.ts create mode 100644 web/src/hooks/l10n.ts delete mode 100644 web/src/hooks/storage/api-model.ts create mode 100644 web/src/hooks/storage/config.ts delete mode 100644 web/src/hooks/storage/product.ts create mode 100644 web/src/hooks/storage/proposal.ts rename web/src/{api => }/http.ts (100%) delete mode 100644 web/src/queries/proposal.ts delete mode 100644 web/src/queries/storage.ts delete mode 100644 web/src/queries/system.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 258cf9883b..b6f4464d5a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,11 +24,9 @@ import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Loading } from "~/components/layout"; import { useProduct, useProductChanges } from "~/queries/software"; -import { useProposalChanges } from "~/queries/proposal"; -import { useSystemChanges } from "~/queries/system"; +import { useSystemChanges, useProposalChanges } from "~/hooks/api"; import { useIssuesChanges } from "~/queries/issues"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; -import { useDeprecatedChanges } from "~/queries/storage"; import { ROOT, PRODUCT } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; import { useQueryClient } from "@tanstack/react-query"; @@ -43,7 +41,6 @@ function App() { useProductChanges(); useIssuesChanges(); useInstallerStatusChanges(); - useDeprecatedChanges(); const location = useLocation(); const { isBusy, phase } = useInstallerStatus({ suspense: true }); diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000000..7c07e5f05c --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { get, patch, post, put } from "~/http"; +import { apiModel } from "~/api/storage"; +import { Config } from "~/api/config"; +import { Issue } from "~/api/issue"; +import { Proposal } from "~/api/proposal"; +import { Question } from "~/api/question"; +import { Status } from "~/api/status"; +import { System } from "~/api/system"; +import { + Action, + L10nSystemConfig, + configureL10n, + activateStorage, + probeStorage, +} from "~/api/action"; +import { AxiosResponse } from "axios"; +import { Job } from "~/types/job"; + +type Response = Promise; + +const getStatus = (): Promise => get("/api/v2/status"); + +const getConfig = (): Promise => get("/api/v2/config"); + +const getExtendedConfig = (): Promise => get("/api/v2/extended_config"); + +const getSystem = (): Promise => get("/api/v2/system"); + +const getProposal = (): Promise => get("/api/v2/proposal"); + +const getIssues = (): Promise => get("/api/v2/issues"); + +const getQuestions = (): Promise => get("/api/v2/questions"); + +const getStorageModel = (): Promise => get("/api/v2/private/storage_model"); + +const solveStorageModel = (model: apiModel.Config): Promise => { + const json = encodeURIComponent(JSON.stringify(model)); + return get(`/api/v2/private/solve_storage_model?model=${json}`); +}; + +const putConfig = (config: Config): Response => put("/api/v2/config", config); + +const putStorageModel = (model: apiModel.Config) => put("/api/v2/private/storage_model", model); + +const patchConfig = (config: Config) => patch("/api/v2/config", { update: config }); + +const postAction = (action: Action) => post("/api/v2/action", action); + +const configureL10nAction = (config: L10nSystemConfig) => postAction(configureL10n(config)); + +const activateStorageAction = () => postAction(activateStorage()); + +const probeStorageAction = () => postAction(probeStorage()); + +/** + * @todo Adapt jobs to the new API. + */ +const getStorageJobs = (): Promise => get("/api/storage/jobs"); + +export { + getStatus, + getConfig, + getExtendedConfig, + getSystem, + getProposal, + getIssues, + getQuestions, + getStorageModel, + solveStorageModel, + putConfig, + putStorageModel, + patchConfig, + configureL10nAction, + activateStorageAction, + probeStorageAction, + getStorageJobs, +}; + +export type { Response, System, Config, Proposal }; +export * as system from "~/api/system"; +export * as config from "~/api/config"; +export * as proposal from "~/api/proposal"; diff --git a/web/src/api/api.ts b/web/src/api/action.ts similarity index 56% rename from web/src/api/api.ts rename to web/src/api/action.ts index 87911ea4ec..5b36cfb563 100644 --- a/web/src/api/api.ts +++ b/web/src/api/action.ts @@ -20,28 +20,30 @@ * find current contact information at www.suse.com. */ -import { get, patch, post } from "~/api/http"; -import { Config } from "~/types/config"; -import { Proposal } from "~/types/proposal"; -import { System } from "~/types/system"; +type Action = ConfigureL10n | ActivateStorage | ProbeStorage; -/** - * Returns the system config - */ -const fetchSystem = (): Promise => get("/api/v2/system"); +type ConfigureL10n = { + configureL10n: L10nSystemConfig; +}; -/** - * Returns the proposal - */ -const fetchProposal = (): Promise => get("/api/v2/proposal"); +type L10nSystemConfig = { + locale?: string; + keymap?: string; +}; -/** - * Updates configuration - */ -const updateConfig = (config: Config) => patch("/api/v2/config", { update: config }); -/** - * Triggers an action - */ -const trigger = (action) => post("/api/v2/action", action); +type ActivateStorage = { + activateStorage: null; +}; + +type ProbeStorage = { + probeStorage: null; +}; + +const configureL10n = (config: L10nSystemConfig): ConfigureL10n => ({ configureL10n: config }); + +const activateStorage = (): ActivateStorage => ({ activateStorage: null }); + +const probeStorage = (): ProbeStorage => ({ probeStorage: null }); -export { fetchSystem, fetchProposal, updateConfig, trigger }; +export { configureL10n, activateStorage, probeStorage }; +export type { Action, L10nSystemConfig }; diff --git a/web/src/types/config.ts b/web/src/api/config.ts similarity index 90% rename from web/src/types/config.ts rename to web/src/api/config.ts index f7248c72cf..fc050d8899 100644 --- a/web/src/types/config.ts +++ b/web/src/api/config.ts @@ -20,10 +20,11 @@ * find current contact information at www.suse.com. */ -import { Localization } from "./l10n"; +import * as storage from "~/api/storage/config"; type Config = { - l10n?: Localization; + storage?: storage.Config; }; +export { storage }; export type { Config }; diff --git a/web/src/api/hostname.ts b/web/src/api/hostname.ts index dfac34b69e..902b160359 100644 --- a/web/src/api/hostname.ts +++ b/web/src/api/hostname.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get, put } from "~/api/http"; +import { get, put } from "~/http"; import { Hostname } from "~/types/hostname"; /** diff --git a/web/src/types/system.ts b/web/src/api/issue.ts similarity index 88% rename from web/src/types/system.ts rename to web/src/api/issue.ts index 60fb1f35c1..41d5691f46 100644 --- a/web/src/types/system.ts +++ b/web/src/api/issue.ts @@ -20,10 +20,6 @@ * find current contact information at www.suse.com. */ -import { Localization } from "./l10n"; +type Issue = object; -type System = { - l10n?: Localization; -}; - -export type { System }; +export type { Issue }; diff --git a/web/src/api/issues.ts b/web/src/api/issues.ts index fee355836f..888febd137 100644 --- a/web/src/api/issues.ts +++ b/web/src/api/issues.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get } from "~/api/http"; +import { get } from "~/http"; import { Issue, IssuesMap, IssuesScope } from "~/types/issues"; /** diff --git a/web/src/types/proposal.ts b/web/src/api/l10n/proposal.ts similarity index 91% rename from web/src/types/proposal.ts rename to web/src/api/l10n/proposal.ts index 1eacf176f9..e7ab01974f 100644 --- a/web/src/types/proposal.ts +++ b/web/src/api/l10n/proposal.ts @@ -20,10 +20,6 @@ * find current contact information at www.suse.com. */ -import { Localization } from "./l10n"; - -type Proposal = { - l10n?: Localization; -}; +type Proposal = object; export type { Proposal }; diff --git a/web/src/types/l10n.ts b/web/src/api/l10n/system.ts similarity index 76% rename from web/src/types/l10n.ts rename to web/src/api/l10n/system.ts index fbc9bc4c24..98cfde5a4b 100644 --- a/web/src/types/l10n.ts +++ b/web/src/api/l10n/system.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2025] SUSE LLC * * All Rights Reserved. * @@ -65,7 +65,7 @@ type Timezone = { utcOffset: number; }; -type Localization = { +type System = { locales?: Locale[]; keymaps?: Keymap[]; timezones?: Timezone[]; @@ -74,23 +74,4 @@ type Localization = { timezone?: string; }; -type LocaleConfig = { - /** - * Selected locale for installation (e.g, "en_US.UTF-8") - */ - locale?: string; - /** - * List of locales to install (e.g., ["en_US.UTF-8"]). - */ - locales?: string[]; - /** - * Selected keymap for installation (e.g., "en"). - */ - keymap?: string; - /** - * Selected timezone for installation (e.g., "Atlantic/Canary"). - */ - timezone?: string; -}; - -export type { Keymap, Locale, Timezone, LocaleConfig, Localization }; +export type { System, Keymap, Locale, Timezone }; diff --git a/web/src/api/manager.ts b/web/src/api/manager.ts index d02d1f6d83..61653c8408 100644 --- a/web/src/api/manager.ts +++ b/web/src/api/manager.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get, post } from "~/api/http"; +import { get, post } from "~/http"; /** * Starts the probing process. diff --git a/web/src/api/network.ts b/web/src/api/network.ts index ec919ca3d9..a6fd2b9fe1 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { del, get, post, put } from "~/api/http"; +import { del, get, post, put } from "~/http"; import { APIAccessPoint, APIConnection, APIDevice, NetworkGeneralState } from "~/types/network"; /** diff --git a/web/src/api/progress.ts b/web/src/api/progress.ts index e94538a6fe..d62095f0e4 100644 --- a/web/src/api/progress.ts +++ b/web/src/api/progress.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get } from "~/api/http"; +import { get } from "~/http"; import { APIProgress, Progress } from "~/types/progress"; /** diff --git a/web/src/api/proposal.ts b/web/src/api/proposal.ts new file mode 100644 index 0000000000..9e7fd99496 --- /dev/null +++ b/web/src/api/proposal.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import * as l10n from "~/api/l10n/proposal"; +import * as storage from "~/api/storage/proposal"; + +type Proposal = { + l10n?: l10n.Proposal; + storage?: storage.Proposal; +}; + +export { l10n, storage }; +export type { Proposal }; diff --git a/web/src/api/storage/types.ts b/web/src/api/question.ts similarity index 83% rename from web/src/api/storage/types.ts rename to web/src/api/question.ts index 09ad88d304..57db1df22b 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/question.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024-2025] SUSE LLC + * Copyright (c) [2025] SUSE LLC * * All Rights Reserved. * @@ -20,6 +20,6 @@ * find current contact information at www.suse.com. */ -export * from "./types/openapi"; -export * as config from "./types/config"; -export * as apiModel from "./types/model"; +type Question = object; + +export type { Question }; diff --git a/web/src/api/questions.ts b/web/src/api/questions.ts index 80680a3bf7..4b78e0232e 100644 --- a/web/src/api/questions.ts +++ b/web/src/api/questions.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get, patch } from "~/api/http"; +import { get, patch } from "~/http"; import { Question } from "~/types/questions"; /** diff --git a/web/src/api/software.ts b/web/src/api/software.ts index ca9efc52dc..0f6210fed6 100644 --- a/web/src/api/software.ts +++ b/web/src/api/software.ts @@ -34,7 +34,7 @@ import { SoftwareConfig, SoftwareProposal, } from "~/types/software"; -import { get, patch, post, put } from "~/api/http"; +import { get, patch, post, put } from "~/http"; /** * Returns the software configuration diff --git a/web/src/api/status.ts b/web/src/api/status.ts index dd4931b481..f7d0f7c65e 100644 --- a/web/src/api/status.ts +++ b/web/src/api/status.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get } from "~/api/http"; +import { get } from "~/http"; import { InstallerStatus } from "~/types/status"; /** @@ -31,4 +31,9 @@ const fetchInstallerStatus = async (): Promise => { return { phase, isBusy, useIguana, canInstall }; }; +// TODO: remove export { fetchInstallerStatus }; + +type Status = object; + +export type { Status }; diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 76bc8cd2d7..dccba2ae09 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024-2025] SUSE LLC + * Copyright (c) [2025] SUSE LLC * * All Rights Reserved. * @@ -20,90 +20,6 @@ * find current contact information at www.suse.com. */ -import { get, post, put } from "~/api/http"; -import { Job } from "~/types/job"; -import { Action, config, apiModel, ProductParams, Volume } from "~/api/storage/types"; - -/** - * Starts the storage probing process. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const probe = (): Promise => post("/api/storage/probe"); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const reprobe = (): Promise => post("/api/storage/reprobe"); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const reactivate = (): Promise => post("/api/storage/reactivate"); - -const fetchConfig = (): Promise => - get("/api/storage/config").then((config) => config.storage ?? null); - -const fetchConfigModel = (): Promise => - get("/api/storage/config_model"); - -const setConfig = (config: config.Config) => put("/api/storage/config", { storage: config }); - -const resetConfig = () => put("/api/storage/config/reset", {}); - -const setConfigModel = (model: apiModel.Config) => put("/api/storage/config_model", model); - -const solveConfigModel = (model: apiModel.Config): Promise => { - const serializedModel = encodeURIComponent(JSON.stringify(model)); - return get(`/api/storage/config_model/solve?model=${serializedModel}`); -}; - -const fetchAvailableDrives = (): Promise => get(`/api/storage/devices/available_drives`); - -const fetchCandidateDrives = (): Promise => get(`/api/storage/devices/candidate_drives`); - -const fetchAvailableMdRaids = (): Promise => - get(`/api/storage/devices/available_md_raids`); - -const fetchCandidateMdRaids = (): Promise => - get(`/api/storage/devices/candidate_md_raids`); - -const fetchProductParams = (): Promise => get("/api/storage/product/params"); - -const fetchVolume = (mountPath: string): Promise => { - const path = encodeURIComponent(mountPath); - return get(`/api/storage/product/volume_for?mount_path=${path}`); -}; - -const fetchVolumes = (mountPaths: string[]): Promise => - Promise.all(mountPaths.map(fetchVolume)); - -const fetchActions = (): Promise => get("/api/storage/devices/actions"); - -/** - * Returns the list of jobs - */ -const fetchStorageJobs = (): Promise => get("/api/storage/jobs"); - -/** - * Returns the job with given id or undefined - */ -const findStorageJob = (id: string): Promise => - fetchStorageJobs().then((jobs: Job[]) => jobs.find((value) => value.id === id)); - -export { - probe, - reprobe, - reactivate, - fetchConfig, - fetchConfigModel, - setConfig, - resetConfig, - setConfigModel, - solveConfigModel, - fetchAvailableDrives, - fetchCandidateDrives, - fetchAvailableMdRaids, - fetchCandidateMdRaids, - fetchProductParams, - fetchVolume, - fetchVolumes, - fetchActions, - fetchStorageJobs, - findStorageJob, -}; +export * as config from "~/api/storage/config"; +export * as apiModel from "~/api/storage/model"; +export * as system from "~/api/storage/system"; diff --git a/web/src/api/storage/types/config.ts b/web/src/api/storage/config.ts similarity index 100% rename from web/src/api/storage/types/config.ts rename to web/src/api/storage/config.ts diff --git a/web/src/api/storage/dasd.ts b/web/src/api/storage/dasd.ts index b3962d26ec..26c5767a1e 100644 --- a/web/src/api/storage/dasd.ts +++ b/web/src/api/storage/dasd.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { post, get, put } from "~/api/http"; +import { post, get, put } from "~/http"; import { DASDDevice } from "~/types/dasd"; /** diff --git a/web/src/api/storage/devices.ts b/web/src/api/storage/devices.ts index ea1fbe4fe1..b6a6d5fd63 100644 --- a/web/src/api/storage/devices.ts +++ b/web/src/api/storage/devices.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { get } from "~/api/http"; +import { get } from "~/http"; import { Component, Device, diff --git a/web/src/api/storage/iscsi.ts b/web/src/api/storage/iscsi.ts index fb808a4187..6c10d4dfc1 100644 --- a/web/src/api/storage/iscsi.ts +++ b/web/src/api/storage/iscsi.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { del, get, patch, post } from "~/api/http"; +import { del, get, patch, post } from "~/http"; import { ISCSIInitiator, ISCSINode } from "~/api/storage/types"; const ISCSI_NODES_NAMESPACE = "/api/storage/iscsi/nodes"; diff --git a/web/src/api/storage/types/model.ts b/web/src/api/storage/model.ts similarity index 100% rename from web/src/api/storage/types/model.ts rename to web/src/api/storage/model.ts diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 0973d1931d..423e6b8bbc 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index 88078a978e..d71d396564 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, diff --git a/web/src/api/storage/types/checks.ts b/web/src/api/storage/types/checks.ts index 0b373aa1e3..cf3dab7393 100644 --- a/web/src/api/storage/types/checks.ts +++ b/web/src/api/storage/types/checks.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import * as config from "./config"; +import * as config from "../config"; // Type guards. diff --git a/web/src/api/storage/types/openapi.ts b/web/src/api/storage/types/openapi.ts deleted file mode 100644 index aee104ea76..0000000000 --- a/web/src/api/storage/types/openapi.ts +++ /dev/null @@ -1,438 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// This file is auto-generated by @hey-api/openapi-ts - -/** - * Represents a single change action done to storage - */ -export type Action = { - delete: boolean; - device: DeviceSid; - resize: boolean; - subvol: boolean; - text: string; -}; - -export type BlockDevice = { - active: boolean; - encrypted: boolean; - shrinking: ShrinkingInfo; - size: DeviceSize; - start: number; - systems: Array; - udevIds: Array; - udevPaths: Array; -}; - -export type Component = { - deviceNames: Array; - devices: Array; - type: string; -}; - -/** - * Information about system device created by composition to reflect different devices on system - */ -export type Device = { - blockDevice?: BlockDevice | null; - component?: Component | null; - deviceInfo: DeviceInfo; - drive?: Drive | null; - filesystem?: Filesystem | null; - lvmLv?: LvmLv | null; - lvmVg?: LvmVg | null; - md?: Md | null; - multipath?: Multipath | null; - partition?: Partition | null; - partitionTable?: PartitionTable | null; - raid?: Raid | null; -}; - -export type DeviceInfo = { - description: string; - name: string; - sid: DeviceSid; -}; - -export type DeviceSid = number; - -export type DeviceSize = number; - -export type DiscoverParams = { - /** - * iSCSI server address. - */ - address: string; - options?: ISCSIAuth; - /** - * iSCSI service port. - */ - port: number; -}; - -export type Drive = { - bus: string; - busId: string; - driver: Array; - info: DriveInfo; - model: string; - transport: string; - type: string; - vendor: string; -}; - -export type DriveInfo = { - dellBOSS: boolean; - sdCard: boolean; -}; - -export type Filesystem = { - label: string; - mountPath: string; - sid: DeviceSid; - type: string; -}; - -export type ISCSIAuth = { - /** - * Password for authentication by target. - */ - password?: string | null; - /** - * Password for authentication by initiator. - */ - reverse_password?: string | null; - /** - * Username for authentication by initiator. - */ - reverse_username?: string | null; - /** - * Username for authentication by target. - */ - username?: string | null; -}; - -export type ISCSIInitiator = { - ibft: boolean; - name: string; -}; - -/** - * ISCSI node - */ -export type ISCSINode = { - /** - * Target IP address (in string-like form). - */ - address: string; - /** - * Whether the node is connected (there is a session). - */ - connected: boolean; - /** - * Whether the node was initiated by iBFT - */ - ibft: boolean; - /** - * Artificial ID to match it against the D-Bus backend. - */ - id: number; - /** - * Interface name. - */ - interface: string; - /** - * Target port. - */ - port: number; - /** - * Startup status (TODO: document better) - */ - startup: string; - /** - * Target name. - */ - target: string; -}; - -export type InitiatorParams = { - /** - * iSCSI initiator name. - */ - name: string; -}; - -export type LoginParams = ISCSIAuth & { - /** - * Startup value. - */ - startup: string; -}; - -export type LoginResult = "Success" | "InvalidStartup" | "Failed"; - -export type LvmLv = { - volumeGroup: DeviceSid; -}; - -export type LvmVg = { - logicalVolumes: Array; - physicalVolumes: Array; - size: DeviceSize; -}; - -export type Md = { - devices: Array; - level: string; - uuid: string; -}; - -export type Multipath = { - wires: Array; -}; - -export type NodeParams = { - /** - * Startup value. - */ - startup: string; -}; - -export type Partition = { - device: DeviceSid; - efi: boolean; -}; - -export type PartitionTable = { - partitions: Array; - type: string; - unusedSlots: Array; -}; - -export type PingResponse = { - /** - * API status - */ - status: string; -}; - -export type ProductParams = { - /** - * Encryption methods allowed by the product. - */ - encryptionMethods: Array; - /** - * Mount points defined by the product. - */ - mountPoints: Array; -}; - -/** - * Represents a proposal configuration - */ -export type ProposalSettings = { - bootDevice: string; - configureBoot: boolean; - defaultBootDevice: string; - encryptionMethod: string; - encryptionPBKDFunction: string; - encryptionPassword: string; - spaceActions: Array; - spacePolicy: string; - target: ProposalTarget; - targetDevice?: string | null; - targetPVDevices?: Array | null; - volumes: Array; -}; - -/** - * Represents a proposal patch -> change of proposal configuration that can be partial - */ -export type ProposalSettingsPatch = { - bootDevice?: string | null; - configureBoot?: boolean | null; - encryptionMethod?: string | null; - encryptionPBKDFunction?: string | null; - encryptionPassword?: string | null; - spaceActions?: Array | null; - spacePolicy?: string | null; - target?: ProposalTarget | null; - targetDevice?: string | null; - targetPVDevices?: Array | null; - volumes?: Array | null; -}; - -export type ProposalTarget = "disk" | "newLvmVg" | "reusedLvmVg"; - -export type Raid = { - devices: Array; -}; - -export type ShrinkingInfo = - | { - supported: DeviceSize; - } - | { - unsupported: Array; - }; - -export type SpaceAction = "force_delete" | "resize" | "keep"; - -export type SpaceActionSettings = { - action: SpaceAction; - device: string; -}; - -export type UnusedSlot = { - size: DeviceSize; - start: number; -}; - -/** - * Represents a single volume - */ -export type Volume = { - autoSize: boolean; - fsType: string; - maxSize?: DeviceSize | null; - minSize?: DeviceSize | null; - mountOptions: Array; - mountPath: string; - outline?: VolumeOutline | null; - snapshots: boolean; - target: VolumeTarget; - targetDevice?: string | null; - transactional?: boolean | null; -}; - -/** - * Represents volume outline aka requirements for volume - */ -export type VolumeOutline = { - adjustByRam: boolean; - fsTypes: Array; - /** - * whether it is required - */ - required: boolean; - sizeRelevantVolumes: Array; - snapshotsAffectSizes: boolean; - snapshotsConfigurable: boolean; - supportAutoSize: boolean; -}; - -/** - * Represents value for target key of Volume - * It is snake cased when serializing to be compatible with yast2-storage-ng. - */ -export type VolumeTarget = "default" | "new_partition" | "new_vg" | "device" | "filesystem"; - -export type DevicesDirtyResponse = boolean; - -export type StagingDevicesResponse = Array; - -export type SystemDevicesResponse = Array; - -export type DiscoverData = { - requestBody: DiscoverParams; -}; - -export type DiscoverResponse = void; - -export type InitiatorResponse = ISCSIInitiator; - -export type UpdateInitiatorData = { - requestBody: InitiatorParams; -}; - -export type UpdateInitiatorResponse = void; - -export type NodesResponse = Array; - -export type UpdateNodeData = { - /** - * iSCSI artificial ID. - */ - id: number; - requestBody: NodeParams; -}; - -export type UpdateNodeResponse = NodeParams; - -export type DeleteNodeData = { - /** - * iSCSI artificial ID. - */ - id: number; -}; - -export type DeleteNodeResponse = void; - -export type LoginNodeData = { - /** - * iSCSI artificial ID. - */ - id: number; - requestBody: LoginParams; -}; - -export type LoginNodeResponse = void; - -export type LogoutNodeData = { - /** - * iSCSI artificial ID. - */ - id: number; -}; - -export type LogoutNodeResponse = void; - -export type StorageProbeResponse = unknown; - -export type ProductParamsResponse = ProductParams; - -export type VolumeForData = { - /** - * Mount path of the volume (empty for an arbitrary volume). - */ - mountPath: string; -}; - -export type VolumeForResponse = Volume; - -export type ActionsResponse = Array; - -export type GetProposalSettingsResponse = ProposalSettings; - -export type SetProposalSettingsData = { - /** - * Proposal settings - */ - requestBody: ProposalSettingsPatch; -}; - -export type SetProposalSettingsResponse = boolean; - -export type UsableDevicesResponse = Array; - -export type PingResponse2 = PingResponse; diff --git a/web/src/api/storage/zfcp.ts b/web/src/api/storage/zfcp.ts index 0d04169d5e..72dccb0719 100644 --- a/web/src/api/storage/zfcp.ts +++ b/web/src/api/storage/zfcp.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { post, get } from "~/api/http"; +import { post, get } from "~/http"; import { ZFCPDisk, ZFCPController, ZFCPConfig } from "~/types/zfcp"; /** diff --git a/web/src/api/system.ts b/web/src/api/system.ts new file mode 100644 index 0000000000..a7325b498b --- /dev/null +++ b/web/src/api/system.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import * as l10n from "~/api/l10n/system"; +import * as storage from "~/api/storage/system"; + +type System = { + l10n?: l10n.System; + storage?: storage.System; +}; + +export { l10n, storage }; +export type { System }; diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 25af9b2ed0..ae8db6e116 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -21,7 +21,7 @@ */ import { AxiosResponse } from "axios"; -import { del, get, patch, post, put } from "~/api/http"; +import { del, get, patch, post, put } from "~/http"; import { FirstUser, PasswordCheckResult, RootUser } from "~/types/users"; /** diff --git a/web/src/components/core/InstallationFinished.test.tsx b/web/src/components/core/InstallationFinished.test.tsx index 91432a6ed8..eaa4bfec1b 100644 --- a/web/src/components/core/InstallationFinished.test.tsx +++ b/web/src/components/core/InstallationFinished.test.tsx @@ -25,7 +25,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import InstallationFinished from "./InstallationFinished"; -import { Encryption } from "~/api/storage/types/config"; +import { Encryption } from "~/api/storage/config"; jest.mock("~/queries/status", () => ({ ...jest.requireActual("~/queries/status"), diff --git a/web/src/components/core/InstallationFinished.tsx b/web/src/components/core/InstallationFinished.tsx index 619f4e5eed..72ce092501 100644 --- a/web/src/components/core/InstallationFinished.tsx +++ b/web/src/components/core/InstallationFinished.tsx @@ -41,7 +41,7 @@ import { Navigate, useNavigate } from "react-router-dom"; import { Icon } from "~/components/layout"; import alignmentStyles from "@patternfly/react-styles/css/utilities/Alignment/alignment"; import { useInstallerStatus } from "~/queries/status"; -import { useConfig } from "~/queries/storage"; +import { useExtendedConfig } from "~/hooks/api"; import { finishInstallation } from "~/api/manager"; import { InstallationPhase } from "~/types/status"; import { ROOT as PATHS } from "~/routes/paths"; @@ -83,11 +83,8 @@ function usingTpm(config): boolean { return null; } - const { guided, drives = [], volumeGroups = [] } = config; + const { drives = [], volumeGroups = [] } = config; - if (guided !== undefined) { - return guided.encryption?.method === "tpm_fde"; - } const devices = [ ...drives, ...drives.flatMap((d) => d.partitions || []), @@ -100,7 +97,7 @@ function usingTpm(config): boolean { } function InstallationFinished() { - const config = useConfig(); + const config = useExtendedConfig(); const { phase, useIguana } = useInstallerStatus({ suspense: true }); const navigate = useNavigate(); diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index 335df6a12d..7347fb0b82 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -28,7 +28,7 @@ import * as utils from "~/utils"; import { PRODUCT, ROOT } from "~/routes/paths"; import InstallerOptions, { InstallerOptionsProps } from "./InstallerOptions"; import { Product } from "~/types/software"; -import { Keymap, Locale } from "~/types/l10n"; +import { Keymap, Locale } from "~/api/system"; let phase: InstallationPhase; let isBusy: boolean; diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 3b97d0c31c..34b680bcc2 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -47,7 +47,7 @@ import { } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { Icon } from "~/components/layout"; -import { Keymap, Locale } from "~/types/l10n"; +import { Keymap, Locale } from "~/api/l10n/system"; import { InstallationPhase } from "~/types/status"; import { useInstallerL10n } from "~/context/installerL10n"; import { useInstallerStatus } from "~/queries/status"; @@ -56,8 +56,8 @@ import { _ } from "~/i18n"; import supportedLanguages from "~/languages.json"; import { PRODUCT, ROOT, L10N } from "~/routes/paths"; import { useProduct } from "~/queries/software"; -import { useSystem } from "~/queries/system"; -import { updateConfig } from "~/api/api"; +import { useSystem } from "~/hooks/api"; +import { patchConfig } from "~/api"; /** * Props for select inputs @@ -593,7 +593,7 @@ export default function InstallerOptions({ if (variant !== "keyboard") systemL10n.locale = systemLocale?.id; if (variant !== "language" && localConnection()) systemL10n.keymap = formState.keymap; - updateConfig({ l10n: systemL10n }); + patchConfig({ l10n: systemL10n }); }; const close = () => { diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index 3c5ec93c03..9cda3ecb94 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -25,7 +25,7 @@ import KeyboardSelection from "./KeyboardSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; -import { Keymap } from "~/types/l10n"; +import { Keymap } from "~/api/system"; const keymaps: Keymap[] = [ { id: "us", name: "English" }, diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index 6b34273243..0dc4020ec5 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -24,9 +24,8 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { updateConfig } from "~/api/api"; -import { useSystem } from "~/queries/system"; -import { useProposal } from "~/queries/proposal"; +import { patchConfig } from "~/api"; +import { useSystem, useProposal } from "~/hooks/api"; import { _ } from "~/i18n"; // TODO: Add documentation @@ -51,7 +50,7 @@ export default function KeyboardSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); // FIXME: udpate when new API is ready - updateConfig({ l10n: { keymap: selected } }); + patchConfig({ l10n: { keymap: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index a4d25c230f..173c50277c 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -24,9 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import L10nPage from "~/components/l10n/L10nPage"; -import { Keymap, Locale, Timezone } from "~/types/l10n"; -import { System } from "~/types/system"; -import { Proposal } from "~/types/proposal"; +import { System, Keymap, Locale, Timezone } from "~/api/system"; +import { Proposal } from "~/api/proposal"; let mockSystemData: System; let mockProposedData: Proposal; diff --git a/web/src/components/l10n/L10nPage.tsx b/web/src/components/l10n/L10nPage.tsx index 534b4a0911..2d17e18330 100644 --- a/web/src/components/l10n/L10nPage.tsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -25,8 +25,7 @@ import { Button, Content, Grid, GridItem } from "@patternfly/react-core"; import { InstallerOptions, Link, Page } from "~/components/core"; import { L10N as PATHS } from "~/routes/paths"; import { localConnection } from "~/utils"; -import { useProposal } from "~/queries/proposal"; -import { useSystem } from "~/queries/system"; +import { useSystem, useProposal } from "~/hooks/api"; import { _ } from "~/i18n"; const InstallerL10nSettingsInfo = () => { diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index 0bf485e541..5b917b56c8 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -25,7 +25,7 @@ import LocaleSelection from "./LocaleSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; -import { Locale } from "~/types/l10n"; +import { Locale } from "~/api/system"; const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 3243a83969..14088dea83 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -24,9 +24,8 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { updateConfig } from "~/api/api"; -import { useSystem } from "~/queries/system"; -import { useProposal } from "~/queries/proposal"; +import { patchConfig } from "~/api"; +import { useSystem, useProposal } from "~/hooks/api"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { _ } from "~/i18n"; @@ -47,7 +46,7 @@ export default function LocaleSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - updateConfig({ l10n: { locale: selected } }); + patchConfig({ l10n: { locale: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 72cfb9b57c..6603881a9f 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -25,7 +25,7 @@ import TimezoneSelection from "./TimezoneSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; -import { Timezone } from "~/types/l10n"; +import { Timezone } from "~/api/system"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (

    ProductRegistrationAlert Mock
    diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 5656a8e795..eec0954aa1 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -24,10 +24,9 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { Timezone } from "~/types/l10n"; -import { updateConfig } from "~/api/api"; -import { useSystem } from "~/queries/system"; -import { useProposal } from "~/queries/proposal"; +import { Timezone } from "~/api/l10n/system"; +import { patchConfig } from "~/api"; +import { useSystem, useProposal } from "~/hooks/api"; import { timezoneTime } from "~/utils"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { _ } from "~/i18n"; @@ -83,7 +82,7 @@ export default function TimezoneSelection() { const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - updateConfig({ l10n: { timezone: selected } }); + patchConfig({ l10n: { timezone: selected } }); navigate(-1); }; diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx index d0e9e040ae..20e542368e 100644 --- a/web/src/components/overview/L10nSection.test.tsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { L10nSection } from "~/components/overview"; -import { Locale } from "~/types/l10n"; +import { Locale } from "~/api/system"; const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx index 9fe2a48c69..806a0ab80c 100644 --- a/web/src/components/overview/L10nSection.tsx +++ b/web/src/components/overview/L10nSection.tsx @@ -22,10 +22,9 @@ import React from "react"; import { Content } from "@patternfly/react-core"; -import { useProposal } from "~/queries/proposal"; -import { useSystem } from "~/queries/system"; +import { useSystem, useProposal } from "~/hooks/api"; import { _ } from "~/i18n"; -import { Locale } from "~/types/l10n"; +import { Locale } from "~/api/l10n/system"; export default function L10nSection() { const { l10n: l10nProposal } = useProposal(); diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index 919d4fe24d..28cf116d59 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -23,21 +23,20 @@ import React from "react"; import { Content } from "@patternfly/react-core"; import { deviceLabel } from "~/components/storage/utils"; -import { useDevices } from "~/queries/storage"; -import { useAvailableDevices } from "~/hooks/storage/system"; +import { useAvailableDevices, useDevices } from "~/hooks/storage/system"; import { useConfigModel } from "~/queries/storage/config-model"; import { useSystemErrors } from "~/queries/issues"; -import { StorageDevice } from "~/types/storage"; -import { apiModel } from "~/api/storage/types"; +import { storage } from "~/api/system"; +import { apiModel } from "~/api/storage"; import { _ } from "~/i18n"; -const findDriveDevice = (drive: apiModel.Drive, devices: StorageDevice[]) => +const findDriveDevice = (drive: apiModel.Drive, devices: storage.Device[]) => devices.find((d) => d.name === drive.name); const NoDeviceSummary = () => _("No device selected yet"); const SingleDiskSummary = ({ drive }: { drive: apiModel.Drive }) => { - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); const device = findDriveDevice(drive, devices); const options = { // TRANSLATORS: %s will be replaced by the device name and its size, @@ -81,7 +80,7 @@ const MultipleDisksSummary = ({ drives }: { drives: apiModel.Drive[] }): string }; const ModelSummary = ({ model }: { model: apiModel.Config }): React.ReactNode => { - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); const drives = model?.drives || []; const existDevice = (name: string) => devices.some((d) => d.name === name); const noDrive = drives.length === 0 || drives.some((d) => !existDevice(d.name)); diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index fe65d32c72..496a235a4d 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -27,7 +27,7 @@ import { AnswerCallback, Question, FieldType } from "~/types/questions"; import { InstallationPhase } from "~/types/status"; import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; -import { Locale, Keymap } from "~/types/l10n"; +import { Locale, Keymap } from "~/api/system"; let question: Question; const questionMock: Question = { diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 60a2961f83..2e1bfc9cdc 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -27,7 +27,7 @@ import { Question, FieldType } from "~/types/questions"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; -import { Locale, Keymap } from "~/types/l10n"; +import { Locale, Keymap } from "~/api/system"; const answerFn = jest.fn(); const question: Question = { diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index d167d584b4..f6201c7dba 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -27,11 +27,10 @@ import { DevicesFormSelect } from "~/components/storage"; import { Page, SubtleContent } from "~/components/core"; import { deviceLabel } from "~/components/storage/utils"; import { StorageDevice } from "~/types/storage"; -import { useCandidateDevices } from "~/hooks/storage/system"; +import { useCandidateDevices, useDevices } from "~/hooks/storage/system"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -import { useDevices } from "~/queries/storage"; import { useModel } from "~/hooks/storage/model"; import { useSetBootDevice, @@ -69,7 +68,7 @@ type BootSelectionState = { export default function BootSelectionDialog() { const [state, setState] = useState({ load: false }); const navigate = useNavigate(); - const devices = useDevices("system"); + const devices = useDevices(); const model = useModel({ suspense: true }); const candidateDevices = filteredCandidates(useCandidateDevices(), model); const setBootDevice = useSetBootDevice(); diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 74a5a0c529..2e1fb4b663 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -26,12 +26,13 @@ import Text from "~/components/core/Text"; import DriveEditor from "~/components/storage/DriveEditor"; import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; import MdRaidEditor from "~/components/storage/MdRaidEditor"; -import { useDevices, useResetConfigMutation } from "~/queries/storage"; +import { useDevices } from "~/hooks/storage/system"; +import { useResetConfig } from "~/hooks/storage/config"; import { useModel } from "~/hooks/storage/model"; import { _ } from "~/i18n"; const NoDevicesConfiguredAlert = () => { - const { mutate: reset } = useResetConfigMutation(); + const reset = useResetConfig(); const title = _("No devices configured yet"); // TRANSLATORS: %s will be replaced by a "reset to default" button const body = _( @@ -72,7 +73,7 @@ const NoDevicesConfiguredAlert = () => { */ export default function ConfigEditor() { const model = useModel(); - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); const drives = model.drives; const mdRaids = model.mdRaids; const volumeGroups = model.volumeGroups; diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 0437893e66..665d82269a 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -31,8 +31,8 @@ import { DropdownItem, Divider, } from "@patternfly/react-core"; -import { useResetConfigMutation } from "~/queries/storage"; -import { useReactivateSystem } from "~/hooks/storage/system"; +import { useResetConfig } from "~/hooks/storage/config"; +import { activateStorageAction } from "~/api"; import { STORAGE as PATHS } from "~/routes/paths"; import { useZFCPSupported } from "~/queries/storage/zfcp"; import { useDASDSupported } from "~/queries/storage/dasd"; @@ -41,8 +41,7 @@ export default function ConfigEditorMenu() { const navigate = useNavigate(); const isZFCPSupported = useZFCPSupported(); const isDASDSupported = useDASDSupported(); - const { mutate: reset } = useResetConfigMutation(); - const reactivate = useReactivateSystem(); + const reset = useResetConfig(); const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); @@ -106,7 +105,7 @@ export default function ConfigEditorMenu() { )} {_("Rescan devices")} diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 8709533a38..ead660ba71 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -25,9 +25,9 @@ import { useNavigate } from "react-router-dom"; import { ActionGroup, Alert, Checkbox, Content, Form } from "@patternfly/react-core"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import PasswordCheck from "~/components/users/PasswordCheck"; -import { useEncryptionMethods } from "~/queries/storage"; +import { useEncryptionMethods } from "~/hooks/storage/system"; import { useEncryption } from "~/queries/storage/config-model"; -import { apiModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index b93e42b72f..b3f2b1747f 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -48,15 +48,14 @@ import { import { Page, SelectWrapper as Select } from "~/components/core/"; import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; -import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; import { useAddFilesystem } from "~/hooks/storage/filesystem"; -import { useModel } from "~/hooks/storage/model"; -import { useDevices } from "~/queries/storage"; -import { data, model, StorageDevice } from "~/types/storage"; +import { useModel, useMissingMountPaths } from "~/hooks/storage/model"; +import { useDevices, useVolumeTemplate } from "~/hooks/storage/system"; +import { data, model } from "~/types/storage"; import { deviceBaseName, filesystemLabel } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { apiModel } from "~/api/storage/types"; +import { apiModel, system } from "~/api/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { unique } from "radashi"; import { compact } from "~/utils"; @@ -144,9 +143,9 @@ function useDeviceModel(): DeviceModel { return model[list].at(listIndex); } -function useDevice(): StorageDevice { +function useDevice(): system.Device { const deviceModel = useDeviceModel(); - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); return devices.find((d) => d.name === deviceModel.name); } @@ -156,7 +155,7 @@ function useCurrentFilesystem(): string | null { } function useDefaultFilesystem(mountPoint: string): string { - const volume = useVolume(mountPoint, { suspense: true }); + const volume = useVolumeTemplate(mountPoint, { suspense: true }); return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; } @@ -173,7 +172,7 @@ function useUnusedMountPoints(): string[] { } function useUsableFilesystems(mountPoint: string): string[] { - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = React.useMemo(() => { @@ -292,7 +291,7 @@ type FilesystemOptionsProps = { function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { const device = useDevice(); - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); const currentFilesystem = useCurrentFilesystem(); diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index f948904ecb..c78a38b54e 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -51,14 +51,14 @@ import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapp import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; import AutoSizeText from "~/components/storage/AutoSizeText"; import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; -import { useApiModel, useSolvedApiModel } from "~/hooks/storage/api-model"; -import { useModel } from "~/hooks/storage/model"; -import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; +import { useSolvedStorageModel, useStorageModel } from "~/hooks/api"; +import { useModel, useMissingMountPaths } from "~/hooks/storage/model"; +import { useVolumeTemplate } from "~/hooks/storage/system"; import { useVolumeGroup } from "~/hooks/storage/volume-group"; import { useAddLogicalVolume, useEditLogicalVolume } from "~/hooks/storage/logical-volume"; import { addLogicalVolume, editLogicalVolume } from "~/helpers/storage/logical-volume"; import { buildLogicalVolumeName } from "~/helpers/storage/api-model"; -import { apiModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage"; import { data } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { unique } from "radashi"; @@ -172,7 +172,7 @@ function toFormValue(logicalVolume: apiModel.LogicalVolume): FormValue { } function useDefaultFilesystem(mountPoint: string): string { - const volume = useVolume(mountPoint, { suspense: true }); + const volume = useVolumeTemplate(mountPoint, { suspense: true }); return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; } @@ -200,7 +200,7 @@ function useUnusedMountPoints(): string[] { } function useUsableFilesystems(mountPoint: string): string[] { - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useMemo(() => { @@ -339,7 +339,7 @@ function useErrors(value: FormValue): ErrorsHandler { function useSolvedModel(value: FormValue): apiModel.Config | null { const { id: vgName, logicalVolumeId: mountPath } = useParams(); - const apiModel = useApiModel(); + const apiModel = useStorageModel(); const { getError } = useErrors(value); const mountPointError = getError("mountPoint"); const data = toData(value); @@ -358,7 +358,7 @@ function useSolvedModel(value: FormValue): apiModel.Config | null { } } - const solvedModel = useSolvedApiModel(sparseModel); + const solvedModel = useSolvedStorageModel(sparseModel); return solvedModel; } @@ -476,7 +476,7 @@ type FilesystemOptionsProps = { function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultOptText = mountPoint !== NO_VALUE && volume.mountPath @@ -561,7 +561,7 @@ type AutoSizeInfoProps = { }; function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { - const volume = useVolume(value.mountPoint); + const volume = useVolumeTemplate(value.mountPoint); const logicalVolume = useSolvedLogicalVolume(value); const size = logicalVolume?.size; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 5e0d039c46..8c2d2d722a 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -51,20 +51,18 @@ import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeMo import AlertOutOfSync from "~/components/core/AlertOutOfSync"; import ResourceNotFound from "~/components/core/ResourceNotFound"; import { useAddPartition, useEditPartition } from "~/hooks/storage/partition"; -import { useMissingMountPaths } from "~/hooks/storage/product"; -import { useModel } from "~/hooks/storage/model"; +import { useModel, useMissingMountPaths } from "~/hooks/storage/model"; import { addPartition as addPartitionHelper, editPartition as editPartitionHelper, } from "~/helpers/storage/partition"; -import { useDevices, useVolume } from "~/queries/storage"; +import { useDevices, useVolumeTemplate } from "~/hooks/storage/system"; import { useConfigModel, useSolvedConfigModel } from "~/queries/storage/config-model"; import { findDevice } from "~/helpers/storage/api-model"; -import { StorageDevice } from "~/types/storage"; import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { apiModel } from "~/api/storage/types"; +import { apiModel, system } from "~/api/storage"; import { STORAGE as PATHS, STORAGE } from "~/routes/paths"; import { isUndefined, unique } from "radashi"; import { compact } from "~/utils"; @@ -195,19 +193,19 @@ function useModelDevice() { return model[list].at(listIndex); } -function useDevice(): StorageDevice { +function useDevice(): system.Device { const modelDevice = useModelDevice(); - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); return devices.find((d) => d.name === modelDevice.name); } -function usePartition(target: string): StorageDevice | null { +function usePartition(target: string): system.Device | null { const device = useDevice(); if (target === NEW_PARTITION) return null; const partitions = device.partitionTable?.partitions || []; - return partitions.find((p: StorageDevice) => p.name === target); + return partitions.find((p: system.Device) => p.name === target); } function usePartitionFilesystem(target: string): string | null { @@ -216,7 +214,7 @@ function usePartitionFilesystem(target: string): string | null { } function useDefaultFilesystem(mountPoint: string): string { - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; } @@ -247,7 +245,7 @@ function useUnusedMountPoints(): string[] { } /** Unused partitions. Includes the currently used partition when editing (if any). */ -function useUnusedPartitions(): StorageDevice[] { +function useUnusedPartitions(): system.Device[] { const device = useDevice(); const allPartitions = device.partitionTable?.partitions || []; const initialPartitionConfig = useInitialPartitionConfig(); @@ -260,7 +258,7 @@ function useUnusedPartitions(): StorageDevice[] { } function useUsableFilesystems(mountPoint: string): string[] { - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = React.useMemo(() => { @@ -502,7 +500,7 @@ function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode { } type PartitionDescriptionProps = { - partition: StorageDevice; + partition: system.Device; }; function PartitionDescription({ partition }: PartitionDescriptionProps): React.ReactNode { @@ -572,7 +570,7 @@ type FilesystemOptionsProps = { }; function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode { - const volume = useVolume(mountPoint); + const volume = useVolumeTemplate(mountPoint); const defaultFilesystem = useDefaultFilesystem(mountPoint); const usableFilesystems = useUsableFilesystems(mountPoint); const partitionFilesystem = usePartitionFilesystem(target); @@ -673,7 +671,7 @@ type AutoSizeInfoProps = { }; function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { - const volume = useVolume(value.mountPoint); + const volume = useVolumeTemplate(value.mountPoint); const solvedPartitionConfig = useSolvedPartitionConfig(value); const size = solvedPartitionConfig?.size; diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index 6421f3fede..10056f6a46 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -27,7 +27,7 @@ import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; -import { Partition } from "~/api/storage/types/model"; +import { Partition } from "~/api/storage/model"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDeletePartition } from "~/hooks/storage/partition"; import * as driveUtils from "~/components/storage/utils/drive"; diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index 8620d65752..29c4d47a0f 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -23,14 +23,14 @@ import React from "react"; import { Alert, Content } from "@patternfly/react-core"; import { IssueSeverity } from "~/types/issues"; -import { useApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; import { useIssues, useConfigErrors } from "~/queries/issues"; import * as partitionUtils from "~/components/storage/utils/partition"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; const Description = () => { - const model = useApiModel({ suspense: true }); + const model = useStorageModel({ suspense: true }); const partitions = model.drives.flatMap((d) => d.partitions || []); const logicalVolumes = model.volumeGroups.flatMap((vg) => vg.logicalVolumes || []); diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index ef1f65ef86..2c8df575a0 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -35,7 +35,7 @@ import { ListItem, } from "@patternfly/react-core"; import { Page, Link } from "~/components/core/"; -import { Icon, Loading } from "~/components/layout"; +import { Icon } from "~/components/layout"; import ConfigEditor from "./ConfigEditor"; import ConfigEditorMenu from "./ConfigEditorMenu"; import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; @@ -46,12 +46,7 @@ import ProposalResultSection from "./ProposalResultSection"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; import UnsupportedModelInfo from "./UnsupportedModelInfo"; import { useAvailableDevices } from "~/hooks/storage/system"; -import { - useResetConfigMutation, - useDeprecated, - useDeprecatedChanges, - useReprobeMutation, -} from "~/queries/storage"; +import { useResetConfig } from "~/hooks/storage/config"; import { useConfigModel } from "~/queries/storage/config-model"; import { useZFCPSupported } from "~/queries/storage/zfcp"; import { useDASDSupported } from "~/queries/storage/dasd"; @@ -63,7 +58,7 @@ import { useNavigate } from "react-router-dom"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); - const { mutate: reset } = useResetConfigMutation(); + const reset = useResetConfig(); return ( { - if (isDeprecated) reprobe().catch(console.log); - }, [isDeprecated, reprobe]); React.useEffect(() => { if (progress && !progress.finished) navigate(PATHS.progress); @@ -259,9 +247,8 @@ export default function ProposalPage(): React.ReactNode { {_("Storage")} - {isDeprecated && } - {!isDeprecated && !showSections && } - {!isDeprecated && showSections && } + {!showSections && } + {showSections && } ); diff --git a/web/src/components/storage/ProposalResultSection.tsx b/web/src/components/storage/ProposalResultSection.tsx index 6c97fcc5f9..61803f86e7 100644 --- a/web/src/components/storage/ProposalResultSection.tsx +++ b/web/src/components/storage/ProposalResultSection.tsx @@ -27,7 +27,8 @@ import DevicesManager from "~/components/storage/DevicesManager"; import ProposalResultTable from "~/components/storage/ProposalResultTable"; import { ProposalActionsDialog } from "~/components/storage"; import { _, n_, formatList } from "~/i18n"; -import { useActions, useDevices } from "~/queries/storage"; +import { useDevices } from "~/hooks/storage/system"; +import { useActions } from "~/hooks/storage/proposal"; import { sprintf } from "sprintf-js"; /** diff --git a/web/src/components/storage/ProposalTransactionalInfo.tsx b/web/src/components/storage/ProposalTransactionalInfo.tsx index a0fcf86e7e..e112b1776f 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.tsx +++ b/web/src/components/storage/ProposalTransactionalInfo.tsx @@ -25,7 +25,7 @@ import { Alert } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { useProduct } from "~/queries/software"; -import { useVolumes } from "~/queries/storage"; +import { useVolumeTemplates } from "~/hooks/storage/system"; import { isTransactionalSystem } from "~/components/storage/utils"; /** @@ -36,7 +36,7 @@ import { isTransactionalSystem } from "~/components/storage/utils"; */ export default function ProposalTransactionalInfo() { const { selectedProduct } = useProduct({ suspense: true }); - const volumes = useVolumes(); + const volumes = useVolumeTemplates(); if (!isTransactionalSystem(volumes)) return; diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index 37d7ed02cc..c26b0f6035 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -28,8 +28,8 @@ import { SpaceActionsTable } from "~/components/storage"; import { deviceChildren } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { apiModel } from "~/api/storage/types"; -import { useDevices } from "~/queries/storage"; +import { apiModel } from "~/api/storage"; +import { useDevices } from "~/hooks/storage/system"; import { useModel } from "~/hooks/storage/model"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { toStorageDevice } from "./device-utils"; @@ -50,7 +50,7 @@ export default function SpacePolicySelection() { const { list, listIndex } = useParams(); const model = useModel({ suspense: true }); const deviceModel = model[list][listIndex]; - const devices = useDevices("system", { suspense: true }); + const devices = useDevices({ suspense: true }); const device = devices.find((d) => d.name === deviceModel.name); const children = deviceChildren(device); const setSpacePolicy = useSetSpacePolicy(); diff --git a/web/src/components/storage/UnsupportedModelInfo.tsx b/web/src/components/storage/UnsupportedModelInfo.tsx index 3fa20a2c27..a3d23b5c79 100644 --- a/web/src/components/storage/UnsupportedModelInfo.tsx +++ b/web/src/components/storage/UnsupportedModelInfo.tsx @@ -24,14 +24,14 @@ import React from "react"; import { Alert, Button, Content, Stack, StackItem } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { useConfigModel } from "~/queries/storage/config-model"; -import { useResetConfigMutation } from "~/queries/storage"; +import { useResetConfig } from "~/hooks/storage/config"; /** * Info about unsupported model. */ export default function UnsupportedModelInfo(): React.ReactNode { const model = useConfigModel({ suspense: true }); - const { mutate: reset } = useResetConfigMutation(); + const reset = useResetConfig(); if (model) return null; diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index b0f2d47302..eefa151500 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -30,7 +30,8 @@ import xbytes from "xbytes"; import { _, N_ } from "~/i18n"; import { PartitionSlot, StorageDevice, model } from "~/types/storage"; -import { apiModel, Volume } from "~/api/storage/types"; +import { Volume } from "~/api/storage/system"; +import { apiModel } from "~/api/storage"; import { sprintf } from "sprintf-js"; /** diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index 1dc2afa005..6913c83efd 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -24,8 +24,8 @@ import React, { useCallback, useEffect, useState } from "react"; import { locationReload, setLocationSearch } from "~/utils"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; -import { fetchSystem, trigger } from "~/api/api"; -import { System } from "~/types/system"; +import { useSystem } from "~/hooks/l10n"; +import { configureL10nAction } from "~/api"; const L10nContext = React.createContext(null); @@ -132,16 +132,6 @@ function languageToLocale(language: string): string { return `${locale}.UTF-8`; } -/** - * Returns the language tag from the backend. - * - * @return Language tag from the backend locale. - */ -async function languageFromBackend(fetchConfig): Promise { - const config = await fetchConfig(); - return languageFromLocale(config?.l10n?.locale); -} - /** * Returns the first supported language from the given list. * @@ -228,31 +218,29 @@ async function loadTranslations(locale: string) { * * @param props * @param [props.children] - Content to display within the wrapper. - * @param [props.fetchConfigFn] - Function to retrieve l10n settings. * * @see useInstallerL10n */ function InstallerL10nProvider({ initialLanguage, - fetchConfigFn, children, }: { initialLanguage?: string; - fetchConfigFn?: () => Promise; children?: React.ReactNode; }) { - const fetchConfig = fetchConfigFn || fetchSystem; + const system = useSystem(); const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(undefined); - // FIXME: NEW-API: sync and updateConfig with new API once it's ready. + const locale = system?.locale; + const backendLanguage = locale ? languageFromLocale(locale) : null; + const syncBackendLanguage = useCallback(async () => { - const backendLanguage = await languageFromBackend(fetchConfig); - if (backendLanguage === language) return; + if (!backendLanguage || backendLanguage === language) return; // FIXME: fallback to en-US if the language is not supported. - await trigger({ configureL10n: { language: languageToLocale(language) } }); - }, [fetchConfig, language]); + await configureL10nAction({ locale: languageToLocale(language) }); + }, [language, backendLanguage]); const changeLanguage = useCallback( async (lang?: string) => { @@ -269,7 +257,7 @@ function InstallerL10nProvider({ wanted, wanted?.split("-")[0], // fallback to the language (e.g., "es" for "es-AR") agamaLanguage(), - await languageFromBackend(fetchConfig), + backendLanguage, ].filter((l) => l); const newLanguage = findSupportedLanguage(candidateLanguages) || "en-US"; const mustReload = storeAgamaLanguage(newLanguage); @@ -284,13 +272,13 @@ function InstallerL10nProvider({ await loadTranslations(newLanguage); } }, - [fetchConfig, setLanguage], + [backendLanguage, setLanguage], ); const changeKeymap = useCallback( async (id: string) => { setKeymap(id); - await trigger({ configureL10n: { keymap: id } }); + await configureL10nAction({ keymap: id }); }, [setKeymap], ); @@ -301,13 +289,12 @@ function InstallerL10nProvider({ useEffect(() => { if (!language) return; - // syncBackendLanguage(); }, [language, syncBackendLanguage]); useEffect(() => { - fetchConfig().then((c) => setKeymap(c?.l10n?.keymap)); - }, [setKeymap, fetchConfig]); + setKeymap(system?.keymap); + }, [setKeymap, system]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/helpers/l10n.ts b/web/src/helpers/l10n.ts new file mode 100644 index 0000000000..237d42a031 --- /dev/null +++ b/web/src/helpers/l10n.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { tzOffset } from "@date-fns/tz/tzOffset"; +import { Timezone } from "~/api/l10n/system"; + +function timezoneOffset(timezone: Timezone) { + return tzOffset(timezone.id, new Date()); +} + +export { timezoneOffset }; diff --git a/web/src/helpers/storage/system.ts b/web/src/helpers/storage/system.ts new file mode 100644 index 0000000000..cad6e40ef2 --- /dev/null +++ b/web/src/helpers/storage/system.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { System, Device } from "~/api/storage/system"; + +function findDevice(system: System, sid: number): Device | undefined { + const device = system.devices.find((d) => d.sid === sid); + if (device === undefined) console.warn("Device not found:", sid); + + return device; +} + +function findDevices(system: System, sids: number[]): Device[] { + return sids.map((sid) => findDevice(system, sid)).filter((d) => d); +} + +export { findDevice, findDevices }; diff --git a/web/src/hooks/api.ts b/web/src/hooks/api.ts new file mode 100644 index 0000000000..77f322e875 --- /dev/null +++ b/web/src/hooks/api.ts @@ -0,0 +1,146 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useQuery, useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; +import { + getSystem, + getProposal, + getExtendedConfig, + solveStorageModel, + getStorageModel, +} from "~/api"; +import { useInstallerClient } from "~/context/installer"; +import { System } from "~/api/system"; +import { Proposal } from "~/api/proposal"; +import { Config } from "~/api/config"; +import { apiModel } from "~/api/storage"; +import { QueryHookOptions } from "~/types/queries"; + +const systemQuery = () => ({ + queryKey: ["system"], + queryFn: getSystem, +}); + +function useSystem(options?: QueryHookOptions): System | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(systemQuery()); + return data; +} + +function useSystemChanges() { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + // TODO: replace the scope instead of invalidating the query. + return client.onEvent((event) => { + if (event.type === "SystemChanged") { + queryClient.invalidateQueries({ queryKey: ["system"] }); + } + }); + }, [client, queryClient]); +} + +const proposalQuery = () => { + return { + queryKey: ["proposal"], + queryFn: getProposal, + }; +}; + +function useProposal(options?: QueryHookOptions): Proposal | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(proposalQuery()); + return data; +} + +function useProposalChanges() { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + // TODO: replace the scope instead of invalidating the query. + return client.onEvent((event) => { + if (event.type === "ProposalChanged") { + queryClient.invalidateQueries({ queryKey: ["extendedConfig"] }); + queryClient.invalidateQueries({ queryKey: ["storageModel"] }); + queryClient.invalidateQueries({ queryKey: ["proposal"] }); + } + }); + }, [client, queryClient]); +} + +const extendedConfigQuery = () => ({ + queryKey: ["extendedConfig"], + queryFn: getExtendedConfig, +}); + +function useExtendedConfig(options?: QueryHookOptions): Config | null { + const query = extendedConfigQuery(); + const func = options?.suspense ? useSuspenseQuery : useQuery; + return func(query)?.data; +} + +const storageModelQuery = () => ({ + queryKey: ["storageModel"], + queryFn: getStorageModel, +}); + +function useStorageModel(options?: QueryHookOptions): apiModel.Config | null { + const query = storageModelQuery(); + const func = options?.suspense ? useSuspenseQuery : useQuery; + return func(query)?.data; +} + +const solvedStorageModelQuery = (apiModel?: apiModel.Config) => ({ + queryKey: ["solvedStorageModel", JSON.stringify(apiModel)], + queryFn: () => (apiModel ? solveStorageModel(apiModel) : Promise.resolve(null)), + staleTime: Infinity, +}); + +function useSolvedStorageModel( + model?: apiModel.Config, + options?: QueryHookOptions, +): apiModel.Config | null { + const query = solvedStorageModelQuery(model); + const func = options?.suspense ? useSuspenseQuery : useQuery; + return func(query)?.data; +} + +export { + systemQuery, + proposalQuery, + extendedConfigQuery, + storageModelQuery, + useSystem, + useSystemChanges, + useProposal, + useProposalChanges, + useExtendedConfig, + useStorageModel, + useSolvedStorageModel, +}; diff --git a/web/src/hooks/l10n.ts b/web/src/hooks/l10n.ts new file mode 100644 index 0000000000..2b062d49c2 --- /dev/null +++ b/web/src/hooks/l10n.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { System, l10n } from "~/api/system"; +import { QueryHookOptions } from "~/types/queries"; +import { systemQuery } from "~/hooks/api"; + +const selectSystem = (data: System | null): l10n.System | null => data?.l10n; + +function useSystem(options?: QueryHookOptions): l10n.System | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectSystem, + }); + return data; +} + +export { useSystem }; diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts deleted file mode 100644 index 0610caef21..0000000000 --- a/web/src/hooks/storage/api-model.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useQuery, useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiModel } from "~/api/storage/types"; -import { apiModelQuery, solveApiModelQuery } from "~/queries/storage"; -import { QueryHookOptions } from "~/types/queries"; -import { setConfigModel } from "~/api/storage"; - -function useApiModel(options?: QueryHookOptions): apiModel.Config | null { - const query = apiModelQuery; - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(query); - return data || null; -} - -/** @todo Use a hash key from the model object as id for the query. */ -function useSolvedApiModel( - model?: apiModel.Config, - options?: QueryHookOptions, -): apiModel.Config | null { - const query = solveApiModelQuery(model); - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(query); - return data; -} - -type UpdateApiModelFn = (apiModel: apiModel.Config) => void; - -function useUpdateApiModel(): UpdateApiModelFn { - const queryClient = useQueryClient(); - const query = { - mutationFn: (apiModel: apiModel.Config) => setConfigModel(apiModel), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - const { mutate } = useMutation(query); - return mutate; -} - -export { useApiModel, useSolvedApiModel, useUpdateApiModel }; -export type { UpdateApiModelFn }; diff --git a/web/src/hooks/storage/boot.ts b/web/src/hooks/storage/boot.ts index 9c59da44d3..aa9a2c3d73 100644 --- a/web/src/hooks/storage/boot.ts +++ b/web/src/hooks/storage/boot.ts @@ -21,7 +21,8 @@ */ import { useModel } from "~/hooks/storage/model"; -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { QueryHookOptions } from "~/types/queries"; import { setBootDevice, setDefaultBootDevice, disableBootConfig } from "~/helpers/storage/boot"; @@ -29,27 +30,24 @@ type SetBootDeviceFn = (deviceName: string) => void; function useSetBootDevice(options?: QueryHookOptions): SetBootDeviceFn { const model = useModel(options); - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return (deviceName: string) => updateApiModel(setBootDevice(model, apiModel, deviceName)); + const apiModel = useStorageModel(options); + return (deviceName: string) => putStorageModel(setBootDevice(model, apiModel, deviceName)); } type SetDefaultBootDeviceFn = () => void; function useSetDefaultBootDevice(options?: QueryHookOptions): SetDefaultBootDeviceFn { const model = useModel(options); - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return () => updateApiModel(setDefaultBootDevice(model, apiModel)); + const apiModel = useStorageModel(options); + return () => putStorageModel(setDefaultBootDevice(model, apiModel)); } type DisableBootConfigFn = () => void; function useDisableBootConfig(options?: QueryHookOptions): DisableBootConfigFn { const model = useModel(options); - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return () => updateApiModel(disableBootConfig(model, apiModel)); + const apiModel = useStorageModel(options); + return () => putStorageModel(disableBootConfig(model, apiModel)); } export { useSetBootDevice, useSetDefaultBootDevice, useDisableBootConfig }; diff --git a/web/src/hooks/storage/config.ts b/web/src/hooks/storage/config.ts new file mode 100644 index 0000000000..fba08ca78d --- /dev/null +++ b/web/src/hooks/storage/config.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { QueryHookOptions } from "~/types/queries"; +import { extendedConfigQuery } from "~/hooks/api"; +import { putConfig, Response } from "~/api"; +import { Config } from "~/api/config"; + +const resetConfig = (data: Config | null): Config => (!data ? {} : { ...data, storage: null }); + +type ResetConfigFn = () => Response; + +function useResetConfig(options?: QueryHookOptions): ResetConfigFn { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...extendedConfigQuery(), + select: resetConfig, + }); + return () => putConfig(data); +} + +export { useResetConfig }; +export type { ResetConfigFn }; diff --git a/web/src/hooks/storage/drive.ts b/web/src/hooks/storage/drive.ts index bf7f4d8624..b83c56e73b 100644 --- a/web/src/hooks/storage/drive.ts +++ b/web/src/hooks/storage/drive.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { addDrive, deleteDrive, switchToDrive } from "~/helpers/storage/drive"; import { QueryHookOptions } from "~/types/queries"; import { model, data } from "~/types/storage"; @@ -35,30 +36,27 @@ function useDrive(name: string, options?: QueryHookOptions): model.Drive | null type AddDriveFn = (data: data.Drive) => void; function useAddDrive(options?: QueryHookOptions): AddDriveFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (data: data.Drive) => { - updateApiModel(addDrive(apiModel, data)); + putStorageModel(addDrive(apiModel, data)); }; } type DeleteDriveFn = (name: string) => void; function useDeleteDrive(options?: QueryHookOptions): DeleteDriveFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (name: string) => { - updateApiModel(deleteDrive(apiModel, name)); + putStorageModel(deleteDrive(apiModel, name)); }; } type SwitchToDriveFn = (oldName: string, drive: data.Drive) => void; function useSwitchToDrive(options?: QueryHookOptions): SwitchToDriveFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (oldName: string, drive: data.Drive) => { - updateApiModel(switchToDrive(apiModel, oldName, drive)); + putStorageModel(switchToDrive(apiModel, oldName, drive)); }; } diff --git a/web/src/hooks/storage/filesystem.ts b/web/src/hooks/storage/filesystem.ts index b674f2fb05..6f2213919f 100644 --- a/web/src/hooks/storage/filesystem.ts +++ b/web/src/hooks/storage/filesystem.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { configureFilesystem } from "~/helpers/storage/filesystem"; import { QueryHookOptions } from "~/types/queries"; import { data } from "~/types/storage"; @@ -28,20 +29,18 @@ import { data } from "~/types/storage"; type AddFilesystemFn = (list: string, index: number, data: data.Formattable) => void; function useAddFilesystem(options?: QueryHookOptions): AddFilesystemFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (list: string, index: number, data: data.Formattable) => { - updateApiModel(configureFilesystem(apiModel, list, index, data)); + putStorageModel(configureFilesystem(apiModel, list, index, data)); }; } type DeleteFilesystemFn = (list: string, index: number) => void; function useDeleteFilesystem(options?: QueryHookOptions): DeleteFilesystemFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (list: string, index: number) => { - updateApiModel(configureFilesystem(apiModel, list, index, {})); + putStorageModel(configureFilesystem(apiModel, list, index, {})); }; } diff --git a/web/src/hooks/storage/logical-volume.ts b/web/src/hooks/storage/logical-volume.ts index f5ce5c9715..febbbab3f9 100644 --- a/web/src/hooks/storage/logical-volume.ts +++ b/web/src/hooks/storage/logical-volume.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { QueryHookOptions } from "~/types/queries"; import { data } from "~/types/storage"; import { @@ -32,30 +33,27 @@ import { type AddLogicalVolumeFn = (vgName: string, data: data.LogicalVolume) => void; function useAddLogicalVolume(options?: QueryHookOptions): AddLogicalVolumeFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (vgName: string, data: data.LogicalVolume) => { - updateApiModel(addLogicalVolume(apiModel, vgName, data)); + putStorageModel(addLogicalVolume(apiModel, vgName, data)); }; } type EditLogicalVolumeFn = (vgName: string, mountPath: string, data: data.LogicalVolume) => void; function useEditLogicalVolume(options?: QueryHookOptions): EditLogicalVolumeFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (vgName: string, mountPath: string, data: data.LogicalVolume) => { - updateApiModel(editLogicalVolume(apiModel, vgName, mountPath, data)); + putStorageModel(editLogicalVolume(apiModel, vgName, mountPath, data)); }; } type DeleteLogicalVolumeFn = (vgName: string, mountPath: string) => void; function useDeleteLogicalVolume(options?: QueryHookOptions): DeleteLogicalVolumeFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (vgName: string, mountPath: string) => - updateApiModel(deleteLogicalVolume(apiModel, vgName, mountPath)); + putStorageModel(deleteLogicalVolume(apiModel, vgName, mountPath)); } export { useAddLogicalVolume, useEditLogicalVolume, useDeleteLogicalVolume }; diff --git a/web/src/hooks/storage/md-raid.ts b/web/src/hooks/storage/md-raid.ts index 2c387cc1d0..8a8066588d 100644 --- a/web/src/hooks/storage/md-raid.ts +++ b/web/src/hooks/storage/md-raid.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { addReusedMdRaid, deleteMdRaid, switchToMdRaid } from "~/helpers/storage/md-raid"; import { QueryHookOptions } from "~/types/queries"; import { data } from "~/types/storage"; @@ -28,30 +29,27 @@ import { data } from "~/types/storage"; type AddReusedMdRaidFn = (data: data.MdRaid) => void; function useAddReusedMdRaid(options?: QueryHookOptions): AddReusedMdRaidFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (data: data.MdRaid) => { - updateApiModel(addReusedMdRaid(apiModel, data)); + putStorageModel(addReusedMdRaid(apiModel, data)); }; } type DeleteMdRaidFn = (name: string) => void; function useDeleteMdRaid(options?: QueryHookOptions): DeleteMdRaidFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (name: string) => { - updateApiModel(deleteMdRaid(apiModel, name)); + putStorageModel(deleteMdRaid(apiModel, name)); }; } type SwitchToMdRaidFn = (oldName: string, raid: data.MdRaid) => void; function useSwitchToMdRaid(options?: QueryHookOptions): SwitchToMdRaidFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (oldName: string, raid: data.MdRaid) => { - updateApiModel(switchToMdRaid(apiModel, oldName, raid)); + putStorageModel(switchToMdRaid(apiModel, oldName, raid)); }; } diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index fcf27af15b..756ba5f101 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -20,20 +20,42 @@ * find current contact information at www.suse.com. */ -import { useMemo } from "react"; -import { useApiModel } from "~/hooks/storage/api-model"; +import { useCallback } from "react"; +import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { storageModelQuery } from "~/hooks/api"; +import { useSystem } from "~/hooks/storage/system"; +import { apiModel } from "~/api/storage"; import { buildModel } from "~/helpers/storage/model"; import { QueryHookOptions } from "~/types/queries"; import { model } from "~/types/storage"; -function useModel(options?: QueryHookOptions): model.Model | null { - const apiModel = useApiModel(options); +const modelFromData = (data: apiModel.Config | null): model.Model | null => + data ? buildModel(data) : null; - const model = useMemo((): model.Model | null => { - return apiModel ? buildModel(apiModel) : null; - }, [apiModel]); +function useModel(options?: QueryHookOptions): model.Model | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...storageModelQuery(), + select: modelFromData, + }); + return data; +} - return model; +function useMissingMountPaths(options?: QueryHookOptions): string[] { + const productMountPoints = useSystem()?.productMountPoints; + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...storageModelQuery(), + select: useCallback( + (data: apiModel.Config | null): string[] => { + const model = modelFromData(data); + const currentMountPaths = model?.getMountPaths() || []; + return (productMountPoints || []).filter((p) => !currentMountPaths.includes(p)); + }, + [productMountPoints], + ), + }); + return data; } -export { useModel }; +export { useModel, useMissingMountPaths }; diff --git a/web/src/hooks/storage/partition.ts b/web/src/hooks/storage/partition.ts index 291ae68a30..84bca69956 100644 --- a/web/src/hooks/storage/partition.ts +++ b/web/src/hooks/storage/partition.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { QueryHookOptions } from "~/types/queries"; import { data } from "~/types/storage"; import { addPartition, editPartition, deletePartition } from "~/helpers/storage/partition"; @@ -32,10 +33,9 @@ type AddPartitionFn = ( ) => void; function useAddPartition(options?: QueryHookOptions): AddPartitionFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (list: "drives" | "mdRaids", listIndex: number | string, data: data.Partition) => { - updateApiModel(addPartition(apiModel, list, listIndex, data)); + putStorageModel(addPartition(apiModel, list, listIndex, data)); }; } @@ -47,15 +47,14 @@ type EditPartitionFn = ( ) => void; function useEditPartition(options?: QueryHookOptions): EditPartitionFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return ( list: "drives" | "mdRaids", listIndex: number | string, mountPath: string, data: data.Partition, ) => { - updateApiModel(editPartition(apiModel, list, listIndex, mountPath, data)); + putStorageModel(editPartition(apiModel, list, listIndex, mountPath, data)); }; } @@ -66,10 +65,9 @@ type DeletePartitionFn = ( ) => void; function useDeletePartition(options?: QueryHookOptions): DeletePartitionFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (list: "drives" | "mdRaids", listIndex: number | string, mountPath: string) => - updateApiModel(deletePartition(apiModel, list, listIndex, mountPath)); + putStorageModel(deletePartition(apiModel, list, listIndex, mountPath)); } export { useAddPartition, useEditPartition, useDeletePartition }; diff --git a/web/src/hooks/storage/product.ts b/web/src/hooks/storage/product.ts deleted file mode 100644 index 54f7ce425c..0000000000 --- a/web/src/hooks/storage/product.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useMemo } from "react"; -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { QueryHookOptions } from "~/types/queries"; -import { ProductParams, Volume } from "~/api/storage/types"; -import { productParamsQuery, volumeQuery } from "~/queries/storage"; -import { useModel } from "~/hooks/storage/model"; - -function useProductParams(options?: QueryHookOptions): ProductParams { - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(productParamsQuery); - return data; -} - -function useMissingMountPaths(options?: QueryHookOptions): string[] { - const productParams = useProductParams(options); - const model = useModel(); - - const missingMountPaths = useMemo(() => { - const currentMountPaths = model?.getMountPaths() || []; - return (productParams?.mountPoints || []).filter((p) => !currentMountPaths.includes(p)); - }, [productParams, model]); - - return missingMountPaths; -} - -function useVolume(mountPath: string, options?: QueryHookOptions): Volume { - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { mountPoints } = useProductParams(options); - - // The query returns a volume with the given mount path, but we need the "generic" volume without - // mount path for an arbitrary mount path. Take it into account while refactoring the backend side - // in order to report all the volumes in a single call (e.g., as part of the product params). - if (!mountPoints.includes(mountPath)) mountPath = ""; - const { data } = func(volumeQuery(mountPath)); - return data; -} - -export { useProductParams, useMissingMountPaths, useVolume }; diff --git a/web/src/hooks/storage/proposal.ts b/web/src/hooks/storage/proposal.ts new file mode 100644 index 0000000000..8bda265b1c --- /dev/null +++ b/web/src/hooks/storage/proposal.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { Proposal, storage } from "~/api/proposal"; +import { QueryHookOptions } from "~/types/queries"; +import { proposalQuery } from "~/hooks/api"; + +const selectActions = (data: Proposal | null): storage.Action[] => data?.storage?.actions || []; + +function useActions(options?: QueryHookOptions): storage.Action[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...proposalQuery(), + select: selectActions, + }); + return data; +} + +export { useActions }; diff --git a/web/src/hooks/storage/space-policy.ts b/web/src/hooks/storage/space-policy.ts index 463ee3c3ab..802357a709 100644 --- a/web/src/hooks/storage/space-policy.ts +++ b/web/src/hooks/storage/space-policy.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { QueryHookOptions } from "~/types/queries"; import { data } from "~/types/storage"; import { setSpacePolicy } from "~/helpers/storage/space-policy"; @@ -28,10 +29,9 @@ import { setSpacePolicy } from "~/helpers/storage/space-policy"; type setSpacePolicyFn = (list: string, listIndex: number | string, data: data.SpacePolicy) => void; function useSetSpacePolicy(options?: QueryHookOptions): setSpacePolicyFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (list: string, listIndex: number | string, data: data.SpacePolicy) => { - updateApiModel(setSpacePolicy(apiModel, list, listIndex, data)); + putStorageModel(setSpacePolicy(apiModel, list, listIndex, data)); }; } diff --git a/web/src/hooks/storage/system.ts b/web/src/hooks/storage/system.ts index 436ff3d078..5d4d223a5b 100644 --- a/web/src/hooks/storage/system.ts +++ b/web/src/hooks/storage/system.ts @@ -20,120 +20,186 @@ * find current contact information at www.suse.com. */ -import { useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { - useDevices, - availableDrivesQuery, - candidateDrivesQuery, - availableMdRaidsQuery, - candidateMdRaidsQuery, -} from "~/queries/storage"; -import { reactivate } from "~/api/storage"; -import { StorageDevice } from "~/types/storage"; - -function findDevice(devices: StorageDevice[], sid: number): StorageDevice | undefined { - const device = devices.find((d) => d.sid === sid); - if (device === undefined) console.warn("Device not found:", sid); - - return device; +import { useCallback } from "react"; +import { useSuspenseQuery, useQuery } from "@tanstack/react-query"; +import { System, storage } from "~/api/system"; +import { QueryHookOptions } from "~/types/queries"; +import { systemQuery } from "~/hooks/api"; +import { findDevices } from "~/helpers/storage/system"; + +const selectSystem = (data: System | null): storage.System => data?.storage; + +function useSystem(options?: QueryHookOptions): storage.System { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectSystem, + }); + return data; +} + +const selectEncryptionMethods = (data: System | null): storage.EncryptionMethod[] => + data?.storage?.encryptionMethods || []; + +function useEncryptionMethods(options?: QueryHookOptions): storage.EncryptionMethod[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectEncryptionMethods, + }); + return data; +} + +const enum DeviceGroup { + AvailableDrives = "availableDrives", + CandidateDrives = "candidateDrives", + AvailableMdRaids = "availableMdRaids", + CandidateMdRaids = "candidateMdRaids", +} + +function selectDeviceGroups(data: System | null, groups: DeviceGroup[]): storage.Device[] { + if (!data?.storage) return []; + const sids = groups.flatMap((g) => data.storage[g]); + return findDevices(data.storage, sids); } +const selectAvailableDrives = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.AvailableDrives]); + /** * Hook that returns the list of available drives for installation. */ -const useAvailableDrives = (): StorageDevice[] => { - const devices = useDevices("system", { suspense: true }); - const { data: sids } = useSuspenseQuery(availableDrivesQuery()); +function useAvailableDrives(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectAvailableDrives, + }); + return data; +} - return useMemo(() => { - return sids.map((sid: number) => findDevice(devices, sid)).filter((d) => d); - }, [devices, sids]); -}; +const selectCandidateDrives = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.CandidateDrives]); /** * Hook that returns the list of candidate drives for installation. */ -const useCandidateDrives = (): StorageDevice[] => { - const devices = useDevices("system", { suspense: true }); - const { data: sids } = useSuspenseQuery(candidateDrivesQuery()); +function useCandidateDrives(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectCandidateDrives, + }); + return data; +} - return useMemo(() => { - return sids.map((sid: number) => findDevice(devices, sid)).filter((d) => d); - }, [devices, sids]); -}; +const selectAvailableMdRaids = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.AvailableMdRaids]); /** * Hook that returns the list of available MD RAIDs for installation. */ -const useAvailableMdRaids = (): StorageDevice[] => { - const devices = useDevices("system", { suspense: true }); - const { data: sids } = useSuspenseQuery(availableMdRaidsQuery()); +function useAvailableMdRaids(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectAvailableMdRaids, + }); + return data; +} - return useMemo(() => { - return sids.map((sid: number) => findDevice(devices, sid)).filter((d) => d); - }, [devices, sids]); -}; +const selectCandidateMdRaids = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.CandidateMdRaids]); /** * Hook that returns the list of available MD RAIDs for installation. */ -const useCandidateMdRaids = (): StorageDevice[] => { - const devices = useDevices("system", { suspense: true }); - const { data: sids } = useSuspenseQuery(candidateMdRaidsQuery()); +function useCandidateMdRaids(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectCandidateMdRaids, + }); + return data; +} - return useMemo(() => { - return sids.map((sid: number) => findDevice(devices, sid)).filter((d) => d); - }, [devices, sids]); -}; +const selectAvailableDevices = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.AvailableDrives, DeviceGroup.AvailableMdRaids]); /** * Hook that returns the list of available devices for installation. */ -const useAvailableDevices = (): StorageDevice[] => { - const availableDrives = useAvailableDrives(); - const availableMdRaids = useAvailableMdRaids(); - - return useMemo( - () => [...availableDrives, ...availableMdRaids], - [availableDrives, availableMdRaids], - ); -}; +function useAvailableDevices(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectAvailableDevices, + }); + return data; +} + +const selectCandidateDevices = (data: System | null) => + selectDeviceGroups(data, [DeviceGroup.CandidateDrives, DeviceGroup.CandidateMdRaids]); /** * Hook that returns the list of candidate devices for installation. */ -const useCandidateDevices = (): StorageDevice[] => { - const candidateDrives = useCandidateDrives(); - const candidateMdRaids = useCandidateMdRaids(); - - return useMemo( - () => [...candidateMdRaids, ...candidateDrives], - [candidateDrives, candidateMdRaids], - ); -}; +function useCandidateDevices(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectCandidateDevices, + }); + return data; +} -type ReactivateSystemFn = () => void; +const selectDevices = (data: System | null): storage.Device[] => data?.storage?.devices || []; -function useReactivateSystem(): ReactivateSystemFn { - const queryClient = useQueryClient(); - const query = { - mutationFn: reactivate, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; +function useDevices(options?: QueryHookOptions): storage.Device[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectDevices, + }); + return data; +} - const { mutate } = useMutation(query); - return mutate; +const selectVolumeTemplates = (data: System | null): storage.Volume[] => + data?.storage?.volumeTemplates || []; + +function useVolumeTemplates(options?: QueryHookOptions): storage.Volume[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectVolumeTemplates, + }); + return data; +} + +const selectVolumeTemplate = (data: System | null, mountPath: string): storage.Volume | null => { + const volumes = data?.storage?.volumeTemplates || []; + return volumes.find((v) => v.mountPath === mountPath); +}; + +function useVolumeTemplate(mountPath: string, options?: QueryHookOptions): storage.Volume | null { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: useCallback((data) => selectVolumeTemplate(data, mountPath), [mountPath]), + }); + return data; } export { + useSystem, + useEncryptionMethods, useAvailableDrives, useCandidateDrives, useAvailableMdRaids, useCandidateMdRaids, useAvailableDevices, useCandidateDevices, - useReactivateSystem, + useDevices, + useVolumeTemplates, + useVolumeTemplate, }; - -export type { ReactivateSystemFn }; diff --git a/web/src/hooks/storage/volume-group.ts b/web/src/hooks/storage/volume-group.ts index 03f23a43fa..6055cdcd56 100644 --- a/web/src/hooks/storage/volume-group.ts +++ b/web/src/hooks/storage/volume-group.ts @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { useStorageModel } from "~/hooks/api"; +import { putStorageModel } from "~/api"; import { addVolumeGroup, editVolumeGroup, @@ -41,30 +42,27 @@ function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.Volum type AddVolumeGroupFn = (data: data.VolumeGroup, moveContent: boolean) => void; function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (data: data.VolumeGroup, moveContent: boolean) => { - updateApiModel(addVolumeGroup(apiModel, data, moveContent)); + putStorageModel(addVolumeGroup(apiModel, data, moveContent)); }; } type EditVolumeGroupFn = (vgName: string, data: data.VolumeGroup) => void; function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (vgName: string, data: data.VolumeGroup) => { - updateApiModel(editVolumeGroup(apiModel, vgName, data)); + putStorageModel(editVolumeGroup(apiModel, vgName, data)); }; } type DeleteVolumeGroupFn = (vgName: string, moveToDrive: boolean) => void; function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (vgName: string, moveToDrive: boolean) => { - updateApiModel( + putStorageModel( moveToDrive ? volumeGroupToPartitions(apiModel, vgName) : deleteVolumeGroup(apiModel, vgName), ); }; @@ -73,10 +71,9 @@ function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { type ConvertToVolumeGroupFn = (driveName: string) => void; function useConvertToVolumeGroup(options?: QueryHookOptions): ConvertToVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); + const apiModel = useStorageModel(options); return (driveName: string) => { - updateApiModel(deviceToVolumeGroup(apiModel, driveName)); + putStorageModel(deviceToVolumeGroup(apiModel, driveName)); }; } diff --git a/web/src/api/http.ts b/web/src/http.ts similarity index 100% rename from web/src/api/http.ts rename to web/src/http.ts diff --git a/web/src/queries/proposal.ts b/web/src/queries/proposal.ts deleted file mode 100644 index c83df902f2..0000000000 --- a/web/src/queries/proposal.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; -import { useInstallerClient } from "~/context/installer"; -import { fetchProposal } from "~/api/api"; - -/** - * Returns a query for retrieving the proposal - */ -const proposalQuery = () => { - return { - queryKey: ["proposal"], - queryFn: fetchProposal, - }; -}; - -const useProposal = () => { - const { data: config } = useSuspenseQuery(proposalQuery()); - return config; -}; - -const useProposalChanges = () => { - const queryClient = useQueryClient(); - const client = useInstallerClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - if (event.type === "ProposalChanged" && event.scope === "localization") { - queryClient.invalidateQueries({ queryKey: ["proposal"] }); - } - }); - }, [client, queryClient]); -}; -export { useProposal, useProposalChanges }; diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts deleted file mode 100644 index 27683f431c..0000000000 --- a/web/src/queries/storage.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import React from "react"; -import { - fetchConfig, - setConfig, - resetConfig, - fetchConfigModel, - solveConfigModel, - fetchActions, - fetchVolume, - fetchVolumes, - fetchProductParams, - fetchAvailableDrives, - fetchCandidateDrives, - fetchAvailableMdRaids, - fetchCandidateMdRaids, - reprobe, -} from "~/api/storage"; -import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; -import { useInstallerClient } from "~/context/installer"; -import { config, apiModel, ProductParams, Volume } from "~/api/storage/types"; -import { Action, StorageDevice } from "~/types/storage"; -import { QueryHookOptions } from "~/types/queries"; - -const configQuery = { - queryKey: ["storage", "config"], - queryFn: fetchConfig, - staleTime: Infinity, -}; - -const apiModelQuery = { - queryKey: ["storage", "apiModel"], - queryFn: fetchConfigModel, - staleTime: Infinity, -}; - -const solveApiModelQuery = (apiModel?: apiModel.Config) => ({ - queryKey: ["storage", "solveApiModel", JSON.stringify(apiModel)], - queryFn: () => (apiModel ? solveConfigModel(apiModel) : Promise.resolve(null)), - staleTime: Infinity, -}); - -const devicesQuery = (scope: "result" | "system") => ({ - queryKey: ["storage", "devices", scope], - queryFn: () => fetchDevices(scope), - staleTime: Infinity, -}); - -const availableDrivesQuery = () => ({ - queryKey: ["storage", "availableDrives"], - queryFn: fetchAvailableDrives, - staleTime: Infinity, -}); - -const candidateDrivesQuery = () => ({ - queryKey: ["storage", "candidateDrives"], - queryFn: fetchCandidateDrives, - staleTime: Infinity, -}); - -const availableMdRaidsQuery = () => ({ - queryKey: ["storage", "availableMdRaids"], - queryFn: fetchAvailableMdRaids, - staleTime: Infinity, -}); - -const candidateMdRaidsQuery = () => ({ - queryKey: ["storage", "candidateMdRaids"], - queryFn: fetchCandidateMdRaids, - staleTime: Infinity, -}); - -const productParamsQuery = { - queryKey: ["storage", "productParams"], - queryFn: fetchProductParams, - staleTime: Infinity, -}; - -const volumeQuery = (mountPath: string) => ({ - queryKey: ["storage", "volume", mountPath], - queryFn: () => fetchVolume(mountPath), - staleTime: Infinity, -}); - -const volumesQuery = (mountPaths: string[]) => ({ - queryKey: ["storage", "volumes"], - queryFn: () => fetchVolumes(mountPaths), - staleTime: Infinity, -}); - -const actionsQuery = { - queryKey: ["storage", "devices", "actions"], - queryFn: fetchActions, -}; - -const deprecatedQuery = { - queryKey: ["storage", "dirty"], - queryFn: fetchDevicesDirty, -}; - -/** - * Hook that returns the unsolved config. - */ -const useConfig = (options?: QueryHookOptions): config.Config => { - const query = configQuery; - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(query); - return data; -}; - -/** - * Hook for setting a new config. - */ -const useConfigMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: async (config: config.Config) => await setConfig(config), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - return useMutation(query); -}; - -/** - * Hook for setting the default config. - */ -const useResetConfigMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: async () => await resetConfig(), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - return useMutation(query); -}; - -/** - * Hook that returns the list of storage devices for the given scope. - * - * @param scope - "system": devices in the current state of the system; "result": - * devices in the proposal ("stage") - */ -const useDevices = ( - scope: "result" | "system", - options?: QueryHookOptions, -): StorageDevice[] | undefined => { - const query = devicesQuery(scope); - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(query); - return data; -}; - -/** - * @deprecated Use useProductParams from ~/hooks/storage/product. - * Hook that returns the product parameters (e.g., mount points). - */ -const useProductParams = (options?: QueryHookOptions): ProductParams => { - const func = options?.suspense ? useSuspenseQuery : useQuery; - const { data } = func(productParamsQuery); - return data; -}; - -/** - * Hook that returns the available encryption methods. - * - * @note The ids of the encryption methods reported by product params are different to the - * EncryptionMethod values. This should be fixed at the bakcend size. - */ -const useEncryptionMethods = (options?: QueryHookOptions): apiModel.EncryptionMethod[] => { - const productParams = useProductParams(options); - - const encryptionMethods = React.useMemo((): apiModel.EncryptionMethod[] => { - const conversions = { - luks1: "luks1", - luks2: "luks2", - pervasive_encryption: "pervasiveEncryption", - tpm_fde: "tpmFde", - protected_swap: "protectedSwap", - secure_swap: "secureSwap", - random_swap: "randomSwap", - }; - - const apiMethods = productParams?.encryptionMethods || []; - return apiMethods.map((v) => conversions[v] || "luks2"); - }, [productParams]); - - return encryptionMethods; -}; - -/** - * Hook that returns the volumes for the current product. - */ -const useVolumes = (): Volume[] => { - const product = useProductParams({ suspense: true }); - const mountPoints = ["", ...product.mountPoints]; - const { data } = useSuspenseQuery(volumesQuery(mountPoints)); - return data; -}; - -/** @deprecated Use useVolume from ~/hooks/storage/product. */ -function useVolume(mountPoint: string): Volume { - const volumes = useVolumes(); - const volume = volumes.find((v) => v.mountPath === mountPoint); - const defaultVolume = volumes.find((v) => v.mountPath === ""); - return volume || defaultVolume; -} - -/** - * Hook that returns the actions to perform in the storage devices. - */ -const useActions = (): Action[] => { - const { data } = useSuspenseQuery(actionsQuery); - return data; -}; - -/** - * Hook that returns whether the storage devices are "dirty". - */ -const useDeprecated = () => { - const { isPending, data } = useQuery(deprecatedQuery); - return isPending ? false : data; -}; - -/** - * Hook that listens for changes to the devices dirty property. - */ -const useDeprecatedChanges = () => { - const client = useInstallerClient(); - const queryClient = useQueryClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent(({ type, dirty: value }) => { - if (type === "DevicesDirty") { - queryClient.setQueryData(deprecatedQuery.queryKey, value); - } - }); - }); -}; - -/** - * Hook that reprobes the devices and recalculates the proposal using the current settings. - */ -const useReprobeMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: async () => { - await reprobe(); - }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - return useMutation(query); -}; - -export { - productParamsQuery, - apiModelQuery, - availableDrivesQuery, - candidateDrivesQuery, - availableMdRaidsQuery, - candidateMdRaidsQuery, - solveApiModelQuery, - volumeQuery, - useConfig, - useConfigMutation, - useResetConfigMutation, - useDevices, - useEncryptionMethods, - useVolumes, - useVolume, - useActions, - useDeprecated, - useDeprecatedChanges, - useReprobeMutation, -}; diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 610e2cafd9..6a359daaf8 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -22,11 +22,13 @@ /** @deprecated These hooks will be replaced by new hooks at ~/hooks/storage/ */ -import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { setConfigModel, solveConfigModel } from "~/api/storage"; -import { apiModel, Volume } from "~/api/storage/types"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { putStorageModel, solveStorageModel } from "~/api"; +import { apiModel } from "~/api/storage"; +import { Volume } from "~/api/storage/system"; import { QueryHookOptions } from "~/types/queries"; -import { apiModelQuery, useVolumes } from "~/queries/storage"; +import { storageModelQuery } from "~/hooks/api"; +import { useVolumeTemplates } from "~/hooks/storage/system"; function copyModel(model: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(model)); @@ -111,30 +113,17 @@ function unusedMountPaths(model: apiModel.Config, volumes: Volume[]): string[] { /** @deprecated Use useApiModel from ~/hooks/storage/api-model. */ export function useConfigModel(options?: QueryHookOptions): apiModel.Config { - const query = apiModelQuery; + const query = storageModelQuery(); const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data; } -/** - * Hook for setting a new config model. - */ -export function useConfigModelMutation() { - const queryClient = useQueryClient(); - const query = { - mutationFn: (model: apiModel.Config) => setConfigModel(model), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - return useMutation(query); -} - /** @deprecated Use useSolvedApiModel from ~/hooks/storage/api-model. */ export function useSolvedConfigModel(model?: apiModel.Config): apiModel.Config | null { const query = useSuspenseQuery({ queryKey: ["storage", "solvedConfigModel", JSON.stringify(model)], - queryFn: () => (model ? solveConfigModel(model) : Promise.resolve(null)), + queryFn: () => (model ? solveStorageModel(model) : Promise.resolve(null)), staleTime: Infinity, }); @@ -149,13 +138,12 @@ export type EncryptionHook = { export function useEncryption(): EncryptionHook { const model = useConfigModel(); - const { mutate } = useConfigModelMutation(); return { encryption: model?.encryption, enable: (method: apiModel.EncryptionMethod, password: string) => - mutate(setEncryption(model, method, password)), - disable: () => mutate(disableEncryption(model)), + putStorageModel(setEncryption(model, method, password)), + disable: () => putStorageModel(disableEncryption(model)), }; } @@ -194,12 +182,11 @@ export type ModelHook = { */ export function useModel(): ModelHook { const model = useConfigModel(); - const { mutate } = useConfigModelMutation(); - const volumes = useVolumes(); + const volumes = useVolumeTemplates(); return { model, - addDrive: (driveName) => mutate(addDrive(model, driveName)), + addDrive: (driveName) => putStorageModel(addDrive(model, driveName)), usedMountPaths: model ? usedMountPaths(model) : [], unusedMountPaths: model ? unusedMountPaths(model, volumes) : [], }; diff --git a/web/src/queries/storage/dasd.ts b/web/src/queries/storage/dasd.ts index c75c2432f6..b42f731e2a 100644 --- a/web/src/queries/storage/dasd.ts +++ b/web/src/queries/storage/dasd.ts @@ -34,7 +34,7 @@ import { useInstallerClient } from "~/context/installer"; import React from "react"; import { hex } from "~/utils"; import { DASDDevice, FormatJob } from "~/types/dasd"; -import { fetchStorageJobs } from "~/api/storage"; +import { getStorageJobs } from "~/api"; /** * Returns a query for retrieving the dasd devices @@ -71,9 +71,7 @@ const useDASDSupported = (): boolean => { const dasdRunningFormatJobsQuery = () => ({ queryKey: ["dasd", "formatJobs", "running"], queryFn: () => - fetchStorageJobs().then((jobs) => - jobs.filter((j) => j.running).map(({ id }) => ({ jobId: id })), - ), + getStorageJobs().then((jobs) => jobs.filter((j) => j.running).map(({ id }) => ({ jobId: id }))), staleTime: 200, }); diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts deleted file mode 100644 index 8d792c5c33..0000000000 --- a/web/src/queries/system.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { tzOffset } from "@date-fns/tz/tzOffset"; -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { useInstallerClient } from "~/context/installer"; -import { fetchSystem } from "~/api/api"; -import { System } from "~/types/system"; - -const transformLocales = (locales) => - locales.map(({ id, language: name, territory }) => ({ id, name, territory })); - -const tranformKeymaps = (keymaps) => keymaps.map(({ id, description: name }) => ({ id, name })); - -const transformTimezones = (timezones) => - timezones.map(({ id, parts, country }) => { - const utcOffset = tzOffset(id, new Date()); - return { id, parts, country, utcOffset }; - }); - -/** - * Returns a query for retrieving the localization configuration - */ -const systemQuery = () => { - return { - queryKey: ["system"], - queryFn: fetchSystem, - - // FIXME: We previously had separate fetch functions (fetchLocales, - // fetchKeymaps, fetchTimezones) that each applied specific transformations to - // the raw API data, for example, adding `utcOffset` to timezones or - // changing keys to follow a consistent structure (e.g. `id` vs `code`). - // - // Now that we've consolidated these into a single "system" cache, instead of - // individual caches, those transformations are currently missing. While it's - // more efficient to fetch everything in one request, we may still want to apply - // those transformations only once. Ideally, this logic should live outside the - // React Query layer, in a dedicated "state layer" or transformation step, so - // that data remains normalized and consistently shaped for the rest of the app. - - select: (system: System) => ({ - ...system, - l10n: { - locales: transformLocales(system.l10n.locales), - keymaps: tranformKeymaps(system.l10n.keymaps), - timezones: transformTimezones(system.l10n.timezones), - locale: system.l10n.locale, - keypmap: system.l10n.keymap, - timezone: system.l10n.timezone, - }, - }), - }; -}; - -const useSystem = () => { - const { data: system } = useSuspenseQuery(systemQuery()); - return system; -}; - -const useSystemChanges = () => { - const queryClient = useQueryClient(); - const client = useInstallerClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - if (event.type === "SystemChanged" && event.scope === "l10n") { - queryClient.invalidateQueries({ queryKey: ["system"] }); - } - }); - }, [client, queryClient]); -}; - -export { useSystem, useSystemChanges }; diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 6df15d8af3..2b60cf66cf 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -119,18 +119,17 @@ const Providers = ({ children, withL10n }) => { } if (withL10n) { - const fetchConfig = async (): Promise => ({ - l10n: { - keymap: "us", - timezone: "Europe/Berlin", - locale: "en_US", - }, - }); + // FIXME + // const fetchConfig = async (): Promise => ({ + // l10n: { + // keymap: "us", + // timezone: "Europe/Berlin", + // locale: "en_US", + // }, + // }); return ( - - {children} - + {children} ); } From 889e415b82face686f13643895e00308c0cc3ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Nov 2025 15:43:21 +0000 Subject: [PATCH 352/917] Rename type --- rust/share/device.storage.schema.json | 2 +- web/src/api/storage/proposal.ts | 8 ++++---- web/src/api/storage/system.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json index 39ebbfcee6..07c0918860 100644 --- a/rust/share/device.storage.schema.json +++ b/rust/share/device.storage.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2019-09/schema", "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/device.storage.schema.json", - "title": "Storage device", + "title": "Device", "description": "Schema to describe a device both in 'system' and 'proposal'.", "type": "object", "additionalProperties": false, diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 423e6b8bbc..1b3bdf0c01 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -13,7 +13,7 @@ export interface Proposal { /** * Expected layout of the system after the commit phase. */ - devices?: StorageDevice[]; + devices?: Device[]; /** * Sorted list of actions to execute during the commit phase. */ @@ -22,7 +22,7 @@ export interface Proposal { /** * Schema to describe a device both in 'system' and 'proposal'. */ -export interface StorageDevice { +export interface Device { sid: number; name: string; description?: string; @@ -33,9 +33,9 @@ export interface StorageDevice { multipath?: Multipath; partitionTable?: PartitionTable; partition?: Partition; - partitions?: StorageDevice[]; + partitions?: Device[]; volumeGroup?: VolumeGroup; - logicalVolumes?: StorageDevice[]; + logicalVolumes?: Device[]; } export interface Block { start: number; diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index d71d396564..d35c77a155 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -13,7 +13,7 @@ export interface System { /** * All relevant devices on the system */ - devices?: StorageDevice[]; + devices?: Device[]; /** * SIDs of the available drives */ @@ -55,7 +55,7 @@ export interface System { /** * Schema to describe a device both in 'system' and 'proposal'. */ -export interface StorageDevice { +export interface Device { sid: number; name: string; description?: string; @@ -66,9 +66,9 @@ export interface StorageDevice { multipath?: Multipath; partitionTable?: PartitionTable; partition?: Partition; - partitions?: StorageDevice[]; + partitions?: Device[]; volumeGroup?: VolumeGroup; - logicalVolumes?: StorageDevice[]; + logicalVolumes?: Device[]; } export interface Block { start: number; From 9ab927fa52bad90810daaeae4417289d0442d310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Nov 2025 16:24:21 +0000 Subject: [PATCH 353/917] Adapt questions --- web/src/api.ts | 11 ++- web/src/api/question.ts | 42 ++++++++- web/src/api/questions.ts | 44 ---------- .../questions/GenericQuestion.test.tsx | 2 +- .../components/questions/GenericQuestion.tsx | 2 +- .../LoadConfigRetryQuestion.test.tsx | 2 +- .../questions/LoadConfigRetryQuestion.tsx | 2 +- .../questions/LuksActivationQuestion.test.tsx | 2 +- .../questions/PackageErrorQuestion.test.tsx | 2 +- .../questions/PackageErrorQuestion.tsx | 2 +- .../questions/QuestionActions.test.tsx | 2 +- .../components/questions/QuestionActions.tsx | 2 +- .../questions/QuestionWithPassword.test.tsx | 2 +- .../questions/QuestionWithPassword.tsx | 2 +- .../components/questions/Questions.test.tsx | 2 +- web/src/components/questions/Questions.tsx | 10 +-- .../RegistrationCertificateQuestion.test.tsx | 2 +- .../RegistrationCertificateQuestion.tsx | 2 +- .../questions/UnsupportedAutoYaST.test.tsx | 2 +- .../questions/UnsupportedAutoYaST.tsx | 2 +- web/src/hooks/api.ts | 37 ++++++++ web/src/queries/questions.ts | 85 ------------------- web/src/types/questions.ts | 63 -------------- 23 files changed, 108 insertions(+), 216 deletions(-) delete mode 100644 web/src/api/questions.ts delete mode 100644 web/src/queries/questions.ts delete mode 100644 web/src/types/questions.ts diff --git a/web/src/api.ts b/web/src/api.ts index 7c07e5f05c..5274b06da3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -52,7 +52,7 @@ const getProposal = (): Promise => get("/api/v2/proposal"); const getIssues = (): Promise => get("/api/v2/issues"); -const getQuestions = (): Promise => get("/api/v2/questions"); +const getQuestions = (): Promise => get("/api/v2/questions"); const getStorageModel = (): Promise => get("/api/v2/private/storage_model"); @@ -67,6 +67,14 @@ const putStorageModel = (model: apiModel.Config) => put("/api/v2/private/storage const patchConfig = (config: Config) => patch("/api/v2/config", { update: config }); +const patchQuestion = (question: Question): Response => { + const { + id, + answer: { action, value }, + } = question; + return patch(`/api/v2/questions`, { answer: { id, action, value } }); +}; + const postAction = (action: Action) => post("/api/v2/action", action); const configureL10nAction = (config: L10nSystemConfig) => postAction(configureL10n(config)); @@ -93,6 +101,7 @@ export { putConfig, putStorageModel, patchConfig, + patchQuestion, configureL10nAction, activateStorageAction, probeStorageAction, diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 57db1df22b..0466470426 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -20,6 +20,44 @@ * find current contact information at www.suse.com. */ -type Question = object; +type Question = { + id: number; + text: string; + class: string; + field: SelectionField | Field; + actions: Action[]; + defaultAction?: string; + data?: { [key: string]: string }; + answer?: Answer; +}; -export type { Question }; +type Field = { + type: FieldType; +}; + +type SelectionField = { + type: FieldType.Select; + options: object; +}; + +type Action = { + id: string; + label: string; +}; + +type Answer = { + action: string; + value?: string; +}; + +enum FieldType { + None = "none", + Password = "password", + String = "string", + Select = "select", +} + +type AnswerCallback = (answeredQuestion: Question) => void; + +export { FieldType }; +export type { Question, Action, AnswerCallback }; diff --git a/web/src/api/questions.ts b/web/src/api/questions.ts deleted file mode 100644 index 4b78e0232e..0000000000 --- a/web/src/api/questions.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { get, patch } from "~/http"; -import { Question } from "~/types/questions"; - -/** - * Returns the list of questions - */ -const fetchQuestions = async (): Promise => await get("/api/v2/questions"); - -/** - * Update a questions' answer - * - * The answer is part of the Question object. - */ -const updateAnswer = async (question: Question): Promise => { - const { - id, - answer: { action, value }, - } = question; - await patch(`/api/v2/questions`, { answer: { id, action, value } }); -}; - -export { fetchQuestions, updateAnswer }; diff --git a/web/src/components/questions/GenericQuestion.test.tsx b/web/src/components/questions/GenericQuestion.test.tsx index 2c36b7e36c..98fb17edc9 100644 --- a/web/src/components/questions/GenericQuestion.test.tsx +++ b/web/src/components/questions/GenericQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import GenericQuestion from "~/components/questions/GenericQuestion"; const question: Question = { diff --git a/web/src/components/questions/GenericQuestion.tsx b/web/src/components/questions/GenericQuestion.tsx index b4f0697ca2..6690473b20 100644 --- a/web/src/components/questions/GenericQuestion.tsx +++ b/web/src/components/questions/GenericQuestion.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Content } from "@patternfly/react-core"; import { Popup } from "~/components/core"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; diff --git a/web/src/components/questions/LoadConfigRetryQuestion.test.tsx b/web/src/components/questions/LoadConfigRetryQuestion.test.tsx index 5d464a046b..5a2d6c2c06 100644 --- a/web/src/components/questions/LoadConfigRetryQuestion.test.tsx +++ b/web/src/components/questions/LoadConfigRetryQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import LoadConfigRetryQuestion from "~/components/questions/LoadConfigRetryQuestion"; const question: Question = { diff --git a/web/src/components/questions/LoadConfigRetryQuestion.tsx b/web/src/components/questions/LoadConfigRetryQuestion.tsx index d16c843a9e..37f8508eca 100644 --- a/web/src/components/questions/LoadConfigRetryQuestion.tsx +++ b/web/src/components/questions/LoadConfigRetryQuestion.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Content, Stack } from "@patternfly/react-core"; import { NestedContent, Popup } from "~/components/core"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 496a235a4d..3da73f7406 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { AnswerCallback, Question, FieldType } from "~/types/questions"; +import { AnswerCallback, Question, FieldType } from "~/api/question"; import { InstallationPhase } from "~/types/status"; import { Product } from "~/types/software"; import LuksActivationQuestion from "~/components/questions/LuksActivationQuestion"; diff --git a/web/src/components/questions/PackageErrorQuestion.test.tsx b/web/src/components/questions/PackageErrorQuestion.test.tsx index 5598e453a9..92bcd080f0 100644 --- a/web/src/components/questions/PackageErrorQuestion.test.tsx +++ b/web/src/components/questions/PackageErrorQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import PackageErrorQuestion from "~/components/questions/PackageErrorQuestion"; const answerFn = jest.fn(); diff --git a/web/src/components/questions/PackageErrorQuestion.tsx b/web/src/components/questions/PackageErrorQuestion.tsx index 5cdc0914ff..fb8804c459 100644 --- a/web/src/components/questions/PackageErrorQuestion.tsx +++ b/web/src/components/questions/PackageErrorQuestion.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Content, Stack } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { Icon } from "~/components/layout"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; diff --git a/web/src/components/questions/QuestionActions.test.tsx b/web/src/components/questions/QuestionActions.test.tsx index 168f0421e2..83dd8a0fc6 100644 --- a/web/src/components/questions/QuestionActions.test.tsx +++ b/web/src/components/questions/QuestionActions.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; let defaultAction = "sure"; diff --git a/web/src/components/questions/QuestionActions.tsx b/web/src/components/questions/QuestionActions.tsx index 026d247071..a28eea97c2 100644 --- a/web/src/components/questions/QuestionActions.tsx +++ b/web/src/components/questions/QuestionActions.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Popup } from "~/components/core"; import { fork } from "radashi"; -import { Action } from "~/types/questions"; +import { Action } from "~/api/question"; /** * A component for building a Question actions, using the defaultAction diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 2e1bfc9cdc..5e857ebac3 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; diff --git a/web/src/components/questions/QuestionWithPassword.tsx b/web/src/components/questions/QuestionWithPassword.tsx index 3811baf26c..addd1ddb99 100644 --- a/web/src/components/questions/QuestionWithPassword.tsx +++ b/web/src/components/questions/QuestionWithPassword.tsx @@ -24,7 +24,7 @@ import React, { useState } from "react"; import { Content, Form, FormGroup, Stack } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; import { InstallerOptions, PasswordInput, Popup } from "~/components/core"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; diff --git a/web/src/components/questions/Questions.test.tsx b/web/src/components/questions/Questions.test.tsx index 51e66474af..125017079a 100644 --- a/web/src/components/questions/Questions.test.tsx +++ b/web/src/components/questions/Questions.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, plainRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import Questions from "~/components/questions/Questions"; import * as GenericQuestionComponent from "~/components/questions/GenericQuestion"; diff --git a/web/src/components/questions/Questions.tsx b/web/src/components/questions/Questions.tsx index 30c5f3ed42..7dd2aeddcd 100644 --- a/web/src/components/questions/Questions.tsx +++ b/web/src/components/questions/Questions.tsx @@ -28,19 +28,19 @@ import PackageErrorQuestion from "~/components/questions/PackageErrorQuestion"; import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import RegistrationCertificateQuestion from "~/components/questions/RegistrationCertificateQuestion"; import LoadConfigRetryQuestion from "~/components/questions/LoadConfigRetryQuestion"; -import { useQuestions, useQuestionsConfig, useQuestionsChanges } from "~/queries/questions"; -import { AnswerCallback, FieldType } from "~/types/questions"; +import { useQuestions, useQuestionsChanges } from "~/hooks/api"; +import { patchQuestion } from "~/api"; +import { AnswerCallback, FieldType } from "~/api/question"; export default function Questions(): React.ReactNode { useQuestionsChanges(); const allQuestions = useQuestions(); - const questionsConfig = useQuestionsConfig(); const pendingQuestions = allQuestions.filter((q) => !q.answer); if (pendingQuestions.length === 0) return null; - const answerQuestion: AnswerCallback = (answeredQuestion) => - questionsConfig.mutate(answeredQuestion); + const answerQuestion: AnswerCallback = async (answeredQuestion) => + await patchQuestion(answeredQuestion); // Renders the first pending question const [currentQuestion] = pendingQuestions; diff --git a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx index 238e34a99f..3ce6932a52 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { Question, FieldType } from "~/types/questions"; +import { Question, FieldType } from "~/api/question"; import RegistrationCertificateQuestion from "~/components/questions/RegistrationCertificateQuestion"; const question: Question = { diff --git a/web/src/components/questions/RegistrationCertificateQuestion.tsx b/web/src/components/questions/RegistrationCertificateQuestion.tsx index 785553cf61..daece4fe58 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.tsx @@ -32,7 +32,7 @@ import { StackItem, } from "@patternfly/react-core"; import { Popup } from "~/components/core"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import QuestionActions from "~/components/questions/QuestionActions"; import { _ } from "~/i18n"; diff --git a/web/src/components/questions/UnsupportedAutoYaST.test.tsx b/web/src/components/questions/UnsupportedAutoYaST.test.tsx index 82e63ccbdf..039b0c526e 100644 --- a/web/src/components/questions/UnsupportedAutoYaST.test.tsx +++ b/web/src/components/questions/UnsupportedAutoYaST.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; -import { AnswerCallback, Question, FieldType } from "~/types/questions"; +import { AnswerCallback, Question, FieldType } from "~/api/question"; import UnsupportedAutoYaST from "~/components/questions/UnsupportedAutoYaST"; import { plainRender } from "~/test-utils"; diff --git a/web/src/components/questions/UnsupportedAutoYaST.tsx b/web/src/components/questions/UnsupportedAutoYaST.tsx index 21c62aaceb..227f3ccec9 100644 --- a/web/src/components/questions/UnsupportedAutoYaST.tsx +++ b/web/src/components/questions/UnsupportedAutoYaST.tsx @@ -30,7 +30,7 @@ import { ListVariant, Stack, } from "@patternfly/react-core"; -import { AnswerCallback, Question } from "~/types/questions"; +import { AnswerCallback, Question } from "~/api/question"; import { Page, Popup } from "~/components/core"; import QuestionActions from "~/components/questions/QuestionActions"; import { sprintf } from "sprintf-js"; diff --git a/web/src/hooks/api.ts b/web/src/hooks/api.ts index 77f322e875..8ea8c1c985 100644 --- a/web/src/hooks/api.ts +++ b/web/src/hooks/api.ts @@ -28,12 +28,14 @@ import { getExtendedConfig, solveStorageModel, getStorageModel, + getQuestions, } from "~/api"; import { useInstallerClient } from "~/context/installer"; import { System } from "~/api/system"; import { Proposal } from "~/api/proposal"; import { Config } from "~/api/config"; import { apiModel } from "~/api/storage"; +import { Question } from "~/api/question"; import { QueryHookOptions } from "~/types/queries"; const systemQuery = () => ({ @@ -105,6 +107,39 @@ function useExtendedConfig(options?: QueryHookOptions): Config | null { return func(query)?.data; } +const questionsQuery = () => ({ + queryKey: ["questions"], + queryFn: getQuestions, +}); + +const useQuestions = (options?: QueryHookOptions): Question[] => { + const func = options?.suspense ? useSuspenseQuery : useQuery; + return func(questionsQuery())?.data || []; +}; + +const useQuestionsChanges = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "QuestionAdded" || event.type === "QuestionAnswered") { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + } + }); + }, [client, queryClient]); + + React.useEffect(() => { + if (!client) return; + + return client.onConnect(() => { + queryClient.invalidateQueries({ queryKey: ["questions"] }); + }); + }, [client, queryClient]); +}; + const storageModelQuery = () => ({ queryKey: ["storageModel"], queryFn: getStorageModel, @@ -141,6 +176,8 @@ export { useProposal, useProposalChanges, useExtendedConfig, + useQuestions, + useQuestionsChanges, useStorageModel, useSolvedStorageModel, }; diff --git a/web/src/queries/questions.ts b/web/src/queries/questions.ts deleted file mode 100644 index 20c25bc348..0000000000 --- a/web/src/queries/questions.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useInstallerClient } from "~/context/installer"; -import { Question } from "~/types/questions"; -import { fetchQuestions, updateAnswer } from "~/api/questions"; - -/** - * Query to retrieve questions - */ -const questionsQuery = () => ({ - queryKey: ["questions"], - queryFn: fetchQuestions, -}); - -/** - * Hook that builds a mutation given question, allowing to answer it - - * TODO: improve/simplify it once the backend API is improved. - */ -const useQuestionsConfig = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: (question: Question) => updateAnswer(question), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["questions"] }), - }; - return useMutation(query); -}; - -/** - * Hook for listening questions changes and performing proper invalidations - */ -const useQuestionsChanges = () => { - const queryClient = useQueryClient(); - const client = useInstallerClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - if (event.type === "QuestionAdded" || event.type === "QuestionAnswered") { - queryClient.invalidateQueries({ queryKey: ["questions"] }); - } - }); - }, [client, queryClient]); - - React.useEffect(() => { - if (!client) return; - - return client.onConnect(() => { - queryClient.invalidateQueries({ queryKey: ["questions"] }); - }); - }, [client, queryClient]); -}; - -/** - * Hook for retrieving available questions - */ -const useQuestions = () => { - const { data: questions, isPending } = useQuery(questionsQuery()); - return isPending ? [] : questions; -}; - -export { questionsQuery, useQuestions, useQuestionsConfig, useQuestionsChanges }; diff --git a/web/src/types/questions.ts b/web/src/types/questions.ts deleted file mode 100644 index d3f799e7a3..0000000000 --- a/web/src/types/questions.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -type Question = { - id: number; - text: string; - class: string; - field: SelectionField | Field; - actions: Action[]; - defaultAction?: string; - data?: { [key: string]: string }; - answer?: Answer; -}; - -type Field = { - type: FieldType; -}; - -type SelectionField = { - type: FieldType.Select; - options: object; -}; - -type Action = { - id: string; - label: string; -}; - -type Answer = { - action: string; - value?: string; -}; - -enum FieldType { - None = "none", - Password = "password", - String = "string", - Select = "select", -} - -type AnswerCallback = (answeredQuestion: Question) => void; - -export { FieldType }; -export type { Question, Action, AnswerCallback }; From 8dd1ac73621860ca2b612fa651e7a222aaf1ed42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 09:41:19 +0000 Subject: [PATCH 354/917] Unify packages and patterns in SoftwareState * Introduce a new Resolvable type which contains a name and a type. --- rust/agama-software/src/lib.rs | 2 +- rust/agama-software/src/model.rs | 10 +- rust/agama-software/src/model/packages.rs | 16 +++ rust/agama-software/src/model/state.rs | 113 ++++++++++++++-------- rust/agama-software/src/zypp_server.rs | 15 ++- 5 files changed, 97 insertions(+), 59 deletions(-) diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index bdf7cf31ad..f38992faf5 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -42,7 +42,7 @@ pub mod service; pub use service::Service; mod model; -pub use model::{Model, ModelAdapter}; +pub use model::{Model, ModelAdapter, Resolvable, ResolvableType}; mod event; pub use event::Event; diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 0ca05c9a1f..6975497a44 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -30,13 +30,7 @@ use agama_utils::{ use async_trait::async_trait; use tokio::sync::{mpsc, oneshot}; -use crate::{ - model::{ - packages::ResolvableType, software_selection::SoftwareSelection, state::SoftwareState, - }, - service, - zypp_server::SoftwareAction, -}; +use crate::{model::state::SoftwareState, service, zypp_server::SoftwareAction}; pub mod conflict; pub mod packages; @@ -44,6 +38,8 @@ pub mod registration; pub mod software_selection; pub mod state; +pub use packages::{Resolvable, ResolvableType}; + /// Abstract the software-related configuration from the underlying system. /// /// It offers an API to query and set different software and product elements of a diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 1c2df708e7..67b7533a4a 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -20,6 +20,22 @@ use serde::{Deserialize, Serialize}; +/// Represents a software resolvable. +#[derive(Debug, Deserialize, PartialEq)] +pub struct Resolvable { + pub name: String, + pub r#type: ResolvableType, +} + +impl Resolvable { + pub fn new(name: &str, r#type: ResolvableType) -> Self { + Self { + name: name.to_string(), + r#type, + } + } +} + /// Software resolvable type (package or pattern). #[derive( Clone, Copy, Debug, Deserialize, Serialize, strum::Display, utoipa::ToSchema, PartialEq, diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 513b41bcfd..def8ab9566 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -27,22 +27,30 @@ use agama_utils::{ products::{ProductSpec, UserPattern}, }; +use crate::{Resolvable, ResolvableType}; + /// Represents the wanted software configuration. /// /// It includes the list of repositories, selected resolvables, configuration /// options, etc. This configuration is later applied by a model adapter. +/// +/// The SoftwareState is built by the [SoftwareStateBuilder] using different +/// sources (the product specification, the user configuration, etc.). #[derive(Debug)] pub struct SoftwareState { pub product: String, pub repositories: Vec, - // TODO: consider implementing a list to make easier working with them. - pub patterns: Vec, - pub packages: Vec, + pub resolvables: Vec, pub options: SoftwareOptions, } -/// Builder to create a [SoftwareState] struct from the other sources like the -/// product specification, the user configuration, etc. +/// Builder to create a [SoftwareState] struct from different sources. +/// +/// At this point it uses the following sources: +/// +/// * [Product specification](ProductSpec). +/// * [Software user configuration](Config). +/// * [System information](agama_utils::api::software::SystemInfo). pub struct SoftwareStateBuilder<'a> { product: &'a ProductSpec, config: Option<&'a Config>, @@ -86,6 +94,10 @@ impl<'a> SoftwareStateBuilder<'a> { state } + /// 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. fn add_system_config(&self, state: &mut SoftwareState, system: &SystemInfo) { let repositories = system .repositories @@ -95,6 +107,7 @@ impl<'a> SoftwareStateBuilder<'a> { state.repositories.extend(repositories); } + /// Adds the elements from the user configuration. fn add_user_config(&self, state: &mut SoftwareState, config: &Config) { let Some(software) = &config.software else { return; @@ -108,24 +121,28 @@ impl<'a> SoftwareStateBuilder<'a> { if let Some(patterns) = &software.patterns { match patterns { PatternsConfig::PatternsList(list) => { - state.patterns.retain(|p| p.optional == false); - state - .patterns - .extend(list.iter().map(|n| ResolvableName::new(n, false))); + // 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)), + ); } PatternsConfig::PatternsMap(map) => { + // Adds or removes elements to the list if let Some(add) = &map.add { - state - .patterns - .extend(add.iter().map(|n| ResolvableName::new(n, false))); + state.resolvables.extend( + add.iter() + .map(|n| ResolvableState::new(n, ResolvableType::Pattern, false)), + ); } if let Some(remove) = &map.remove { // NOTE: should we notify when a user wants to remove a // pattern which is not optional? state - .patterns - .retain(|p| !(p.optional && remove.contains(&p.name))); + .resolvables + .retain(|p| !(p.optional && remove.contains(&p.resolvable.name))); } } } @@ -153,24 +170,28 @@ impl<'a> SoftwareStateBuilder<'a> { }) .collect(); - let mut patterns: Vec = software + let mut resolvables: Vec = software .mandatory_patterns .iter() - .map(|p| ResolvableName::new(p, false)) + .map(|p| ResolvableState::new(p, ResolvableType::Pattern, false)) .collect(); - patterns.extend( + resolvables.extend( software .optional_patterns .iter() - .map(|p| ResolvableName::new(p, true)), + .map(|p| ResolvableState::new(p, ResolvableType::Pattern, true)), ); - patterns.extend(software.user_patterns.iter().filter_map(|p| match p { + resolvables.extend(software.user_patterns.iter().filter_map(|p| match p { UserPattern::Plain(_) => None, UserPattern::Preselected(pattern) => { if pattern.selected { - Some(ResolvableName::new(&pattern.name, true)) + Some(ResolvableState::new( + &pattern.name, + ResolvableType::Pattern, + true, + )) } else { None } @@ -180,8 +201,7 @@ impl<'a> SoftwareStateBuilder<'a> { SoftwareState { product: software.base_product.clone(), repositories, - patterns, - packages: vec![], + resolvables, options: Default::default(), } } @@ -230,17 +250,17 @@ impl From<&agama_utils::api::software::Repository> for Repository { /// Defines a resolvable to be selected. #[derive(Debug, PartialEq)] -pub struct ResolvableName { +pub struct ResolvableState { /// Resolvable name. - pub name: String, + pub resolvable: Resolvable, /// Whether this resolvable is optional or not. pub optional: bool, } -impl ResolvableName { - pub fn new(name: &str, optional: bool) -> Self { +impl ResolvableState { + pub fn new(name: &str, r#type: ResolvableType, optional: bool) -> Self { Self { - name: name.to_string(), + resolvable: Resolvable::new(name, r#type), optional, } } @@ -265,7 +285,10 @@ mod tests { products::ProductSpec, }; - use crate::model::state::{ResolvableName, SoftwareStateBuilder}; + use crate::model::{ + packages::ResolvableType, + state::{ResolvableState, SoftwareStateBuilder}, + }; fn build_user_config(patterns: Option) -> Config { let repo = RepositoryConfig { @@ -318,10 +341,10 @@ mod tests { assert_eq!(state.product, "openSUSE".to_string()); assert_eq!( - state.patterns, + state.resolvables, vec![ - ResolvableName::new("enhanced_base", false), - ResolvableName::new("selinux", true), + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true), ] ); } @@ -358,11 +381,11 @@ mod tests { .with_config(&config) .build(); assert_eq!( - state.patterns, + state.resolvables, vec![ - ResolvableName::new("enhanced_base", false), - ResolvableName::new("selinux", true), - ResolvableName::new("gnome", false) + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true), + ResolvableState::new("gnome", ResolvableType::Pattern, false) ] ); } @@ -380,8 +403,12 @@ mod tests { .with_config(&config) .build(); assert_eq!( - state.patterns, - vec![ResolvableName::new("enhanced_base", false),] + state.resolvables, + vec![ResolvableState::new( + "enhanced_base", + ResolvableType::Pattern, + false + ),] ); } @@ -398,10 +425,10 @@ mod tests { .with_config(&config) .build(); assert_eq!( - state.patterns, + state.resolvables, vec![ - ResolvableName::new("enhanced_base", false), - ResolvableName::new("selinux", true) + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("selinux", ResolvableType::Pattern, true) ] ); } @@ -416,10 +443,10 @@ mod tests { .with_config(&config) .build(); assert_eq!( - state.patterns, + state.resolvables, vec![ - ResolvableName::new("enhanced_base", false), - ResolvableName::new("gnome", false) + ResolvableState::new("enhanced_base", ResolvableType::Pattern, false), + ResolvableState::new("gnome", ResolvableType::Pattern, false) ] ); } diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index ff741496e8..a704aab55c 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -252,8 +252,7 @@ impl ZyppServer { fn read(&self, zypp: &zypp_agama::Zypp) -> Result { let repositories = zypp - .list_repositories() - .unwrap() + .list_repositories()? .into_iter() .map(|repo| state::Repository { name: repo.user_name, @@ -267,8 +266,7 @@ impl ZyppServer { // FIXME: read the real product. product: "SLES".to_string(), repositories, - patterns: vec![], - packages: vec![], + resolvables: vec![], options: Default::default(), }; Ok(state) @@ -365,17 +363,18 @@ impl ZyppServer { } _ = progress.cast(progress::message::Next::new(Scope::Software)); - for pattern in &state.patterns { + 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( - &pattern.name, - zypp_agama::ResolvableKind::Pattern, + &resolvable.name, + resolvable.r#type.into(), zypp_agama::ResolvableSelected::Installation, ); if let Err(error) = result { - let message = format!("Could not select pattern '{}'", &pattern.name); + let message = format!("Could not select pattern '{}'", &resolvable.name); issues.push( Issue::new("software.select_pattern", &message, IssueSeverity::Error) .with_details(&error.to_string()), From 1697aae44a3ea770d8f90a026a2381a5cd535369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 12:33:37 +0000 Subject: [PATCH 355/917] Set the product on PATCH /config requests. --- rust/agama-manager/src/service.rs | 74 +++++++++++++++++++------------ 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 452be1de62..faf811f291 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -112,6 +112,7 @@ impl Service { /// If a default product is set, it asks the other services to initialize their configurations. pub async fn setup(&mut self) -> Result<(), Error> { self.read_registries().await?; + if let Some(product) = self.products.default_product() { let product = Arc::new(RwLock::new(product.clone())); _ = self.software.cast(software::message::SetConfig::new( @@ -119,9 +120,9 @@ impl Service { None, )); self.product = Some(product); - } else { - self.notify_no_product() } + + self.update_issues(); Ok(()) } @@ -173,15 +174,34 @@ impl Service { Ok(()) } - fn notify_no_product(&self) { - let issue = Issue::new( - "no_product", - "No product has been selected.", - IssueSeverity::Error, - ); - _ = self - .issues - .cast(issue::message::Set::new(Scope::Manager, vec![issue])); + fn set_product_from_config(&mut self, config: &Config) { + let product_id = config + .software + .as_ref() + .and_then(|s| s.product.as_ref()) + .and_then(|p| p.id.as_ref()); + + if let Some(id) = product_id { + if let Some(product_spec) = self.products.find(&id) { + let product = RwLock::new(product_spec.clone()); + self.product = Some(Arc::new(product)); + } + } + } + + fn update_issues(&self) { + if self.product.is_some() { + _ = self.issues.cast(issue::message::Clear::new(Scope::Manager)); + } else { + let issue = Issue::new( + "no_product", + "No product has been selected.", + IssueSeverity::Error, + ); + _ = self + .issues + .cast(issue::message::Set::new(Scope::Manager, vec![issue])); + } } } @@ -248,22 +268,8 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { /// Sets the user configuration with the given values. - /// Sets the config. async fn handle(&mut self, message: message::SetConfig) -> Result<(), Error> { - let product_id = message - .config - .software - .as_ref() - .and_then(|s| s.product.as_ref()) - .and_then(|p| p.id.as_ref()); - - if let Some(id) = product_id { - if let Some(product_spec) = self.products.find(&id) { - let product = RwLock::new(product_spec.clone()); - self.product = Some(Arc::new(product)); - _ = self.issues.cast(issue::message::Clear::new(Scope::Manager)); - } - } + self.set_product_from_config(&message.config); self.config = message.config.clone(); let config = message.config; @@ -279,8 +285,6 @@ impl MessageHandler for Service { config.software.clone(), )) .await?; - } else { - self.notify_no_product(); } self.l10n @@ -291,6 +295,7 @@ impl MessageHandler for Service { .call(storage::message::SetConfig::new(config.storage.clone())) .await?; + self.update_issues(); Ok(()) } } @@ -303,6 +308,7 @@ impl MessageHandler for Service { /// config, then it keeps the values from the current config. async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; + self.set_product_from_config(&config); if let Some(l10n) = &config.l10n { self.l10n @@ -322,7 +328,19 @@ impl MessageHandler for Service { .await?; } + if let Some(product) = &self.product { + if let Some(software) = &config.software { + self.software + .call(software::message::SetConfig::with( + Arc::clone(&product), + software.clone(), + )) + .await?; + } + } + self.config = config; + self.update_issues(); Ok(()) } } From 0786e4b8992a95dff528b5b53c4cea4626de6847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 12:35:17 +0000 Subject: [PATCH 356/917] Add support to specify a list of resolvables * It is an private API for other Agama services to ask for the installation of a given resolvable. --- rust/agama-manager/src/message.rs | 1 + rust/agama-manager/src/service.rs | 10 ++++ rust/agama-server/src/server/web.rs | 29 +++++++++- rust/agama-software/src/message.rs | 25 +++++++++ rust/agama-software/src/model.rs | 33 ------------ rust/agama-software/src/model/packages.rs | 3 +- .../src/model/software_selection.rs | 54 ++++++------------- rust/agama-software/src/model/state.rs | 39 ++++++++++++-- rust/agama-software/src/service.rs | 22 +++++++- 9 files changed, 136 insertions(+), 80 deletions(-) diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 83676f56a6..2ee9bcecf1 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_software::Resolvable; use agama_utils::{ actor::Message, api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index faf811f291..284c673e16 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -408,3 +408,13 @@ impl MessageHandler for Service { .await?) } } + +// FIXME: write a macro to forward a message. +#[async_trait] +impl MessageHandler for Service { + /// It sets the software resolvables. + async fn handle(&mut self, message: software::message::SetResolvables) -> Result<(), Error> { + self.software.call(message).await?; + Ok(()) + } +} diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 389b686cc7..ada38182cc 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -23,6 +23,7 @@ use crate::server::config_schema; use agama_lib::error::ServiceError; use agama_manager::{self as manager, message}; +use agama_software::Resolvable; use agama_utils::{ actor::Handler, api::{ @@ -33,9 +34,9 @@ use agama_utils::{ question, }; use axum::{ - extract::State, + extract::{Path, State}, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{get, post, put}, Json, Router, }; use hyper::StatusCode; @@ -109,6 +110,7 @@ pub async fn server_service( "/private/storage_model", get(get_storage_model).put(set_storage_model), ) + .route("/private/resolvables/:id", put(set_resolvables)) .with_state(state)) } @@ -378,6 +380,29 @@ async fn set_storage_model( Ok(()) } +#[utoipa::path( + put, + path = "/resolvables/:id", + context_path = "/api/v2", + responses( + (status = 200, description = "The resolvables list was updated.") + ) +)] +async fn set_resolvables( + State(state): State, + Path(id): Path, + Json(resolvables): Json>, +) -> ServerResult<()> { + state + .manager + .call(agama_software::message::SetResolvables::new( + id, + resolvables, + )) + .await?; + Ok(()) +} + fn to_option_response(value: Option) -> Response { match value { Some(inner) => Json(inner).into_response(), diff --git a/rust/agama-software/src/message.rs b/rust/agama-software/src/message.rs index 2d468bb389..fbe68f6521 100644 --- a/rust/agama-software/src/message.rs +++ b/rust/agama-software/src/message.rs @@ -26,6 +26,8 @@ use agama_utils::{ use std::sync::Arc; use tokio::sync::RwLock; +use crate::Resolvable; + #[derive(Clone)] pub struct GetSystem; @@ -66,6 +68,13 @@ impl SetConfig { pub fn new(product: Arc>, config: Option) -> Self { Self { config, product } } + + pub fn with(product: Arc>, config: T) -> Self { + Self { + config: Some(config), + product, + } + } } pub struct GetProposal; @@ -91,3 +100,19 @@ pub struct Finish; impl Message for Finish { type Reply = (); } + +// Sets a resolvables list +pub struct SetResolvables { + pub id: String, + pub resolvables: Vec, +} + +impl SetResolvables { + pub fn new(id: String, resolvables: Vec) -> Self { + Self { id, resolvables } + } +} + +impl Message for SetResolvables { + type Reply = (); +} diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 6975497a44..386a55bc04 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -50,18 +50,6 @@ pub trait ModelAdapter: Send + Sync + 'static { /// List of available patterns. async fn patterns(&self) -> Result, service::Error>; - /// Gets resolvables set for given combination of id, type and optional flag - fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec; - - /// Sets resolvables set for given combination of id, type and optional flag - async fn set_resolvables( - &mut self, - id: &str, - r#type: ResolvableType, - resolvables: Vec, - optional: bool, - ) -> Result<(), service::Error>; - async fn compute_proposal(&self) -> Result; /// Refresh repositories information. @@ -91,7 +79,6 @@ pub struct Model { zypp_sender: mpsc::UnboundedSender, // FIXME: what about having a SoftwareServiceState to keep business logic state? selected_product: Option, - software_selection: SoftwareSelection, } impl Model { @@ -100,7 +87,6 @@ impl Model { Ok(Self { zypp_sender, selected_product: None, - software_selection: SoftwareSelection::default(), }) } } @@ -146,29 +132,10 @@ impl ModelAdapter for Model { Ok(rx.await??) } - fn get_resolvables(&self, id: &str, r#type: ResolvableType, optional: bool) -> Vec { - self.software_selection - .get(id, r#type, optional) - .unwrap_or_default() - } - async fn refresh(&mut self) -> Result<(), service::Error> { unimplemented!() } - async fn set_resolvables( - &mut self, - id: &str, - r#type: ResolvableType, - resolvables: Vec, - optional: bool, - ) -> Result<(), service::Error> { - self.software_selection - .set(&self.zypp_sender, id, r#type, optional, resolvables) - .await?; - Ok(()) - } - async fn finish(&self) -> Result<(), service::Error> { let (tx, rx) = oneshot::channel(); self.zypp_sender.send(SoftwareAction::Finish(tx))?; diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 67b7533a4a..39b923b9cb 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -21,9 +21,10 @@ use serde::{Deserialize, Serialize}; /// Represents a software resolvable. -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, utoipa::ToSchema)] pub struct Resolvable { pub name: String, + #[serde(rename = "type")] pub r#type: ResolvableType, } diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs index 2913a2a1b1..224db13224 100644 --- a/rust/agama-software/src/model/software_selection.rs +++ b/rust/agama-software/src/model/software_selection.rs @@ -18,15 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use tokio::sync::{mpsc, oneshot}; - -use crate::{model::packages::ResolvableType, service, zypp_server::SoftwareAction}; +use crate::{service, Resolvable}; pub struct ResolvablesSelection { id: String, optional: bool, - resolvables: Vec, - r#type: ResolvableType, + resolvables: Vec, } /// A selection of resolvables to be installed. @@ -41,39 +38,17 @@ pub struct SoftwareSelection { impl SoftwareSelection { /// Updates a set of resolvables. /// - /// * `zypp` - pointer to message bus to zypp thread to do real action /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). /// * `optional` - Whether the selection is optional or not. /// * `resolvables` - The resolvables included in the set. pub async fn set( &mut self, - zypp: &mpsc::UnboundedSender, id: &str, - r#type: ResolvableType, optional: bool, - resolvables: Vec, + resolvables: Vec, ) -> Result<(), service::Error> { - let list = self.find_or_create_selection(id, r#type, optional); - // FIXME: use reference counting here, if multiple ids require some package, to not unselect it - let (tx, rx) = oneshot::channel(); - zypp.send(SoftwareAction::UnsetResolvables { - tx, - resolvables: list.resolvables.clone(), - r#type: r#type.into(), - optional, - })?; - rx.await??; - + let list = self.find_or_create_selection(id, optional); list.resolvables = resolvables; - let (tx, rx) = oneshot::channel(); - zypp.send(SoftwareAction::UnsetResolvables { - tx, - resolvables: list.resolvables.clone(), - r#type: r#type.into(), - optional, - })?; - rx.await??; Ok(()) } @@ -82,30 +57,31 @@ impl SoftwareSelection { /// * `id` - The id of the set. /// * `r#type` - The type of the resolvables (patterns or packages). /// * `optional` - Whether the selection is optional or not. - pub fn get(&self, id: &str, r#type: ResolvableType, optional: bool) -> Option> { + pub fn get(&self, id: &str, optional: bool) -> Option> { self.selections .iter() - .find(|l| l.id == id && l.r#type == r#type && l.optional == optional) + .find(|l| l.id == id && l.optional == optional) .map(|l| l.resolvables.clone()) } - fn find_or_create_selection( - &mut self, - id: &str, - r#type: ResolvableType, - optional: bool, - ) -> &mut ResolvablesSelection { + pub fn resolvables<'a>(&'a self) -> impl Iterator + 'a { + self.selections + .iter() + .map(|s| s.resolvables.clone()) + .flatten() + } + + fn find_or_create_selection(&mut self, id: &str, optional: bool) -> &mut ResolvablesSelection { let found = self .selections .iter() - .position(|l| l.id == id && l.r#type == r#type && l.optional == optional); + .position(|l| l.id == id && l.optional == optional); if let Some(index) = found { &mut self.selections[index] } else { let selection = ResolvablesSelection { id: id.to_string(), - r#type, optional, resolvables: vec![], }; diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index def8ab9566..03022fa9a5 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -27,7 +27,7 @@ use agama_utils::{ products::{ProductSpec, UserPattern}, }; -use crate::{Resolvable, ResolvableType}; +use crate::{model::software_selection::SoftwareSelection, Resolvable, ResolvableType}; /// Represents the wanted software configuration. /// @@ -52,9 +52,14 @@ pub struct SoftwareState { /// * [Software user configuration](Config). /// * [System information](agama_utils::api::software::SystemInfo). pub struct SoftwareStateBuilder<'a> { + /// Product specification. product: &'a ProductSpec, + /// Configuration. config: Option<&'a Config>, + /// Information from the underlying system. system: Option<&'a SystemInfo>, + /// Agama's software selection. + selection: Option<&'a SoftwareSelection>, } impl<'a> SoftwareStateBuilder<'a> { @@ -64,6 +69,7 @@ impl<'a> SoftwareStateBuilder<'a> { product, config: None, system: None, + selection: None, } } @@ -78,6 +84,11 @@ impl<'a> SoftwareStateBuilder<'a> { self } + 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. pub fn build(self) -> SoftwareState { @@ -91,6 +102,10 @@ impl<'a> SoftwareStateBuilder<'a> { self.add_user_config(&mut state, config); } + if let Some(selection) = self.selection { + self.add_selection(&mut state, selection); + } + state } @@ -153,6 +168,14 @@ 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)); + state.resolvables.extend(resolvables) + } + fn from_product_spec(&self) -> SoftwareState { let software = &self.product.software; let repositories = software @@ -209,10 +232,16 @@ impl<'a> SoftwareStateBuilder<'a> { impl SoftwareState { // TODO: Add SoftwareSelection as additional argument. - pub fn build_from(product: &ProductSpec, config: &Config, system: &SystemInfo) -> Self { + pub fn build_from( + product: &ProductSpec, + config: &Config, + system: &SystemInfo, + selection: &SoftwareSelection, + ) -> Self { SoftwareStateBuilder::for_product(product) .with_config(config) .with_system(system) + .with_selection(selection) .build() } } @@ -259,8 +288,12 @@ pub struct ResolvableState { impl ResolvableState { pub fn new(name: &str, r#type: ResolvableType, optional: bool) -> Self { + Self::new_with_resolvable(&Resolvable::new(name, r#type), optional) + } + + pub fn new_with_resolvable(resolvable: &Resolvable, optional: bool) -> Self { Self { - resolvable: Resolvable::new(name, r#type), + resolvable: resolvable.clone(), optional, } } diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 087fac9cf3..bc18f67e84 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -22,7 +22,7 @@ use std::{process::Command, sync::Arc}; use crate::{ message, - model::{state::SoftwareState, ModelAdapter}, + model::{software_selection::SoftwareSelection, state::SoftwareState, ModelAdapter}, zypp_server::{self, SoftwareAction}, }; use agama_utils::{ @@ -77,6 +77,7 @@ pub struct Service { progress: Handler, events: event::Sender, state: State, + selection: SoftwareSelection, } #[derive(Default)] @@ -99,6 +100,7 @@ impl Service { progress, events, state: Default::default(), + selection: Default::default(), } } @@ -148,7 +150,13 @@ impl MessageHandler> for Service { scope: Scope::Software, })?; - let software = SoftwareState::build_from(&product, &self.state.config, &self.state.system); + let software = SoftwareState::build_from( + &product, + &self.state.config, + &self.state.system, + &self.selection, + ); + tracing::info!("Wanted software state: {software:?}"); let model = self.model.clone(); let issues = self.issues.clone(); @@ -225,6 +233,16 @@ impl MessageHandler for Service { } } +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::SetResolvables) -> Result<(), Error> { + self.selection + .set(&message.id, false, message.resolvables) + .await?; + Ok(()) + } +} + const LIVE_REPO_DIR: &str = "/run/initramfs/live/install"; fn find_install_repository() -> Option { From 9ab57c149c07e59e4e7c0bdd6f245a4bca69b00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 14:01:17 +0000 Subject: [PATCH 357/917] Simplify the SoftwareSelection struct * Use a HashMap to keep the resolvables lists. * Remove the "optional" argument because it is not used. * Re-enable and fix the tests. --- .../src/model/software_selection.rs | 92 +++++++------------ rust/agama-software/src/service.rs | 4 +- 2 files changed, 32 insertions(+), 64 deletions(-) diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs index 224db13224..218206f241 100644 --- a/rust/agama-software/src/model/software_selection.rs +++ b/rust/agama-software/src/model/software_selection.rs @@ -18,7 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{service, Resolvable}; +use std::collections::HashMap; + +use crate::Resolvable; pub struct ResolvablesSelection { id: String, @@ -31,9 +33,7 @@ pub struct ResolvablesSelection { /// It holds a selection of patterns and packages to be installed and whether they are optional or /// not. This class is similar to the `PackagesProposal` YaST module. #[derive(Default)] -pub struct SoftwareSelection { - selections: Vec, -} +pub struct SoftwareSelection(HashMap>); impl SoftwareSelection { /// Updates a set of resolvables. @@ -41,80 +41,50 @@ impl SoftwareSelection { /// * `id` - The id of the set. /// * `optional` - Whether the selection is optional or not. /// * `resolvables` - The resolvables included in the set. - pub async fn set( - &mut self, - id: &str, - optional: bool, - resolvables: Vec, - ) -> Result<(), service::Error> { - let list = self.find_or_create_selection(id, optional); - list.resolvables = resolvables; - Ok(()) + pub fn set(&mut self, id: &str, resolvables: Vec) { + self.0.insert(id.to_string(), resolvables); } - /// Returns a set of resolvables. - /// - /// * `id` - The id of the set. - /// * `r#type` - The type of the resolvables (patterns or packages). - /// * `optional` - Whether the selection is optional or not. - pub fn get(&self, id: &str, optional: bool) -> Option> { - self.selections - .iter() - .find(|l| l.id == id && l.optional == optional) - .map(|l| l.resolvables.clone()) + /// Remove the selection list with the given ID. + pub fn remove(&mut self, id: &str) { + self.0.remove(id); } + /// Returns all the resolvables. pub fn resolvables<'a>(&'a self) -> impl Iterator + 'a { - self.selections - .iter() - .map(|s| s.resolvables.clone()) - .flatten() - } - - fn find_or_create_selection(&mut self, id: &str, optional: bool) -> &mut ResolvablesSelection { - let found = self - .selections - .iter() - .position(|l| l.id == id && l.optional == optional); - - if let Some(index) = found { - &mut self.selections[index] - } else { - let selection = ResolvablesSelection { - id: id.to_string(), - optional, - resolvables: vec![], - }; - self.selections.push(selection); - self.selections.last_mut().unwrap() - } + self.0.values().flatten().cloned() } } -/* TODO: Fix tests with real mock of libzypp #[cfg(test)] mod tests { - use super::*; + use crate::ResolvableType; + + use super::{Resolvable, SoftwareSelection}; #[test] fn test_set_selection() { - let mut selection = SoftwareSelection::new(); - selection.add("agama", ResolvableType::Package, false, &["agama-scripts"]); - selection.set("agama", ResolvableType::Package, false, &["suse"]); + let mut selection = SoftwareSelection::default(); + let resolvable = Resolvable::new("agama-scripts", ResolvableType::Package); + selection.set("agama", vec![resolvable]); + let resolvable = Resolvable::new("btrfsprogs", ResolvableType::Pattern); + selection.set("software", vec![resolvable]); - let packages = selection - .get("agama", ResolvableType::Package, false) - .unwrap(); - assert_eq!(packages.len(), 1); + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert_eq!(all_resolvables.len(), 2); } #[test] fn test_remove_selection() { - let mut selection = SoftwareSelection::new(); - selection.add("agama", ResolvableType::Package, true, &["agama-scripts"]); - selection.remove("agama", ResolvableType::Package, true); - let packages = selection.get("agama", ResolvableType::Package, true); - assert_eq!(packages, None); + let mut selection = SoftwareSelection::default(); + let resolvable = Resolvable::new("agama-scripts", ResolvableType::Package); + selection.set("agama", vec![resolvable]); + + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert_eq!(all_resolvables.len(), 1); + + selection.remove("agama"); + let all_resolvables: Vec<_> = selection.resolvables().collect(); + assert!(all_resolvables.is_empty()); } } - */ diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index bc18f67e84..2cedf3c880 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -236,9 +236,7 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::SetResolvables) -> Result<(), Error> { - self.selection - .set(&message.id, false, message.resolvables) - .await?; + self.selection.set(&message.id, message.resolvables); Ok(()) } } From 9f76751fd84ff08c6f1d5023c8207cf4eecf5f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 14:04:54 +0000 Subject: [PATCH 358/917] Drop unused code related to resolvables handling --- rust/agama-manager/src/message.rs | 1 - rust/agama-software/src/model/packages.rs | 11 ---- .../src/model/software_selection.rs | 6 -- rust/agama-software/src/zypp_server.rs | 57 +------------------ 4 files changed, 1 insertion(+), 74 deletions(-) diff --git a/rust/agama-manager/src/message.rs b/rust/agama-manager/src/message.rs index 2ee9bcecf1..83676f56a6 100644 --- a/rust/agama-manager/src/message.rs +++ b/rust/agama-manager/src/message.rs @@ -18,7 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_software::Resolvable; use agama_utils::{ actor::Message, api::{Action, Config, IssueMap, Proposal, Status, SystemInfo}, diff --git a/rust/agama-software/src/model/packages.rs b/rust/agama-software/src/model/packages.rs index 39b923b9cb..d04ff4c640 100644 --- a/rust/agama-software/src/model/packages.rs +++ b/rust/agama-software/src/model/packages.rs @@ -58,14 +58,3 @@ impl From for zypp_agama::ResolvableKind { } } } - -/// Resolvable list specification. -#[derive(Deserialize, Serialize, utoipa::ToSchema)] -pub struct ResolvableParams { - /// List of resolvables. - pub names: Vec, - /// Resolvable type. - pub r#type: ResolvableType, - /// Whether the resolvables are optional or not. - pub optional: bool, -} diff --git a/rust/agama-software/src/model/software_selection.rs b/rust/agama-software/src/model/software_selection.rs index 218206f241..2371dbd678 100644 --- a/rust/agama-software/src/model/software_selection.rs +++ b/rust/agama-software/src/model/software_selection.rs @@ -22,12 +22,6 @@ use std::collections::HashMap; use crate::Resolvable; -pub struct ResolvablesSelection { - id: String, - optional: bool, - resolvables: Vec, -} - /// A selection of resolvables to be installed. /// /// It holds a selection of patterns and packages to be installed and whether they are optional or diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index a704aab55c..63f7030828 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -34,10 +34,7 @@ use tokio::sync::{ }; use zypp_agama::ZyppError; -use crate::model::{ - packages::ResolvableType, - state::{self, SoftwareState}, -}; +use crate::model::state::{self, SoftwareState}; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -96,18 +93,6 @@ pub enum SoftwareAction { ProductSpec, oneshot::Sender>, ), - SetResolvables { - tx: oneshot::Sender>, - resolvables: Vec, - r#type: ResolvableType, - optional: bool, - }, - UnsetResolvables { - tx: oneshot::Sender>, - resolvables: Vec, - r#type: ResolvableType, - optional: bool, - }, Write { state: SoftwareState, progress: Handler, @@ -194,46 +179,6 @@ impl ZyppServer { SoftwareAction::Finish(tx) => { self.finish(zypp, tx).await?; } - SoftwareAction::SetResolvables { - tx, - r#type, - resolvables, - optional, - } => { - // TODO: support optional with check if resolvable is available - for res in resolvables { - let result = zypp.select_resolvable( - &res, - r#type.into(), - zypp_agama::ResolvableSelected::Installation, - ); - if let Err(e) = result { - tx.send(Err(e)) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - break; - } - } - } - SoftwareAction::UnsetResolvables { - tx, - r#type, - resolvables, - optional, - } => { - // TODO: support optional with check if resolvable is available - for res in resolvables { - let result = zypp.unselect_resolvable( - &res, - r#type.into(), - zypp_agama::ResolvableSelected::Installation, - ); - if let Err(e) = result { - tx.send(Err(e)) - .map_err(|_| ZyppDispatchError::ResponseChannelClosed)?; - break; - } - } - } SoftwareAction::ComputeProposal(product_spec, sender) => { self.compute_proposal(product_spec, sender, zypp).await? } From 89a5b319c8537419e20d0664ef0b640cbe4f09d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 14:10:38 +0000 Subject: [PATCH 359/917] Remove the unused agama_software::events module --- rust/agama-software/src/event.rs | 40 -------------------------------- rust/agama-software/src/lib.rs | 3 --- 2 files changed, 43 deletions(-) delete mode 100644 rust/agama-software/src/event.rs diff --git a/rust/agama-software/src/event.rs b/rust/agama-software/src/event.rs deleted file mode 100644 index 7b750454b6..0000000000 --- a/rust/agama-software/src/event.rs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; - -/// Localization-related events. -// FIXME: is it really needed to implement Deserialize? -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "name")] -pub enum Event { - /// Proposal changed. - ProposalChanged, - /// The underlying system changed. - SystemChanged, - /// The use configuration changed. - ConfigChanged, -} - -/// Multi-producer single-consumer events sender. -pub type Sender = mpsc::UnboundedSender; -/// Multi-producer single-consumer events receiver. -pub type Receiver = mpsc::UnboundedReceiver; diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index f38992faf5..808d465c07 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -44,8 +44,5 @@ pub use service::Service; mod model; pub use model::{Model, ModelAdapter, Resolvable, ResolvableType}; -mod event; -pub use event::Event; - pub mod message; mod zypp_server; From 194a6717a3eea9c21f1fd0a25391988f8f310859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Sun, 9 Nov 2025 14:32:10 +0000 Subject: [PATCH 360/917] Adapt PackagesProposal.SetResolvables to the new API --- .../y2dir/manager/modules/PackagesProposal.rb | 6 +-- service/lib/agama/http/clients/main.rb | 12 +++++ service/test/agama/http/clients/main_test.rb | 45 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 service/test/agama/http/clients/main_test.rb diff --git a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb index ecb8d8a001..7d0ceeabce 100644 --- a/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb +++ b/service/lib/agama/dbus/y2dir/manager/modules/PackagesProposal.rb @@ -18,7 +18,7 @@ # find current contact information at www.suse.com. require "yast" -require "agama/http/clients/software" +require "agama/http/clients/main" # :nodoc: module Yast @@ -26,7 +26,7 @@ module Yast class PackagesProposalClass < Module def main puts "Loading mocked module #{__FILE__}" - @client = Agama::HTTP::Clients::Software.new(::Logger.new($stdout)) + @client = Agama::HTTP::Clients::Main.new(::Logger.new($stdout)) end # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L118 @@ -40,7 +40,7 @@ def AddResolvables(unique_id, type, resolvables, optional: false) # @see https://github.com/yast/yast-yast2/blob/b8cd178b7f341f6e3438782cb703f4a3ab0529ed/library/general/src/modules/PackagesProposal.rb#L145 def SetResolvables(unique_id, type, resolvables, optional: false) - client.set_resolvables(unique_id, type, resolvables || [], optional) + client.set_resolvables(unique_id, type, resolvables || []) true end diff --git a/service/lib/agama/http/clients/main.rb b/service/lib/agama/http/clients/main.rb index a6d70f5aec..600bef871c 100644 --- a/service/lib/agama/http/clients/main.rb +++ b/service/lib/agama/http/clients/main.rb @@ -29,6 +29,18 @@ class Main < Base def install post("v2/action", '"install"') end + + # Sets a list of resolvables for installation. + # + # @param unique_id [String] Unique ID to identify the list. + # @param type [String] Resolvable type (e.g., "package" or "pattern"). + # @param resolvables [Array] Resolvables names. + def set_resolvables(unique_id, type, resolvables) + data = resolvables.map do |name| + { "name" => name, "type" => type } + end + put("v2/private/resolvables/#{unique_id}", data) + end end end end diff --git a/service/test/agama/http/clients/main_test.rb b/service/test/agama/http/clients/main_test.rb new file mode 100644 index 0000000000..e1ddd668ca --- /dev/null +++ b/service/test/agama/http/clients/main_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "agama/http/clients/main" + +describe Agama::HTTP::Clients::Main do + subject(:main) { described_class.new(Logger.new($stdout)) } + let(:response) { instance_double(Net::HTTPResponse, body: "") } + + before do + allow(File).to receive(:read).with("/run/agama/token") + .and_return("123456") + end + + describe "#set_resolvables" do + it "calls the end-point to set resolvables" do + url = URI("http://localhost/api/v2/private/resolvables/storage") + data = [{ "name" => "btrfsprogs", "type" => "package" }].to_json + expect(Net::HTTP).to receive(:put).with(url, data, { + "Content-Type": "application/json", + Authorization: "Bearer 123456" + }).and_return(response) + main.set_resolvables("storage", "package", ["btrfsprogs"]) + end + end +end From 0652b82b3d7cc7a28d7a36d51e4da34060c0aa9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 10 Nov 2025 07:14:42 +0000 Subject: [PATCH 361/917] Fix questions schema --- rust/agama-lib/share/profile.schema.json | 25 +++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 14ee57cfb8..071e1aac4b 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -1024,18 +1024,15 @@ "title": "Question class", "description": "Each question has a \"class\" which works as an identifier.", "type": "string", - "examples": ["storage.activate_multipath"] + "examples": [ + "storage.activate_multipath" + ] }, "text": { "title": "Question text", "description": "Question full text", "type": "string" }, - "answer": { - "title": "Question answer", - "description": "Answer to use for the question.", - "type": "string" - }, "password": { "title": "Password provided as response to a password-based question", "type": "string" @@ -1044,7 +1041,21 @@ "title": "Additional data for matching questions", "description": "Additional data for matching questions and answers", "type": "object", - "examples": [{ "device": "/dev/sda" }] + "examples": [ + { + "device": "/dev/sda" + } + ] + }, + "action": { + "title": "Predefined question action", + "description": "Action to use for the question.", + "type": "string" + }, + "value": { + "title": "Predefined question value", + "description": "Value to use for the question.", + "type": "string" } } }, From a096784b51bbbc933c2621597bf857cc0903abdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 10 Nov 2025 10:39:45 +0000 Subject: [PATCH 362/917] Log when the product is known --- rust/Cargo.lock | 1 + rust/agama-manager/Cargo.toml | 1 + rust/agama-manager/src/service.rs | 2 ++ 3 files changed, 4 insertions(+) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a61972b45c..53011ea3ab 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -141,6 +141,7 @@ dependencies = [ "thiserror 2.0.16", "tokio", "tokio-test", + "tracing", "zbus", ] diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 7900b723d9..61e507b0f0 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -15,6 +15,7 @@ async-trait = "0.1.83" zbus = { version = "5", default-features = false, features = ["tokio"] } merge-struct = "0.1.0" serde_json = "1.0.140" +tracing = "0.1.41" [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 284c673e16..aaa7524390 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -185,6 +185,8 @@ impl Service { if let Some(product_spec) = self.products.find(&id) { let product = RwLock::new(product_spec.clone()); self.product = Some(Arc::new(product)); + } else { + tracing::warn!("Unknown product '{id}'"); } } } From 073fcc97bb601397830305dbb1f61206b71422a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 10 Nov 2025 10:40:13 +0000 Subject: [PATCH 363/917] Update SoftwareStateBuilder documentation --- rust/agama-software/src/model/state.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index 03022fa9a5..f6a717661b 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -50,7 +50,8 @@ pub struct SoftwareState { /// /// * [Product specification](ProductSpec). /// * [Software user configuration](Config). -/// * [System information](agama_utils::api::software::SystemInfo). +/// * [System information](SystemInfo). +/// * [Agama software selection](SoftwareSelection). pub struct SoftwareStateBuilder<'a> { /// Product specification. product: &'a ProductSpec, @@ -231,7 +232,6 @@ impl<'a> SoftwareStateBuilder<'a> { } impl SoftwareState { - // TODO: Add SoftwareSelection as additional argument. pub fn build_from( product: &ProductSpec, config: &Config, From 5d283ee037929fa458daf933214192c5f650f9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 11:35:17 +0000 Subject: [PATCH 364/917] Adapt issues --- web/src/App.tsx | 3 +- web/src/api.ts | 4 +- web/src/api/hostname.ts | 2 + web/src/api/issue.ts | 76 +++++++- web/src/api/issues.ts | 37 ---- web/src/api/storage/dasd.ts | 2 + web/src/api/storage/devices.ts | 178 ------------------ web/src/api/storage/iscsi.ts | 81 +++++++- web/src/api/storage/types/checks.ts | 107 ----------- web/src/api/storage/zfcp.ts | 2 + .../components/core/InstallButton.test.tsx | 2 +- web/src/components/core/InstallButton.tsx | 5 +- web/src/components/core/InstallerOptions.tsx | 4 +- web/src/components/core/IssuesAlert.test.tsx | 2 +- web/src/components/core/IssuesAlert.tsx | 2 +- web/src/components/core/IssuesDrawer.test.tsx | 2 +- web/src/components/core/IssuesDrawer.tsx | 6 +- .../overview/StorageSection.test.tsx | 2 +- .../components/overview/StorageSection.tsx | 5 +- .../product/ProductRegistrationAlert.test.tsx | 6 +- .../product/ProductRegistrationAlert.tsx | 5 +- web/src/components/software/SoftwarePage.tsx | 4 +- .../components/storage/FixableConfigInfo.tsx | 4 +- .../storage/ProposalFailedInfo.test.tsx | 2 +- .../components/storage/ProposalFailedInfo.tsx | 9 +- .../components/storage/ProposalPage.test.tsx | 2 +- web/src/components/storage/ProposalPage.tsx | 10 +- web/src/components/users/UsersPage.tsx | 4 +- web/src/hooks/api.ts | 64 ++++++- web/src/hooks/storage/issues.ts | 52 +++++ web/src/hooks/storage/system.ts | 12 ++ web/src/queries/issues.ts | 95 ---------- web/src/types/issues.ts | 95 ---------- 33 files changed, 326 insertions(+), 560 deletions(-) delete mode 100644 web/src/api/issues.ts delete mode 100644 web/src/api/storage/devices.ts delete mode 100644 web/src/api/storage/types/checks.ts create mode 100644 web/src/hooks/storage/issues.ts delete mode 100644 web/src/queries/issues.ts delete mode 100644 web/src/types/issues.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index b6f4464d5a..7db7370ed3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,8 +24,7 @@ import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Loading } from "~/components/layout"; import { useProduct, useProductChanges } from "~/queries/software"; -import { useSystemChanges, useProposalChanges } from "~/hooks/api"; -import { useIssuesChanges } from "~/queries/issues"; +import { useSystemChanges, useProposalChanges, useIssuesChanges } from "~/hooks/api"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; import { ROOT, PRODUCT } from "~/routes/paths"; import { InstallationPhase } from "~/types/status"; diff --git a/web/src/api.ts b/web/src/api.ts index 5274b06da3..9787c969c2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -23,7 +23,7 @@ import { get, patch, post, put } from "~/http"; import { apiModel } from "~/api/storage"; import { Config } from "~/api/config"; -import { Issue } from "~/api/issue"; +import { IssuesMap } from "~/api/issue"; import { Proposal } from "~/api/proposal"; import { Question } from "~/api/question"; import { Status } from "~/api/status"; @@ -50,7 +50,7 @@ const getSystem = (): Promise => get("/api/v2/system"); const getProposal = (): Promise => get("/api/v2/proposal"); -const getIssues = (): Promise => get("/api/v2/issues"); +const getIssues = (): Promise => get("/api/v2/issues"); const getQuestions = (): Promise => get("/api/v2/questions"); diff --git a/web/src/api/hostname.ts b/web/src/api/hostname.ts index 902b160359..d119ebaf0b 100644 --- a/web/src/api/hostname.ts +++ b/web/src/api/hostname.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { get, put } from "~/http"; import { Hostname } from "~/types/hostname"; diff --git a/web/src/api/issue.ts b/web/src/api/issue.ts index 41d5691f46..5efb26ecd9 100644 --- a/web/src/api/issue.ts +++ b/web/src/api/issue.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,6 +20,76 @@ * find current contact information at www.suse.com. */ -type Issue = object; +/** + * Known scopes for issues. + */ +type IssuesScope = "localization" | "product" | "software" | "storage" | "users" | "iscsi"; + +/** + * Source of the issue + * + * Which is the origin of the issue (the system, the configuration or unknown). + */ +enum IssueSource { + /** Unknown source (it is kind of a fallback value) */ + Unknown = "unknown", + /** An unexpected situation in the system (e.g., missing device). */ + System = "system", + /** Wrong or incomplete configuration (e.g., an authentication mechanism is not set) */ + Config = "config", +} + +/** + * Issue severity + * + * It indicates how severe the problem is. + */ +enum IssueSeverity { + /** Just a warning, the installation can start */ + Warn = "warn", + /** An important problem that makes the installation not possible */ + Error = "error", +} + +/** + * Pre-installation issue as they come from the API. + */ +type ApiIssue = { + /** Issue description */ + description: string; + /** Issue kind **/ + kind: string; + /** Issue details */ + details?: string; + /** Where the issue comes from */ + source: IssueSource; + /** How severe is the issue */ + severity: IssueSeverity; +}; + +/** + * Issues grouped by scope as they come from the API. + */ +type IssuesMap = { + localization?: ApiIssue[]; + software?: ApiIssue[]; + product?: ApiIssue[]; + storage?: ApiIssue[]; + iscsi?: ApiIssue[]; + users?: ApiIssue[]; +}; + +/** + * Pre-installation issue augmented with the scope. + */ +type Issue = ApiIssue & { scope: IssuesScope }; + +/** + * Validation error + */ +type ValidationError = { + message: string; +}; -export type { Issue }; +export { IssueSource, IssueSeverity }; +export type { ApiIssue, IssuesMap, IssuesScope, Issue, ValidationError }; diff --git a/web/src/api/issues.ts b/web/src/api/issues.ts deleted file mode 100644 index 888febd137..0000000000 --- a/web/src/api/issues.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { get } from "~/http"; -import { Issue, IssuesMap, IssuesScope } from "~/types/issues"; - -/** - * Return the issues of the given scope. - */ -const fetchIssues = async (): Promise => { - const issues = (await get(`/api/v2/issues`)) as IssuesMap; - return Object.keys(issues).reduce((all: Issue[], key: IssuesScope) => { - const scoped = issues[key].map((i) => ({ ...i, scope: key })); - return all.concat(scoped); - }, []); -}; - -export { fetchIssues }; diff --git a/web/src/api/storage/dasd.ts b/web/src/api/storage/dasd.ts index 26c5767a1e..d9317970a3 100644 --- a/web/src/api/storage/dasd.ts +++ b/web/src/api/storage/dasd.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { post, get, put } from "~/http"; import { DASDDevice } from "~/types/dasd"; diff --git a/web/src/api/storage/devices.ts b/web/src/api/storage/devices.ts deleted file mode 100644 index b6a6d5fd63..0000000000 --- a/web/src/api/storage/devices.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { get } from "~/http"; -import { - Component, - Device, - DevicesDirtyResponse, - Drive, - Filesystem, - LvmVg, - Md, - Multipath, - Partition, - PartitionTable, - Raid, -} from "./types"; -import { StorageDevice } from "~/types/storage"; - -/** - * @fixme Use a transformation instead of building the devices as part of the fetch function, see - * https://tkdodo.eu/blog/react-query-data-transformations. - * - * Returns the list of devices in the given scope - * - * @param scope - "system": devices in the current state of the system; "result": - * devices in the proposal ("stage") - */ -const fetchDevices = async (scope: "result" | "system") => { - const buildDevice = (jsonDevice: Device, jsonDevices: Device[]) => { - const buildDefaultDevice = (): StorageDevice => { - return { - sid: 0, - name: "", - description: "", - isDrive: false, - type: "drive", - }; - }; - - const buildCollectionFromNames = (names: string[]): StorageDevice[] => { - return names.map((name) => ({ ...buildDefaultDevice(), name })); - }; - - const buildCollection = (sids: number[], jsonDevices: Device[]): StorageDevice[] => { - if (sids === null || sids === undefined) return []; - - // Some devices might not be found because they are not exported, for example, the members of - // a BIOS RAID, see bsc#1237803. - return sids - .map((sid) => jsonDevices.find((dev) => dev.deviceInfo?.sid === sid)) - .filter((jsonDevice) => jsonDevice) - .map((jsonDevice) => buildDevice(jsonDevice, jsonDevices)); - }; - - const addDriveInfo = (device: StorageDevice, info: Drive) => { - device.isDrive = true; - device.type = info.type; - device.vendor = info.vendor; - device.model = info.model; - device.driver = info.driver; - device.bus = info.bus; - device.busId = info.busId; - device.transport = info.transport; - device.sdCard = info.info.sdCard; - device.dellBOSS = info.info.dellBOSS; - }; - - const addRaidInfo = (device: StorageDevice, info: Raid) => { - device.devices = buildCollectionFromNames(info.devices); - }; - - const addMultipathInfo = (device: StorageDevice, info: Multipath) => { - device.wires = buildCollectionFromNames(info.wires); - }; - - const addMDInfo = (device: StorageDevice, info: Md) => { - device.type = "md"; - device.level = info.level; - device.uuid = info.uuid; - device.devices = buildCollection(info.devices, jsonDevices); - }; - - const addPartitionInfo = (device: StorageDevice, info: Partition) => { - device.type = "partition"; - device.isEFI = info.efi; - }; - - const addVgInfo = (device: StorageDevice, info: LvmVg) => { - device.type = "lvmVg"; - device.size = info.size; - device.physicalVolumes = buildCollection(info.physicalVolumes, jsonDevices); - device.logicalVolumes = buildCollection(info.logicalVolumes, jsonDevices); - }; - - const addLvInfo = (device: StorageDevice) => { - device.type = "lvmLv"; - }; - - const addPTableInfo = (device: StorageDevice, tableInfo: PartitionTable) => { - const partitions = buildCollection(tableInfo.partitions, jsonDevices); - device.partitionTable = { - type: tableInfo.type, - partitions, - unpartitionedSize: device.size - partitions.reduce((s, p) => s + p.size, 0), - unusedSlots: tableInfo.unusedSlots.map((s) => Object.assign({}, s)), - }; - }; - - const addFilesystemInfo = (device: StorageDevice, filesystemInfo: Filesystem) => { - const buildMountPath = (path: string) => (path.length > 0 ? path : undefined); - const buildLabel = (label: string) => (label.length > 0 ? label : undefined); - device.filesystem = { - sid: filesystemInfo.sid, - type: filesystemInfo.type, - mountPath: buildMountPath(filesystemInfo.mountPath), - label: buildLabel(filesystemInfo.label), - }; - }; - - const addComponentInfo = (device: StorageDevice, info: Component) => { - device.component = { - type: info.type, - deviceNames: info.deviceNames, - }; - }; - - const device = buildDefaultDevice(); - - const process = (jsonProperty: string, method: Function) => { - const info = jsonDevice[jsonProperty]; - if (info === undefined || info === null) return; - - method(device, info); - }; - - process("deviceInfo", Object.assign); - process("drive", addDriveInfo); - process("raid", addRaidInfo); - process("multipath", addMultipathInfo); - process("md", addMDInfo); - process("blockDevice", Object.assign); - process("partition", addPartitionInfo); - process("lvmVg", addVgInfo); - process("lvmLv", addLvInfo); - process("partitionTable", addPTableInfo); - process("filesystem", addFilesystemInfo); - process("component", addComponentInfo); - - return device; - }; - - const jsonDevices: Device[] = await get(`/api/storage/devices/${scope}`); - return jsonDevices.map((d) => buildDevice(d, jsonDevices)); -}; - -const fetchDevicesDirty = (): Promise => get("/api/storage/devices/dirty"); - -export { fetchDevices, fetchDevicesDirty }; diff --git a/web/src/api/storage/iscsi.ts b/web/src/api/storage/iscsi.ts index 6c10d4dfc1..58fb6b4dcf 100644 --- a/web/src/api/storage/iscsi.ts +++ b/web/src/api/storage/iscsi.ts @@ -20,8 +20,87 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { del, get, patch, post } from "~/http"; -import { ISCSIInitiator, ISCSINode } from "~/api/storage/types"; + +export type ISCSIAuth = { + /** + * Password for authentication by target. + */ + password?: string | null; + /** + * Password for authentication by initiator. + */ + reverse_password?: string | null; + /** + * Username for authentication by initiator. + */ + reverse_username?: string | null; + /** + * Username for authentication by target. + */ + username?: string | null; +}; + +export type ISCSIInitiator = { + ibft: boolean; + name: string; +}; + +/** + * ISCSI node + */ +export type ISCSINode = { + /** + * Target IP address (in string-like form). + */ + address: string; + /** + * Whether the node is connected (there is a session). + */ + connected: boolean; + /** + * Whether the node was initiated by iBFT + */ + ibft: boolean; + /** + * Artificial ID to match it against the D-Bus backend. + */ + id: number; + /** + * Interface name. + */ + interface: string; + /** + * Target port. + */ + port: number; + /** + * Startup status (TODO: document better) + */ + startup: string; + /** + * Target name. + */ + target: string; +}; + +export type InitiatorParams = { + /** + * iSCSI initiator name. + */ + name: string; +}; + +export type LoginParams = ISCSIAuth & { + /** + * Startup value. + */ + startup: string; +}; + +export type LoginResult = "Success" | "InvalidStartup" | "Failed"; const ISCSI_NODES_NAMESPACE = "/api/storage/iscsi/nodes"; diff --git a/web/src/api/storage/types/checks.ts b/web/src/api/storage/types/checks.ts deleted file mode 100644 index cf3dab7393..0000000000 --- a/web/src/api/storage/types/checks.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import * as config from "../config"; - -// Type guards. - -export function isFormattedDrive(drive: config.DriveElement): drive is config.NonPartitionedDrive { - return "filesystem" in drive; -} - -export function isPartitionedDrive(drive: config.DriveElement): drive is config.PartitionedDrive { - return !("filesystem" in drive); -} - -export function isSimpleSearchAll(search: config.SearchElement): search is config.SimpleSearchAll { - return search === "*"; -} - -export function isSimpleSearchByName( - search: config.SearchElement, -): search is config.SimpleSearchByName { - return !isSimpleSearchAll(search) && typeof search === "string"; -} - -export function isAdvancedSearch(search: config.SearchElement): search is config.AdvancedSearch { - return !isSimpleSearchAll(search) && !isSimpleSearchByName(search); -} - -export function isPartitionToDelete( - partition: config.PartitionElement, -): partition is config.PartitionToDelete { - return "delete" in partition; -} - -export function isPartitionToDeleteIfNeeded( - partition: config.PartitionElement, -): partition is config.PartitionToDeleteIfNeeded { - return "deleteIfNeeded" in partition; -} - -export function isRegularPartition( - partition: config.PartitionElement, -): partition is config.RegularPartition { - if ("generate" in partition) return false; - - return !isPartitionToDelete(partition) && !isPartitionToDeleteIfNeeded(partition); -} - -export function isFilesystemTypeAny( - fstype: config.FilesystemType, -): fstype is config.FilesystemTypeAny { - return typeof fstype === "string"; -} - -export function isFilesystemTypeBtrfs( - fstype: config.FilesystemType, -): fstype is config.FilesystemTypeBtrfs { - return !isFilesystemTypeAny(fstype) && "btrfs" in fstype; -} - -export function isSizeCurrent(size: config.SizeValueWithCurrent): size is config.SizeCurrent { - return size === "current"; -} - -export function isSizeBytes( - size: config.Size | config.SizeValueWithCurrent, -): size is config.SizeBytes { - return typeof size === "number"; -} - -export function isSizeString( - size: config.Size | config.SizeValueWithCurrent, -): size is config.SizeString { - return typeof size === "string" && size !== "current"; -} - -export function isSizeValue(size: config.Size): size is config.SizeValue { - return isSizeBytes(size) || isSizeString(size); -} - -export function isSizeTuple(size: config.Size): size is config.SizeTuple { - return Array.isArray(size); -} - -export function isSizeRange(size: config.Size): size is config.SizeRange { - return !isSizeTuple(size) && typeof size === "object"; -} diff --git a/web/src/api/storage/zfcp.ts b/web/src/api/storage/zfcp.ts index 72dccb0719..bbf528d3f8 100644 --- a/web/src/api/storage/zfcp.ts +++ b/web/src/api/storage/zfcp.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { post, get } from "~/http"; import { ZFCPDisk, ZFCPController, ZFCPConfig } from "~/types/zfcp"; diff --git a/web/src/components/core/InstallButton.test.tsx b/web/src/components/core/InstallButton.test.tsx index f953801184..d352ba3adf 100644 --- a/web/src/components/core/InstallButton.test.tsx +++ b/web/src/components/core/InstallButton.test.tsx @@ -25,7 +25,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import { installerRender, mockRoutes } from "~/test-utils"; import { InstallButton } from "~/components/core"; import { PRODUCT, ROOT } from "~/routes/paths"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; const mockStartInstallationFn = jest.fn(); let mockIssuesList: Issue[]; diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 2323a5d6cf..2c146ce648 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -24,8 +24,7 @@ import React, { useId, useState } from "react"; import { Button, ButtonProps, Stack, Tooltip, TooltipProps } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { startInstallation } from "~/api/manager"; -import { useAllIssues } from "~/queries/issues"; -import { IssueSeverity } from "~/types/issues"; +import { useIssues } from "~/hooks/api"; import { useLocation } from "react-router-dom"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; @@ -79,7 +78,7 @@ const InstallButton = ( ) => { const labelId = useId(); const tooltipId = useId(); - const issues = useAllIssues().filter((i) => i.severity === IssueSeverity.Error); + const issues = useIssues({ suspense: true }); const [isOpen, setIsOpen] = useState(false); const location = useLocation(); const hasIssues = !isEmpty(issues); diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 34b680bcc2..4effa05fea 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -90,7 +90,7 @@ const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( const KeyboardFormInput = ({ value, onChange }: SelectProps) => { const { l10n: { keymaps }, - } = useSystem(); + } = useSystem({ suspense: true }); if (!localConnection()) { return ( @@ -554,7 +554,7 @@ export default function InstallerOptions({ const location = useLocation(); const { l10n: { locales }, - } = useSystem(); + } = useSystem({ suspense: true }); const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); const { phase } = useInstallerStatus({ suspense: true }); const { selectedProduct } = useProduct({ suspense: true }); diff --git a/web/src/components/core/IssuesAlert.test.tsx b/web/src/components/core/IssuesAlert.test.tsx index 494a972cbb..4c198f71a2 100644 --- a/web/src/components/core/IssuesAlert.test.tsx +++ b/web/src/components/core/IssuesAlert.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { IssuesAlert } from "~/components/core"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; import { SOFTWARE } from "~/routes/paths"; describe("IssueAlert", () => { diff --git a/web/src/components/core/IssuesAlert.tsx b/web/src/components/core/IssuesAlert.tsx index 18c635aefd..ff46d6e4de 100644 --- a/web/src/components/core/IssuesAlert.tsx +++ b/web/src/components/core/IssuesAlert.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Alert, List, ListItem } from "@patternfly/react-core"; import { _ } from "~/i18n"; -import { Issue } from "~/types/issues"; +import { Issue } from "~/api/issue"; import Link from "./Link"; import { PATHS } from "~/routes/software"; diff --git a/web/src/components/core/IssuesDrawer.test.tsx b/web/src/components/core/IssuesDrawer.test.tsx index 20c52bab18..427e049446 100644 --- a/web/src/components/core/IssuesDrawer.test.tsx +++ b/web/src/components/core/IssuesDrawer.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { InstallationPhase } from "~/types/status"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; import IssuesDrawer from "./IssuesDrawer"; let phase = InstallationPhase.Config; diff --git a/web/src/components/core/IssuesDrawer.tsx b/web/src/components/core/IssuesDrawer.tsx index 493cd00f1a..ee495ff09d 100644 --- a/web/src/components/core/IssuesDrawer.tsx +++ b/web/src/components/core/IssuesDrawer.tsx @@ -30,9 +30,9 @@ import { Stack, } from "@patternfly/react-core"; import Link from "~/components/core/Link"; -import { useAllIssues } from "~/queries/issues"; +import { useIssues } from "~/hooks/api"; import { useInstallerStatus } from "~/queries/status"; -import { IssueSeverity } from "~/types/issues"; +import { IssueSeverity } from "~/api/issue"; import { InstallationPhase } from "~/types/status"; import { _ } from "~/i18n"; @@ -40,7 +40,7 @@ import { _ } from "~/i18n"; * Drawer for displaying installation issues */ const IssuesDrawer = forwardRef(({ onClose }: { onClose: () => void }, ref) => { - const issues = useAllIssues().filter((i) => i.severity === IssueSeverity.Error); + const issues = useIssues().filter((i) => i.severity === IssueSeverity.Error); const { phase } = useInstallerStatus({ suspense: true }); // FIXME: share below headers with navigation menu diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index 39f47e7b14..bc452cb09f 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; -import { IssueSeverity, IssueSource } from "~/types/issues"; +import { IssueSeverity, IssueSource } from "~/api/issue"; let mockModel = { drives: [], diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index 28cf116d59..25e2096cf6 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -23,9 +23,8 @@ import React from "react"; import { Content } from "@patternfly/react-core"; import { deviceLabel } from "~/components/storage/utils"; -import { useAvailableDevices, useDevices } from "~/hooks/storage/system"; +import { useAvailableDevices, useDevices, useIssues } from "~/hooks/storage/system"; import { useConfigModel } from "~/queries/storage/config-model"; -import { useSystemErrors } from "~/queries/issues"; import { storage } from "~/api/system"; import { apiModel } from "~/api/storage"; import { _ } from "~/i18n"; @@ -92,7 +91,7 @@ const ModelSummary = ({ model }: { model: apiModel.Config }): React.ReactNode => const NoModelSummary = (): React.ReactNode => { const availableDevices = useAvailableDevices(); - const systemErrors = useSystemErrors("storage"); + const systemErrors = useIssues(); const hasDisks = !!availableDevices.length; const hasResult = !systemErrors.length; diff --git a/web/src/components/product/ProductRegistrationAlert.test.tsx b/web/src/components/product/ProductRegistrationAlert.test.tsx index fc93a796c7..ef1e01049f 100644 --- a/web/src/components/product/ProductRegistrationAlert.test.tsx +++ b/web/src/components/product/ProductRegistrationAlert.test.tsx @@ -26,9 +26,9 @@ import { installerRender, mockRoutes } from "~/test-utils"; import ProductRegistrationAlert from "./ProductRegistrationAlert"; import { Product } from "~/types/software"; import { useProduct } from "~/queries/software"; -import { useIssues } from "~/queries/issues"; +import { useScopeIssues } from "~/hooks/issues"; import { PRODUCT, REGISTRATION, ROOT } from "~/routes/paths"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; const tw: Product = { id: "Tumbleweed", @@ -66,7 +66,7 @@ const registrationIssue: Issue = { jest.mock("~/queries/issues", () => ({ ...jest.requireActual("~/queries/issues"), - useIssues: (): ReturnType => issues, + useIssues: (): ReturnType => issues, })); const rendersNothingInSomePaths = () => { diff --git a/web/src/components/product/ProductRegistrationAlert.tsx b/web/src/components/product/ProductRegistrationAlert.tsx index 1e6660670d..1c7bbbcdd4 100644 --- a/web/src/components/product/ProductRegistrationAlert.tsx +++ b/web/src/components/product/ProductRegistrationAlert.tsx @@ -28,7 +28,7 @@ import { useProduct } from "~/queries/software"; import { REGISTRATION, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { useIssues } from "~/queries/issues"; +import { useScopeIssues } from "~/hooks/api"; const LinkToRegistration = ({ text }: { text: string }) => { const location = useLocation(); @@ -45,7 +45,8 @@ const LinkToRegistration = ({ text }: { text: string }) => { export default function ProductRegistrationAlert() { const location = useLocation(); const { selectedProduct: product } = useProduct(); - const issues = useIssues("product"); + // FIXME: what scope reports these issues with the new API? + const issues = useScopeIssues("product"); const registrationRequired = issues.find((i) => i.kind === "missing_registration"); // NOTE: it shouldn't be mounted in these paths, but let's prevent rendering diff --git a/web/src/components/software/SoftwarePage.tsx b/web/src/components/software/SoftwarePage.tsx index 707d51e996..574fed3294 100644 --- a/web/src/components/software/SoftwarePage.tsx +++ b/web/src/components/software/SoftwarePage.tsx @@ -36,7 +36,7 @@ import { } from "@patternfly/react-core"; import { Link, Page, IssuesAlert } from "~/components/core"; import UsedSize from "./UsedSize"; -import { useIssues } from "~/queries/issues"; +import { useScopeIssues } from "~/hooks/api"; import { usePatterns, useSoftwareProposal, @@ -133,7 +133,7 @@ const ReloadSection = ({ * Software page component */ function SoftwarePage(): React.ReactNode { - const issues = useIssues("software"); + const issues = useScopeIssues("software"); const proposal = useSoftwareProposal(); const patterns = usePatterns(); const repos = useRepositories(); diff --git a/web/src/components/storage/FixableConfigInfo.tsx b/web/src/components/storage/FixableConfigInfo.tsx index 92d8cd28da..d62e067978 100644 --- a/web/src/components/storage/FixableConfigInfo.tsx +++ b/web/src/components/storage/FixableConfigInfo.tsx @@ -23,7 +23,7 @@ import React from "react"; import { Alert, List, ListItem } from "@patternfly/react-core"; import { n_ } from "~/i18n"; -import { useConfigErrors } from "~/queries/issues"; +import { useScopeIssues } from "~/hooks/api"; const Description = ({ errors }) => { return ( @@ -40,7 +40,7 @@ const Description = ({ errors }) => { * */ export default function FixableConfigInfo() { - const configErrors = useConfigErrors("storage"); + const configErrors = useScopeIssues("storage"); if (!configErrors.length) return; diff --git a/web/src/components/storage/ProposalFailedInfo.test.tsx b/web/src/components/storage/ProposalFailedInfo.test.tsx index 43ebc2c25e..b979c33bab 100644 --- a/web/src/components/storage/ProposalFailedInfo.test.tsx +++ b/web/src/components/storage/ProposalFailedInfo.test.tsx @@ -25,7 +25,7 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import ProposalFailedInfo from "./ProposalFailedInfo"; import { LogicalVolume } from "~/types/storage/data"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; import { apiModel } from "~/api/storage/types"; const mockUseConfigErrorsFn = jest.fn(); diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index 29c4d47a0f..aa06e36c36 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -22,9 +22,8 @@ import React from "react"; import { Alert, Content } from "@patternfly/react-core"; -import { IssueSeverity } from "~/types/issues"; -import { useStorageModel } from "~/hooks/api"; -import { useIssues, useConfigErrors } from "~/queries/issues"; +import { useStorageModel, useScopeIssues } from "~/hooks/api"; +import { useConfigIssues } from "~/hooks/storage/issues"; import * as partitionUtils from "~/components/storage/utils/partition"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; @@ -88,8 +87,8 @@ const Description = () => { * - The generated proposal contains no errors. */ export default function ProposalFailedInfo() { - const configErrors = useConfigErrors("storage"); - const errors = useIssues("storage").filter((s) => s.severity === IssueSeverity.Error); + const configErrors = useConfigIssues(); + const errors = useScopeIssues("storage"); if (configErrors.length !== 0) return; if (errors.length === 0) return; diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 5f0b708e8e..7c69a65e90 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -30,7 +30,7 @@ import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import ProposalPage from "~/components/storage/ProposalPage"; import { StorageDevice } from "~/types/storage"; -import { Issue, IssueSeverity, IssueSource } from "~/types/issues"; +import { Issue, IssueSeverity, IssueSource } from "~/api/issue"; const disk: StorageDevice = { sid: 60, diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 2c8df575a0..5a8966f786 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -50,14 +50,14 @@ import { useResetConfig } from "~/hooks/storage/config"; import { useConfigModel } from "~/queries/storage/config-model"; import { useZFCPSupported } from "~/queries/storage/zfcp"; import { useDASDSupported } from "~/queries/storage/dasd"; -import { useSystemErrors, useConfigErrors } from "~/queries/issues"; +import { useSystemIssues, useConfigIssues } from "~/hooks/storage/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; import { useProgress, useProgressChanges } from "~/queries/progress"; import { useNavigate } from "react-router-dom"; function InvalidConfigEmptyState(): React.ReactNode { - const errors = useConfigErrors("storage"); + const errors = useConfigIssues(); const reset = useResetConfig(); return ( @@ -175,7 +175,7 @@ function ProposalEmptyState(): React.ReactNode { function ProposalSections(): React.ReactNode { const model = useConfigModel({ suspense: true }); - const systemErrors = useSystemErrors("storage"); + const systemErrors = useSystemIssues(); const hasResult = !systemErrors.length; return ( @@ -223,8 +223,8 @@ function ProposalSections(): React.ReactNode { export default function ProposalPage(): React.ReactNode { const model = useConfigModel({ suspense: true }); const availableDevices = useAvailableDevices(); - const systemErrors = useSystemErrors("storage"); - const configErrors = useConfigErrors("storage"); + const systemErrors = useSystemIssues(); + const configErrors = useConfigIssues(); const progress = useProgress("storage"); const navigate = useNavigate(); diff --git a/web/src/components/users/UsersPage.tsx b/web/src/components/users/UsersPage.tsx index 301f67d50d..ce568d8cba 100644 --- a/web/src/components/users/UsersPage.tsx +++ b/web/src/components/users/UsersPage.tsx @@ -24,11 +24,11 @@ import React from "react"; import { Content, Grid, GridItem } from "@patternfly/react-core"; import { IssuesAlert, Page } from "~/components/core"; import { FirstUser, RootUser } from "~/components/users"; -import { useIssues } from "~/queries/issues"; +import { useScopeIssues } from "~/hooks/api"; import { _ } from "~/i18n"; export default function UsersPage() { - const issues = useIssues("users"); + const issues = useScopeIssues("users"); return ( diff --git a/web/src/hooks/api.ts b/web/src/hooks/api.ts index 8ea8c1c985..3f425f4231 100644 --- a/web/src/hooks/api.ts +++ b/web/src/hooks/api.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { useCallback } from "react"; import { useQuery, useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; import { getSystem, @@ -29,6 +29,7 @@ import { solveStorageModel, getStorageModel, getQuestions, + getIssues, } from "~/api"; import { useInstallerClient } from "~/context/installer"; import { System } from "~/api/system"; @@ -36,6 +37,7 @@ import { Proposal } from "~/api/proposal"; import { Config } from "~/api/config"; import { apiModel } from "~/api/storage"; import { Question } from "~/api/question"; +import { IssuesScope, Issue, IssuesMap } from "~/api/issue"; import { QueryHookOptions } from "~/types/queries"; const systemQuery = () => ({ @@ -60,6 +62,8 @@ function useSystemChanges() { return client.onEvent((event) => { if (event.type === "SystemChanged") { queryClient.invalidateQueries({ queryKey: ["system"] }); + if (event.scope === "storage") + queryClient.invalidateQueries({ queryKey: ["solvedStorageModel"] }); } }); }, [client, queryClient]); @@ -166,11 +170,66 @@ function useSolvedStorageModel( return func(query)?.data; } +const issuesQuery = () => { + return { + queryKey: ["issues"], + queryFn: getIssues, + }; +}; + +const selectIssues = (data: IssuesMap | null): Issue[] => { + if (!data) return []; + + return Object.keys(data).reduce((all: Issue[], key: IssuesScope) => { + const scoped = data[key].map((i) => ({ ...i, scope: key })); + return all.concat(scoped); + }, []); +}; + +function useIssues(options?: QueryHookOptions): Issue[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...issuesQuery(), + select: selectIssues, + }); + return data; +} + +const useIssuesChanges = () => { + const queryClient = useQueryClient(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.onEvent((event) => { + if (event.type === "IssuesChanged") { + queryClient.invalidateQueries({ queryKey: ["issues"] }); + } + }); + }, [client, queryClient]); +}; + +function useScopeIssues(scope: IssuesScope, options?: QueryHookOptions): Issue[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...issuesQuery(), + select: useCallback( + (data: IssuesMap | null): Issue[] => + selectIssues(data).filter((i: Issue) => i.scope === scope), + [scope], + ), + }); + return data; +} + export { systemQuery, proposalQuery, extendedConfigQuery, storageModelQuery, + issuesQuery, + selectIssues, useSystem, useSystemChanges, useProposal, @@ -180,4 +239,7 @@ export { useQuestionsChanges, useStorageModel, useSolvedStorageModel, + useIssues, + useScopeIssues, + useIssuesChanges, }; diff --git a/web/src/hooks/storage/issues.ts b/web/src/hooks/storage/issues.ts new file mode 100644 index 0000000000..565bbc0577 --- /dev/null +++ b/web/src/hooks/storage/issues.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { QueryHookOptions } from "~/types/queries"; +import { IssueSource, Issue, IssuesMap } from "~/api/issue"; +import { issuesQuery, selectIssues } from "~/hooks/api"; + +const selectSystemIssues = (data: IssuesMap | null) => + selectIssues(data).filter((i) => i.scope === "storage" && i.source === IssueSource.System); + +function useSystemIssues(options?: QueryHookOptions): Issue[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...issuesQuery(), + select: selectSystemIssues, + }); + return data; +} + +const selectConfigIssues = (data: IssuesMap | null) => + selectIssues(data).filter((i) => i.scope === "storage" && i.source === IssueSource.Config); + +function useConfigIssues(options?: QueryHookOptions): Issue[] { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...issuesQuery(), + select: selectConfigIssues, + }); + return data; +} + +export { useSystemIssues, useConfigIssues }; diff --git a/web/src/hooks/storage/system.ts b/web/src/hooks/storage/system.ts index 5d4d223a5b..ff3d33ee85 100644 --- a/web/src/hooks/storage/system.ts +++ b/web/src/hooks/storage/system.ts @@ -190,6 +190,17 @@ function useVolumeTemplate(mountPath: string, options?: QueryHookOptions): stora return data; } +const selectIssues = (data: System | null): storage.Issue[] => data?.storage?.issues || []; + +function useIssues(options?: QueryHookOptions) { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func({ + ...systemQuery(), + select: selectIssues, + }); + return data; +} + export { useSystem, useEncryptionMethods, @@ -202,4 +213,5 @@ export { useDevices, useVolumeTemplates, useVolumeTemplate, + useIssues, }; diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts deleted file mode 100644 index b767800062..0000000000 --- a/web/src/queries/issues.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { useInstallerClient } from "~/context/installer"; -import { IssuesScope, IssueSeverity, IssueSource, Issue } from "~/types/issues"; -import { fetchIssues } from "~/api/issues"; - -const issuesQuery = (selectFn?: (i: Issue[]) => Issue[]) => { - return { - queryKey: ["issues"], - queryFn: fetchIssues, - select: selectFn, - }; -}; - -/** - * Returns the issues for the given scope. - * - * @param scope - Scope to get the issues from. - * @return issues for the given scope. - */ -const useIssues = (scope: IssuesScope): Issue[] => { - const { data } = useSuspenseQuery( - issuesQuery((issues: Issue[]) => { - return issues.filter((i: Issue) => i.scope === scope); - }), - ); - return data; -}; - -const useAllIssues = (): Issue[] => { - const { data } = useSuspenseQuery(issuesQuery()); - return data; -}; - -const useIssuesChanges = () => { - const queryClient = useQueryClient(); - const client = useInstallerClient(); - - React.useEffect(() => { - if (!client) return; - - return client.onEvent((event) => { - if (event.type === "IssuesChanged") { - queryClient.invalidateQueries({ queryKey: ["issues"] }); - queryClient.invalidateQueries({ queryKey: ["status"] }); - } - }); - }, [client, queryClient]); -}; - -/** - * Returns the system errors for the given scope. - */ -const useSystemErrors = (scope: IssuesScope) => { - const issues = useIssues(scope); - - return issues - .filter((i) => i.severity === IssueSeverity.Error) - .filter((i) => i.source === IssueSource.System); -}; - -/** - * Returns the config errors for the given scope. - */ -const useConfigErrors = (scope: IssuesScope) => { - const issues = useIssues(scope); - - return issues - .filter((i) => i.severity === IssueSeverity.Error) - .filter((i) => i.source === IssueSource.Config); -}; - -export { useIssues, useAllIssues, useIssuesChanges, useSystemErrors, useConfigErrors }; diff --git a/web/src/types/issues.ts b/web/src/types/issues.ts deleted file mode 100644 index 5efb26ecd9..0000000000 --- a/web/src/types/issues.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -/** - * Known scopes for issues. - */ -type IssuesScope = "localization" | "product" | "software" | "storage" | "users" | "iscsi"; - -/** - * Source of the issue - * - * Which is the origin of the issue (the system, the configuration or unknown). - */ -enum IssueSource { - /** Unknown source (it is kind of a fallback value) */ - Unknown = "unknown", - /** An unexpected situation in the system (e.g., missing device). */ - System = "system", - /** Wrong or incomplete configuration (e.g., an authentication mechanism is not set) */ - Config = "config", -} - -/** - * Issue severity - * - * It indicates how severe the problem is. - */ -enum IssueSeverity { - /** Just a warning, the installation can start */ - Warn = "warn", - /** An important problem that makes the installation not possible */ - Error = "error", -} - -/** - * Pre-installation issue as they come from the API. - */ -type ApiIssue = { - /** Issue description */ - description: string; - /** Issue kind **/ - kind: string; - /** Issue details */ - details?: string; - /** Where the issue comes from */ - source: IssueSource; - /** How severe is the issue */ - severity: IssueSeverity; -}; - -/** - * Issues grouped by scope as they come from the API. - */ -type IssuesMap = { - localization?: ApiIssue[]; - software?: ApiIssue[]; - product?: ApiIssue[]; - storage?: ApiIssue[]; - iscsi?: ApiIssue[]; - users?: ApiIssue[]; -}; - -/** - * Pre-installation issue augmented with the scope. - */ -type Issue = ApiIssue & { scope: IssuesScope }; - -/** - * Validation error - */ -type ValidationError = { - message: string; -}; - -export { IssueSource, IssueSeverity }; -export type { ApiIssue, IssuesMap, IssuesScope, Issue, ValidationError }; From ff0c943115fe8fe25cb92828aa250be3cd892431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 12:17:15 +0000 Subject: [PATCH 365/917] web: update to React Router v7 --- web/package-lock.json | 54 ++++++++++++++++++++----------------------- web/package.json | 2 +- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index edca841e6d..ffd806c7b2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,7 +20,7 @@ "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1", + "react-router": "^7.9.5", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" }, @@ -4444,15 +4444,6 @@ } } }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -16411,35 +16402,34 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, - "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" - }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "node": ">=18" } }, "node_modules/readable-stream": { @@ -17181,6 +17171,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/web/package.json b/web/package.json index 26991b3891..8219b8810a 100644 --- a/web/package.json +++ b/web/package.json @@ -101,7 +101,7 @@ "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1", + "react-router": "^7.9.5", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" }, From c1060f2e32aec223aebc1377fe59e9c07d653f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:04:35 +0000 Subject: [PATCH 366/917] web: adapt code to React Router v7 By using react-router and react-router/dom imports as stated in documentation https://reactrouter.com/upgrading/v6#upgrade-to-v7 --- web/src/App.tsx | 2 +- web/src/Protected.tsx | 2 +- web/src/components/core/ChangeProductOption.tsx | 2 +- web/src/components/core/InstallButton.tsx | 2 +- web/src/components/core/InstallationFinished.tsx | 2 +- web/src/components/core/InstallationProgress.tsx | 2 +- web/src/components/core/InstallerOptions.tsx | 2 +- web/src/components/core/Link.tsx | 2 +- web/src/components/core/LoginPage.tsx | 2 +- web/src/components/core/Page.tsx | 2 +- web/src/components/l10n/KeyboardSelection.tsx | 2 +- web/src/components/l10n/LocaleSelection.tsx | 2 +- web/src/components/l10n/TimezoneSelection.tsx | 2 +- web/src/components/layout/Header.tsx | 2 +- web/src/components/layout/Layout.tsx | 2 +- web/src/components/layout/Sidebar.tsx | 2 +- web/src/components/network/BindingSettingsForm.tsx | 2 +- web/src/components/network/IpSettingsForm.tsx | 2 +- web/src/components/network/WifiNetworkPage.tsx | 2 +- web/src/components/network/WiredConnectionPage.tsx | 2 +- web/src/components/network/WiredConnectionsList.tsx | 2 +- web/src/components/product/ProductRegistrationAlert.tsx | 2 +- web/src/components/product/ProductSelectionPage.tsx | 2 +- web/src/components/product/ProductSelectionProgress.tsx | 2 +- web/src/components/storage/BootSelection.tsx | 2 +- web/src/components/storage/ConfigEditorMenu.tsx | 2 +- web/src/components/storage/ConfigureDeviceMenu.tsx | 2 +- web/src/components/storage/EncryptionSettingsPage.tsx | 2 +- web/src/components/storage/FilesystemMenu.tsx | 2 +- web/src/components/storage/FormattableDevicePage.tsx | 2 +- web/src/components/storage/LogicalVolumePage.tsx | 2 +- web/src/components/storage/LvmPage.tsx | 2 +- web/src/components/storage/MountPathMenuItem.tsx | 2 +- web/src/components/storage/PartitionPage.tsx | 2 +- web/src/components/storage/PartitionsMenu.tsx | 2 +- web/src/components/storage/Progress.tsx | 2 +- web/src/components/storage/ProposalPage.tsx | 2 +- web/src/components/storage/SpacePolicyMenu.tsx | 2 +- web/src/components/storage/SpacePolicySelection.tsx | 2 +- web/src/components/storage/UnusedMenu.tsx | 2 +- web/src/components/storage/VolumeGroupEditor.tsx | 2 +- web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx | 2 +- web/src/components/storage/zfcp/ZFCPPage.tsx | 2 +- web/src/components/users/FirstUserForm.tsx | 2 +- web/src/components/users/RootUserForm.tsx | 2 +- web/src/index.tsx | 2 +- web/src/router.tsx | 2 +- web/src/routes/storage.tsx | 2 +- web/src/types/routes.ts | 2 +- web/src/utils.ts | 2 +- 50 files changed, 50 insertions(+), 50 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 258cf9883b..161950a3ab 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect } from "react"; -import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router"; import { Loading } from "~/components/layout"; import { useProduct, useProductChanges } from "~/queries/software"; import { useProposalChanges } from "~/queries/proposal"; diff --git a/web/src/Protected.tsx b/web/src/Protected.tsx index bf8c468a9e..b3aa43fc9c 100644 --- a/web/src/Protected.tsx +++ b/web/src/Protected.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet } from "react-router"; import { useAuth } from "./context/auth"; import { AppProviders } from "./context/app"; diff --git a/web/src/components/core/ChangeProductOption.tsx b/web/src/components/core/ChangeProductOption.tsx index b5863e4596..2d66d7f362 100644 --- a/web/src/components/core/ChangeProductOption.tsx +++ b/web/src/components/core/ChangeProductOption.tsx @@ -22,7 +22,7 @@ import React from "react"; import { DropdownItem, DropdownItemProps } from "@patternfly/react-core"; -import { useHref, useLocation } from "react-router-dom"; +import { useHref, useLocation } from "react-router"; import { useProduct, useRegistration } from "~/queries/software"; import { PRODUCT as PATHS, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 2323a5d6cf..2665b9cb21 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -26,7 +26,7 @@ import { Popup } from "~/components/core"; import { startInstallation } from "~/api/manager"; import { useAllIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; -import { useLocation } from "react-router-dom"; +import { useLocation } from "react-router"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; diff --git a/web/src/components/core/InstallationFinished.tsx b/web/src/components/core/InstallationFinished.tsx index 619f4e5eed..eb81183a4f 100644 --- a/web/src/components/core/InstallationFinished.tsx +++ b/web/src/components/core/InstallationFinished.tsx @@ -37,7 +37,7 @@ import { GridItem, Stack, } from "@patternfly/react-core"; -import { Navigate, useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router"; import { Icon } from "~/components/layout"; import alignmentStyles from "@patternfly/react-styles/css/utilities/Alignment/alignment"; import { useInstallerStatus } from "~/queries/status"; diff --git a/web/src/components/core/InstallationProgress.tsx b/web/src/components/core/InstallationProgress.tsx index 6197d8720d..5c8f20cd80 100644 --- a/web/src/components/core/InstallationProgress.tsx +++ b/web/src/components/core/InstallationProgress.tsx @@ -25,7 +25,7 @@ import { _ } from "~/i18n"; import ProgressReport from "./ProgressReport"; import { InstallationPhase } from "~/types/status"; import { ROOT as PATHS } from "~/routes/paths"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; function InstallationProgress() { diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 3b97d0c31c..89b771eddb 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -32,7 +32,7 @@ */ import React, { useReducer } from "react"; -import { useHref, useLocation } from "react-router-dom"; +import { useHref, useLocation } from "react-router"; import { Button, ButtonProps, diff --git a/web/src/components/core/Link.tsx b/web/src/components/core/Link.tsx index d05401db7b..05847470c9 100644 --- a/web/src/components/core/Link.tsx +++ b/web/src/components/core/Link.tsx @@ -22,7 +22,7 @@ import React from "react"; import { Button, ButtonProps } from "@patternfly/react-core"; -import { To, useHref, useLinkClickHandler } from "react-router-dom"; +import { To, useHref, useLinkClickHandler } from "react-router"; export type LinkProps = Omit & { /** The target route */ diff --git a/web/src/components/core/LoginPage.tsx b/web/src/components/core/LoginPage.tsx index fb9e788884..a9737787e9 100644 --- a/web/src/components/core/LoginPage.tsx +++ b/web/src/components/core/LoginPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 20674f8ecb..3e6e41ef84 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -44,7 +44,7 @@ import { ProductRegistrationAlert } from "~/components/product"; import Link, { LinkProps } from "~/components/core/Link"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router"; import { isEmpty, isObject } from "radashi"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index 6b34273243..e685692cfa 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { updateConfig } from "~/api/api"; import { useSystem } from "~/queries/system"; diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 3243a83969..ac2e19fcf8 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { updateConfig } from "~/api/api"; import { useSystem } from "~/queries/system"; diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 5656a8e795..b2da31b0a9 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { Timezone } from "~/types/l10n"; import { updateConfig } from "~/api/api"; diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 61be78df71..3538f92f82 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -39,7 +39,7 @@ import { ToolbarGroup, ToolbarItem, } from "@patternfly/react-core"; -import { useMatches } from "react-router-dom"; +import { useMatches } from "react-router"; import { Icon } from "~/components/layout"; import { useProduct } from "~/queries/software"; import { Route } from "~/types/routes"; diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 5d7a89d522..968ba9a508 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -21,7 +21,7 @@ */ import React, { Suspense, useRef, useState } from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Outlet, useLocation } from "react-router"; import { Masthead, Page, PageProps } from "@patternfly/react-core"; import { Questions } from "~/components/questions"; import Header, { HeaderProps } from "~/components/layout/Header"; diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index b4291fae99..bfc2154064 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation } from "react-router"; import { Nav, NavItem, diff --git a/web/src/components/network/BindingSettingsForm.tsx b/web/src/components/network/BindingSettingsForm.tsx index 1fdb8d98d5..d120a7a8e5 100644 --- a/web/src/components/network/BindingSettingsForm.tsx +++ b/web/src/components/network/BindingSettingsForm.tsx @@ -21,7 +21,7 @@ */ import React, { useReducer } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 0b0d87d36e..d23d25f951 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index 7ff06ff184..bbc4d13a0c 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useParams } from "react-router-dom"; +import { useParams } from "react-router"; import { Content, EmptyState, diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index 2cb251b30f..affc71addf 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useParams } from "react-router-dom"; +import { useParams } from "react-router"; import { Content, EmptyState, diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx index 873c765bb0..5f539fa017 100644 --- a/web/src/components/network/WiredConnectionsList.tsx +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { Content, DataList, diff --git a/web/src/components/product/ProductRegistrationAlert.tsx b/web/src/components/product/ProductRegistrationAlert.tsx index 1e6660670d..199dc88465 100644 --- a/web/src/components/product/ProductRegistrationAlert.tsx +++ b/web/src/components/product/ProductRegistrationAlert.tsx @@ -22,7 +22,7 @@ import React from "react"; import { Alert } from "@patternfly/react-core"; -import { useLocation } from "react-router-dom"; +import { useLocation } from "react-router"; import { Link } from "~/components/core"; import { useProduct } from "~/queries/software"; import { REGISTRATION, SIDE_PATHS } from "~/routes/paths"; diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 0568882e9b..c65d723be5 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -37,7 +37,7 @@ import { Stack, StackItem, } from "@patternfly/react-core"; -import { Navigate, useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router"; import { Page } from "~/components/core"; import { useConfigMutation, useProduct, useRegistration } from "~/queries/software"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; diff --git a/web/src/components/product/ProductSelectionProgress.tsx b/web/src/components/product/ProductSelectionProgress.tsx index 19525cfd8c..b971b337ce 100644 --- a/web/src/components/product/ProductSelectionProgress.tsx +++ b/web/src/components/product/ProductSelectionProgress.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { Page, ProgressReport } from "~/components/core"; import { useProduct } from "~/queries/software"; import { useInstallerStatus } from "~/queries/status"; diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index d167d584b4..b0aeb21d86 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ActionGroup, Content, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; import { DevicesFormSelect } from "~/components/storage"; import { Page, SubtleContent } from "~/components/core"; diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 0437893e66..fa379a9ede 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { _ } from "~/i18n"; import { Dropdown, diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index fe01608c0e..8042a7283e 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton"; import { Divider, MenuItemProps } from "@patternfly/react-core"; import { useAvailableDevices } from "~/hooks/storage/system"; diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 8709533a38..d11b482348 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect, useState, useRef } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ActionGroup, Alert, Checkbox, Content, Form } from "@patternfly/react-core"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import PasswordCheck from "~/components/users/PasswordCheck"; diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index b0c4d345a1..11dfcb1121 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuHeader from "~/components/core/MenuHeader"; import MenuButton from "~/components/core/MenuButton"; diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index b93e42b72f..6d21d236d0 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -26,7 +26,7 @@ */ import React, { useId } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index f948904ecb..46f9359c28 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -27,7 +27,7 @@ */ import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index eaee72523b..b3a4b03311 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx index 4a667652d2..2f18118f23 100644 --- a/web/src/components/storage/MountPathMenuItem.tsx +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import * as partitionUtils from "~/components/storage/utils/partition"; import { Icon } from "~/components/layout"; import { MenuItem, MenuItemAction } from "@patternfly/react-core"; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 5e0d039c46..5d07970230 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index 6421f3fede..952b8c8df1 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Stack, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; diff --git a/web/src/components/storage/Progress.tsx b/web/src/components/storage/Progress.tsx index 3ece2a79b9..ce445bcb20 100644 --- a/web/src/components/storage/Progress.tsx +++ b/web/src/components/storage/Progress.tsx @@ -33,7 +33,7 @@ import { _ } from "~/i18n"; import { useProgress, useProgressChanges, useResetProgress } from "~/queries/progress"; import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; import { STORAGE } from "~/routes/paths"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; type StepProps = { id: string; diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index ef1f65ef86..a2f496c7a9 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -59,7 +59,7 @@ import { useSystemErrors, useConfigErrors } from "~/queries/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; import { useProgress, useProgressChanges } from "~/queries/progress"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx index bc39a47d39..acb4fff49f 100644 --- a/web/src/components/storage/SpacePolicyMenu.tsx +++ b/web/src/components/storage/SpacePolicyMenu.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Flex } from "@patternfly/react-core"; import MenuButton from "~/components/core/MenuButton"; import Text from "~/components/core/Text"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { apiModel } from "~/api/storage/types"; diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index 37d7ed02cc..0a134935e2 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { ActionGroup, Content, Form } from "@patternfly/react-core"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; import { deviceChildren } from "~/components/storage/utils"; diff --git a/web/src/components/storage/UnusedMenu.tsx b/web/src/components/storage/UnusedMenu.tsx index 2c8cc53259..b9fc1bc190 100644 --- a/web/src/components/storage/UnusedMenu.tsx +++ b/web/src/components/storage/UnusedMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import { STORAGE as PATHS } from "~/routes/paths"; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 1a918ab9dc..774c6ce29a 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex, Title } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Link from "~/components/core/Link"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; diff --git a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx index 1f4d8a0ce4..3fd0b05ccd 100644 --- a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx @@ -28,7 +28,7 @@ import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; import { LUNInfo } from "~/types/zfcp"; import { activateZFCPDisk } from "~/api/storage/zfcp"; import { PATHS } from "~/routes/storage"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import ZFCPDiskForm from "./ZFCPDiskForm"; import { useZFCPControllersChanges, useZFCPDisksChanges } from "~/queries/storage/zfcp"; diff --git a/web/src/components/storage/zfcp/ZFCPPage.tsx b/web/src/components/storage/zfcp/ZFCPPage.tsx index 7e3ca849b6..dd8a28ef36 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.tsx @@ -43,7 +43,7 @@ import ZFCPDisksTable from "./ZFCPDisksTable"; import ZFCPControllersTable from "./ZFCPControllersTable"; import { probeZFCP } from "~/api/storage/zfcp"; import { STORAGE as PATHS } from "~/routes/paths"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { inactiveLuns } from "~/utils/zfcp"; const LUNScanInfo = () => { diff --git a/web/src/components/users/FirstUserForm.tsx b/web/src/components/users/FirstUserForm.tsx index d4989b5ba4..d15f91b04e 100644 --- a/web/src/components/users/FirstUserForm.tsx +++ b/web/src/components/users/FirstUserForm.tsx @@ -34,7 +34,7 @@ import { ActionGroup, Button, } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { Loading } from "~/components/layout"; import { PasswordAndConfirmationInput, Page } from "~/components/core"; import PasswordCheck from "~/components/users/PasswordCheck"; diff --git a/web/src/components/users/RootUserForm.tsx b/web/src/components/users/RootUserForm.tsx index 4b74b03fa0..993f2e2e11 100644 --- a/web/src/components/users/RootUserForm.tsx +++ b/web/src/components/users/RootUserForm.tsx @@ -31,7 +31,7 @@ import { Form, FormGroup, } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import { useRootUser, useRootUserMutation } from "~/queries/users"; import { RootUser } from "~/types/users"; diff --git a/web/src/index.tsx b/web/src/index.tsx index e678bef2a8..b164322cd5 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -22,7 +22,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; -import { RouterProvider } from "react-router-dom"; +import { RouterProvider } from "react-router/dom"; import { RootProviders } from "~/context/root"; import { router } from "~/router"; diff --git a/web/src/router.tsx b/web/src/router.tsx index 61912d1cce..81a9e73ce1 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { createHashRouter, Outlet } from "react-router-dom"; +import { createHashRouter, Outlet } from "react-router"; import App from "~/App"; import Protected from "~/Protected"; import { FullLayout, PlainLayout } from "~/components/layout"; diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 82e9b1f4ca..44bfc2330e 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { redirect } from "react-router-dom"; +import { redirect } from "react-router"; import { N_ } from "~/i18n"; import { Route } from "~/types/routes"; import BootSelection from "~/components/storage/BootSelection"; diff --git a/web/src/types/routes.ts b/web/src/types/routes.ts index fe98027843..e73fa36e51 100644 --- a/web/src/types/routes.ts +++ b/web/src/types/routes.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { RouteObject } from "react-router-dom"; +import { RouteObject } from "react-router"; type RouteHandle = { /** Text to be used as label when building a link from route information */ diff --git a/web/src/utils.ts b/web/src/utils.ts index 3b0865998e..51bcce1950 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -21,7 +21,7 @@ */ import { mapEntries } from "radashi"; -import { generatePath } from "react-router-dom"; +import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; /** From 706160d7f460670df7b093f004719975db7baf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:08:13 +0000 Subject: [PATCH 367/917] web: start adapting test to React Router v7 By using the right imports/mocks --- web/src/components/l10n/KeyboardSelection.test.tsx | 4 ++-- web/src/components/l10n/LocaleSelection.test.tsx | 4 ++-- web/src/components/l10n/TimezoneSelection.test.tsx | 4 ++-- web/src/components/storage/BootSelection.test.tsx | 4 ++-- web/src/test-utils.tsx | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index 3c5ec93c03..7b256dc184 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -53,8 +53,8 @@ jest.mock("~/api/api", () => ({ updateConfig: (config) => mockUpdateConfigFn(config), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index 0bf485e541..1507979541 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -53,8 +53,8 @@ jest.mock("~/api/api", () => ({ updateConfig: (config) => mockUpdateConfigFn(config), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 72cfb9b57c..bb8a79f8d5 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -60,8 +60,8 @@ jest.mock("~/queries/proposal", () => ({ useProposal: () => ({ l10n: { timezones, timezone: "Europe/Berlin" } }), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 12f8c93279..7cba257175 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -98,8 +98,8 @@ const sdc: StorageDevice = { udevPaths: ["pci-0000:00-19"], }; -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 6df15d8af3..b2d62a2057 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -29,7 +29,7 @@ */ import React from "react"; -import { MemoryRouter, useParams } from "react-router-dom"; +import { MemoryRouter, useParams } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { render, within } from "@testing-library/react"; @@ -87,9 +87,9 @@ const mockRoutes = (...routes) => initialRoutes.mockReturnValueOnce(routes); */ const mockParams = (params: ReturnType) => (paramsMock = params); -// Centralize the react-router-dom mock here -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +// Centralize the react-router mock here +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useHref: (to) => to, useNavigate: () => mockNavigateFn, useMatches: () => [], From 3fb632a169b63adfdb786a8ce6da5d94a3f741b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:09:20 +0000 Subject: [PATCH 368/917] web: fix ReferenceError after React Router v7 migration After migrating to React Router v7, the test suite fails with the following error: > ReferenceError: TextEncoder is not defined This appears to be due to an unimplemented TextEncoder in jsdom. To resolve this, we follow the same workaround as React Router, as outlined in https://github.com/remix-run/react-router/issues/12363#issuecomment-2496226528 --- web/src/setupTests.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/setupTests.ts b/web/src/setupTests.ts index 1dd407a63e..04773a7b04 100644 --- a/web/src/setupTests.ts +++ b/web/src/setupTests.ts @@ -3,3 +3,11 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; +import { TextDecoder, TextEncoder } from "util"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.TextEncoder || !globalThis.TextDecoder) { + globalThis.TextEncoder = TextEncoder; + globalThis.TextDecoder = TextDecoder; +} From 1878d878ea1ca5318120e527f9ce911f321b6884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:34:29 +0000 Subject: [PATCH 369/917] web: stop using custom generateEncodedPath It was introduced to work around React Router v6's issue of not encoding parameters in the generatePath function. However, after migrating to v7, tests began failing due to double encoding. This revealed that React Router v7 already handles encoding parameters in generatePath. See https://github.com/remix-run/react-router/pull/13530 Thus, this commit reverts the changes made in https://github.com/agama-project/agama/pull/2576 --- .../network/WifiConnectionDetails.tsx | 4 +- .../components/network/WifiNetworksList.tsx | 5 +- .../network/WiredConnectionDetails.tsx | 6 +-- .../network/WiredConnectionsList.tsx | 5 +- web/src/components/storage/FilesystemMenu.tsx | 5 +- web/src/components/storage/PartitionsMenu.tsx | 7 ++- .../components/storage/SpacePolicyMenu.tsx | 5 +- web/src/components/storage/UnusedMenu.tsx | 7 ++- .../components/storage/VolumeGroupEditor.tsx | 9 ++-- web/src/utils.test.ts | 53 +------------------ web/src/utils.ts | 22 -------- 11 files changed, 24 insertions(+), 104 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 846c027963..3e52572baa 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -32,12 +32,12 @@ import { GridItem, Stack, } from "@patternfly/react-core"; +import { generatePath } from "react-router"; import { Link, Page } from "~/components/core"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; import { Device, WifiNetwork } from "~/types/network"; import { NETWORK } from "~/routes/paths"; import { formatIp } from "~/utils/network"; -import { generateEncodedPath } from "~/utils"; import { _ } from "~/i18n"; const NetworkDetails = ({ network }: { network: WifiNetwork }) => { @@ -96,7 +96,7 @@ const IpDetails = ({ device, settings }: { device: Device; settings: WifiNetwork title={_("IP settings")} pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ - + {_("Edit")} } diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 3c22ab84ad..6ee95cb8ba 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router-dom"; +import { generatePath, useNavigate } from "react-router"; import { Content, DataList, @@ -41,7 +41,6 @@ import { Connection, ConnectionState, WifiNetwork, WifiNetworkStatus } from "~/t import { useConnections, useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { isEmpty } from "radashi"; -import { generateEncodedPath } from "~/utils"; import { formatIp } from "~/utils/network"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -190,7 +189,7 @@ function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { return ( navigate(generateEncodedPath(PATHS.wifiNetwork, { ssid }))} + onSelectDataListItem={(_, ssid) => navigate(generatePath(PATHS.wifiNetwork, { ssid }))} {...props} > {networks.map((n) => ( diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx index e54b3616f8..c39b5e3350 100644 --- a/web/src/components/network/WiredConnectionDetails.tsx +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -37,13 +37,13 @@ import { Tabs, TabTitleText, } from "@patternfly/react-core"; +import { generatePath } from "react-router"; import { Link, Page } from "~/components/core"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; import { Connection, Device } from "~/types/network"; import { connectionBindingMode, formatIp } from "~/utils/network"; import { NETWORK } from "~/routes/paths"; import { useNetworkDevices } from "~/queries/network"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -73,7 +73,7 @@ const BindingSettings = ({ connection }: { connection: Connection }) => { pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ @@ -203,7 +203,7 @@ const ConnectionDetails = ({ connection }: { connection: Connection }) => { title={_("Settings")} pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ - + {_("Edit connection settings")} } diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx index 5f539fa017..7c09839980 100644 --- a/web/src/components/network/WiredConnectionsList.tsx +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import { Content, DataList, @@ -39,7 +39,6 @@ import { useConnections, useNetworkDevices } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { formatIp } from "~/utils/network"; import { _ } from "~/i18n"; -import { generateEncodedPath } from "~/utils"; type ConnectionListItemProps = { connection: Connection }; @@ -93,7 +92,7 @@ function WiredConnectionsList(props: DataListProps) { return ( navigate(generateEncodedPath(PATHS.wiredConnection, { id }))} + onSelectDataListItem={(_, id) => navigate(generatePath(PATHS.wiredConnection, { id }))} {...props} > {wiredConnections.map((c: Connection) => ( diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index 11dfcb1121..1853000f4d 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuHeader from "~/components/core/MenuHeader"; import MenuButton from "~/components/core/MenuButton"; @@ -30,7 +30,6 @@ import { STORAGE as PATHS } from "~/routes/paths"; import { model } from "~/types/storage"; import * as driveUtils from "~/components/storage/utils/drive"; import { filesystemType, formattedPath } from "~/components/storage/utils"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -61,7 +60,7 @@ export default function FilesystemMenu({ deviceModel }: FilesystemMenuProps): Re const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = deviceModel; - const editFilesystemPath = generateEncodedPath(PATHS.formatDevice, { list, listIndex }); + const editFilesystemPath = generatePath(PATHS.formatDevice, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index 952b8c8df1..91fe74fe48 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Stack, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; @@ -31,14 +31,13 @@ import { Partition } from "~/api/storage/types/model"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDeletePartition } from "~/hooks/storage/partition"; import * as driveUtils from "~/components/storage/utils/drive"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; const PartitionMenuItem = ({ device, mountPath }) => { const partition = device.getPartition(mountPath); const { list, listIndex } = device; - const editPath = generateEncodedPath(PATHS.editPartition, { + const editPath = generatePath(PATHS.editPartition, { list, listIndex, partitionId: mountPath, @@ -140,7 +139,7 @@ export default function PartitionsMenu({ device }) { const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = device; - const newPartitionPath = generateEncodedPath(PATHS.addPartition, { list, listIndex }); + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), device.name); const hasPartitions = device.partitions.some((p: Partition) => p.mountPath); diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx index acb4fff49f..04b403f901 100644 --- a/web/src/components/storage/SpacePolicyMenu.tsx +++ b/web/src/components/storage/SpacePolicyMenu.tsx @@ -24,13 +24,12 @@ import React from "react"; import { Flex } from "@patternfly/react-core"; import MenuButton from "~/components/core/MenuButton"; import Text from "~/components/core/Text"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; import * as driveUtils from "~/components/storage/utils/drive"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; @@ -57,7 +56,7 @@ export default function SpacePolicyMenu({ modelDevice, device }) { const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generateEncodedPath(PATHS.editSpacePolicy, { list, listIndex })); + return navigate(generatePath(PATHS.editSpacePolicy, { list, listIndex })); } else { setSpacePolicy(list, listIndex, { type: spacePolicy }); } diff --git a/web/src/components/storage/UnusedMenu.tsx b/web/src/components/storage/UnusedMenu.tsx index b9fc1bc190..b3d672438f 100644 --- a/web/src/components/storage/UnusedMenu.tsx +++ b/web/src/components/storage/UnusedMenu.tsx @@ -22,12 +22,11 @@ import React, { useId } from "react"; import { Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import { STORAGE as PATHS } from "~/routes/paths"; import { model } from "~/types/storage"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -38,8 +37,8 @@ export default function UnusedMenu({ deviceModel }: UnusedMenuProps): React.Reac const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = deviceModel; - const newPartitionPath = generateEncodedPath(PATHS.addPartition, { list, listIndex }); - const formatDevicePath = generateEncodedPath(PATHS.formatDevice, { list, listIndex }); + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); + const formatDevicePath = generatePath(PATHS.formatDevice, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 774c6ce29a..3381ba95ab 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex, Title } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Link from "~/components/core/Link"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; @@ -36,7 +36,6 @@ import { baseName, formattedPath } from "~/components/storage/utils"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useDeleteVolumeGroup } from "~/hooks/storage/volume-group"; import { useDeleteLogicalVolume } from "~/hooks/storage/logical-volume"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _, n_, formatList } from "~/i18n"; @@ -93,7 +92,7 @@ const EditVgOption = ({ vg }: { vg: model.VolumeGroup }) => { itemId="edit-volume-group" description={_("Modify settings and physical volumes")} role="menuitem" - onClick={() => navigate(generateEncodedPath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + onClick={() => navigate(generatePath(PATHS.volumeGroup.edit, { id: vg.vgName }))} > {_("Edit volume group")} @@ -130,7 +129,7 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { const deleteLogicalVolume = useDeleteLogicalVolume(); const ariaLabelId = useId(); const toggleTextId = useId(); - const newLvPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName }); + const newLvPath = generatePath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName }); const menuAriaLabel = sprintf(_("Logical volumes for %s"), vg.vgName); if (isEmpty(vg.logicalVolumes)) { @@ -176,7 +175,7 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { { it("removes null and undefined values", () => { @@ -197,49 +189,6 @@ describe("localConnection", () => { }); }); -describe("generateEncodedPath", () => { - it("encodes special characters in parameters", () => { - const path = "/network/:id"; - const params = { id: "Wired #1" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/network/Wired%20%231"); - }); - - it("handles multiple parameters", () => { - const path = "/network/:id/bridge/:bridge"; - const params = { id: "Wired #1", bridge: "br $0" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/network/Wired%20%231/bridge/br%20%240"); - }); - - it("leaves safe characters unchanged", () => { - const path = "/product/:id"; - const params = { id: "12345" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/product/12345"); - }); - - it("works with empty params", () => { - const path = "/static/path"; - - const result = generateEncodedPath(path, {}); - - expect(result).toBe("/static/path"); - }); - - it("throws if a param is missing", () => { - const path = "/network/:id"; - - expect(() => generateEncodedPath(path, {})).toThrow(); - }); -}); - describe("simpleFastSort", () => { const fakeDevices = [ { sid: 100, name: "/dev/sdz", size: 5 }, diff --git a/web/src/utils.ts b/web/src/utils.ts index 51bcce1950..ebd97eeaf0 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -import { mapEntries } from "radashi"; -import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; /** @@ -160,25 +158,6 @@ const mask = (value: string, visible: number = 4, maskChar: string = "*"): strin return maskChar.repeat(maskedLength) + visiblePart; }; -/** - * A wrapper around React Router's `generatePath` that ensures all path parameters - * are URI-encoded using `encodeURIComponent`. This prevents broken URLs caused by - * special characters such as spaces, `#`, `$`, and others. - * - * @example - * ```ts - * // Returns "/network/Wired%20%231" - * generateEncodedPath("/network/:id", { id: "Wired #1" }); - * ``` - */ -const generateEncodedPath = (...args: Parameters) => { - const [path, params] = args; - return generatePath( - path, - mapEntries(params, (key, value) => [key, encodeURIComponent(value)]), - ); -}; - /** * A lightweight wrapper around `fast-sort`. * @@ -213,6 +192,5 @@ export { localConnection, timezoneTime, mask, - generateEncodedPath, sortCollection, }; From 916a4aa0df70350442fa86192868213eb59563c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 16:02:17 +0000 Subject: [PATCH 370/917] web: fix TypeScript conflicts with TextEncoder and TextDecoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In TypeScript, TextEncoder and TextDecoder are global types when targeting the DOM environment, causing conflicts when importing these classes from Node’s util module. To avoid these conflicts, TextEncoder and TextDecoder from util have been imported with different names (NodeTextEncoder, NodeTextDecoder) and assigned to globalThis with explicit type assertions. * MDN - https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder - https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder * StackOverflow - https://stackoverflow.com/a/77752064 * TypeScript types - https://github.com/microsoft/TypeScript/blob/efca03ffed10dccede4fbc8dd8a624374e5424d9/src/lib/dom.generated.d.ts#L32378 --- web/src/setupTests.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/setupTests.ts b/web/src/setupTests.ts index 04773a7b04..1f940b3cd9 100644 --- a/web/src/setupTests.ts +++ b/web/src/setupTests.ts @@ -3,11 +3,11 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; -import { TextDecoder, TextEncoder } from "util"; +import { TextDecoder as NodeTextDecoder, TextEncoder as NodeTextEncoder } from "util"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.TextEncoder || !globalThis.TextDecoder) { - globalThis.TextEncoder = TextEncoder; - globalThis.TextDecoder = TextDecoder; + globalThis.TextEncoder = NodeTextEncoder as typeof TextEncoder; + globalThis.TextDecoder = NodeTextDecoder as typeof TextDecoder; } From 2dcde83900c4bb13589f70f1e47403c76918b429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 16:57:50 +0000 Subject: [PATCH 371/917] web: switch to "bundler" moduleResolution To fix an issue with React Router v7 types > src/index.tsx:25:32 - error TS2307: Cannot find module 'react-router/dom' or its corresponding type declarations. > There are types at 'node_modules/react-router/dist/development/dom-export.d.mts', > but this result could not be resolved under your current 'moduleResolution' setting. > Consider updating to 'node16', 'nodenext', or 'bundler'. As per TypeScript documentation, https://www.typescriptlang.org/tsconfig/#moduleResolution > 'bundler' for use with bundlers. Like node16 and nodenext, this mode > supports package.json "imports" and "exports", but unlike the Node.js > resolution modes, bundler never requires file extensions on relative > paths in imports. --- web/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tsconfig.json b/web/tsconfig.json index f1524252f8..248e8afa22 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "dist/", "isolatedModules": true, "target": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "esModuleInterop": true, "allowJs": true, From 73a6f38b4b0f12fbe2a1826b9897915b355f9ca4 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 07:14:39 +0000 Subject: [PATCH 372/917] Adapted network service to the new config based API --- rust/Cargo.lock | 4 + rust/agama-lib/share/profile.schema.json | 23 + rust/agama-lib/src/network.rs | 4 +- rust/agama-lib/src/network/client.rs | 2 +- rust/agama-lib/src/network/store.rs | 19 +- rust/agama-manager/Cargo.toml | 1 + rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/service.rs | 50 +- rust/agama-manager/src/start.rs | 10 +- rust/agama-network/src/action.rs | 16 +- rust/agama-network/src/error.rs | 10 + rust/agama-network/src/lib.rs | 1 - rust/agama-network/src/model.rs | 716 ++++++---------- rust/agama-network/src/nm/builder.rs | 3 +- rust/agama-network/src/nm/client.rs | 11 +- rust/agama-network/src/nm/dbus.rs | 28 +- rust/agama-network/src/nm/error.rs | 2 +- rust/agama-network/src/nm/model.rs | 4 +- rust/agama-network/src/nm/watcher.rs | 9 +- rust/agama-network/src/system.rs | 53 +- rust/agama-network/src/types.rs | 323 +------ rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/network/web.rs | 490 ----------- rust/agama-server/src/web.rs | 9 - rust/agama-server/src/web/docs.rs | 2 - rust/agama-server/src/web/docs/config.rs | 47 +- rust/agama-server/src/web/docs/network.rs | 119 --- rust/agama-server/tests/network_service.rs | 285 ------- rust/agama-utils/Cargo.toml | 2 + rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/config.rs | 4 +- .../src => agama-utils/src/api}/network.rs | 19 +- rust/agama-utils/src/api/network/config.rs | 34 + rust/agama-utils/src/api/network/proposal.rs | 34 + .../src/api/network}/settings.rs | 36 +- .../src/api/network/system_info.rs | 36 + rust/agama-utils/src/api/network/types.rs | 790 ++++++++++++++++++ rust/agama-utils/src/api/proposal.rs | 3 +- rust/agama-utils/src/api/system_info.rs | 2 + rust/xtask/src/main.rs | 5 +- 41 files changed, 1415 insertions(+), 1795 deletions(-) delete mode 100644 rust/agama-server/src/network/web.rs delete mode 100644 rust/agama-server/src/web/docs/network.rs delete mode 100644 rust/agama-server/tests/network_service.rs rename rust/{agama-server/src => agama-utils/src/api}/network.rs (70%) create mode 100644 rust/agama-utils/src/api/network/config.rs create mode 100644 rust/agama-utils/src/api/network/proposal.rs rename rust/{agama-network/src => agama-utils/src/api/network}/settings.rs (90%) create mode 100644 rust/agama-utils/src/api/network/system_info.rs create mode 100644 rust/agama-utils/src/api/network/types.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9d07e9219b..e1aece7139 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -131,6 +131,7 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-l10n", + "agama-network", "agama-storage", "agama-utils", "async-trait", @@ -175,6 +176,7 @@ dependencies = [ "agama-lib", "agama-locale-data", "agama-manager", + "agama-network", "agama-utils", "anyhow", "async-trait", @@ -238,7 +240,9 @@ version = "0.1.0" dependencies = [ "agama-locale-data", "async-trait", + "cidr", "gettext-rs", + "macaddr", "serde", "serde_json", "serde_with", diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 33e06eab27..a40f9da479 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -336,6 +336,29 @@ "type": "object", "additionalProperties": false, "properties": { + "state": { + "title": "Network general state settings", + "type": "object", + "properties": { + "connectivity": { + "title": "Determines whether the user is able to access the Internet", + "type": "boolean", + "readOnly": true + }, + "copyNetwork": { + "title": "Whether the network configuration should be copied to the target system", + "type": "boolean" + }, + "networkingEnabled": { + "title": "Whether the network should be enabled", + "type": "boolean" + }, + "wirelessEnabled": { + "title": "Whether the wireless should be enabled", + "type": "boolean" + } + } + }, "connections": { "title": "Network connections to be defined", "type": "array", diff --git a/rust/agama-lib/src/network.rs b/rust/agama-lib/src/network.rs index 41fa7fb7d9..5fe3da04f5 100644 --- a/rust/agama-lib/src/network.rs +++ b/rust/agama-lib/src/network.rs @@ -24,9 +24,9 @@ mod client; mod store; pub use agama_network::{ - error, model, settings, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, + error, model, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, }; +pub use agama_utils::api::network::*; pub use client::{NetworkClient, NetworkClientError}; -pub use settings::NetworkSettings; pub use store::{NetworkStore, NetworkStoreError}; diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index 0fb0b6bb30..dbb3854beb 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, types::Device}; use crate::http::{BaseHTTPClient, BaseHTTPClientError}; +use crate::network::{Device, NetworkConnection}; use crate::utils::url::encode; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/network/store.rs b/rust/agama-lib/src/network/store.rs index a591b529dd..9527d212fd 100644 --- a/rust/agama-lib/src/network/store.rs +++ b/rust/agama-lib/src/network/store.rs @@ -18,11 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, NetworkClientError}; +use super::NetworkClientError; use crate::{ http::BaseHTTPClient, network::{NetworkClient, NetworkSettings}, }; +use agama_network::types::NetworkConnectionsCollection; +use agama_utils::api::network::NetworkConnection; #[derive(Debug, thiserror::Error)] #[error("Error processing network settings: {0}")] @@ -44,15 +46,20 @@ impl NetworkStore { // TODO: read the settings from the service pub async fn load(&self) -> NetworkStoreResult { - let connections = self.network_client.connections().await?; - Ok(NetworkSettings { connections }) + let connections = NetworkConnectionsCollection(self.network_client.connections().await?); + + Ok(NetworkSettings { + connections, + ..Default::default() + }) } pub async fn store(&self, settings: &NetworkSettings) -> NetworkStoreResult<()> { - for id in ordered_connections(&settings.connections) { + let connections = &settings.connections.0; + for id in ordered_connections(connections) { let id = id.as_str(); let fallback = default_connection(id); - let conn = find_connection(id, &settings.connections).unwrap_or(&fallback); + let conn = find_connection(id, connections).unwrap_or(&fallback); self.network_client .add_or_update_connection(conn.clone()) .await?; @@ -129,7 +136,7 @@ fn default_connection(id: &str) -> NetworkConnection { #[cfg(test)] mod tests { use super::ordered_connections; - use crate::network::settings::{BondSettings, BridgeSettings, NetworkConnection}; + use crate::network::{BondSettings, BridgeSettings, NetworkConnection}; #[test] fn test_ordered_connections() { diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 9738008b51..5004fffb6c 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true [dependencies] agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-network = { path = "../agama-network" } agama-storage = { path = "../agama-storage" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 49a1a5b366..39260e92ad 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -27,4 +27,5 @@ pub use service::Service; pub mod message; pub use agama_l10n as l10n; +pub use agama_network as network; pub use agama_storage as storage; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 29e03e6dfc..62873e87eb 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, message, storage}; +use crate::{l10n, message, network, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -29,6 +29,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; +use network::{NetworkSystemClient, NetworkSystemError}; use serde_json::Value; use tokio::sync::broadcast; @@ -50,10 +51,13 @@ pub enum Error { Questions(#[from] question::service::Error), #[error(transparent)] Progress(#[from] progress::service::Error), + #[error(transparent)] + NetworkSystemError(#[from] NetworkSystemError), } pub struct Service { l10n: Handler, + network: NetworkSystemClient, storage: Handler, issues: Handler, progress: Handler, @@ -66,6 +70,7 @@ pub struct Service { impl Service { pub fn new( l10n: Handler, + network: NetworkSystemClient, storage: Handler, issues: Handler, progress: Handler, @@ -74,6 +79,7 @@ impl Service { ) -> Self { Self { l10n, + network, storage, issues, progress, @@ -147,7 +153,12 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; let storage = self.storage.call(storage::message::GetSystem).await?; - Ok(SystemInfo { l10n, storage }) + let network = self.network.get_system_config().await?; + Ok(SystemInfo { + l10n, + network, + storage, + }) } } @@ -159,10 +170,13 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; + let network = self.network.get_config().await?; let storage = self.storage.call(storage::message::GetConfig).await?; + Ok(Config { l10n: Some(l10n), - questions, + questions: questions, + network: Some(network), storage, }) } @@ -196,11 +210,28 @@ impl MessageHandler for Service { .call(storage::message::SetConfig::new(config.storage.clone())) .await?; + if let Some(network) = config.network.clone() { + self.network.update_config(network).await?; + self.network.apply().await?; + } + self.config = config; Ok(()) } } +fn merge_network(mut config: Config, update_config: Config) -> Config { + if let Some(network) = &update_config.network { + if let Some(connections) = &network.connections { + if let Some(ref mut config_network) = config.network { + config_network.connections = Some(connections.clone()); + } + } + } + + config +} + #[async_trait] impl MessageHandler for Service { /// Patches the config. @@ -209,6 +240,7 @@ impl MessageHandler for Service { /// config, then it keeps the values from the current config. async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; + let config = merge_network(config, message.config); if let Some(l10n) = &config.l10n { self.l10n @@ -228,6 +260,10 @@ impl MessageHandler for Service { .await?; } + if let Some(network) = &config.network { + self.network.update_config(network.clone()).await?; + } + self.config = config; Ok(()) } @@ -239,7 +275,13 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { let l10n = self.l10n.call(l10n::message::GetProposal).await?; let storage = self.storage.call(storage::message::GetProposal).await?; - Ok(Some(Proposal { l10n, storage })) + let network = self.network.get_proposal().await?; + + Ok(Some(Proposal { + l10n, + network, + storage, + })) } } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 98acd79976..8a3f034e7f 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, service::Service, storage}; +use crate::{l10n, network, service::Service, storage}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -35,6 +35,8 @@ pub enum Error { L10n(#[from] l10n::start::Error), #[error(transparent)] Storage(#[from] storage::start::Error), + #[error(transparent)] + NetworkSystem(#[from] network::NetworkSystemError), } /// Starts the manager service. @@ -51,8 +53,12 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; + let network_adapter = network::NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager"); + let network = network::NetworkSystem::new(network_adapter).start().await?; - let service = Service::new(l10n, storage, issues, progress, questions, events); + let service = Service::new(l10n, network, storage, issues, progress, questions, events); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index d1d18a83ba..791bcb3622 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -18,12 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::model::{AccessPoint, Connection, Device}; -use crate::types::{ConnectionState, DeviceType}; +use crate::model::{Connection, GeneralState}; +use crate::types::{AccessPoint, ConnectionState, Device, DeviceType, Proposal, SystemInfo}; +use agama_utils::api::network::Config; use tokio::sync::oneshot; use uuid::Uuid; -use super::{error::NetworkStateError, model::GeneralState, NetworkAdapterError}; +use super::{error::NetworkStateError, NetworkAdapterError}; pub type Responder = oneshot::Sender; pub type ControllerConnection = (Connection, Vec); @@ -42,6 +43,15 @@ pub enum Action { GetConnection(String, Responder>), /// Gets a connection by its Uuid GetConnectionByUuid(Uuid, Responder>), + /// Gets the internal state of the network configuration + GetConfig(Responder), + /// Gets the internal state of the network configuration proposal + GetProposal(Responder), + /// Updates th internal state of the network configuration + UpdateConfig(Box, Responder>), + /// Gets the current network configuration containing connections, devices, access_points and + /// also the general state + GetSystemConfig(Responder), /// Gets a connection GetConnections(Responder>), /// Gets a controller connection diff --git a/rust/agama-network/src/error.rs b/rust/agama-network/src/error.rs index 87a3498554..291f40317f 100644 --- a/rust/agama-network/src/error.rs +++ b/rust/agama-network/src/error.rs @@ -21,6 +21,16 @@ //! Error types. use thiserror::Error; +use crate::NetworkSystemError; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + NetworkStateError(#[from] NetworkStateError), + #[error(transparent)] + NetworkSystemError(#[from] NetworkSystemError), +} + /// Errors that are related to the network configuration. #[derive(Error, Debug)] pub enum NetworkStateError { diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 01b992bc03..0cf9b47e59 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -27,7 +27,6 @@ pub mod adapter; pub mod error; pub mod model; mod nm; -pub mod settings; mod system; pub mod types; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index e5a2eb28ed..0e9372a04f 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -23,13 +23,9 @@ //! * This module contains the types that represent the network concepts. They are supposed to be //! agnostic from the real network service (e.g., NetworkManager). use crate::error::NetworkStateError; -use crate::settings::{ - BondSettings, BridgeSettings, IEEE8021XSettings, NetworkConnection, VlanSettings, - WirelessSettings, -}; -use crate::types::{BondMode, ConnectionState, DeviceState, DeviceType, Status, SSID}; +use crate::types::*; + use agama_utils::openapi::schemas; -use cidr::IpInet; use macaddr::MacAddr6; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; @@ -37,12 +33,10 @@ use std::{ collections::HashMap, default::Default, fmt, - net::IpAddr, str::{self, FromStr}, }; use thiserror::Error; use uuid::Uuid; -use zbus::zvariant::Value; #[derive(PartialEq)] pub struct StateConfig { @@ -164,6 +158,52 @@ impl NetworkState { Ok(()) } + pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { + if let Some(connections) = config.connections { + let mut collection: ConnectionCollection = connections.clone().try_into()?; + for conn in collection.0.iter_mut() { + if let Some(current_conn) = self.get_connection(conn.id.as_str()) { + // Replaced the UUID with a real one + conn.uuid = current_conn.uuid; + self.update_connection(conn.to_owned())?; + } else { + self.add_connection(conn.to_owned())?; + } + } + + for conn in connections.0 { + if conn.bridge.is_some() | conn.bond.is_some() { + let mut ports = vec![]; + if let Some(model) = conn.bridge { + ports = model.ports; + } + if let Some(model) = conn.bond { + ports = model.ports; + } + + if let Some(controller) = self.get_connection(conn.id.as_str()) { + self.set_ports(&controller.clone(), ports)?; + } + } + } + } + + if let Some(state) = config.state { + if let Some(wireless_enabled) = state.wireless_enabled { + self.general_state.wireless_enabled = wireless_enabled; + } + + if let Some(networking_enabled) = state.networking_enabled { + self.general_state.networking_enabled = networking_enabled; + } + + if let Some(copy_network) = state.copy_network { + self.general_state.copy_network = copy_network; + } + } + Ok(()) + } + /// Updates a connection with a new one. /// /// It uses the `id` to decide which connection to update. @@ -252,6 +292,20 @@ impl NetworkState { )), } } + + pub fn ports_for(&self, uuid: Uuid) -> Vec { + self.connections + .iter() + .filter(|c| c.controller == Some(uuid)) + .map(|c| { + if let Some(interface) = c.interface.to_owned() { + interface + } else { + c.clone().id + } + }) + .collect() + } } #[cfg(test)] @@ -260,57 +314,6 @@ mod tests { use crate::error::NetworkStateError; use uuid::Uuid; - #[test] - fn test_macaddress() { - let mut val: Option = None; - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("preserve")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Preserve - )); - - val = Some(String::from("permanent")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Permanent - )); - - val = Some(String::from("random")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Random - )); - - val = Some(String::from("stable")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Stable - )); - - val = Some(String::from("This is not a MACAddr")); - assert!(matches!( - MacAddress::try_from(&val), - Err(InvalidMacAddress(_)) - )); - - val = Some(String::from("de:ad:be:ef:2b:ad")); - assert_eq!( - MacAddress::try_from(&val).unwrap().to_string(), - String::from("de:ad:be:ef:2b:ad").to_uppercase() - ); - } - #[test] fn test_add_connection() { let mut state = NetworkState::default(); @@ -461,9 +464,7 @@ mod tests { pub const NOT_COPY_NETWORK_PATH: &str = "/run/agama/not_copy_network"; /// Network state -#[serde_as] -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Default)] pub struct GeneralState { pub hostname: String, pub connectivity: bool, @@ -472,37 +473,6 @@ pub struct GeneralState { pub networking_enabled: bool, // pub network_state: NMSTATE } -/// Access Point -#[serde_as] -#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AccessPoint { - #[serde_as(as = "DisplayFromStr")] - pub ssid: SSID, - pub hw_address: String, - pub strength: u8, - pub flags: u32, - pub rsn_flags: u32, - pub wpa_flags: u32, -} - -/// Network device -#[serde_as] -#[skip_serializing_none] -#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Device { - pub name: String, - #[serde(rename = "type")] - pub type_: DeviceType, - #[serde_as(as = "DisplayFromStr")] - pub mac_address: MacAddress, - pub ip_config: Option, - // Connection.id - pub connection: Option, - pub state: DeviceState, -} - /// Represents a known network connection. #[serde_as] #[skip_serializing_none] @@ -806,274 +776,6 @@ impl From for ConnectionConfig { } } -#[derive(Debug, Error)] -#[error("Invalid MAC address: {0}")] -pub struct InvalidMacAddress(String); - -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] -pub enum MacAddress { - #[schema(value_type = String, format = "MAC address in EUI-48 format")] - MacAddress(macaddr::MacAddr6), - Preserve, - Permanent, - Random, - Stable, - #[default] - Unset, -} - -impl FromStr for MacAddress { - type Err = InvalidMacAddress; - - fn from_str(s: &str) -> Result { - match s { - "preserve" => Ok(Self::Preserve), - "permanent" => Ok(Self::Permanent), - "random" => Ok(Self::Random), - "stable" => Ok(Self::Stable), - "" => Ok(Self::Unset), - _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { - Ok(mac) => mac, - Err(e) => return Err(InvalidMacAddress(e.to_string())), - })), - } - } -} - -impl TryFrom<&Option> for MacAddress { - type Error = InvalidMacAddress; - - fn try_from(value: &Option) -> Result { - match &value { - Some(str) => MacAddress::from_str(str), - None => Ok(Self::Unset), - } - } -} - -impl fmt::Display for MacAddress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::MacAddress(mac) => mac.to_string(), - Self::Preserve => "preserve".to_string(), - Self::Permanent => "permanent".to_string(), - Self::Random => "random".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidMacAddress) -> Self { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[skip_serializing_none] -#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpConfig { - pub method4: Ipv4Method, - pub method6: Ipv6Method, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_inet_array)] - pub addresses: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_addr_array)] - pub nameservers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub dns_searchlist: Vec, - pub ignore_auto_dns: bool, - #[schema(schema_with = schemas::ip_addr)] - pub gateway4: Option, - #[schema(schema_with = schemas::ip_addr)] - pub gateway6: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes4: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes6: Vec, - pub dhcp4_settings: Option, - pub dhcp6_settings: Option, - pub ip6_privacy: Option, - pub dns_priority4: Option, - pub dns_priority6: Option, -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp4Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub client_id: DhcpClientId, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpClientId { - Id(String), - Mac, - PermMac, - Ipv6Duid, - Duid, - Stable, - None, - #[default] - Unset, -} - -impl From<&str> for DhcpClientId { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ipv6-duid" => Self::Ipv6Duid, - "duid" => Self::Duid, - "stable" => Self::Stable, - "none" => Self::None, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpClientId { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpClientId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ipv6Duid => "ipv6-duid".to_string(), - Self::Duid => "duid".to_string(), - Self::Stable => "stable".to_string(), - Self::None => "none".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpIaid { - Id(String), - Mac, - PermMac, - Ifname, - Stable, - #[default] - Unset, -} - -impl From<&str> for DhcpIaid { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ifname" => Self::Ifname, - "stable" => Self::Stable, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpIaid { - fn from(value: Option) -> Self { - match value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpIaid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ifname => "ifname".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp6Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub duid: DhcpDuid, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpDuid { - Id(String), - Lease, - Llt, - Ll, - StableLlt, - StableLl, - StableUuid, - #[default] - Unset, -} - -impl From<&str> for DhcpDuid { - fn from(s: &str) -> Self { - match s { - "lease" => Self::Lease, - "llt" => Self::Llt, - "ll" => Self::Ll, - "stable-llt" => Self::StableLlt, - "stable-ll" => Self::StableLl, - "stable-uuid" => Self::StableUuid, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpDuid { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpDuid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Lease => "lease".to_string(), - Self::Llt => "llt".to_string(), - Self::Ll => "ll".to_string(), - Self::StableLlt => "stable-llt".to_string(), - Self::StableLl => "stable-ll".to_string(), - Self::StableUuid => "stable-uuid".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - #[skip_serializing_none] #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct MatchConfig { @@ -1087,125 +789,6 @@ pub struct MatchConfig { pub kernel: Vec, } -#[derive(Debug, Error)] -#[error("Unknown IP configuration method name: {0}")] -pub struct UnknownIpMethod(String); - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv4Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, -} - -impl fmt::Display for Ipv4Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv4Method::Disabled => "disabled", - Ipv4Method::Auto => "auto", - Ipv4Method::Manual => "manual", - Ipv4Method::LinkLocal => "link-local", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv4Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv4Method::Disabled), - "auto" => Ok(Ipv4Method::Auto), - "manual" => Ok(Ipv4Method::Manual), - "link-local" => Ok(Ipv4Method::LinkLocal), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv6Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, - Ignore = 4, - Dhcp = 5, -} - -impl fmt::Display for Ipv6Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv6Method::Disabled => "disabled", - Ipv6Method::Auto => "auto", - Ipv6Method::Manual => "manual", - Ipv6Method::LinkLocal => "link-local", - Ipv6Method::Ignore => "ignore", - Ipv6Method::Dhcp => "dhcp", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv6Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv6Method::Disabled), - "auto" => Ok(Ipv6Method::Auto), - "manual" => Ok(Ipv6Method::Manual), - "link-local" => Ok(Ipv6Method::LinkLocal), - "ignore" => Ok(Ipv6Method::Ignore), - "dhcp" => Ok(Ipv6Method::Dhcp), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: UnknownIpMethod) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpRoute { - #[schema(schema_with = schemas::ip_inet_ref)] - pub destination: IpInet, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(schema_with = schemas::ip_addr)] - pub next_hop: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metric: Option, -} - -impl From<&IpRoute> for HashMap<&str, Value<'_>> { - fn from(route: &IpRoute) -> Self { - let mut map: HashMap<&str, Value> = HashMap::from([ - ("dest", Value::new(route.destination.address().to_string())), - ( - "prefix", - Value::new(route.destination.network_length() as u32), - ), - ]); - if let Some(next_hop) = route.next_hop { - map.insert("next-hop", Value::new(next_hop.to_string())); - } - if let Some(metric) = route.metric { - map.insert("metric", Value::new(metric)); - } - map - } -} - #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum VlanProtocol { #[default] @@ -1742,6 +1325,179 @@ pub struct BondConfig { pub options: BondOptions, } +#[derive(Clone, Debug, Default)] +pub struct ConnectionCollection(pub Vec); + +impl ConnectionCollection { + pub fn ports_for(&self, uuid: Uuid) -> Vec { + self.0 + .iter() + .filter(|c| c.controller == Some(uuid)) + .map(|c| { + if let Some(interface) = c.interface.to_owned() { + interface + } else { + c.clone().id + } + }) + .collect() + } +} + +impl TryFrom for NetworkConnectionsCollection { + type Error = NetworkStateError; + + fn try_from(collection: ConnectionCollection) -> Result { + let network_connections = collection + .0 + .iter() + .filter(|c| c.controller.is_none()) + .map(|c| { + let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); + if let Some(ref mut bond) = conn.bond { + bond.ports = collection.ports_for(c.uuid); + } + if let Some(ref mut bridge) = conn.bridge { + bridge.ports = collection.ports_for(c.uuid); + }; + conn + }) + .collect(); + + Ok(NetworkConnectionsCollection(network_connections)) + } +} + +impl TryFrom for ConnectionCollection { + type Error = NetworkStateError; + + fn try_from(collection: NetworkConnectionsCollection) -> Result { + let mut conns: Vec = vec![]; + let mut controller_ports: HashMap = HashMap::new(); + + for net_conn in &collection.0 { + let mut conn = Connection::try_from(net_conn.clone())?; + conn.uuid = Uuid::new_v4(); + let mut ports = vec![]; + if let Some(bridge) = &net_conn.bridge { + ports = bridge.ports.clone(); + } + if let Some(bond) = &net_conn.bond { + ports = bond.ports.clone(); + } + for port in &ports { + controller_ports.insert(port.to_string(), conn.uuid); + } + + conns.push(conn); + } + + for (port, uuid) in controller_ports { + let default = Connection::new(port.clone(), DeviceType::Ethernet); + let mut conn = conns + .iter() + .find(|&c| c.id == port || c.interface == Some(port.to_string())) + .unwrap_or(&default) + .to_owned(); + conn.controller = Some(uuid); + conns.push(conn); + } + + Ok(ConnectionCollection(conns)) + } +} + +impl TryFrom for NetworkConnectionsCollection { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let network_connections = state + .connections + .iter() + .filter(|c| c.controller.is_none()) + .map(|c| { + let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); + if let Some(ref mut bond) = conn.bond { + bond.ports = state.ports_for(c.uuid); + } + if let Some(ref mut bridge) = conn.bridge { + bridge.ports = state.ports_for(c.uuid); + }; + conn + }) + .collect(); + + Ok(NetworkConnectionsCollection(network_connections)) + } +} + +impl TryFrom for StateSettings { + type Error = NetworkStateError; + + fn try_from(state: GeneralState) -> Result { + Ok(StateSettings { + connectivity: Some(state.connectivity), + copy_network: Some(state.copy_network), + wireless_enabled: Some(state.wireless_enabled), + networking_enabled: Some(state.networking_enabled), + }) + } +} + +impl TryFrom for NetworkSettings { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = state.try_into()?; + + Ok(NetworkSettings { connections }) + } +} + +impl TryFrom for Config { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Config { + connections: Some(connections), + state: Some(state.general_state.try_into()?), + }) + } +} + +impl TryFrom for SystemInfo { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(SystemInfo { + access_points: state.access_points, + connections, + devices: state.devices, + state: state.general_state.try_into()?, + }) + } +} + +impl TryFrom for Proposal { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Proposal { + connections, + state: state.general_state.try_into()?, + }) + } +} + impl TryFrom for BondConfig { type Error = NetworkStateError; diff --git a/rust/agama-network/src/nm/builder.rs b/rust/agama-network/src/nm/builder.rs index 3f79fa659e..fa216c1dad 100644 --- a/rust/agama-network/src/nm/builder.rs +++ b/rust/agama-network/src/nm/builder.rs @@ -20,13 +20,12 @@ //! Conversion mechanism between proxies and model structs. -use crate::types::{DeviceState, DeviceType}; use crate::{ - model::{Device, IpConfig, IpRoute, MacAddress}, nm::{ model::NmDeviceType, proxies::{DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, }, + types::{Device, DeviceState, DeviceType, IpConfig, IpRoute, MacAddress}, }; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; diff --git a/rust/agama-network/src/nm/client.rs b/rust/agama-network/src/nm/client.rs index 3b79dd527c..2268dd014d 100644 --- a/rust/agama-network/src/nm/client.rs +++ b/rust/agama-network/src/nm/client.rs @@ -35,10 +35,9 @@ use super::proxies::{ SettingsProxy, WirelessProxy, }; use crate::model::{ - AccessPoint, Connection, ConnectionConfig, Device, GeneralState, SecurityProtocol, - NOT_COPY_NETWORK_PATH, + Connection, ConnectionConfig, GeneralState, SecurityProtocol, NOT_COPY_NETWORK_PATH, }; -use crate::types::{AddFlags, ConnectionFlags, DeviceType, UpdateFlags, SSID}; +use crate::types::{AccessPoint, AddFlags, ConnectionFlags, Device, DeviceType, UpdateFlags, SSID}; use agama_utils::dbus::get_optional_property; use semver::Version; use uuid::Uuid; @@ -159,6 +158,7 @@ impl<'a> NetworkManagerClient<'a> { .build() .await?; + let device = proxy.interface().await?; let ssid = SSID(wproxy.ssid().await?); let hw_address = wproxy.hw_address().await?; let strength = wproxy.strength().await?; @@ -167,6 +167,7 @@ impl<'a> NetworkManagerClient<'a> { let wpa_flags = wproxy.wpa_flags().await?; points.push(AccessPoint { + device, ssid, hw_address, strength, @@ -439,7 +440,7 @@ impl<'a> NetworkManagerClient<'a> { Ok(()) } - async fn get_connection_proxy(&self, uuid: Uuid) -> Result { + async fn get_connection_proxy(&self, uuid: Uuid) -> Result, NmError> { let proxy = SettingsProxy::new(&self.connection).await?; let uuid_s = uuid.to_string(); let path = proxy.get_connection_by_uuid(uuid_s.as_str()).await?; @@ -453,7 +454,7 @@ impl<'a> NetworkManagerClient<'a> { // Returns the DeviceProxy for the given device name // /// * `name`: Device name. - async fn get_device_proxy(&self, name: String) -> Result { + async fn get_device_proxy(&self, name: String) -> Result, NmError> { let mut device_path: Option = None; for path in &self.nm_proxy.get_all_devices().await? { let proxy = DeviceProxy::builder(&self.connection) diff --git a/rust/agama-network/src/nm/dbus.rs b/rust/agama-network/src/nm/dbus.rs index c983180840..13dd8c66c3 100644 --- a/rust/agama-network/src/nm/dbus.rs +++ b/rust/agama-network/src/nm/dbus.rs @@ -24,7 +24,7 @@ //! with nested hash maps (see [NestedHash] and [OwnedNestedHash]). use super::{error::NmError, model::*}; use crate::model::*; -use crate::types::{BondMode, SSID}; +use crate::types::*; use agama_utils::dbus::{ get_optional_property, get_property, to_owned_hash, NestedHash, OwnedNestedHash, }; @@ -693,13 +693,13 @@ fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { NestedHash::from([(WIRELESS_KEY, wireless), (WIRELESS_SECURITY_KEY, security)]) } -fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value> { +fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut options = config.options.0.clone(); options.insert("mode".to_string(), config.mode.to_string()); HashMap::from([("options", Value::new(options))]) } -fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value> { +fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(stp) = bridge.stp { @@ -739,7 +739,9 @@ fn bridge_config_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn bridge_port_config_to_dbus( + bridge_port: &BridgePortConfig, +) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(prio) = bridge_port.priority { @@ -765,7 +767,7 @@ fn bridge_port_config_from_dbus( })) } -fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value> { +fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut infiniband_config: HashMap<&str, zvariant::Value> = HashMap::from([ ( "transport-mode", @@ -801,7 +803,7 @@ fn infiniband_config_from_dbus( Ok(Some(config)) } -fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value> { +fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut tun_config: HashMap<&str, zvariant::Value> = HashMap::from([("mode", Value::new(config.mode.clone() as u32))]); @@ -833,7 +835,7 @@ fn tun_config_from_dbus(conn: &OwnedNestedHash) -> Result, NmE })) } -fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut br_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(mcast_snooping) = br.mcast_snooping_enable { @@ -863,7 +865,7 @@ fn ovs_bridge_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn ovs_port_config_to_dbus(config: &OvsPortConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut port_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(tag) = &config.tag { @@ -883,7 +885,7 @@ fn ovs_port_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ifc_config: HashMap<&str, zvariant::Value> = HashMap::new(); ifc_config.insert("type", config.interface_type.to_string().clone().into()); @@ -905,7 +907,7 @@ fn ovs_interface_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn match_config_to_dbus(match_config: &MatchConfig) -> HashMap<&str, zvariant::Value<'_>> { let drivers: Value = match_config.driver.to_vec().into(); let kernels: Value = match_config.kernel.to_vec().into(); @@ -1374,7 +1376,7 @@ fn bond_config_from_dbus(conn: &OwnedNestedHash) -> Result, N Ok(Some(bond)) } -fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash { +fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash<'_> { let vlan: HashMap<&str, zvariant::Value> = HashMap::from([ ("id", cfg.id.into()), ("parent", cfg.parent.clone().into()), @@ -1401,7 +1403,7 @@ fn vlan_config_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value> { +fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ieee_8021x_config: HashMap<&str, zvariant::Value> = HashMap::from([( "eap", config @@ -1573,7 +1575,6 @@ mod test { connection_from_dbus, connection_to_dbus, merge_dbus_connections, NestedHash, OwnedNestedHash, }; - use crate::types::{BondMode, SSID}; use crate::{ model::*, nm::{ @@ -1583,6 +1584,7 @@ mod test { }, error::NmError, }, + types::*, }; use cidr::IpInet; use macaddr::MacAddr6; diff --git a/rust/agama-network/src/nm/error.rs b/rust/agama-network/src/nm/error.rs index 6e90c7bd44..be85ef8a8a 100644 --- a/rust/agama-network/src/nm/error.rs +++ b/rust/agama-network/src/nm/error.rs @@ -69,7 +69,7 @@ pub enum NmError { #[error("Invalid infiniband transport mode: '{0}'")] InvalidInfinibandTranportMode(#[from] crate::model::InvalidInfinibandTransportMode), #[error("Invalid MAC address: '{0}'")] - InvalidMACAddress(#[from] crate::model::InvalidMacAddress), + InvalidMACAddress(#[from] crate::types::InvalidMacAddress), #[error("Invalid network prefix: '{0}'")] InvalidNetworkPrefix(#[from] NetworkLengthTooLongError), #[error("Invalid network address: '{0}'")] diff --git a/rust/agama-network/src/nm/model.rs b/rust/agama-network/src/nm/model.rs index 10a6719b2f..75b499e03a 100644 --- a/rust/agama-network/src/nm/model.rs +++ b/rust/agama-network/src/nm/model.rs @@ -27,9 +27,9 @@ /// Using the newtype pattern around an String is enough. For proper support, we might replace this /// struct with an enum. use crate::{ - model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, + model::{SecurityProtocol, WirelessMode}, nm::error::NmError, - types::{ConnectionState, DeviceType}, + types::{ConnectionState, DeviceType, Ipv4Method, Ipv6Method}, }; use std::fmt; use std::str::FromStr; diff --git a/rust/agama-network/src/nm/watcher.rs b/rust/agama-network/src/nm/watcher.rs index 2f446848fc..141ca193e2 100644 --- a/rust/agama-network/src/nm/watcher.rs +++ b/rust/agama-network/src/nm/watcher.rs @@ -25,9 +25,8 @@ use std::collections::{hash_map::Entry, HashMap}; -use crate::{ - adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, -}; +use crate::types::Device; +use crate::{adapter::Watcher, nm::proxies::DeviceProxy, Action, NetworkAdapterError}; use anyhow::anyhow; use async_trait::async_trait; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; @@ -359,14 +358,14 @@ impl<'a> ProxiesRegistry<'a> { pub fn remove_active_connection( &mut self, path: &OwnedObjectPath, - ) -> Option { + ) -> Option> { self.active_connections.remove(path) } /// Removes a device from the registry. /// /// * `path`: D-Bus object path. - pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy<'_>)> { self.devices.remove(path) } diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index 64a80cc623..69bf54c5b4 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -21,10 +21,8 @@ use crate::{ action::Action, error::NetworkStateError, - model::{ - AccessPoint, Connection, Device, GeneralState, NetworkChange, NetworkState, StateConfig, - }, - types::DeviceType, + model::{Connection, GeneralState, NetworkChange, NetworkState, StateConfig}, + types::{AccessPoint, Config, Device, DeviceType, Proposal, SystemInfo}, Adapter, NetworkAdapterError, }; use std::error::Error; @@ -163,6 +161,31 @@ impl NetworkSystemClient { self.actions.send(Action::GetConnections(tx))?; Ok(rx.await?) } + pub async fn get_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetConfig(tx))?; + Ok(rx.await?) + } + + pub async fn get_proposal(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetProposal(tx))?; + Ok(rx.await?) + } + + pub async fn update_config(&self, config: Config) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::UpdateConfig(Box::new(config.clone()), tx))?; + let result = rx.await?; + Ok(result?) + } + + pub async fn get_system_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetSystemConfig(tx))?; + Ok(rx.await?) + } /// Adds a new connection. pub async fn add_connection(&self, connection: Connection) -> Result<(), NetworkSystemError> { @@ -310,6 +333,23 @@ impl NetworkSystemServer { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } + Action::GetSystemConfig(tx) => { + let result = self.read().await?.try_into()?; + tx.send(result).unwrap(); + } + Action::GetConfig(tx) => { + let config: Config = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::GetProposal(tx) => { + let config: Proposal = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::UpdateConfig(config, tx) => { + let result = self.state.update_state(*config); + + tx.send(result).unwrap(); + } Action::GetConnections(tx) => { let connections = self .state @@ -424,6 +464,11 @@ impl NetworkSystemServer { Ok((conn, controlled)) } + /// Reads the system network configuration. + pub async fn read(&mut self) -> Result { + self.adapter.read(StateConfig::default()).await + } + /// Writes the network configuration. pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { self.adapter.write(&self.state).await?; diff --git a/rust/agama-network/src/types.rs b/rust/agama-network/src/types.rs index f063d63949..a1b78ad55a 100644 --- a/rust/agama-network/src/types.rs +++ b/rust/agama-network/src/types.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -18,171 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use cidr::errors::NetworkParseError; +pub use agama_utils::api::network::*; use serde::{Deserialize, Serialize}; -use std::{ - fmt, - str::{self, FromStr}, -}; +use std::str::{self}; use thiserror::Error; -use zbus; - -use super::settings::NetworkConnection; - -/// Network device -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub struct Device { - pub name: String, - pub type_: DeviceType, - pub state: DeviceState, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct SSID(pub Vec); - -impl SSID { - pub fn to_vec(&self) -> &Vec { - &self.0 - } -} - -impl fmt::Display for SSID { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", str::from_utf8(&self.0).unwrap()) - } -} - -impl FromStr for SSID { - type Err = NetworkParseError; - - fn from_str(s: &str) -> Result { - Ok(SSID(s.as_bytes().into())) - } -} - -impl From for Vec { - fn from(value: SSID) -> Self { - value.0 - } -} - -#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum DeviceType { - Loopback = 0, - #[default] - Ethernet = 1, - Wireless = 2, - Dummy = 3, - Bond = 4, - Vlan = 5, - Bridge = 6, -} - -/// Network device state. -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum DeviceState { - #[default] - /// The device's state is unknown. - Unknown, - /// The device is recognized but not managed by Agama. - Unmanaged, - /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). - Unavailable, - /// The device is connecting to the network. - Connecting, - /// The device is successfully connected to the network. - Connected, - /// The device is disconnecting from the network. - Disconnecting, - /// The device is disconnected from the network. - Disconnected, - /// The device failed to connect to a network. - Failed, -} - -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ConnectionState { - /// The connection is getting activated. - Activating, - /// The connection is activated. - Activated, - /// The connection is getting deactivated. - Deactivating, - #[default] - /// The connection is deactivated. - Deactivated, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Status { - #[default] - Up, - Down, - Removed, - // Workaound for not modify the connection status - Keep, -} - -impl fmt::Display for Status { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Status::Up => "up", - Status::Down => "down", - Status::Keep => "keep", - Status::Removed => "removed", - }; - write!(f, "{}", name) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid status: {0}")] -pub struct InvalidStatus(String); - -impl TryFrom<&str> for Status { - type Error = InvalidStatus; - - fn try_from(value: &str) -> Result { - match value { - "up" => Ok(Status::Up), - "down" => Ok(Status::Down), - "keep" => Ok(Status::Keep), - "removed" => Ok(Status::Removed), - _ => Err(InvalidStatus(value.to_string())), - } - } -} // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMSettingsConnectionFlags #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] @@ -232,159 +71,3 @@ pub enum UpdateFlags { BlockAutoconnect = 0x20, NoReapply = 0x40, } - -/// Bond mode -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] -pub enum BondMode { - #[serde(rename = "balance-rr")] - RoundRobin = 0, - #[serde(rename = "active-backup")] - ActiveBackup = 1, - #[serde(rename = "balance-xor")] - BalanceXOR = 2, - #[serde(rename = "broadcast")] - Broadcast = 3, - #[serde(rename = "802.3ad")] - LACP = 4, - #[serde(rename = "balance-tlb")] - BalanceTLB = 5, - #[serde(rename = "balance-alb")] - BalanceALB = 6, -} -impl Default for BondMode { - fn default() -> Self { - Self::RoundRobin - } -} - -impl std::fmt::Display for BondMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - BondMode::RoundRobin => "balance-rr", - BondMode::ActiveBackup => "active-backup", - BondMode::BalanceXOR => "balance-xor", - BondMode::Broadcast => "broadcast", - BondMode::LACP => "802.3ad", - BondMode::BalanceTLB => "balance-tlb", - BondMode::BalanceALB => "balance-alb", - } - ) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid bond mode: {0}")] -pub struct InvalidBondMode(String); - -impl TryFrom<&str> for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: &str) -> Result { - match value { - "balance-rr" => Ok(BondMode::RoundRobin), - "active-backup" => Ok(BondMode::ActiveBackup), - "balance-xor" => Ok(BondMode::BalanceXOR), - "broadcast" => Ok(BondMode::Broadcast), - "802.3ad" => Ok(BondMode::LACP), - "balance-tlb" => Ok(BondMode::BalanceTLB), - "balance-alb" => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} -impl TryFrom for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(BondMode::RoundRobin), - 1 => Ok(BondMode::ActiveBackup), - 2 => Ok(BondMode::BalanceXOR), - 3 => Ok(BondMode::Broadcast), - 4 => Ok(BondMode::LACP), - 5 => Ok(BondMode::BalanceTLB), - 6 => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidBondMode) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid device type: {0}")] -pub struct InvalidDeviceType(u8); - -impl TryFrom for DeviceType { - type Error = InvalidDeviceType; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(DeviceType::Loopback), - 1 => Ok(DeviceType::Ethernet), - 2 => Ok(DeviceType::Wireless), - 3 => Ok(DeviceType::Dummy), - 4 => Ok(DeviceType::Bond), - 5 => Ok(DeviceType::Vlan), - 6 => Ok(DeviceType::Bridge), - _ => Err(InvalidDeviceType(value)), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidDeviceType) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -// FIXME: found a better place for the HTTP types. -// -// TODO: If the client ignores the additional "state" field, this struct -// does not need to be here. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct NetworkConnectionWithState { - #[serde(flatten)] - pub connection: NetworkConnection, - pub state: ConnectionState, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_display_ssid() { - let ssid = SSID(vec![97, 103, 97, 109, 97]); - assert_eq!(format!("{}", ssid), "agama"); - } - - #[test] - fn test_ssid_to_vec() { - let vec = vec![97, 103, 97, 109, 97]; - let ssid = SSID(vec.clone()); - assert_eq!(ssid.to_vec(), &vec); - } - - #[test] - fn test_device_type_from_u8() { - let dtype = DeviceType::try_from(0); - assert_eq!(dtype, Ok(DeviceType::Loopback)); - - let dtype = DeviceType::try_from(128); - assert_eq!(dtype, Err(InvalidDeviceType(128))); - } - - #[test] - fn test_display_bond_mode() { - let mode = BondMode::try_from(1).unwrap(); - assert_eq!(format!("{}", mode), "active-backup"); - } -} diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 338df05d2e..42b3976db3 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -13,6 +13,7 @@ agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } agama-locale-data = { path = "../agama-locale-data" } agama-manager = { path = "../agama-manager" } +agama-network = { path = "../agama-network" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 3000c9fd87..0339ee0d70 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -26,7 +26,6 @@ pub mod files; pub mod hostname; pub mod logs; pub mod manager; -pub mod network; pub mod profile; pub mod scripts; pub mod security; diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs deleted file mode 100644 index 004f75d231..0000000000 --- a/rust/agama-server/src/network/web.rs +++ /dev/null @@ -1,490 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the network module. - -use crate::error::Error; -use anyhow::Context; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Json, Router, -}; -use uuid::Uuid; - -use agama_lib::{ - error::ServiceError, - event, http, - network::{ - error::NetworkStateError, - model::{AccessPoint, Connection, Device, GeneralState}, - settings::NetworkConnection, - types::NetworkConnectionWithState, - Adapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, - }, -}; - -use serde::Deserialize; -use serde_json::json; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum NetworkError { - #[error("Unknown connection id: {0}")] - UnknownConnection(String), - #[error("Cannot translate: {0}")] - CannotTranslate(#[from] Error), - #[error("Cannot add new connection: {0}")] - CannotAddConnection(String), - #[error("Cannot update configuration: {0}")] - CannotUpdate(String), - #[error("Cannot apply configuration")] - CannotApplyConfig, - // TODO: to be removed after adapting to the NetworkSystemServer API - #[error("Network state error: {0}")] - Error(#[from] NetworkStateError), - #[error("Network system error: {0}")] - SystemError(#[from] NetworkSystemError), -} - -impl IntoResponse for NetworkError { - fn into_response(self) -> Response { - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - -#[derive(Clone)] -struct NetworkServiceState { - network: NetworkSystemClient, -} - -/// Sets up and returns the axum service for the network module. -/// * `adapter`: networking configuration adapter. -/// * `events`: sending-half of the broadcast channel. -pub async fn network_service( - adapter: T, - events: http::event::OldSender, -) -> Result { - let network = NetworkSystem::new(adapter); - // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own - // error type. - let client = network - .start() - .await - .context("Could not start the network configuration service.")?; - - let mut changes = client.subscribe(); - tokio::spawn(async move { - loop { - match changes.recv().await { - Ok(message) => { - let change = event!(NetworkChange { change: message }); - if let Err(e) = events.send(change) { - eprintln!("Could not send the event: {}", e); - } - } - Err(e) => { - eprintln!("Could not send the event: {}", e); - } - } - } - }); - - let state = NetworkServiceState { network: client }; - - Ok(Router::new() - .route("/state", get(general_state).put(update_general_state)) - .route("/connections", get(connections).post(add_connection)) - .route( - "/connections/:id", - delete(delete_connection) - .put(update_connection) - .get(connection), - ) - .route("/connections/:id/connect", post(connect)) - .route("/connections/:id/disconnect", post(disconnect)) - .route("/connections/persist", post(persist)) - .route("/devices", get(devices)) - .route("/system/apply", post(apply)) - .route("/wifi", get(wifi_networks)) - .with_state(state)) -} - -#[utoipa::path( - get, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Get general network config", body = GeneralState) - ) -)] -async fn general_state( - State(state): State, -) -> Result, NetworkError> { - let general_state = state.network.get_state().await?; - Ok(Json(general_state)) -} - -#[utoipa::path( - put, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Update general network config", body = GeneralState) - ) -)] -async fn update_general_state( - State(state): State, - Json(value): Json, -) -> Result, NetworkError> { - state.network.update_state(value)?; - let state = state.network.get_state().await?; - Ok(Json(state)) -} - -#[utoipa::path( - get, - path = "/wifi", - context_path = "/api/network", - responses( - (status = 200, description = "List of wireless networks", body = Vec) - ) -)] -async fn wifi_networks( - State(state): State, -) -> Result>, NetworkError> { - state.network.wifi_scan().await?; - let access_points = state.network.get_access_points().await?; - - let mut networks = vec![]; - for ap in access_points { - if !ap.ssid.to_string().is_empty() { - networks.push(ap); - } - } - - Ok(Json(networks)) -} - -#[utoipa::path( - get, - path = "/devices", - context_path = "/api/network", - responses( - (status = 200, description = "List of devices", body = Vec) - ) -)] -async fn devices( - State(state): State, -) -> Result>, NetworkError> { - Ok(Json(state.network.get_devices().await?)) -} - -#[utoipa::path( - get, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "List of known connections", body = Vec) - ) -)] -async fn connections( - State(state): State, -) -> Result>, NetworkError> { - let connections = state.network.get_connections().await?; - - let network_connections = connections - .iter() - .filter(|c| c.controller.is_none()) - .map(|c| { - let state = c.state; - let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); - if let Some(ref mut bond) = conn.bond { - bond.ports = ports_for(connections.to_owned(), c.uuid); - } - if let Some(ref mut bridge) = conn.bridge { - bridge.ports = ports_for(connections.to_owned(), c.uuid); - }; - NetworkConnectionWithState { - connection: conn, - state, - } - }) - .collect(); - - Ok(Json(network_connections)) -} - -fn ports_for(connections: Vec, uuid: Uuid) -> Vec { - return connections - .iter() - .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) - .collect(); -} - -#[utoipa::path( - post, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "Add a new connection", body = Connection) - ) -)] -async fn add_connection( - State(state): State, - Json(net_conn): Json, -) -> Result, NetworkError> { - let bond = net_conn.bond.clone(); - let bridge = net_conn.bridge.clone(); - let conn = Connection::try_from(net_conn)?; - let id = conn.id.clone(); - - state.network.add_connection(conn.clone()).await?; - - match state.network.get_connection(&id).await? { - None => Err(NetworkError::CannotAddConnection(id.clone())), - Some(conn) => { - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - Ok(Json(conn)) - } - } -} - -#[utoipa::path( - get, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Get connection given by its ID", body = NetworkConnection) - ) -)] -async fn connection( - State(state): State, - Path(id): Path, -) -> Result, NetworkError> { - let conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - - let conn = NetworkConnection::try_from(conn)?; - - Ok(Json(conn)) -} - -#[utoipa::path( - delete, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Delete connection", body = Connection) - ) -)] -async fn delete_connection( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - if state.network.remove_connection(&id).await.is_ok() { - StatusCode::NO_CONTENT - } else { - StatusCode::NOT_FOUND - } -} - -#[utoipa::path( - put, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 204, description = "Update connection", body = Connection) - ) -)] -async fn update_connection( - State(state): State, - Path(id): Path, - Json(conn): Json, -) -> Result { - let orig_conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - let bond = conn.bond.clone(); - let bridge = conn.bridge.clone(); - - let mut conn = Connection::try_from(conn)?; - conn.uuid = orig_conn.uuid; - - state.network.update_connection(conn.clone()).await?; - - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/connect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn connect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_up(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/disconnect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn disconnect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_down(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct PersistParams { - pub only: Option>, - pub value: bool, -} - -#[utoipa::path( - post, - path = "/connections/persist", - context_path = "/api/network", - responses( - (status = 204, description = "Persist the given connection to disk", body = PersistParams) - ) -)] -async fn persist( - State(state): State, - Json(persist): Json, -) -> Result { - let mut connections = state.network.get_connections().await?; - let ids = persist.only.unwrap_or(vec![]); - - for conn in connections.iter_mut() { - if ids.is_empty() || ids.contains(&conn.id) { - conn.persistent = persist.value; - conn.keep_status(); - - state - .network - .update_connection(conn.to_owned()) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - } - } - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/system/apply", - context_path = "/api/network", - responses( - (status = 204, description = "Apply configuration") - ) -)] -async fn apply( - State(state): State, -) -> Result { - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 5bd9bd4b72..6641c41aba 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -30,7 +30,6 @@ use crate::{ files::web::files_service, hostname::web::hostname_service, manager::web::{manager_service, manager_stream}, - network::{web::network_service, NetworkManagerAdapter}, profile::web::profile_service, scripts::web::scripts_service, security::security_service, @@ -77,10 +76,6 @@ pub async fn service

    ( where P: AsRef, { - let network_adapter = NetworkManagerAdapter::from_system() - .await - .expect("Could not connect to NetworkManager to read the configuration"); - let progress = ProgressService::start(dbus.clone(), old_events.clone()).await; let router = MainServiceBuilder::new(events.clone(), old_events.clone(), web_ui_dir) @@ -97,10 +92,6 @@ where .add_service("/storage", storage_service(dbus.clone(), progress).await?) .add_service("/iscsi", iscsi_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) - .add_service( - "/network", - network_service(network_adapter, old_events).await?, - ) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) .add_service("/files", files_service().await?) diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 219a476ed3..87fcffbc08 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -24,8 +24,6 @@ mod config; pub use config::ConfigApiDocBuilder; mod hostname; pub use hostname::HostnameApiDocBuilder; -mod network; -pub use network::NetworkApiDocBuilder; mod storage; pub use storage::StorageApiDocBuilder; mod bootloader; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ef0c136a18..1ae7c030db 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -54,30 +54,17 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -99,16 +86,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -172,6 +149,30 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/src/web/docs/network.rs b/rust/agama-server/src/web/docs/network.rs deleted file mode 100644 index 26661f41fe..0000000000 --- a/rust/agama-server/src/web/docs/network.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_utils::openapi::schemas; -use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; - -use super::ApiDocBuilder; - -pub struct NetworkApiDocBuilder; - -impl ApiDocBuilder for NetworkApiDocBuilder { - fn title(&self) -> String { - "Network HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema("IpAddr", schemas::ip_addr()) - .schema("IpInet", schemas::ip_inet()) - .schema("macaddr.MacAddr6", schemas::mac_addr6()) - .build() - } -} diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs deleted file mode 100644 index f1714d4e8e..0000000000 --- a/rust/agama-server/tests/network_service.rs +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod common; - -use agama_lib::error::ServiceError; -use agama_lib::network::settings::{BondSettings, BridgeSettings, NetworkConnection}; -use agama_lib::network::types::{DeviceType, SSID}; -use agama_lib::network::{ - model::{self, AccessPoint, GeneralState, NetworkState, StateConfig}, - Adapter, NetworkAdapterError, -}; -use agama_server::network::web::network_service; - -use async_trait::async_trait; -use axum::http::header; -use axum::{ - body::Body, - http::{Method, Request, StatusCode}, - Router, -}; -use common::body_to_string; -use serde_json::to_string; -use std::error::Error; -use tokio::{sync::broadcast, test}; -use tower::ServiceExt; - -async fn build_state() -> NetworkState { - let general_state = GeneralState::default(); - let device = model::Device { - name: String::from("eth0"), - type_: DeviceType::Ethernet, - ..Default::default() - }; - let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - - NetworkState::new(general_state, vec![], vec![device], vec![eth0]) -} - -async fn build_service(state: NetworkState) -> Result { - let adapter = NetworkTestAdapter(state); - let (tx, _rx) = broadcast::channel(16); - network_service(adapter, tx).await -} - -#[derive(Default)] -pub struct NetworkTestAdapter(NetworkState); - -#[async_trait] -impl Adapter for NetworkTestAdapter { - async fn read(&self, _: StateConfig) -> Result { - Ok(self.0.clone()) - } - - async fn write(&self, _network: &NetworkState) -> Result<(), NetworkAdapterError> { - unimplemented!("Not used in tests"); - } -} - -#[test] -async fn test_network_state() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state).await?; - - let request = Request::builder() - .uri("/state") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""wirelessEnabled":false"#)); - Ok(()) -} - -#[test] -async fn test_change_network_state() -> Result<(), Box> { - let mut state = build_state().await; - let network_service = build_service(state.clone()).await?; - state.general_state.wireless_enabled = true; - - let request = Request::builder() - .uri("/state") - .method(Method::PUT) - .header(header::CONTENT_TYPE, "application/json") - .body(to_string(&state.general_state)?) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body(); - let body = body_to_string(body).await; - assert_eq!(body, to_string(&state.general_state)?); - Ok(()) -} - -#[test] -async fn test_network_connections() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"eth0""#)); - Ok(()) -} - -#[test] -async fn test_network_devices() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/devices") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""name":"eth0""#)); - Ok(()) -} - -#[test] -async fn test_network_wifis() -> Result<(), Box> { - let mut state = build_state().await; - state.access_points = vec![ - AccessPoint { - ssid: SSID("AgamaNetwork".as_bytes().into()), - hw_address: "00:11:22:33:44:00".into(), - ..Default::default() - }, - AccessPoint { - ssid: SSID("AgamaNetwork2".as_bytes().into()), - hw_address: "00:11:22:33:44:01".into(), - ..Default::default() - }, - ]; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/wifi") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""ssid":"AgamaNetwork""#)); - assert!(body.contains(r#""ssid":"AgamaNetwork2""#)); - Ok(()) -} - -#[test] -async fn test_add_bond_connection() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let eth2 = NetworkConnection { - id: "eth2".to_string(), - ..Default::default() - }; - - let bond0 = NetworkConnection { - id: "bond0".to_string(), - method4: Some("auto".to_string()), - method6: Some("disabled".to_string()), - interface: Some("bond0".to_string()), - bond: Some(BondSettings { - mode: "active-backup".to_string(), - ports: vec!["eth0".to_string()], - options: Some("primary=eth0".to_string()), - }), - ..Default::default() - }; - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(ð2)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(&bond0)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"bond0""#)); - assert!(body.contains(r#""mode":"active-backup""#)); - assert!(body.contains(r#""primary=eth0""#)); - assert!(body.contains(r#""ports":["eth0"]"#)); - - Ok(()) -} - -#[test] -async fn test_add_bridge_connection() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let br0 = NetworkConnection { - id: "br0".to_string(), - method4: Some("manual".to_string()), - method6: Some("disabled".to_string()), - interface: Some("br0".to_string()), - bridge: Some(BridgeSettings { - ports: vec!["eth0".to_string()], - stp: Some(false), - ..Default::default() - }), - ..Default::default() - }; - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(&br0)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"br0""#)); - assert!(body.contains(r#""ports":["eth0"]"#)); - assert!(body.contains(r#""stp":false"#)); - - Ok(()) -} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 15f4b79fb0..961d044bde 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -19,6 +19,8 @@ zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } uuid = { version = "1.10.0", features = ["v4"] } +cidr = { version = "0.3.1", features = ["serde"] } +macaddr = { version = "1.0.1", features = ["serde_std"] } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 89ccd79dcc..9348189faf 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -52,5 +52,6 @@ mod action; pub use action::Action; pub mod l10n; +pub mod network; pub mod question; pub mod storage; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index c648114f46..31608dc14b 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question, storage}; +use crate::api::{l10n, network, question, storage}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] @@ -28,6 +28,8 @@ pub struct Config { #[serde(alias = "localization")] pub l10n: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(flatten)] diff --git a/rust/agama-server/src/network.rs b/rust/agama-utils/src/api/network.rs similarity index 70% rename from rust/agama-server/src/network.rs rename to rust/agama-utils/src/api/network.rs index 95e80f2639..75eb23f9a4 100644 --- a/rust/agama-server/src/network.rs +++ b/rust/agama-utils/src/api/network.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,8 +18,17 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod web; +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. -pub use agama_lib::network::{ - model::NetworkState, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, -}; +mod config; +pub use config::Config; +mod proposal; +pub use proposal::Proposal; +mod settings; +mod system_info; +pub use system_info::SystemInfo; + +mod types; +pub use settings::*; +pub use types::*; diff --git a/rust/agama-utils/src/api/network/config.rs b/rust/agama-utils/src/api/network/config.rs new file mode 100644 index 0000000000..51b7848513 --- /dev/null +++ b/rust/agama-utils/src/api/network/config.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network config settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Connections to use in the installation + pub connections: Option, + pub state: Option, +} diff --git a/rust/agama-utils/src/api/network/proposal.rs b/rust/agama-utils/src/api/network/proposal.rs new file mode 100644 index 0000000000..39bbe548a6 --- /dev/null +++ b/rust/agama-utils/src/api/network/proposal.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network proposal settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Proposal { + /// Connections to use in the installation + pub connections: NetworkConnectionsCollection, + pub state: StateSettings, +} diff --git a/rust/agama-network/src/settings.rs b/rust/agama-utils/src/api/network/settings.rs similarity index 90% rename from rust/agama-network/src/settings.rs rename to rust/agama-utils/src/api/network/settings.rs index db9a4f6120..f4ac92f023 100644 --- a/rust/agama-network/src/settings.rs +++ b/rust/agama-utils/src/api/network/settings.rs @@ -20,19 +20,34 @@ //! Representation of the network settings -use super::types::{DeviceState, DeviceType, Status}; -use agama_utils::openapi::schemas; +use super::types::{ConnectionState, DeviceState, DeviceType, Status}; +use crate::openapi::schemas; use cidr::IpInet; use serde::{Deserialize, Serialize}; use std::default::Default; use std::net::IpAddr; +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionsCollection(pub Vec); + /// Network settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { - /// Connections to use in the installation - pub connections: Vec, + pub connections: NetworkConnectionsCollection, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StateSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub connectivity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wireless_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub networking_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub copy_network: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] @@ -196,7 +211,7 @@ pub struct IEEE8021XSettings { pub peap_label: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct NetworkDevice { pub id: String, pub type_: DeviceType, @@ -302,3 +317,14 @@ impl NetworkConnection { } } } + +// FIXME: found a better place for the HTTP types. +// +// TODO: If the client ignores the additional "state" field, this struct +// does not need to be here. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionWithState { + #[serde(flatten)] + pub connection: NetworkConnection, + pub state: ConnectionState, +} diff --git a/rust/agama-utils/src/api/network/system_info.rs b/rust/agama-utils/src/api/network/system_info.rs new file mode 100644 index 0000000000..f7d9d43b97 --- /dev/null +++ b/rust/agama-utils/src/api/network/system_info.rs @@ -0,0 +1,36 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{AccessPoint, Device, NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SystemInfo { + pub access_points: Vec, // networks or access_points shold be returned + /// Connections to use in the installation + pub connections: NetworkConnectionsCollection, + pub devices: Vec, + pub state: StateSettings, +} diff --git a/rust/agama-utils/src/api/network/types.rs b/rust/agama-utils/src/api/network/types.rs new file mode 100644 index 0000000000..29115dcc1a --- /dev/null +++ b/rust/agama-utils/src/api/network/types.rs @@ -0,0 +1,790 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::openapi::schemas; +use cidr::{errors::NetworkParseError, IpInet}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; +use std::{ + collections::HashMap, + fmt, + net::IpAddr, + str::{self, FromStr}, +}; +use thiserror::Error; +use zbus::zvariant::Value; + +/// Access Point +#[serde_as] +#[derive(Default, Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccessPoint { + pub device: String, + #[serde_as(as = "DisplayFromStr")] + pub ssid: SSID, + pub hw_address: String, + pub strength: u8, + pub flags: u32, + pub rsn_flags: u32, + pub wpa_flags: u32, +} + +/// Network device +#[serde_as] +#[skip_serializing_none] +#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub name: String, + #[serde(rename = "type")] + pub type_: DeviceType, + #[serde_as(as = "DisplayFromStr")] + pub mac_address: MacAddress, + pub ip_config: Option, + // Connection.id + pub connection: Option, + pub state: DeviceState, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] +pub enum MacAddress { + #[schema(value_type = String, format = "MAC address in EUI-48 format")] + MacAddress(macaddr::MacAddr6), + Preserve, + Permanent, + Random, + Stable, + #[default] + Unset, +} + +impl fmt::Display for MacAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::MacAddress(mac) => mac.to_string(), + Self::Preserve => "preserve".to_string(), + Self::Permanent => "permanent".to_string(), + Self::Random => "random".to_string(), + Self::Stable => "stable".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Error)] +#[error("Invalid MAC address: {0}")] +pub struct InvalidMacAddress(String); + +impl FromStr for MacAddress { + type Err = InvalidMacAddress; + + fn from_str(s: &str) -> Result { + match s { + "preserve" => Ok(Self::Preserve), + "permanent" => Ok(Self::Permanent), + "random" => Ok(Self::Random), + "stable" => Ok(Self::Stable), + "" => Ok(Self::Unset), + _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { + Ok(mac) => mac, + Err(e) => return Err(InvalidMacAddress(e.to_string())), + })), + } + } +} + +impl TryFrom<&Option> for MacAddress { + type Error = InvalidMacAddress; + + fn try_from(value: &Option) -> Result { + match &value { + Some(str) => MacAddress::from_str(str), + None => Ok(Self::Unset), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidMacAddress) -> Self { + zbus::fdo::Error::Failed(value.to_string()) + } +} + +#[skip_serializing_none] +#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IpConfig { + pub method4: Ipv4Method, + pub method6: Ipv6Method, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schema(schema_with = schemas::ip_inet_array)] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schema(schema_with = schemas::ip_addr_array)] + pub nameservers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dns_searchlist: Vec, + pub ignore_auto_dns: bool, + #[schema(schema_with = schemas::ip_addr)] + pub gateway4: Option, + #[schema(schema_with = schemas::ip_addr)] + pub gateway6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes4: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes6: Vec, + pub dhcp4_settings: Option, + pub dhcp6_settings: Option, + pub ip6_privacy: Option, + pub dns_priority4: Option, + pub dns_priority6: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Dhcp4Settings { + pub send_hostname: Option, + pub hostname: Option, + pub send_release: Option, + pub client_id: DhcpClientId, + pub iaid: DhcpIaid, +} + +#[skip_serializing_none] +#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Dhcp6Settings { + pub send_hostname: Option, + pub hostname: Option, + pub send_release: Option, + pub duid: DhcpDuid, + pub iaid: DhcpIaid, +} +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpClientId { + Id(String), + Mac, + PermMac, + Ipv6Duid, + Duid, + Stable, + None, + #[default] + Unset, +} + +impl From<&str> for DhcpClientId { + fn from(s: &str) -> Self { + match s { + "mac" => Self::Mac, + "perm-mac" => Self::PermMac, + "ipv6-duid" => Self::Ipv6Duid, + "duid" => Self::Duid, + "stable" => Self::Stable, + "none" => Self::None, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpClientId { + fn from(value: Option) -> Self { + match &value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpClientId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Mac => "mac".to_string(), + Self::PermMac => "perm-mac".to_string(), + Self::Ipv6Duid => "ipv6-duid".to_string(), + Self::Duid => "duid".to_string(), + Self::Stable => "stable".to_string(), + Self::None => "none".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpDuid { + Id(String), + Lease, + Llt, + Ll, + StableLlt, + StableLl, + StableUuid, + #[default] + Unset, +} + +impl From<&str> for DhcpDuid { + fn from(s: &str) -> Self { + match s { + "lease" => Self::Lease, + "llt" => Self::Llt, + "ll" => Self::Ll, + "stable-llt" => Self::StableLlt, + "stable-ll" => Self::StableLl, + "stable-uuid" => Self::StableUuid, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpDuid { + fn from(value: Option) -> Self { + match &value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpDuid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Lease => "lease".to_string(), + Self::Llt => "llt".to_string(), + Self::Ll => "ll".to_string(), + Self::StableLlt => "stable-llt".to_string(), + Self::StableLl => "stable-ll".to_string(), + Self::StableUuid => "stable-uuid".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpIaid { + Id(String), + Mac, + PermMac, + Ifname, + Stable, + #[default] + Unset, +} + +impl From<&str> for DhcpIaid { + fn from(s: &str) -> Self { + match s { + "mac" => Self::Mac, + "perm-mac" => Self::PermMac, + "ifname" => Self::Ifname, + "stable" => Self::Stable, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpIaid { + fn from(value: Option) -> Self { + match value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpIaid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Mac => "mac".to_string(), + Self::PermMac => "perm-mac".to_string(), + Self::Ifname => "ifname".to_string(), + Self::Stable => "stable".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IpRoute { + #[schema(schema_with = schemas::ip_inet_ref)] + pub destination: IpInet, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(schema_with = schemas::ip_addr)] + pub next_hop: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, +} + +impl From<&IpRoute> for HashMap<&str, Value<'_>> { + fn from(route: &IpRoute) -> Self { + let mut map: HashMap<&str, Value> = HashMap::from([ + ("dest", Value::new(route.destination.address().to_string())), + ( + "prefix", + Value::new(route.destination.network_length() as u32), + ), + ]); + if let Some(next_hop) = route.next_hop { + map.insert("next-hop", Value::new(next_hop.to_string())); + } + if let Some(metric) = route.metric { + map.insert("metric", Value::new(metric)); + } + map + } +} + +#[derive(Debug, Error)] +#[error("Unknown IP configuration method name: {0}")] +pub struct UnknownIpMethod(String); + +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Ipv4Method { + Disabled = 0, + #[default] + Auto = 1, + Manual = 2, + LinkLocal = 3, +} + +impl fmt::Display for Ipv4Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Ipv4Method::Disabled => "disabled", + Ipv4Method::Auto => "auto", + Ipv4Method::Manual => "manual", + Ipv4Method::LinkLocal => "link-local", + }; + write!(f, "{}", name) + } +} + +impl FromStr for Ipv4Method { + type Err = UnknownIpMethod; + + fn from_str(s: &str) -> Result { + match s { + "disabled" => Ok(Ipv4Method::Disabled), + "auto" => Ok(Ipv4Method::Auto), + "manual" => Ok(Ipv4Method::Manual), + "link-local" => Ok(Ipv4Method::LinkLocal), + _ => Err(UnknownIpMethod(s.to_string())), + } + } +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Ipv6Method { + Disabled = 0, + #[default] + Auto = 1, + Manual = 2, + LinkLocal = 3, + Ignore = 4, + Dhcp = 5, +} + +impl fmt::Display for Ipv6Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Ipv6Method::Disabled => "disabled", + Ipv6Method::Auto => "auto", + Ipv6Method::Manual => "manual", + Ipv6Method::LinkLocal => "link-local", + Ipv6Method::Ignore => "ignore", + Ipv6Method::Dhcp => "dhcp", + }; + write!(f, "{}", name) + } +} + +impl FromStr for Ipv6Method { + type Err = UnknownIpMethod; + + fn from_str(s: &str) -> Result { + match s { + "disabled" => Ok(Ipv6Method::Disabled), + "auto" => Ok(Ipv6Method::Auto), + "manual" => Ok(Ipv6Method::Manual), + "link-local" => Ok(Ipv6Method::LinkLocal), + "ignore" => Ok(Ipv6Method::Ignore), + "dhcp" => Ok(Ipv6Method::Dhcp), + _ => Err(UnknownIpMethod(s.to_string())), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: UnknownIpMethod) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(value.to_string()) + } +} +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SSID(pub Vec); + +impl SSID { + pub fn to_vec(&self) -> &Vec { + &self.0 + } +} + +impl fmt::Display for SSID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", str::from_utf8(&self.0).unwrap()) + } +} + +impl FromStr for SSID { + type Err = NetworkParseError; + + fn from_str(s: &str) -> Result { + Ok(SSID(s.as_bytes().into())) + } +} + +impl From for Vec { + fn from(value: SSID) -> Self { + value.0 + } +} + +#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum DeviceType { + Loopback = 0, + #[default] + Ethernet = 1, + Wireless = 2, + Dummy = 3, + Bond = 4, + Vlan = 5, + Bridge = 6, +} + +/// Network device state. +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum DeviceState { + #[default] + /// The device's state is unknown. + Unknown, + /// The device is recognized but not managed by Agama. + Unmanaged, + /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). + Unavailable, + /// The device is connecting to the network. + Connecting, + /// The device is successfully connected to the network. + Connected, + /// The device is disconnecting from the network. + Disconnecting, + /// The device is disconnected from the network. + Disconnected, + /// The device failed to connect to a network. + Failed, +} + +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ConnectionState { + /// The connection is getting activated. + Activating, + /// The connection is activated. + Activated, + /// The connection is getting deactivated. + Deactivating, + #[default] + /// The connection is deactivated. + Deactivated, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Status { + #[default] + Up, + Down, + Removed, + // Workaound for not modify the connection status + Keep, +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Status::Up => "up", + Status::Down => "down", + Status::Keep => "keep", + Status::Removed => "removed", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid status: {0}")] +pub struct InvalidStatus(String); + +impl TryFrom<&str> for Status { + type Error = InvalidStatus; + + fn try_from(value: &str) -> Result { + match value { + "up" => Ok(Status::Up), + "down" => Ok(Status::Down), + "keep" => Ok(Status::Keep), + "removed" => Ok(Status::Removed), + _ => Err(InvalidStatus(value.to_string())), + } + } +} + +/// Bond mode +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] +pub enum BondMode { + #[serde(rename = "balance-rr")] + RoundRobin = 0, + #[serde(rename = "active-backup")] + ActiveBackup = 1, + #[serde(rename = "balance-xor")] + BalanceXOR = 2, + #[serde(rename = "broadcast")] + Broadcast = 3, + #[serde(rename = "802.3ad")] + LACP = 4, + #[serde(rename = "balance-tlb")] + BalanceTLB = 5, + #[serde(rename = "balance-alb")] + BalanceALB = 6, +} +impl Default for BondMode { + fn default() -> Self { + Self::RoundRobin + } +} + +impl std::fmt::Display for BondMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + BondMode::RoundRobin => "balance-rr", + BondMode::ActiveBackup => "active-backup", + BondMode::BalanceXOR => "balance-xor", + BondMode::Broadcast => "broadcast", + BondMode::LACP => "802.3ad", + BondMode::BalanceTLB => "balance-tlb", + BondMode::BalanceALB => "balance-alb", + } + ) + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid bond mode: {0}")] +pub struct InvalidBondMode(String); + +impl TryFrom<&str> for BondMode { + type Error = InvalidBondMode; + + fn try_from(value: &str) -> Result { + match value { + "balance-rr" => Ok(BondMode::RoundRobin), + "active-backup" => Ok(BondMode::ActiveBackup), + "balance-xor" => Ok(BondMode::BalanceXOR), + "broadcast" => Ok(BondMode::Broadcast), + "802.3ad" => Ok(BondMode::LACP), + "balance-tlb" => Ok(BondMode::BalanceTLB), + "balance-alb" => Ok(BondMode::BalanceALB), + _ => Err(InvalidBondMode(value.to_string())), + } + } +} +impl TryFrom for BondMode { + type Error = InvalidBondMode; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(BondMode::RoundRobin), + 1 => Ok(BondMode::ActiveBackup), + 2 => Ok(BondMode::BalanceXOR), + 3 => Ok(BondMode::Broadcast), + 4 => Ok(BondMode::LACP), + 5 => Ok(BondMode::BalanceTLB), + 6 => Ok(BondMode::BalanceALB), + _ => Err(InvalidBondMode(value.to_string())), + } + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid device type: {0}")] +pub struct InvalidDeviceType(u8); + +impl TryFrom for DeviceType { + type Error = InvalidDeviceType; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(DeviceType::Loopback), + 1 => Ok(DeviceType::Ethernet), + 2 => Ok(DeviceType::Wireless), + 3 => Ok(DeviceType::Dummy), + 4 => Ok(DeviceType::Bond), + 5 => Ok(DeviceType::Vlan), + 6 => Ok(DeviceType::Bridge), + _ => Err(InvalidDeviceType(value)), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidBondMode) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(format!("Network error: {value}")) + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidDeviceType) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(format!("Network error: {value}")) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_ssid() { + let ssid = SSID(vec![97, 103, 97, 109, 97]); + assert_eq!(format!("{}", ssid), "agama"); + } + + #[test] + fn test_ssid_to_vec() { + let vec = vec![97, 103, 97, 109, 97]; + let ssid = SSID(vec.clone()); + assert_eq!(ssid.to_vec(), &vec); + } + + #[test] + fn test_device_type_from_u8() { + let dtype = DeviceType::try_from(0); + assert_eq!(dtype, Ok(DeviceType::Loopback)); + + let dtype = DeviceType::try_from(128); + assert_eq!(dtype, Err(InvalidDeviceType(128))); + } + + #[test] + fn test_display_bond_mode() { + let mode = BondMode::try_from(1).unwrap(); + assert_eq!(format!("{}", mode), "active-backup"); + } + + #[test] + fn test_macaddress() { + let mut val: Option = None; + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Unset + )); + + val = Some(String::from("")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Unset + )); + + val = Some(String::from("preserve")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Preserve + )); + + val = Some(String::from("permanent")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Permanent + )); + + val = Some(String::from("random")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Random + )); + + val = Some(String::from("stable")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Stable + )); + + val = Some(String::from("This is not a MACAddr")); + assert!(matches!( + MacAddress::try_from(&val), + Err(InvalidMacAddress(_)) + )); + + val = Some(String::from("de:ad:be:ef:2b:ad")); + assert_eq!( + MacAddress::try_from(&val).unwrap().to_string(), + String::from("de:ad:be:ef:2b:ad").to_uppercase() + ); + } +} diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 4b184c0913..7a66f5d050 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::l10n; +use crate::api::{l10n, network}; use serde::Serialize; use serde_json::Value; @@ -27,6 +27,7 @@ use serde_json::Value; pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, + pub network: network::Proposal, #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, } diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index 0ae7e5b8b2..7bc787077e 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -19,6 +19,7 @@ // find current contact information at www.suse.com. use crate::api::l10n; +use crate::api::network; use serde::Serialize; use serde_json::Value; @@ -29,4 +30,5 @@ pub struct SystemInfo { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, + pub network: network::SystemInfo, } diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 92cf1d7a1f..0e31d8df2b 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -6,8 +6,8 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, - MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, - SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, + MiscApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, SoftwareApiDocBuilder, + StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -68,7 +68,6 @@ mod tasks { write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; - write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; write_openapi(ScriptsApiDocBuilder {}, out_dir.join("scripts.json"))?; write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; From b520d861131d4c1f2e6e09445789ee9725f6b3b2 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Mon, 10 Nov 2025 21:33:12 +0100 Subject: [PATCH 373/917] add to C layer callbacks for download part of commit --- .../zypp-agama-sys/c-layer/callbacks.cxx | 171 ++++++++++++++++++ .../c-layer/include/callbacks.h | 61 +++++++ .../zypp-agama-sys/c-layer/include/lib.h | 5 +- .../c-layer/internal/callbacks.hxx | 7 + .../zypp-agama/zypp-agama-sys/c-layer/lib.cxx | 8 +- 5 files changed, 247 insertions(+), 5 deletions(-) diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx index f76be253ca..38bdf5e845 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/callbacks.cxx @@ -1,6 +1,7 @@ #include #include #include +#include "zypp/target/rpm/RpmDb.h" #include "callbacks.h" @@ -118,6 +119,158 @@ struct DownloadProgressReceive : public zypp::callback::ReceiveReport< static DownloadProgressReceive download_progress_receive; +struct DownloadResolvableReport : public zypp::callback::ReceiveReport< + zypp::repo::DownloadResolvableReport> { + struct DownloadResolvableCallbacks *callbacks; + + DownloadResolvableReport() { callbacks = NULL; } + + void set_callbacks(DownloadResolvableCallbacks *callbacks_) { + callbacks = callbacks_; + } + + virtual Action problem(zypp::Resolvable::constPtr resolvable_ptr, Error error, + const std::string &description) { + if (callbacks != NULL && callbacks->problem != NULL) { + enum DownloadResolvableError error_enum; + switch (error) { + case zypp::repo::DownloadResolvableReport::NO_ERROR: + error_enum = DownloadResolvableError::DRE_NO_ERROR; + break; + case zypp::repo::DownloadResolvableReport::NOT_FOUND: + error_enum = DownloadResolvableError::DRE_NOT_FOUND; + break; + case zypp::repo::DownloadResolvableReport::IO: + error_enum = DownloadResolvableError::DRE_IO; + break; + case zypp::repo::DownloadResolvableReport::INVALID: + error_enum = DownloadResolvableError::DRE_INVALID; + break; + } + PROBLEM_RESPONSE response = + callbacks->problem(resolvable_ptr->name().c_str(), error_enum, + description.c_str(), callbacks->problem_data); + + switch (response) { + case PROBLEM_RETRY: + return zypp::repo::DownloadResolvableReport::RETRY; + case PROBLEM_ABORT: + return zypp::repo::DownloadResolvableReport::ABORT; + case PROBLEM_IGNORE: + return zypp::repo::DownloadResolvableReport::IGNORE; + } + } + // otherwise return the default value from the parent class + return zypp::repo::DownloadResolvableReport::problem(resolvable_ptr, error, + description); + } + + virtual void pkgGpgCheck(const UserData & userData_r = UserData() ) + { + if (callbacks == NULL || callbacks->gpg_check == NULL) { + return; + } + zypp::ResObject::constPtr resobject = userData_r.get("ResObject"); + const zypp::RepoInfo repo = resobject->repoInfo(); + const std::string repo_url = repo.rawUrl().asString(); + typedef zypp::target::rpm::RpmDb RpmDb; + enum GPGCheckPackageResult result; + switch (userData_r.get("CheckPackageResult")){ + case RpmDb::CHK_OK: + result = GPGCheckPackageResult::CHK_OK; + break; + case RpmDb::CHK_NOTFOUND: + result = GPGCheckPackageResult::CHK_NOTFOUND; + break; + case RpmDb::CHK_FAIL: + result = GPGCheckPackageResult::CHK_FAIL; + break; + case RpmDb::CHK_NOTTRUSTED: + result = GPGCheckPackageResult::CHK_NOTTRUSTED; + break; + case RpmDb::CHK_NOKEY: + result = GPGCheckPackageResult::CHK_NOKEY; + break; + case RpmDb::CHK_ERROR: + result = GPGCheckPackageResult::CHK_ERROR; + break; + case RpmDb::CHK_NOSIG: + result = GPGCheckPackageResult::CHK_NOSIG; + break; + }; + PROBLEM_RESPONSE response = callbacks->gpg_check(resobject->name().c_str(), repo_url.c_str(), result, callbacks->gpg_check_data); + DownloadResolvableReport::Action zypp_action; + switch (response) { + case PROBLEM_RETRY: + zypp_action = zypp::repo::DownloadResolvableReport::RETRY; + break; + case PROBLEM_ABORT: + zypp_action = zypp::repo::DownloadResolvableReport::ABORT; + break; + case PROBLEM_IGNORE: + zypp_action = zypp::repo::DownloadResolvableReport::IGNORE; + break; + }; + userData_r.set("Action", zypp_action); + } +}; + +static DownloadResolvableReport download_resolvable_receive; + +struct CommitPreloadReport + : public zypp::callback::ReceiveReport { + + struct DownloadResolvableCallbacks *callbacks; + + CommitPreloadReport() { callbacks = NULL; } + + void set_callbacks(DownloadResolvableCallbacks *callbacks_) { + callbacks = callbacks_; + } + virtual void start(const UserData &userData = UserData()) { + if (callbacks != NULL && callbacks->start_preload != NULL) { + callbacks->start_preload(callbacks->start_preload_data); + } + } + + virtual void fileDone(const zypp::Pathname &localfile, Error error, + const UserData &userData = UserData()) { + if (callbacks != NULL && callbacks->file_finish != NULL) { + const char *url = ""; + if (userData.hasvalue("url")) { + url = userData.get("url").asString().c_str(); + } + const char *local_path = localfile.c_str(); + const char *error_details = ""; + if (userData.hasvalue("description")) { + error_details = userData.get("description").c_str(); + } + enum DownloadResolvableFileError error_enum; + switch (error) { + case zypp::media::CommitPreloadReport::NO_ERROR: + error_enum = DownloadResolvableFileError::DRFE_NO_ERROR; + break; + case zypp::media::CommitPreloadReport::NOT_FOUND: + error_enum = DownloadResolvableFileError::DRFE_NOT_FOUND; + break; + case zypp::media::CommitPreloadReport::IO: + error_enum = DownloadResolvableFileError::DRFE_IO; + break; + case zypp::media::CommitPreloadReport::ACCESS_DENIED: + error_enum = DownloadResolvableFileError::DRFE_ACCESS_DENIED; + break; + case zypp::media::CommitPreloadReport::ERROR: + error_enum = DownloadResolvableFileError::DRFE_ERROR; + break; + } + callbacks->file_finish(url, local_path, error_enum, error_details, + callbacks->file_finish_data); + } + } +}; + +static CommitPreloadReport commit_preload_report; + extern "C" { void set_zypp_progress_callback(ZyppProgressCallback progress, void *user_data) { @@ -137,6 +290,24 @@ void unset_zypp_download_callbacks() { download_progress_receive.disconnect(); } +// Sets both reports as we consolidate download resolvables +// and commitPreload into one set for easier hooking +void set_zypp_resolvable_download_callbacks( + struct DownloadResolvableCallbacks *callbacks) { + download_resolvable_receive.set_callbacks(callbacks); + download_resolvable_receive.connect(); + commit_preload_report.set_callbacks(callbacks); + commit_preload_report.connect(); +} + +void unset_zypp_resolvable_download_callbacks() { + // NULL pointer to struct to be sure it is not called + download_resolvable_receive.set_callbacks(NULL); + download_resolvable_receive.disconnect(); + commit_preload_report.set_callbacks(NULL); + commit_preload_report.disconnect(); +} + #ifdef __cplusplus bool dynamic_progress_callback(ZyppProgressCallback progress, void *user_data, const zypp::ProgressData &task) { diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h index 0413033e9a..b6f7d0bab7 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/callbacks.h @@ -53,6 +53,67 @@ struct DownloadProgressCallbacks { ZyppDownloadFinishCallback finish; void *finish_data; }; + +enum DownloadResolvableError { + DRE_NO_ERROR, + DRE_NOT_FOUND, // the requested Url was not found + DRE_IO, // IO error + DRE_INVALID // the downloaded file is invalid +}; + +enum DownloadResolvableFileError { + DRFE_NO_ERROR, + DRFE_NOT_FOUND, // the requested Url was not found + DRFE_IO, // IO error + DRFE_ACCESS_DENIED, // user authent. failed while accessing restricted file + DRFE_ERROR // other error +}; + +// keep in sync with https://github.com/openSUSE/libzypp/blob/master/zypp-logic/zypp/target/rpm/RpmDb.h#L376 +// maybe there is a better way to export it to C? +enum GPGCheckPackageResult + { + CHK_OK = 0, /*!< Signature is OK. */ + CHK_NOTFOUND = 1, /*!< Signature is unknown type. */ + CHK_FAIL = 2, /*!< Signature does not verify. */ + CHK_NOTTRUSTED = 3, /*!< Signature is OK, but key is not trusted. */ + CHK_NOKEY = 4, /*!< Public key is unavailable. */ + CHK_ERROR = 5, /*!< File does not exist or can't be opened. */ + CHK_NOSIG = 6, /*!< File has no gpg signature (only digests). */ + }; + +typedef void (*ZyppDownloadResolvableStartCallback)(void *user_data); +// TODO: do we need more resolvable details? for now just use name and url +typedef enum PROBLEM_RESPONSE (*ZyppDownloadResolvableProblemCallback)( + const char *resolvable_name, enum DownloadResolvableError error, + const char *description, void *user_data); +typedef enum PROBLEM_RESPONSE (*ZyppDownloadResolvableGpgCheckCallback)( + const char *resolvable_name, const char *repo_url, + enum GPGCheckPackageResult check_result, + void *user_data); +typedef void (*ZyppDownloadResolvableFileFinishCallback)( + const char *url, const char *local_path, + enum DownloadResolvableFileError error, const char *error_details, + void *user_data); + +// progress for downloading resolvables (rpms). There are 3 callbacks now ( can +// be extended with progress and finish one): +// 1. start for start of preload +// 2. problem to react when something wrong happen and how to behave +// 3. gpg_check when there is issue with gpg check on resolvable +// 4. finish_file is when preload finish download of package including failed +// NOTE: user_data is separated for each call. +// NOTE: libzypp provides more data, but only those used by agama is used now. +struct DownloadResolvableCallbacks { + ZyppDownloadResolvableStartCallback start_preload; + void *start_preload_data; + ZyppDownloadResolvableProblemCallback problem; + void *problem_data; + ZyppDownloadResolvableGpgCheckCallback gpg_check; + void *gpg_check_data; + ZyppDownloadResolvableFileFinishCallback file_finish; + void *file_finish_data; +}; #ifdef __cplusplus } #endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h index d36dc5c85e..c0b96be087 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/include/lib.h @@ -61,11 +61,12 @@ void switch_target(struct Zypp *zypp, const char *root, struct Status *status) noexcept; /// Commit zypp settings and install -/// TODO: callbacks +/// TODO: install callbacks /// @param zypp /// @param status +/// @param download_callbacks /// @return true if there is no error -bool commit(struct Zypp *zypp, struct Status *status) noexcept; +bool commit(struct Zypp *zypp, struct Status *status, struct DownloadResolvableCallbacks *download_callbacks) noexcept; /// Represents a single mount point and its space usage. /// The string pointers are not owned by this struct. diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx index 890dda262b..488e1d7335 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/internal/callbacks.hxx @@ -13,4 +13,11 @@ create_progress_callback(ZyppProgressCallback progress, void *user_data); void set_zypp_download_callbacks(struct DownloadProgressCallbacks *callbacks); void unset_zypp_download_callbacks(); +// pair of set/unset callbacks used during commit when download packages. +// Uses mixture of ResolvableDownloadReport and also CommitPreloadReport +// to capture related parts of commit download reports. +void set_zypp_resolvable_download_callbacks( + struct DownloadResolvableCallbacks *callbacks); +void unset_zypp_resolvable_download_callbacks(); + #endif diff --git a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx index 4a36578c97..769bc22c48 100644 --- a/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx +++ b/rust/zypp-agama/zypp-agama-sys/c-layer/lib.cxx @@ -155,14 +155,17 @@ void switch_target(struct Zypp *zypp, const char *root, STATUS_OK(status); } -bool commit(struct Zypp *zypp, struct Status *status) noexcept { +bool commit(struct Zypp *zypp, struct Status *status, struct DownloadResolvableCallbacks *download_callbacks) noexcept { try { + set_zypp_resolvable_download_callbacks(download_callbacks); zypp::ZYppCommitPolicy policy; zypp::ZYppCommitResult result = zypp->zypp_pointer->commit(policy); STATUS_OK(status); + unset_zypp_resolvable_download_callbacks(); return result.noError(); } catch (zypp::Exception &excpt) { STATUS_EXCEPT(status, excpt); + unset_zypp_resolvable_download_callbacks(); return false; } } @@ -666,5 +669,4 @@ void get_space_usage(struct Zypp *zypp, struct Status *status, STATUS_EXCEPT(status, excpt); } } - -} \ No newline at end of file +} From 3689f020f04862d4f651535d2ad319b8d20d05e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Alejandro=20Anderssen=20Gonz=C3=A1lez?= Date: Tue, 11 Nov 2025 09:24:59 +0000 Subject: [PATCH 374/917] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Imobach González Sosa --- rust/agama-lib/share/profile.schema.json | 4 ++-- rust/agama-network/src/action.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index a40f9da479..8c5df5597e 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -337,11 +337,11 @@ "additionalProperties": false, "properties": { "state": { - "title": "Network general state settings", + "title": "Network general settings", "type": "object", "properties": { "connectivity": { - "title": "Determines whether the user is able to access the Internet", + "title": "Whether the user is able to access the Internet", "type": "boolean", "readOnly": true }, diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index 791bcb3622..b465528f8b 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -47,7 +47,7 @@ pub enum Action { GetConfig(Responder), /// Gets the internal state of the network configuration proposal GetProposal(Responder), - /// Updates th internal state of the network configuration + /// Updates the internal state of the network configuration UpdateConfig(Box, Responder>), /// Gets the current network configuration containing connections, devices, access_points and /// also the general state From 6a4d1dc01c62ac77c4fa2d3805743a741d65a255 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 11 Nov 2025 14:00:43 +0100 Subject: [PATCH 375/917] initial rust wrapper for commit download callbacks --- rust/Cargo.lock | 2 + rust/agama-software/src/zypp_server.rs | 5 +- rust/zypp-agama/Cargo.toml | 1 + rust/zypp-agama/src/callbacks.rs | 236 ++++++++++++++++-- rust/zypp-agama/src/lib.rs | 9 +- rust/zypp-agama/zypp-agama-sys/Cargo.toml | 3 + .../zypp-agama/zypp-agama-sys/src/bindings.rs | 96 ++++++- 7 files changed, 328 insertions(+), 24 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 53011ea3ab..4b7d56bb60 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -5720,6 +5720,7 @@ dependencies = [ name = "zypp-agama" version = "0.1.0" dependencies = [ + "tracing", "url", "zypp-agama-sys", ] @@ -5729,4 +5730,5 @@ name = "zypp-agama-sys" version = "0.1.0" dependencies = [ "bindgen 0.72.1", + "tracing", ] diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 63f7030828..c090087429 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -32,7 +32,7 @@ use tokio::sync::{ mpsc::{self, UnboundedSender}, oneshot, }; -use zypp_agama::ZyppError; +use zypp_agama::{ZyppError, callbacks::EmptyPkgDownloadCallbacks}; use crate::model::state::{self, SoftwareState}; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; @@ -190,7 +190,8 @@ impl ZyppServer { fn install(&self, zypp: &zypp_agama::Zypp) -> ZyppServerResult { let target = "/mnt"; zypp.switch_target(target)?; - let result = zypp.commit()?; + // TODO: write real callbacks + let result = zypp.commit(&EmptyPkgDownloadCallbacks)?; tracing::info!("libzypp commit ends with {}", result); Ok(result) } diff --git a/rust/zypp-agama/Cargo.toml b/rust/zypp-agama/Cargo.toml index a3df587f59..3a0d9d51ec 100644 --- a/rust/zypp-agama/Cargo.toml +++ b/rust/zypp-agama/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] zypp-agama-sys = { path="./zypp-agama-sys" } url = "2.5.7" +tracing = "0.1.41" diff --git a/rust/zypp-agama/src/callbacks.rs b/rust/zypp-agama/src/callbacks.rs index 9c611ce525..03c8277309 100644 --- a/rust/zypp-agama/src/callbacks.rs +++ b/rust/zypp-agama/src/callbacks.rs @@ -1,12 +1,5 @@ use std::os::raw::{c_char, c_int, c_void}; -use zypp_agama_sys::{ - DownloadProgressCallbacks, ZyppDownloadFinishCallback, ZyppDownloadProblemCallback, - ZyppDownloadProgressCallback, ZyppDownloadStartCallback, PROBLEM_RESPONSE, - PROBLEM_RESPONSE_PROBLEM_ABORT, PROBLEM_RESPONSE_PROBLEM_IGNORE, - PROBLEM_RESPONSE_PROBLEM_RETRY, -}; - use crate::helpers::string_from_ptr; // empty progress callback @@ -20,12 +13,12 @@ pub enum ProblemResponse { IGNORE, } -impl From for PROBLEM_RESPONSE { +impl From for zypp_agama_sys::PROBLEM_RESPONSE { fn from(response: ProblemResponse) -> Self { match response { - ProblemResponse::ABORT => PROBLEM_RESPONSE_PROBLEM_ABORT, - ProblemResponse::IGNORE => PROBLEM_RESPONSE_PROBLEM_IGNORE, - ProblemResponse::RETRY => PROBLEM_RESPONSE_PROBLEM_RETRY, + ProblemResponse::ABORT => zypp_agama_sys::PROBLEM_RESPONSE_PROBLEM_ABORT, + ProblemResponse::IGNORE => zypp_agama_sys::PROBLEM_RESPONSE_PROBLEM_IGNORE, + ProblemResponse::RETRY => zypp_agama_sys::PROBLEM_RESPONSE_PROBLEM_RETRY, } } } @@ -61,7 +54,7 @@ unsafe extern "C" fn download_progress_start( user_data(string_from_ptr(url), string_from_ptr(localfile)); } -fn get_download_progress_start(_closure: &F) -> ZyppDownloadStartCallback +fn get_download_progress_start(_closure: &F) -> zypp_agama_sys::ZyppDownloadStartCallback where F: FnMut(String, String), { @@ -82,7 +75,7 @@ where user_data(value, string_from_ptr(url), bps_avg, bps_current) } -fn get_download_progress_progress(_closure: &F) -> ZyppDownloadProgressCallback +fn get_download_progress_progress(_closure: &F) -> zypp_agama_sys::ZyppDownloadProgressCallback where F: FnMut(i32, String, f64, f64) -> bool, { @@ -94,7 +87,7 @@ unsafe extern "C" fn download_progress_problem( error: c_int, description: *const c_char, user_data: *mut c_void, -) -> PROBLEM_RESPONSE +) -> zypp_agama_sys::PROBLEM_RESPONSE where F: FnMut(String, c_int, String) -> ProblemResponse, { @@ -103,7 +96,7 @@ where res.into() } -fn get_download_progress_problem(_closure: &F) -> ZyppDownloadProblemCallback +fn get_download_progress_problem(_closure: &F) -> zypp_agama_sys::ZyppDownloadProblemCallback where F: FnMut(String, c_int, String) -> ProblemResponse, { @@ -122,7 +115,7 @@ unsafe extern "C" fn download_progress_finish( user_data(string_from_ptr(url), error, string_from_ptr(reason)); } -fn get_download_progress_finish(_closure: &F) -> ZyppDownloadFinishCallback +fn get_download_progress_finish(_closure: &F) -> zypp_agama_sys::ZyppDownloadFinishCallback where F: FnMut(String, c_int, String), { @@ -131,7 +124,7 @@ where pub(crate) fn with_c_download_callbacks(callbacks: &impl DownloadProgress, block: &mut F) -> R where - F: FnMut(DownloadProgressCallbacks) -> R, + F: FnMut(zypp_agama_sys::DownloadProgressCallbacks) -> R, { let mut start_call = |url: String, localfile: String| callbacks.start(&url, &localfile); let cb_start = get_download_progress_start(&start_call); @@ -146,7 +139,7 @@ where |url: String, error, description: String| callbacks.finish(&url, error, &description); let cb_finish = get_download_progress_finish(&finish_call); - let callbacks = DownloadProgressCallbacks { + let callbacks = zypp_agama_sys::DownloadProgressCallbacks { start: cb_start, start_data: &mut start_call as *mut _ as *mut c_void, progress: cb_progress, @@ -158,3 +151,210 @@ where }; block(callbacks) } + +pub enum DownloadResolvableError { + NoError, + NotFound, // the requested Url was not found + IO, // IO error + Invalid, // the downloaded file is invalid +} + +impl From for DownloadResolvableError { + fn from(error: zypp_agama_sys::DownloadResolvableError) -> Self { + match error { + zypp_agama_sys::DownloadResolvableError_DRE_NO_ERROR => DownloadResolvableError::NoError, + zypp_agama_sys::DownloadResolvableError_DRE_NOT_FOUND => DownloadResolvableError::NotFound, + zypp_agama_sys::DownloadResolvableError_DRE_IO => DownloadResolvableError::IO, + zypp_agama_sys::DownloadResolvableError_DRE_INVALID => DownloadResolvableError::Invalid, + _ => { + tracing::error!("Unknown error code {:?}", error); + DownloadResolvableError::Invalid + } + } + } +} + +pub enum GPGCheckPackageResult { + Ok, // Signature is OK. + NotFound, // Signature is unknown type. + Fail, // Signature does not verify. + NotTrusted, // Signature is OK, but key is not trusted. + NoKey, // Public key is unavailable. + Error, // File does not exist or can't be opened. + NoSig, // File has no gpg signature (only digests). +} + +impl From for GPGCheckPackageResult { + fn from(value: zypp_agama_sys::GPGCheckPackageResult) -> Self { + match value { + zypp_agama_sys::GPGCheckPackageResult_CHK_OK => GPGCheckPackageResult::Ok, + zypp_agama_sys::GPGCheckPackageResult_CHK_NOTFOUND => GPGCheckPackageResult::NotFound, + zypp_agama_sys::GPGCheckPackageResult_CHK_FAIL => GPGCheckPackageResult::Fail, + zypp_agama_sys::GPGCheckPackageResult_CHK_NOTTRUSTED => GPGCheckPackageResult::NotTrusted, + zypp_agama_sys::GPGCheckPackageResult_CHK_NOKEY => GPGCheckPackageResult::NoKey, + zypp_agama_sys::GPGCheckPackageResult_CHK_ERROR => GPGCheckPackageResult::Error, + zypp_agama_sys::GPGCheckPackageResult_CHK_NOSIG => GPGCheckPackageResult::NoSig, + _ => { + tracing::error!("Unknown error code {:?}", value); + GPGCheckPackageResult::Error + } + } + } +} + +pub enum DownloadResolvableFileError { + NoError, + NotFound, // the requested Url was not found + IO, // IO error + AccessDenied, // user authent. failed while accessing restricted file + Error, // other error +} + +impl From for DownloadResolvableFileError { + fn from(error: zypp_agama_sys::DownloadResolvableFileError) -> Self { + match error { + zypp_agama_sys::DownloadResolvableFileError_DRFE_NO_ERROR => DownloadResolvableFileError::NoError, + zypp_agama_sys::DownloadResolvableFileError_DRFE_NOT_FOUND => DownloadResolvableFileError::NotFound, + zypp_agama_sys::DownloadResolvableFileError_DRFE_IO => DownloadResolvableFileError::IO, + zypp_agama_sys::DownloadResolvableFileError_DRFE_ACCESS_DENIED => DownloadResolvableFileError::AccessDenied, + zypp_agama_sys::DownloadResolvableFileError_DRFE_ERROR => DownloadResolvableFileError::Error, + _ => { + tracing::error!("Unknown error code {:?}", error); + DownloadResolvableFileError::Error + }, + } + } +} + +// generic trait to +pub trait PkgDownloadCallbacks { + // callback when start preloading packages during commit phase + fn start_preload(&self) {} + // callback when problem occurs during download of resolvable + fn problem(&self, _name: &str, _error: DownloadResolvableError, _description: &str) -> ProblemResponse { + ProblemResponse::ABORT + } + // callback after gpg check is done + fn gpg_check(&self, _resolvable_name: &str, _repo_url: &str, _check_result: GPGCheckPackageResult) -> ProblemResponse { + ProblemResponse::ABORT + } + // callback when download finishes either successfully or with error + fn finish(&self, _url: &str, _local_path: &str, _error: DownloadResolvableFileError, _error_details: &str) {} +} + +// Default progress that do nothing +pub struct EmptyPkgDownloadCallbacks; +impl PkgDownloadCallbacks for EmptyPkgDownloadCallbacks {} + +unsafe extern "C" fn pkg_download_progress_start_preload( + user_data: *mut c_void, +) where + F: FnMut(), +{ + let user_data = &mut *(user_data as *mut F); + user_data(); +} + +fn get_pkg_download_progress_start_preload(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableStartCallback +where + F: FnMut(), +{ + Some(pkg_download_progress_start_preload::) +} + +unsafe extern "C" fn pkg_download_progress_problem( + resolvable_name: *const c_char, + error: zypp_agama_sys::DownloadResolvableError, + description: *const c_char, + user_data: *mut c_void, +) -> zypp_agama_sys::PROBLEM_RESPONSE where + F: FnMut(String, DownloadResolvableError, String) -> ProblemResponse, +{ + let user_data = &mut *(user_data as *mut F); + let res = user_data( + string_from_ptr(resolvable_name), + error.into(), + string_from_ptr(description), + ); + res.into() +} + +fn get_pkg_download_problem(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableProblemCallback +where + F: FnMut(String, DownloadResolvableError, String) -> ProblemResponse, +{ + Some(pkg_download_progress_problem::) +} + +unsafe extern "C" fn pkg_download_gpg_check( + resolvable_name: *const c_char, + repo_url: *const c_char, + check_result: zypp_agama_sys::GPGCheckPackageResult, + user_data: *mut c_void, +) -> zypp_agama_sys::PROBLEM_RESPONSE where + F: FnMut(String, String, GPGCheckPackageResult) -> ProblemResponse, +{ + let user_data = &mut *(user_data as *mut F); + let res = user_data( + string_from_ptr(resolvable_name), + string_from_ptr(repo_url), + check_result.into(), + ); + res.into() +} + +fn get_pkg_download_gpg_check(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableGpgCheckCallback +where + F: FnMut(String, String, GPGCheckPackageResult) -> ProblemResponse, +{ + Some(pkg_download_gpg_check::) +} + +unsafe extern "C" fn pkg_download_file_finish( + url: *const c_char, + local_path: *const c_char, + error: zypp_agama_sys::DownloadResolvableFileError, + details: *const c_char, + user_data: *mut c_void, +) where + F: FnMut(String, String, DownloadResolvableFileError, String), +{ + let user_data = &mut *(user_data as *mut F); + user_data(string_from_ptr(url), string_from_ptr(local_path), error.into(), string_from_ptr(details)); +} + +fn get_pkg_download_file_finish(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableFileFinishCallback +where + F: FnMut(String, String, DownloadResolvableFileError, String), +{ + Some(pkg_download_file_finish::) +} + +pub(crate) fn with_c_commit_download_callbacks(callbacks: &impl PkgDownloadCallbacks, block: &mut F) -> R +where + F: FnMut(zypp_agama_sys::DownloadResolvableCallbacks) -> R, +{ + let mut start_call = || callbacks.start_preload(); + let cb_start = get_pkg_download_progress_start_preload(&start_call); + let mut problem_call = + |name: String, error, description: String| callbacks.problem(&name, error, &description); + let cb_problem = get_pkg_download_problem(&problem_call); + let mut gpg_check = + |name: String, url: String, check_result: GPGCheckPackageResult| callbacks.gpg_check(&name, &url, check_result); + let cb_gpg_check = get_pkg_download_gpg_check(&gpg_check); + let mut finish_call = + |url: String, local_path: String, error, details: String| callbacks.finish(&url, &local_path, error, &details); + let cb_finish = get_pkg_download_file_finish(&finish_call); + + let callbacks = zypp_agama_sys::DownloadResolvableCallbacks { + start_preload: cb_start, + start_preload_data: &mut start_call as *mut _ as *mut c_void, + problem: cb_problem, + problem_data: &mut problem_call as *mut _ as *mut c_void, + gpg_check: cb_gpg_check, + gpg_check_data: &mut gpg_check as *mut _ as *mut c_void, + file_finish: cb_finish, + file_finish_data: &mut finish_call as *mut _ as *mut c_void, + }; + block(callbacks) +} \ No newline at end of file diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index dba7f8eebd..94c986285e 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -16,6 +16,8 @@ pub use errors::ZyppError; mod helpers; use helpers::{status_to_result, status_to_result_void, string_from_ptr}; +use crate::callbacks::PkgDownloadCallbacks; + pub mod callbacks; #[derive(Debug)] @@ -153,11 +155,14 @@ impl Zypp { } } - pub fn commit(&self) -> ZyppResult { + pub fn commit(&self, report: &impl PkgDownloadCallbacks) -> ZyppResult { let mut status: Status = Status::default(); let status_ptr = &mut status as *mut _; unsafe { - let res = zypp_agama_sys::commit(self.ptr, status_ptr); + let mut commit_fn = |mut callbacks| { + zypp_agama_sys::commit(self.ptr, status_ptr, &mut callbacks) + }; + let res = callbacks::with_c_commit_download_callbacks(report, &mut commit_fn); helpers::status_to_result(status, res) } } diff --git a/rust/zypp-agama/zypp-agama-sys/Cargo.toml b/rust/zypp-agama/zypp-agama-sys/Cargo.toml index 5600e0469e..1f485acc8c 100644 --- a/rust/zypp-agama/zypp-agama-sys/Cargo.toml +++ b/rust/zypp-agama/zypp-agama-sys/Cargo.toml @@ -3,5 +3,8 @@ name = "zypp-agama-sys" version = "0.1.0" edition.workspace = true +[dependencies] +tracing = "0.1.41" + [build-dependencies] bindgen = { version= "0.72.1", features = ["runtime"] } diff --git a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs index b80b0faa5d..38982d71cd 100644 --- a/rust/zypp-agama/zypp-agama-sys/src/bindings.rs +++ b/rust/zypp-agama/zypp-agama-sys/src/bindings.rs @@ -91,6 +91,94 @@ const _: () = { ["Offset of field: DownloadProgressCallbacks::finish_data"] [::std::mem::offset_of!(DownloadProgressCallbacks, finish_data) - 56usize]; }; +pub const DownloadResolvableError_DRE_NO_ERROR: DownloadResolvableError = 0; +pub const DownloadResolvableError_DRE_NOT_FOUND: DownloadResolvableError = 1; +pub const DownloadResolvableError_DRE_IO: DownloadResolvableError = 2; +pub const DownloadResolvableError_DRE_INVALID: DownloadResolvableError = 3; +pub type DownloadResolvableError = ::std::os::raw::c_uint; +pub const DownloadResolvableFileError_DRFE_NO_ERROR: DownloadResolvableFileError = 0; +pub const DownloadResolvableFileError_DRFE_NOT_FOUND: DownloadResolvableFileError = 1; +pub const DownloadResolvableFileError_DRFE_IO: DownloadResolvableFileError = 2; +pub const DownloadResolvableFileError_DRFE_ACCESS_DENIED: DownloadResolvableFileError = 3; +pub const DownloadResolvableFileError_DRFE_ERROR: DownloadResolvableFileError = 4; +pub type DownloadResolvableFileError = ::std::os::raw::c_uint; +#[doc = "< Signature is OK."] +pub const GPGCheckPackageResult_CHK_OK: GPGCheckPackageResult = 0; +#[doc = "< Signature is unknown type."] +pub const GPGCheckPackageResult_CHK_NOTFOUND: GPGCheckPackageResult = 1; +#[doc = "< Signature does not verify."] +pub const GPGCheckPackageResult_CHK_FAIL: GPGCheckPackageResult = 2; +#[doc = "< Signature is OK, but key is not trusted."] +pub const GPGCheckPackageResult_CHK_NOTTRUSTED: GPGCheckPackageResult = 3; +#[doc = "< Public key is unavailable."] +pub const GPGCheckPackageResult_CHK_NOKEY: GPGCheckPackageResult = 4; +#[doc = "< File does not exist or can't be opened."] +pub const GPGCheckPackageResult_CHK_ERROR: GPGCheckPackageResult = 5; +#[doc = "< File has no gpg signature (only digests)."] +pub const GPGCheckPackageResult_CHK_NOSIG: GPGCheckPackageResult = 6; +pub type GPGCheckPackageResult = ::std::os::raw::c_uint; +pub type ZyppDownloadResolvableStartCallback = + ::std::option::Option; +pub type ZyppDownloadResolvableProblemCallback = ::std::option::Option< + unsafe extern "C" fn( + resolvable_name: *const ::std::os::raw::c_char, + error: DownloadResolvableError, + description: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ) -> PROBLEM_RESPONSE, +>; +pub type ZyppDownloadResolvableGpgCheckCallback = ::std::option::Option< + unsafe extern "C" fn( + resolvable_name: *const ::std::os::raw::c_char, + repo_url: *const ::std::os::raw::c_char, + check_result: GPGCheckPackageResult, + user_data: *mut ::std::os::raw::c_void, + ) -> PROBLEM_RESPONSE, +>; +pub type ZyppDownloadResolvableFileFinishCallback = ::std::option::Option< + unsafe extern "C" fn( + url: *const ::std::os::raw::c_char, + local_path: *const ::std::os::raw::c_char, + error: DownloadResolvableFileError, + error_details: *const ::std::os::raw::c_char, + user_data: *mut ::std::os::raw::c_void, + ), +>; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct DownloadResolvableCallbacks { + pub start_preload: ZyppDownloadResolvableStartCallback, + pub start_preload_data: *mut ::std::os::raw::c_void, + pub problem: ZyppDownloadResolvableProblemCallback, + pub problem_data: *mut ::std::os::raw::c_void, + pub gpg_check: ZyppDownloadResolvableGpgCheckCallback, + pub gpg_check_data: *mut ::std::os::raw::c_void, + pub file_finish: ZyppDownloadResolvableFileFinishCallback, + pub file_finish_data: *mut ::std::os::raw::c_void, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of DownloadResolvableCallbacks"] + [::std::mem::size_of::() - 64usize]; + ["Alignment of DownloadResolvableCallbacks"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: DownloadResolvableCallbacks::start_preload"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, start_preload) - 0usize]; + ["Offset of field: DownloadResolvableCallbacks::start_preload_data"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, start_preload_data) - 8usize]; + ["Offset of field: DownloadResolvableCallbacks::problem"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, problem) - 16usize]; + ["Offset of field: DownloadResolvableCallbacks::problem_data"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, problem_data) - 24usize]; + ["Offset of field: DownloadResolvableCallbacks::gpg_check"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, gpg_check) - 32usize]; + ["Offset of field: DownloadResolvableCallbacks::gpg_check_data"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, gpg_check_data) - 40usize]; + ["Offset of field: DownloadResolvableCallbacks::file_finish"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, file_finish) - 48usize]; + ["Offset of field: DownloadResolvableCallbacks::file_finish_data"] + [::std::mem::offset_of!(DownloadResolvableCallbacks, file_finish_data) - 56usize]; +}; #[doc = " status struct to pass and obtain from calls that can fail.\n After usage free with \\ref free_status function.\n\n Most functions act as *constructors* for this, taking a pointer\n to it as an output parameter, disregarding the struct current contents\n and filling it in. Thus, if you reuse a `Status` without \\ref free_status\n in between, `error` will leak."] #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -280,8 +368,12 @@ unsafe extern "C" { ) -> *mut Zypp; #[doc = " Switch Zypp target (where to install packages to).\n @param root\n @param[out] status"] pub fn switch_target(zypp: *mut Zypp, root: *const ::std::os::raw::c_char, status: *mut Status); - #[doc = " Commit zypp settings and install\n TODO: callbacks\n @param zypp\n @param status\n @return true if there is no error"] - pub fn commit(zypp: *mut Zypp, status: *mut Status) -> bool; + #[doc = " Commit zypp settings and install\n TODO: install callbacks\n @param zypp\n @param status\n @param download_callbacks\n @return true if there is no error"] + pub fn commit( + zypp: *mut Zypp, + status: *mut Status, + download_callbacks: *mut DownloadResolvableCallbacks, + ) -> bool; #[doc = " Calculates the space usage for a given list of mount points.\n This function populates the `used_size` field for each element in the\n provided `mount_points` array.\n\n @param zypp The Zypp context.\n @param[out] status Output status object.\n @param[in,out] mount_points An array of mount points to be evaluated.\n @param mount_points_size The number of elements in the `mount_points` array."] pub fn get_space_usage( zypp: *mut Zypp, From b20b92896062e1c3667cd4cd56ef29e874f2960e Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Tue, 11 Nov 2025 14:02:41 +0100 Subject: [PATCH 376/917] cargo fmt --- rust/agama-software/src/zypp_server.rs | 2 +- rust/zypp-agama/src/callbacks.rs | 126 +++++++++++++++++-------- rust/zypp-agama/src/lib.rs | 5 +- 3 files changed, 91 insertions(+), 42 deletions(-) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index c090087429..d66ae6ad8c 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -32,7 +32,7 @@ use tokio::sync::{ mpsc::{self, UnboundedSender}, oneshot, }; -use zypp_agama::{ZyppError, callbacks::EmptyPkgDownloadCallbacks}; +use zypp_agama::{callbacks::EmptyPkgDownloadCallbacks, ZyppError}; use crate::model::state::{self, SoftwareState}; const TARGET_DIR: &str = "/run/agama/software_ng_zypp"; diff --git a/rust/zypp-agama/src/callbacks.rs b/rust/zypp-agama/src/callbacks.rs index 03c8277309..c8ee8d7971 100644 --- a/rust/zypp-agama/src/callbacks.rs +++ b/rust/zypp-agama/src/callbacks.rs @@ -155,15 +155,19 @@ where pub enum DownloadResolvableError { NoError, NotFound, // the requested Url was not found - IO, // IO error - Invalid, // the downloaded file is invalid + IO, // IO error + Invalid, // the downloaded file is invalid } impl From for DownloadResolvableError { fn from(error: zypp_agama_sys::DownloadResolvableError) -> Self { match error { - zypp_agama_sys::DownloadResolvableError_DRE_NO_ERROR => DownloadResolvableError::NoError, - zypp_agama_sys::DownloadResolvableError_DRE_NOT_FOUND => DownloadResolvableError::NotFound, + zypp_agama_sys::DownloadResolvableError_DRE_NO_ERROR => { + DownloadResolvableError::NoError + } + zypp_agama_sys::DownloadResolvableError_DRE_NOT_FOUND => { + DownloadResolvableError::NotFound + } zypp_agama_sys::DownloadResolvableError_DRE_IO => DownloadResolvableError::IO, zypp_agama_sys::DownloadResolvableError_DRE_INVALID => DownloadResolvableError::Invalid, _ => { @@ -175,13 +179,13 @@ impl From for DownloadResolvableError { } pub enum GPGCheckPackageResult { - Ok, // Signature is OK. - NotFound, // Signature is unknown type. - Fail, // Signature does not verify. + Ok, // Signature is OK. + NotFound, // Signature is unknown type. + Fail, // Signature does not verify. NotTrusted, // Signature is OK, but key is not trusted. - NoKey, // Public key is unavailable. - Error, // File does not exist or can't be opened. - NoSig, // File has no gpg signature (only digests). + NoKey, // Public key is unavailable. + Error, // File does not exist or can't be opened. + NoSig, // File has no gpg signature (only digests). } impl From for GPGCheckPackageResult { @@ -190,7 +194,9 @@ impl From for GPGCheckPackageResult { zypp_agama_sys::GPGCheckPackageResult_CHK_OK => GPGCheckPackageResult::Ok, zypp_agama_sys::GPGCheckPackageResult_CHK_NOTFOUND => GPGCheckPackageResult::NotFound, zypp_agama_sys::GPGCheckPackageResult_CHK_FAIL => GPGCheckPackageResult::Fail, - zypp_agama_sys::GPGCheckPackageResult_CHK_NOTTRUSTED => GPGCheckPackageResult::NotTrusted, + zypp_agama_sys::GPGCheckPackageResult_CHK_NOTTRUSTED => { + GPGCheckPackageResult::NotTrusted + } zypp_agama_sys::GPGCheckPackageResult_CHK_NOKEY => GPGCheckPackageResult::NoKey, zypp_agama_sys::GPGCheckPackageResult_CHK_ERROR => GPGCheckPackageResult::Error, zypp_agama_sys::GPGCheckPackageResult_CHK_NOSIG => GPGCheckPackageResult::NoSig, @@ -204,24 +210,32 @@ impl From for GPGCheckPackageResult { pub enum DownloadResolvableFileError { NoError, - NotFound, // the requested Url was not found - IO, // IO error + NotFound, // the requested Url was not found + IO, // IO error AccessDenied, // user authent. failed while accessing restricted file - Error, // other error + Error, // other error } impl From for DownloadResolvableFileError { fn from(error: zypp_agama_sys::DownloadResolvableFileError) -> Self { match error { - zypp_agama_sys::DownloadResolvableFileError_DRFE_NO_ERROR => DownloadResolvableFileError::NoError, - zypp_agama_sys::DownloadResolvableFileError_DRFE_NOT_FOUND => DownloadResolvableFileError::NotFound, + zypp_agama_sys::DownloadResolvableFileError_DRFE_NO_ERROR => { + DownloadResolvableFileError::NoError + } + zypp_agama_sys::DownloadResolvableFileError_DRFE_NOT_FOUND => { + DownloadResolvableFileError::NotFound + } zypp_agama_sys::DownloadResolvableFileError_DRFE_IO => DownloadResolvableFileError::IO, - zypp_agama_sys::DownloadResolvableFileError_DRFE_ACCESS_DENIED => DownloadResolvableFileError::AccessDenied, - zypp_agama_sys::DownloadResolvableFileError_DRFE_ERROR => DownloadResolvableFileError::Error, + zypp_agama_sys::DownloadResolvableFileError_DRFE_ACCESS_DENIED => { + DownloadResolvableFileError::AccessDenied + } + zypp_agama_sys::DownloadResolvableFileError_DRFE_ERROR => { + DownloadResolvableFileError::Error + } _ => { tracing::error!("Unknown error code {:?}", error); DownloadResolvableFileError::Error - }, + } } } } @@ -231,31 +245,49 @@ pub trait PkgDownloadCallbacks { // callback when start preloading packages during commit phase fn start_preload(&self) {} // callback when problem occurs during download of resolvable - fn problem(&self, _name: &str, _error: DownloadResolvableError, _description: &str) -> ProblemResponse { + fn problem( + &self, + _name: &str, + _error: DownloadResolvableError, + _description: &str, + ) -> ProblemResponse { ProblemResponse::ABORT } // callback after gpg check is done - fn gpg_check(&self, _resolvable_name: &str, _repo_url: &str, _check_result: GPGCheckPackageResult) -> ProblemResponse { + fn gpg_check( + &self, + _resolvable_name: &str, + _repo_url: &str, + _check_result: GPGCheckPackageResult, + ) -> ProblemResponse { ProblemResponse::ABORT } // callback when download finishes either successfully or with error - fn finish(&self, _url: &str, _local_path: &str, _error: DownloadResolvableFileError, _error_details: &str) {} + fn finish( + &self, + _url: &str, + _local_path: &str, + _error: DownloadResolvableFileError, + _error_details: &str, + ) { + } } // Default progress that do nothing pub struct EmptyPkgDownloadCallbacks; impl PkgDownloadCallbacks for EmptyPkgDownloadCallbacks {} -unsafe extern "C" fn pkg_download_progress_start_preload( - user_data: *mut c_void, -) where +unsafe extern "C" fn pkg_download_progress_start_preload(user_data: *mut c_void) +where F: FnMut(), { let user_data = &mut *(user_data as *mut F); user_data(); } -fn get_pkg_download_progress_start_preload(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableStartCallback +fn get_pkg_download_progress_start_preload( + _closure: &F, +) -> zypp_agama_sys::ZyppDownloadResolvableStartCallback where F: FnMut(), { @@ -267,7 +299,8 @@ unsafe extern "C" fn pkg_download_progress_problem( error: zypp_agama_sys::DownloadResolvableError, description: *const c_char, user_data: *mut c_void, -) -> zypp_agama_sys::PROBLEM_RESPONSE where +) -> zypp_agama_sys::PROBLEM_RESPONSE +where F: FnMut(String, DownloadResolvableError, String) -> ProblemResponse, { let user_data = &mut *(user_data as *mut F); @@ -279,7 +312,9 @@ unsafe extern "C" fn pkg_download_progress_problem( res.into() } -fn get_pkg_download_problem(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableProblemCallback +fn get_pkg_download_problem( + _closure: &F, +) -> zypp_agama_sys::ZyppDownloadResolvableProblemCallback where F: FnMut(String, DownloadResolvableError, String) -> ProblemResponse, { @@ -291,7 +326,8 @@ unsafe extern "C" fn pkg_download_gpg_check( repo_url: *const c_char, check_result: zypp_agama_sys::GPGCheckPackageResult, user_data: *mut c_void, -) -> zypp_agama_sys::PROBLEM_RESPONSE where +) -> zypp_agama_sys::PROBLEM_RESPONSE +where F: FnMut(String, String, GPGCheckPackageResult) -> ProblemResponse, { let user_data = &mut *(user_data as *mut F); @@ -303,7 +339,9 @@ unsafe extern "C" fn pkg_download_gpg_check( res.into() } -fn get_pkg_download_gpg_check(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableGpgCheckCallback +fn get_pkg_download_gpg_check( + _closure: &F, +) -> zypp_agama_sys::ZyppDownloadResolvableGpgCheckCallback where F: FnMut(String, String, GPGCheckPackageResult) -> ProblemResponse, { @@ -320,17 +358,27 @@ unsafe extern "C" fn pkg_download_file_finish( F: FnMut(String, String, DownloadResolvableFileError, String), { let user_data = &mut *(user_data as *mut F); - user_data(string_from_ptr(url), string_from_ptr(local_path), error.into(), string_from_ptr(details)); + user_data( + string_from_ptr(url), + string_from_ptr(local_path), + error.into(), + string_from_ptr(details), + ); } -fn get_pkg_download_file_finish(_closure: &F) -> zypp_agama_sys::ZyppDownloadResolvableFileFinishCallback +fn get_pkg_download_file_finish( + _closure: &F, +) -> zypp_agama_sys::ZyppDownloadResolvableFileFinishCallback where F: FnMut(String, String, DownloadResolvableFileError, String), { Some(pkg_download_file_finish::) } -pub(crate) fn with_c_commit_download_callbacks(callbacks: &impl PkgDownloadCallbacks, block: &mut F) -> R +pub(crate) fn with_c_commit_download_callbacks( + callbacks: &impl PkgDownloadCallbacks, + block: &mut F, +) -> R where F: FnMut(zypp_agama_sys::DownloadResolvableCallbacks) -> R, { @@ -339,11 +387,13 @@ where let mut problem_call = |name: String, error, description: String| callbacks.problem(&name, error, &description); let cb_problem = get_pkg_download_problem(&problem_call); - let mut gpg_check = - |name: String, url: String, check_result: GPGCheckPackageResult| callbacks.gpg_check(&name, &url, check_result); + let mut gpg_check = |name: String, url: String, check_result: GPGCheckPackageResult| { + callbacks.gpg_check(&name, &url, check_result) + }; let cb_gpg_check = get_pkg_download_gpg_check(&gpg_check); - let mut finish_call = - |url: String, local_path: String, error, details: String| callbacks.finish(&url, &local_path, error, &details); + let mut finish_call = |url: String, local_path: String, error, details: String| { + callbacks.finish(&url, &local_path, error, &details) + }; let cb_finish = get_pkg_download_file_finish(&finish_call); let callbacks = zypp_agama_sys::DownloadResolvableCallbacks { @@ -357,4 +407,4 @@ where file_finish_data: &mut finish_call as *mut _ as *mut c_void, }; block(callbacks) -} \ No newline at end of file +} diff --git a/rust/zypp-agama/src/lib.rs b/rust/zypp-agama/src/lib.rs index 94c986285e..96dcb0e37c 100644 --- a/rust/zypp-agama/src/lib.rs +++ b/rust/zypp-agama/src/lib.rs @@ -159,9 +159,8 @@ impl Zypp { let mut status: Status = Status::default(); let status_ptr = &mut status as *mut _; unsafe { - let mut commit_fn = |mut callbacks| { - zypp_agama_sys::commit(self.ptr, status_ptr, &mut callbacks) - }; + let mut commit_fn = + |mut callbacks| zypp_agama_sys::commit(self.ptr, status_ptr, &mut callbacks); let res = callbacks::with_c_commit_download_callbacks(report, &mut commit_fn); helpers::status_to_result(status, res) } From 1b02ac938f171311ffdc4f1ed0333865b6fa201d Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 13:14:11 +0000 Subject: [PATCH 377/917] More suggestions from code review --- rust/agama-manager/src/service.rs | 7 +-- rust/agama-manager/src/start.rs | 8 +--- rust/agama-network/src/action.rs | 6 +-- rust/agama-network/src/lib.rs | 2 + rust/agama-network/src/model.rs | 77 ++++++------------------------- rust/agama-network/src/start.rs | 8 ++++ rust/agama-network/src/system.rs | 17 +++++-- 7 files changed, 46 insertions(+), 79 deletions(-) create mode 100644 rust/agama-network/src/start.rs diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 62873e87eb..eaacf002f3 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -29,7 +29,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; -use network::{NetworkSystemClient, NetworkSystemError}; +use network::NetworkSystemClient; use serde_json::Value; use tokio::sync::broadcast; @@ -52,7 +52,7 @@ pub enum Error { #[error(transparent)] Progress(#[from] progress::service::Error), #[error(transparent)] - NetworkSystemError(#[from] NetworkSystemError), + Network(#[from] network::NetworkSystemError), } pub struct Service { @@ -153,7 +153,8 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; let storage = self.storage.call(storage::message::GetSystem).await?; - let network = self.network.get_system_config().await?; + let network = self.network.get_system().await?; + Ok(SystemInfo { l10n, network, diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 8a3f034e7f..60537f10da 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -36,7 +36,7 @@ pub enum Error { #[error(transparent)] Storage(#[from] storage::start::Error), #[error(transparent)] - NetworkSystem(#[from] network::NetworkSystemError), + Network(#[from] network::start::Error), } /// Starts the manager service. @@ -53,11 +53,7 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; - let network_adapter = network::NetworkManagerAdapter::from_system() - .await - .expect("Could not connect to NetworkManager"); - let network = network::NetworkSystem::new(network_adapter).start().await?; - + let network = network::start().await?; let service = Service::new(l10n, network, storage, issues, progress, questions, events); let handler = actor::spawn(service); Ok(handler) diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index b465528f8b..4d4462f460 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -47,11 +47,11 @@ pub enum Action { GetConfig(Responder), /// Gets the internal state of the network configuration proposal GetProposal(Responder), - /// Updates the internal state of the network configuration + /// Updates the internal state of the network configuration applying the changes to the system UpdateConfig(Box, Responder>), - /// Gets the current network configuration containing connections, devices, access_points and + /// Gets the current network system configuration containing connections, devices, access_points and /// also the general state - GetSystemConfig(Responder), + GetSystem(Responder), /// Gets a connection GetConnections(Responder>), /// Gets a controller connection diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 0cf9b47e59..735ca6ca96 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -27,6 +27,8 @@ pub mod adapter; pub mod error; pub mod model; mod nm; +pub mod start; +pub use start::start; mod system; pub mod types; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 0e9372a04f..5aeb3b9d29 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -161,7 +161,7 @@ impl NetworkState { pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { if let Some(connections) = config.connections { let mut collection: ConnectionCollection = connections.clone().try_into()?; - for conn in collection.0.iter_mut() { + for conn in collection.iter_mut() { if let Some(current_conn) = self.get_connection(conn.id.as_str()) { // Replaced the UUID with a real one conn.uuid = current_conn.uuid; @@ -292,20 +292,6 @@ impl NetworkState { )), } } - - pub fn ports_for(&self, uuid: Uuid) -> Vec { - self.connections - .iter() - .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) - .collect() - } } #[cfg(test)] @@ -1330,18 +1316,19 @@ pub struct ConnectionCollection(pub Vec); impl ConnectionCollection { pub fn ports_for(&self, uuid: Uuid) -> Vec { - self.0 - .iter() + self.iter() .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) + .map(|c| c.interface.as_ref().unwrap_or(&c.id).clone()) .collect() } + + fn iter(&self) -> impl Iterator { + self.0.iter() + } + + fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } } impl TryFrom for NetworkConnectionsCollection { @@ -1349,7 +1336,6 @@ impl TryFrom for NetworkConnectionsCollection { fn try_from(collection: ConnectionCollection) -> Result { let network_connections = collection - .0 .iter() .filter(|c| c.controller.is_none()) .map(|c| { @@ -1393,12 +1379,11 @@ impl TryFrom for ConnectionCollection { } for (port, uuid) in controller_ports { - let default = Connection::new(port.clone(), DeviceType::Ethernet); let mut conn = conns .iter() - .find(|&c| c.id == port || c.interface == Some(port.to_string())) - .unwrap_or(&default) - .to_owned(); + .find(|c| c.id == port || c.interface.as_ref() == Some(&port)) + .cloned() + .unwrap_or_else(|| Connection::new(port, DeviceType::Ethernet)); conn.controller = Some(uuid); conns.push(conn); } @@ -1407,30 +1392,6 @@ impl TryFrom for ConnectionCollection { } } -impl TryFrom for NetworkConnectionsCollection { - type Error = NetworkStateError; - - fn try_from(state: NetworkState) -> Result { - let network_connections = state - .connections - .iter() - .filter(|c| c.controller.is_none()) - .map(|c| { - let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); - if let Some(ref mut bond) = conn.bond { - bond.ports = state.ports_for(c.uuid); - } - if let Some(ref mut bridge) = conn.bridge { - bridge.ports = state.ports_for(c.uuid); - }; - conn - }) - .collect(); - - Ok(NetworkConnectionsCollection(network_connections)) - } -} - impl TryFrom for StateSettings { type Error = NetworkStateError; @@ -1444,16 +1405,6 @@ impl TryFrom for StateSettings { } } -impl TryFrom for NetworkSettings { - type Error = NetworkStateError; - - fn try_from(state: NetworkState) -> Result { - let connections: NetworkConnectionsCollection = state.try_into()?; - - Ok(NetworkSettings { connections }) - } -} - impl TryFrom for Config { type Error = NetworkStateError; diff --git a/rust/agama-network/src/start.rs b/rust/agama-network/src/start.rs new file mode 100644 index 0000000000..5f27c7f8b2 --- /dev/null +++ b/rust/agama-network/src/start.rs @@ -0,0 +1,8 @@ +pub use crate::error::Error; +use crate::{NetworkManagerAdapter, NetworkSystem, NetworkSystemClient}; + +pub async fn start() -> Result { + let system = NetworkSystem::::for_network_manager().await; + + Ok(system.start().await?) +} diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index 69bf54c5b4..f0cb6dfddd 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -23,7 +23,7 @@ use crate::{ error::NetworkStateError, model::{Connection, GeneralState, NetworkChange, NetworkState, StateConfig}, types::{AccessPoint, Config, Device, DeviceType, Proposal, SystemInfo}, - Adapter, NetworkAdapterError, + Adapter, NetworkAdapterError, NetworkManagerAdapter, }; use std::error::Error; use tokio::sync::{ @@ -85,6 +85,15 @@ impl NetworkSystem { Self { adapter } } + /// Returns a new instance of the network configuration system using the [NetworkManagerAdapter] for the system. + pub async fn for_network_manager() -> NetworkSystem> { + let adapter = NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager"); + + NetworkSystem::new(adapter) + } + /// Starts the network configuration service and returns a client for communication purposes. /// /// This function starts the server (using [NetworkSystemServer]) on a separate @@ -181,9 +190,9 @@ impl NetworkSystemClient { Ok(result?) } - pub async fn get_system_config(&self) -> Result { + pub async fn get_system(&self) -> Result { let (tx, rx) = oneshot::channel(); - self.actions.send(Action::GetSystemConfig(tx))?; + self.actions.send(Action::GetSystem(tx))?; Ok(rx.await?) } @@ -333,7 +342,7 @@ impl NetworkSystemServer { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } - Action::GetSystemConfig(tx) => { + Action::GetSystem(tx) => { let result = self.read().await?.try_into()?; tx.send(result).unwrap(); } From f5f25e78d932afa55534c2cd51a33f5968b7756e Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 14:48:17 +0000 Subject: [PATCH 378/917] Added some doc --- rust/agama-network/src/model.rs | 10 +++++++++- rust/agama-network/src/system.rs | 5 +++++ rust/agama-utils/src/api/network/config.rs | 1 + rust/agama-utils/src/api/network/proposal.rs | 1 + rust/agama-utils/src/api/network/settings.rs | 3 +++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 5aeb3b9d29..061a814c8a 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -66,7 +66,8 @@ pub struct NetworkState { } impl NetworkState { - /// Returns a NetworkState struct with the given devices and connections. + /// Returns a NetworkState struct with the given general_state, access_points, devices + /// and connections. /// /// * `general_state`: General network configuration /// * `access_points`: Access points to include in the state. @@ -138,6 +139,7 @@ impl NetworkState { self.devices.iter_mut().find(|c| c.name == name) } + /// Returns the controller's connection for the givne connection Uuid. pub fn get_controlled_by(&mut self, uuid: Uuid) -> Vec<&Connection> { let uuid = Some(uuid); self.connections @@ -158,6 +160,12 @@ impl NetworkState { Ok(()) } + /// Updates the current [NetworkState] with the configuration provided. + /// + /// The config could contain a [NetworkConnectionsCollection] to be updated, in case of + /// provided it will iterate over the connections adding or updating them. + /// + /// If the general state is provided it will sets the options given. pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { if let Some(connections) = config.connections { let mut collection: ConnectionCollection = connections.clone().try_into()?; diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index f0cb6dfddd..a987457be6 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -170,18 +170,22 @@ impl NetworkSystemClient { self.actions.send(Action::GetConnections(tx))?; Ok(rx.await?) } + + /// Returns the cofiguration from the current network state as a [Config]. pub async fn get_config(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetConfig(tx))?; Ok(rx.await?) } + /// Returns the cofiguration from the current network state as a [Proposal]. pub async fn get_proposal(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetProposal(tx))?; Ok(rx.await?) } + /// Updates the current network state based on the configuration given. pub async fn update_config(&self, config: Config) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions @@ -190,6 +194,7 @@ impl NetworkSystemClient { Ok(result?) } + /// Reads the current system network configuration returning it directly pub async fn get_system(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetSystem(tx))?; diff --git a/rust/agama-utils/src/api/network/config.rs b/rust/agama-utils/src/api/network/config.rs index 51b7848513..f7f9d1d791 100644 --- a/rust/agama-utils/src/api/network/config.rs +++ b/rust/agama-utils/src/api/network/config.rs @@ -30,5 +30,6 @@ use std::default::Default; pub struct Config { /// Connections to use in the installation pub connections: Option, + /// Network general settings pub state: Option, } diff --git a/rust/agama-utils/src/api/network/proposal.rs b/rust/agama-utils/src/api/network/proposal.rs index 39bbe548a6..75d93c25f3 100644 --- a/rust/agama-utils/src/api/network/proposal.rs +++ b/rust/agama-utils/src/api/network/proposal.rs @@ -30,5 +30,6 @@ use std::default::Default; pub struct Proposal { /// Connections to use in the installation pub connections: NetworkConnectionsCollection, + /// General network settings pub state: StateSettings, } diff --git a/rust/agama-utils/src/api/network/settings.rs b/rust/agama-utils/src/api/network/settings.rs index f4ac92f023..be354de3d9 100644 --- a/rust/agama-utils/src/api/network/settings.rs +++ b/rust/agama-utils/src/api/network/settings.rs @@ -37,6 +37,9 @@ pub struct NetworkSettings { pub connections: NetworkConnectionsCollection, } +/// Network general settings for the installation like enabling wireless, networking and +/// allowing to enable or disable the copy of the network settings to the +/// target system #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct StateSettings { From db7f3544847f3b7fdb7b09a191048af22a4bc30f Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 11 Nov 2025 14:40:28 +0000 Subject: [PATCH 379/917] Add device class to the JSON of storage --- rust/share/device.storage.schema.json | 3 +++ .../to_json_conversions/device.rb | 15 ++++++++++++++- .../devicegraph_conversions/to_json_test.rb | 14 ++++++++++++-- web/src/api/storage/proposal.ts | 1 + web/src/api/storage/system.ts | 1 + 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json index 39ebbfcee6..33372facca 100644 --- a/rust/share/device.storage.schema.json +++ b/rust/share/device.storage.schema.json @@ -10,6 +10,9 @@ "sid": { "type": "integer" }, "name": { "type": "string" }, "description": { "type": "string" }, + "class": { + "enum": ["drive", "mdRaid", "partition", "volumeGroup", "logicalVolume"] + }, "block": { "$ref": "#/$defs/block" }, "drive": { "$ref": "#/$defs/drive" }, "filesystem": { "$ref": "#/$defs/filesystem" }, diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb index f0254f2a32..d9638e1d11 100644 --- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/device.rb @@ -40,7 +40,8 @@ def convert result = { sid: device_sid, name: device_name, - description: device_description + description: device_description, + class: device_class } add_sections(result) add_nested_devices(result) @@ -74,6 +75,18 @@ def device_description Y2Storage::DeviceDescription.new(storage_device, include_encryption: true).to_s end + # Type of device. + # + # @return [String] "drive", "mdRaid", "volumeGroup", "partition" or "logicalVolume" + def device_class + return "partition" if storage_device.is?(:partition) + return "logicalVolume" if storage_device.is?(:lvm_lv) + return "volumeGroup" if storage_device.is?(:lvm_vg) + return "mdRaid" if storage_device.is?(:software_raid) + + "drive" + end + # Adds the required sub-sections according to the storage object. # # @param hash [Hash] the argument gets modified diff --git a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb index b8ec92c336..0c55a690b5 100644 --- a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb +++ b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb @@ -51,6 +51,7 @@ it "generates an entry for each disk" do json = subject.convert expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/vdc") + expect(json.map { |e| e[:class] }).to all(eq "drive") end it "exports the block device sizes in bytes" do @@ -63,6 +64,7 @@ vda = json.find { |d| d[:name] == "/dev/vda" } expect(vda[:partitions].size).to eq 3 + expect(vda[:partitions].map { |p| p[:class] }).to all(eq "partition") expect(vda[:partitionTable][:type]).to eq "gpt" vdb = json.find { |d| d[:name] == "/dev/vdb" } @@ -94,7 +96,10 @@ it "generates an entry for each disk and volume group" do json = subject.convert - expect(json.map { |e| e[:name] }).to contain_exactly("/dev/sda", "/dev/vg0") + expect(json).to contain_exactly( + a_hash_including(name: "/dev/sda", class: "drive"), + a_hash_including(name: "/dev/vg0", class: "volumeGroup") + ) end it "exports the size and physical volumes of the LVM volume group" do @@ -116,6 +121,7 @@ lvs = vg0[:logicalVolumes] expect(lvs.map { |lv| lv[:name] }).to eq ["/dev/vg0/lv1"] expect(lvs.first[:block].keys).to include :size + expect(lvs.first[:class]).to eq "logicalVolume" end it "generates the :filesystem entry for formatted logical volumes" do @@ -133,7 +139,11 @@ it "generates an entry for each disk and MD RAID" do json = subject.convert - expect(json.map { |e| e[:name] }).to contain_exactly("/dev/vda", "/dev/vdb", "/dev/md0") + expect(json).to contain_exactly( + a_hash_including(name: "/dev/vda", class: "drive"), + a_hash_including(name: "/dev/vdb", class: "drive"), + a_hash_including(name: "/dev/md0", class: "mdRaid") + ) end it "exports the level and members of the MD RAIDs" do diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 0973d1931d..7a7644ea2e 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -27,6 +27,7 @@ export interface StorageDevice { sid: number; name: string; description?: string; + class?: "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; block?: Block; drive?: Drive; filesystem?: Filesystem; diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index 88078a978e..6a86be8072 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -60,6 +60,7 @@ export interface StorageDevice { sid: number; name: string; description?: string; + class?: "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; block?: Block; drive?: Drive; filesystem?: Filesystem; From d2c831883417a5bbf7c38534330a5015cb042a6b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 11 Nov 2025 15:20:45 +0000 Subject: [PATCH 380/917] Improve unusedSlots structure in the JSON --- rust/share/device.storage.schema.json | 14 ++++++++++---- .../to_json_conversions/partition_table.rb | 4 ++-- .../devicegraph_conversions/to_json_test.rb | 3 +++ web/src/api/storage/proposal.ts | 6 +++++- web/src/api/storage/system.ts | 6 +++++- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json index 33372facca..e145da1c27 100644 --- a/rust/share/device.storage.schema.json +++ b/rust/share/device.storage.schema.json @@ -175,16 +175,22 @@ "type": { "$ref": "#/$defs/ptableType" }, "unusedSlots": { "type": "array", - "items": { - "type": "array", - "items": { "type": "integer" } - } + "items": { "$ref": "#/$defs/unusedSlot" } } } }, "ptableType": { "enum": ["gpt", "msdos", "dasd"] }, + "unusedSlot": { + "type": "object", + "additionalProperties": false, + "required": ["start", "size"], + "properties": { + "start": { "type": "integer" }, + "size": { "type": "integer" } + } + }, "partition": { "type": "object", "additionalProperties": false, diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb index a97a70a9db..4f0f4873ca 100644 --- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/partition_table.rb @@ -54,10 +54,10 @@ def partition_table_type # Available slots within a partition table, that is, the spaces that can be used to # create a new partition. # - # @return [Array] The first block and the size of each slot. + # @return [Array] Each hash contains the first block and the size of the slot. def partition_table_unused_slots storage_device.partition_table.unused_partition_slots.map do |slot| - [slot.region.start, slot.region.size.to_i] + { start: slot.region.start, size: slot.region.size.to_i } end end end diff --git a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb index 0c55a690b5..b65f23a528 100644 --- a/service/test/agama/storage/devicegraph_conversions/to_json_test.rb +++ b/service/test/agama/storage/devicegraph_conversions/to_json_test.rb @@ -66,6 +66,9 @@ expect(vda[:partitions].size).to eq 3 expect(vda[:partitions].map { |p| p[:class] }).to all(eq "partition") expect(vda[:partitionTable][:type]).to eq "gpt" + expect(vda[:partitionTable][:unusedSlots]).to contain_exactly( + a_hash_including(start: Integer, size: Integer) + ) vdb = json.find { |d| d[:name] == "/dev/vdb" } expect(vdb.keys).to_not include :partitions diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 7a7644ea2e..dc2c62248f 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -101,7 +101,11 @@ export interface Multipath { } export interface PartitionTable { type: "gpt" | "msdos" | "dasd"; - unusedSlots: number[][]; + unusedSlots: UnusedSlot[]; +} +export interface UnusedSlot { + start: number; + size: number; } export interface Partition { efi: boolean; diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index 6a86be8072..ae1fb56e5d 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -134,7 +134,11 @@ export interface Multipath { } export interface PartitionTable { type: "gpt" | "msdos" | "dasd"; - unusedSlots: number[][]; + unusedSlots: UnusedSlot[]; +} +export interface UnusedSlot { + start: number; + size: number; } export interface Partition { efi: boolean; From 0f59d0ce1f13d70cbb96e34780716f198e69a33e Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 11 Nov 2025 15:33:00 +0000 Subject: [PATCH 381/917] Improve structure about shrinking in the storage JSON --- rust/share/device.storage.schema.json | 21 ++++++------------- .../to_json_conversions/block.rb | 10 +++++++-- web/src/api/storage/proposal.ts | 11 +++++----- web/src/api/storage/system.ts | 11 +++++----- 4 files changed, 24 insertions(+), 29 deletions(-) diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json index e145da1c27..f6ae569797 100644 --- a/rust/share/device.storage.schema.json +++ b/rust/share/device.storage.schema.json @@ -52,26 +52,17 @@ "type": "array", "items": { "type": "string" } }, - "shrinking": { - "anyOf": [ - { "$ref": "#/$defs/shrinkingSupported" }, - { "$ref": "#/$defs/shrinkingUnsupported" } - ] - } - } - }, - "shrinkingSupported": { - "type": "object", - "additionalProperties": false, - "properties": { - "supported": { "type": "integer" } + "shrinking": { "$ref": "#/$defs/shrinkInfo" } } }, - "shrinkingUnsupported": { + "shrinkInfo": { "type": "object", "additionalProperties": false, + "required": ["supported"], "properties": { - "unsupported": { + "supported": { "type": "boolean" }, + "minSize": { "type": "integer" }, + "reasons": { "type": "array", "items": { "type": "string" } } diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb index 7fd8225e36..9e4cf1c212 100644 --- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb +++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/block.rb @@ -98,9 +98,15 @@ def block_shrinking shrinking = Agama::Storage::DeviceShrinking.new(storage_device) if shrinking.supported? - { supported: shrinking.min_size.to_i } + { + supported: true, + minSize: shrinking.min_size.to_i + } else - { unsupported: shrinking.unsupported_reasons } + { + supported: false, + reasons: shrinking.unsupported_reasons + } end end diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index dc2c62248f..2e6589578a 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -47,13 +47,12 @@ export interface Block { udevIds?: string[]; udevPaths?: string[]; systems?: string[]; - shrinking: ShrinkingSupported | ShrinkingUnsupported; + shrinking: ShrinkInfo; } -export interface ShrinkingSupported { - supported?: number; -} -export interface ShrinkingUnsupported { - unsupported?: string[]; +export interface ShrinkInfo { + supported: boolean; + minSize?: number; + reasons?: string[]; } export interface Drive { type?: "disk" | "raid" | "multipath" | "dasd"; diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index ae1fb56e5d..3202d0c43a 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -80,13 +80,12 @@ export interface Block { udevIds?: string[]; udevPaths?: string[]; systems?: string[]; - shrinking: ShrinkingSupported | ShrinkingUnsupported; + shrinking: ShrinkInfo; } -export interface ShrinkingSupported { - supported?: number; -} -export interface ShrinkingUnsupported { - unsupported?: string[]; +export interface ShrinkInfo { + supported: boolean; + minSize?: number; + reasons?: string[]; } export interface Drive { type?: "disk" | "raid" | "multipath" | "dasd"; From 6a0b41f816b5d4aa2b21b32def65377c756f5739 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Tue, 11 Nov 2025 16:06:35 +0000 Subject: [PATCH 382/917] Fool json2ts to generate separate types for the enums --- rust/share/device.storage.schema.json | 16 +++- rust/share/system.storage.schema.json | 18 +++-- web/src/api/storage/proposal.ts | 44 ++++++----- web/src/api/storage/system.ts | 102 ++++++++++---------------- 4 files changed, 85 insertions(+), 95 deletions(-) diff --git a/rust/share/device.storage.schema.json b/rust/share/device.storage.schema.json index f6ae569797..1adddfe1af 100644 --- a/rust/share/device.storage.schema.json +++ b/rust/share/device.storage.schema.json @@ -10,9 +10,7 @@ "sid": { "type": "integer" }, "name": { "type": "string" }, "description": { "type": "string" }, - "class": { - "enum": ["drive", "mdRaid", "partition", "volumeGroup", "logicalVolume"] - }, + "class": { "$ref": "#/$defs/deviceClass" }, "block": { "$ref": "#/$defs/block" }, "drive": { "$ref": "#/$defs/drive" }, "filesystem": { "$ref": "#/$defs/filesystem" }, @@ -31,6 +29,10 @@ } }, "$defs": { + "deviceClass": { + "title": "Device class", + "enum": ["drive", "mdRaid", "partition", "volumeGroup", "logicalVolume"] + }, "block": { "type": "object", "additionalProperties": false, @@ -72,7 +74,7 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["disk", "raid", "multipath", "dasd"] }, + "type": { "$ref": "#/$defs/driveType" }, "vendor": { "type": "string" }, "model": { "type": "string" }, "transport": { "type": "string" }, @@ -85,6 +87,10 @@ "info": { "$ref": "#/$defs/driveInfo" } } }, + "driveType": { + "title": "Drive type", + "enum": ["disk", "raid", "multipath", "dasd"] + }, "driveInfo": { "type": "object", "additionalProperties": false, @@ -105,6 +111,7 @@ } }, "filesystemType": { + "title": "Filesystem type", "enum": [ "bcachefs", "btrfs", @@ -171,6 +178,7 @@ } }, "ptableType": { + "title": "Partition table type", "enum": ["gpt", "msdos", "dasd"] }, "unusedSlot": { diff --git a/rust/share/system.storage.schema.json b/rust/share/system.storage.schema.json index 7d7e30fe5c..8ededfb2c5 100644 --- a/rust/share/system.storage.schema.json +++ b/rust/share/system.storage.schema.json @@ -39,11 +39,7 @@ "encryptionMethods": { "description": "Possible encryption methods for the current system and product", "type": "array", - "items": { - "enum": [ - "luks1", "luks2", "pervasiveLuks2", "tmpFde", "protectedSwap", "secureSwap", "randomSwap" - ] - } + "items": { "$ref": "#/$defs/encryptionMethod" } }, "volumeTemplates": { "description": "Volumes defined by the product as templates", @@ -56,6 +52,12 @@ } }, "$defs": { + "encryptionMethod": { + "title": "Encryption method", + "enum": [ + "luks1", "luks2", "pervasiveLuks2", "tmpFde", "protectedSwap", "secureSwap", "randomSwap" + ] + }, "volume": { "type": "object", "additionalProperties": false, @@ -103,9 +105,13 @@ "description": { "type": "string" }, "class": { "type": "string" }, "details": { "type": "string" }, - "source": { "enum": ["config", "system"] }, + "source": { "$ref": "#/$defs/issueSource" }, "severity": { "enum": ["warn", "error"] } } + }, + "issueSource": { + "title": "System issue source", + "enum": ["config", "system"] } } } diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 2e6589578a..938619c194 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -5,7 +5,27 @@ * and run json-schema-to-typescript to regenerate this file. */ +export type DeviceClass = "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; +export type DriveType = "disk" | "raid" | "multipath" | "dasd"; +export type FilesystemType = + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; +export type PartitionTableType = "gpt" | "msdos" | "dasd"; /** * API description of the storage proposal. @@ -27,7 +47,7 @@ export interface StorageDevice { sid: number; name: string; description?: string; - class?: "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; + class?: DeviceClass; block?: Block; drive?: Drive; filesystem?: Filesystem; @@ -55,7 +75,7 @@ export interface ShrinkInfo { reasons?: string[]; } export interface Drive { - type?: "disk" | "raid" | "multipath" | "dasd"; + type?: DriveType; vendor?: string; model?: string; transport?: string; @@ -70,23 +90,7 @@ export interface DriveInfo { } export interface Filesystem { sid: number; - type: - | "bcachefs" - | "btrfs" - | "exfat" - | "ext2" - | "ext3" - | "ext4" - | "f2fs" - | "jfs" - | "nfs" - | "nilfs2" - | "ntfs" - | "reiserfs" - | "swap" - | "tmpfs" - | "vfat" - | "xfs"; + type: FilesystemType; mountPath?: string; label?: string; } @@ -99,7 +103,7 @@ export interface Multipath { wireNames: string[]; } export interface PartitionTable { - type: "gpt" | "msdos" | "dasd"; + type: PartitionTableType; unusedSlots: UnusedSlot[]; } export interface UnusedSlot { diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index 3202d0c43a..29406456f5 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -5,7 +5,36 @@ * and run json-schema-to-typescript to regenerate this file. */ +export type DeviceClass = "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; +export type DriveType = "disk" | "raid" | "multipath" | "dasd"; +export type FilesystemType = + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; +export type PartitionTableType = "gpt" | "msdos" | "dasd"; +export type EncryptionMethod = + | "luks1" + | "luks2" + | "pervasiveLuks2" + | "tmpFde" + | "protectedSwap" + | "secureSwap" + | "randomSwap"; +export type SystemIssueSource = "config" | "system"; /** * API description of the system @@ -38,15 +67,7 @@ export interface System { /** * Possible encryption methods for the current system and product */ - encryptionMethods?: ( - | "luks1" - | "luks2" - | "pervasiveLuks2" - | "tmpFde" - | "protectedSwap" - | "secureSwap" - | "randomSwap" - )[]; + encryptionMethods?: EncryptionMethod[]; /** * Volumes defined by the product as templates */ @@ -60,7 +81,7 @@ export interface StorageDevice { sid: number; name: string; description?: string; - class?: "drive" | "mdRaid" | "partition" | "volumeGroup" | "logicalVolume"; + class?: DeviceClass; block?: Block; drive?: Drive; filesystem?: Filesystem; @@ -88,7 +109,7 @@ export interface ShrinkInfo { reasons?: string[]; } export interface Drive { - type?: "disk" | "raid" | "multipath" | "dasd"; + type?: DriveType; vendor?: string; model?: string; transport?: string; @@ -103,23 +124,7 @@ export interface DriveInfo { } export interface Filesystem { sid: number; - type: - | "bcachefs" - | "btrfs" - | "exfat" - | "ext2" - | "ext3" - | "ext4" - | "f2fs" - | "jfs" - | "nfs" - | "nilfs2" - | "ntfs" - | "reiserfs" - | "swap" - | "tmpfs" - | "vfat" - | "xfs"; + type: FilesystemType; mountPath?: string; label?: string; } @@ -132,7 +137,7 @@ export interface Multipath { wireNames: string[]; } export interface PartitionTable { - type: "gpt" | "msdos" | "dasd"; + type: PartitionTableType; unusedSlots: UnusedSlot[]; } export interface UnusedSlot { @@ -149,23 +154,7 @@ export interface VolumeGroup { export interface Volume { mountPath: string; mountOptions?: string[]; - fsType?: - | "bcachefs" - | "btrfs" - | "exfat" - | "ext2" - | "ext3" - | "ext4" - | "f2fs" - | "jfs" - | "nfs" - | "nilfs2" - | "ntfs" - | "reiserfs" - | "swap" - | "tmpfs" - | "vfat" - | "xfs"; + fsType?: FilesystemType; autoSize: boolean; minSize: number; maxSize?: number; @@ -176,24 +165,7 @@ export interface Volume { export interface VolumeOutline { required: boolean; supportAutoSize: boolean; - fsTypes?: ( - | "bcachefs" - | "btrfs" - | "exfat" - | "ext2" - | "ext3" - | "ext4" - | "f2fs" - | "jfs" - | "nfs" - | "nilfs2" - | "ntfs" - | "reiserfs" - | "swap" - | "tmpfs" - | "vfat" - | "xfs" - )[]; + fsTypes?: FilesystemType[]; adjustByRam?: boolean; snapshotsConfigurable?: boolean; snapshotsAffectSizes?: boolean; @@ -203,6 +175,6 @@ export interface Issue { description: string; class?: string; details?: string; - source?: "config" | "system"; + source?: SystemIssueSource; severity?: "warn" | "error"; } From 3c012bca1614030fc471a71a9aa6c90c71464b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 16:01:20 +0000 Subject: [PATCH 383/917] Fix types - Tests are not fixed yet. --- web/src/api/config.ts | 4 +- .../storage/device.ts => api/l10n/config.ts} | 24 +-- web/src/api/l10n/proposal.ts | 6 +- web/src/api/manager.ts | 2 + web/src/api/network.ts | 2 + web/src/api/progress.ts | 2 + web/src/api/software.ts | 2 + web/src/api/status.ts | 2 + web/src/api/storage.ts | 1 + web/src/api/storage/config.ts | 179 ++++++++++++------ web/src/api/storage/proposal.ts | 7 +- web/src/api/storage/system.ts | 7 +- web/src/api/users.ts | 2 + web/src/components/overview/L10nSection.tsx | 7 +- web/src/components/storage/AutoSizeText.tsx | 3 +- web/src/components/storage/BootSelection.tsx | 14 +- .../storage/ConfigureDeviceMenu.tsx | 11 +- .../storage/DeviceEditorContent.tsx | 5 +- .../storage/DeviceSelectorModal.tsx | 34 ++-- .../components/storage/DeviceSelectorPage.tsx | 8 +- ...esFormSelect.jsx => DevicesFormSelect.tsx} | 41 ++-- .../{DevicesManager.js => DevicesManager.ts} | 151 ++++----------- web/src/components/storage/DriveEditor.tsx | 8 +- web/src/components/storage/DriveHeader.tsx | 5 +- .../components/storage/EncryptionSection.tsx | 2 +- web/src/components/storage/LvmPage.tsx | 14 +- web/src/components/storage/MdRaidEditor.tsx | 8 +- web/src/components/storage/MdRaidHeader.tsx | 5 +- .../storage/MenuDeviceDescription.tsx | 4 +- .../components/storage/MountPathMenuItem.tsx | 2 +- web/src/components/storage/PartitionPage.tsx | 4 +- .../storage/ProposalActionsDialog.tsx | 2 +- .../storage/ProposalResultSection.tsx | 8 +- .../storage/ProposalResultTable.tsx | 22 ++- .../components/storage/SearchedDeviceMenu.tsx | 26 ++- .../components/storage/SpaceActionsTable.tsx | 60 +++--- .../components/storage/SpacePolicyMenu.tsx | 12 +- .../storage/SpacePolicySelection.tsx | 22 +-- .../components/storage/device-utils.test.tsx | 12 +- web/src/components/storage/device-utils.tsx | 49 +++-- web/src/components/storage/utils.ts | 58 +++--- web/src/components/storage/utils/device.tsx | 38 ++-- web/src/components/storage/utils/drive.tsx | 2 +- .../components/storage/utils/partition.tsx | 2 +- web/src/helpers/storage/api-model.ts | 2 +- web/src/helpers/storage/boot.ts | 2 +- web/src/helpers/storage/device.ts | 93 +++++++++ web/src/helpers/storage/drive.ts | 2 +- web/src/helpers/storage/filesystem.ts | 2 +- web/src/helpers/storage/logical-volume.ts | 2 +- web/src/helpers/storage/md-raid.ts | 2 +- web/src/helpers/storage/model.ts | 2 +- web/src/helpers/storage/partition.ts | 2 +- web/src/helpers/storage/search.ts | 2 +- web/src/helpers/storage/space-policy.ts | 2 +- web/src/helpers/storage/volume-group.ts | 2 +- web/src/hooks/storage/proposal.ts | 13 +- web/src/hooks/storage/system.ts | 14 +- web/src/queries/l10n.ts | 48 ----- web/src/queries/storage/iscsi.ts | 10 +- web/src/test-utils.tsx | 1 - web/src/types/storage.ts | 106 ----------- web/src/types/storage/data.ts | 2 +- web/src/types/storage/model.ts | 2 +- 64 files changed, 595 insertions(+), 595 deletions(-) rename web/src/{factories/storage/device.ts => api/l10n/config.ts} (68%) rename web/src/components/storage/{DevicesFormSelect.jsx => DevicesFormSelect.tsx} (57%) rename web/src/components/storage/{DevicesManager.js => DevicesManager.ts} (57%) create mode 100644 web/src/helpers/storage/device.ts delete mode 100644 web/src/queries/l10n.ts diff --git a/web/src/api/config.ts b/web/src/api/config.ts index fc050d8899..a14daf0ca1 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -20,11 +20,13 @@ * find current contact information at www.suse.com. */ +import * as l10n from "~/api/l10n/config"; import * as storage from "~/api/storage/config"; type Config = { + l10n?: l10n.Config; storage?: storage.Config; }; -export { storage }; +export { l10n, storage }; export type { Config }; diff --git a/web/src/factories/storage/device.ts b/web/src/api/l10n/config.ts similarity index 68% rename from web/src/factories/storage/device.ts rename to web/src/api/l10n/config.ts index 5d9314150c..925b6c169c 100644 --- a/web/src/factories/storage/device.ts +++ b/web/src/api/l10n/config.ts @@ -20,22 +20,10 @@ * find current contact information at www.suse.com. */ -import { Device } from "~/api/storage/types/openapi"; +type Config = { + locale?: string; + keymap?: string; + timezone?: string; +}; -function generate({ name, size, sid }): Device { - return { - deviceInfo: { sid, name, description: "" }, - blockDevice: { - active: true, - encrypted: false, - shrinking: { unsupported: [] }, - size, - start: 0, - systems: [], - udevIds: [], - udevPaths: [], - }, - }; -} - -export { generate }; +export type { Config }; diff --git a/web/src/api/l10n/proposal.ts b/web/src/api/l10n/proposal.ts index e7ab01974f..c8a2766a7e 100644 --- a/web/src/api/l10n/proposal.ts +++ b/web/src/api/l10n/proposal.ts @@ -20,6 +20,10 @@ * find current contact information at www.suse.com. */ -type Proposal = object; +type Proposal = { + locale?: string; + keymap?: string; + timezone?: string; +}; export type { Proposal }; diff --git a/web/src/api/manager.ts b/web/src/api/manager.ts index 61653c8408..0e27a2de96 100644 --- a/web/src/api/manager.ts +++ b/web/src/api/manager.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { get, post } from "~/http"; /** diff --git a/web/src/api/network.ts b/web/src/api/network.ts index a6fd2b9fe1..edd4ea14ec 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { del, get, post, put } from "~/http"; import { APIAccessPoint, APIConnection, APIDevice, NetworkGeneralState } from "~/types/network"; diff --git a/web/src/api/progress.ts b/web/src/api/progress.ts index d62095f0e4..e4437e0fb8 100644 --- a/web/src/api/progress.ts +++ b/web/src/api/progress.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { get } from "~/http"; import { APIProgress, Progress } from "~/types/progress"; diff --git a/web/src/api/software.ts b/web/src/api/software.ts index 0f6210fed6..b457b5c0e4 100644 --- a/web/src/api/software.ts +++ b/web/src/api/software.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { AddonInfo, Conflict, diff --git a/web/src/api/status.ts b/web/src/api/status.ts index f7d0f7c65e..45de115e7e 100644 --- a/web/src/api/status.ts +++ b/web/src/api/status.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { get } from "~/http"; import { InstallerStatus } from "~/types/status"; diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index dccba2ae09..9350eae137 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,3 +23,4 @@ export * as config from "~/api/storage/config"; export * as apiModel from "~/api/storage/model"; export * as system from "~/api/storage/system"; +export * as proposal from "~/api/storage/proposal"; diff --git a/web/src/api/storage/config.ts b/web/src/api/storage/config.ts index b6ee6456c1..e20ca9b81e 100644 --- a/web/src/api/storage/config.ts +++ b/web/src/api/storage/config.ts @@ -9,16 +9,40 @@ */ export type Alias = string; export type DriveElement = NonPartitionedDrive | PartitionedDrive; -export type SearchElement = SimpleSearchAll | SimpleSearchByName | AdvancedSearch; +export type DriveSearch = SearchAll | SearchName | DriveAdvancedSearch; /** * Shortcut to match all devices if there is any (equivalent to specify no conditions and to skip the entry if no device is found). */ -export type SimpleSearchAll = "*"; -export type SimpleSearchByName = string; +export type SearchAll = "*"; +/** + * Search by device name + */ +export type SearchName = string; +export type DriveSearchCondition = SearchConditionName | SearchConditionSize; +export type SizeValue = SizeString | SizeBytes; +/** + * Human readable size. + */ +export type SizeString = string; +/** + * Size in bytes. + */ +export type SizeBytes = number; +export type DriveSearchSort = DriveSearchSortCriterion | DriveSearchSortCriterion[]; +export type DriveSearchSortCriterion = DriveSearchSortCriterionShort | DriveSearchSortCriterionFull; +export type DriveSearchSortCriterionShort = "name" | "size"; +/** + * Direction of sorting at the search results + */ +export type SearchSortCriterionOrder = "asc" | "desc"; +/** + * Maximum devices to match. + */ +export type SearchMax = number; /** * How to handle the section if the device is not found. */ -export type SearchAction = "skip" | "error"; +export type SearchActions = "skip" | "error"; export type Encryption = | EncryptionLuks1 | EncryptionLuks2 @@ -74,17 +98,22 @@ export type PartitionElement = | RegularPartition | PartitionToDelete | PartitionToDeleteIfNeeded; -export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | "bios_boot"; -export type Size = SizeValue | SizeTuple | SizeRange; -export type SizeValue = SizeString | SizeBytes; -/** - * Human readable size. - */ -export type SizeString = string; +export type PartitionSearch = SearchAll | SearchName | PartitionAdvancedSearch; +export type PartitionSearchCondition = + | SearchConditionName + | SearchConditionSize + | SearchConditionPartitionNumber; +export type PartitionSearchSort = PartitionSearchSortCriterion | PartitionSearchSortCriterion[]; +export type PartitionSearchSortCriterion = + | PartitionSearchSortCriterionShort + | PartitionSearchSortCriterionFull; +export type PartitionSearchSortCriterionShort = "name" | "size" | "number"; /** - * Size in bytes. + * How to handle the section if the device is not found. */ -export type SizeBytes = number; +export type SearchCreatableActions = "skip" | "error" | "create"; +export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | "bios_boot"; +export type Size = SizeValue | SizeTuple | SizeRange; /** * Lower size limit and optionally upper size limit. * @@ -97,6 +126,11 @@ export type SizeValueWithCurrent = SizeValue | SizeCurrent; * The current size of the device. */ export type SizeCurrent = "current"; +export type DeletePartitionSearch = SearchAll | SearchName | DeletePartitionAdvancedSearch; +/** + * Device base name. + */ +export type BaseName = string; export type PhysicalVolumeElement = | Alias | SimplePhysicalVolumesGenerator @@ -112,10 +146,13 @@ export type LogicalVolumeElement = */ export type LogicalVolumeStripes = number; export type MdRaidElement = NonPartitionedMdRaid | PartitionedMdRaid; -/** - * MD base name. - */ -export type MdRaidName = string; +export type MdRaidSearch = SearchAll | SearchName | MdRaidAdvancedSearch; +export type MdRaidSearchCondition = SearchConditionName | SearchConditionSize; +export type MdRaidSearchSort = MdRaidSearchSortCriterion | MdRaidSearchSortCriterion[]; +export type MdRaidSearchSortCriterion = + | MdRaidSearchSortCriterionShort + | MdRaidSearchSortCriterionFull; +export type MdRaidSearchSortCriterionShort = "name" | "size"; export type MDLevel = "raid0" | "raid1" | "raid5" | "raid6" | "raid10"; /** * Only applies to raid5, raid6 and raid10 @@ -170,24 +207,35 @@ export interface Boot { * Drive without a partition table (e.g., directly formatted). */ export interface NonPartitionedDrive { - search?: SearchElement; + search?: DriveSearch; alias?: Alias; encryption?: Encryption; filesystem?: Filesystem; } -/** - * Advanced options for searching devices. - */ -export interface AdvancedSearch { - condition?: SearchCondition; - /** - * Maximum devices to match. - */ - max?: number; - ifNotFound?: SearchAction; +export interface DriveAdvancedSearch { + condition?: DriveSearchCondition; + sort?: DriveSearchSort; + max?: SearchMax; + ifNotFound?: SearchActions; } -export interface SearchCondition { - name: SimpleSearchByName; +export interface SearchConditionName { + name: SearchName; +} +export interface SearchConditionSize { + size: SizeValue | SearchConditionSizeEqual | SearchConditionSizeGreater | SearchConditionSizeLess; +} +export interface SearchConditionSizeEqual { + equal: SizeValue; +} +export interface SearchConditionSizeGreater { + greater: SizeValue; +} +export interface SearchConditionSizeLess { + less: SizeValue; +} +export interface DriveSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; } /** * LUKS1 encryption. @@ -223,7 +271,7 @@ export interface EncryptionPervasiveLuks2 { }; } /** - * TPM-Based Full Disk Encrytion. + * TPM-Based Full Disk Encryption. */ export interface EncryptionTPM { tpmFde: { @@ -266,7 +314,7 @@ export interface FilesystemTypeBtrfs { }; } export interface PartitionedDrive { - search?: SearchElement; + search?: DriveSearch; alias?: Alias; ptableType?: PtableType; partitions: PartitionElement[]; @@ -287,13 +335,30 @@ export interface AdvancedPartitionsGenerator { }; } export interface RegularPartition { - search?: SearchElement; + search?: PartitionSearch; alias?: Alias; id?: PartitionId; size?: Size; encryption?: Encryption; filesystem?: Filesystem; } +export interface PartitionAdvancedSearch { + condition?: PartitionSearchCondition; + sort?: PartitionSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface SearchConditionPartitionNumber { + /** + * Partition number (e.g., 1 for vda1). + */ + number: number; +} +export interface PartitionSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; + number?: SearchSortCriterionOrder; +} /** * Size range. */ @@ -302,14 +367,20 @@ export interface SizeRange { max?: SizeValueWithCurrent; } export interface PartitionToDelete { - search: SearchElement; + search: DeletePartitionSearch; /** * Delete the partition. */ delete: true; } +export interface DeletePartitionAdvancedSearch { + condition?: PartitionSearchCondition; + sort?: PartitionSearchSort; + max?: SearchMax; + ifNotFound?: SearchActions; +} export interface PartitionToDeleteIfNeeded { - search: SearchElement; + search: DeletePartitionSearch; /** * Delete the partition if needed to make space. */ @@ -320,10 +391,7 @@ export interface PartitionToDeleteIfNeeded { * LVM volume group. */ export interface VolumeGroup { - /** - * Volume group name. - */ - name: string; + name: BaseName; extentSize?: SizeValue; /** * Devices to use as physical volumes. @@ -358,10 +426,7 @@ export interface AdvancedLogicalVolumesGenerator { }; } export interface LogicalVolume { - /** - * Logical volume name. - */ - name?: string; + name?: BaseName; size?: Size; stripes?: LogicalVolumeStripes; stripeSize?: SizeValue; @@ -374,20 +439,14 @@ export interface ThinPoolLogicalVolume { */ pool: true; alias?: Alias; - /** - * Logical volume name. - */ - name?: string; + name?: BaseName; size?: Size; stripes?: LogicalVolumeStripes; stripeSize?: SizeValue; encryption?: Encryption; } export interface ThinLogicalVolume { - /** - * Thin logical volume name. - */ - name?: string; + name?: BaseName; size?: Size; usedPool: Alias; encryption?: Encryption; @@ -397,9 +456,9 @@ export interface ThinLogicalVolume { * MD RAID without a partition table (e.g., directly formatted). */ export interface NonPartitionedMdRaid { - search?: SearchElement; + search?: MdRaidSearch; alias?: Alias; - name?: MdRaidName; + name?: BaseName; level?: MDLevel; parity?: MDParity; chunkSize?: SizeValue; @@ -407,10 +466,20 @@ export interface NonPartitionedMdRaid { encryption?: Encryption; filesystem?: Filesystem; } +export interface MdRaidAdvancedSearch { + condition?: MdRaidSearchCondition; + sort?: MdRaidSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface MdRaidSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; +} export interface PartitionedMdRaid { - search?: SearchElement; + search?: MdRaidSearch; alias?: Alias; - name?: MdRaidName; + name?: BaseName; level?: MDLevel; parity?: MDParity; chunkSize?: SizeValue; diff --git a/web/src/api/storage/proposal.ts b/web/src/api/storage/proposal.ts index 1b3bdf0c01..ee3d33062b 100644 --- a/web/src/api/storage/proposal.ts +++ b/web/src/api/storage/proposal.ts @@ -97,12 +97,17 @@ export interface Md { export interface Multipath { wireNames: string[]; } +export type PartitionSlot = { + start: number; + size: number; +}; export interface PartitionTable { type: "gpt" | "msdos" | "dasd"; - unusedSlots: number[][]; + unusedSlots: PartitionSlot[]; } export interface Partition { efi: boolean; + start: number; } export interface VolumeGroup { size: number; diff --git a/web/src/api/storage/system.ts b/web/src/api/storage/system.ts index d35c77a155..9c70bfa588 100644 --- a/web/src/api/storage/system.ts +++ b/web/src/api/storage/system.ts @@ -130,12 +130,17 @@ export interface Md { export interface Multipath { wireNames: string[]; } +export type PartitionSlot = { + start: number; + size: number; +}; export interface PartitionTable { type: "gpt" | "msdos" | "dasd"; - unusedSlots: number[][]; + unusedSlots: PartitionSlot[]; } export interface Partition { efi: boolean; + start: number; } export interface VolumeGroup { size: number; diff --git a/web/src/api/users.ts b/web/src/api/users.ts index ae8db6e116..da9c19bff5 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +// @todo Move to the new API. + import { AxiosResponse } from "axios"; import { del, get, patch, post, put } from "~/http"; import { FirstUser, PasswordCheckResult, RootUser } from "~/types/users"; diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx index 806a0ab80c..044921d0d5 100644 --- a/web/src/components/overview/L10nSection.tsx +++ b/web/src/components/overview/L10nSection.tsx @@ -27,10 +27,11 @@ import { _ } from "~/i18n"; import { Locale } from "~/api/l10n/system"; export default function L10nSection() { - const { l10n: l10nProposal } = useProposal(); - const { l10n: l10nSystem } = useSystem(); + const proposal = useProposal({ suspense: true }); + const system = useSystem({ suspense: true }); const locale = - l10nProposal.locale && l10nSystem.locales.find((l: Locale) => l.id === l10nProposal.locale); + proposal?.l10n?.locale && + system?.l10n?.locales?.find((l: Locale) => l.id === proposal.l10n.locale); // TRANSLATORS: %s will be replaced by a language name and territory, example: // "English (United States)". diff --git a/web/src/components/storage/AutoSizeText.tsx b/web/src/components/storage/AutoSizeText.tsx index 03b7a050d8..83403ed754 100644 --- a/web/src/components/storage/AutoSizeText.tsx +++ b/web/src/components/storage/AutoSizeText.tsx @@ -26,7 +26,8 @@ import { SubtleContent } from "~/components/core/"; import { deviceSize } from "~/components/storage/utils"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { apiModel, Volume } from "~/api/storage/types"; +import { apiModel } from "~/api/storage"; +import { Volume } from "~/api/storage/system"; type DeviceType = "partition" | "logicalVolume"; diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index f6201c7dba..895c466f3c 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -26,21 +26,23 @@ import { ActionGroup, Content, Form, FormGroup, Radio, Stack } from "@patternfly import { DevicesFormSelect } from "~/components/storage"; import { Page, SubtleContent } from "~/components/core"; import { deviceLabel } from "~/components/storage/utils"; -import { StorageDevice } from "~/types/storage"; +import { storage } from "~/api/system"; import { useCandidateDevices, useDevices } from "~/hooks/storage/system"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { useModel } from "~/hooks/storage/model"; +import { Model } from "~/types/storage/model"; +import { isDrive } from "~/helpers/storage/device"; import { useSetBootDevice, useSetDefaultBootDevice, useDisableBootConfig, } from "~/hooks/storage/boot"; -const filteredCandidates = (candidates, model): StorageDevice[] => { +const filteredCandidates = (candidates: storage.Device[], model: Model): storage.Device[] => { return candidates.filter((candidate) => { - const collection = candidate.isDrive ? model.drives : model.mdRaids; + const collection = isDrive(candidate) ? model.drives : model.mdRaids; const device = collection.find((d) => d.name === candidate.name); return !device || !device.filesystem; }); @@ -57,9 +59,9 @@ type BootSelectionState = { load: boolean; selectedOption?: string; configureBoot?: boolean; - bootDevice?: StorageDevice; - defaultBootDevice?: StorageDevice; - candidateDevices?: StorageDevice[]; + bootDevice?: storage.Device; + defaultBootDevice?: storage.Device; + candidateDevices?: storage.Device[]; }; /** diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index fe01608c0e..02e0fef9a3 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -31,14 +31,15 @@ import { useAddReusedMdRaid } from "~/hooks/storage/md-raid"; import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; -import { StorageDevice } from "~/types/storage"; +import { storage } from "~/api/system"; import DeviceSelectorModal from "./DeviceSelectorModal"; +import { isDrive } from "~/helpers/storage/device"; type AddDeviceMenuItemProps = { /** Whether some of the available devices is an MD RAID */ withRaids: boolean; /** Available devices to be chosen */ - devices: StorageDevice[]; + devices: storage.Device[]; /** The total amount of drives and RAIDs already configured */ usedCount: number; } & MenuItemProps; @@ -132,10 +133,10 @@ export default function ConfigureDeviceMenu(): React.ReactNode { const usedDevicesNames = model.drives.concat(model.mdRaids).map((d) => d.name); const usedDevicesCount = usedDevicesNames.length; const devices = allDevices.filter((d) => !usedDevicesNames.includes(d.name)); - const withRaids = !!allDevices.filter((d) => !d.isDrive).length; + const withRaids = !!allDevices.filter((d) => !isDrive(d)).length; - const addDevice = (device: StorageDevice) => { - const hook = device.isDrive ? addDrive : addReusedMdRaid; + const addDevice = (device: storage.Device) => { + const hook = isDrive(device) ? addDrive : addReusedMdRaid; hook({ name: device.name, spacePolicy: "keep" }); }; diff --git a/web/src/components/storage/DeviceEditorContent.tsx b/web/src/components/storage/DeviceEditorContent.tsx index 65f5143f83..1ffb5b7ae5 100644 --- a/web/src/components/storage/DeviceEditorContent.tsx +++ b/web/src/components/storage/DeviceEditorContent.tsx @@ -25,9 +25,10 @@ import UnusedMenu from "~/components/storage/UnusedMenu"; import FilesystemMenu from "~/components/storage/FilesystemMenu"; import PartitionsMenu from "~/components/storage/PartitionsMenu"; import SpacePolicyMenu from "~/components/storage/SpacePolicyMenu"; -import { model, StorageDevice } from "~/types/storage"; +import { model } from "~/types/storage"; +import { system } from "~/api/storage"; -type DeviceEditorContentProps = { deviceModel: model.Drive | model.MdRaid; device: StorageDevice }; +type DeviceEditorContentProps = { deviceModel: model.Drive | model.MdRaid; device: system.Device }; export default function DeviceEditorContent({ deviceModel, diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 197e577328..7b5f5accd4 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -27,7 +27,7 @@ import SelectableDataTable, { SortedBy, SelectableDataTableProps, } from "~/components/core/SelectableDataTable"; -import { StorageDevice } from "~/types/storage"; +import { storage } from "~/api/system"; import { typeDescription, contentDescription, @@ -36,29 +36,31 @@ import { import { deviceSize } from "~/components/storage/utils"; import { sortCollection } from "~/utils"; import { _ } from "~/i18n"; +import { deviceSystems } from "~/helpers/storage/device"; type DeviceSelectorProps = { - devices: StorageDevice[]; - selectedDevices?: StorageDevice[]; - onSelectionChange: SelectableDataTableProps["onSelectionChange"]; - selectionMode?: SelectableDataTableProps["selectionMode"]; + devices: storage.Device[]; + selectedDevices?: storage.Device[]; + onSelectionChange: SelectableDataTableProps["onSelectionChange"]; + selectionMode?: SelectableDataTableProps["selectionMode"]; }; -const size = (device: StorageDevice) => { - return deviceSize(device.size); +const size = (device: storage.Device) => { + return deviceSize(device.block.size); }; -const description = (device: StorageDevice) => { - if (device.model && device.model.length) return device.model; +const description = (device: storage.Device) => { + const model = device.drive?.model; + if (model && model.length) return model; return typeDescription(device); }; -const details = (device: StorageDevice) => { +const details = (device: storage.Device) => { return ( {contentDescription(device)} - {device.systems.map((s, i) => ( + {deviceSystems(device).map((s, i) => ( @@ -82,7 +84,7 @@ const DeviceSelector = ({ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" }); const columns = [ - { name: _("Device"), value: (device: StorageDevice) => device.name, sortingKey: "name" }, + { name: _("Device"), value: (device: storage.Device) => device.name, sortingKey: "name" }, { name: _("Size"), value: size, @@ -114,9 +116,9 @@ const DeviceSelector = ({ }; type DeviceSelectorModalProps = Omit & { - selected?: StorageDevice; - devices: StorageDevice[]; - onConfirm: (selection: StorageDevice[]) => void; + selected?: storage.Device; + devices: storage.Device[]; + onConfirm: (selection: storage.Device[]) => void; onCancel: ButtonProps["onClick"]; }; @@ -128,7 +130,7 @@ export default function DeviceSelectorModal({ ...popupProps }: DeviceSelectorModalProps): React.ReactNode { // FIXME: improve initial selection handling - const [selectedDevices, setSelectedDevices] = useState( + const [selectedDevices, setSelectedDevices] = useState( selected ? [selected] : [devices[0]], ); diff --git a/web/src/components/storage/DeviceSelectorPage.tsx b/web/src/components/storage/DeviceSelectorPage.tsx index 33126af9b3..574c00427f 100644 --- a/web/src/components/storage/DeviceSelectorPage.tsx +++ b/web/src/components/storage/DeviceSelectorPage.tsx @@ -23,7 +23,7 @@ import React, { useState } from "react"; import { Content } from "@patternfly/react-core"; import { SelectableDataTable, Page } from "~/components/core/"; -import { StorageDevice } from "~/types/storage"; +import { storage } from "~/api/system"; import { useAvailableDevices } from "~/hooks/storage/system"; import { _ } from "~/i18n"; import { SelectableDataTableProps } from "../core/SelectableDataTable"; @@ -34,8 +34,8 @@ import { } from "~/components/storage/utils/device"; type DeviceSelectorProps = { - devices: StorageDevice[]; - selectedDevices?: StorageDevice[]; + devices: storage.Device[]; + selectedDevices?: storage.Device[]; onSelectionChange: SelectableDataTableProps["onSelectionChange"]; selectionMode?: SelectableDataTableProps["selectionMode"]; }; @@ -51,7 +51,7 @@ const DeviceSelector = ({ device.name }, + { name: _("Name"), value: (device: storage.Device) => device.name }, { name: _("Content"), value: contentDescription }, { name: _("Filesystems"), value: filesystemLabels }, ]} diff --git a/web/src/components/storage/DevicesFormSelect.jsx b/web/src/components/storage/DevicesFormSelect.tsx similarity index 57% rename from web/src/components/storage/DevicesFormSelect.jsx rename to web/src/components/storage/DevicesFormSelect.tsx index 3614339177..d3927f986a 100644 --- a/web/src/components/storage/DevicesFormSelect.jsx +++ b/web/src/components/storage/DevicesFormSelect.tsx @@ -20,34 +20,27 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; -import { FormSelect, FormSelectOption } from "@patternfly/react-core"; +import { FormSelectProps, FormSelect, FormSelectOption } from "@patternfly/react-core"; + import { deviceLabel } from "~/components/storage/utils"; +import { storage } from "~/api/system"; -/** - * @typedef {import ("@patternfly/react-core").FormSelectProps} PFFormSelectProps - * @typedef {import ("~/types/storage").StorageDevice} StorageDevice - */ +type DevicesFormSelectBaseProps = { + devices: storage.Device[]; + selectedDevice: storage.Device; + onChange: (device: storage.Device) => void; +}; -/** - * A PF/Select for simple device selection - * @component - * - * @example Simple usage - * import { devices, selected } from "somewhere"; - * - * - * - * @typedef {object} DevicesFormSelectBaseProps - * @property {StorageDevice[]} props.devices - Devices to show in the selector. - * @property {StorageDevice} [props.selectedDevice] - Currently selected device. In case of - * @property {(StorageDevice) => void} props.onChange - Callback to be called when the selection changes - * - * @param {DevicesFormSelectBaseProps & Omit} props - */ -export default function DevicesFormSelect({ devices, selectedDevice, onChange, ...otherProps }) { +type DevicesFormSelectProps = DevicesFormSelectBaseProps & + Omit; + +export default function DevicesFormSelect({ + devices, + selectedDevice, + onChange, + ...otherProps +}: DevicesFormSelectProps) { return ( /** @ts-expect-error: for some reason using otherProps makes TS complain */ d.sid === sid); } /** * Staging device with the given SID. - * @method - * - * @param {Number} sid - * @returns {StorageDevice|undefined} */ - stagingDevice(sid) { - return this.#device(sid, this.staging); + stagingDevice(sid: number): proposal.Device { + return this.staging.find((d) => d.sid === sid); } /** * Whether the given device exists in system. - * @method - * - * @param {StorageDevice} device - * @returns {Boolean} */ - existInSystem(device) { - return this.#exist(device, this.system); + existInSystem(device: system.Device): boolean { + return this.system.find((d) => d.sid === device.sid) !== undefined; } /** * Whether the given device exists in staging. - * @method - * - * @param {StorageDevice} device - * @returns {Boolean} */ - existInStaging(device) { - return this.#exist(device, this.staging); + existInStaging(device: proposal.Device): boolean { + return this.staging.find((d) => d.sid === device.sid) !== undefined; } /** * Whether the given device is going to be formatted. - * @method - * - * @param {StorageDevice} device - * @returns {Boolean} */ - hasNewFilesystem(device) { + hasNewFilesystem(device: proposal.Device): boolean { if (!device.filesystem) return false; const systemDevice = this.systemDevice(device.sid); @@ -109,46 +87,37 @@ export default class DevicesManager { /** * Whether the given device is going to be shrunk. - * @method - * - * @param {StorageDevice} device - * @returns {Boolean} */ - isShrunk(device) { + isShrunk(device: proposal.Device): boolean { return this.shrinkSize(device) > 0; } /** * Amount of bytes the given device is going to be shrunk. - * @method - * - * @param {StorageDevice} device - * @returns {Number} */ - shrinkSize(device) { + shrinkSize(device: proposal.Device): number { const systemDevice = this.systemDevice(device.sid); const stagingDevice = this.stagingDevice(device.sid); if (!systemDevice || !stagingDevice) return 0; - const amount = systemDevice.size - stagingDevice.size; + const amount = systemDevice.block.size - stagingDevice.block.size; return amount > 0 ? amount : 0; } /** * Disk devices and LVM volume groups used for the installation. - * @method * * @note The used devices are extracted from the actions, but the optional argument * can be used to expand the list if some devices must be included despite not * being affected by the actions. * - * @param {string[]} knownNames - names of devices already known to be used, even if - * there are no actions on them - * @returns {StorageDevice[]} + * @param knownNames - names of devices already known to be used, even if there are no actions on + * them. */ - usedDevices(knownNames = []) { - const isTarget = (device) => device.isDrive || ["md", "lvmVg"].includes(device.type); + usedDevices(knownNames: string[] = []): proposal.Device[] { + const isTarget = (device: system.Device | proposal.Device): boolean => + isDrive(device) || isMd(device) || isVolumeGroup(device); // Check in system devices to detect removals. const targetSystem = this.system.filter(isTarget); @@ -164,82 +133,48 @@ export default class DevicesManager { /** * Devices deleted. - * @method * * @note The devices are extracted from the actions. - * - * @returns {StorageDevice[]} */ - deletedDevices() { - return this.#deleteActionsDevice().filter((d) => !d.isDrive); + deletedDevices(): system.Device[] { + return this.#deleteActionsDevice().filter((d) => !d.drive); } /** * Devices resized. - * @method * * @note The devices are extracted from the actions. - * - * @returns {StorageDevice[]} */ - resizedDevices() { - return this.#resizeActionsDevice().filter((d) => !d.isDrive); + resizedDevices(): system.Device[] { + return this.#resizeActionsDevice().filter((d) => !d.drive); } /** * Systems deleted. - * @method - * - * @returns {string[]} */ - deletedSystems() { + deletedSystems(): string[] { const systems = this.#deleteActionsDevice() .filter((d) => !d.partitionTable) - .map((d) => d.systems) + .map(deviceSystems) .flat(); return compact(systems); } /** * Systems resized. - * @method - * - * @returns {string[]} */ - resizedSystems() { + resizedSystems(): string[] { const systems = this.#resizeActionsDevice() .filter((d) => !d.partitionTable) - .map((d) => d.systems) + .map(deviceSystems) .flat(); return compact(systems); } - /** - * @param {number} sid - * @param {StorageDevice[]} source - * @returns {StorageDevice|undefined} - */ - #device(sid, source) { - return source.find((d) => d.sid === sid); - } - - /** - * @param {StorageDevice} device - * @param {StorageDevice[]} source - * @returns {boolean} - */ - #exist(device, source) { - return this.#device(device.sid, source) !== undefined; - } - - /** - * @param {StorageDevice} device - * @returns {boolean} - */ - #isUsed(device) { + #isUsed(device: system.Device | proposal.Device): boolean { const sids = unique(compact(this.actions.map((a) => a.device))); - const partitions = device.partitionTable?.partitions || []; + const partitions = device.partitions || []; const lvmLvs = device.logicalVolumes || []; return ( @@ -249,19 +184,13 @@ export default class DevicesManager { ); } - /** - * @returns {StorageDevice[]} - */ - #deleteActionsDevice() { + #deleteActionsDevice(): system.Device[] { const sids = this.actions.filter((a) => a.delete).map((a) => a.device); const devices = sids.map((sid) => this.systemDevice(sid)); return compact(devices); } - /** - * @returns {StorageDevice[]} - */ - #resizeActionsDevice() { + #resizeActionsDevice(): system.Device[] { const sids = this.actions.filter((a) => a.resize).map((a) => a.device); const devices = sids.map((sid) => this.systemDevice(sid)); return compact(devices); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index d2147ce5c2..15d8453c30 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -25,13 +25,13 @@ import ConfigEditorItem from "~/components/storage/ConfigEditorItem"; import DriveHeader from "~/components/storage/DriveHeader"; import DeviceEditorContent from "~/components/storage/DeviceEditorContent"; import SearchedDeviceMenu from "~/components/storage/SearchedDeviceMenu"; -import { Drive } from "~/types/storage/model"; -import { model, StorageDevice } from "~/types/storage"; +import { model } from "~/types/storage"; +import { storage } from "~/api/system"; import { useDeleteDrive } from "~/hooks/storage/drive"; type DriveDeviceMenuProps = { drive: model.Drive; - selected: StorageDevice; + selected: storage.Device; }; /** @@ -44,7 +44,7 @@ const DriveDeviceMenu = ({ drive, selected }: DriveDeviceMenuProps) => { return ; }; -export type DriveEditorProps = { drive: Drive; driveDevice: StorageDevice }; +export type DriveEditorProps = { drive: model.Drive; driveDevice: storage.Device }; /** * Component responsible for displaying detailed information and available actions diff --git a/web/src/components/storage/DriveHeader.tsx b/web/src/components/storage/DriveHeader.tsx index 266c642a83..ad0cd0b8f3 100644 --- a/web/src/components/storage/DriveHeader.tsx +++ b/web/src/components/storage/DriveHeader.tsx @@ -20,12 +20,13 @@ * find current contact information at www.suse.com. */ -import { model, StorageDevice } from "~/types/storage"; +import { model } from "~/types/storage"; +import { storage } from "~/api/system"; import { sprintf } from "sprintf-js"; import { deviceLabel } from "./utils"; import { _ } from "~/i18n"; -export type DriveHeaderProps = { drive: model.Drive; device: StorageDevice }; +export type DriveHeaderProps = { drive: model.Drive; device: storage.Device }; const text = (drive: model.Drive): string => { if (drive.filesystem) { diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx index edb1087930..753c9a9f53 100644 --- a/web/src/components/storage/EncryptionSection.tsx +++ b/web/src/components/storage/EncryptionSection.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Card, CardBody, Content } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; import { useEncryption } from "~/queries/storage/config-model"; -import { apiModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage"; import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; import PasswordCheck from "~/components/users/PasswordCheck"; diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index eaee72523b..5af666664b 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -36,7 +36,8 @@ import { } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/hooks/storage/system"; -import { StorageDevice, model, data } from "~/types/storage"; +import { model, data } from "~/types/storage"; +import { storage } from "~/api/system"; import { useModel } from "~/hooks/storage/model"; import { useVolumeGroup, @@ -48,19 +49,20 @@ import { contentDescription, filesystemLabels, typeDescription } from "./utils/d import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +import { deviceSystems, isDrive } from "~/helpers/storage/device"; /** * Hook that returns the devices that can be selected as target to automatically create LVM PVs. * * Filters out devices that are going to be directly formatted. */ -function useLvmTargetDevices(): StorageDevice[] { +function useLvmTargetDevices(): storage.Device[] { const availableDevices = useAvailableDevices(); const model = useModel({ suspense: true }); const targetDevices = useMemo(() => { return availableDevices.filter((candidate) => { - const collection = candidate.isDrive ? model.drives : model.mdRaids; + const collection = isDrive(candidate) ? model.drives : model.mdRaids; const device = collection.find((d) => d.name === candidate.name); return !device || !device.filesystem; }); @@ -81,7 +83,7 @@ function vgNameError( return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName); } -function targetDevicesError(targetDevices: StorageDevice[]): string | undefined { +function targetDevicesError(targetDevices: storage.Device[]): string | undefined { if (!targetDevices.length) return _("Select at least one disk."); } @@ -100,7 +102,7 @@ export default function LvmPage() { const editVolumeGroup = useEditVolumeGroup(); const allDevices = useLvmTargetDevices(); const [name, setName] = useState(""); - const [selectedDevices, setSelectedDevices] = useState([]); + const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); const [errors, setErrors] = useState([]); @@ -199,7 +201,7 @@ export default function LvmPage() { {s} ))} - {device.systems.map((s, i) => ( + {deviceSystems(device).map((s, i) => ( diff --git a/web/src/components/storage/MdRaidEditor.tsx b/web/src/components/storage/MdRaidEditor.tsx index 8d1465ac3e..c0741369ff 100644 --- a/web/src/components/storage/MdRaidEditor.tsx +++ b/web/src/components/storage/MdRaidEditor.tsx @@ -25,13 +25,13 @@ import ConfigEditorItem from "~/components/storage/ConfigEditorItem"; import MdRaidHeader from "~/components/storage/MdRaidHeader"; import DeviceEditorContent from "~/components/storage/DeviceEditorContent"; import SearchedDeviceMenu from "~/components/storage/SearchedDeviceMenu"; -import { model, StorageDevice } from "~/types/storage"; -import { MdRaid } from "~/types/storage/model"; +import { model } from "~/types/storage"; +import { storage } from "~/api/system"; import { useDeleteMdRaid } from "~/hooks/storage/md-raid"; type MdRaidDeviceMenuProps = { raid: model.MdRaid; - selected: StorageDevice; + selected: storage.Device; }; /** @@ -44,7 +44,7 @@ const MdRaidDeviceMenu = ({ raid, selected }: MdRaidDeviceMenuProps): React.Reac return ; }; -type MdRaidEditorProps = { raid: MdRaid; raidDevice: StorageDevice }; +type MdRaidEditorProps = { raid: model.MdRaid; raidDevice: storage.Device }; /** * Component responsible for displaying detailed information and available diff --git a/web/src/components/storage/MdRaidHeader.tsx b/web/src/components/storage/MdRaidHeader.tsx index 503c778088..79a4eaddf5 100644 --- a/web/src/components/storage/MdRaidHeader.tsx +++ b/web/src/components/storage/MdRaidHeader.tsx @@ -20,12 +20,13 @@ * find current contact information at www.suse.com. */ -import { model, StorageDevice } from "~/types/storage"; +import { model } from "~/types/storage"; +import { storage } from "~/api/system"; import { sprintf } from "sprintf-js"; import { deviceLabel } from "./utils"; import { _ } from "~/i18n"; -export type MdRaidHeaderProps = { raid: model.MdRaid; device: StorageDevice }; +export type MdRaidHeaderProps = { raid: model.MdRaid; device: storage.Device }; const text = (raid: model.MdRaid): string => { if (raid.filesystem) { diff --git a/web/src/components/storage/MenuDeviceDescription.tsx b/web/src/components/storage/MenuDeviceDescription.tsx index 6c6b92bf8e..612ad94c2b 100644 --- a/web/src/components/storage/MenuDeviceDescription.tsx +++ b/web/src/components/storage/MenuDeviceDescription.tsx @@ -27,7 +27,7 @@ import { contentDescription, filesystemLabels, } from "~/components/storage/utils/device"; -import { StorageDevice } from "~/types/storage"; +import { storage } from "~/api/system"; /** * Renders the content to be used at a menu entry describing a device. @@ -35,7 +35,7 @@ import { StorageDevice } from "~/types/storage"; * * @param device - Device to represent */ -export default function MenuDeviceDescription({ device }: { device: StorageDevice }) { +export default function MenuDeviceDescription({ device }: { device: storage.Device }) { return ( diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx index 4a667652d2..8d3860f919 100644 --- a/web/src/components/storage/MountPathMenuItem.tsx +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -25,7 +25,7 @@ import { useNavigate } from "react-router-dom"; import * as partitionUtils from "~/components/storage/utils/partition"; import { Icon } from "~/components/layout"; import { MenuItem, MenuItemAction } from "@patternfly/react-core"; -import { apiModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage"; export type MountPathMenuItemProps = { device: apiModel.Partition | apiModel.LogicalVolume; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 8c2d2d722a..7e4a5a9c28 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -204,7 +204,7 @@ function usePartition(target: string): system.Device | null { if (target === NEW_PARTITION) return null; - const partitions = device.partitionTable?.partitions || []; + const partitions = device.partitions || []; return partitions.find((p: system.Device) => p.name === target); } @@ -247,7 +247,7 @@ function useUnusedMountPoints(): string[] { /** Unused partitions. Includes the currently used partition when editing (if any). */ function useUnusedPartitions(): system.Device[] { const device = useDevice(); - const allPartitions = device.partitionTable?.partitions || []; + const allPartitions = device.partitions || []; const initialPartitionConfig = useInitialPartitionConfig(); const configuredPartitionConfigs = useModelDevice() .getConfiguredExistingPartitions() diff --git a/web/src/components/storage/ProposalActionsDialog.tsx b/web/src/components/storage/ProposalActionsDialog.tsx index 885d5e4e05..79b775874c 100644 --- a/web/src/components/storage/ProposalActionsDialog.tsx +++ b/web/src/components/storage/ProposalActionsDialog.tsx @@ -25,7 +25,7 @@ import { List, ListItem, ExpandableSection } from "@patternfly/react-core"; import { n_ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { fork } from "radashi"; -import { Action } from "~/types/storage"; +import { Action } from "~/api/storage/proposal"; const ActionsList = ({ actions }: { actions: Action[] }) => { // Some actions (e.g., deleting a LV) are reported as several actions joined by a line break diff --git a/web/src/components/storage/ProposalResultSection.tsx b/web/src/components/storage/ProposalResultSection.tsx index 61803f86e7..4db0f566b3 100644 --- a/web/src/components/storage/ProposalResultSection.tsx +++ b/web/src/components/storage/ProposalResultSection.tsx @@ -27,8 +27,8 @@ import DevicesManager from "~/components/storage/DevicesManager"; import ProposalResultTable from "~/components/storage/ProposalResultTable"; import { ProposalActionsDialog } from "~/components/storage"; import { _, n_, formatList } from "~/i18n"; -import { useDevices } from "~/hooks/storage/system"; -import { useActions } from "~/hooks/storage/proposal"; +import { useDevices as useSystemDevices } from "~/hooks/storage/system"; +import { useDevices as useProposalDevices, useActions } from "~/hooks/storage/proposal"; import { sprintf } from "sprintf-js"; /** @@ -115,8 +115,8 @@ export type ProposalResultSectionProps = { }; export default function ProposalResultSection({ isLoading = false }: ProposalResultSectionProps) { - const system = useDevices("system", { suspense: true }); - const staging = useDevices("result", { suspense: true }); + const system = useSystemDevices({ suspense: true }); + const staging = useProposalDevices({ suspense: true }); const actions = useActions(); const devicesManager = new DevicesManager(system, staging, actions); diff --git a/web/src/components/storage/ProposalResultTable.tsx b/web/src/components/storage/ProposalResultTable.tsx index 66e3cfc085..7aa02149bf 100644 --- a/web/src/components/storage/ProposalResultTable.tsx +++ b/web/src/components/storage/ProposalResultTable.tsx @@ -26,25 +26,25 @@ import { DeviceName, DeviceDetails, DeviceSize, - toStorageDevice, + toDevice, + toPartitionSlot, } from "~/components/storage/device-utils"; import DevicesManager from "~/components/storage/DevicesManager"; import { TreeTable } from "~/components/core"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { deviceChildren, deviceSize } from "~/components/storage/utils"; -import { PartitionSlot, StorageDevice } from "~/types/storage"; +import { proposal } from "~/api/storage"; import { TreeTableColumn } from "~/components/core/TreeTable"; -import { DeviceInfo } from "~/api/storage/types"; import { useConfigModel } from "~/queries/storage/config-model"; -type TableItem = StorageDevice | PartitionSlot; +type TableItem = proposal.Device | proposal.PartitionSlot; /** * @component */ const MountPoint = ({ item }: { item: TableItem }) => { - const device = toStorageDevice(item); + const device = toDevice(item); if (!(device && device.filesystem?.mountPath)) return null; @@ -62,7 +62,7 @@ const DeviceCustomDetails = ({ devicesManager: DevicesManager; }) => { const isNew = () => { - const device = toStorageDevice(item); + const device = toDevice(item); if (!device) return false; // FIXME New PVs over a disk is not detected as new. @@ -91,9 +91,11 @@ const DeviceCustomSize = ({ item: TableItem; devicesManager: DevicesManager; }) => { - const device = toStorageDevice(item); + const device = toDevice(item); const isResized = device && devicesManager.isShrunk(device); - const sizeBefore = isResized ? devicesManager.systemDevice(device.sid).size : item.size; + const sizeBefore = isResized + ? devicesManager.systemDevice(device.sid).block.size + : toPartitionSlot(item)?.size; return ( @@ -154,8 +156,8 @@ export default function ProposalResultTable({ devicesManager }: ProposalResultTa items={devices} expandedItems={devices} itemChildren={deviceChildren} - rowClassNames={(item: DeviceInfo) => { - if (!item.sid) return "dimmed-row"; + rowClassNames={(item: TableItem) => { + if (!toDevice(item)) return "dimmed-row"; }} className="proposal-result" /> diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index cb015a3855..94c5683534 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -28,15 +28,17 @@ import { useModel } from "~/hooks/storage/model"; import { useSwitchToDrive } from "~/hooks/storage/drive"; import { useSwitchToMdRaid } from "~/hooks/storage/md-raid"; import { deviceBaseName, formattedPath } from "~/components/storage/utils"; -import * as model from "~/types/storage/model"; -import { StorageDevice } from "~/types/storage"; +import { model } from "~/types/storage"; +import { Model } from "~/types/storage/model"; +import { storage } from "~/api/system"; import { sprintf } from "sprintf-js"; import { _, formatList } from "~/i18n"; import DeviceSelectorModal from "./DeviceSelectorModal"; import { MenuItemProps } from "@patternfly/react-core"; import { Icon } from "../layout"; +import { isDrive } from "~/helpers/storage/device"; -const baseName = (device: StorageDevice): string => deviceBaseName(device, true); +const baseName = (device: storage.Device): string => deviceBaseName(device, true); const useOnlyOneOption = (device: model.Drive | model.MdRaid): boolean => { if (device.filesystem && device.filesystem.reuse) return true; @@ -49,7 +51,7 @@ const useOnlyOneOption = (device: model.Drive | model.MdRaid): boolean => { type ChangeDeviceMenuItemProps = { modelDevice: model.Drive | model.MdRaid; - device: StorageDevice; + device: storage.Device; } & MenuItemProps; const ChangeDeviceTitle = ({ modelDevice }) => { @@ -259,11 +261,15 @@ const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.R ); }; -const targetDevices = (modelDevice, model, availableDevices): StorageDevice[] => { +const targetDevices = ( + modelDevice: model.Drive | model.MdRaid, + model: Model, + availableDevices: storage.Device[], +): storage.Device[] => { return availableDevices.filter((availableDev) => { if (modelDevice.name === availableDev.name) return true; - const collection = availableDev.isDrive ? model.drives : model.mdRaids; + const collection = isDrive(availableDev) ? model.drives : model.mdRaids; const device = collection.find((d) => d.name === availableDev.name); if (!device) return true; @@ -273,7 +279,7 @@ const targetDevices = (modelDevice, model, availableDevices): StorageDevice[] => export type SearchedDeviceMenuProps = { modelDevice: model.Drive | model.MdRaid; - selected: StorageDevice; + selected: storage.Device; deleteFn: (device: model.Drive | model.MdRaid) => void; }; @@ -290,13 +296,13 @@ export default function SearchedDeviceMenu({ const [isSelectorOpen, setIsSelectorOpen] = useState(false); const switchToDrive = useSwitchToDrive(); const switchToMdRaid = useSwitchToMdRaid(); - const changeTargetFn = (device: StorageDevice) => { - const hook = device.isDrive ? switchToDrive : switchToMdRaid; + const changeTargetFn = (device: storage.Device) => { + const hook = isDrive(device) ? switchToDrive : switchToMdRaid; hook(modelDevice.name, { name: device.name }); }; const devices = targetDevices(modelDevice, useModel(), useAvailableDevices()); - const onDeviceChange = ([drive]: StorageDevice[]) => { + const onDeviceChange = ([drive]: storage.Device[]) => { setIsSelectorOpen(false); changeTargetFn(drive); }; diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx index d4b97c3f26..a49cf43938 100644 --- a/web/src/components/storage/SpaceActionsTable.tsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -35,18 +35,19 @@ import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import { deviceSize, formattedPath } from "~/components/storage/utils"; -import { - DeviceName, - DeviceDetails, - DeviceSize, - toStorageDevice, -} from "~/components/storage/device-utils"; +import { DeviceName, DeviceDetails, DeviceSize, toDevice } from "~/components/storage/device-utils"; import { Icon } from "~/components/layout"; -import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { apiModel } from "~/api/storage/types"; +import { Device, PartitionSlot } from "~/api/storage/proposal"; +import { apiModel } from "~/api/storage"; import { TreeTableColumn } from "~/components/core/TreeTable"; import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table"; import { useConfigModel } from "~/queries/storage/config-model"; +import { isPartition } from "~/helpers/storage/device"; + +export type SpacePolicyAction = { + deviceName: string; + value: "delete" | "resizeIfNeeded"; +}; const isUsedPartition = (partition: apiModel.Partition): boolean => { return partition.filesystem !== undefined; @@ -67,8 +68,9 @@ const useReusedPartition = (name: string): apiModel.Partition | undefined => { * Info about the device. * @component */ -const DeviceInfoContent = ({ device }: { device: StorageDevice }) => { - const minSize = device.shrinking?.supported; +const DeviceInfoContent = ({ device }: { device: Device }) => { + // FIXME + const minSize = device.block?.shrinking?.min; const reused = useReusedPartition(device.name); if (reused) { @@ -79,20 +81,21 @@ const DeviceInfoContent = ({ device }: { device: StorageDevice }) => { } if (minSize) { - const recoverable = device.size - minSize; + const recoverable = device.block.size - minSize; return sprintf( _("Up to %s can be recovered by shrinking the device."), deviceSize(recoverable), ); } - const reasons = device.shrinking.unsupported; + // FXIME + const reasons = device.shrinking.unsupportedReasons; return ( <> {_("The device cannot be shrunk:")} - {reasons.map((reason, idx) => ( + {reasons.map((reason: string, idx: number) => ( {reason} ))} @@ -105,9 +108,9 @@ const DeviceInfoContent = ({ device }: { device: StorageDevice }) => { * @component * * @param {object} props - * @param {StorageDevice} props.device + * @param {Device} props.device */ -const DeviceInfo = ({ device }: { device: StorageDevice }) => { +const DeviceInfo = ({ device }: { device: Device }) => { return ( }>