public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Christian Ebner <c.ebner@proxmox.com>
To: Erik Fastermann <e.fastermann@proxmox.com>,
	pbs-devel@lists.proxmox.com, pdm-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox 1/3] system-report: add crate for shared report generation
Date: Tue, 28 Jul 2026 15:59:39 +0200	[thread overview]
Message-ID: <1219b0fa-c7c8-4749-9069-9cb440338e91@proxmox.com> (raw)
In-Reply-To: <20260714083156.99794-2-e.fastermann@proxmox.com>

On 7/14/26 10:31 AM, Erik Fastermann wrote:
> 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"

nit: let's start with 1.0.0, otherwise this could be mis-interpreted as 
not production-ready.

> +
> +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

comment: these dependencies must also be declared in debian/control 
(still missing and should be included to build deb packages with their 
dependencies), so apt can resolve and install these in case no other 
package does.

> 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>

question: not sure about the copyright timespan here, but since the 
factored out code lived in proxmox-backup and that has 2019-2026 this is 
fine I guess?

> +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>);

nit: IMHO this should be StaticArgsCommandSpec, and the docstring state 
that this is for commands with static, compile time known arguments ...

> +
> +/// A command to run with owned arguments: `(command, [arg, ...])`.
> +type DynamicCommandSpec = (&'static str, Vec<String>);

... and this DynamicArgsCommmandSpec, the docstring mentioning that 
these commands are to be run with dynamically generated arguments 
produced on runtime.

> +
> +/// 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> {

nit: in PBS and PDM, the files() helper generates also the list of 
additional, product specific file groups. So might be better to call 
this product_common_files() or product_agnostic_files()?

> +    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> {

nit: same as for files(), the command() helper should reflect that these 
are product common commands, so maybe rename this to 
product_common_commands()?

> +    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"]),

comment: these commands depend on external executables. So there should 
be a dependency in debian/control as well.

> +    ]
> +}
> +
> +fn dynamic_commands() -> Vec<DynamicCommandSpec> {

nit: same as files() and commands() above ...

> +    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> {

nit: same as for others above, this are the product_common_function_calls()

> +    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"
> +    )
> +}





  reply	other threads:[~2026-07-28 13:59 UTC|newest]

Thread overview: 12+ 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 ` [PATCH proxmox 1/3] system-report: add crate for shared report generation Erik Fastermann
2026-07-28 13:59   ` Christian Ebner [this message]
2026-07-29 11:55     ` Erik Fastermann
2026-07-29 12:08       ` Christian Ebner
2026-07-14  8:31 ` [PATCH proxmox-backup 2/3] report: use shared proxmox-system-report crate Erik Fastermann
2026-07-28 13:59   ` Christian Ebner
2026-07-14  8:31 ` [PATCH proxmox-datacenter-manager 3/3] " Erik Fastermann
2026-07-28 13:59   ` Christian Ebner
2026-07-14  9:05 ` [PATCH proxmox{,-backup,-datacenter-manager} 0/3] factor system report into shared crate Erik Fastermann
2026-07-28 13:59 ` Christian Ebner
2026-07-30 11:14 ` superseded: " 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=1219b0fa-c7c8-4749-9069-9cb440338e91@proxmox.com \
    --to=c.ebner@proxmox.com \
    --cc=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