diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 841bfd2c53..4f834a7351 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -24,10 +24,10 @@ dependencies = [ "agama-lib", "anyhow", "async-trait", + "chrono", "clap", "console", "curl", - "fs_extra", "indicatif", "inquire", "nix 0.27.1", @@ -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", @@ -796,8 +798,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..6458d8c254 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -14,15 +14,14 @@ 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" 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/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..cc9a12da07 100644 --- a/rust/agama-cli/src/logs.rs +++ b/rust/agama-cli/src/logs.rs @@ -18,27 +18,18 @@ // 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::set_archive_permissions; +use agama_lib::manager::http_client::ManagerHTTPClient as 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; +use std::path::PathBuf; // 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. @@ -49,25 +40,41 @@ 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, - destination, - } => { + LogsCommands::Store { destination } => { // 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() - }; + 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(result.clone()) + .map_err(|_| anyhow::Error::msg("Cannot store the logs"))?; - Ok(store(options)?) + println!("{}", result.clone().display()); + + Ok(()) } LogsCommands::List => { - list(LogOptions::default()); + 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(()) } @@ -85,7 +92,11 @@ pub async fn run(subcommand: LogsCommands) -> anyhow::Result<()> { /// 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 @@ -106,358 +117,4 @@ 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. -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 -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!(); - } -} 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/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index dc9bbbc3cd..2b1d54b47d 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,16 @@ 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_raw(&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/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..beed88c968 --- /dev/null +++ b/rust/agama-lib/src/logs.rs @@ -0,0 +1,321 @@ +// 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::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; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; +use utoipa::ToSchema; + +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 = "/run/agama/agama-logs"; +// what compression is used by default: +// (, ) +pub const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); +const TMP_DIR_PREFIX: &str = "agama-logs."; + +fn log_paths() -> Vec { + DEFAULT_PATHS.iter().map(|p| p.to_string()).collect() +} + +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 +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(PathBuf::from(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. +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.clone(), permissions)?; + + // set the archive owner to root:root + // note: std::os::unix::fs::chown is unstable for now + std::os::unix::fs::chown(archive.as_path(), Some(0), Some(0)) +} + +/// Handler for the "agama logs store" subcommand +pub fn store() -> Result { + // preparation, e.g. in later features some log commands can be added / excluded per users request or + 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 + // does) + 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)); + + // 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/ + 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", + ))); + } + } + + if compress_logs(&tmp_dir, &result).is_err() { + return Err(ServiceError::CannotGenerateLogs(String::from( + "Cannot collect the logs", + ))); + } + + Ok(PathBuf::from(result)) +} + +#[derive(Serialize, serde::Deserialize, ToSchema)] +pub struct LogsLists { + pub commands: Vec, + pub files: Vec, +} + +/// Handler for the "agama logs list" subcommand +pub fn list() -> LogsLists { + LogsLists { + commands: log_commands().iter().map(|c| c.0.clone()).collect(), + files: log_paths().clone(), + } +} 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?) + } } 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/manager/web.rs b/rust/agama-server/src/manager/web.rs index 09fd6e1b9f..8f59b0adee 100644 --- a/rust/agama-server/src/manager/web.rs +++ b/rust/agama-server/src/manager/web.rs @@ -25,24 +25,24 @@ //! * `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 list_logs, store as store_logs, 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, status::StatusCode, 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 +116,7 @@ pub async fn manager_service(dbus: zbus::Connection) -> Result Router> { + Router::new() + .route("/store", get(download_logs)) + .route("/list", get(show_logs)) +} -pub async fn download_logs() -> impl IntoResponse { - let path = generate_logs().await; - let Ok(path) = path else { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); - }; +#[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"), +))] - 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(), - } -} +async fn download_logs() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + let err_response = (headers.clone(), Body::empty()); -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}"); + match store_logs() { + Ok(path) => { + 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()); - Command::new("agama") - .args(["logs", "store", "-d", path.as_str()]) - .status() - .await - .map_err(|e| ServiceError::CannotGenerateLogs(e.to_string()))?; + // 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/x-compressed-tar"), + ); + 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) + (StatusCode::OK, (headers, body)) + } else { + (StatusCode::INSUFFICIENT_STORAGE, err_response) + } + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, err_response), + } +} +#[utoipa::path(get, path = "/manager/logs/list", responses( + (status = 200, description = "Lists of collected logs", body = LogsLists) +))] +pub async fn show_logs() -> Json { + Json(list_logs()) } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index fdd2f4f04b..31591bc2a9 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 + +- 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 + ------------------------------------------------------------------- Wed Oct 30 15:27:11 UTC 2024 - Jorik Cronenberg 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 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 }; 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 = {