agama/rust/agama-lib/src/logs.rs
Imobach González Sosa a143f3e89a
Remove utoipa from agama-server and xtask
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-22 21:55:40 +01:00

345 lines
10 KiB
Rust

// 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 schemars::JsonSchema;
use serde::Serialize;
use std::fs;
use std::fs::File;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use zypp_agama::SOLVER_TESTCASE_DIR;
const DEFAULT_COMMANDS: [(&str, &str); 4] = [
// (<command to be executed>, <file name used for storing result of the command>)
("journalctl -o json", "journal_json"),
("agama-journal", "journal"),
("agama-zypp-journal", "libzypp"),
("rpm -qa", "rpm-qa"),
];
const DEFAULT_PATHS: [&str; 18] = [
// logs
"/var/log/build",
"/var/log/YaST2",
"/var/log/zypper.log",
"/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",
"/var/log/zypp/history",
SOLVER_TESTCASE_DIR,
"/run/agama/dbus.log",
"/run/agama/inst-scripts",
// config
"/etc/install.inf",
"/etc/os-release",
"/linuxrc.config",
"/.packages.root",
];
const DEFAULT_RESULT: &str = "/run/agama/agama-logs";
// what compression is used by default:
// (<compression as distinguished by tar>, <an extension for resulting archive>)
pub const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz");
const TMP_DIR_PREFIX: &str = "agama-logs.";
#[derive(Debug, thiserror::Error)]
#[error("Could not generate logs: {0}")]
pub struct LogsError(String);
fn log_paths() -> Vec<String> {
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 /<tmp dir>/etc/
fs::create_dir_all(dst_path)?;
let options = CopyOptions::new();
copy_items(&[self.src_path.as_str()], dst_path, &options)
.map_err(|_| io::Error::other("Copying of a file failed"))?;
if let Some(name) = dst_file.file_name().and_then(|fname| fname.to_str()) {
let dst_name = name.trim_start_matches(".");
let _ = fs::rename(dst_file.clone(), dst_file.with_file_name(dst_name));
}
Ok(())
}
}
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::<Vec<&str>>();
let file_path = self.to();
let mut stdout_name = file_path.clone();
stdout_name.set_extension("out.log");
let mut stderr_name = file_path.clone();
stderr_name.set_extension("err.log");
let file_stdout = File::create(&stdout_name)?;
let file_stderr = File::create(&stderr_name)?;
let status = Command::new(cmd_parts[0])
.args(&cmd_parts[1..])
.stdout(file_stdout)
.stderr(file_stderr)
.status()?;
tracing::info!(
"Command {} finished with status: {}",
self.cmd,
status.code().unwrap_or_default()
);
// delete the created files if they are empty
if fs::metadata(&stdout_name)?.len() == 0 {
let _ = fs::remove_file(&stdout_name);
}
if fs::metadata(&stderr_name)?.len() == 0 {
let _ = fs::remove_file(&stderr_name);
}
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<Box<dyn LogItem>> {
let mut log_sources: Vec<Box<dyn LogItem>> = 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<Box<dyn LogItem>> {
let mut log_sources: Vec<Box<dyn LogItem>> = 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::<Vec<&str>>();
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::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<PathBuf, LogsError> {
// 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(|_| LogsError(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 /<tmp dir>/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(LogsError(String::from("Cannot collect the logs")));
}
}
if compress_logs(&tmp_dir, &result).is_err() {
return Err(LogsError(String::from("Cannot collect the logs")));
}
Ok(PathBuf::from(result))
}
#[derive(Serialize, serde::Deserialize, JsonSchema)]
pub struct LogsLists {
pub commands: Vec<String>,
pub files: Vec<String>,
}
/// 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(),
}
}