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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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(())) +}