public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Erik Fastermann <e.fastermann@proxmox.com>
To: pbs-devel@lists.proxmox.com, pdm-devel@lists.proxmox.com
Cc: Erik Fastermann <e.fastermann@proxmox.com>
Subject: [PATCH proxmox 1/3] system-report: add crate for shared report generation
Date: Tue, 14 Jul 2026 10:31:54 +0200	[thread overview]
Message-ID: <20260714083156.99794-2-e.fastermann@proxmox.com> (raw)
In-Reply-To: <20260714083156.99794-1-e.fastermann@proxmox.com>

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 <l.wagner@proxmox.com>
Suggested-by: Christian Ebner <c.ebner@proxmox.com>
Signed-off-by: Erik Fastermann <e.fastermann@proxmox.com>
---
 Cargo.toml                                 |   2 +
 proxmox-system-report/Cargo.toml           |  16 ++
 proxmox-system-report/debian/copyright     |  18 ++
 proxmox-system-report/debian/debcargo.toml |   7 +
 proxmox-system-report/src/lib.rs           | 235 +++++++++++++++++++++
 5 files changed, 278 insertions(+)
 create mode 100644 proxmox-system-report/Cargo.toml
 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..84a87c76
--- /dev/null
+++ b/proxmox-system-report/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "proxmox-system-report"
+description = "Shared system report functionality"
+version = "0.1.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/copyright b/proxmox-system-report/debian/copyright
new file mode 100644
index 00000000..77952eba
--- /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: 2019 - 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+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 <https://www.gnu.org/licenses/>.
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 <support@proxmox.com>"
+
+[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..92ca8e5d
--- /dev/null
+++ b/proxmox-system-report/src/lib.rs
@@ -0,0 +1,235 @@
+//! 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: `(command, [arg, ...])`.
+pub type CommandSpec = (&'static str, Vec<&'static str>);
+
+/// A command to run with owned arguments: `(command, [arg, ...])`.
+type DynamicCommandSpec = (&'static str, Vec<String>);
+
+/// 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::<Vec<&str>>().join("\n");
+    format!("$ `{exe} {}`\n```\n{output}\n```", args.join(" "))
+}
+
+fn files() -> Vec<FileGroup> {
+    vec![(
+        "General System Info",
+        vec![
+            "/etc/hostname",
+            "/etc/hosts",
+            "/etc/network/interfaces",
+            "/etc/apt/sources.list",
+            "/etc/apt/sources.list.d/",
+            "/proc/pressure/",
+        ],
+    )]
+}
+
+fn commands() -> Vec<CommandSpec> {
+    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 dynamic_commands() -> Vec<DynamicCommandSpec> {
+    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 function_calls() -> Vec<FunctionMapping> {
+    vec![("System Load & Uptime", get_top_processes)]
+}
+
+fn get_file_content(file: impl AsRef<Path>) -> 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<Path>) -> 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<FileGroup>,
+    project_commands: Vec<CommandSpec>,
+    project_function_calls: Vec<FunctionMapping>,
+) -> String {
+    // We deliberately output the shared files before the project specific files
+    // to preserve the legacy report ordering.
+    let file_contents = 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::<Vec<String>>()
+                .join("\n\n");
+
+            format!("### {group}\n\n{group_content}")
+        })
+        .collect::<Vec<String>>()
+        .join("\n\n");
+
+    let static_command_outputs = project_commands
+        .into_iter()
+        .chain(commands())
+        .map(|(command, args)| get_command_output(command, &args));
+
+    let dynamic_command_outputs = 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::<Vec<String>>()
+        .join("\n\n");
+
+    let function_outputs = project_function_calls
+        .iter()
+        .chain(&function_calls())
+        .map(|(desc, function)| {
+            let output = function();
+            format!("#### {desc}\n{}\n", output.trim_end())
+        })
+        .collect::<Vec<String>>()
+        .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




  reply	other threads:[~2026-07-14  8:32 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-14  8:31 [PATCH proxmox{,-backup,-datacenter-manager} 0/3] factor system report into shared crate Erik Fastermann
2026-07-14  8:31 ` Erik Fastermann [this message]
2026-07-14  8:31 ` [PATCH proxmox-backup 2/3] report: use shared proxmox-system-report crate Erik Fastermann
2026-07-14  8:31 ` [PATCH proxmox-datacenter-manager 3/3] " Erik Fastermann
2026-07-14  9:05 ` [PATCH proxmox{,-backup,-datacenter-manager} 0/3] factor system report into shared crate Erik Fastermann

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260714083156.99794-2-e.fastermann@proxmox.com \
    --to=e.fastermann@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal