diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index 2d7321fd89..a93441bcd8 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -1,5 +1,5 @@ use clap::{arg, Args, Subcommand}; -use home; + use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use std::fs; use std::fs::File; @@ -36,7 +36,7 @@ pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> { /// Reads stored token and returns it fn jwt() -> anyhow::Result { if let Some(file) = jwt_file() { - if let Ok(token) = read_line_from_file(&file.as_path()) { + if let Ok(token) = read_line_from_file(file.as_path()) { return Ok(token); } } @@ -93,7 +93,7 @@ impl Credentials for KnownCredentials { impl Credentials for FileCredentials { fn password(&self) -> io::Result { - read_line_from_file(&self.path.as_path()) + read_line_from_file(self.path.as_path()) } } @@ -119,7 +119,7 @@ fn read_line_from_file(path: &Path) -> io::Result { )); } - if let Ok(file) = File::open(&path) { + if let Ok(file) = File::open(path) { // cares only of first line, take everything. No comments // or something like that supported let raw = BufReader::new(file).lines().next(); diff --git a/rust/agama-lib/src/manager.rs b/rust/agama-lib/src/manager.rs index 2c4e3e9ada..ef7d9f7bc2 100644 --- a/rust/agama-lib/src/manager.rs +++ b/rust/agama-lib/src/manager.rs @@ -61,7 +61,7 @@ impl<'a> ManagerClient<'a> { /// Returns the current installation phase. pub async fn current_installation_phase(&self) -> Result { let phase = self.manager_proxy.current_installation_phase().await?; - Ok(phase.try_into()?) + phase.try_into() } /// Starts the probing process. diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 74fb587486..5a64753915 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -96,7 +96,7 @@ trait Questions1 { /// New method #[dbus_proxy(name = "New")] - fn new_quetion( + fn new_question( &self, class: &str, text: &str, @@ -122,6 +122,56 @@ trait Questions1 { fn set_interactive(&self, value: bool) -> zbus::Result<()>; } +#[dbus_proxy( + interface = "org.opensuse.Agama1.Questions.Generic", + default_service = "org.opensuse.Agama1", + default_path = "/org/opensuse/Agama1/Questions" +)] +trait GenericQuestion { + /// Answer property + #[dbus_proxy(property)] + fn answer(&self) -> zbus::Result; + #[dbus_proxy(property)] + fn set_answer(&self, value: &str) -> zbus::Result<()>; + + /// Class property + #[dbus_proxy(property)] + fn class(&self) -> zbus::Result; + + /// Data property + #[dbus_proxy(property)] + fn data(&self) -> zbus::Result>; + + /// DefaultOption property + #[dbus_proxy(property)] + fn default_option(&self) -> zbus::Result; + + /// Id property + #[dbus_proxy(property)] + fn id(&self) -> zbus::Result; + + /// Options property + #[dbus_proxy(property)] + fn options(&self) -> zbus::Result>; + + /// Text property + #[dbus_proxy(property)] + fn text(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama1.Questions.WithPassword", + default_service = "org.opensuse.Agama1", + default_path = "/org/opensuse/Agama1/Questions" +)] +trait QuestionWithPassword { + /// Password property + #[dbus_proxy(property)] + fn password(&self) -> zbus::Result; + #[dbus_proxy(property)] + fn set_password(&self, value: &str) -> zbus::Result<()>; +} + #[dbus_proxy(interface = "org.opensuse.Agama1.Issues", assume_defaults = true)] trait Issues { /// All property diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 46c14797d0..215250ede5 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -13,7 +13,7 @@ use tokio::sync::broadcast::channel; use tracing_subscriber::prelude::*; use utoipa::OpenApi; -const DEFAULT_WEB_UI_DIR: &'static str = "/usr/share/agama/web_ui"; +const DEFAULT_WEB_UI_DIR: &str = "/usr/share/agama/web_ui"; #[derive(Subcommand, Debug)] enum Commands { diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs index e130c01a46..afa4db98b6 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/l10n.rs @@ -40,9 +40,9 @@ impl Locale { #[dbus_interface(property)] fn set_locales(&mut self, locales: Vec) -> zbus::fdo::Result<()> { if locales.is_empty() { - return Err(zbus::fdo::Error::Failed(format!( - "The locales list cannot be empty" - ))); + return Err(zbus::fdo::Error::Failed( + "The locales list cannot be empty".to_string(), + )); } for loc in &locales { if !self.locales_db.exists(loc.as_str()) { diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index 5cb2a7f777..271a2bd846 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -153,7 +153,7 @@ async fn set_config( .output() .map_err(LocaleError::Commit)?; Command::new("/usr/bin/setxkbmap") - .arg(&ui_keymap) + .arg(ui_keymap) .env("DISPLAY", ":0") .output() .map_err(LocaleError::Commit)?; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index ce3ebe64d5..3564dd787b 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -77,8 +77,8 @@ pub async fn manager_stream( /// Sets up and returns the axum service for the manager module pub async fn manager_service(dbus: zbus::Connection) -> Result { - const DBUS_SERVICE: &'static str = "org.opensuse.Agama.Manager1"; - const DBUS_PATH: &'static str = "/org/opensuse/Agama/Manager1"; + const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Manager1"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; diff --git a/rust/agama-server/src/questions.rs b/rust/agama-server/src/questions.rs index 795029674c..64b510623d 100644 --- a/rust/agama-server/src/questions.rs +++ b/rust/agama-server/src/questions.rs @@ -5,6 +5,7 @@ use log; use zbus::{dbus_interface, fdo::ObjectManager, zvariant::ObjectPath, Connection}; mod answers; +pub mod web; #[derive(thiserror::Error, Debug)] pub enum QuestionsError { diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs new file mode 100644 index 0000000000..9cb1ca2a26 --- /dev/null +++ b/rust/agama-server/src/questions/web.rs @@ -0,0 +1,261 @@ +//! This module implements the web API for the questions module. +//! +//! The module offers two public functions: +//! +//! * `questions_service` which returns the Axum service. +//! * `questions_stream` which offers an stream that emits questions related signals. + +use crate::{error::Error, web::Event}; +use agama_lib::{ + error::ServiceError, + proxies::{GenericQuestionProxy, QuestionWithPasswordProxy}, +}; +use anyhow::Context; +use axum::{ + extract::{Path, State}, + routing::{get, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, pin::Pin}; +use tokio_stream::{Stream, StreamExt}; +use zbus::{ + fdo::ObjectManagerProxy, + names::{InterfaceName, OwnedInterfaceName}, + zvariant::{ObjectPath, OwnedObjectPath}, +}; + +// TODO: move to lib +#[derive(Clone)] +struct QuestionsClient<'a> { + connection: zbus::Connection, + objects_proxy: ObjectManagerProxy<'a>, +} + +impl<'a> QuestionsClient<'a> { + pub async fn new(dbus: zbus::Connection) -> Result { + let question_path = + OwnedObjectPath::from(ObjectPath::try_from("/org/opensuse/Agama1/Questions")?); + Ok(Self { + connection: dbus.clone(), + objects_proxy: ObjectManagerProxy::builder(&dbus) + .path(question_path)? + .destination("org.opensuse.Agama1")? + .build() + .await?, + }) + } + + pub async fn questions(&self) -> Result, ServiceError> { + let objects = self + .objects_proxy + .get_managed_objects() + .await + .context("failed to get managed object with Object Manager")?; + let mut result: Vec = Vec::with_capacity(objects.len()); + let password_interface = OwnedInterfaceName::from( + InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") + .context("Failed to create interface name for question with password")?, + ); + for (path, interfaces_hash) in objects.iter() { + if interfaces_hash.contains_key(&password_interface) { + result.push(self.create_question_with_password(path).await?) + } else { + result.push(self.create_generic_question(path).await?) + } + } + Ok(result) + } + + async fn create_generic_question( + &self, + path: &OwnedObjectPath, + ) -> Result { + let dbus_question = GenericQuestionProxy::builder(&self.connection) + .path(path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + let result = Question { + generic: GenericQuestion { + id: dbus_question.id().await?, + class: dbus_question.class().await?, + text: dbus_question.text().await?, + options: dbus_question.options().await?, + default_option: dbus_question.default_option().await?, + data: dbus_question.data().await?, + }, + with_password: None, + }; + + Ok(result) + } + + async fn create_question_with_password( + &self, + path: &OwnedObjectPath, + ) -> Result { + let dbus_question = QuestionWithPasswordProxy::builder(&self.connection) + .path(path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + let mut result = self.create_generic_question(path).await?; + result.with_password = Some(QuestionWithPassword { + password: dbus_question.password().await?, + }); + + Ok(result) + } + + pub async fn answer(&self, id: u32, answer: Answer) -> Result<(), ServiceError> { + let question_path = OwnedObjectPath::from( + ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) + .context("Failed to create dbus path")?, + ); + if let Some(password) = answer.with_password { + let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) + .path(&question_path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + dbus_password + .set_password(password.password.as_str()) + .await? + } + let dbus_generic = GenericQuestionProxy::builder(&self.connection) + .path(&question_path)? + .cache_properties(zbus::CacheProperties::No) + .build() + .await?; + dbus_generic + .set_answer(answer.generic.answer.as_str()) + .await?; + Ok(()) + } +} + +#[derive(Clone)] +struct QuestionsState<'a> { + questions: QuestionsClient<'a>, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Question { + generic: GenericQuestion, + with_password: Option, +} + +/// Facade of agama_lib::questions::GenericQuestion +/// For fields details see it. +/// Reason why it does not use directly GenericQuestion from lib +/// is that it contain both question and answer. It works for dbus +/// API which has both as attributes, but web API separate +/// question and its answer. So here it is split into GenericQuestion +/// and GenericAnswer +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GenericQuestion { + id: u32, + class: String, + text: String, + options: Vec, + default_option: String, + data: HashMap, +} + +/// Facade of agama_lib::questions::WithPassword +/// For fields details see it. +/// Reason why it does not use directly WithPassword from lib +/// is that it is not composition as used here, but more like +/// child of generic question and contain reference to Base. +/// Here for web API we want to have in json that separation that would +/// allow to compose any possible future specialization of question +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct QuestionWithPassword { + password: String, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Answer { + generic: GenericAnswer, + with_password: Option, +} + +/// Answer needed for GenericQuestion +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GenericAnswer { + answer: String, +} + +/// Answer needed for Password specific questions. +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PasswordAnswer { + password: String, +} +/// Sets up and returns the axum service for the questions module. +pub async fn questions_service(dbus: zbus::Connection) -> Result { + let questions = QuestionsClient::new(dbus.clone()).await?; + let state = QuestionsState { questions }; + let router = Router::new() + .route("/", get(list_questions)) + .route("/:id/answer", put(answer)) + .with_state(state); + Ok(router) +} + +pub async fn questions_stream( + dbus: zbus::Connection, +) -> Result + Send>>, Error> { + let question_path = OwnedObjectPath::from( + ObjectPath::try_from("/org/opensuse/Agama1/Questions") + .context("failed to create object path")?, + ); + let proxy = ObjectManagerProxy::builder(&dbus) + .path(question_path) + .context("Failed to create object manager path")? + .destination("org.opensuse.Agama1")? + .build() + .await + .context("Failed to create Object MAnager proxy")?; + let add_stream = proxy + .receive_interfaces_added() + .await? + .then(|_| async move { Event::QuestionsChanged }); + let remove_stream = proxy + .receive_interfaces_removed() + .await? + .then(|_| async move { Event::QuestionsChanged }); + let stream = StreamExt::merge(add_stream, remove_stream); + Ok(Box::pin(stream)) +} + +/// Returns the list of questions that waits for answer. +/// +/// * `state`: service state. +#[utoipa::path(get, path = "/questions", responses( + (status = 200, description = "List of open questions", body = Vec), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn list_questions( + State(state): State>, +) -> Result>, Error> { + Ok(Json(state.questions.questions().await?)) +} + +/// Provide answer to question. +/// +/// * `state`: service state. +/// * `questions_id`: id of question +/// * `answer`: struct with answer and possible other data needed for answer like password +#[utoipa::path(put, path = "/questions/:id/answer", responses( + (status = 200, description = "answer question"), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn answer( + State(state): State>, + Path(question_id): Path, + Json(answer): Json, +) -> Result<(), Error> { + state.questions.answer(question_id, answer).await?; + Ok(()) +} diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index f5c7bd6cb5..e8f7effb4e 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -112,9 +112,9 @@ fn reason_to_selected_by( /// Sets up and returns the axum service for the software module. pub async fn software_service(dbus: zbus::Connection) -> Result { - const DBUS_SERVICE: &'static str = "org.opensuse.Agama.Software1"; - const DBUS_PATH: &'static str = "/org/opensuse/Agama/Software1"; - const DBUS_PRODUCT_PATH: &'static str = "/org/opensuse/Agama/Software1/Product"; + const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; + const DBUS_PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; @@ -229,7 +229,7 @@ async fn get_config(State(state): State>) -> Result Res ) .await?, ); + stream.insert("questions", questions_stream(dbus.clone()).await?); stream.insert( "software-issues", issues_stream( diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index 5484111121..86b3fb1c49 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -109,7 +109,7 @@ async fn build_service_status_proxy<'a>( destination: &str, path: &str, ) -> Result, zbus::Error> { - let proxy = ServiceStatusProxy::builder(&dbus) + let proxy = ServiceStatusProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() @@ -205,7 +205,7 @@ impl<'a> Stream for ProgressStream<'a> { let pinned = self.project(); match pinned.inner.poll_next(cx) { Poll::Pending => Poll::Pending, - Poll::Ready(_change) => match Progress::from_cached_proxy(&pinned.proxy) { + Poll::Ready(_change) => match Progress::from_cached_proxy(pinned.proxy) { Some(progress) => { let event = Event::Progress { progress, @@ -224,7 +224,7 @@ async fn build_progress_proxy<'a>( destination: &str, path: &str, ) -> Result, zbus::Error> { - let proxy = ProgressProxy::builder(&dbus) + let proxy = ProgressProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() @@ -345,7 +345,7 @@ async fn build_issues_proxy<'a>( destination: &str, path: &str, ) -> Result, zbus::Error> { - let proxy = IssuesProxy::builder(&dbus) + let proxy = IssuesProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 34ded88acf..af8309be15 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -16,6 +16,8 @@ use utoipa::OpenApi; crate::manager::web::install_action, crate::manager::web::finish_action, crate::manager::web::installer_status, + crate::questions::web::list_questions, + crate::questions::web::answer, super::http::ping, ), components( @@ -30,6 +32,12 @@ use utoipa::OpenApi; schemas(crate::software::web::SoftwareConfig), schemas(crate::software::web::SoftwareProposal), schemas(crate::manager::web::InstallerStatus), + schemas(crate::questions::web::Question), + schemas(crate::questions::web::GenericQuestion), + schemas(crate::questions::web::QuestionWithPassword), + schemas(crate::questions::web::Answer), + schemas(crate::questions::web::GenericAnswer), + schemas(crate::questions::web::PasswordAnswer), schemas(super::http::PingResponse), ) )] diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 999e4d6619..255886a1e4 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -22,6 +22,7 @@ pub enum Event { id: String, }, PatternsChanged(HashMap), + QuestionsChanged, InstallationPhaseChanged { phase: InstallationPhase, }, diff --git a/rust/agama-server/tests/common/mod.rs b/rust/agama-server/tests/common/mod.rs index cd77539774..4525c96fa4 100644 --- a/rust/agama-server/tests/common/mod.rs +++ b/rust/agama-server/tests/common/mod.rs @@ -41,6 +41,12 @@ pub trait ServerState {} impl ServerState for Started {} impl ServerState for Stopped {} +impl Default for DBusServer { + fn default() -> Self { + Self::new() + } +} + impl DBusServer { pub fn new() -> Self { let uuid = Uuid::new_v4();