diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8bef766f82..cc576c6eaf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -112,6 +112,7 @@ dependencies = [ "tokio", "tokio-stream", "url", + "utoipa", "zbus", ] diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 5ef8166609..40702e2fde 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -21,4 +21,5 @@ thiserror = "1.0.39" tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.14" url = "2.5.0" +utoipa = "4.2.0" zbus = { version = "3", default-features = false, features = ["tokio"] } diff --git a/rust/agama-lib/src/product.rs b/rust/agama-lib/src/product.rs index e85173f6c9..8b352f602d 100644 --- a/rust/agama-lib/src/product.rs +++ b/rust/agama-lib/src/product.rs @@ -5,6 +5,6 @@ mod proxies; mod settings; mod store; -pub use client::ProductClient; +pub use client::{Product, ProductClient}; 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 106166b621..2f4e18f432 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -8,7 +8,7 @@ use zbus::Connection; use super::proxies::RegistrationProxy; /// Represents a software product -#[derive(Debug, Serialize)] +#[derive(Default, Debug, Serialize, utoipa::ToSchema)] pub struct Product { /// Product ID (eg., "ALP", "Tumbleweed", etc.) pub id: String, @@ -19,6 +19,7 @@ pub struct Product { } /// D-Bus client for the software service +#[derive(Clone)] pub struct ProductClient<'a> { product_proxy: SoftwareProductProxy<'a>, registration_proxy: RegistrationProxy<'a>, diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs index 53f2d6bf51..9231b0e5ba 100644 --- a/rust/agama-lib/src/software.rs +++ b/rust/agama-lib/src/software.rs @@ -5,6 +5,6 @@ pub mod proxies; mod settings; mod store; -pub use client::SoftwareClient; +pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; pub use settings::SoftwareSettings; pub use store::SoftwareStore; diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 0670467b02..4769976847 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -1,10 +1,11 @@ use super::proxies::Software1Proxy; use crate::error::ServiceError; use serde::Serialize; +use std::collections::HashMap; use zbus::Connection; /// Represents a software product -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct Pattern { /// Pattern ID (eg., "aaa_base", "gnome") pub id: String, @@ -20,7 +21,35 @@ pub struct Pattern { pub order: String, } +/// Represents the reason why a pattern is selected. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum SelectedBy { + /// The pattern was selected by the user. + User = 0, + /// The pattern was selected automatically. + Auto = 1, + /// The pattern has not be selected. + None = 2, +} + +#[derive(Debug, thiserror::Error)] +#[error("Unknown selected by value: '{0}'")] +pub struct UnknownSelectedBy(u8); + +impl TryFrom for SelectedBy { + type Error = UnknownSelectedBy; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::User), + 1 => Ok(Self::Auto), + _ => Err(UnknownSelectedBy(value)), + } + } +} + /// D-Bus client for the software service +#[derive(Clone)] pub struct SoftwareClient<'a> { software_proxy: Software1Proxy<'a>, } @@ -55,14 +84,35 @@ impl<'a> SoftwareClient<'a> { /// Returns the ids of patterns selected by user pub async fn user_selected_patterns(&self) -> Result, ServiceError> { - const USER_SELECTED: u8 = 0; let patterns: Vec = self .software_proxy .selected_patterns() .await? .into_iter() - .filter(|(_id, reason)| *reason == USER_SELECTED) - .map(|(id, _reason)| id) + .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { + Ok(reason) if reason == SelectedBy::User => Some(id), + Ok(_reason) => None, + Err(e) => { + log::warn!("Ignoring pattern {}. Error: {}", &id, e); + None + } + }) + .collect(); + Ok(patterns) + } + + /// Returns the selected pattern and the reason each one selected. + pub async fn selected_patterns(&self) -> Result, ServiceError> { + let patterns = self.software_proxy.selected_patterns().await?; + let patterns = patterns + .into_iter() + .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { + Ok(reason) => Some((id, reason)), + Err(e) => { + log::warn!("Ignoring pattern {}. Error: {}", &id, e); + None + } + }) .collect(); Ok(patterns) } @@ -80,4 +130,16 @@ impl<'a> SoftwareClient<'a> { Ok(()) } } + + /// Returns the required space for installing the selected patterns. + /// + /// It returns a formatted string including the size and the unit. + pub async fn used_disk_space(&self) -> Result { + Ok(self.software_proxy.used_disk_space().await?) + } + + /// Starts the process to read the repositories data. + pub async fn probe(&self) -> Result<(), ServiceError> { + Ok(self.software_proxy.probe().await?) + } } diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index d8a16fc565..af96034b34 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -4,7 +4,8 @@ use agama_dbus_server::{ l10n::helpers, web::{self, run_monitor}, }; -use clap::{Parser, Subcommand}; +use agama_lib::connection_to; +use clap::{Args, Parser, Subcommand}; use tokio::sync::broadcast::channel; use tracing_subscriber::prelude::*; use utoipa::OpenApi; @@ -12,16 +13,22 @@ use utoipa::OpenApi; #[derive(Subcommand, Debug)] enum Commands { /// Start the API server. - Serve { - // Address to listen on (":::3000" listens for both IPv6 and IPv4 - // connections unless manually disabled in /proc/sys/net/ipv6/bindv6only) - #[arg(long, default_value = ":::3000")] - address: String, - }, + Serve(ServeArgs), /// Display the API documentation in OpenAPI format. Openapi, } +#[derive(Debug, Args)] +pub struct ServeArgs { + // Address to listen on (":::3000" listens for both IPv6 and IPv4 + // connections unless manually disabled in /proc/sys/net/ipv6/bindv6only) + #[arg(long, default_value = ":::3000")] + address: String, + // Agama D-Bus address + #[arg(long, default_value = "unix:path=/run/agama/bus")] + dbus_address: String, +} + #[derive(Parser, Debug)] #[command( version, @@ -33,22 +40,26 @@ struct Cli { } /// Start serving the API. -async fn serve_command(address: &str) -> anyhow::Result<()> { +/// +/// `args`: command-line arguments. +async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { let journald = tracing_journald::layer().expect("could not connect to journald"); tracing_subscriber::registry().with(journald).init(); - let listener = tokio::net::TcpListener::bind(address) + let listener = tokio::net::TcpListener::bind(&args.address) .await - .unwrap_or_else(|_| panic!("could not listen on {}", address)); + .unwrap_or_else(|_| panic!("could not listen on {}", &args.address)); let (tx, _) = channel(16); run_monitor(tx.clone()).await?; - let config = web::ServiceConfig::load().unwrap(); - let service = web::service(config, tx); + let config = web::ServiceConfig::load()?; + let dbus = connection_to(&args.dbus_address).await?; + let service = web::service(config, tx, dbus).await?; axum::serve(listener, service) .await .expect("could not mount app on listener"); + Ok(()) } @@ -60,7 +71,7 @@ fn openapi_command() -> anyhow::Result<()> { async fn run_command(cli: Cli) -> anyhow::Result<()> { match cli.command { - Commands::Serve { address } => serve_command(&address).await, + Commands::Serve(args) => serve_command(args).await, Commands::Openapi => openapi_command(), } } diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 421b9e3efd..1a5c30c1a2 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -2,5 +2,6 @@ pub mod error; pub mod l10n; pub mod network; pub mod questions; +pub mod software; pub mod web; pub use web::service; diff --git a/rust/agama-server/src/software.rs b/rust/agama-server/src/software.rs new file mode 100644 index 0000000000..b882542d90 --- /dev/null +++ b/rust/agama-server/src/software.rs @@ -0,0 +1,2 @@ +pub mod web; +pub use web::{software_service, software_stream}; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs new file mode 100644 index 0000000000..d8575a3339 --- /dev/null +++ b/rust/agama-server/src/software/web.rs @@ -0,0 +1,266 @@ +//! This module implements the web API for the software module. +//! +//! The module offers two public functions: +//! +//! * `software_service` which returns the Axum service. +//! * `software_stream` which offers an stream that emits the software events coming from D-Bus. + +use crate::{error::Error, web::Event}; +use agama_lib::{ + error::ServiceError, + product::{Product, ProductClient}, + software::{ + proxies::{Software1Proxy, SoftwareProductProxy}, + Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, + }, +}; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; +use thiserror::Error; +use tokio_stream::{Stream, StreamExt}; + +#[derive(Clone)] +struct SoftwareState<'a> { + product: ProductClient<'a>, + software: SoftwareClient<'a>, +} + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SoftwareConfig { + patterns: Option>, + product: Option, +} + +#[derive(Error, Debug)] +pub enum SoftwareError { + #[error("Software service error: {0}")] + Error(#[from] ServiceError), +} + +impl IntoResponse for SoftwareError { + fn into_response(self) -> Response { + let body = json!({ + "error": self.to_string() + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() + } +} + +/// Returns an stream that emits software related events coming from D-Bus. +/// +/// * `connection`: D-Bus connection to listen for events. +pub async fn software_stream(dbus: zbus::Connection) -> Result, Error> { + Ok(StreamExt::merge( + product_changed_stream(dbus.clone()).await?, + patterns_changed_stream(dbus.clone()).await?, + )) +} + +async fn product_changed_stream( + dbus: zbus::Connection, +) -> Result, Error> { + let proxy = SoftwareProductProxy::new(&dbus).await?; + let stream = proxy + .receive_selected_product_changed() + .await + .then(|change| async move { + if let Ok(id) = change.get().await { + return Some(Event::ProductChanged { id }); + } + None + }) + .filter_map(|e| e); + Ok(stream) +} + +async fn patterns_changed_stream( + dbus: zbus::Connection, +) -> Result, Error> { + let proxy = Software1Proxy::new(&dbus).await?; + let stream = proxy + .receive_selected_patterns_changed() + .await + .then(|change| async move { + if let Ok(patterns) = change.get().await { + return match reason_to_selected_by(patterns) { + Ok(patterns) => Some(patterns), + Err(error) => { + log::warn!("Ignoring the list of changed patterns. Error: {}", error); + None + } + }; + } + None + }) + .filter_map(|e| e.map(Event::PatternsChanged)); + Ok(stream) +} + +// Returns a hash replacing the selection "reason" from D-Bus with a SelectedBy variant. +fn reason_to_selected_by( + patterns: HashMap, +) -> Result, UnknownSelectedBy> { + let mut selected: HashMap = HashMap::new(); + for (id, reason) in patterns { + match SelectedBy::try_from(reason) { + Ok(selected_by) => selected.insert(id, selected_by), + Err(e) => return Err(e), + }; + } + Ok(selected) +} + +/// Sets up and returns the axum service for the software module. +pub async fn software_service(dbus: zbus::Connection) -> Result { + let product = ProductClient::new(dbus.clone()).await?; + let software = SoftwareClient::new(dbus).await?; + let state = SoftwareState { product, software }; + let router = Router::new() + .route("/patterns", get(patterns)) + .route("/products", get(products)) + .route("/proposal", get(proposal)) + .route("/config", put(set_config).get(get_config)) + .route("/probe", post(probe)) + .with_state(state); + Ok(router) +} + +/// Returns the list of available products. +/// +/// * `state`: service state. +#[utoipa::path(get, path = "/software/products", responses( + (status = 200, description = "List of known products", body = Vec), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn products( + State(state): State>, +) -> Result>, SoftwareError> { + let products = state.product.products().await?; + Ok(Json(products)) +} + +/// Represents a pattern. +/// +/// It augments the information coming from the D-Bus client. +#[derive(Serialize, utoipa::ToSchema)] +pub struct PatternEntry { + #[serde(flatten)] + pattern: Pattern, + selected_by: SelectedBy, +} + +/// Returns the list of software patterns. +/// +/// * `state`: service state. +#[utoipa::path(get, path = "/software/patterns", responses( + (status = 200, description = "List of known software patterns", body = Vec), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn patterns( + State(state): State>, +) -> Result>, SoftwareError> { + let patterns = state.software.patterns(true).await?; + let selected = state.software.selected_patterns().await?; + let items = patterns + .into_iter() + .map(|pattern| { + let selected_by: SelectedBy = selected + .get(&pattern.id) + .copied() + .unwrap_or(SelectedBy::None); + PatternEntry { + pattern, + selected_by, + } + }) + .collect(); + + Ok(Json(items)) +} + +/// Sets the software configuration. +/// +/// * `state`: service state. +/// * `config`: software configuration. +#[utoipa::path(put, path = "/software/config", responses( + (status = 200, description = "Set the software configuration"), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn set_config( + State(state): State>, + Json(config): Json, +) -> Result<(), SoftwareError> { + if let Some(product) = config.product { + state.product.select_product(&product).await?; + } + + if let Some(patterns) = config.patterns { + state.software.select_patterns(&patterns).await?; + } + + Ok(()) +} + +/// Returns the software configuration. +/// +/// * `state` : service state. +#[utoipa::path(get, path = "/software/config", responses( + (status = 200, description = "Software configuration", body = SoftwareConfig), + (status = 400, description = "The D-Bus service could not perform the action") +))] +async fn get_config( + State(state): State>, +) -> Result, SoftwareError> { + let product = state.product.product().await?; + let patterns = state.software.user_selected_patterns().await?; + let config = SoftwareConfig { + patterns: Some(patterns), + product: Some(product), + }; + Ok(Json(config)) +} + +#[derive(Serialize, utoipa::ToSchema)] +/// Software proposal information. +pub struct SoftwareProposal { + /// Space required for installation. It is returned as a formatted string which includes + /// a number and a unit (e.g., "GiB"). + size: String, +} + +/// Returns the proposal information. +/// +/// At this point, only the required space is reported. +#[utoipa::path( + get, path = "/software/proposal", responses( + (status = 200, description = "Software proposal", body = SoftwareProposal) +))] +async fn proposal( + State(state): State>, +) -> Result, SoftwareError> { + let size = state.software.used_disk_space().await?; + let proposal = SoftwareProposal { size }; + Ok(Json(proposal)) +} + +/// Returns the proposal information. +/// +/// At this point, only the required space is reported. +#[utoipa::path( + post, path = "/software/probe", responses( + (status = 200, description = "Read repositories data"), + (status = 400, description = "The D-Bus service could not perform the action +") +))] +async fn probe(State(state): State>) -> Result, SoftwareError> { + state.software.probe().await?; + Ok(Json(())) +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 7cbb1ec6b8..b088528589 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -4,6 +4,14 @@ //! * Emit relevant events via websocket. //! * Serve the code for the web user interface (not implemented yet). +use self::progress::EventsProgressPresenter; +use crate::{ + error::Error, + l10n::web::l10n_service, + software::web::{software_service, software_stream}, +}; +use axum::Router; + mod auth; mod config; mod docs; @@ -19,22 +27,24 @@ pub use auth::generate_token; pub use config::ServiceConfig; pub use docs::ApiDoc; pub use event::{Event, EventsReceiver, EventsSender}; - -use crate::l10n::web::l10n_service; -use axum::Router; pub use service::MainServiceBuilder; - -use self::progress::EventsProgressPresenter; +use tokio_stream::StreamExt; /// Returns a service that implements the web-based Agama API. /// /// * `config`: service configuration. /// * `events`: D-Bus connection. -pub fn service(config: ServiceConfig, events: EventsSender) -> Router { - MainServiceBuilder::new(events.clone()) - .add_service("/l10n", l10n_service(events)) +pub async fn service( + config: ServiceConfig, + events: EventsSender, + dbus: zbus::Connection, +) -> Result { + let router = MainServiceBuilder::new(events.clone()) + .add_service("/l10n", l10n_service(events.clone())) + .add_service("/software", software_service(dbus).await?) .with_config(config) - .build() + .build(); + Ok(router) } /// Starts monitoring the D-Bus service progress. @@ -43,13 +53,29 @@ pub fn service(config: ServiceConfig, events: EventsSender) -> Router { /// /// * `events`: channel to send the events to. pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { - let presenter = EventsProgressPresenter::new(events); + let presenter = EventsProgressPresenter::new(events.clone()); let connection = connection().await?; - let mut monitor = ProgressMonitor::new(connection).await?; + let mut monitor = ProgressMonitor::new(connection.clone()).await?; tokio::spawn(async move { if let Err(error) = monitor.run(presenter).await { eprintln!("Could not monitor the D-Bus server: {}", error); } }); + tokio::spawn(run_events_monitor(connection, events.clone())); + + Ok(()) +} + +/// Emits the events from the system streams through the events channel. +/// +/// * `connection`: D-Bus connection. +/// * `events`: channel to send the events to. +pub async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { + let stream = software_stream(dbus).await?; + tokio::pin!(stream); + let e = events.clone(); + while let Some(event) = stream.next().await { + _ = e.send(event); + } Ok(()) } diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 58a1de6aed..d5e1249e1c 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -3,13 +3,29 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( info(description = "Agama web API description"), - paths(super::http::ping, crate::l10n::web::locales), + paths( + crate::l10n::web::get_config, + crate::l10n::web::keymaps, + crate::l10n::web::locales, + crate::l10n::web::set_config, + crate::l10n::web::timezones, + crate::software::web::get_config, + crate::software::web::patterns, + crate::software::web::patterns, + crate::software::web::set_config, + super::http::ping, + ), components( - schemas(super::http::PingResponse), + schemas(agama_lib::product::Product), + schemas(agama_lib::software::Pattern), + schemas(crate::l10n::Keymap), schemas(crate::l10n::LocaleEntry), + schemas(crate::l10n::TimezoneEntry), schemas(crate::l10n::web::LocaleConfig), - schemas(crate::l10n::Keymap), - schemas(crate::l10n::TimezoneEntry) + schemas(crate::software::web::PatternEntry), + schemas(crate::software::web::SoftwareConfig), + schemas(crate::software::web::SoftwareProposal), + schemas(super::http::PingResponse), ) )] pub struct ApiDoc; diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 76db326bc4..a84956c2f3 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -1,5 +1,6 @@ -use agama_lib::progress::Progress; +use agama_lib::{progress::Progress, software::SelectedBy}; use serde::Serialize; +use std::collections::HashMap; use tokio::sync::broadcast::{Receiver, Sender}; #[derive(Clone, Serialize)] @@ -7,6 +8,8 @@ use tokio::sync::broadcast::{Receiver, Sender}; pub enum Event { LocaleChanged { locale: String }, Progress(Progress), + ProductChanged { id: String }, + PatternsChanged(HashMap), } pub type EventsSender = Sender; diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index 6179484be8..14c5c4ae0d 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -11,19 +11,22 @@ use axum::{ routing::get, Router, }; -use common::body_to_string; +use common::{body_to_string, DBusServer}; use std::error::Error; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; -fn build_service() -> Router { +async fn build_service() -> Router { let (tx, _) = channel(16); - service(ServiceConfig::default(), tx) + let server = DBusServer::new().start().await.unwrap(); + service(ServiceConfig::default(), tx, server.connection()) + .await + .unwrap() } #[test] async fn test_ping() -> Result<(), Box> { - let web_service = build_service(); + let web_service = build_service().await; let request = Request::builder().uri("/ping").body(Body::empty()).unwrap(); let response = web_service.oneshot(request).await.unwrap();