diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8bc36469c1..3bf1468038 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -69,7 +69,9 @@ dependencies = [ "tempfile", "thiserror 2.0.12", "tokio", + "tokio-native-tls", "tokio-stream", + "tokio-tungstenite 0.26.2", "url", "utoipa", "zbus", @@ -587,7 +589,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower", "tower-layer", "tower-service", @@ -4265,7 +4267,21 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.26.2", ] [[package]] @@ -4473,6 +4489,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "native-tls", + "rand 0.9.1", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index 829f9b4d8f..370fc46da0 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -24,7 +24,7 @@ use url::Url; use crate::auth_tokens_file::AuthTokensFile; use crate::error::CliError; -use agama_lib::base_http_client::BaseHTTPClient; +use agama_lib::http::BaseHTTPClient; use inquire::Password; use std::collections::HashMap; use std::io::{self, IsTerminal}; diff --git a/rust/agama-cli/src/commands.rs b/rust/agama-cli/src/commands.rs index 81b1e744a1..25dc20de66 100644 --- a/rust/agama-cli/src/commands.rs +++ b/rust/agama-cli/src/commands.rs @@ -125,4 +125,14 @@ pub enum Commands { #[clap(default_value = "reboot")] method: Option, }, + + /// Monitors the Agama service. + Monitor, + + /// Display Agama events. + Events { + /// Display the events in a more human-readable way. + #[arg(short, long)] + pretty: bool, + }, } diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index b2047ceb14..9f3a0abbcb 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -24,16 +24,17 @@ use std::{ process::Command, }; -use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, context::InstallationContext, - install_settings::InstallSettings, Store as SettingsStore, + context::InstallationContext, http::BaseHTTPClient, install_settings::InstallSettings, + monitor::MonitorClient, Store as SettingsStore, }; use anyhow::anyhow; use clap::Subcommand; use std::io::Write; use tempfile::Builder; +use crate::show_progress; + const DEFAULT_EDITOR: &str = "/usr/bin/vi"; #[derive(Subcommand, Debug)] @@ -62,7 +63,11 @@ pub enum ConfigCommands { }, } -pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> anyhow::Result<()> { +pub async fn run( + http_client: BaseHTTPClient, + monitor: MonitorClient, + subcommand: ConfigCommands, +) -> anyhow::Result<()> { let store = SettingsStore::new(http_client).await?; match subcommand { @@ -78,7 +83,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any stdin.read_to_string(&mut contents)?; let result = InstallSettings::from_json(&contents, &InstallationContext::from_env()?)?; tokio::spawn(async move { - show_progress().await.unwrap(); + show_progress(monitor, true).await; }); store.store(&result).await?; Ok(()) @@ -90,7 +95,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any .unwrap_or(DEFAULT_EDITOR.to_string()); let result = edit(&model, &editor)?; tokio::spawn(async move { - show_progress().await.unwrap(); + show_progress(monitor, true).await; }); store.store(&result).await?; Ok(()) diff --git a/rust/agama-cli/src/events.rs b/rust/agama-cli/src/events.rs new file mode 100644 index 0000000000..64e1990fc2 --- /dev/null +++ b/rust/agama-cli/src/events.rs @@ -0,0 +1,38 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_lib::http::WebSocketClient; + +/// Main entry point called from Agama CLI main loop +pub async fn run(mut ws_client: WebSocketClient, pretty: bool) -> anyhow::Result<()> { + loop { + let event = ws_client.receive().await?; + let conversion = if pretty { + serde_json::to_string_pretty(&event) + } else { + serde_json::to_string(&event) + }; + + match conversion { + Ok(event_json) => println!("{}", event_json), + Err(_) => eprintln!("Could not serialize {:?}", &event), + } + } +} diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index b63cc1e6a5..a59215fe20 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -20,7 +20,8 @@ use agama_lib::auth::AuthToken; use agama_lib::context::InstallationContext; -use agama_lib::manager::FinishMethod; +use agama_lib::manager::{FinishMethod, ManagerHTTPClient}; +use agama_lib::monitor::{Monitor, MonitorClient}; use anyhow::Context; use auth_tokens_file::AuthTokensFile; use clap::{Args, Parser}; @@ -31,22 +32,22 @@ mod auth_tokens_file; mod commands; mod config; mod error; +mod events; mod logs; mod profile; mod progress; mod questions; use crate::error::CliError; -use agama_lib::base_http_client::BaseHTTPClient; -use agama_lib::{ - error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, utils::Transfer, -}; +use agama_lib::http::{BaseHTTPClient, WebSocketClient}; +use agama_lib::{error::ServiceError, utils::Transfer}; use auth::run as run_auth_cmd; use commands::Commands; use config::run as run_config_cmd; +use events::run as run_events_cmd; use logs::run as run_logs_cmd; use profile::run as run_profile_cmd; -use progress::InstallerProgress; +use progress::ProgressMonitor; use questions::run as run_questions_cmd; use std::fs; use std::os::unix::fs::OpenOptionsExt; @@ -88,13 +89,11 @@ pub struct Cli { pub command: Commands, } -async fn probe() -> anyhow::Result<()> { - let another_manager = build_manager().await?; +async fn probe(manager: ManagerHTTPClient, monitor: MonitorClient) -> anyhow::Result<()> { let probe = tokio::spawn(async move { - let _ = another_manager.probe().await; + let _ = manager.probe().await; }); - show_progress().await?; - + show_progress(monitor, true).await; Ok(probe.await?) } @@ -103,19 +102,21 @@ async fn probe() -> anyhow::Result<()> { /// Before starting, it makes sure that the manager is idle. /// /// * `manager`: the manager client. -async fn install(manager: &ManagerClient<'_>, max_attempts: u8) -> anyhow::Result<()> { - if manager.is_busy().await { - println!("Agama's manager is busy. Waiting until it is ready..."); - } - - // Make sure that the manager is ready - manager.wait().await?; - - if !manager.can_install().await? { +async fn install( + manager: ManagerHTTPClient, + monitor: MonitorClient, + max_attempts: usize, +) -> anyhow::Result<()> { + wait_until_idle(monitor.clone()).await?; + + let status = manager.status().await?; + if !status.can_install { return Err(CliError::Validation)?; } - let progress = tokio::spawn(async { show_progress().await }); + let progress = tokio::spawn(async { + show_progress(monitor, true).await; + }); // Try to start the installation up to max_attempts times. let mut attempts = 1; loop { @@ -144,13 +145,13 @@ async fn install(manager: &ManagerClient<'_>, max_attempts: u8) -> anyhow::Resul /// Before finishing, it makes sure that the manager is idle. /// /// * `manager`: the manager client. -async fn finish(manager: &ManagerClient<'_>, method: FinishMethod) -> anyhow::Result<()> { - if manager.is_busy().await { - println!("Agama's manager is busy. Waiting until it is ready..."); - } +async fn finish( + manager: ManagerHTTPClient, + monitor: MonitorClient, + method: FinishMethod, +) -> anyhow::Result<()> { + wait_until_idle(monitor.clone()).await?; - // Make sure that the manager is ready - manager.wait().await?; if !manager.finish(method).await? { eprintln!("Cannot finish the installation ({method})"); return Err(CliError::NotFinished)?; @@ -158,35 +159,16 @@ async fn finish(manager: &ManagerClient<'_>, method: FinishMethod) -> anyhow::Re Ok(()) } -/// If D-Bus indicates that the backend is busy, show it on the terminal -async fn show_progress() -> Result<(), ServiceError> { - // wait 1 second to give other task chance to start, so progress can display something - tokio::time::sleep(Duration::from_secs(1)).await; - let conn = agama_lib::connection().await?; - let mut monitor = ProgressMonitor::new(conn).await?; - let terminal_presenter = InstallerProgress::new(); - monitor - .run(terminal_presenter) - .await - .expect("failed to monitor the progress"); - Ok(()) -} - -async fn wait_for_services(manager: &ManagerClient<'_>) -> Result<(), ServiceError> { - let services = manager.busy_services().await?; - // TODO: having it optional - if !services.is_empty() { +async fn wait_until_idle(monitor: MonitorClient) -> anyhow::Result<()> { + // FIXME: implement something like "wait_until_idle" in the monitor? + let status = monitor.get_status().await?; + if status.installer_status.is_busy { eprintln!("The Agama service is busy. Waiting for it to be available..."); - show_progress().await? + show_progress(monitor.clone(), true).await; } Ok(()) } -async fn build_manager<'a>() -> anyhow::Result> { - let conn = agama_lib::connection().await?; - Ok(ManagerClient::new(conn).await?) -} - pub fn download_file(url: &str, path: &PathBuf) -> anyhow::Result<()> { let mut file = fs::OpenOptions::new() .create(true) @@ -211,6 +193,10 @@ pub fn download_file(url: &str, path: &PathBuf) -> anyhow::Result<()> { Ok(()) } +/// * `api_url`: API URL. +/// * `insecure`: whether an insecure connnection (e.g., using a self-signed certificate) +/// is allowed. +/// * `authenticated`: build an authenticated client (if possible). async fn build_http_client( api_url: Url, insecure: bool, @@ -235,6 +221,25 @@ async fn build_http_client( } } +/// Build a WebSocket client. +/// +/// * `api_url`: API URL. +/// * `insecure`: whether an insecure connnection (e.g., using a self-signed certificate) +/// is allowed. +async fn build_ws_client(api_url: Url, insecure: bool) -> anyhow::Result { + let mut url = api_url.join("ws")?; + let scheme = if api_url.scheme() == "http" { + "ws" + } else { + "wss" + }; + + let token = find_client_token(&api_url).ok_or(ServiceError::NotAuthenticated)?; + // Setting the scheme to a known value ("ws" or "wss" should not fail). + url.set_scheme(scheme).unwrap(); + Ok(WebSocketClient::connect(&url, &token, insecure).await?) +} + /// Build the API url from the host. /// /// * `host`: ip or host name. The protocol is optional, using https if omitted (e.g, "myserver", @@ -263,33 +268,57 @@ fn find_client_token(api_url: &Url) -> Option { AuthToken::master() } -pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { - // somehow check whether we need to ask user for self-signed certificate acceptance +async fn build_clients( + api_url: Url, + insecure: bool, +) -> anyhow::Result<(BaseHTTPClient, MonitorClient)> { + let client = build_http_client(api_url.clone(), insecure, true).await?; + let ws_client = build_ws_client(api_url, true).await?; + let monitor = Monitor::connect(client.clone(), ws_client).await?; + Ok((client, monitor)) +} +/// Helper function to display the progress in the terminal. +/// +/// * `monitor`: monitor client. +/// * `stop_on_idle`: stop displaying the progress when Agama becomes idle. +pub async fn show_progress(monitor: MonitorClient, stop_on_idle: bool) { + let mut progress = ProgressMonitor::new(monitor).stop_on_idle(stop_on_idle); + if let Err(e) = progress.run().await { + eprintln!("Could not display the progress: {e:?}"); + } +} + +pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { let api_url = api_url(cli.opts.host)?; match cli.command { Commands::Config(subcommand) => { - let client = build_http_client(api_url, cli.opts.insecure, true).await?; - run_config_cmd(client, subcommand).await? + let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + run_config_cmd(client, monitor, subcommand).await? } Commands::Probe => { - let manager = build_manager().await?; - wait_for_services(&manager).await?; - probe().await? + let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + let manager = ManagerHTTPClient::new(client.clone()); + let _ = wait_until_idle(monitor.clone()).await; + probe(manager, monitor).await? } Commands::Profile(subcommand) => { - let client = build_http_client(api_url, cli.opts.insecure, true).await?; - run_profile_cmd(client, subcommand).await?; + let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + run_profile_cmd(client, monitor, subcommand).await?; } Commands::Install => { - let manager = build_manager().await?; - install(&manager, 3).await? + let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + let manager = ManagerHTTPClient::new(client.clone()); + let _ = wait_until_idle(monitor.clone()).await; + install(manager, monitor, 3).await? } Commands::Finish { method } => { - let manager = build_manager().await?; + let (client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + let manager = ManagerHTTPClient::new(client.clone()); + let _ = wait_until_idle(monitor.clone()).await; let method = method.unwrap_or_default(); - finish(&manager, method).await?; + finish(manager, monitor, method).await?; } Commands::Questions(subcommand) => { let client = build_http_client(api_url, cli.opts.insecure, true).await?; @@ -304,6 +333,14 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { let client = build_http_client(api_url, cli.opts.insecure, false).await?; run_auth_cmd(client, subcommand).await?; } + Commands::Monitor => { + let (_client, monitor) = build_clients(api_url, cli.opts.insecure).await?; + show_progress(monitor, false).await; + } + Commands::Events { pretty } => { + let ws_client = build_ws_client(api_url, cli.opts.insecure).await?; + run_events_cmd(ws_client, pretty).await?; + } }; Ok(()) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 6ebd8505fc..e6506672a9 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.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::base_http_client::BaseHTTPClient; +use agama_lib::http::BaseHTTPClient; use agama_lib::manager::http_client::ManagerHTTPClient; use clap::Subcommand; use std::io; diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 7b3580c061..cee264f08b 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -20,9 +20,10 @@ use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, context::InstallationContext, + http::BaseHTTPClient, install_settings::InstallSettings, + monitor::MonitorClient, profile::ValidationOutcome, utils::{FileFormat, Transfer}, Store as SettingsStore, @@ -225,12 +226,14 @@ async fn evaluate(client: &BaseHTTPClient, url_or_path: CliInput) -> anyhow::Res Ok(()) } -async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<()> { +async fn import( + client: BaseHTTPClient, + monitor: MonitorClient, + url_string: String, +) -> anyhow::Result<()> { // useful for store_settings tokio::spawn(async move { - if let Err(error) = show_progress().await { - eprintln!("Cannot monitor progress: {}", error); - } + show_progress(monitor, true).await; }); let url = Uri::parse(url_string.as_str())?; @@ -318,11 +321,15 @@ async fn autoyast(client: BaseHTTPClient, url_string: String) -> anyhow::Result< Ok(()) } -pub async fn run(client: BaseHTTPClient, subcommand: ProfileCommands) -> anyhow::Result<()> { +pub async fn run( + client: BaseHTTPClient, + monitor: MonitorClient, + subcommand: ProfileCommands, +) -> anyhow::Result<()> { match subcommand { ProfileCommands::Autoyast { url } => autoyast(client, url).await, ProfileCommands::Validate { url_or_path } => validate(&client, url_or_path).await, ProfileCommands::Evaluate { url_or_path } => evaluate(&client, url_or_path).await, - ProfileCommands::Import { url } => import(client, url).await, + ProfileCommands::Import { url } => import(client, monitor, url).await, } } diff --git a/rust/agama-cli/src/progress.rs b/rust/agama-cli/src/progress.rs index e2b7724e45..085d10dd8d 100644 --- a/rust/agama-cli/src/progress.rs +++ b/rust/agama-cli/src/progress.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -18,43 +18,89 @@ // 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::progress::{Progress, ProgressPresenter}; -use async_trait::async_trait; +use agama_lib::{ + monitor::{MonitorClient, MonitorStatus}, + progress::Progress, +}; use console::style; use indicatif::{ProgressBar, ProgressStyle}; use std::time::Duration; -/// Reports the installer progress through the terminal -pub struct InstallerProgress { +const MANAGER_SERVICE: &str = "org.opensuse.Agama.Manager1"; +const SOFTWARE_SERVICE: &str = "org.opensuse.Agama.Software1"; + +/// Displays the progress on the terminal. +pub struct ProgressMonitor { + monitor: MonitorClient, bar: Option, + current_step: u32, + running: bool, + stop_on_idle: bool, } -impl InstallerProgress { - pub fn new() -> Self { - Self { bar: None } +impl ProgressMonitor { + /// Builds a new instance. + /// + /// * `MonitorClient`: client to access the Agama monitor. + pub fn new(monitor: MonitorClient) -> Self { + Self { + monitor, + bar: None, + current_step: 0, + running: false, + stop_on_idle: true, + } } - fn update_bar(&mut self, progress: &Progress) { - let bar = self.bar.get_or_insert_with(|| { - let style = ProgressStyle::with_template("{spinner:.green} {msg}").unwrap(); - let bar = ProgressBar::new(0).with_style(style); - bar.enable_steady_tick(Duration::from_millis(120)); - bar - }); - bar.set_length(progress.max_steps.into()); - bar.set_position(progress.current_step.into()); - bar.set_message(progress.current_title.to_owned()); + /// Determines whether the progress should stop when the service becomes idle. + pub fn stop_on_idle(mut self, stop_on_idle: bool) -> Self { + self.stop_on_idle = stop_on_idle; + self } -} -#[async_trait] -impl ProgressPresenter for InstallerProgress { - async fn start(&mut self, progress: &Progress) { - if !progress.finished { - self.update_main(progress).await; + /// Starts the UI representing the progress. + pub async fn run(&mut self) -> anyhow::Result<()> { + let mut updates = self.monitor.subscribe(); + let status = self.monitor.get_status().await?; + self.update(status).await; + + loop { + if let Ok(status) = updates.recv().await { + if !self.update(status).await { + return Ok(()); + } + } + } + } + + /// Updates the progress. + /// + /// It returns true if the monitor should continue. + async fn update(&mut self, status: MonitorStatus) -> bool { + if status.progress.get(MANAGER_SERVICE).is_none() && self.running { + self.finish(); + if self.stop_on_idle { + return false; + } + } + + if let Some(progress) = status.progress.get(MANAGER_SERVICE) { + self.running = true; + if self.current_step != progress.current_step { + self.update_main(&progress).await; + self.current_step = progress.current_step; + } } + + match status.progress.get(SOFTWARE_SERVICE) { + Some(progress) => self.update_bar(progress), + None => self.remove_bar(), + } + + true } + /// Updates the main bar. async fn update_main(&mut self, progress: &Progress) { let counter = format!("[{}/{}]", &progress.current_step, &progress.max_steps); @@ -65,19 +111,28 @@ impl ProgressPresenter for InstallerProgress { ); } - async fn update_detail(&mut self, progress: &Progress) { - if progress.finished { - if let Some(bar) = self.bar.take() { - bar.finish_and_clear(); - } - } else { - self.update_bar(progress); - } + fn update_bar(&mut self, progress: &Progress) { + let bar = self.bar.get_or_insert_with(|| { + let style = ProgressStyle::with_template("{spinner:.green} {msg}").unwrap(); + let bar = ProgressBar::new(0).with_style(style); + bar.enable_steady_tick(Duration::from_millis(120)); + bar + }); + + bar.set_length(progress.max_steps.into()); + bar.set_position(progress.current_step.into()); + bar.set_message(progress.current_title.to_owned()); + } + + fn remove_bar(&mut self) { + _ = self.bar.take() } - async fn finish(&mut self) { + /// Stops the representation. + fn finish(&mut self) { + self.running = false; if let Some(bar) = self.bar.take() { - bar.finish_and_clear(); + bar.finish_with_message("Done"); } } } diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index 89866e0692..d43199be9a 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use agama_lib::{ - base_http_client::BaseHTTPClient, connection, proxies::questions::QuestionsProxy, + connection, http::BaseHTTPClient, proxies::questions::QuestionsProxy, questions::http_client::HTTPClient, }; use anyhow::anyhow; diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index bc2aa734cc..20e2823199 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -41,6 +41,8 @@ fs_extra = "1.3.0" serde_with = "3.12.0" regex = "1.11.1" fluent-uri = { version = "0.3.2", features = ["serde"] } +tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] } +tokio-native-tls = "0.3.1" [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/src/bootloader/http_client.rs b/rust/agama-lib/src/bootloader/http_client.rs index 7e73153ced..c186da202b 100644 --- a/rust/agama-lib/src/bootloader/http_client.rs +++ b/rust/agama-lib/src/bootloader/http_client.rs @@ -21,8 +21,8 @@ //! Implements a client to access Agama's HTTP API related to Bootloader management. use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, bootloader::model::BootloaderSettings, + http::{BaseHTTPClient, BaseHTTPClientError}, }; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/bootloader/store.rs b/rust/agama-lib/src/bootloader/store.rs index dec9ecf645..fc6dbf13be 100644 --- a/rust/agama-lib/src/bootloader/store.rs +++ b/rust/agama-lib/src/bootloader/store.rs @@ -24,7 +24,7 @@ use super::{ http_client::{BootloaderHTTPClient, BootloaderHTTPClientError}, model::BootloaderSettings, }; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; // FIXME: should we follow this approach more often? type BootloaderStoreResult = Result; diff --git a/rust/agama-lib/src/files/client.rs b/rust/agama-lib/src/files/client.rs index 327538ffe1..33716fbd17 100644 --- a/rust/agama-lib/src/files/client.rs +++ b/rust/agama-lib/src/files/client.rs @@ -21,7 +21,7 @@ //! Implements a client to access Agama's HTTP API related to Bootloader management. use super::model::UserFile; -use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; #[derive(Debug, thiserror::Error)] pub enum FilesHTTPClientError { diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs index ef6cdd16d2..ffefa1611e 100644 --- a/rust/agama-lib/src/files/store.rs +++ b/rust/agama-lib/src/files/store.rs @@ -24,7 +24,7 @@ use super::{ client::{FilesClient, FilesHTTPClientError}, model::UserFile, }; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; #[derive(Debug, thiserror::Error)] pub enum FilesStoreError { diff --git a/rust/agama-lib/src/hostname/http_client.rs b/rust/agama-lib/src/hostname/http_client.rs index 3703c78350..67176bcdfd 100644 --- a/rust/agama-lib/src/hostname/http_client.rs +++ b/rust/agama-lib/src/hostname/http_client.rs @@ -21,8 +21,8 @@ //! Implements a client to access Agama's HTTP API related to Hostname management. use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, hostname::model::HostnameSettings, + http::{BaseHTTPClient, BaseHTTPClientError}, }; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/hostname/store.rs b/rust/agama-lib/src/hostname/store.rs index 0803a7e4da..ed7551fb4a 100644 --- a/rust/agama-lib/src/hostname/store.rs +++ b/rust/agama-lib/src/hostname/store.rs @@ -24,7 +24,7 @@ use super::{ http_client::{HostnameHTTPClient, HostnameHTTPClientError}, model::HostnameSettings, }; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; #[derive(Debug, thiserror::Error)] #[error("Error processing hostname settings: {0}")] diff --git a/rust/agama-lib/src/http.rs b/rust/agama-lib/src/http.rs new file mode 100644 index 0000000000..b8d31ccbd5 --- /dev/null +++ b/rust/agama-lib/src/http.rs @@ -0,0 +1,28 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +mod base_http_client; +pub use base_http_client::{BaseHTTPClient, BaseHTTPClientError}; + +mod event; +pub use event::Event; + +mod websocket; +pub use websocket::{WebSocketClient, WebSocketError}; diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/http/base_http_client.rs similarity index 99% rename from rust/agama-lib/src/base_http_client.rs rename to rust/agama-lib/src/http/base_http_client.rs index 2950455bea..bae02a1b93 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/http/base_http_client.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -47,7 +47,7 @@ pub enum BaseHTTPClientError { /// /// ```no_run /// use agama_lib::questions::model::Question; -/// use agama_lib::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +/// use agama_lib::http::{BaseHTTPClient, BaseHTTPClientError}; /// /// async fn get_questions() -> Result, BaseHTTPClientError> { /// let client = BaseHTTPClient::new("http://localhost/api/").unwrap(); diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs new file mode 100644 index 0000000000..223ddd8292 --- /dev/null +++ b/rust/agama-lib/src/http/event.rs @@ -0,0 +1,144 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{ + jobs::Job, + localization::model::LocaleConfig, + manager::InstallationPhase, + network::model::NetworkChange, + progress::Progress, + software::SelectedBy, + storage::{ + model::{ + dasd::{DASDDevice, DASDFormatSummary}, + zfcp::{ZFCPController, ZFCPDisk}, + }, + ISCSINode, + }, + users::{FirstUser, RootUser}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::issue::Issue; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum Event { + L10nConfigChanged(LocaleConfig), + LocaleChanged { + locale: String, + }, + DevicesDirty { + dirty: bool, + }, + Progress { + service: String, + #[serde(flatten)] + progress: Progress, + }, + ProductChanged { + id: String, + }, + RegistrationChanged, + FirstUserChanged(FirstUser), + RootUserChanged(RootUser), + NetworkChange { + #[serde(flatten)] + change: NetworkChange, + }, + // TODO: it should include the full software proposal or, at least, + // all the relevant changes. + SoftwareProposalChanged { + patterns: HashMap, + }, + QuestionsChanged, + InstallationPhaseChanged { + phase: InstallationPhase, + }, + ServiceStatusChanged { + service: String, + status: u32, + }, + IssuesChanged { + service: String, + path: String, + issues: Vec, + }, + ValidationChanged { + service: String, + path: String, + errors: Vec, + }, + ISCSINodeAdded { + node: ISCSINode, + }, + ISCSINodeChanged { + node: ISCSINode, + }, + ISCSINodeRemoved { + node: ISCSINode, + }, + ISCSIInitiatorChanged { + name: Option, + ibft: Option, + }, + DASDDeviceAdded { + device: DASDDevice, + }, + DASDDeviceChanged { + device: DASDDevice, + }, + DASDDeviceRemoved { + device: DASDDevice, + }, + JobAdded { + job: Job, + }, + JobChanged { + job: Job, + }, + JobRemoved { + job: Job, + }, + DASDFormatJobChanged { + #[serde(rename = "jobId")] + job_id: String, + summary: HashMap, + }, + ZFCPDiskAdded { + device: ZFCPDisk, + }, + ZFCPDiskChanged { + device: ZFCPDisk, + }, + ZFCPDiskRemoved { + device: ZFCPDisk, + }, + ZFCPControllerAdded { + device: ZFCPController, + }, + ZFCPControllerChanged { + device: ZFCPController, + }, + ZFCPControllerRemoved { + device: ZFCPController, + }, +} diff --git a/rust/agama-lib/src/http/websocket.rs b/rust/agama-lib/src/http/websocket.rs new file mode 100644 index 0000000000..1eb6a4e296 --- /dev/null +++ b/rust/agama-lib/src/http/websocket.rs @@ -0,0 +1,125 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a WSClient to connect to Agama's WebSocket and +//! listen for events. + +use tokio::{net::TcpStream, sync::broadcast}; +use tokio_native_tls::{native_tls, TlsConnector}; +use tokio_stream::StreamExt; +use tokio_tungstenite::{ + client_async, + tungstenite::{ + http::{self, Uri}, + ClientRequestBuilder, + }, + WebSocketStream, +}; +use url::Url; + +use super::Event; +use crate::auth::AuthToken; + +#[derive(Debug, thiserror::Error)] +pub enum WebSocketError { + #[error(transparent)] + Websocket(#[from] tokio_tungstenite::tungstenite::Error), + #[error("The WebSocket is closed")] + Closed, + #[error(transparent)] + Tls(#[from] tokio_native_tls::native_tls::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error("TLS handshake error: {0}")] + Handshake(String), + #[error(transparent)] + InvalidUri(#[from] http::uri::InvalidUri), + #[error(transparent)] + EventDeserialize(#[from] serde_json::Error), + #[error("Missing hostname in {0}")] + MissingHostname(String), + #[error("Internal communication error: {0}")] + RecvError(#[from] broadcast::error::RecvError), +} + +/// WebSocket client for the Agama service. +/// +/// TODO: implement Stream. +pub struct WebSocketClient { + socket: WebSocketStream>, +} + +impl WebSocketClient { + /// Connects to a websocket using the given authentication token. + /// + /// * `url`: URL of the websocket to connect. + /// * `auth_token`: Agama authentication token. + /// * `insecure`: whether invalid certs and hostnames are allowed. + pub async fn connect( + url: &Url, + auth_token: &AuthToken, + insecure: bool, + ) -> Result { + let host = url + .host_str() + .ok_or_else(|| WebSocketError::MissingHostname(url.to_string()))?; + let port = Self::find_port(&url); + + let tls_connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(insecure) + .danger_accept_invalid_hostnames(insecure) + .build()?; + let tls_connector: TlsConnector = tls_connector.into(); + + let socket_addr = format!("{}:{}", host, port); + let stream = TcpStream::connect(socket_addr).await?; + let stream = tls_connector + .connect(host, stream) + .await + .map_err(|e| WebSocketError::Handshake(e.to_string()))?; + + let uri: Uri = url.as_str().parse()?; + let token = auth_token.as_str(); + let request = + ClientRequestBuilder::new(uri).with_header("Authorization", format!("Bearer {token}")); + + let (socket, _response) = client_async(request, stream) + .await + .map_err(|e| WebSocketError::Handshake(e.to_string()))?; + Ok(Self { socket }) + } + + /// Receive an event from the websocket. + /// + /// It returns the message as an event. + pub async fn receive(&mut self) -> Result { + let msg = self.socket.next().await.ok_or(WebSocketError::Closed)?; + let content = msg?.to_string(); + let event: Event = serde_json::from_str(&content)?; + Ok(event) + } + + fn find_port(url: &Url) -> u16 { + url.port().unwrap_or_else(|| match url.scheme() { + "wss" => 443, + _ => 80, + }) + } +} diff --git a/rust/agama-lib/src/issue.rs b/rust/agama-lib/src/issue.rs new file mode 100644 index 0000000000..10be81c43e --- /dev/null +++ b/rust/agama-lib/src/issue.rs @@ -0,0 +1,50 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Issue { + description: String, + details: Option, + source: u32, + severity: u32, + kind: String, +} + +impl Issue { + pub fn from_tuple( + (description, kind, details, source, severity): (String, String, String, u32, u32), + ) -> Self { + let details = if details.is_empty() { + None + } else { + Some(details) + }; + + Self { + description, + kind, + details, + source, + severity, + } + } +} diff --git a/rust/agama-lib/src/jobs.rs b/rust/agama-lib/src/jobs.rs index 5b6b85bc47..4090cff362 100644 --- a/rust/agama-lib/src/jobs.rs +++ b/rust/agama-lib/src/jobs.rs @@ -24,7 +24,7 @@ use std::collections::HashMap; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zbus::zvariant::OwnedValue; use crate::error::ServiceError; @@ -33,7 +33,7 @@ use agama_utils::dbus::get_property; pub mod client; /// Represents a job. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Job { /// Artificial job identifier. diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 9719ac7d62..02fa38d1a7 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -44,18 +44,20 @@ //! As said, those modules might implement additional stuff, like specific types, clients, etc. pub mod auth; -pub mod base_http_client; pub mod bootloader; pub mod context; pub mod error; pub mod file_source; pub mod files; pub mod hostname; +pub mod http; pub mod install_settings; +pub mod issue; pub mod jobs; pub mod localization; pub mod logs; pub mod manager; +pub mod monitor; pub mod network; pub mod product; pub mod profile; diff --git a/rust/agama-lib/src/localization/http_client.rs b/rust/agama-lib/src/localization/http_client.rs index e82aaa8101..57bfcba383 100644 --- a/rust/agama-lib/src/localization/http_client.rs +++ b/rust/agama-lib/src/localization/http_client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use super::model::LocaleConfig; -use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; #[derive(Debug, thiserror::Error)] pub enum LocalizationHTTPClientError { diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs index 66fa54bea7..9bda06fb48 100644 --- a/rust/agama-lib/src/localization/store.rs +++ b/rust/agama-lib/src/localization/store.rs @@ -24,7 +24,7 @@ use super::{ http_client::LocalizationHTTPClientError, LocalizationHTTPClient, LocalizationSettings, }; -use crate::{base_http_client::BaseHTTPClient, localization::model::LocaleConfig}; +use crate::{http::BaseHTTPClient, localization::model::LocaleConfig}; #[derive(Debug, thiserror::Error)] #[error("Error processing localization settings: {0}")] @@ -94,7 +94,7 @@ impl LocalizationStore { #[cfg(test)] mod test { use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use httpmock::Method::PATCH; use std::error::Error; diff --git a/rust/agama-lib/src/manager.rs b/rust/agama-lib/src/manager.rs index 47dccede46..a43b23a4c5 100644 --- a/rust/agama-lib/src/manager.rs +++ b/rust/agama-lib/src/manager.rs @@ -43,7 +43,7 @@ pub struct ManagerClient<'a> { } /// Holds information about the manager's status. -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Default, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct InstallerStatus { /// Current installation phase. @@ -58,10 +58,13 @@ pub struct InstallerStatus { /// Represents the installation phase. /// NOTE: does this conversion have any value? -#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, Deserialize_repr, utoipa::ToSchema)] +#[derive( + Clone, Copy, Default, Debug, PartialEq, Serialize_repr, Deserialize_repr, utoipa::ToSchema, +)] #[repr(u32)] pub enum InstallationPhase { /// Start up phase. + #[default] Startup, /// Configuration phase. Config, diff --git a/rust/agama-lib/src/manager/http_client.rs b/rust/agama-lib/src/manager/http_client.rs index ac864cee6d..06bbbe532b 100644 --- a/rust/agama-lib/src/manager/http_client.rs +++ b/rust/agama-lib/src/manager/http_client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, + http::{BaseHTTPClient, BaseHTTPClientError}, logs::LogsLists, manager::InstallerStatus, }; @@ -27,6 +27,8 @@ use reqwest::header::CONTENT_ENCODING; use std::io::Cursor; use std::path::{Path, PathBuf}; +use super::FinishMethod; + #[derive(Debug, thiserror::Error)] pub enum ManagerHTTPClientError { #[error(transparent)] @@ -50,6 +52,19 @@ impl ManagerHTTPClient { Ok(self.client.post_void("/manager/probe_sync", &()).await?) } + /// Starts the installation. + pub async fn install(&self) -> Result<(), ManagerHTTPClientError> { + Ok(self.client.post_void("/manager/install", &()).await?) + } + + /// Finishes the installation. + /// + /// * `method`: halt, reboot, stop or poweroff the system. + pub async fn finish(&self, method: FinishMethod) -> Result { + let method = Some(method); + Ok(self.client.post("/manager/finish", &method).await?) + } + /// Downloads package of logs from the backend /// /// For now the path is path to a destination file without an extension. Extension diff --git a/rust/agama-lib/src/monitor.rs b/rust/agama-lib/src/monitor.rs new file mode 100644 index 0000000000..4866ceea7a --- /dev/null +++ b/rust/agama-lib/src/monitor.rs @@ -0,0 +1,283 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a monitor that keeps track of Agama service status. +//! +//! The monitor tracks: +//! +//! * Changes in the installer status (see InstallerStatus). +//! * Progress changes in any service. +//! +//! Each time the installer status changes, it sends the new status using the +//! MonitorStatus struct. +//! +//! Note: in the future we might send only the changes, but at this point +//! the monitor sends the full status. +//! +//! ```no_run +//! # use agama_lib::{monitor::Monitor, auth::AuthToken, http::{BaseHTTPClient, WebSocketClient}}; +//! +//! async fn print_status(http_url: url::Url, ws_url: url::Url, token: AuthToken) -> anyhow::Result<()> { +//! let http_client = BaseHTTPClient::new(http_url)? +//! .authenticated(&token)?; +//! let ws_client = WebSocketClient::connect(&ws_url, token.clone(), false) +//! .await?; +//! let monitor = Monitor::connect(http_client, ws_client).await.unwrap(); +//! let mut updates = monitor.subscribe(); +//! +//! loop { +//! if let Ok(status) = updates.recv().await { +//! println!("Status: {:?}", &status.installer_status); +//! } +//! } +//! } +//! ``` +//! + +use std::collections::HashMap; +use tokio::sync::{broadcast, mpsc, oneshot}; + +use crate::{ + http::{BaseHTTPClient, BaseHTTPClientError, Event, WebSocketClient, WebSocketError}, + manager::{InstallationPhase, InstallerStatus}, + progress::Progress, +}; + +const MANAGER_SERVICE: &str = "org.opensuse.Agama.Manager1"; +// const SOFTWARE_SERVICE: &str = "org.opensuse.Agama.Software1"; + +#[derive(thiserror::Error, Debug)] +pub enum MonitorError { + #[error("Error connecting to the HTTP API: {0}")] + HTTP(#[from] BaseHTTPClientError), + #[error("WebSocket error: {0}")] + WebSocket(#[from] WebSocketError), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Error receiving the monitor message: {0}")] + Recv(#[from] oneshot::error::RecvError), +} + +/// Represents the current status of the installer. +#[derive(Clone, Debug, Default)] +pub struct MonitorStatus { + /// The general installer status. + /// + /// FIXME: do not hold the full status (some elements are not updated) + pub installer_status: InstallerStatus, + /// Progress for each service using the name as the key. If the progress is + /// finished, the entry is removed from the map. + pub progress: HashMap, +} + +impl MonitorStatus { + /// Updates the progress for the given service. + /// + /// The entry is removed if the progress is finished. + /// + /// * `service`: service name. + /// * `progress`: updated progress. + fn update_progress(&mut self, service: String, progress: Progress) { + if progress.finished { + _ = self.progress.remove_entry(&service); + } else { + _ = self.progress.insert(service, progress); + } + } + + /// Sets whether the installer is busy or not. + /// + /// * `is_busy`: whether the installer is busy. + fn set_is_busy(&mut self, is_busy: bool) { + self.installer_status.is_busy = is_busy; + } + + /// Sets the service phase. + /// + /// * `phase`: installation phase. + fn set_phase(&mut self, phase: InstallationPhase) { + self.installer_status.phase = phase; + } +} + +/// It allows connecting to the Agama monitor to get the status or listen for changes. +/// +/// It can be cloned and moved between threads. +#[derive(Clone)] +pub struct MonitorClient { + commands: mpsc::Sender, + pub updates: broadcast::Sender, +} + +impl MonitorClient { + /// Returns the installer status. + pub async fn get_status(&self) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + _ = self.commands.send(MonitorCommand::GetStatus(tx)).await; + Ok(rx.await?) + } + + /// Subscribe to status updates from the monitor. + /// + /// It uses a regular broadcast channel from the Tokio library. + pub fn subscribe(&self) -> broadcast::Receiver { + self.updates.subscribe() + } +} + +/// Monitors an Agama service and keeps track of the status, listens for +/// events, etc. +pub struct Monitor { + // Channel to receive commands. + commands: mpsc::Receiver, + // Channel to send updates. + updates: broadcast::Sender, + status: MonitorStatus, + ws_client: WebSocketClient, +} + +#[derive(Debug)] +enum MonitorCommand { + GetStatus(tokio::sync::oneshot::Sender), +} + +impl Monitor { + /// Connects and monitors to an Agama service. + /// + /// * `http_client`: HTTP client to talk to the service. + /// * `websocket_client`: websocket to listen for events. + /// + /// The monitor runs on a separate Tokio task. + pub async fn connect( + http_client: BaseHTTPClient, + websocket_client: WebSocketClient, + ) -> Result { + // Channel to send/receive updates from the monitor. + let (updates, _rx) = broadcast::channel(16); + // Channel to send/receive commands from the client. + let (commands_tx, commands_rx) = mpsc::channel(16); + let client = MonitorClient { + commands: commands_tx, + updates: updates.clone(), + }; + + let status = MonitorStatusReader::with_client(http_client).read().await?; + + let mut monitor = Monitor { + status, + updates, + commands: commands_rx, + ws_client: websocket_client, + }; + + tokio::spawn(async move { monitor.run().await }); + Ok(client) + } + + /// Runs the monitor. + async fn run(&mut self) { + loop { + tokio::select! { + Some(cmd) = self.commands.recv() => { + self.handle_command(cmd); + } + Ok(event) = self.ws_client.receive() => { + self.handle_event(event); + } + } + } + } + + /// Handle commands from the client. + /// + /// * `command`: command to execute. + fn handle_command(&mut self, command: MonitorCommand) { + match command { + MonitorCommand::GetStatus(channel) => { + let _ = channel.send(self.status.clone()); + } + } + } + + /// Handle events from Agama. + /// + /// Given an event, updates the internal state. Once updated, it emits + /// sends the updated state to its subscribers. + /// + /// * `event`: Agama event. + fn handle_event(&mut self, event: Event) { + match event { + Event::Progress { service, progress } => { + self.status.update_progress(service, progress); + } + Event::ServiceStatusChanged { service, status } => { + if service.as_str() == MANAGER_SERVICE { + self.status.set_is_busy(status == 1); + } + } + Event::InstallationPhaseChanged { phase } => { + self.status.set_phase(phase); + } + _ => {} + } + let _ = self.updates.send(self.status.clone()); + } +} + +/// Ancillary struct to read the status from the API. +struct MonitorStatusReader { + http: BaseHTTPClient, +} + +impl MonitorStatusReader { + pub fn with_client(http: BaseHTTPClient) -> Self { + Self { http } + } + + pub async fn read(self) -> Result { + let installer_status: InstallerStatus = self.http.get("/manager/installer").await?; + let mut status = MonitorStatus { + installer_status, + ..Default::default() + }; + + self.add_service_progress(&mut status, MANAGER_SERVICE, "/manager/progress") + .await?; + // FIXME: do not read the software status yet because it might block + // the progress. Enable this line when the software service does not block + // self.add_service_progress(&mut status, SOFTWARE_SERVICE, "/software/progress") + // .await?; + Ok(status) + } + + async fn add_service_progress( + &self, + status: &mut MonitorStatus, + service: &str, + path: &str, + ) -> Result<(), MonitorError> { + let progress: Progress = self.http.get(path).await?; + if progress.finished { + return Ok(()); + } + status.progress.insert(service.to_string(), progress); + Ok(()) + } +} diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index 6ab2e95a7f..6f7ab76209 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use super::{settings::NetworkConnection, types::Device}; -use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; #[derive(Debug, thiserror::Error)] pub enum NetworkClientError { diff --git a/rust/agama-lib/src/network/store.rs b/rust/agama-lib/src/network/store.rs index da06acd76e..6bdd5fceeb 100644 --- a/rust/agama-lib/src/network/store.rs +++ b/rust/agama-lib/src/network/store.rs @@ -20,7 +20,7 @@ use super::{settings::NetworkConnection, NetworkClientError}; use crate::{ - base_http_client::BaseHTTPClient, + http::BaseHTTPClient, network::{NetworkClient, NetworkSettings}, }; diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs index 1ea24e9dcf..6beb33d6f4 100644 --- a/rust/agama-lib/src/product/http_client.rs +++ b/rust/agama-lib/src/product/http_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, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; use crate::software::model::{ AddonParams, RegistrationError, RegistrationInfo, RegistrationParams, SoftwareConfig, }; diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs index cf97d47b12..ebb9f76e76 100644 --- a/rust/agama-lib/src/product/store.rs +++ b/rust/agama-lib/src/product/store.rs @@ -21,7 +21,7 @@ //! Implements the store for the product settings. use super::{http_client::ProductHTTPClientError, ProductHTTPClient, ProductSettings}; use crate::{ - base_http_client::BaseHTTPClient, + http::BaseHTTPClient, manager::http_client::{ManagerHTTPClient, ManagerHTTPClientError}, }; @@ -114,7 +114,7 @@ impl ProductStore { #[cfg(test)] mod test { use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" diff --git a/rust/agama-lib/src/progress.rs b/rust/agama-lib/src/progress.rs index 2b43468a8d..e3c0eaadf1 100644 --- a/rust/agama-lib/src/progress.rs +++ b/rust/agama-lib/src/progress.rs @@ -18,62 +18,12 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! This module offers a mechanism to report the installation progress in Agama's command-line -//! interface. -//! -//! The library does not prescribe any way to present that information to the user. As shown in the -//! example below, you can build your own presenter and implement the [ProgressPresenter] trait. -//! -//! ```no_run -//! # use agama_lib::progress::{Progress, ProgressMonitor, ProgressPresenter}; -//! # use async_trait::async_trait; -//! # use tokio::{runtime::Handle, task}; -//! # use zbus; -//! -//! // Custom presenter -//! struct SimplePresenter {} -//! -//! impl SimplePresenter { -//! fn report_progress(&self, progress: &Progress) { -//! println!("{}/{} {}", &progress.current_step, &progress.max_steps, &progress.current_title); -//! } -//! } -//! -//! #[async_trait] -//! impl ProgressPresenter for SimplePresenter { -//! async fn start(&mut self, progress: &Progress) { -//! println!("Starting..."); -//! self.report_progress(progress); -//! } -//! -//! async fn update_main(&mut self, progress: &Progress) { -//! self.report_progress(progress); -//! } -//! -//! async fn update_detail(&mut self, progress: &Progress) { -//! self.report_progress(progress); -//! } -//! -//! async fn finish(&mut self) { -//! println!("Done"); -//! } -//! } -//! -//! async fn run_monitor() { -//! let connection = zbus::Connection::system().await.unwrap(); -//! let mut monitor = ProgressMonitor::new(connection).await.unwrap(); -//! monitor.run(SimplePresenter {}).await; -//!} -//! ``` +//! This module includes the struct that represent a service progress step. -use crate::{error::ServiceError, proxies::ProgressProxy}; -use async_trait::async_trait; -use serde::Serialize; -use tokio_stream::{StreamExt, StreamMap}; -use zbus::{proxy::PropertyStream, Connection}; +use serde::{Deserialize, Serialize}; /// Represents the progress for an Agama service. -#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +#[derive(Clone, Default, Debug, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Progress { /// Current step @@ -113,108 +63,3 @@ impl Progress { }) } } - -/// Monitorizes and reports the progress of Agama's current operation. -/// -/// It implements a main/details reporter by listening to the manager and software services, -/// similar to Agama's web UI. How this information is displayed depends on the presenter (see -/// [ProgressMonitor.run]). -pub struct ProgressMonitor<'a> { - manager_proxy: ProgressProxy<'a>, - software_proxy: ProgressProxy<'a>, -} - -impl<'a> ProgressMonitor<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - let manager_proxy = ProgressProxy::builder(&connection) - .path("/org/opensuse/Agama/Manager1")? - .destination("org.opensuse.Agama.Manager1")? - .build() - .await?; - - let software_proxy = ProgressProxy::builder(&connection) - .path("/org/opensuse/Agama/Software1")? - .destination("org.opensuse.Agama.Software1")? - .build() - .await?; - - Ok(Self { - manager_proxy, - software_proxy, - }) - } - - /// Runs the monitor until the current operation finishes. - pub async fn run(&mut self, mut presenter: impl ProgressPresenter) -> Result<(), ServiceError> { - presenter.start(&self.main_progress().await?).await; - let mut changes = self.build_stream().await; - - while let Some(stream) = changes.next().await { - match stream { - ("/org/opensuse/Agama/Manager1", _) => { - let progress = self.main_progress().await?; - if progress.finished { - presenter.finish().await; - return Ok(()); - } - presenter.update_main(&progress).await; - } - ("/org/opensuse/Agama/Software1", _) => { - let progress = &self.detail_progress().await?; - presenter.update_detail(progress).await; - } - _ => eprintln!("Unknown"), - }; - } - - Ok(()) - } - - /// Proxy that reports the progress. - async fn main_progress(&self) -> Result { - Ok(Progress::from_proxy(&self.manager_proxy).await?) - } - - /// Proxy that reports the progress detail. - async fn detail_progress(&self) -> Result { - Ok(Progress::from_proxy(&self.software_proxy).await?) - } - - /// Builds an stream of progress changes. - /// - /// It listens for changes in the `Current` property and generates a stream identifying the - /// proxy where the change comes from. - async fn build_stream(&self) -> StreamMap<&str, PropertyStream<'_, (u32, String)>> { - let mut streams = StreamMap::new(); - - let proxies = [&self.manager_proxy, &self.software_proxy]; - for proxy in proxies.iter() { - let stream = proxy.receive_current_step_changed().await; - let path = proxy.inner().path().as_str(); - streams.insert(path, stream); - } - streams - } -} - -/// Presents the progress to the user. -#[async_trait] -pub trait ProgressPresenter { - /// Starts the progress reporting. - /// - /// * `progress`: current main progress. - async fn start(&mut self, progress: &Progress); - - /// Updates the progress. - /// - /// * `progress`: current progress. - async fn update_main(&mut self, progress: &Progress); - - /// Updates the progress detail. - /// - /// * `progress`: current progress detail. - async fn update_detail(&mut self, progress: &Progress); - - /// Finishes the progress reporting. - async fn finish(&mut self); -} diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index 326403d25d..54771fd776 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, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; use super::model::{self, Answer, Question}; @@ -96,7 +96,7 @@ impl HTTPClient { mod test { use super::model::{GenericAnswer, GenericQuestion}; use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use std::collections::HashMap; use std::error::Error; diff --git a/rust/agama-lib/src/scripts/client.rs b/rust/agama-lib/src/scripts/client.rs index b0c9a299c4..fa4afdd271 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, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; use super::{Script, ScriptsGroup}; diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index ffdc3decfb..5ab57ebff4 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::{ - base_http_client::BaseHTTPClient, file_source::FileSourceError, + http::BaseHTTPClient, software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, }; diff --git a/rust/agama-lib/src/security/http_client.rs b/rust/agama-lib/src/security/http_client.rs index 404a2a8b28..764840475d 100644 --- a/rust/agama-lib/src/security/http_client.rs +++ b/rust/agama-lib/src/security/http_client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use super::model::SSLFingerprint; -use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; #[derive(Debug, thiserror::Error)] pub enum SecurityHTTPClientError { diff --git a/rust/agama-lib/src/security/store.rs b/rust/agama-lib/src/security/store.rs index 29674c3c1c..3b9b96fdd8 100644 --- a/rust/agama-lib/src/security/store.rs +++ b/rust/agama-lib/src/security/store.rs @@ -21,7 +21,7 @@ //! Implements the store for the security settings. use super::{settings::SecuritySettings, SecurityHTTPClient, SecurityHTTPClientError}; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; #[derive(Debug, thiserror::Error)] #[error("Error processing security settings: {0}")] diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 9ad2d0dc79..6a87843e18 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -24,7 +24,7 @@ use super::{ }; use crate::error::ServiceError; use serde::Serialize; -use serde_repr::Serialize_repr; +use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::HashMap; use zbus::Connection; @@ -49,7 +49,7 @@ pub struct Pattern { } /// Represents the reason why a pattern is selected. -#[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, utoipa::ToSchema)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize_repr, Serialize_repr, utoipa::ToSchema)] #[repr(u8)] pub enum SelectedBy { /// The pattern was selected by the user. diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs index 62aee73161..a20268d8ae 100644 --- a/rust/agama-lib/src/software/http_client.rs +++ b/rust/agama-lib/src/software/http_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, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; use crate::software::model::SoftwareConfig; use std::collections::HashMap; diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 136f50a25d..b6116da437 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -26,7 +26,7 @@ use super::{ http_client::SoftwareHTTPClientError, model::SoftwareConfig, SoftwareHTTPClient, SoftwareSettings, }; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; #[derive(Debug, thiserror::Error)] #[error("Error processing software settings: {0}")] @@ -81,7 +81,7 @@ impl SoftwareStore { #[cfg(test)] mod test { use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" diff --git a/rust/agama-lib/src/storage/client/iscsi.rs b/rust/agama-lib/src/storage/client/iscsi.rs index 4b0f1423b5..eddeed34c2 100644 --- a/rust/agama-lib/src/storage/client/iscsi.rs +++ b/rust/agama-lib/src/storage/client/iscsi.rs @@ -41,7 +41,7 @@ pub struct ISCSIInitiator { ibft: bool, } -#[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] /// ISCSI node pub struct ISCSINode { /// Artificial ID to match it against the D-Bus backend. diff --git a/rust/agama-lib/src/storage/http_client.rs b/rust/agama-lib/src/storage/http_client.rs index 74527cf342..3c89c19f2e 100644 --- a/rust/agama-lib/src/storage/http_client.rs +++ b/rust/agama-lib/src/storage/http_client.rs @@ -24,7 +24,7 @@ pub mod dasd; pub mod iscsi; use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, + http::{BaseHTTPClient, BaseHTTPClientError}, storage::StorageSettings, }; diff --git a/rust/agama-lib/src/storage/http_client/dasd.rs b/rust/agama-lib/src/storage/http_client/dasd.rs index e42974a88b..b9ffe9c087 100644 --- a/rust/agama-lib/src/storage/http_client/dasd.rs +++ b/rust/agama-lib/src/storage/http_client/dasd.rs @@ -21,7 +21,7 @@ //! Implements a client to access Agama's iscsi service. use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, + http::{BaseHTTPClient, BaseHTTPClientError}, storage::settings::dasd::DASDConfig, }; diff --git a/rust/agama-lib/src/storage/http_client/iscsi.rs b/rust/agama-lib/src/storage/http_client/iscsi.rs index 152b00ea3a..58aebd2c09 100644 --- a/rust/agama-lib/src/storage/http_client/iscsi.rs +++ b/rust/agama-lib/src/storage/http_client/iscsi.rs @@ -23,7 +23,7 @@ use serde_json::value::RawValue; use crate::{ - base_http_client::{BaseHTTPClient, BaseHTTPClientError}, + http::{BaseHTTPClient, BaseHTTPClientError}, storage::StorageSettings, }; diff --git a/rust/agama-lib/src/storage/model/dasd.rs b/rust/agama-lib/src/storage/model/dasd.rs index 02207e9d61..26c9bd4b5e 100644 --- a/rust/agama-lib/src/storage/model/dasd.rs +++ b/rust/agama-lib/src/storage/model/dasd.rs @@ -21,14 +21,14 @@ //! Implements a data model for DASD devices management. use std::collections::HashMap; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zbus::zvariant::OwnedValue; use crate::error::ServiceError; use agama_utils::dbus::get_property; /// Represents a DASD device (specific to s390x systems). -#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DASDDevice { pub id: String, @@ -41,7 +41,7 @@ pub struct DASDDevice { pub access_type: String, pub partition_info: String, } -#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, utoipa::ToSchema)] pub struct DASDFormatSummary { pub total: u32, pub step: u32, diff --git a/rust/agama-lib/src/storage/model/zfcp.rs b/rust/agama-lib/src/storage/model/zfcp.rs index e65f3cb1c8..6dd47e12f2 100644 --- a/rust/agama-lib/src/storage/model/zfcp.rs +++ b/rust/agama-lib/src/storage/model/zfcp.rs @@ -21,14 +21,14 @@ //! Implements a data model for zFCP devices management. use std::collections::HashMap; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zbus::zvariant::OwnedValue; use crate::error::ServiceError; use agama_utils::dbus::get_property; /// Represents a zFCP disk (specific to s390x systems). -#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPDisk { /// Name of the zFCP device (e.g., /dev/sda) @@ -55,7 +55,7 @@ impl TryFrom<&HashMap> for ZFCPDisk { } /// Represents a zFCP controller (specific to s390x systems). -#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPController { /// unique internal ID for given controller diff --git a/rust/agama-lib/src/storage/store.rs b/rust/agama-lib/src/storage/store.rs index 00580b975c..a9f1982978 100644 --- a/rust/agama-lib/src/storage/store.rs +++ b/rust/agama-lib/src/storage/store.rs @@ -23,7 +23,7 @@ pub mod dasd; use super::{http_client::StorageHTTPClientError, StorageSettings}; -use crate::{base_http_client::BaseHTTPClient, storage::http_client::StorageHTTPClient}; +use crate::{http::BaseHTTPClient, storage::http_client::StorageHTTPClient}; #[derive(Debug, thiserror::Error)] #[error("Error processing storage settings: {0}")] @@ -56,7 +56,7 @@ impl StorageStore { #[cfg(test)] mod test { use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" diff --git a/rust/agama-lib/src/storage/store/dasd.rs b/rust/agama-lib/src/storage/store/dasd.rs index 07c21278d6..3de60448b7 100644 --- a/rust/agama-lib/src/storage/store/dasd.rs +++ b/rust/agama-lib/src/storage/store/dasd.rs @@ -21,7 +21,7 @@ //! Implements the store for the storage settings. use crate::{ - base_http_client::BaseHTTPClient, + http::BaseHTTPClient, storage::{ http_client::dasd::{DASDHTTPClient, DASDHTTPClientError}, settings::dasd::DASDConfig, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 1177b9b9d1..396ff9bd7a 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -22,10 +22,10 @@ // TODO: quickly explain difference between FooSettings and FooStore, with an example use crate::{ - base_http_client::BaseHTTPClient, bootloader::store::{BootloaderStore, BootloaderStoreError}, files::store::{FilesStore, FilesStoreError}, hostname::store::{HostnameStore, HostnameStoreError}, + http::BaseHTTPClient, install_settings::InstallSettings, localization::{LocalizationStore, LocalizationStoreError}, manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs index 77708bff29..52808739ad 100644 --- a/rust/agama-lib/src/users/http_client.rs +++ b/rust/agama-lib/src/users/http_client.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use super::client::{FirstUser, RootUser}; -use crate::base_http_client::{BaseHTTPClient, BaseHTTPClientError}; +use crate::http::{BaseHTTPClient, BaseHTTPClientError}; use crate::users::model::RootPatchSettings; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 1b54aab71e..63b85393e0 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -22,7 +22,7 @@ use super::{ http_client::UsersHTTPClientError, FirstUser, FirstUserSettings, RootUserSettings, UserSettings, UsersHTTPClient, }; -use crate::base_http_client::BaseHTTPClient; +use crate::http::BaseHTTPClient; #[derive(Debug, thiserror::Error)] #[error("Error processing users options: {0}")] @@ -111,7 +111,7 @@ impl UsersStore { #[cfg(test)] mod test { use super::*; - use crate::base_http_client::BaseHTTPClient; + use crate::http::BaseHTTPClient; use httpmock::prelude::*; use httpmock::Method::PATCH; use std::error::Error; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 32ca60cdae..2437539cd7 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -478,7 +478,7 @@ pub struct AccessPoint { /// Network device #[serde_as] #[skip_serializing_none] -#[derive(Default, Debug, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Device { pub name: String, @@ -798,7 +798,7 @@ impl From for zbus::fdo::Error { } #[skip_serializing_none] -#[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] +#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpConfig { pub method4: Ipv4Method, @@ -826,7 +826,7 @@ pub struct IpConfig { } #[skip_serializing_none] -#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct Dhcp4Settings { pub send_hostname: bool, pub hostname: Option, @@ -847,7 +847,7 @@ impl Default for Dhcp4Settings { } } -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub enum DhcpClientId { Id(String), Mac, @@ -900,7 +900,7 @@ impl fmt::Display for DhcpClientId { } } -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub enum DhcpIaid { Id(String), Mac, @@ -948,7 +948,7 @@ impl fmt::Display for DhcpIaid { } #[skip_serializing_none] -#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct Dhcp6Settings { pub send_hostname: bool, pub hostname: Option, @@ -969,7 +969,7 @@ impl Default for Dhcp6Settings { } } -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub enum DhcpDuid { Id(String), Lease, @@ -1039,7 +1039,7 @@ pub struct MatchConfig { #[error("Unknown IP configuration method name: {0}")] pub struct UnknownIpMethod(String); -#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv4Method { #[default] @@ -1075,7 +1075,7 @@ impl FromStr for Ipv4Method { } } -#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv6Method { #[default] @@ -1123,7 +1123,7 @@ impl From for zbus::fdo::Error { } } -#[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpRoute { #[schema(schema_with = schemas::ip_inet_ref)] @@ -1772,7 +1772,7 @@ pub struct TunConfig { } /// Represents a network change. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum NetworkChange { /// A new device has been added. diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs index 7eb500004a..a4e138e611 100644 --- a/rust/agama-server/src/l10n/web.rs +++ b/rust/agama-server/src/l10n/web.rs @@ -24,12 +24,9 @@ use super::{ error::LocaleError, model::{keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n}, }; -use crate::{ - error::Error, - web::{Event, EventsSender}, -}; +use crate::{error::Error, web::EventsSender}; use agama_lib::{ - error::ServiceError, localization::model::LocaleConfig, localization::LocaleProxy, + error::ServiceError, http::Event, localization::model::LocaleConfig, localization::LocaleProxy, proxies::LocaleMixinProxy as ManagerLocaleProxy, }; use agama_locale_data::LocaleId; diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 1aa4ad2931..5a093dabe3 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -45,11 +45,9 @@ use tokio_util::io::ReaderStream; use crate::{ error::Error, - web::{ - common::{progress_router, service_status_router}, - Event, - }, + web::common::{progress_router, service_status_router}, }; +use agama_lib::http::Event; #[derive(Clone)] pub struct ManagerState<'a> { diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index 6a49670efa..f8fa1f38be 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -20,10 +20,8 @@ //! This module implements the web API for the network module. -use crate::{ - error::Error, - web::{Event, EventsSender}, -}; +use crate::{error::Error, web::EventsSender}; +use agama_lib::http::Event; use anyhow::Context; use axum::{ extract::{Path, State}, diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs index e834c9d12f..59e37f406c 100644 --- a/rust/agama-server/src/questions/web.rs +++ b/rust/agama-server/src/questions/web.rs @@ -25,9 +25,10 @@ //! * `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 crate::error::Error; use agama_lib::{ error::ServiceError, + http::Event, proxies::questions::{GenericQuestionProxy, QuestionWithPasswordProxy, QuestionsProxy}, questions::model::{Answer, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, }; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index d0136452df..8c8f86855b 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -27,14 +27,12 @@ use crate::{ error::Error, - web::{ - common::{issues_router, progress_router, service_status_router, EventStreams}, - Event, - }, + web::common::{issues_router, progress_router, service_status_router, EventStreams}, }; use agama_lib::{ error::ServiceError, + http::Event, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{ diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 0667ccc4e3..f492b48e07 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -54,13 +54,11 @@ use crate::{ dasd::{dasd_service, dasd_stream}, iscsi::iscsi_stream, }, - web::{ - common::{ - issues_router, jobs_service, progress_router, service_status_router, EventStreams, - }, - Event, + web::common::{ + issues_router, jobs_service, progress_router, service_status_router, EventStreams, }, }; +use agama_lib::http::Event; pub async fn storage_streams(dbus: zbus::Connection) -> Result { let mut result: EventStreams = vec![( diff --git a/rust/agama-server/src/storage/web/dasd/stream.rs b/rust/agama-server/src/storage/web/dasd/stream.rs index 273a12c93d..f19e11eb6e 100644 --- a/rust/agama-server/src/storage/web/dasd/stream.rs +++ b/rust/agama-server/src/storage/web/dasd/stream.rs @@ -24,6 +24,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + http::Event, storage::{ client::dasd::DASDClient, model::dasd::{DASDDevice, DASDFormatSummary}, @@ -42,10 +43,7 @@ use zbus::{ MatchRule, Message, MessageStream, }; -use crate::{ - dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, - web::Event, -}; +use crate::dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}; #[derive(Debug, Error)] enum DASDDeviceStreamError { diff --git a/rust/agama-server/src/storage/web/iscsi.rs b/rust/agama-server/src/storage/web/iscsi.rs index b16ed1cc88..b5d27deb81 100644 --- a/rust/agama-server/src/storage/web/iscsi.rs +++ b/rust/agama-server/src/storage/web/iscsi.rs @@ -27,13 +27,11 @@ use crate::{ error::Error, - web::{ - common::{issues_router, EventStreams}, - Event, - }, + web::common::{issues_router, EventStreams}, }; use agama_lib::{ error::ServiceError, + http::Event, storage::{ client::iscsi::{ISCSIAuth, ISCSIInitiator, ISCSINode, LoginResult}, ISCSIClient, diff --git a/rust/agama-server/src/storage/web/iscsi/stream.rs b/rust/agama-server/src/storage/web/iscsi/stream.rs index 391ce36dbc..3de2e94a7a 100644 --- a/rust/agama-server/src/storage/web/iscsi/stream.rs +++ b/rust/agama-server/src/storage/web/iscsi/stream.rs @@ -22,6 +22,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + http::Event, storage::{ISCSIClient, ISCSINode}, }; use agama_utils::{ @@ -35,10 +36,7 @@ use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; -use crate::{ - dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, - web::Event, -}; +use crate::dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}; /// This stream listens for changes in the collection ISCSI nodes and emits /// the updated objects. diff --git a/rust/agama-server/src/storage/web/zfcp/stream.rs b/rust/agama-server/src/storage/web/zfcp/stream.rs index 15722e1f3e..f58be37d73 100644 --- a/rust/agama-server/src/storage/web/zfcp/stream.rs +++ b/rust/agama-server/src/storage/web/zfcp/stream.rs @@ -24,6 +24,7 @@ use std::{collections::HashMap, task::Poll}; use agama_lib::{ error::ServiceError, + http::Event, storage::{ client::zfcp::ZFCPClient, model::zfcp::{ZFCPController, ZFCPDisk}, @@ -37,10 +38,7 @@ use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; -use crate::{ - dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, - web::Event, -}; +use crate::dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}; #[derive(Debug, Error)] enum ZFCPDiskStreamError { diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index 0b7912e74e..31d463ad73 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -26,13 +26,11 @@ use crate::{ error::Error, - web::{ - common::{issues_router, service_status_router, EventStreams}, - Event, - }, + web::common::{issues_router, service_status_router, EventStreams}, }; use agama_lib::{ error::ServiceError, + http::Event, users::{model::RootPatchSettings, proxies::Users1Proxy, FirstUser, RootUser, UsersClient}, }; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 6e8f83cca2..f4035e4a13 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -53,9 +53,9 @@ mod service; mod state; mod ws; -use agama_lib::{connection, error::ServiceError}; +use agama_lib::{connection, error::ServiceError, http::Event}; pub use config::ServiceConfig; -pub use event::{Event, EventsReceiver, EventsSender}; +pub use event::{EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; use std::path::Path; use tokio_stream::{StreamExt, StreamMap}; diff --git a/rust/agama-server/src/web/auth.rs b/rust/agama-server/src/web/auth.rs index a268dc829b..5faff69ef3 100644 --- a/rust/agama-server/src/web/auth.rs +++ b/rust/agama-server/src/web/auth.rs @@ -56,7 +56,7 @@ impl IntoResponse for AuthError { let body = json!({ "error": self.to_string() }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() + (StatusCode::UNAUTHORIZED, Json(body)).into_response() } } diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index 50a6295c76..643c6b16e6 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -24,6 +24,7 @@ use std::{pin::Pin, task::Poll}; use agama_lib::{ error::ServiceError, + issue::Issue, progress::Progress, proxies::{IssuesProxy, ProgressProxy, ServiceStatusProxy}, }; @@ -319,35 +320,6 @@ struct IssuesState<'a> { proxy: IssuesProxy<'a>, } -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] -pub struct Issue { - description: String, - details: Option, - source: u32, - severity: u32, - kind: String, -} - -impl Issue { - pub fn from_tuple( - (description, kind, details, source, severity): (String, String, String, u32, u32), - ) -> Self { - let details = if details.is_empty() { - None - } else { - Some(details) - }; - - Self { - description, - kind, - details, - source, - severity, - } - } -} - /// Builds a stream of the changes in the the `org.opensuse.Agama1.Issues` /// interface of the given D-Bus object. /// diff --git a/rust/agama-server/src/web/docs/common.rs b/rust/agama-server/src/web/docs/common.rs index 0a5015cb87..64e63243a0 100644 --- a/rust/agama-server/src/web/docs/common.rs +++ b/rust/agama-server/src/web/docs/common.rs @@ -22,8 +22,8 @@ //! (e.g., issues, service status or progress). use super::ApiDocBuilder; -use crate::web::common::{Issue, ServiceStatus}; -use agama_lib::progress::Progress; +use crate::web::common::ServiceStatus; +use agama_lib::{issue::Issue, progress::Progress}; use utoipa::openapi::{ path::OperationBuilder, schema::RefBuilder, ArrayBuilder, Components, ComponentsBuilder, ContentBuilder, HttpMethod, PathItem, Paths, PathsBuilder, ResponseBuilder, ResponsesBuilder, diff --git a/rust/agama-server/src/web/docs/software.rs b/rust/agama-server/src/web/docs/software.rs index 0eccb3d8cd..6f694e7d17 100644 --- a/rust/agama-server/src/web/docs/software.rs +++ b/rust/agama-server/src/web/docs/software.rs @@ -52,6 +52,7 @@ impl ApiDocBuilder for SoftwareApiDocBuilder { fn components(&self) -> Components { ComponentsBuilder::new() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -66,7 +67,6 @@ impl ApiDocBuilder for SoftwareApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .build() } diff --git a/rust/agama-server/src/web/docs/storage.rs b/rust/agama-server/src/web/docs/storage.rs index 08c781f72c..c1d7723c72 100644 --- a/rust/agama-server/src/web/docs/storage.rs +++ b/rust/agama-server/src/web/docs/storage.rs @@ -78,6 +78,7 @@ impl ApiDocBuilder for StorageApiDocBuilder { fn components(&self) -> Components { ComponentsBuilder::new() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -121,7 +122,6 @@ impl ApiDocBuilder for StorageApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .build() } diff --git a/rust/agama-server/src/web/docs/users.rs b/rust/agama-server/src/web/docs/users.rs index 4f32219cb4..27bd34b744 100644 --- a/rust/agama-server/src/web/docs/users.rs +++ b/rust/agama-server/src/web/docs/users.rs @@ -44,10 +44,10 @@ impl ApiDocBuilder for UsersApiDocBuilder { fn components(&self) -> utoipa::openapi::Components { ComponentsBuilder::new() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema( "zbus.zvariant.OwnedValue", utoipa::openapi::ObjectBuilder::new() diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index a33e3ed19b..beb7611e33 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -18,131 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_lib::network::model::NetworkChange; -use agama_lib::{ - jobs::Job, - localization::model::LocaleConfig, - manager::InstallationPhase, - progress::Progress, - software::SelectedBy, - storage::{ - model::{ - dasd::{DASDDevice, DASDFormatSummary}, - zfcp::{ZFCPController, ZFCPDisk}, - }, - ISCSINode, - }, - users::{FirstUser, RootUser}, -}; -use serde::Serialize; -use std::collections::HashMap; +use agama_lib::http::Event; use tokio::sync::broadcast::{Receiver, Sender}; -use super::common::Issue; - -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "type")] -pub enum Event { - L10nConfigChanged(LocaleConfig), - LocaleChanged { - locale: String, - }, - DevicesDirty { - dirty: bool, - }, - Progress { - service: String, - #[serde(flatten)] - progress: Progress, - }, - ProductChanged { - id: String, - }, - RegistrationChanged, - FirstUserChanged(FirstUser), - RootUserChanged(RootUser), - NetworkChange { - #[serde(flatten)] - change: NetworkChange, - }, - // TODO: it should include the full software proposal or, at least, - // all the relevant changes. - SoftwareProposalChanged { - patterns: HashMap, - }, - QuestionsChanged, - InstallationPhaseChanged { - phase: InstallationPhase, - }, - ServiceStatusChanged { - service: String, - status: u32, - }, - IssuesChanged { - service: String, - path: String, - issues: Vec, - }, - ValidationChanged { - service: String, - path: String, - errors: Vec, - }, - ISCSINodeAdded { - node: ISCSINode, - }, - ISCSINodeChanged { - node: ISCSINode, - }, - ISCSINodeRemoved { - node: ISCSINode, - }, - ISCSIInitiatorChanged { - name: Option, - ibft: Option, - }, - DASDDeviceAdded { - device: DASDDevice, - }, - DASDDeviceChanged { - device: DASDDevice, - }, - DASDDeviceRemoved { - device: DASDDevice, - }, - JobAdded { - job: Job, - }, - JobChanged { - job: Job, - }, - JobRemoved { - job: Job, - }, - DASDFormatJobChanged { - #[serde(rename = "jobId")] - job_id: String, - summary: HashMap, - }, - ZFCPDiskAdded { - device: ZFCPDisk, - }, - ZFCPDiskChanged { - device: ZFCPDisk, - }, - ZFCPDiskRemoved { - device: ZFCPDisk, - }, - ZFCPControllerAdded { - device: ZFCPController, - }, - ZFCPControllerChanged { - device: ZFCPController, - }, - ZFCPControllerRemoved { - device: ZFCPController, - }, -} - pub type EventsSender = Sender; pub type EventsReceiver = Receiver; diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index 63a5535a6d..cdc843c5cd 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -100,6 +100,6 @@ async fn test_access_protected_route() -> Result<(), Box> { async fn test_access_protected_route_failed() -> Result<(), Box> { let token = AuthToken::generate("nots3cr3t")?; let response = access_protected_route(token.as_str(), "wrong").await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); Ok(()) } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 7807d0ae96..46f111a53d 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,12 @@ +------------------------------------------------------------------- +Mon May 19 12:02:46 UTC 2025 - Imobach Gonzalez Sosa + +- Adapt "install", "probe" and "finish" to use the HTTP API + (gh#agama-project/agama#2368). +- Add commands for monitoring Agama (gh#agama-project/agama#2368): + - "monitor": to display the progress. + - "events": to display the events in JSON format. + ------------------------------------------------------------------- Wed May 14 15:17:25 UTC 2025 - José Iván López González