From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id B000C1FF0E2 for ; Thu, 30 Jul 2026 12:14:14 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1FE022154D; Thu, 30 Jul 2026 12:13:57 +0200 (CEST) From: Erik Fastermann To: pbs-devel@lists.proxmox.com, pdm-devel@lists.proxmox.com Subject: [PATCH proxmox v2 1/5] system-report: add crate for shared report generation Date: Thu, 30 Jul 2026 12:13:45 +0200 Message-ID: <20260730101349.217371-2-e.fastermann@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260730101349.217371-1-e.fastermann@proxmox.com> References: <20260730101349.217371-1-e.fastermann@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 1 AWL -0.449 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods KAM_SHORT 0.001 Use of a URL Shortener for very short URL RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: EBYKLFGLDQVXPW6L34QDN4QRRP2IXIER X-Message-ID-Hash: EBYKLFGLDQVXPW6L34QDN4QRRP2IXIER X-MailFrom: efastermann@ruth.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Erik Fastermann X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Factor the shared report generation from PBS and PDM into a new proxmox-system-report crate, so both generate their reports from a single implementation. The crate defines the common section order (FILES, COMMANDS, FUNCTIONS) and the set of general commands run on every product; products pass their own additional files, commands and functions on top. It produces no report on its own, so the user-visible changes land in the respective server packages. Suggested-by: Lukas Wagner Suggested-by: Christian Ebner Signed-off-by: Erik Fastermann --- Cargo.toml | 2 + proxmox-system-report/Cargo.toml | 16 ++ proxmox-system-report/debian/changelog | 6 + proxmox-system-report/debian/control | 36 ++++ proxmox-system-report/debian/copyright | 18 ++ proxmox-system-report/debian/debcargo.toml | 7 + proxmox-system-report/src/lib.rs | 239 +++++++++++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 proxmox-system-report/Cargo.toml create mode 100644 proxmox-system-report/debian/changelog create mode 100644 proxmox-system-report/debian/control create mode 100644 proxmox-system-report/debian/copyright create mode 100644 proxmox-system-report/debian/debcargo.toml create mode 100644 proxmox-system-report/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ef407166..1732444b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ "proxmox-subscription", "proxmox-sys", "proxmox-syslog-api", + "proxmox-system-report", "proxmox-systemd", "proxmox-tfa", "proxmox-time", @@ -177,6 +178,7 @@ proxmox-io = { version = "1.2.1", path = "proxmox-io" } proxmox-lang = { version = "1.5", path = "proxmox-lang" } proxmox-log = { version = "1.0.0", path = "proxmox-log" } proxmox-login = { version = "1.0.0", path = "proxmox-login" } +proxmox-network-api = { version = "1.0.5", path = "proxmox-network-api" } proxmox-network-types = { version = "1.0.2", path = "proxmox-network-types" } proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" } proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" } diff --git a/proxmox-system-report/Cargo.toml b/proxmox-system-report/Cargo.toml new file mode 100644 index 00000000..3efee984 --- /dev/null +++ b/proxmox-system-report/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "proxmox-system-report" +description = "Shared system report functionality" +version = "1.0.0" + +authors.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +proxmox-network-api = { workspace = true, features = ["impl"] } +proxmox-sys.workspace = true diff --git a/proxmox-system-report/debian/changelog b/proxmox-system-report/debian/changelog new file mode 100644 index 00000000..cfdfc030 --- /dev/null +++ b/proxmox-system-report/debian/changelog @@ -0,0 +1,6 @@ +rust-proxmox-system-report (1.0.0-1) trixie; urgency=medium + + * initial release, factored out from proxmox-backup and + proxmox-datacenter-manager. + + -- Proxmox Support Team Wed, 29 Jul 2026 10:40:07 +0200 diff --git a/proxmox-system-report/debian/control b/proxmox-system-report/debian/control new file mode 100644 index 00000000..b764f690 --- /dev/null +++ b/proxmox-system-report/debian/control @@ -0,0 +1,36 @@ +Source: rust-proxmox-system-report +Section: rust +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-sequence-cargo +Build-Depends-Arch: cargo:native , + rustc:native (>= 1.85) , + libstd-rust-dev , + librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~) , + librust-proxmox-network-api-1+impl-dev (>= 1.0.5-~~) , + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) +Maintainer: Proxmox Support Team +Standards-Version: 4.7.2 +Vcs-Git: git://git.proxmox.com/git/proxmox.git +Vcs-Browser: https://git.proxmox.com/?p=proxmox.git +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-system-report + +Package: librust-proxmox-system-report-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~), + librust-proxmox-network-api-1+impl-dev (>= 1.0.5-~~), + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) +Provides: + librust-proxmox-system-report+default-dev (= ${binary:Version}), + librust-proxmox-system-report-1-dev (= ${binary:Version}), + librust-proxmox-system-report-1+default-dev (= ${binary:Version}), + librust-proxmox-system-report-1.0-dev (= ${binary:Version}), + librust-proxmox-system-report-1.0+default-dev (= ${binary:Version}), + librust-proxmox-system-report-1.0.0-dev (= ${binary:Version}), + librust-proxmox-system-report-1.0.0+default-dev (= ${binary:Version}) +Description: Shared system report functionality - Rust source code + Source code for Debianized Rust crate "proxmox-system-report" diff --git a/proxmox-system-report/debian/copyright b/proxmox-system-report/debian/copyright new file mode 100644 index 00000000..01138fa0 --- /dev/null +++ b/proxmox-system-report/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2026 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 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 Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-system-report/debian/debcargo.toml b/proxmox-system-report/debian/debcargo.toml new file mode 100644 index 00000000..b7864cdb --- /dev/null +++ b/proxmox-system-report/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-system-report/src/lib.rs b/proxmox-system-report/src/lib.rs new file mode 100644 index 00000000..9ae3e573 --- /dev/null +++ b/proxmox-system-report/src/lib.rs @@ -0,0 +1,239 @@ +//! Shared functionality to generate system reports. + +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![deny(unsafe_code)] +#![deny(missing_docs)] + +use std::fmt::Write; +use std::path::Path; +use std::process::Command; + +use proxmox_network_api::NetworkInterfaceType; + +/// A group of files to include: `(group_name, [path, ...])`. +pub type FileGroup = (&'static str, Vec<&'static str>); + +/// A command to run with static, compile time known arguments: +/// `(command, [arg, ...])`. +pub type StaticArgsCommandSpec = (&'static str, Vec<&'static str>); + +/// A command to run with arguments generated dynamically at runtime: +/// `(command, [arg, ...])`. +type DynamicArgsCommandSpec = (&'static str, Vec); + +/// A function to run and its label: `(description, function)`. +pub type FunctionMapping = (&'static str, fn() -> String); + +fn get_top_processes() -> String { + let (exe, args) = ("top", vec!["-b", "-c", "-w512", "-n", "1", "-o", "TIME"]); + let output = Command::new(exe).args(&args).output(); + let output = match output { + Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(), + Err(err) => err.to_string(), + }; + let output = output.lines().take(30).collect::>().join("\n"); + format!("$ `{exe} {}`\n```\n{output}\n```", args.join(" ")) +} + +fn common_files() -> Vec { + vec![( + "General System Info", + vec![ + "/etc/hostname", + "/etc/hosts", + "/etc/network/interfaces", + "/etc/apt/sources.list", + "/etc/apt/sources.list.d/", + "/proc/pressure/", + ], + )] +} + +fn common_commands() -> Vec { + vec![ + ("proxmox-boot-tool", vec!["status"]), + ("df", vec!["-h", "-T"]), + ( + "lsblk", + vec![ + "--ascii", + "-M", + "-o", + "+HOTPLUG,ROTA,PHY-SEC,FSTYPE,MODEL,TRAN", + ], + ), + ("bash", vec!["-c", "ls -l /dev/disk/by-*/"]), + ("zpool", vec!["status"]), + ("zfs", vec!["list"]), + ("zarcstat", vec![]), + ("dmidecode", vec!["-t", "bios"]), + ("lscpu", vec![]), + ("lspci", vec!["-nnk"]), + ("ip", vec!["-details", "-statistics", "a"]), + ("ip", vec!["-4", "route", "show"]), + ("ip", vec!["-6", "route", "show"]), + ] +} + +fn common_dynamic_commands() -> Vec { + let mut commands = Vec::new(); + + match proxmox_network_api::config() { + Ok((config, _)) => { + for (name, iface) in config.interfaces { + if iface.interface_type == NetworkInterfaceType::Eth { + commands.push(("ethtool", vec![name])); + } + } + } + Err(err) => { + eprintln!("failed to query network interfaces: {err}"); + } + } + + commands +} + +fn common_function_calls() -> Vec { + vec![("System Load & Uptime", get_top_processes)] +} + +fn get_file_content(file: impl AsRef) -> String { + use proxmox_sys::fs::file_read_optional_string; + let content = match file_read_optional_string(&file) { + Ok(Some(content)) => content, + Ok(None) => String::from("# file does not exist"), + Err(err) => err.to_string(), + }; + let file_name = file.as_ref().display(); + format!("`$ cat '{file_name}'`\n```\n{}\n```", content.trim_end()) +} + +fn get_directory_content(path: impl AsRef) -> String { + let read_dir_iter = match std::fs::read_dir(&path) { + Ok(iter) => iter, + Err(err) => { + return format!( + "`$ cat '{}*'`\n```\n# read dir failed - {err}\n```", + path.as_ref().display(), + ); + } + }; + let mut out = String::new(); + let mut first = true; + for entry in read_dir_iter { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + let _ = writeln!(out, "error during read-dir - {err}"); + continue; + } + }; + let path = entry.path(); + if path.is_file() { + if first { + let _ = writeln!(out, "{}", get_file_content(path)); + first = false; + } else { + let _ = writeln!(out, "\n{}", get_file_content(path)); + } + } else { + let _ = writeln!(out, "skipping sub-directory `{}`", path.display()); + } + } + out +} + +fn get_command_output(exe: &str, args: &[&str]) -> String { + let output = Command::new(exe) + .env("PROXMOX_OUTPUT_NO_BORDER", "1") + .args(args) + .output(); + let output = match output { + Ok(output) => { + let mut out = String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(); + let stderr = String::from_utf8_lossy(&output.stderr) + .trim_end() + .to_string(); + if !stderr.is_empty() { + let _ = writeln!(out, "\n```\nSTDERR:\n```\n{stderr}"); + } + out + } + Err(err) => err.to_string(), + }; + format!("$ `{exe} {}`\n```\n{output}\n```", args.join(" ")) +} + +/// Generate a system report as a Markdown-like document. +/// +/// The report has three top-level sections, always emitted in this order: +/// `FILES`, `COMMANDS` and `FUNCTIONS`. The project-specific entries passed in +/// are merged with a set of built-in, general-purpose entries common to all +/// products. +/// +/// Missing files, unreadable directories and commands that fail to spawn are +/// reported inline instead of aborting the report. +pub fn generate_report( + project_files: Vec, + project_commands: Vec, + project_function_calls: Vec, +) -> String { + // We deliberately output the shared files before the project specific files + // to preserve the legacy report ordering. + let file_contents = common_files() + .iter() + .chain(&project_files) + .map(|group| { + let (group, files) = group; + let group_content = files + .iter() + .map(|file_name| { + let path = Path::new(file_name); + if path.is_dir() { + get_directory_content(path) + } else { + get_file_content(file_name) + } + }) + .collect::>() + .join("\n\n"); + + format!("### {group}\n\n{group_content}") + }) + .collect::>() + .join("\n\n"); + + let static_command_outputs = project_commands + .into_iter() + .chain(common_commands()) + .map(|(command, args)| get_command_output(command, &args)); + + let dynamic_command_outputs = common_dynamic_commands() + .into_iter() + .map(|(command, args)| { + let args: Vec<_> = args.iter().map(String::as_str).collect(); + get_command_output(command, &args) + }); + + let command_outputs = static_command_outputs + .chain(dynamic_command_outputs) + .collect::>() + .join("\n\n"); + + let function_outputs = project_function_calls + .iter() + .chain(&common_function_calls()) + .map(|(desc, function)| { + let output = function(); + format!("#### {desc}\n{}\n", output.trim_end()) + }) + .collect::>() + .join("\n\n"); + + format!( + "## FILES\n\n{file_contents}\n## COMMANDS\n\n{command_outputs}\n## FUNCTIONS\n\n{function_outputs}\n" + ) +} -- 2.47.3