all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Christoph Heiss <c.heiss@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH installer v4 11/12] fix #5536: post-hook: add utility for sending notifications after auto-install
Date: Mon, 11 Nov 2024 14:15:07 +0100	[thread overview]
Message-ID: <20241111131519.867887-12-c.heiss@proxmox.com> (raw)
In-Reply-To: <20241111131519.867887-1-c.heiss@proxmox.com>

This utility can be called with the low-level install config after a
successful installation to send a notification via a HTTP POST request,
if the user has configured an endpoint for that in the answer file.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v3 -> v4:
  * no changes

Changes v2 -> v3:
  * split out two prepratory changes into separate changes
  * fix outdated doc-comment for `struct DiskInfo`
  * align continuation dots with rest of messages

Changes v1 -> v2:
  * squash implementation and unit tests into one patch
  * simplify udev property retrieving by introducing proper helpers on
    `UdevInfo` itself
  * rename Answer::from_reader() -> Answer::try_from_reader to better
    reflect it returns a Result<>
  * improved error message in some places
  * added new fields; now includes ISO version, SecureBoot state, CPU
    and DMI info
  * product information was split into separate fields
  * boot mode information was split into separate fields
  * product version is now retrieved from the package using dpkg-query
    directly
  * kernel version was split into separate fields, retrieving version
    string from the image directly
  * all disks and NICs are now included, a field indicates whether they
    are boot disk or management interface, respectively
  * move with_chroot() invocation out of PostHookInfo::gather()

 Cargo.toml                                    |   1 +
 Makefile                                      |   8 +-
 debian/install                                |   1 +
 .../src/bin/proxmox-auto-installer.rs         |   2 -
 proxmox-installer-common/src/options.rs       |   5 +
 proxmox-installer-common/src/setup.rs         |   2 +-
 proxmox-installer-common/src/utils.rs         |   2 +
 proxmox-post-hook/Cargo.toml                  |  18 +
 proxmox-post-hook/src/main.rs                 | 784 ++++++++++++++++++
 9 files changed, 818 insertions(+), 5 deletions(-)
 create mode 100644 proxmox-post-hook/Cargo.toml
 create mode 100644 proxmox-post-hook/src/main.rs

diff --git a/Cargo.toml b/Cargo.toml
index a27df88..8385d58 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ members = [
     "proxmox-fetch-answer",
     "proxmox-installer-common",
     "proxmox-tui-installer",
+    "proxmox-post-hook",
 ]
 
 [workspace.dependencies]
diff --git a/Makefile b/Makefile
index d80fc63..d85347f 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,8 @@ USR_BIN := \
 	   proxmox-tui-installer\
 	   proxmox-fetch-answer\
 	   proxmox-auto-install-assistant \
-	   proxmox-auto-installer
+	   proxmox-auto-installer \
+	   proxmox-post-hook
 
 COMPILED_BINS := \
 	$(addprefix $(CARGO_COMPILEDIR)/,$(USR_BIN))
@@ -59,6 +60,7 @@ $(BUILDDIR):
 	  proxmox-chroot \
 	  proxmox-tui-installer/ \
 	  proxmox-installer-common/ \
+	  proxmox-post-hook \
 	  test/ \
 	  $(SHELL_SCRIPTS) \
 	  $@.tmp
@@ -132,7 +134,9 @@ cargo-build:
 		--package proxmox-auto-installer --bin proxmox-auto-installer \
 		--package proxmox-fetch-answer --bin proxmox-fetch-answer \
 		--package proxmox-auto-install-assistant --bin proxmox-auto-install-assistant \
-		--package proxmox-chroot --bin proxmox-chroot $(CARGO_BUILD_ARGS)
+		--package proxmox-chroot --bin proxmox-chroot \
+		--package proxmox-post-hook --bin proxmox-post-hook \
+		$(CARGO_BUILD_ARGS)
 
 %-banner.png: %-banner.svg
 	rsvg-convert -o $@ $<
diff --git a/debian/install b/debian/install
index bb91da7..b64c8ec 100644
--- a/debian/install
+++ b/debian/install
@@ -15,4 +15,5 @@ usr/bin/proxmox-chroot
 usr/bin/proxmox-fetch-answer
 usr/bin/proxmox-low-level-installer
 usr/bin/proxmox-tui-installer
+usr/bin/proxmox-post-hook
 var
diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
index 1da6e79..ea45c29 100644
--- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
+++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
@@ -82,8 +82,6 @@ fn main() -> ExitCode {
         }
     }
 
-    // TODO: (optionally) do a HTTP post with basic system info, like host SSH public key(s) here
-
     ExitCode::SUCCESS
 }
 
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index de5d578..8b6c281 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -49,6 +49,11 @@ impl FsType {
     pub fn is_btrfs(&self) -> bool {
         matches!(self, FsType::Btrfs(_))
     }
+
+    /// Returns true if the filesystem is used on top of LVM, e.g. ext4 or XFS.
+    pub fn is_lvm(&self) -> bool {
+        matches!(self, FsType::Ext4 | FsType::Xfs)
+    }
 }
 
 impl fmt::Display for FsType {
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index d203e78..cd1e8b4 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -363,7 +363,7 @@ pub struct RuntimeInfo {
     pub secure_boot: Option<bool>,
 }
 
-#[derive(Copy, Clone, Eq, Deserialize, PartialEq)]
+#[derive(Copy, Clone, Eq, Deserialize, PartialEq, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum BootType {
     Bios,
diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs
index 57b1753..2579c80 100644
--- a/proxmox-installer-common/src/utils.rs
+++ b/proxmox-installer-common/src/utils.rs
@@ -114,6 +114,8 @@ impl<'de> Deserialize<'de> for CidrAddress {
     }
 }
 
+serde_plain::derive_serialize_from_display!(CidrAddress);
+
 fn mask_limit(addr: &IpAddr) -> usize {
     if addr.is_ipv4() {
         32
diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml
new file mode 100644
index 0000000..3acea6c
--- /dev/null
+++ b/proxmox-post-hook/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "proxmox-post-hook"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Christoph Heiss <c.heiss@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+anyhow.workspace = true
+proxmox-auto-installer.workspace = true
+proxmox-installer-common = { workspace = true, features = ["http"] }
+serde.workspace = true
+serde_json.workspace = true
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
new file mode 100644
index 0000000..8558da2
--- /dev/null
+++ b/proxmox-post-hook/src/main.rs
@@ -0,0 +1,784 @@
+//! Post installation hook for the Proxmox installer, mainly for combination
+//! with the auto-installer.
+//!
+//! If a `[posthook]` section is specified in the given answer file, it will
+//! send a HTTP POST request to that URL, with an optional certificate fingerprint
+//! for usage with (self-signed) TLS certificates.
+//! In the body of the request, information about the newly installed system is sent.
+//!
+//! Relies on `proxmox-chroot` as an external dependency to (bind-)mount the
+//! previously installed system.
+
+use std::{
+    collections::HashSet,
+    ffi::CStr,
+    fs::{self, File},
+    io::BufReader,
+    os::unix::fs::FileExt,
+    path::PathBuf,
+    process::{Command, ExitCode},
+};
+
+use anyhow::{anyhow, bail, Context, Result};
+use proxmox_auto_installer::{
+    answer::{Answer, PostNotificationHookInfo},
+    udevinfo::{UdevInfo, UdevProperties},
+};
+use proxmox_installer_common::{
+    options::{Disk, FsType},
+    setup::{
+        load_installer_setup_files, BootType, InstallConfig, IsoInfo, ProxmoxProduct, RuntimeInfo,
+        SetupInfo,
+    },
+    sysinfo::SystemDMI,
+    utils::CidrAddress,
+};
+use serde::Serialize;
+
+/// Information about the system boot status.
+#[derive(Serialize)]
+struct BootInfo {
+    /// Whether the system is booted using UEFI or legacy BIOS.
+    mode: BootType,
+    /// Whether SecureBoot is enabled for the installation.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    secureboot: Option<bool>,
+}
+
+/// Holds all the public keys for the different algorithms available.
+#[derive(Serialize)]
+struct SshPublicHostKeys {
+    // ECDSA-based public host key
+    ecdsa: String,
+    // ED25519-based public host key
+    ed25519: String,
+    // RSA-based public host key
+    rsa: String,
+}
+
+/// Holds information about a single disk in the system.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct DiskInfo {
+    /// Size in bytes
+    size: usize,
+    /// Set to true if the disk is used for booting.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    is_bootdisk: Option<bool>,
+    /// Properties about the device as given by udev.
+    udev_properties: UdevProperties,
+}
+
+/// Holds information about the management network interface.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct NetworkInterfaceInfo {
+    /// MAC address of the interface
+    mac: String,
+    /// (Designated) IP address of the interface
+    #[serde(skip_serializing_if = "Option::is_none")]
+    address: Option<CidrAddress>,
+    /// Set to true if the interface is the chosen management interface during
+    /// installation.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    is_management: Option<bool>,
+    /// Properties about the device as given by udev.
+    udev_properties: UdevProperties,
+}
+
+/// Information about the installed product itself.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct ProductInfo {
+    /// Full name of the product
+    fullname: String,
+    /// Product abbreviation
+    short: ProxmoxProduct,
+    /// Version of the installed product
+    version: String,
+}
+
+/// The current kernel version.
+/// Aligns with the format as used by the /nodes/<node>/status API of each product.
+#[derive(Serialize)]
+struct KernelVersionInformation {
+    /// The systemname/nodename
+    pub sysname: String,
+    /// The kernel release number
+    pub release: String,
+    /// The kernel version
+    pub version: String,
+    /// The machine architecture
+    pub machine: String,
+}
+
+/// Information about the CPU(s) installed in the system
+#[derive(Serialize)]
+struct CpuInfo {
+    /// Number of physical CPU cores.
+    cores: usize,
+    /// Number of logical CPU cores aka. threads.
+    cpus: usize,
+    /// CPU feature flag set as a space-delimited list.
+    flags: String,
+    /// Whether hardware-accelerated virtualization is supported.
+    hvm: bool,
+    /// Reported model of the CPU(s)
+    model: String,
+    /// Number of physical CPU sockets
+    sockets: usize,
+}
+
+/// All data sent as request payload with the post-hook POST request.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct PostHookInfo {
+    /// major.minor version of Debian as installed, retrieved from /etc/debian_version
+    debian_version: String,
+    /// PVE/PMG/PBS version as reported by `pveversion`, `pmgversion` or
+    /// `proxmox-backup-manager version`, respectively.
+    product: ProductInfo,
+    /// Release information for the ISO used for the installation.
+    iso: IsoInfo,
+    /// Installed kernel version
+    kernel_version: KernelVersionInformation,
+    /// Describes the boot mode of the machine and the SecureBoot status.
+    boot_info: BootInfo,
+    /// Information about the installed CPU(s)
+    cpu_info: CpuInfo,
+    /// DMI information about the system
+    dmi: SystemDMI,
+    /// Filesystem used for boot disk(s)
+    filesystem: FsType,
+    /// Fully qualified domain name of the installed system
+    fqdn: String,
+    /// Unique systemd-id128 identifier of the installed system (128-bit, 16 bytes)
+    machine_id: String,
+    /// All disks detected on the system.
+    disks: Vec<DiskInfo>,
+    /// All network interfaces detected on the system.
+    network_interfaces: Vec<NetworkInterfaceInfo>,
+    /// Public parts of SSH host keys of the installed system
+    ssh_public_host_keys: SshPublicHostKeys,
+}
+
+/// Defines the size of a gibibyte in bytes.
+const SIZE_GIB: usize = 1024 * 1024 * 1024;
+
+impl PostHookInfo {
+    /// Gathers all needed information about the newly installed system for sending
+    /// it to a specified server.
+    ///
+    /// # Arguments
+    ///
+    /// * `target_path` - Path to where the chroot environment root is mounted
+    /// * `answer` - Answer file as provided by the user
+    fn gather(target_path: &str, answer: &Answer) -> Result<Self> {
+        println!("Gathering installed system data ...");
+
+        let config: InstallConfig =
+            serde_json::from_reader(BufReader::new(File::open("/tmp/low-level-config.json")?))?;
+
+        let (setup_info, _, run_env) =
+            load_installer_setup_files(proxmox_installer_common::RUNTIME_DIR)
+                .map_err(|err| anyhow!("Failed to load setup files: {err}"))?;
+
+        let udev: UdevInfo = {
+            let path =
+                PathBuf::from(proxmox_installer_common::RUNTIME_DIR).join("run-env-udev.json");
+            serde_json::from_reader(BufReader::new(File::open(path)?))?
+        };
+
+        // Opens a file, specified by an absolute path _inside_ the chroot
+        // from the target.
+        let open_file = |path: &str| {
+            File::open(format!("{}/{}", target_path, path))
+                .with_context(|| format!("failed to open '{path}'"))
+        };
+
+        // Reads a file, specified by an absolute path _inside_ the chroot
+        // from the target.
+        let read_file = |path: &str| {
+            fs::read_to_string(format!("{}/{}", target_path, path))
+                .map(|s| s.trim().to_owned())
+                .with_context(|| format!("failed to read '{path}'"))
+        };
+
+        // Runs a command inside the target chroot.
+        let run_cmd = |cmd: &[&str]| {
+            Command::new("chroot")
+                .arg(target_path)
+                .args(cmd)
+                .output()
+                .with_context(|| format!("failed to run '{cmd:?}'"))
+                .and_then(|r| Ok(String::from_utf8(r.stdout)?))
+        };
+
+        Ok(Self {
+            debian_version: read_file("/etc/debian_version")?,
+            product: Self::gather_product_info(&setup_info, &run_cmd)?,
+            iso: setup_info.iso_info.clone(),
+            kernel_version: Self::gather_kernel_version(&run_cmd, &open_file)?,
+            boot_info: BootInfo {
+                mode: run_env.boot_type,
+                secureboot: run_env.secure_boot,
+            },
+            cpu_info: Self::gather_cpu_info(&run_env)?,
+            dmi: SystemDMI::get()?,
+            filesystem: answer.disks.fs_type,
+            fqdn: answer.global.fqdn.to_string(),
+            machine_id: read_file("/etc/machine-id")?,
+            disks: Self::gather_disks(&config, &run_env, &udev)?,
+            network_interfaces: Self::gather_nic(&config, &run_env, &udev)?,
+            ssh_public_host_keys: SshPublicHostKeys {
+                ecdsa: read_file("/etc/ssh/ssh_host_ecdsa_key.pub")?,
+                ed25519: read_file("/etc/ssh/ssh_host_ed25519_key.pub")?,
+                rsa: read_file("/etc/ssh/ssh_host_rsa_key.pub")?,
+            },
+        })
+    }
+
+    /// Retrieves all needed information about the boot disks that were selected during
+    /// installation, most notable the udev properties.
+    ///
+    /// # Arguments
+    ///
+    /// * `config` - Low-level installation configuration
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    /// * `udev` - udev information for all system devices
+    fn gather_disks(
+        config: &InstallConfig,
+        run_env: &RuntimeInfo,
+        udev: &UdevInfo,
+    ) -> Result<Vec<DiskInfo>> {
+        let get_udev_properties = |disk: &Disk| {
+            udev.disks
+                .get(&disk.index)
+                .with_context(|| {
+                    format!("could not find udev information for disk '{}'", disk.path)
+                })
+                .cloned()
+        };
+
+        let disks = if config.filesys.is_lvm() {
+            // If the filesystem is LVM, there is only boot disk. The path (aka. /dev/..)
+            // can be found in `config.target_hd`.
+            run_env
+                .disks
+                .iter()
+                .flat_map(|disk| {
+                    let is_bootdisk = config
+                        .target_hd
+                        .as_ref()
+                        .and_then(|hd| (*hd == disk.path).then_some(true));
+
+                    anyhow::Ok(DiskInfo {
+                        size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+                        is_bootdisk,
+                        udev_properties: get_udev_properties(disk)?,
+                    })
+                })
+                .collect()
+        } else {
+            // If the filesystem is not LVM-based (thus Btrfs or ZFS), `config.disk_selection`
+            // contains a list of indices identifiying the boot disks, as given by udev.
+            let selected_disks_indices: Vec<&String> = config.disk_selection.values().collect();
+
+            run_env
+                .disks
+                .iter()
+                .flat_map(|disk| {
+                    let is_bootdisk = selected_disks_indices
+                        .contains(&&disk.index)
+                        .then_some(true);
+
+                    anyhow::Ok(DiskInfo {
+                        size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+                        is_bootdisk,
+                        udev_properties: get_udev_properties(disk)?,
+                    })
+                })
+                .collect()
+        };
+
+        Ok(disks)
+    }
+
+    /// Retrieves all needed information about the management network interface that was selected
+    /// during installation, most notable the udev properties.
+    ///
+    /// # Arguments
+    ///
+    /// * `config` - Low-level installation configuration
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    /// * `udev` - udev information for all system devices
+    fn gather_nic(
+        config: &InstallConfig,
+        run_env: &RuntimeInfo,
+        udev: &UdevInfo,
+    ) -> Result<Vec<NetworkInterfaceInfo>> {
+        Ok(run_env
+            .network
+            .interfaces
+            .values()
+            .flat_map(|nic| {
+                let udev_properties = udev
+                    .nics
+                    .get(&nic.name)
+                    .with_context(|| {
+                        format!("could not find udev information for NIC '{}'", nic.name)
+                    })?
+                    .clone();
+
+                if config.mngmt_nic == nic.name {
+                    // Use the actual IP address from the low-level install config, as the runtime info
+                    // contains the original IP address from DHCP.
+                    anyhow::Ok(NetworkInterfaceInfo {
+                        mac: nic.mac.clone(),
+                        address: Some(config.cidr.clone()),
+                        is_management: Some(true),
+                        udev_properties,
+                    })
+                } else {
+                    anyhow::Ok(NetworkInterfaceInfo {
+                        mac: nic.mac.clone(),
+                        address: None,
+                        is_management: None,
+                        udev_properties,
+                    })
+                }
+            })
+            .collect())
+    }
+
+    /// Retrieves the version of the installed product from the chroot.
+    ///
+    /// # Arguments
+    ///
+    /// * `setup_info` - Filled-out struct with information about the product
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn gather_product_info(
+        setup_info: &SetupInfo,
+        run_cmd: &dyn Fn(&[&str]) -> Result<String>,
+    ) -> Result<ProductInfo> {
+        let package = match setup_info.config.product {
+            ProxmoxProduct::PVE => "pve-manager",
+            ProxmoxProduct::PMG => "pmg-api",
+            ProxmoxProduct::PBS => "proxmox-backup-server",
+        };
+
+        let version = run_cmd(&[
+            "dpkg-query",
+            "--showformat",
+            "${Version}",
+            "--show",
+            package,
+        ])
+        .with_context(|| format!("failed to retrieve version of {package}"))?;
+
+        Ok(ProductInfo {
+            fullname: setup_info.config.fullname.clone(),
+            short: setup_info.config.product,
+            version,
+        })
+    }
+
+    /// Extracts the version string from the *installed* kernel image.
+    ///
+    /// First, it determines the exact path to the kernel image (aka. `/boot/vmlinuz-<version>`)
+    /// by looking at the installed kernel package, then reads the string directly from the image
+    /// from the well-defined kernel header. See also [0] for details.
+    ///
+    /// [0] https://www.kernel.org/doc/html/latest/arch/x86/boot.html
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    /// * `open_file` - Callback to open a file inside the target chroot.
+    #[cfg(target_arch = "x86_64")]
+    fn gather_kernel_version(
+        run_cmd: &dyn Fn(&[&str]) -> Result<String>,
+        open_file: &dyn Fn(&str) -> Result<File>,
+    ) -> Result<KernelVersionInformation> {
+        let file = open_file(&Self::find_kernel_image_path(run_cmd)?)?;
+
+        // Read the 2-byte `kernel_version` field at offset 0x20e [0] from the file ..
+        // https://www.kernel.org/doc/html/latest/arch/x86/boot.html#the-real-mode-kernel-header
+        let mut buffer = [0u8; 2];
+        file.read_exact_at(&mut buffer, 0x20e)
+            .context("could not read kernel_version offset from image")?;
+
+        // .. which gives us the offset of the kernel version string inside the image, minus 0x200.
+        // https://www.kernel.org/doc/html/latest/arch/x86/boot.html#details-of-header-fields
+        let offset = u16::from_le_bytes(buffer) + 0x200;
+
+        // The string is usually somewhere around 80-100 bytes long, so 256 bytes is more than
+        // enough to cover all cases.
+        let mut buffer = [0u8; 256];
+        file.read_exact_at(&mut buffer, offset.into())
+            .context("could not read kernel version string from image")?;
+
+        // Now just consume the buffer until the NUL byte
+        let kernel_version = CStr::from_bytes_until_nul(&buffer)
+            .context("did not find a NUL-terminator in kernel version string")?
+            .to_str()
+            .context("could not convert kernel version string")?;
+
+        // The version string looks like:
+        //   6.8.4-2-pve (build@proxmox) #1 SMP PREEMPT_DYNAMIC PMX 6.8.4-2 (2024-04-10T17:36Z) x86_64 GNU/Linux
+        //
+        // Thus split it into three parts, as we are interested in the release version
+        // and everything starting at the build number
+        let parts: Vec<&str> = kernel_version.splitn(3, ' ').collect();
+
+        if parts.len() != 3 {
+            bail!("failed to split kernel version string");
+        }
+
+        Ok(KernelVersionInformation {
+            machine: std::env::consts::ARCH.to_owned(),
+            sysname: "Linux".to_owned(),
+            release: parts
+                .first()
+                .context("kernel release not found")?
+                .to_string(),
+            version: parts
+                .get(2)
+                .context("kernel version not found")?
+                .to_string(),
+        })
+    }
+
+    /// Retrieves the absolute path to the kernel image (aka. `/boot/vmlinuz-<version>`)
+    /// inside the chroot by looking at the file list installed by the kernel package.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn find_kernel_image_path(run_cmd: &dyn Fn(&[&str]) -> Result<String>) -> Result<String> {
+        let pkg_name = Self::find_kernel_package_name(run_cmd)?;
+
+        let all_files = run_cmd(&["dpkg-query", "--listfiles", &pkg_name])?;
+        for file in all_files.lines() {
+            if file.starts_with("/boot/vmlinuz-") {
+                return Ok(file.to_owned());
+            }
+        }
+
+        bail!("failed to find installed kernel image path")
+    }
+
+    /// Retrieves the full name of the kernel package installed inside the chroot.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn find_kernel_package_name(run_cmd: &dyn Fn(&[&str]) -> Result<String>) -> Result<String> {
+        let dpkg_arch = run_cmd(&["dpkg", "--print-architecture"])?
+            .trim()
+            .to_owned();
+
+        let kernel_pkgs = run_cmd(&[
+            "dpkg-query",
+            "--showformat",
+            "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+            "--show",
+            "proxmox-kernel-[0-9]*",
+        ])?;
+
+        // The output to parse looks like this:
+        //   ii |all|proxmox-kernel-6.8
+        //   un ||proxmox-kernel-6.8.8-2-pve
+        //   ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+        for pkg in kernel_pkgs.lines() {
+            let parts = pkg.split('|').collect::<Vec<&str>>();
+
+            if let [status, arch, name] = parts[..] {
+                if status.trim() == "ii" && arch.trim() == dpkg_arch {
+                    return Ok(name.trim().to_owned());
+                }
+            }
+        }
+
+        bail!("failed to find installed kernel package")
+    }
+
+    /// Retrieves some basic information about the CPU in the running system,
+    /// reading them from /proc/cpuinfo.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    fn gather_cpu_info(run_env: &RuntimeInfo) -> Result<CpuInfo> {
+        let mut result = CpuInfo {
+            cores: 0,
+            cpus: 0,
+            flags: String::new(),
+            hvm: run_env.hvm_supported,
+            model: String::new(),
+            sockets: 0,
+        };
+        let mut sockets = HashSet::new();
+        let mut cores = HashSet::new();
+
+        // Does not matter if we read the file from inside the chroot or directly on the host.
+        let cpuinfo = fs::read_to_string("/proc/cpuinfo")?;
+        for line in cpuinfo.lines() {
+            match line.split_once(':') {
+                Some((key, _)) if key.trim() == "processor" => {
+                    result.cpus += 1;
+                }
+                Some((key, value)) if key.trim() == "core id" => {
+                    cores.insert(value);
+                }
+                Some((key, value)) if key.trim() == "physical id" => {
+                    sockets.insert(value);
+                }
+                Some((key, value)) if key.trim() == "flags" => {
+                    value.trim().clone_into(&mut result.flags);
+                }
+                Some((key, value)) if key.trim() == "model name" => {
+                    value.trim().clone_into(&mut result.model);
+                }
+                _ => {}
+            }
+        }
+
+        result.cores = cores.len();
+        result.sockets = sockets.len();
+
+        Ok(result)
+    }
+}
+
+/// Runs the specified callback with the mounted chroot, passing along the
+/// absolute path to where / is mounted.
+/// The callback is *not* run inside the chroot itself, that is left to the caller.
+///
+/// # Arguments
+///
+/// * `callback` - Callback to call with the absolute path where the chroot environment root is
+///                mounted.
+fn with_chroot<R, F: FnOnce(&str) -> Result<R>>(callback: F) -> Result<R> {
+    let ec = Command::new("proxmox-chroot")
+        .arg("prepare")
+        .status()
+        .context("failed to run proxmox-chroot")?;
+
+    if !ec.success() {
+        bail!("failed to create chroot for installed system");
+    }
+
+    // See also proxmox-chroot/src/main.rs w.r.t to the path, which is hard-coded there
+    let result = callback("/target");
+
+    let ec = Command::new("proxmox-chroot").arg("cleanup").status();
+    // We do not want to necessarily fail here, as the install environment is about
+    // to be teared down completely anyway.
+    if ec.is_err() || !ec.map(|ec| ec.success()).unwrap_or(false) {
+        eprintln!("failed to clean up chroot for installed system");
+    }
+
+    result
+}
+
+/// Reads the answer file from stdin, checks for a configured post-hook URL (+ optional certificate
+/// fingerprint for HTTPS). If configured, retrieves all relevant information about the installed
+/// system and sends them to the given endpoint.
+fn do_main() -> Result<()> {
+    let answer = Answer::try_from_reader(std::io::stdin().lock())?;
+
+    if let Some(PostNotificationHookInfo {
+        url,
+        cert_fingerprint,
+    }) = &answer.posthook
+    {
+        println!("Found posthook; sending POST request to '{url}'.");
+
+        let info = with_chroot(|target_path| PostHookInfo::gather(target_path, &answer))?;
+
+        proxmox_installer_common::http::post(
+            url,
+            cert_fingerprint.as_deref(),
+            serde_json::to_string(&info)?,
+        )?;
+    } else {
+        println!("No posthook found; skipping");
+    }
+
+    Ok(())
+}
+
+fn main() -> ExitCode {
+    match do_main() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(err) => {
+            eprintln!("\nError occurred during posthook:");
+            eprintln!("{err:#}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::PostHookInfo;
+
+    #[test]
+    fn finds_correct_kernel_package_name() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd).unwrap(),
+            "proxmox-kernel-6.8.8-2-pve-signed"
+        );
+    }
+
+    #[test]
+    fn find_kernel_package_name_fails_on_wrong_architecture() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("arm64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
+                .unwrap_err()
+                .to_string(),
+            "failed to find installed kernel package"
+        );
+    }
+
+    #[test]
+    fn find_kernel_package_name_fails_on_missing_package() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
+                .unwrap_err()
+                .to_string(),
+            "failed to find installed kernel package"
+        );
+    }
+
+    #[test]
+    fn finds_correct_absolute_kernel_image_path() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else if cmd[0..=1] == ["dpkg-query", "--showformat"] {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    [
+                        "dpkg-query",
+                        "--listfiles",
+                        "proxmox-kernel-6.8.8-2-pve-signed"
+                    ]
+                );
+                Ok(r#"
+/.
+/boot
+/boot/System.map-6.8.8-2-pve
+/boot/config-6.8.8-2-pve
+/boot/vmlinuz-6.8.8-2-pve
+/lib
+/lib/modules
+/lib/modules/6.8.8-2-pve
+/lib/modules/6.8.8-2-pve/kernel
+/lib/modules/6.8.8-2-pve/kernel/arch
+/lib/modules/6.8.8-2-pve/kernel/arch/x86
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aegis128-aesni.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aesni-intel.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx2-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-gfni-avx512-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/blowfish-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/camellia-aesni-avx-x86_64.ko
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_image_path(&mocked_run_cmd).unwrap(),
+            "/boot/vmlinuz-6.8.8-2-pve"
+        );
+    }
+}
-- 
2.47.0



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


  parent reply	other threads:[~2024-11-11 13:16 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2024-11-11 13:14 [pve-devel] [PATCH installer v4 00/12] fix #5536: implement post-(auto-)installation notification mechanism Christoph Heiss
2024-11-11 13:14 ` [pve-devel] [PATCH installer v4 01/12] debian: strip unused library dependencies Christoph Heiss
2024-11-11 13:14 ` [pve-devel] [PATCH installer v4 02/12] fetch-answer: move http-related code to gated module in installer-common Christoph Heiss
2024-11-11 13:14 ` [pve-devel] [PATCH installer v4 03/12] tree-wide: convert some more crates to use workspace dependencies Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 04/12] auto-install-assistant: replace `PathBuf` parameters with `AsRef<Path>` Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 05/12] auto-installer: tests: simplify empty disks check Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 06/12] auto-installer: tests: replace `PathBuf` parameters with `AsRef<Path>` Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 07/12] auto-installer: move `SystemDMI` struct to common crate Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 08/12] auto-installer: answer: factor out answer file reading into function Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 09/12] auto-installer: udevinfo: introduce type alias for udev properties Christoph Heiss
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 10/12] fix #5536: auto-installer: answer: add `posthook` section Christoph Heiss
2024-11-11 13:15 ` Christoph Heiss [this message]
2024-11-11 13:15 ` [pve-devel] [PATCH installer v4 12/12] unconfigured.sh: run proxmox-post-hook after successful auto-install Christoph Heiss
2024-11-11 17:41 ` [pve-devel] applied: [PATCH installer v4 00/12] fix #5536: implement post-(auto-)installation notification mechanism Thomas Lamprecht
2024-11-12 10:33   ` Christoph Heiss

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=20241111131519.867887-12-c.heiss@proxmox.com \
    --to=c.heiss@proxmox.com \
    --cc=pve-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal