diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index a080ac4420..8838a6e434 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -20,6 +20,7 @@ use crate::{auth::AuthToken, error::ServiceError}; /// client.get("/questions").await /// } /// ``` +#[derive(Clone)] pub struct BaseHTTPClient { client: reqwest::Client, pub base_url: String, diff --git a/rust/agama-lib/src/manager.rs b/rust/agama-lib/src/manager.rs index ef7d9f7bc2..577af76ae7 100644 --- a/rust/agama-lib/src/manager.rs +++ b/rust/agama-lib/src/manager.rs @@ -1,5 +1,8 @@ //! This module implements the web API for the manager module. +pub mod http_client; +pub use http_client::ManagerHTTPClient; + use crate::error::ServiceError; use crate::proxies::ServiceStatusProxy; use crate::{ diff --git a/rust/agama-lib/src/manager/http_client.rs b/rust/agama-lib/src/manager/http_client.rs new file mode 100644 index 0000000000..9255efd738 --- /dev/null +++ b/rust/agama-lib/src/manager/http_client.rs @@ -0,0 +1,23 @@ +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; + +pub struct ManagerHTTPClient { + client: BaseHTTPClient, +} + +impl ManagerHTTPClient { + pub fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub fn new_with_base(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + pub async fn probe(&self) -> Result<(), ServiceError> { + // BaseHTTPClient did not anticipate POST without request body + // so we pass () which is rendered as `null` + self.client.post_void("/manager/probe_sync", &()).await + } +} diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs index c7a0a6582e..2903f10b4d 100644 --- a/rust/agama-lib/src/product.rs +++ b/rust/agama-lib/src/product.rs @@ -1,10 +1,13 @@ //! Implements support for handling the product settings mod client; +mod http_client; pub mod proxies; mod settings; mod store; -pub use client::{Product, ProductClient, RegistrationRequirement}; +pub use crate::software::model::RegistrationRequirement; +pub use client::{Product, ProductClient}; +pub use http_client::ProductHTTPClient; pub use settings::ProductSettings; pub use store::ProductStore; diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs index 3283678694..5740df2c2a 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; use crate::error::ServiceError; +use crate::software::model::RegistrationRequirement; use crate::software::proxies::SoftwareProductProxy; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use zbus::Connection; use super::proxies::RegistrationProxy; @@ -20,35 +21,6 @@ pub struct Product { pub icon: String, } -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -pub enum RegistrationRequirement { - /// Product does not require registration - NotRequired = 0, - /// Product has optional registration - Optional = 1, - /// It is mandatory to register the product - Mandatory = 2, -} - -impl TryFrom for RegistrationRequirement { - type Error = (); - - fn try_from(v: u32) -> Result { - match v { - x if x == RegistrationRequirement::NotRequired as u32 => { - Ok(RegistrationRequirement::NotRequired) - } - x if x == RegistrationRequirement::Optional as u32 => { - Ok(RegistrationRequirement::Optional) - } - x if x == RegistrationRequirement::Mandatory as u32 => { - Ok(RegistrationRequirement::Mandatory) - } - _ => Err(()), - } - } -} - /// D-Bus client for the software service #[derive(Clone)] pub struct ProductClient<'a> { diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs new file mode 100644 index 0000000000..8c6f01054b --- /dev/null +++ b/rust/agama-lib/src/product/http_client.rs @@ -0,0 +1,62 @@ +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, +} + +impl ProductHTTPClient { + pub fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub fn new_with_base(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + pub async fn get_software(&self) -> Result { + self.client.get("/software/config").await + } + + pub async fn set_software(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { + self.client.put_void("/software/config", config).await + } + + /// Returns the id of the selected product to install + pub async fn product(&self) -> Result { + let config = self.get_software().await?; + if let Some(product) = config.product { + Ok(product) + } else { + Ok("".to_owned()) + } + } + + /// Selects the product to install + pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { + let config = SoftwareConfig { + product: Some(product_id.to_owned()), + patterns: None, + }; + self.set_software(&config).await + } + + 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> { + // note RegistrationParams != RegistrationInfo, fun! + let params = RegistrationParams { + key: key.to_owned(), + email: email.to_owned(), + }; + + self.client.post("/software/registration", ¶ms).await + } +} diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs index b4674a3d9c..701444fe72 100644 --- a/rust/agama-lib/src/product/settings.rs +++ b/rust/agama-lib/src/product/settings.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProductSettings { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs index 5ed3e72745..1c3609e27d 100644 --- a/rust/agama-lib/src/product/store.rs +++ b/rust/agama-lib/src/product/store.rs @@ -1,33 +1,38 @@ //! Implements the store for the product settings. - -use super::{ProductClient, ProductSettings}; +use super::{ProductHTTPClient, ProductSettings}; use crate::error::ServiceError; -use crate::manager::ManagerClient; -use zbus::Connection; +use crate::manager::http_client::ManagerHTTPClient; /// Loads and stores the product settings from/to the D-Bus service. -pub struct ProductStore<'a> { - product_client: ProductClient<'a>, - manager_client: ManagerClient<'a>, +pub struct ProductStore { + product_client: ProductHTTPClient, + manager_client: ManagerHTTPClient, } -impl<'a> ProductStore<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { +impl ProductStore { + pub fn new() -> Result { Ok(Self { - product_client: ProductClient::new(connection.clone()).await?, - manager_client: ManagerClient::new(connection).await?, + product_client: ProductHTTPClient::new()?, + manager_client: ManagerHTTPClient::new()?, }) } + fn non_empty_string(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } + } + pub async fn load(&self) -> Result { let product = self.product_client.product().await?; - let registration_code = self.product_client.registration_code().await?; - let email = self.product_client.email().await?; + let registration_info = self.product_client.get_registration().await?; Ok(ProductSettings { id: Some(product), - registration_code: Some(registration_code), - registration_email: Some(email), + registration_code: Self::non_empty_string(registration_info.key), + registration_email: Self::non_empty_string(registration_info.email), }) } @@ -63,3 +68,118 @@ impl<'a> ProductStore<'a> { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::base_http_client::BaseHTTPClient; + use httpmock::prelude::*; + use std::error::Error; + use tokio::test; // without this, "error: async functions cannot be used for tests" + + fn product_store(mock_server_url: String) -> ProductStore { + let mut bhc = BaseHTTPClient::default(); + bhc.base_url = mock_server_url; + let p_client = ProductHTTPClient::new_with_base(bhc.clone()); + let m_client = ManagerHTTPClient::new_with_base(bhc); + ProductStore { + product_client: p_client, + manager_client: m_client, + } + } + + #[test] + async fn test_getting_product() -> Result<(), Box> { + let server = MockServer::start(); + let software_mock = server.mock(|when, then| { + when.method(GET).path("/api/software/config"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "patterns": {"xfce":true}, + "product": "Tumbleweed" + }"#, + ); + }); + let registration_mock = server.mock(|when, then| { + when.method(GET).path("/api/software/registration"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "key": "", + "email": "", + "requirement": "NotRequired" + }"#, + ); + }); + let url = server.url("/api"); + + let store = product_store(url); + let settings = store.load().await?; + + let expected = ProductSettings { + id: Some("Tumbleweed".to_owned()), + registration_code: None, + registration_email: None, + }; + // main assertion + assert_eq!(settings, expected); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + software_mock.assert(); + registration_mock.assert(); + Ok(()) + } + + #[test] + async fn test_setting_product_ok() -> Result<(), Box> { + let server = MockServer::start(); + // no product selected at first + let get_software_mock = server.mock(|when, then| { + when.method(GET).path("/api/software/config"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "patterns": {}, + "product": "" + }"#, + ); + }); + let software_mock = server.mock(|when, then| { + when.method(PUT) + .path("/api/software/config") + .header("content-type", "application/json") + .body(r#"{"patterns":null,"product":"Tumbleweed"}"#); + then.status(200); + }); + let manager_mock = server.mock(|when, then| { + when.method(POST) + .path("/api/manager/probe_sync") + .header("content-type", "application/json") + .body("null"); + then.status(200); + }); + let url = server.url("/api"); + + let store = product_store(url); + let settings = ProductSettings { + id: Some("Tumbleweed".to_owned()), + registration_code: None, + registration_email: None, + }; + + let result = store.store(&settings).await; + + // main assertion + result?; + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + get_software_mock.assert(); + software_mock.assert(); + manager_mock.assert(); + Ok(()) + } +} diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs index 868afe8097..e6ed9573d2 100644 --- a/rust/agama-lib/src/software/model.rs +++ b/rust/agama-lib/src/software/model.rs @@ -9,3 +9,54 @@ pub struct SoftwareConfig { /// Name of the product to install. pub product: Option, } + +/// Software service configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RegistrationParams { + /// Registration key. + pub key: String, + /// Registration email. + pub email: String, +} + +/// Information about registration configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationInfo { + /// Registration key. Empty value mean key not used or not registered. + pub key: String, + /// Registration email. Empty value mean email not used or not registered. + pub email: String, + /// if registration is required, optional or not needed for current product. + /// Change only if selected product is changed. + pub requirement: RegistrationRequirement, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub enum RegistrationRequirement { + /// Product does not require registration + NotRequired = 0, + /// Product has optional registration + Optional = 1, + /// It is mandatory to register the product + Mandatory = 2, +} + +impl TryFrom for RegistrationRequirement { + type Error = (); + + fn try_from(v: u32) -> Result { + match v { + x if x == RegistrationRequirement::NotRequired as u32 => { + Ok(RegistrationRequirement::NotRequired) + } + x if x == RegistrationRequirement::Optional as u32 => { + Ok(RegistrationRequirement::Optional) + } + x if x == RegistrationRequirement::Mandatory as u32 => { + Ok(RegistrationRequirement::Mandatory) + } + _ => Err(()), + } + } +} diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 036d6cfc9b..8a043e6dcb 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -18,7 +18,7 @@ use zbus::Connection; pub struct Store<'a> { users: UsersStore, network: NetworkStore, - product: ProductStore<'a>, + product: ProductStore, software: SoftwareStore, storage: StorageStore<'a>, localization: LocalizationStore, @@ -33,7 +33,7 @@ impl<'a> Store<'a> { localization: LocalizationStore::new()?, users: UsersStore::new()?, network: NetworkStore::new(http_client).await?, - product: ProductStore::new(connection.clone()).await?, + product: ProductStore::new()?, software: SoftwareStore::new()?, storage: StorageStore::new(connection).await?, }) diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 79d87c4f2c..fbf5aea530 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -91,6 +91,7 @@ pub async fn manager_service(dbus: zbus::Connection) -> Result Result(State(state): State>) -> Result<(), E Ok(()) } -/// Starts the probing process. +/// Starts the probing process and waits until it is done. +/// We need this because the CLI (agama_lib::Store) only does sync calls. #[utoipa::path( - get, + post, + path = "/probe_sync", + context_path = "/api/manager", + responses( + (status = 200, description = "Probing done.") + ) +)] +async fn probe_sync_action(State(state): State>) -> Result<(), Error> { + state.manager.probe().await?; + Ok(()) +} + +/// Starts the installation process. +#[utoipa::path( + post, path = "/install", context_path = "/api/manager", responses( @@ -150,7 +166,7 @@ async fn install_action(State(state): State>) -> Result<(), Err /// Executes the post installation tasks (e.g., rebooting the system). #[utoipa::path( - get, + post, path = "/install", context_path = "/api/manager", responses( diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index ab8526b151..baeefc4200 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -12,11 +12,12 @@ use crate::{ Event, }, }; + use agama_lib::{ error::ServiceError, - product::{proxies::RegistrationProxy, Product, ProductClient, RegistrationRequirement}, + product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ - model::SoftwareConfig, + model::{RegistrationInfo, RegistrationParams, SoftwareConfig}, proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, }, @@ -229,19 +230,6 @@ async fn products(State(state): State>) -> Result + +- For CLI, use HTTP clients instead of D-Bus clients, + for Product (name and registration) (gh#openSUSE/agama#1548) + - added ProductHTTPClient + ------------------------------------------------------------------- Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman diff --git a/setup-services.sh b/setup-services.sh index 5e3f82e8e1..c573ea566e 100755 --- a/setup-services.sh +++ b/setup-services.sh @@ -112,7 +112,7 @@ fi # we are in a container, told to use that one # instead of a released version # edit +Gemfile and -gemspec - sed -e '/ruby-dbus/d' -i Gemfile agama.gemspec + sed -e '/ruby-dbus/d' -i Gemfile agama-yast.gemspec sed -e '/gemspec/a gem "ruby-dbus", path: "/checkout-ruby-dbus"' -i Gemfile fi @@ -146,6 +146,8 @@ $SUDO $ZYPPER install \ ( cd $MYDIR/rust cargo build + + ln -st /usr/bin $MYDIR/rust/target/debug/agama{,*server} ) # - D-Bus configuration diff --git a/testing_using_container.sh b/testing_using_container.sh index 988d3d8fd4..715a14ac11 100755 --- a/testing_using_container.sh +++ b/testing_using_container.sh @@ -44,7 +44,10 @@ podman run --name ${CNAME?} \ # shortcut for the following CEXEC="podman exec ${CNAME?} bash -c" -${CEXEC?} "cd /checkout && ./setup.sh" +# Set it up +# but continue in case of failure, the remaining steps (password and shell) +# are useful even then +${CEXEC?} "cd /checkout && ./setup.sh || true" echo "Set the Agama (root) password:" podman exec -it ${CNAME?} passwd