From ee654a55fca9a1eb22e9f8d893e6031b5dbf005b Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Fri, 25 Oct 2024 09:44:04 +0200 Subject: [PATCH 01/18] Introduced /logs HTTP API --- rust/agama-server/src/web/http.rs | 13 +++++++++++++ rust/agama-server/src/web/service.rs | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 3b019a7da5..b6ef951f41 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -34,6 +34,19 @@ use pam::Client; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +#[derive(Serialize, ToSchema)] +pub struct LogsResponse { + /// Logs archive file name + logs: String, +} + +#[utoipa::path(get, path = "/logs", responses( + (status = 200, description = "Compressed Agama logs", body = LogsResponse) +))] +pub async fn logs() -> Json { + Json(LogsResponse { logs: "/path/to/file".to_string() }) +} + #[derive(Serialize, ToSchema)] pub struct PingResponse { /// API status diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index fb68656115..a4a7d3b667 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -107,8 +107,10 @@ impl MainServiceBuilder { state.clone(), )) .route("/ping", get(super::http::ping)) - .route("/auth", post(login).get(session).delete(logout)); + .route("/auth", post(login).get(session).delete(logout)) + .route("/logs", get(super::http::logs)); + eprintln!("Modified router"); tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir).precompressed_gzip(); From af6bedc646bfcb76689d9722d6b038613167008d Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Tue, 29 Oct 2024 19:38:51 +0100 Subject: [PATCH 02/18] Moved logs compression from CLI to lib, using it in HTTP logs API --- rust/Cargo.lock | 2 + rust/agama-lib/Cargo.toml | 1 + rust/agama-lib/src/lib.rs | 1 + rust/agama-lib/src/logs.rs | 337 +++++++++++++++++++++++++++ rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/web/http.rs | 35 ++- rust/agama-server/src/web/service.rs | 1 - 7 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 rust/agama-lib/src/logs.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 841bfd2c53..805a185add 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -49,6 +49,7 @@ dependencies = [ "cidr", "curl", "env_logger", + "fs_extra", "futures-util", "home", "httpmock", @@ -120,6 +121,7 @@ dependencies = [ "tokio-openssl", "tokio-stream", "tokio-test", + "tokio-util", "tower 0.4.13", "tower-http", "tracing", diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 016001d9fc..f9278389ca 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -34,6 +34,7 @@ chrono = { version = "0.4.38", default-features = false, features = [ ] } home = "0.5.9" strum = { version = "0.26.3", features = ["derive"] } +fs_extra = "1.3.0" [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index c2b4f607da..13fd52c99a 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -49,6 +49,7 @@ pub mod error; pub mod install_settings; pub mod jobs; pub mod localization; +pub mod logs; pub mod manager; pub mod network; pub mod product; diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs new file mode 100644 index 0000000000..4206956632 --- /dev/null +++ b/rust/agama-lib/src/logs.rs @@ -0,0 +1,337 @@ +// 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 fs_extra::copy_items; +use fs_extra::dir::CopyOptions; +use std::fs; +use std::fs::File; +use std::io; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +const DEFAULT_COMMANDS: [(&str, &str); 3] = [ + // (, ) + ("journalctl -u agama", "agama"), + ("journalctl -u agama-auto", "agama-auto"), + ("journalctl --dmesg", "dmesg"), +]; + +const DEFAULT_PATHS: [&str; 14] = [ + // logs + "/var/log/YaST2", + "/var/log/zypper.log", + "/var/log/zypper/history*", + "/var/log/zypper/pk_backend_zypp", + "/var/log/pbl.log", + "/var/log/linuxrc.log", + "/var/log/wickedd.log", + "/var/log/NetworkManager", + "/var/log/messages", + "/var/log/boot.msg", + "/var/log/udev.log", + // config + "/etc/install.inf", + "/etc/os-release", + "/linuxrc.config", +]; + +const DEFAULT_RESULT: &str = "/tmp/agama-logs"; +// what compression is used by default: +// (, ) +const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); +const TMP_DIR_PREFIX: &str = "agama-logs."; + +/// Configurable parameters of the "agama logs" which can be +/// set by user when calling a (sub)command +pub struct LogOptions { + paths: Vec, + commands: Vec<(String, String)>, + destination: PathBuf, +} + +impl Default for LogOptions { + fn default() -> Self { + Self { + paths: DEFAULT_PATHS.iter().map(|p| p.to_string()).collect(), + commands: DEFAULT_COMMANDS + .iter() + .map(|(cmd, name)| (cmd.to_string(), name.to_string())) + .collect(), + destination: PathBuf::from(DEFAULT_RESULT), + } + } +} + +/// Struct for log represented by a file +struct LogPath { + // log source + src_path: String, + + // directory where to collect logs + dst_path: PathBuf, +} + +impl LogPath { + fn new(src: &str, dst: &Path) -> Self { + Self { + src_path: src.to_string(), + dst_path: dst.to_owned(), + } + } +} + +/// Struct for log created on demand by a command +struct LogCmd { + // command which stdout / stderr is logged + cmd: String, + + // user defined log file name (if any) + file_name: String, + + // place where to collect logs + dst_path: PathBuf, +} + +impl LogCmd { + fn new(cmd: &str, file_name: &str, dst: &Path) -> Self { + Self { + cmd: cmd.to_string(), + file_name: file_name.to_string(), + dst_path: dst.to_owned(), + } + } +} + +trait LogItem { + // definition of destination as path to a file + fn to(&self) -> PathBuf; + + // performs whatever is needed to store logs from "from" at "to" path + fn store(&self) -> Result<(), io::Error>; +} + +impl LogItem for LogPath { + fn to(&self) -> PathBuf { + // remove leading '/' if any from the path (reason see later) + let r_path = Path::new(self.src_path.as_str()).strip_prefix("/").unwrap(); + + // here is the reason, join overwrites the content if the joined path is absolute + self.dst_path.join(r_path) + } + + fn store(&self) -> Result<(), io::Error> { + let dst_file = self.to(); + let dst_path = dst_file.parent().unwrap(); + + // for now keep directory structure close to the original + // e.g. what was in /etc will be in //etc/ + fs::create_dir_all(dst_path)?; + + let options = CopyOptions::new(); + // fs_extra's own Error doesn't implement From trait so ? operator is unusable + match copy_items(&[self.src_path.as_str()], dst_path, &options) { + Ok(_p) => Ok(()), + Err(_e) => Err(io::Error::new( + io::ErrorKind::Other, + "Copying of a file failed", + )), + } + } +} + +impl LogItem for LogCmd { + fn to(&self) -> PathBuf { + let mut file_name; + + if self.file_name.is_empty() { + file_name = self.cmd.clone(); + } else { + file_name = self.file_name.clone(); + }; + + file_name.retain(|c| c != ' '); + self.dst_path.as_path().join(&file_name) + } + + fn store(&self) -> Result<(), io::Error> { + let cmd_parts = self.cmd.split_whitespace().collect::>(); + let file_path = self.to(); + let output = Command::new(cmd_parts[0]) + .args(cmd_parts[1..].iter()) + .output()?; + let mut file_stdout = File::create(format!("{}.out.log", file_path.display()))?; + let mut file_stderr = File::create(format!("{}.err.log", file_path.display()))?; + + file_stdout.write_all(&output.stdout)?; + file_stderr.write_all(&output.stderr)?; + + Ok(()) + } +} + +/// Collect existing / requested paths which should already exist in the system. +/// Turns them into list of log sources +fn paths_to_log_sources(paths: &[String], tmp_dir: &TempDir) -> Vec> { + let mut log_sources: Vec> = Vec::new(); + + for path in paths.iter() { + // assumption: path is full path + if Path::new(path).try_exists().is_ok() { + log_sources.push(Box::new(LogPath::new(path.as_str(), tmp_dir.path()))); + } + } + + log_sources +} + +/// Some info can be collected via particular commands only, turn it into log sources +fn cmds_to_log_sources(commands: &[(String, String)], tmp_dir: &TempDir) -> Vec> { + let mut log_sources: Vec> = Vec::new(); + + for cmd in commands.iter() { + log_sources.push(Box::new(LogCmd::new( + cmd.0.as_str(), + cmd.1.as_str(), + tmp_dir.path(), + ))); + } + + log_sources +} + +/// Compress given directory into a tar archive +fn compress_logs(tmp_dir: &TempDir, result: &String) -> io::Result<()> { + let compression = DEFAULT_COMPRESSION.0; + let tmp_path = tmp_dir + .path() + .parent() + .and_then(|p| p.as_os_str().to_str()) + .ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Malformed path to temporary directory", + ))?; + let dir = tmp_dir + .path() + .file_name() + .and_then(|f| f.to_str()) + .ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Malformed path to temporary director", + ))?; + let compress_cmd = format!( + "tar -c -f {} --warning=no-file-changed --{} --dereference -C {} {}", + result, compression, tmp_path, dir, + ); + let cmd_parts = compress_cmd.split_whitespace().collect::>(); + let res = Command::new(cmd_parts[0]) + .args(cmd_parts[1..].iter()) + .status()?; + + if res.success() { + set_archive_permissions(result) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + "Cannot create tar archive", + )) + } +} + +/// Sets the archive owner to root:root. Also sets the file permissions to read/write for the +/// owner only. +fn set_archive_permissions(archive: &String) -> io::Result<()> { + let attr = fs::metadata(archive)?; + let mut permissions = attr.permissions(); + + // set the archive file permissions to -rw------- + permissions.set_mode(0o600); + fs::set_permissions(archive, permissions)?; + + // set the archive owner to root:root + // note: std::os::unix::fs::chown is unstable for now + Command::new("chown") + .args(["root:root", archive.as_str()]) + .status()?; + + Ok(()) +} + +/// Handler for the "agama logs store" subcommand +pub fn store(options: LogOptions) -> Result { + // preparation, e.g. in later features some log commands can be added / excluded per users request or + let commands = options.commands; + let paths = options.paths; + let opt_dest = options.destination.into_os_string(); + let destination = opt_dest.to_str().ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Malformed destination path", + ))?; + let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); + + // create temporary directory where to collect all files (similar to what old save_y2logs + // does) + let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; + let mut log_sources = paths_to_log_sources(&paths, &tmp_dir); + + log_sources.append(&mut cmds_to_log_sources(&commands, &tmp_dir)); + + // some info can be collected via particular commands only + // store it + for log in log_sources.iter() { + // for now keep directory structure close to the original + // e.g. what was in /etc will be in //etc/ + + // TODO: deal with errors properly here (no more strings, and so on) + // may be switch to common agamalib's Service errors + let res = match fs::create_dir_all(log.to().parent().unwrap()) { + Ok(_p) => match log.store() { + Ok(_p) => "[Ok]", + Err(_e) => "[Failed]", + }, + Err(_e) => "[Failed]", + }; + } + + compress_logs(&tmp_dir, &result); + + Ok(String::from(result)) +} + +/// Handler for the "agama logs list" subcommand +fn list(options: LogOptions) { + for list in [ + ("Log paths: ", options.paths), + ( + "Log commands: ", + options.commands.iter().map(|c| c.0.clone()).collect(), + ), + ] { + println!("{}", list.0); + + for item in list.1.iter() { + println!("\t{}", item); + } + + println!(); + } +} diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index cdf61440f7..5004271e50 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -48,6 +48,7 @@ futures-util = { version = "0.3.30", default-features = false, features = [ libsystemd = "0.7.0" subprocess = "0.2.9" gethostname = "0.4.3" +tokio-util = "0.7.12" [[bin]] name = "agama-dbus-server" diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index b6ef951f41..53df7677e0 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -22,6 +22,7 @@ use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; +use agama_lib::logs::{LogOptions, store as storeLogs}; use axum::{ body::Body, extract::{Query, State}, @@ -32,6 +33,7 @@ use axum::{ use axum_extra::extract::cookie::CookieJar; use pam::Client; use serde::{Deserialize, Serialize}; +use tokio_util::io::ReaderStream; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] @@ -40,11 +42,40 @@ pub struct LogsResponse { logs: String, } +// For development only - a mockup of logs archive creation. +async fn store() -> Result +{ + let options = LogOptions::default(); + let path = storeLogs(options).unwrap(); + + Ok(path) +} + #[utoipa::path(get, path = "/logs", responses( (status = 200, description = "Compressed Agama logs", body = LogsResponse) ))] -pub async fn logs() -> Json { - Json(LogsResponse { logs: "/path/to/file".to_string() }) +pub async fn logs() -> impl IntoResponse { + // TODO: require authorization + let mut headers = HeaderMap::new(); + + match store().await { + Ok(path) => { + let file = tokio::fs::File::open(path.clone()).await.unwrap(); + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + // TODO: should be only filename! + let disposition = format!("attachment; filename=\"{}\"", path); + + headers.insert(header::CONTENT_TYPE, "text/toml; charset=utf-8".parse().unwrap()); + headers.insert(header::CONTENT_DISPOSITION, disposition.parse().unwrap()); + + (headers, body) + } + Err(_) => { + // fill in with meaningful headers + (headers, Body::empty()) + } + } } #[derive(Serialize, ToSchema)] diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index a4a7d3b667..c8cc25e271 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -110,7 +110,6 @@ impl MainServiceBuilder { .route("/auth", post(login).get(session).delete(logout)) .route("/logs", get(super::http::logs)); - eprintln!("Modified router"); tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir).precompressed_gzip(); From c5f5bf64a5f5d4abfec20102397ac23924e57ffe Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Wed, 30 Oct 2024 11:17:09 +0100 Subject: [PATCH 03/18] Agama CLI uses new HTTP API for downloading logs --- rust/agama-cli/src/lib.rs | 4 +- rust/agama-cli/src/logs.rs | 377 ++----------------------- rust/agama-lib/src/base_http_client.rs | 13 +- rust/agama-lib/src/logs.rs | 4 +- rust/agama-lib/src/logs/http_client.rs | 63 +++++ rust/agama-server/src/web/http.rs | 4 +- 6 files changed, 112 insertions(+), 353 deletions(-) create mode 100644 rust/agama-lib/src/logs/http_client.rs diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index 3bec4aa2ec..af02837a51 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -212,9 +212,7 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { install(&manager, 3).await? } Commands::Questions(subcommand) => run_questions_cmd(client, subcommand).await?, - // TODO: logs command was originally designed with idea that agama's cli and agama - // installation runs on the same machine, so it is unable to do remote connection - Commands::Logs(subcommand) => run_logs_cmd(subcommand).await?, + Commands::Logs(subcommand) => run_logs_cmd(client, subcommand).await?, Commands::Download { url } => Transfer::get(&url, std::io::stdout())?, Commands::Auth(subcommand) => { run_auth_cmd(client, subcommand).await?; diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index f4fe4d9edb..337ca4e7d8 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -18,18 +18,32 @@ // 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::logs::http_client::HTTPClient; use clap::Subcommand; -use fs_extra::copy_items; -use fs_extra::dir::CopyOptions; -use nix::unistd::Uid; use std::fs; -use std::fs::File; use std::io; -use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; -use tempfile::TempDir; + +/// A wrapper around println which shows (or not) the text depending on the boolean variable +fn showln(show: bool, text: &str) { + if !show { + return; + } + + println!("{}", text); +} + +/// A wrapper around println which shows (or not) the text depending on the boolean variable +fn show(show: bool, text: &str) { + if !show { + return; + } + + print!("{}", text); +} // definition of "agama logs" subcommands, see clap crate for details #[derive(Subcommand, Debug)] @@ -49,7 +63,9 @@ pub enum LogsCommands { } /// Main entry point called from agama CLI main loop -pub async fn run(subcommand: LogsCommands) -> anyhow::Result<()> { +pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Result<()> { + let client = HTTPClient::new(client)?; + match subcommand { LogsCommands::Store { verbose, @@ -58,17 +74,14 @@ pub async fn run(subcommand: LogsCommands) -> anyhow::Result<()> { // feed internal options structure by what was received from user // for now we always use / add defaults if any let destination = parse_destination(destination)?; - let options = LogOptions { - verbose, - destination, - ..Default::default() - }; - Ok(store(options)?) + // TODO: deal with return values + let destination = client.store(destination.as_path()).await.unwrap(); + showln(verbose, format!("{:?}", destination).as_str()); + + Ok(()) } LogsCommands::List => { - list(LogOptions::default()); - Ok(()) } } @@ -106,264 +119,7 @@ fn parse_destination(destination: Option) -> Result Ok(buffer) } -const DEFAULT_COMMANDS: [(&str, &str); 3] = [ - // (, ) - ("journalctl -u agama", "agama"), - ("journalctl -u agama-auto", "agama-auto"), - ("journalctl --dmesg", "dmesg"), -]; - -const DEFAULT_PATHS: [&str; 14] = [ - // logs - "/var/log/YaST2", - "/var/log/zypper.log", - "/var/log/zypper/history*", - "/var/log/zypper/pk_backend_zypp", - "/var/log/pbl.log", - "/var/log/linuxrc.log", - "/var/log/wickedd.log", - "/var/log/NetworkManager", - "/var/log/messages", - "/var/log/boot.msg", - "/var/log/udev.log", - // config - "/etc/install.inf", - "/etc/os-release", - "/linuxrc.config", -]; - const DEFAULT_RESULT: &str = "/tmp/agama-logs"; -// what compression is used by default: -// (, ) -const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); -const TMP_DIR_PREFIX: &str = "agama-logs."; - -/// A wrapper around println which shows (or not) the text depending on the boolean variable -fn showln(show: bool, text: &str) { - if !show { - return; - } - - println!("{}", text); -} - -/// A wrapper around println which shows (or not) the text depending on the boolean variable -fn show(show: bool, text: &str) { - if !show { - return; - } - - print!("{}", text); -} - -/// Configurable parameters of the "agama logs" which can be -/// set by user when calling a (sub)command -struct LogOptions { - paths: Vec, - commands: Vec<(String, String)>, - verbose: bool, - destination: PathBuf, -} - -impl Default for LogOptions { - fn default() -> Self { - Self { - paths: DEFAULT_PATHS.iter().map(|p| p.to_string()).collect(), - commands: DEFAULT_COMMANDS - .iter() - .map(|(cmd, name)| (cmd.to_string(), name.to_string())) - .collect(), - verbose: false, - destination: PathBuf::from(DEFAULT_RESULT), - } - } -} - -/// Struct for log represented by a file -struct LogPath { - // log source - src_path: String, - - // directory where to collect logs - dst_path: PathBuf, -} - -impl LogPath { - fn new(src: &str, dst: &Path) -> Self { - Self { - src_path: src.to_string(), - dst_path: dst.to_owned(), - } - } -} - -/// Struct for log created on demand by a command -struct LogCmd { - // command which stdout / stderr is logged - cmd: String, - - // user defined log file name (if any) - file_name: String, - - // place where to collect logs - dst_path: PathBuf, -} - -impl LogCmd { - fn new(cmd: &str, file_name: &str, dst: &Path) -> Self { - Self { - cmd: cmd.to_string(), - file_name: file_name.to_string(), - dst_path: dst.to_owned(), - } - } -} - -trait LogItem { - // definition of log source - fn from(&self) -> &String; - - // definition of destination as path to a file - fn to(&self) -> PathBuf; - - // performs whatever is needed to store logs from "from" at "to" path - fn store(&self) -> Result<(), io::Error>; -} - -impl LogItem for LogPath { - fn from(&self) -> &String { - &self.src_path - } - - fn to(&self) -> PathBuf { - // remove leading '/' if any from the path (reason see later) - let r_path = Path::new(self.src_path.as_str()).strip_prefix("/").unwrap(); - - // here is the reason, join overwrites the content if the joined path is absolute - self.dst_path.join(r_path) - } - - fn store(&self) -> Result<(), io::Error> { - let dst_file = self.to(); - let dst_path = dst_file.parent().unwrap(); - - // for now keep directory structure close to the original - // e.g. what was in /etc will be in //etc/ - fs::create_dir_all(dst_path)?; - - let options = CopyOptions::new(); - // fs_extra's own Error doesn't implement From trait so ? operator is unusable - match copy_items(&[self.src_path.as_str()], dst_path, &options) { - Ok(_p) => Ok(()), - Err(_e) => Err(io::Error::new( - io::ErrorKind::Other, - "Copying of a file failed", - )), - } - } -} - -impl LogItem for LogCmd { - fn from(&self) -> &String { - &self.cmd - } - - fn to(&self) -> PathBuf { - let mut file_name; - - if self.file_name.is_empty() { - file_name = self.cmd.clone(); - } else { - file_name = self.file_name.clone(); - }; - - file_name.retain(|c| c != ' '); - self.dst_path.as_path().join(&file_name) - } - - fn store(&self) -> Result<(), io::Error> { - let cmd_parts = self.cmd.split_whitespace().collect::>(); - let file_path = self.to(); - let output = Command::new(cmd_parts[0]) - .args(cmd_parts[1..].iter()) - .output()?; - let mut file_stdout = File::create(format!("{}.out.log", file_path.display()))?; - let mut file_stderr = File::create(format!("{}.err.log", file_path.display()))?; - - file_stdout.write_all(&output.stdout)?; - file_stderr.write_all(&output.stderr)?; - - Ok(()) - } -} - -/// Collect existing / requested paths which should already exist in the system. -/// Turns them into list of log sources -fn paths_to_log_sources(paths: &[String], tmp_dir: &TempDir) -> Vec> { - let mut log_sources: Vec> = Vec::new(); - - for path in paths.iter() { - // assumption: path is full path - if Path::new(path).try_exists().is_ok() { - log_sources.push(Box::new(LogPath::new(path.as_str(), tmp_dir.path()))); - } - } - - log_sources -} - -/// Some info can be collected via particular commands only, turn it into log sources -fn cmds_to_log_sources(commands: &[(String, String)], tmp_dir: &TempDir) -> Vec> { - let mut log_sources: Vec> = Vec::new(); - - for cmd in commands.iter() { - log_sources.push(Box::new(LogCmd::new( - cmd.0.as_str(), - cmd.1.as_str(), - tmp_dir.path(), - ))); - } - - log_sources -} - -/// Compress given directory into a tar archive -fn compress_logs(tmp_dir: &TempDir, result: &String) -> io::Result<()> { - let compression = DEFAULT_COMPRESSION.0; - let tmp_path = tmp_dir - .path() - .parent() - .and_then(|p| p.as_os_str().to_str()) - .ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Malformed path to temporary directory", - ))?; - let dir = tmp_dir - .path() - .file_name() - .and_then(|f| f.to_str()) - .ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Malformed path to temporary director", - ))?; - let compress_cmd = format!( - "tar -c -f {} --warning=no-file-changed --{} --dereference -C {} {}", - result, compression, tmp_path, dir, - ); - let cmd_parts = compress_cmd.split_whitespace().collect::>(); - let res = Command::new(cmd_parts[0]) - .args(cmd_parts[1..].iter()) - .status()?; - - if res.success() { - set_archive_permissions(result) - } else { - Err(io::Error::new( - io::ErrorKind::Other, - "Cannot create tar archive", - )) - } -} /// Sets the archive owner to root:root. Also sets the file permissions to read/write for the /// owner only. @@ -384,80 +140,7 @@ fn set_archive_permissions(archive: &String) -> io::Result<()> { Ok(()) } -/// Handler for the "agama logs store" subcommand -fn store(options: LogOptions) -> Result<(), io::Error> { - if !Uid::effective().is_root() { - panic!("No Root, no logs. Sorry."); - } - - // preparation, e.g. in later features some log commands can be added / excluded per users request or - let commands = options.commands; - let paths = options.paths; - let verbose = options.verbose; - let opt_dest = options.destination.into_os_string(); - let destination = opt_dest.to_str().ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Malformed destination path", - ))?; - let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); - - showln(verbose, "Collecting Agama logs:"); - - // create temporary directory where to collect all files (similar to what old save_y2logs - // does) - let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; - let mut log_sources = paths_to_log_sources(&paths, &tmp_dir); - - showln(verbose, "\t- proceeding well known paths"); - log_sources.append(&mut cmds_to_log_sources(&commands, &tmp_dir)); - - // some info can be collected via particular commands only - showln(verbose, "\t- proceeding output of commands"); - - // store it - if verbose { - showln(true, format!("Storing result in: \"{}\"", result).as_str()); - } else { - showln(true, result.as_str()); - } - - for log in log_sources.iter() { - show( - verbose, - format!("\t- storing: \"{}\" ... ", log.from()).as_str(), - ); - - // for now keep directory structure close to the original - // e.g. what was in /etc will be in //etc/ - let res = match fs::create_dir_all(log.to().parent().unwrap()) { - Ok(_p) => match log.store() { - Ok(_p) => "[Ok]", - Err(_e) => "[Failed]", - }, - Err(_e) => "[Failed]", - }; - - showln(verbose, res.to_string().as_str()); - } - - compress_logs(&tmp_dir, &result) -} - /// Handler for the "agama logs list" subcommand -fn list(options: LogOptions) { - for list in [ - ("Log paths: ", options.paths), - ( - "Log commands: ", - options.commands.iter().map(|c| c.0.clone()).collect(), - ), - ] { - println!("{}", list.0); - - for item in list.1.iter() { - println!("\t{}", item); - } - - println!(); - } +fn list() { + // TODO: Needs new API too } diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index dc9bbbc3cd..a6d243c2a6 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -215,7 +215,7 @@ impl BaseHTTPClient { /// /// Arguments: /// - /// * `path`: path relative to HTTP API like `/questions/1` + /// * `path`: path relative to HTTP API like `/questions/1` pub async fn delete_void(&self, path: &str) -> Result<(), ServiceError> { let response: Result<_, ServiceError> = self .client @@ -226,6 +226,17 @@ impl BaseHTTPClient { self.unit_or_error(response?).await } + /// Returns raw reqwest::Response. Use e.g. in case when response content is not + /// JSON body but e.g. binary data + pub async fn get_binary(&self, path: &str) -> Result + { + self.client + .get(self.url(path)) + .send() + .await + .map_err(|e| e.into()) + } + /// POST/PUT/PATCH an object to a given path and returns server response. /// Reports Err only if failed to send /// request, but if server returns e.g. 500, it will be in Ok result. diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 4206956632..f5b4a424eb 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -29,6 +29,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; +pub mod http_client; + const DEFAULT_COMMANDS: [(&str, &str); 3] = [ // (, ) ("journalctl -u agama", "agama"), @@ -58,7 +60,7 @@ const DEFAULT_PATHS: [&str; 14] = [ const DEFAULT_RESULT: &str = "/tmp/agama-logs"; // what compression is used by default: // (, ) -const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); +pub const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); const TMP_DIR_PREFIX: &str = "agama-logs."; /// Configurable parameters of the "agama logs" which can be diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs new file mode 100644 index 0000000000..c467241d61 --- /dev/null +++ b/rust/agama-lib/src/logs/http_client.rs @@ -0,0 +1,63 @@ +// 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 crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use reqwest::Response; +use reqwest::header::CONTENT_ENCODING; +use std::io::Cursor; +use std::path::Path; + +pub struct HTTPClient { + client: BaseHTTPClient, +} + +impl HTTPClient { + pub fn new(client: BaseHTTPClient) -> Result { + Ok(Self { client }) + } + + /// Downloads package of logs from the backend + /// + /// For now the path is path to a destination file without an extension. Extension + /// will be added according to the compression type found in the response + /// + /// Returns path to logs + pub async fn store(&self, path: &Path) -> Result { + // TODO: proper result/error handling - get rid ow unwraps + // 1) response with logs + let response = self.client.get_binary("/logs").await?; + + // 2) find out the destination file name + // TODO: deal with missing header + // TODO: requires root - otherwise fails to get the header + let ext = &response.headers().get(CONTENT_ENCODING).unwrap(); + let mut destination = path.to_path_buf(); + + destination.set_extension(ext.to_str().unwrap()); + + // 3) store response's binary content (logs) in a file + let mut file = std::fs::File::create(destination.as_path()).unwrap(); + let mut content = Cursor::new(response.bytes().await.unwrap()); + + std::io::copy(&mut content, &mut file); + + Ok(String::from(destination.as_path().to_str().unwrap())) + } +} diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 53df7677e0..8d575fae0d 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -22,7 +22,7 @@ use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; -use agama_lib::logs::{LogOptions, store as storeLogs}; +use agama_lib::logs::{LogOptions, store as storeLogs, DEFAULT_COMPRESSION}; use axum::{ body::Body, extract::{Query, State}, @@ -64,10 +64,12 @@ pub async fn logs() -> impl IntoResponse { let stream = ReaderStream::new(file); let body = Body::from_stream(stream); // TODO: should be only filename! + // tells the browser that body contains an attachment, not web page to be displayed let disposition = format!("attachment; filename=\"{}\"", path); headers.insert(header::CONTENT_TYPE, "text/toml; charset=utf-8".parse().unwrap()); headers.insert(header::CONTENT_DISPOSITION, disposition.parse().unwrap()); + headers.insert(header::CONTENT_ENCODING, DEFAULT_COMPRESSION.1.parse().unwrap()); (headers, body) } From 1ed857340f9a73b600fa2c4434845663dd9ef82e Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Thu, 31 Oct 2024 13:59:29 +0100 Subject: [PATCH 04/18] Cleanup, refactoring and error handling --- rust/agama-cli/src/logs.rs | 11 +-------- rust/agama-lib/src/logs.rs | 33 ++++++++++++-------------- rust/agama-lib/src/logs/http_client.rs | 1 - rust/agama-server/src/web/http.rs | 28 ++++------------------ 4 files changed, 21 insertions(+), 52 deletions(-) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 337ca4e7d8..5ddc26346a 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -24,7 +24,7 @@ use clap::Subcommand; use std::fs; use std::io; use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Command; /// A wrapper around println which shows (or not) the text depending on the boolean variable @@ -36,15 +36,6 @@ fn showln(show: bool, text: &str) { println!("{}", text); } -/// A wrapper around println which shows (or not) the text depending on the boolean variable -fn show(show: bool, text: &str) { - if !show { - return; - } - - print!("{}", text); -} - // definition of "agama logs" subcommands, see clap crate for details #[derive(Subcommand, Debug)] pub enum LogsCommands { diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index f5b4a424eb..b19046a08c 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -18,6 +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::error::ServiceError; use fs_extra::copy_items; use fs_extra::dir::CopyOptions; use std::fs; @@ -279,20 +280,17 @@ fn set_archive_permissions(archive: &String) -> io::Result<()> { } /// Handler for the "agama logs store" subcommand -pub fn store(options: LogOptions) -> Result { +pub fn store(options: LogOptions) -> Result { // preparation, e.g. in later features some log commands can be added / excluded per users request or let commands = options.commands; let paths = options.paths; let opt_dest = options.destination.into_os_string(); - let destination = opt_dest.to_str().ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Malformed destination path", - ))?; + let destination = opt_dest.to_str().ok_or(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs")))?; let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); // create temporary directory where to collect all files (similar to what old save_y2logs // does) - let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; + let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX).map_err(|_| ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs")))?; let mut log_sources = paths_to_log_sources(&paths, &tmp_dir); log_sources.append(&mut cmds_to_log_sources(&commands, &tmp_dir)); @@ -302,21 +300,20 @@ pub fn store(options: LogOptions) -> Result { for log in log_sources.iter() { // for now keep directory structure close to the original // e.g. what was in /etc will be in //etc/ - - // TODO: deal with errors properly here (no more strings, and so on) - // may be switch to common agamalib's Service errors - let res = match fs::create_dir_all(log.to().parent().unwrap()) { - Ok(_p) => match log.store() { - Ok(_p) => "[Ok]", - Err(_e) => "[Failed]", - }, - Err(_e) => "[Failed]", - }; + if fs::create_dir_all(log.to().parent().unwrap()).is_ok() { + // if storing of one particular log fails, just ignore it + // file might be missing e.g. bcs the tool doesn't generate it anymore, ... + let _ = log.store().is_err(); + } else { + return Err(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs"))); + } } - compress_logs(&tmp_dir, &result); + if compress_logs(&tmp_dir, &result).is_err() { + return Err(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs"))); + } - Ok(String::from(result)) + Ok(PathBuf::from(result)) } /// Handler for the "agama logs list" subcommand diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index c467241d61..cd69b05b97 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -19,7 +19,6 @@ // find current contact information at www.suse.com. use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; -use reqwest::Response; use reqwest::header::CONTENT_ENCODING; use std::io::Cursor; use std::path::Path; diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 8d575fae0d..00e780ef54 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -36,40 +36,22 @@ use serde::{Deserialize, Serialize}; use tokio_util::io::ReaderStream; use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] -pub struct LogsResponse { - /// Logs archive file name - logs: String, -} - -// For development only - a mockup of logs archive creation. -async fn store() -> Result -{ - let options = LogOptions::default(); - let path = storeLogs(options).unwrap(); - - Ok(path) -} - #[utoipa::path(get, path = "/logs", responses( - (status = 200, description = "Compressed Agama logs", body = LogsResponse) + (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream") ))] pub async fn logs() -> impl IntoResponse { // TODO: require authorization let mut headers = HeaderMap::new(); - match store().await { + match storeLogs(LogOptions::default()) { Ok(path) => { let file = tokio::fs::File::open(path.clone()).await.unwrap(); let stream = ReaderStream::new(file); let body = Body::from_stream(stream); - // TODO: should be only filename! - // tells the browser that body contains an attachment, not web page to be displayed - let disposition = format!("attachment; filename=\"{}\"", path); - headers.insert(header::CONTENT_TYPE, "text/toml; charset=utf-8".parse().unwrap()); - headers.insert(header::CONTENT_DISPOSITION, disposition.parse().unwrap()); - headers.insert(header::CONTENT_ENCODING, DEFAULT_COMPRESSION.1.parse().unwrap()); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/toml; charset=utf-8")); + headers.insert(header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment; filename=\"agama-logs\"")); + headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(DEFAULT_COMPRESSION.1)); (headers, body) } From 5e432ef6c95a1c7453908092888dbe1fa040d0a2 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Fri, 1 Nov 2024 10:02:40 +0100 Subject: [PATCH 05/18] Formatting --- rust/agama-cli/src/logs.rs | 4 +--- rust/agama-lib/src/base_http_client.rs | 3 +-- rust/agama-lib/src/logs.rs | 17 +++++++++++++---- rust/agama-lib/src/logs/http_client.rs | 2 +- rust/agama-server/src/web/http.rs | 17 +++++++++++++---- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 5ddc26346a..beb8b8ecba 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -72,9 +72,7 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re Ok(()) } - LogsCommands::List => { - Ok(()) - } + LogsCommands::List => Ok(()), } } diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index a6d243c2a6..083144ec86 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -228,8 +228,7 @@ impl BaseHTTPClient { /// Returns raw reqwest::Response. Use e.g. in case when response content is not /// JSON body but e.g. binary data - pub async fn get_binary(&self, path: &str) -> Result - { + pub async fn get_binary(&self, path: &str) -> Result { self.client .get(self.url(path)) .send() diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index b19046a08c..43349efa14 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -285,12 +285,17 @@ pub fn store(options: LogOptions) -> Result { let commands = options.commands; let paths = options.paths; let opt_dest = options.destination.into_os_string(); - let destination = opt_dest.to_str().ok_or(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs")))?; + let destination = opt_dest + .to_str() + .ok_or(ServiceError::CannotGenerateLogs(String::from( + "Cannot collect the logs", + )))?; let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); // create temporary directory where to collect all files (similar to what old save_y2logs // does) - let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX).map_err(|_| ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs")))?; + let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX) + .map_err(|_| ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs")))?; let mut log_sources = paths_to_log_sources(&paths, &tmp_dir); log_sources.append(&mut cmds_to_log_sources(&commands, &tmp_dir)); @@ -305,12 +310,16 @@ pub fn store(options: LogOptions) -> Result { // file might be missing e.g. bcs the tool doesn't generate it anymore, ... let _ = log.store().is_err(); } else { - return Err(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs"))); + return Err(ServiceError::CannotGenerateLogs(String::from( + "Cannot collect the logs", + ))); } } if compress_logs(&tmp_dir, &result).is_err() { - return Err(ServiceError::CannotGenerateLogs(String::from("Cannot collect the logs"))); + return Err(ServiceError::CannotGenerateLogs(String::from( + "Cannot collect the logs", + ))); } Ok(PathBuf::from(result)) diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index cd69b05b97..4a6502c505 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -53,7 +53,7 @@ impl HTTPClient { // 3) store response's binary content (logs) in a file let mut file = std::fs::File::create(destination.as_path()).unwrap(); - let mut content = Cursor::new(response.bytes().await.unwrap()); + let mut content = Cursor::new(response.bytes().await.unwrap()); std::io::copy(&mut content, &mut file); diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 00e780ef54..46e7d0d52d 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -22,7 +22,7 @@ use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; -use agama_lib::logs::{LogOptions, store as storeLogs, DEFAULT_COMPRESSION}; +use agama_lib::logs::{store as storeLogs, LogOptions, DEFAULT_COMPRESSION}; use axum::{ body::Body, extract::{Query, State}, @@ -49,9 +49,18 @@ pub async fn logs() -> impl IntoResponse { let stream = ReaderStream::new(file); let body = Body::from_stream(stream); - headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/toml; charset=utf-8")); - headers.insert(header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment; filename=\"agama-logs\"")); - headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(DEFAULT_COMPRESSION.1)); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/toml; charset=utf-8"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"agama-logs\""), + ); + headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(DEFAULT_COMPRESSION.1), + ); (headers, body) } From 97029e297de3523ed58f69955b8cd414ebeb90f9 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Fri, 1 Nov 2024 21:47:27 +0100 Subject: [PATCH 06/18] Cleanup, refactoring and error handling on CLI side --- rust/agama-cli/src/logs.rs | 35 ++++++++------------------ rust/agama-lib/src/logs.rs | 14 ++++------- rust/agama-lib/src/logs/http_client.rs | 32 +++++++++++++++-------- rust/agama-server/src/web/http.rs | 3 ++- 4 files changed, 38 insertions(+), 46 deletions(-) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index beb8b8ecba..37c2aed388 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -20,12 +20,10 @@ use agama_lib::base_http_client::BaseHTTPClient; use agama_lib::logs::http_client::HTTPClient; +use agama_lib::logs::set_archive_permissions; use clap::Subcommand; -use std::fs; use std::io; -use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; -use std::process::Command; /// A wrapper around println which shows (or not) the text depending on the boolean variable fn showln(show: bool, text: &str) { @@ -66,9 +64,15 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re // for now we always use / add defaults if any let destination = parse_destination(destination)?; - // TODO: deal with return values - let destination = client.store(destination.as_path()).await.unwrap(); - showln(verbose, format!("{:?}", destination).as_str()); + let destination = client + .store(destination.as_path()) + .await + .map_err(|_| anyhow::Error::msg("Downloading of logs failed"))?; + + set_archive_permissions(destination.clone()) + .map_err(|_| anyhow::Error::msg("Cannot store the logs"))?; + + showln(verbose, format!("{:?}", destination.clone()).as_str()); Ok(()) } @@ -110,25 +114,6 @@ fn parse_destination(destination: Option) -> Result const DEFAULT_RESULT: &str = "/tmp/agama-logs"; -/// Sets the archive owner to root:root. Also sets the file permissions to read/write for the -/// owner only. -fn set_archive_permissions(archive: &String) -> io::Result<()> { - let attr = fs::metadata(archive)?; - let mut permissions = attr.permissions(); - - // set the archive file permissions to -rw------- - permissions.set_mode(0o600); - fs::set_permissions(archive, permissions)?; - - // set the archive owner to root:root - // note: std::os::unix::fs::chown is unstable for now - Command::new("chown") - .args(["root:root", archive.as_str()]) - .status()?; - - Ok(()) -} - /// Handler for the "agama logs list" subcommand fn list() { // TODO: Needs new API too diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 43349efa14..65fbd6efce 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -251,7 +251,7 @@ fn compress_logs(tmp_dir: &TempDir, result: &String) -> io::Result<()> { .status()?; if res.success() { - set_archive_permissions(result) + set_archive_permissions(PathBuf::from(result)) } else { Err(io::Error::new( io::ErrorKind::Other, @@ -262,21 +262,17 @@ fn compress_logs(tmp_dir: &TempDir, result: &String) -> io::Result<()> { /// Sets the archive owner to root:root. Also sets the file permissions to read/write for the /// owner only. -fn set_archive_permissions(archive: &String) -> io::Result<()> { - let attr = fs::metadata(archive)?; +pub fn set_archive_permissions(archive: PathBuf) -> io::Result<()> { + let attr = fs::metadata(archive.as_path())?; let mut permissions = attr.permissions(); // set the archive file permissions to -rw------- permissions.set_mode(0o600); - fs::set_permissions(archive, permissions)?; + fs::set_permissions(archive.clone(), permissions)?; // set the archive owner to root:root // note: std::os::unix::fs::chown is unstable for now - Command::new("chown") - .args(["root:root", archive.as_str()]) - .status()?; - - Ok(()) + std::os::unix::fs::chown(archive.as_path(), Some(0), Some(0)) } /// Handler for the "agama logs store" subcommand diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index 4a6502c505..3f7b0d6380 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -21,7 +21,7 @@ use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use reqwest::header::CONTENT_ENCODING; use std::io::Cursor; -use std::path::Path; +use std::path::{Path, PathBuf}; pub struct HTTPClient { client: BaseHTTPClient, @@ -38,25 +38,35 @@ impl HTTPClient { /// will be added according to the compression type found in the response /// /// Returns path to logs - pub async fn store(&self, path: &Path) -> Result { - // TODO: proper result/error handling - get rid ow unwraps + pub async fn store(&self, path: &Path) -> Result { // 1) response with logs let response = self.client.get_binary("/logs").await?; // 2) find out the destination file name - // TODO: deal with missing header - // TODO: requires root - otherwise fails to get the header - let ext = &response.headers().get(CONTENT_ENCODING).unwrap(); + let ext = + &response + .headers() + .get(CONTENT_ENCODING) + .ok_or(ServiceError::CannotGenerateLogs(String::from( + "Invalid response", + )))?; let mut destination = path.to_path_buf(); - destination.set_extension(ext.to_str().unwrap()); + destination.set_extension( + ext.to_str() + .map_err(|_| ServiceError::CannotGenerateLogs(String::from("Invalid response")))?, + ); // 3) store response's binary content (logs) in a file - let mut file = std::fs::File::create(destination.as_path()).unwrap(); - let mut content = Cursor::new(response.bytes().await.unwrap()); + let mut file = std::fs::File::create(destination.as_path()).map_err(|_| { + ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) + })?; + let mut content = Cursor::new(response.bytes().await?); - std::io::copy(&mut content, &mut file); + std::io::copy(&mut content, &mut file).map_err(|_| { + ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) + })?; - Ok(String::from(destination.as_path().to_str().unwrap())) + Ok(destination) } } diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 46e7d0d52d..c076df626c 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -37,7 +37,8 @@ use tokio_util::io::ReaderStream; use utoipa::ToSchema; #[utoipa::path(get, path = "/logs", responses( - (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream") + (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), + (status = 404, description = "Agama logs not available") ))] pub async fn logs() -> impl IntoResponse { // TODO: require authorization From 6c1c8faa4f7f52239735969813ca038776ba8d7d Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Sun, 3 Nov 2024 18:55:17 +0100 Subject: [PATCH 07/18] Implemented HTTP API /logs/list endpoint and used in the CLI --- rust/agama-cli/src/logs.rs | 24 +++++++++++++++++------ rust/agama-lib/src/logs.rs | 27 ++++++++++++-------------- rust/agama-lib/src/logs/http_client.rs | 9 ++++++++- rust/agama-server/src/web/http.rs | 25 ++++++++++++++++++++---- rust/agama-server/src/web/service.rs | 2 +- 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 37c2aed388..b9ad2c91cb 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -76,7 +76,24 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re Ok(()) } - LogsCommands::List => Ok(()), + LogsCommands::List => { + let logs_list = client + .list() + .await + .map_err(|_| anyhow::Error::msg("Cannot get the logs list"))?; + + println!("Log files:"); + for f in logs_list.files.iter() { + println!("\t{}", f); + } + + println!("Log commands:"); + for c in logs_list.commands.iter() { + println!("\t{}", c); + } + + Ok(()) + } } } @@ -113,8 +130,3 @@ fn parse_destination(destination: Option) -> Result } const DEFAULT_RESULT: &str = "/tmp/agama-logs"; - -/// Handler for the "agama logs list" subcommand -fn list() { - // TODO: Needs new API too -} diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 65fbd6efce..1bbecbf64d 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -21,6 +21,7 @@ use crate::error::ServiceError; use fs_extra::copy_items; use fs_extra::dir::CopyOptions; +use serde::Serialize; use std::fs; use std::fs::File; use std::io; @@ -29,6 +30,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; +use utoipa::ToSchema; pub mod http_client; @@ -321,21 +323,16 @@ pub fn store(options: LogOptions) -> Result { Ok(PathBuf::from(result)) } -/// Handler for the "agama logs list" subcommand -fn list(options: LogOptions) { - for list in [ - ("Log paths: ", options.paths), - ( - "Log commands: ", - options.commands.iter().map(|c| c.0.clone()).collect(), - ), - ] { - println!("{}", list.0); - - for item in list.1.iter() { - println!("\t{}", item); - } +#[derive(Serialize, serde::Deserialize, ToSchema)] +pub struct LogsLists { + pub commands: Vec, + pub files: Vec, +} - println!(); +/// Handler for the "agama logs list" subcommand +pub fn list(options: LogOptions) -> LogsLists { + LogsLists { + commands: options.commands.iter().map(|c| c.0.clone()).collect(), + files: options.paths.clone(), } } diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index 3f7b0d6380..26528b78fd 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -18,6 +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::logs::LogsLists; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use reqwest::header::CONTENT_ENCODING; use std::io::Cursor; @@ -40,7 +41,7 @@ impl HTTPClient { /// Returns path to logs pub async fn store(&self, path: &Path) -> Result { // 1) response with logs - let response = self.client.get_binary("/logs").await?; + let response = self.client.get_binary("/logs/store").await?; // 2) find out the destination file name let ext = @@ -69,4 +70,10 @@ impl HTTPClient { Ok(destination) } + + /// Asks backend for lists of log files and commands used for creating logs archive returned by + /// store (/logs/store) backed HTTP API command + pub async fn list(&self) -> Result { + Ok(self.client.get("/logs/list").await?) + } } diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index c076df626c..c8fafe7f4b 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -22,13 +22,16 @@ use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; -use agama_lib::logs::{store as storeLogs, LogOptions, DEFAULT_COMPRESSION}; +use agama_lib::logs::{ + list as listLogs, store as storeLogs, LogOptions, LogsLists, DEFAULT_COMPRESSION, +}; use axum::{ body::Body, extract::{Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, - Json, + routing::get, + Json, Router, }; use axum_extra::extract::cookie::CookieJar; use pam::Client; @@ -36,11 +39,18 @@ use serde::{Deserialize, Serialize}; use tokio_util::io::ReaderStream; use utoipa::ToSchema; -#[utoipa::path(get, path = "/logs", responses( +/// Creates router for handling /logs/* endpoints +pub fn logs_router() -> Router { + Router::new() + .route("/store", get(logs_store)) + .route("/list", get(logs_list)) +} + +#[utoipa::path(get, path = "/logs/store", responses( (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), (status = 404, description = "Agama logs not available") ))] -pub async fn logs() -> impl IntoResponse { +async fn logs_store() -> impl IntoResponse { // TODO: require authorization let mut headers = HeaderMap::new(); @@ -72,6 +82,13 @@ pub async fn logs() -> impl IntoResponse { } } +#[utoipa::path(get, path = "/logs/list", responses( + (status = 200, description = "Lists of collected logs", body = LogsLists) +))] +pub async fn logs_list() -> Json { + Json(listLogs(LogOptions::default())) +} + #[derive(Serialize, ToSchema)] pub struct PingResponse { /// API status diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index c8cc25e271..de008e0964 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -108,7 +108,7 @@ impl MainServiceBuilder { )) .route("/ping", get(super::http::ping)) .route("/auth", post(login).get(session).delete(logout)) - .route("/logs", get(super::http::logs)); + .nest("/logs", super::http::logs_router()); tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir).precompressed_gzip(); From d9251e16b8c120413a700c7734b5ad2b2afff023 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 07:48:17 +0100 Subject: [PATCH 08/18] Minor improvements. --- rust/Cargo.lock | 3 +++ rust/agama-cli/Cargo.toml | 1 + rust/agama-cli/src/logs.rs | 17 ++++++++++------- rust/agama-server/src/web/http.rs | 3 +++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 805a185add..4d000e166a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "agama-lib", "anyhow", "async-trait", + "chrono", "clap", "console", "curl", @@ -798,8 +799,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 3622fedbef..ea0642e3f7 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -23,6 +23,7 @@ async-trait = "0.1.83" reqwest = { version = "0.11", features = ["json"] } url = "2.5.2" inquire = { version = "0.7.5", default-features = false, features = ["crossterm", "one-liners"] } +chrono = "0.4.38" [[bin]] name = "agama" diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index b9ad2c91cb..6e257901ca 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -62,17 +62,16 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re } => { // feed internal options structure by what was received from user // for now we always use / add defaults if any - let destination = parse_destination(destination)?; - - let destination = client - .store(destination.as_path()) + let dst_file = parse_destination(destination)?; + let result = client + .store(dst_file.as_path()) .await .map_err(|_| anyhow::Error::msg("Downloading of logs failed"))?; - set_archive_permissions(destination.clone()) + set_archive_permissions(result.clone()) .map_err(|_| anyhow::Error::msg("Cannot store the logs"))?; - showln(verbose, format!("{:?}", destination.clone()).as_str()); + showln(verbose, format!("{:?}", result.clone()).as_str()); Ok(()) } @@ -108,7 +107,11 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re /// be appended later on (depends on used compression) fn parse_destination(destination: Option) -> Result { let err = io::Error::new(io::ErrorKind::InvalidInput, "Invalid destination path"); - let mut buffer = destination.unwrap_or(PathBuf::from(DEFAULT_RESULT)); + let mut buffer = destination.unwrap_or(PathBuf::from(format!( + "{}-{}", + DEFAULT_RESULT, + chrono::prelude::Utc::now().timestamp() + ))); let path = buffer.as_path(); // existing directory -> append an archive name diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index c8fafe7f4b..fed2703edd 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -60,6 +60,9 @@ async fn logs_store() -> impl IntoResponse { let stream = ReaderStream::new(file); let body = Body::from_stream(stream); + // Cleanup - remove temporary file, no one cares it it fails + let _ = std::fs::remove_file(path.clone()); + headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("text/toml; charset=utf-8"), From ffcb098441c44afc0059429cc8ce2ad594be3697 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 08:53:49 +0100 Subject: [PATCH 09/18] Updated changelog --- rust/package/agama.changes | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index fdd2f4f04b..ea605b5a1a 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Mon Nov 4 07:51:55 UTC 2024 - Michal Filka + +- Followup of original fix for gh#agama-project/agama#1495 +- Implemented HTTP API for downloading logs +- Adapted CLI command "logs" to use the HTTP API for downloading logs + ------------------------------------------------------------------- Wed Oct 30 15:27:11 UTC 2024 - Jorik Cronenberg From 7a4c237c755d44e910a1d17013eb9e6becdc21e0 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 09:02:11 +0100 Subject: [PATCH 10/18] Removed unnecessary imports from Cargo --- rust/Cargo.lock | 1 - rust/agama-cli/Cargo.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4d000e166a..4f834a7351 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,7 +28,6 @@ dependencies = [ "clap", "console", "curl", - "fs_extra", "indicatif", "inquire", "nix 0.27.1", diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index ea0642e3f7..6458d8c254 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -14,9 +14,7 @@ indicatif= "0.17.8" thiserror = "1.0.64" console = "0.15.8" anyhow = "1.0.89" -# tempdir, fs_extra, nix is for logs (sub)command tempfile = "3.13.0" -fs_extra = "1.3.0" nix = { version = "0.27.1", features = ["user"] } tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } async-trait = "0.1.83" From 6ed7dfdb46f10968368d0be5ac28b0248ae72ec8 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 10:58:58 +0100 Subject: [PATCH 11/18] Minor stuff from the review (renaming, ...) --- rust/agama-cli/src/logs.rs | 19 ++----------------- rust/agama-lib/src/base_http_client.rs | 2 +- rust/agama-lib/src/logs/http_client.rs | 2 +- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 6e257901ca..1f8d881ce3 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -25,23 +25,11 @@ use clap::Subcommand; use std::io; use std::path::PathBuf; -/// A wrapper around println which shows (or not) the text depending on the boolean variable -fn showln(show: bool, text: &str) { - if !show { - return; - } - - println!("{}", text); -} - // definition of "agama logs" subcommands, see clap crate for details #[derive(Subcommand, Debug)] pub enum LogsCommands { /// Collect and store the logs in a tar archive. Store { - #[clap(long, short = 'v')] - /// Verbose output - verbose: bool, #[clap(long, short = 'd')] /// Path to destination directory and, optionally, the archive file name. The extension will /// be added automatically. @@ -56,10 +44,7 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re let client = HTTPClient::new(client)?; match subcommand { - LogsCommands::Store { - verbose, - destination, - } => { + LogsCommands::Store { destination } => { // feed internal options structure by what was received from user // for now we always use / add defaults if any let dst_file = parse_destination(destination)?; @@ -71,7 +56,7 @@ pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Re set_archive_permissions(result.clone()) .map_err(|_| anyhow::Error::msg("Cannot store the logs"))?; - showln(verbose, format!("{:?}", result.clone()).as_str()); + println!("{}", result.clone().display()); Ok(()) } diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index 083144ec86..2b1d54b47d 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -228,7 +228,7 @@ impl BaseHTTPClient { /// Returns raw reqwest::Response. Use e.g. in case when response content is not /// JSON body but e.g. binary data - pub async fn get_binary(&self, path: &str) -> Result { + pub async fn get_raw(&self, path: &str) -> Result { self.client .get(self.url(path)) .send() diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index 26528b78fd..b85c3a8dfc 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -41,7 +41,7 @@ impl HTTPClient { /// Returns path to logs pub async fn store(&self, path: &Path) -> Result { // 1) response with logs - let response = self.client.get_binary("/logs/store").await?; + let response = self.client.get_raw("/logs/store").await?; // 2) find out the destination file name let ext = From 415fd5aab0d885b483ce7d4b819c227b2ab4b0da Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 12:55:56 +0100 Subject: [PATCH 12/18] Moved new logs HTTP API under /manager so http://.../api/logs is now http://.../api/manager/logs --- rust/agama-lib/src/logs.rs | 2 +- rust/agama-lib/src/logs/http_client.rs | 4 +- rust/agama-server/src/manager/web.rs | 83 ++++++++++++++++---------- rust/agama-server/src/web/http.rs | 60 +------------------ rust/agama-server/src/web/service.rs | 3 +- 5 files changed, 56 insertions(+), 96 deletions(-) diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 1bbecbf64d..8f64d62684 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -60,7 +60,7 @@ const DEFAULT_PATHS: [&str; 14] = [ "/linuxrc.config", ]; -const DEFAULT_RESULT: &str = "/tmp/agama-logs"; +const DEFAULT_RESULT: &str = "/run/agama/agama-logs"; // what compression is used by default: // (, ) pub const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs index b85c3a8dfc..e0824dbf92 100644 --- a/rust/agama-lib/src/logs/http_client.rs +++ b/rust/agama-lib/src/logs/http_client.rs @@ -41,7 +41,7 @@ impl HTTPClient { /// Returns path to logs pub async fn store(&self, path: &Path) -> Result { // 1) response with logs - let response = self.client.get_raw("/logs/store").await?; + let response = self.client.get_raw("/manager/logs/store").await?; // 2) find out the destination file name let ext = @@ -74,6 +74,6 @@ impl HTTPClient { /// Asks backend for lists of log files and commands used for creating logs archive returned by /// store (/logs/store) backed HTTP API command pub async fn list(&self) -> Result { - Ok(self.client.get("/logs/list").await?) + Ok(self.client.get("/manager/logs/list").await?) } } diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 09fd6e1b9f..f29092844c 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -25,24 +25,26 @@ //! * `manager_service` which returns the Axum service. //! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. +use agama_lib::logs::{ + list as listLogs, store as storeLogs, LogOptions, LogsLists, DEFAULT_COMPRESSION, +}; use agama_lib::{ error::ServiceError, manager::{InstallationPhase, ManagerClient}, proxies::Manager1Proxy, }; use axum::{ - extract::{Request, State}, - http::StatusCode, + body::Body, + extract::State, + http::{header, HeaderMap, HeaderValue}, response::IntoResponse, routing::{get, post}, Json, Router, }; -use rand::distributions::{Alphanumeric, DistString}; use serde::Serialize; use std::pin::Pin; -use tokio::process::Command; use tokio_stream::{Stream, StreamExt}; -use tower_http::services::ServeFile; +use tokio_util::io::ReaderStream; use crate::{ error::Error, @@ -116,7 +118,7 @@ pub async fn manager_service(dbus: zbus::Connection) -> Result Router> { + Router::new() + .route("/store", get(download_logs)) + .route("/list", get(list_logs)) +} + +#[utoipa::path(get, path = "/logs/store", responses( + (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), ))] -pub async fn download_logs() -> impl IntoResponse { - let path = generate_logs().await; - let Ok(path) = path else { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); - }; +async fn download_logs() -> impl IntoResponse { + let mut headers = HeaderMap::new(); - match ServeFile::new(path) - .try_call(Request::new(axum::body::Body::empty())) - .await - { - Ok(res) => res.into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - } -} + match storeLogs(LogOptions::default()) { + Ok(path) => { + let file = tokio::fs::File::open(path.clone()).await.unwrap(); + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); -async fn generate_logs() -> Result { - let random_name: String = Alphanumeric.sample_string(&mut rand::thread_rng(), 8); - let path = format!("/run/agama/logs_{random_name}"); + // Cleanup - remove temporary file, no one cares it it fails + let _ = std::fs::remove_file(path.clone()); - Command::new("agama") - .args(["logs", "store", "-d", path.as_str()]) - .status() - .await - .map_err(|e| ServiceError::CannotGenerateLogs(e.to_string()))?; + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/toml; charset=utf-8"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"agama-logs\""), + ); + headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(DEFAULT_COMPRESSION.1), + ); - let full_path = format!("{path}.tar.gz"); - Ok(full_path) + (headers, body) + } + Err(_) => { + // fill in with meaningful headers + (headers, Body::empty()) + } + } +} +#[utoipa::path(get, path = "/logs/list", responses( + (status = 200, description = "Lists of collected logs", body = LogsLists) +))] +pub async fn list_logs() -> Json { + Json(listLogs(LogOptions::default())) } diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index fed2703edd..3b019a7da5 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -22,76 +22,18 @@ use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; -use agama_lib::logs::{ - list as listLogs, store as storeLogs, LogOptions, LogsLists, DEFAULT_COMPRESSION, -}; use axum::{ body::Body, extract::{Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, - routing::get, - Json, Router, + Json, }; use axum_extra::extract::cookie::CookieJar; use pam::Client; use serde::{Deserialize, Serialize}; -use tokio_util::io::ReaderStream; use utoipa::ToSchema; -/// Creates router for handling /logs/* endpoints -pub fn logs_router() -> Router { - Router::new() - .route("/store", get(logs_store)) - .route("/list", get(logs_list)) -} - -#[utoipa::path(get, path = "/logs/store", responses( - (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), - (status = 404, description = "Agama logs not available") -))] -async fn logs_store() -> impl IntoResponse { - // TODO: require authorization - let mut headers = HeaderMap::new(); - - match storeLogs(LogOptions::default()) { - Ok(path) => { - let file = tokio::fs::File::open(path.clone()).await.unwrap(); - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); - - // Cleanup - remove temporary file, no one cares it it fails - let _ = std::fs::remove_file(path.clone()); - - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/toml; charset=utf-8"), - ); - headers.insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_static("attachment; filename=\"agama-logs\""), - ); - headers.insert( - header::CONTENT_ENCODING, - HeaderValue::from_static(DEFAULT_COMPRESSION.1), - ); - - (headers, body) - } - Err(_) => { - // fill in with meaningful headers - (headers, Body::empty()) - } - } -} - -#[utoipa::path(get, path = "/logs/list", responses( - (status = 200, description = "Lists of collected logs", body = LogsLists) -))] -pub async fn logs_list() -> Json { - Json(listLogs(LogOptions::default())) -} - #[derive(Serialize, ToSchema)] pub struct PingResponse { /// API status diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index de008e0964..fb68656115 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -107,8 +107,7 @@ impl MainServiceBuilder { state.clone(), )) .route("/ping", get(super::http::ping)) - .route("/auth", post(login).get(session).delete(logout)) - .nest("/logs", super::http::logs_router()); + .route("/auth", post(login).get(session).delete(logout)); tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir).precompressed_gzip(); From 5057ae7a5ea13cef8a20cbd2cae128211f664b9e Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Mon, 4 Nov 2024 13:16:05 +0100 Subject: [PATCH 13/18] Merged logs http client into manager http client --- rust/agama-cli/src/logs.rs | 4 +- rust/agama-lib/src/logs.rs | 2 - rust/agama-lib/src/logs/http_client.rs | 79 ----------------------- rust/agama-lib/src/manager/http_client.rs | 48 ++++++++++++++ 4 files changed, 50 insertions(+), 83 deletions(-) delete mode 100644 rust/agama-lib/src/logs/http_client.rs diff --git a/rust/agama-cli/src/logs.rs b/rust/agama-cli/src/logs.rs index 1f8d881ce3..cc9a12da07 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use agama_lib::base_http_client::BaseHTTPClient; -use agama_lib::logs::http_client::HTTPClient; use agama_lib::logs::set_archive_permissions; +use agama_lib::manager::http_client::ManagerHTTPClient as HTTPClient; use clap::Subcommand; use std::io; use std::path::PathBuf; @@ -41,7 +41,7 @@ pub enum LogsCommands { /// Main entry point called from agama CLI main loop pub async fn run(client: BaseHTTPClient, subcommand: LogsCommands) -> anyhow::Result<()> { - let client = HTTPClient::new(client)?; + let client = HTTPClient::new(client); match subcommand { LogsCommands::Store { destination } => { diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 8f64d62684..7d9d7c2405 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -32,8 +32,6 @@ use std::process::Command; use tempfile::TempDir; use utoipa::ToSchema; -pub mod http_client; - const DEFAULT_COMMANDS: [(&str, &str); 3] = [ // (, ) ("journalctl -u agama", "agama"), diff --git a/rust/agama-lib/src/logs/http_client.rs b/rust/agama-lib/src/logs/http_client.rs deleted file mode 100644 index e0824dbf92..0000000000 --- a/rust/agama-lib/src/logs/http_client.rs +++ /dev/null @@ -1,79 +0,0 @@ -// 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 crate::logs::LogsLists; -use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; -use reqwest::header::CONTENT_ENCODING; -use std::io::Cursor; -use std::path::{Path, PathBuf}; - -pub struct HTTPClient { - client: BaseHTTPClient, -} - -impl HTTPClient { - pub fn new(client: BaseHTTPClient) -> Result { - Ok(Self { client }) - } - - /// Downloads package of logs from the backend - /// - /// For now the path is path to a destination file without an extension. Extension - /// will be added according to the compression type found in the response - /// - /// Returns path to logs - pub async fn store(&self, path: &Path) -> Result { - // 1) response with logs - let response = self.client.get_raw("/manager/logs/store").await?; - - // 2) find out the destination file name - let ext = - &response - .headers() - .get(CONTENT_ENCODING) - .ok_or(ServiceError::CannotGenerateLogs(String::from( - "Invalid response", - )))?; - let mut destination = path.to_path_buf(); - - destination.set_extension( - ext.to_str() - .map_err(|_| ServiceError::CannotGenerateLogs(String::from("Invalid response")))?, - ); - - // 3) store response's binary content (logs) in a file - let mut file = std::fs::File::create(destination.as_path()).map_err(|_| { - ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) - })?; - let mut content = Cursor::new(response.bytes().await?); - - std::io::copy(&mut content, &mut file).map_err(|_| { - ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) - })?; - - Ok(destination) - } - - /// Asks backend for lists of log files and commands used for creating logs archive returned by - /// store (/logs/store) backed HTTP API command - pub async fn list(&self) -> Result { - Ok(self.client.get("/manager/logs/list").await?) - } -} diff --git a/rust/agama-lib/src/manager/http_client.rs b/rust/agama-lib/src/manager/http_client.rs index 7cfd373fda..30245bc954 100644 --- a/rust/agama-lib/src/manager/http_client.rs +++ b/rust/agama-lib/src/manager/http_client.rs @@ -18,7 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use crate::logs::LogsLists; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use reqwest::header::CONTENT_ENCODING; +use std::io::Cursor; +use std::path::{Path, PathBuf}; pub struct ManagerHTTPClient { client: BaseHTTPClient, @@ -34,4 +38,48 @@ impl ManagerHTTPClient { // so we pass () which is rendered as `null` self.client.post_void("/manager/probe_sync", &()).await } + + /// Downloads package of logs from the backend + /// + /// For now the path is path to a destination file without an extension. Extension + /// will be added according to the compression type found in the response + /// + /// Returns path to logs + pub async fn store(&self, path: &Path) -> Result { + // 1) response with logs + let response = self.client.get_raw("/manager/logs/store").await?; + + // 2) find out the destination file name + let ext = + &response + .headers() + .get(CONTENT_ENCODING) + .ok_or(ServiceError::CannotGenerateLogs(String::from( + "Invalid response", + )))?; + let mut destination = path.to_path_buf(); + + destination.set_extension( + ext.to_str() + .map_err(|_| ServiceError::CannotGenerateLogs(String::from("Invalid response")))?, + ); + + // 3) store response's binary content (logs) in a file + let mut file = std::fs::File::create(destination.as_path()).map_err(|_| { + ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) + })?; + let mut content = Cursor::new(response.bytes().await?); + + std::io::copy(&mut content, &mut file).map_err(|_| { + ServiceError::CannotGenerateLogs(String::from("Cannot store received response")) + })?; + + Ok(destination) + } + + /// Asks backend for lists of log files and commands used for creating logs archive returned by + /// store (/logs/store) backed HTTP API command + pub async fn list(&self) -> Result { + Ok(self.client.get("/manager/logs/list").await?) + } } From a3b55db7b35a48a41f67d5bcbd9b4e263170692a Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Tue, 5 Nov 2024 11:13:43 +0100 Subject: [PATCH 14/18] Cleanup and reorganized the code a bit --- rust/agama-lib/src/logs.rs | 43 ++++++------------ rust/agama-server/src/manager/web.rs | 67 ++++++++++++++-------------- 2 files changed, 48 insertions(+), 62 deletions(-) diff --git a/rust/agama-lib/src/logs.rs b/rust/agama-lib/src/logs.rs index 7d9d7c2405..beed88c968 100644 --- a/rust/agama-lib/src/logs.rs +++ b/rust/agama-lib/src/logs.rs @@ -64,25 +64,15 @@ const DEFAULT_RESULT: &str = "/run/agama/agama-logs"; pub const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); const TMP_DIR_PREFIX: &str = "agama-logs."; -/// Configurable parameters of the "agama logs" which can be -/// set by user when calling a (sub)command -pub struct LogOptions { - paths: Vec, - commands: Vec<(String, String)>, - destination: PathBuf, +fn log_paths() -> Vec { + DEFAULT_PATHS.iter().map(|p| p.to_string()).collect() } -impl Default for LogOptions { - fn default() -> Self { - Self { - paths: DEFAULT_PATHS.iter().map(|p| p.to_string()).collect(), - commands: DEFAULT_COMMANDS - .iter() - .map(|(cmd, name)| (cmd.to_string(), name.to_string())) - .collect(), - destination: PathBuf::from(DEFAULT_RESULT), - } - } +fn log_commands() -> Vec<(String, String)> { + DEFAULT_COMMANDS + .iter() + .map(|(cmd, name)| (cmd.to_string(), name.to_string())) + .collect() } /// Struct for log represented by a file @@ -276,16 +266,11 @@ pub fn set_archive_permissions(archive: PathBuf) -> io::Result<()> { } /// Handler for the "agama logs store" subcommand -pub fn store(options: LogOptions) -> Result { +pub fn store() -> Result { // preparation, e.g. in later features some log commands can be added / excluded per users request or - let commands = options.commands; - let paths = options.paths; - let opt_dest = options.destination.into_os_string(); - let destination = opt_dest - .to_str() - .ok_or(ServiceError::CannotGenerateLogs(String::from( - "Cannot collect the logs", - )))?; + let commands = log_commands(); + let paths = log_paths(); + let destination = DEFAULT_RESULT; let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); // create temporary directory where to collect all files (similar to what old save_y2logs @@ -328,9 +313,9 @@ pub struct LogsLists { } /// Handler for the "agama logs list" subcommand -pub fn list(options: LogOptions) -> LogsLists { +pub fn list() -> LogsLists { LogsLists { - commands: options.commands.iter().map(|c| c.0.clone()).collect(), - files: options.paths.clone(), + commands: log_commands().iter().map(|c| c.0.clone()).collect(), + files: log_paths().clone(), } } diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index f29092844c..5d2e78d363 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -25,9 +25,7 @@ //! * `manager_service` which returns the Axum service. //! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. -use agama_lib::logs::{ - list as listLogs, store as storeLogs, LogOptions, LogsLists, DEFAULT_COMPRESSION, -}; +use agama_lib::logs::{list as list_logs, store as store_logs, LogsLists, DEFAULT_COMPRESSION}; use agama_lib::{ error::ServiceError, manager::{InstallationPhase, ManagerClient}, @@ -36,7 +34,7 @@ use agama_lib::{ use axum::{ body::Body, extract::State, - http::{header, HeaderMap, HeaderValue}, + http::{header, status::StatusCode, HeaderMap, HeaderValue}, response::IntoResponse, routing::{get, post}, Json, Router, @@ -232,49 +230,52 @@ async fn installer_status( fn logs_router() -> Router> { Router::new() .route("/store", get(download_logs)) - .route("/list", get(list_logs)) + .route("/list", get(show_logs)) } -#[utoipa::path(get, path = "/logs/store", responses( +#[utoipa::path(get, path = "/manager/logs/store", responses( (status = 200, description = "Compressed Agama logs", content_type="application/octet-stream"), + (status = 500, description = "Cannot collect the logs"), + (status = 507, description = "Server is probably out of space"), ))] async fn download_logs() -> impl IntoResponse { let mut headers = HeaderMap::new(); + let err_response = (headers.clone(), Body::empty()); - match storeLogs(LogOptions::default()) { + match store_logs() { Ok(path) => { - let file = tokio::fs::File::open(path.clone()).await.unwrap(); - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); - - // Cleanup - remove temporary file, no one cares it it fails - let _ = std::fs::remove_file(path.clone()); + if let Ok(file) = tokio::fs::File::open(path.clone()).await { + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + let _ = std::fs::remove_file(path.clone()); - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/toml; charset=utf-8"), - ); - headers.insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_static("attachment; filename=\"agama-logs\""), - ); - headers.insert( - header::CONTENT_ENCODING, - HeaderValue::from_static(DEFAULT_COMPRESSION.1), - ); + // See RFC2046, RFC2616 and + // https://www.iana.org/assignments/media-types/media-types.xhtml + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/gzip"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"agama-logs\""), + ); + headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(DEFAULT_COMPRESSION.1), + ); - (headers, body) - } - Err(_) => { - // fill in with meaningful headers - (headers, Body::empty()) + (StatusCode::OK, (headers, body)) + } else { + (StatusCode::INSUFFICIENT_STORAGE, err_response) + } } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, err_response), } } -#[utoipa::path(get, path = "/logs/list", responses( +#[utoipa::path(get, path = "/manager/logs/list", responses( (status = 200, description = "Lists of collected logs", body = LogsLists) ))] -pub async fn list_logs() -> Json { - Json(listLogs(LogOptions::default())) +pub async fn show_logs() -> Json { + Json(list_logs()) } From 4c9cef4fe13306a9f6a6383a7388e5eb74618d92 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Tue, 5 Nov 2024 11:15:37 +0100 Subject: [PATCH 15/18] Adapted logs store api references in web UI --- web/src/api/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/api/manager.ts b/web/src/api/manager.ts index b9e13f6147..856e404813 100644 --- a/web/src/api/manager.ts +++ b/web/src/api/manager.ts @@ -42,6 +42,6 @@ const finishInstallation = () => post("/api/manager/finish"); /** * Returns the binary content of the YaST logs file. */ -const fetchLogs = () => get("/api/manager/logs.tar.gz"); +const fetchLogs = () => get("/api/manager/logs/store"); export { startProbing, startInstallation, finishInstallation, fetchLogs }; From 49fab1d9e3e9ca9a20854f55ea6b7fee974d4149 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Tue, 5 Nov 2024 11:21:09 +0100 Subject: [PATCH 16/18] Fixed typo in changes --- rust/package/agama.changes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index ea605b5a1a..31591bc2a9 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,7 +1,7 @@ ------------------------------------------------------------------- Mon Nov 4 07:51:55 UTC 2024 - Michal Filka -- Followup of original fix for gh#agama-project/agama#1495 +- Follow-up of original fix for gh#agama-project/agama#1495 - Implemented HTTP API for downloading logs - Adapted CLI command "logs" to use the HTTP API for downloading logs From 3887cf3356a09dc0573f484482eff4d04dcd7b37 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Tue, 5 Nov 2024 19:55:07 +0100 Subject: [PATCH 17/18] Setup correct content type for response carrying logs --- rust/agama-server/src/manager/web.rs | 3 ++- web/src/routes/paths.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/agama-server/src/manager/web.rs b/rust/agama-server/src/manager/web.rs index 5d2e78d363..8f59b0adee 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -252,9 +252,10 @@ async fn download_logs() -> impl IntoResponse { // See RFC2046, RFC2616 and // https://www.iana.org/assignments/media-types/media-types.xhtml + // or /etc/mime.types headers.insert( header::CONTENT_TYPE, - HeaderValue::from_static("application/gzip"), + HeaderValue::from_static("application/x-compressed-tar"), ); headers.insert( header::CONTENT_DISPOSITION, diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 31b07e639e..63d1047e05 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -46,7 +46,7 @@ const ROOT = { installation: "/installation", installationProgress: "/installation/progress", installationFinished: "/installation/finished", - logs: "/api/manager/logs.tar.gz", + logs: "/api/manager/logs/store", }; const USER = { From 73ffc9e2fedd4f7a52ab66514abfe9dc01716ee2 Mon Sep 17 00:00:00 2001 From: Michal Filka Date: Wed, 6 Nov 2024 07:13:04 +0100 Subject: [PATCH 18/18] Updated changelog --- web/package/agama-web-ui.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index e0d79ae883..8f1188efa8 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Nov 6 06:06:51 UTC 2024 - Michal Filka + +- URL for downloading Agama logs adapted to use new HTTP API +- https://github.com/agama-project/agama/pull/1720 + ------------------------------------------------------------------- Tue Nov 5 11:33:12 UTC 2024 - Imobach Gonzalez Sosa