From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 4CFDF1FF184 for ; Thu, 4 Dec 2025 13:51:37 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 0BFDC26569; Thu, 4 Dec 2025 13:52:04 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Date: Thu, 4 Dec 2025 13:51:15 +0100 Message-ID: <20251204125122.945961-7-c.heiss@proxmox.com> X-Mailer: git-send-email 2.51.2 In-Reply-To: <20251204125122.945961-1-c.heiss@proxmox.com> References: <20251204125122.945961-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1764852666464 X-SPAM-LEVEL: Spam detection results: 0 AWL -2.452 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_SOMETLD_ARE_BAD_TLD 5 .bar, .beauty, .buzz, .cam, .casa, .cfd, .club, .date, .guru, .link, .live, .monster, .online, .press, .pw, .quest, .rest, .sbs, .shop, .stream, .top, .trade, .wiki, .work, .xyz TLD abuse SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" Moving them over from proxmox-auto-installer and proxmox-post-hook, to allow re-use in other places. The network configuration and disk setup has been restructured slightly, making its typing a bit more ergonomic to work with. No functional changes though, still parses from/into the same format. Signed-off-by: Christoph Heiss --- proxmox-installer-types/Cargo.toml | 1 + proxmox-installer-types/debian/control | 2 + proxmox-installer-types/src/answer.rs | 898 +++++++++++++++++++++++ proxmox-installer-types/src/lib.rs | 3 + proxmox-installer-types/src/post_hook.rs | 183 +++++ 5 files changed, 1087 insertions(+) create mode 100644 proxmox-installer-types/src/answer.rs create mode 100644 proxmox-installer-types/src/post_hook.rs diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml index 62df7f4e..96413fe1 100644 --- a/proxmox-installer-types/Cargo.toml +++ b/proxmox-installer-types/Cargo.toml @@ -12,6 +12,7 @@ exclude.workspace = true rust-version.workspace = true [dependencies] +anyhow.workspace = true serde = { workspace = true, features = ["derive"] } serde_plain.workspace = true proxmox-network-types.workspace = true diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control index 902977af..d208a014 100644 --- a/proxmox-installer-types/debian/control +++ b/proxmox-installer-types/debian/control @@ -6,6 +6,7 @@ Build-Depends: debhelper-compat (= 13), Build-Depends-Arch: cargo:native , rustc:native (>= 1.82) , libstd-rust-dev , + librust-anyhow-1+default-dev , librust-proxmox-network-types-0.1+api-types-dev , librust-proxmox-network-types-0.1+default-dev , librust-serde-1+default-dev , @@ -23,6 +24,7 @@ Architecture: any Multi-Arch: same Depends: ${misc:Depends}, + librust-anyhow-1+default-dev, librust-proxmox-network-types-0.1+api-types-dev, librust-proxmox-network-types-0.1+default-dev, librust-serde-1+default-dev, diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs new file mode 100644 index 00000000..7129a941 --- /dev/null +++ b/proxmox-installer-types/src/answer.rs @@ -0,0 +1,898 @@ +//! Defines API types for the answer file format used by proxmox-auto-installer. +//! +//! **NOTE**: New answer file properties must use kebab-case, but should allow +//! snake_case for backwards compatibility. +//! +//! TODO: Remove the snake_case'd variants in a future major version (e.g. +//! PVE 10). + +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::{self, Display}, + str::FromStr, +}; + +use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr}; +type IpAddr = std::net::IpAddr; + +/// Defines API types used by proxmox-fetch-answer, the first part of the +/// auto-installer. +pub mod fetch { + use serde::{Deserialize, Serialize}; + + use crate::SystemInfo; + + #[derive(Deserialize, Serialize)] + #[serde(rename_all = "kebab-case")] + /// Metadata of the HTTP POST payload, such as schema version of the document. + pub struct AnswerFetchDataSchema { + /// major.minor version describing the schema version of this document, in a semanticy-version + /// way. + /// + /// major: Incremented for incompatible/breaking API changes, e.g. removing an existing + /// field. + /// minor: Incremented when adding functionality in a backwards-compatible matter, e.g. + /// adding a new field. + pub version: String, + } + + impl AnswerFetchDataSchema { + const SCHEMA_VERSION: &str = "1.0"; + } + + impl Default for AnswerFetchDataSchema { + fn default() -> Self { + Self { + version: Self::SCHEMA_VERSION.to_owned(), + } + } + } + + #[derive(Deserialize, Serialize)] + #[serde(rename_all = "kebab-case")] + /// Data sent in the body of POST request when retrieving the answer file via HTTP(S). + /// + /// NOTE: The format is versioned through `schema.version` (`$schema.version` in the + /// resulting JSON), ensure you update it when this struct or any of its members gets modified. + pub struct AnswerFetchData { + /// Metadata for the answer file fetch payload + // This field is prefixed by `$` on purpose, to indicate that it is document metadata and not + // part of the actual content itself. (E.g. JSON Schema uses a similar naming scheme) + #[serde(rename = "$schema")] + pub schema: AnswerFetchDataSchema, + /// Information about the running system, flattened into this structure directly. + #[serde(flatten)] + pub sysinfo: SystemInfo, + } +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Top-level answer file structure, describing all possible options for an +/// automated installation. +pub struct AutoInstallerConfig { + /// General target system options for setting up the system in an automated + /// installation. + pub global: GlobalOptions, + /// Network configuration to set up inside the target installation. + pub network: NetworkConfig, + #[serde(rename = "disk-setup")] + /// Disk configuration for the target installation. + pub disks: DiskSetup, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional webhook to hit after a successful installation with information + /// about the provisioned system. + pub post_installation_webhook: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional one-time hook to run on the first boot into the newly provisioned + /// system. + pub first_boot: Option, +} + +#[derive(Clone, Default, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// General target system options for setting up the system in an automated +/// installation. +pub struct GlobalOptions { + /// Country to use for apt mirrors. + pub country: String, + /// FQDN to set for the installed system. + pub fqdn: FqdnConfig, + /// Keyboard layout to set. + pub keyboard: KeyboardLayout, + /// Mail address for `root@pam`. + pub mailto: String, + /// Timezone to set on the new system. + pub timezone: String, + #[serde(alias = "root_password", skip_serializing_if = "Option::is_none")] + /// Password to set for the `root` PAM account in plain text. Mutual + /// exclusive with the `root-password-hashed` option. + pub root_password: Option, + #[cfg_attr(feature = "legacy", serde(alias = "root_password_hashed"))] + #[serde(skip_serializing_if = "Option::is_none")] + /// Password to set for the `root` PAM account as hash, created using e.g. + /// mkpasswd(8). Mutual exclusive with the `root-password` option. + pub root_password_hashed: Option, + #[serde(default)] + #[cfg_attr(feature = "legacy", serde(alias = "reboot_on_error"))] + /// Whether to reboot the machine if an error occurred during the + /// installation. + pub reboot_on_error: bool, + #[serde(default)] + #[cfg_attr(feature = "legacy", serde(alias = "reboot_mode"))] + /// Action to take after the installation completed successfully. + pub reboot_mode: RebootMode, + #[serde(default)] + #[cfg_attr(feature = "legacy", serde(alias = "root_ssh_keys"))] + /// Public SSH keys to set up for the `root` PAM account. + pub root_ssh_keys: Vec, +} + +#[derive(Copy, Clone, Deserialize, Serialize, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Action to take after the installation completed successfully. +pub enum RebootMode { + #[default] + /// Reboot the machine. + Reboot, + /// Power off and halt the machine. + PowerOff, +} + +serde_plain::derive_fromstr_from_deserialize!(RebootMode); + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde( + untagged, + expecting = "either a fully-qualified domain name or extendend configuration for usage with DHCP must be specified" +)] +/// Allow the user to either set the FQDN of the installation to either some +/// fixed value or retrieve it dynamically via e.g.DHCP. +pub enum FqdnConfig { + /// Sets the FQDN to the exact value. + Simple(Fqdn), + /// Extended configuration, e.g. to use hostname and domain from DHCP. + FromDhcp(FqdnFromDhcpConfig), +} + +impl Default for FqdnConfig { + fn default() -> Self { + Self::FromDhcp(FqdnFromDhcpConfig::default()) + } +} + +impl FqdnConfig { + /// Constructs a new "simple" FQDN configuration, i.e. a fixed hostname. + pub fn simple>(fqdn: S) -> Result { + Ok(Self::Simple( + fqdn.into() + .parse::() + .map_err(|err| anyhow!("{err}"))?, + )) + } + + /// Constructs an extended FQDN configuration, in particular instructing the + /// auto-installer to use the FQDN from DHCP lease information. + pub fn from_dhcp(domain: Option) -> Self { + Self::FromDhcp(FqdnFromDhcpConfig { + source: FqdnSourceMode::FromDhcp, + domain, + }) + } +} + +#[derive(Clone, Default, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Extended configuration for retrieving the FQDN from external sources. +pub struct FqdnFromDhcpConfig { + /// Source to gather the FQDN from. + #[serde(default)] + pub source: FqdnSourceMode, + /// Domain to use if none is received via DHCP. + #[serde(default, deserialize_with = "deserialize_non_empty_string_maybe")] + pub domain: Option, +} + +#[derive(Clone, Deserialize, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Describes the source to retrieve the FQDN of the installation. +pub enum FqdnSourceMode { + #[default] + /// Use the FQDN as provided by the DHCP server, if any. + FromDhcp, +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Configuration for the post-installation hook, which runs after an +/// installation has completed successfully. +pub struct PostNotificationHookInfo { + /// URL to send a POST request to + pub url: String, + /// SHA256 cert fingerprint if certificate pinning should be used. + #[serde(skip_serializing_if = "Option::is_none", alias = "cert_fingerprint")] + pub cert_fingerprint: Option, +} + +#[derive(Clone, Deserialize, Debug, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Possible sources for the optional first-boot hook script/executable file. +pub enum FirstBootHookSourceMode { + /// Fetch the executable file from an URL, specified in the parent. + FromUrl, + /// The executable file has been baked into the ISO at a known location, + /// and should be retrieved from there. + FromIso, +} + +#[derive(Clone, Default, Deserialize, Debug, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Possible orderings for the `proxmox-first-boot` systemd service. +/// +/// Determines the final value of `Unit.Before` and `Unit.Wants` in the service +/// file. +// Must be kept in sync with Proxmox::Install::Config and the service files in the +// proxmox-first-boot package. +pub enum FirstBootHookServiceOrdering { + /// Needed for bringing up the network itself, runs before any networking is attempted. + BeforeNetwork, + /// Network needs to be already online, runs after networking was brought up. + NetworkOnline, + /// Runs after the system has successfully booted up completely. + #[default] + FullyUp, +} + +impl FirstBootHookServiceOrdering { + /// Maps the enum to the appropriate systemd target name, without the '.target' suffix. + pub fn as_systemd_target_name(&self) -> &str { + match self { + FirstBootHookServiceOrdering::BeforeNetwork => "network-pre", + FirstBootHookServiceOrdering::NetworkOnline => "network-online", + FirstBootHookServiceOrdering::FullyUp => "multi-user", + } + } +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Describes from where to fetch the first-boot hook script, either being baked into the ISO or +/// from a URL. +pub struct FirstBootHookInfo { + /// Mode how to retrieve the first-boot executable file, either from an URL or from the ISO if + /// it has been baked-in. + pub source: FirstBootHookSourceMode, + /// Determines the service order when the hook will run on first boot. + #[serde(default)] + pub ordering: FirstBootHookServiceOrdering, + #[serde(skip_serializing_if = "Option::is_none")] + /// Retrieve the post-install script from a URL, if source == "from-url". + pub url: Option, + /// SHA256 cert fingerprint if certificate pinning should be used, if source == "from-url". + #[cfg_attr(feature = "legacy", serde(alias = "cert_fingerprint"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub cert_fingerprint: Option, +} + +/// Options controlling the behaviour of the network interface pinning (by +/// creating appropriate systemd.link files) during the installation. +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct NetworkInterfacePinningOptionsAnswer { + /// Whether interfaces should be pinned during the installation. + pub enabled: bool, + /// Maps MAC address to custom name + #[serde(default)] + pub mapping: HashMap, +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Static network configuration given by the user. +pub struct NetworkConfigFromAnswer { + /// CIDR of the machine. + pub cidr: Cidr, + /// DNS nameserver host to use. + pub dns: IpAddr, + /// Gateway to set. + pub gateway: IpAddr, + #[serde(default)] + /// Filter for network devices, to select a specific management interface. + pub filter: BTreeMap, + /// Controls network interface pinning behaviour during installation. + /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes. + #[serde(default)] + pub interface_name_pinning: Option, +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Use the network configuration received from the DHCP server. +pub struct NetworkConfigFromDhcp { + /// Controls network interface pinning behaviour during installation. + /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes. + #[serde(default)] + pub interface_name_pinning: Option, +} + +#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields, tag = "source")] +/// Network configuration to set up inside the target installation. +/// It can either be given statically or taken from the DHCP lease. +pub enum NetworkConfig { + /// Use the configuration from the DHCP lease. + FromDhcp(NetworkConfigFromDhcp), + /// Static configuration to apply. + FromAnswer(NetworkConfigFromAnswer), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", tag = "filesystem")] +/// Filesystem-specific options to set on the root disk. +pub enum FilesystemOptions { + /// LVM-specific options, used when the selected filesystem is ext4 or xfs. + Lvm(LvmOptions), + /// ZFS-specific options. + Zfs(ZfsOptions), + /// Btrfs-specific options. + Btrfs(BtrfsOptions), +} + +#[derive(Clone, Debug, Serialize)] +/// Defines the disks to use for the installation. Can either be a fixed list +/// of disk names or a dynamic filter list. +pub enum DiskSelection { + /// Fixed list of disk names to use for the installation. + Selection(Vec), + /// Select disks dynamically by filtering them by udev properties. + Filter(BTreeMap), +} + +impl Display for DiskSelection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Selection(disks) => write!(f, "{}", disks.join(", ")), + Self::Filter(map) => write!( + f, + "{}", + map.iter() + .fold(String::new(), |acc, (k, v)| format!("{acc}{k}: {v}\n")) + .trim_end() + ), + } + } +} + +#[derive(Copy, Clone, Default, Deserialize, Debug, PartialEq, Serialize)] +#[serde(rename_all = "lowercase", deny_unknown_fields)] +/// Whether the associated filters must all match for a device or if any one +/// is enough. +pub enum FilterMatch { + /// Device must match any filter. + #[default] + Any, + /// Device must match all given filters. + All, +} + +serde_plain::derive_fromstr_from_deserialize!(FilterMatch); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Disk configuration for the target installation. +pub struct DiskSetup { + /// Filesystem to use on the root disk. + pub filesystem: Filesystem, + #[serde(default)] + #[cfg_attr(feature = "legacy", serde(alias = "disk_list"))] + /// List of raw disk identifiers to use for the root filesystem. + pub disk_list: Vec, + #[serde(default)] + /// Filter against udev properties to select the disks for the installation, + /// to allow dynamic selection of disks. + pub filter: BTreeMap, + #[cfg_attr(feature = "legacy", serde(alias = "filter_match"))] + #[serde(skip_serializing_if = "Option::is_none")] + /// Set whether it is enough that any filter matches on a disk or all given + /// filters must match to select a disk. + pub filter_match: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// ZFS-specific filesystem options. + pub zfs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// LVM-specific filesystem options, when using ext4 or xfs as filesystem. + pub lvm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Btrfs-specific filesystem options. + pub btrfs: Option, +} + +impl DiskSetup { + /// Returns the concrete disk selection made in the setup. + pub fn disk_selection(&self) -> Result { + if self.disk_list.is_empty() && self.filter.is_empty() { + bail!("Need either 'disk-list' or 'filter' set"); + } + if !self.disk_list.is_empty() && !self.filter.is_empty() { + bail!("Cannot use both, 'disk-list' and 'filter'"); + } + + if !self.disk_list.is_empty() { + Ok(DiskSelection::Selection(self.disk_list.clone())) + } else { + Ok(DiskSelection::Filter(self.filter.clone())) + } + } + + /// Returns the concrete filesystem type and corresponding options selected + /// in the setup. + pub fn filesystem_details(&self) -> Result<(FilesystemType, FilesystemOptions)> { + let lvm_checks = || -> Result<()> { + if self.zfs.is_some() || self.btrfs.is_some() { + bail!("make sure only 'lvm' options are set"); + } + if self.disk_list.len() > 1 { + bail!("make sure to define only one disk for ext4 and xfs"); + } + Ok(()) + }; + + match self.filesystem { + Filesystem::Xfs => { + lvm_checks()?; + Ok(( + FilesystemType::Xfs, + FilesystemOptions::Lvm(self.lvm.unwrap_or_default()), + )) + } + Filesystem::Ext4 => { + lvm_checks()?; + Ok(( + FilesystemType::Ext4, + FilesystemOptions::Lvm(self.lvm.unwrap_or_default()), + )) + } + Filesystem::Zfs => { + if self.lvm.is_some() || self.btrfs.is_some() { + bail!("make sure only 'zfs' options are set"); + } + match self.zfs { + None | Some(ZfsOptions { raid: None, .. }) => { + bail!("ZFS raid level 'zfs.raid' must be set"); + } + Some(opts) => Ok(( + FilesystemType::Zfs(opts.raid.unwrap()), + FilesystemOptions::Zfs(opts), + )), + } + } + Filesystem::Btrfs => { + if self.zfs.is_some() || self.lvm.is_some() { + bail!("make sure only 'btrfs' options are set"); + } + match self.btrfs { + None | Some(BtrfsOptions { raid: None, .. }) => { + bail!("Btrfs raid level 'btrfs.raid' must be set"); + } + Some(opts) => Ok(( + FilesystemType::Btrfs(opts.raid.unwrap()), + FilesystemOptions::Btrfs(opts), + )), + } + } + } + } +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)] +#[serde(rename_all = "lowercase", deny_unknown_fields)] +/// Available filesystem during installation. +pub enum Filesystem { + /// Fourth extended filesystem + Ext4, + /// XFS + Xfs, + /// ZFS + Zfs, + /// Btrfs + Btrfs, +} + +#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// ZFS-specific filesystem options. +pub struct ZfsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + /// RAID level to use. + pub raid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// `ashift` value to create the zpool with. + pub ashift: Option, + #[cfg_attr(feature = "legacy", serde(alias = "arc_max"))] + #[serde(skip_serializing_if = "Option::is_none")] + /// Maximum ARC size that ZFS should use, in MiB. + pub arc_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Checksumming algorithm to create the zpool with. + pub checksum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Compression algorithm to set on the zpool. + pub compress: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// `copies` value to create the zpool with. + pub copies: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Size of the root disk to use, can be used to reserve free space on the + /// hard disk for further partitioning after the installation. Optional, + /// will be heuristically determined if unset. + pub hdsize: Option, +} + +#[derive(Clone, Copy, Default, Deserialize, Serialize, Debug, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// LVM-specific filesystem options, when using ext4 or xfs as filesystem. +pub struct LvmOptions { + #[serde(skip_serializing_if = "Option::is_none")] + /// Size of the root disk to use, can be used to reserve free space on the + /// hard disk for further partitioning after the installation. Optional, + /// will be heuristically determined if unset. + pub hdsize: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Size of the swap volume. Optional, will be heuristically determined if + /// unset. + pub swapsize: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Maximum size the `root` volume. Optional, will be heuristically determined + /// if unset. + pub maxroot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Maximum size the `data` volume. Optional, will be heuristically determined + /// if unset. + pub maxvz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Minimum amount of free space that should be left in the LVM volume group. + /// Optional, will be heuristically determined if unset. + pub minfree: Option, +} + +#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Btrfs-specific filesystem options. +pub struct BtrfsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + /// Size of the root partition. Optional, will be heuristically determined if + /// unset. + pub hdsize: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// RAID level to use. + pub raid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Whether to enable filesystem-level compression and what type. + pub compress: Option, +} + +#[derive(Copy, Clone, Deserialize, Serialize, Debug, Default, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Keyboard layout of the system. +pub enum KeyboardLayout { + /// German + De, + /// Swiss-German + DeCh, + /// Danish + Dk, + /// United Kingdom English + EnGb, + #[default] + /// U.S. English + EnUs, + /// Spanish + Es, + /// Finnish + Fi, + /// French + Fr, + /// Belgium-French + FrBe, + /// Canada-French + FrCa, + /// Swiss-French + FrCh, + /// Hungarian + Hu, + /// Icelandic + Is, + /// Italian + It, + /// Japanese + Jp, + /// Lithuanian + Lt, + /// Macedonian + Mk, + /// Dutch + Nl, + /// Norwegian + No, + /// Polish + Pl, + /// Portuguese + Pt, + /// Brazil-Portuguese + PtBr, + /// Swedish + Se, + /// Slovenian + Si, + /// Turkish + Tr, +} + +impl Display for KeyboardLayout { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dk => write!(f, "Danish"), + Self::De => write!(f, "German"), + Self::DeCh => write!(f, "Swiss-German"), + Self::EnGb => write!(f, "United Kingdom"), + Self::EnUs => write!(f, "U.S. English"), + Self::Es => write!(f, "Spanish"), + Self::Fi => write!(f, "Finnish"), + Self::Fr => write!(f, "French"), + Self::FrBe => write!(f, "Belgium-French"), + Self::FrCa => write!(f, "Canada-French"), + Self::FrCh => write!(f, "Swiss-French"), + Self::Hu => write!(f, "Hungarian"), + Self::Is => write!(f, "Icelandic"), + Self::It => write!(f, "Italian"), + Self::Jp => write!(f, "Japanese"), + Self::Lt => write!(f, "Lithuanian"), + Self::Mk => write!(f, "Macedonian"), + Self::Nl => write!(f, "Dutch"), + Self::No => write!(f, "Norwegian"), + Self::Pl => write!(f, "Polish"), + Self::Pt => write!(f, "Portuguese"), + Self::PtBr => write!(f, "Brazil-Portuguese"), + Self::Si => write!(f, "Slovenian"), + Self::Se => write!(f, "Swedish"), + Self::Tr => write!(f, "Turkish"), + } + } +} + +serde_plain::derive_fromstr_from_deserialize!(KeyboardLayout); + +#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))] +/// Available Btrfs RAID levels. +pub enum BtrfsRaidLevel { + #[serde(alias = "RAID0")] + /// RAID 0, aka. single or striped. + Raid0, + #[serde(alias = "RAID1")] + /// RAID 1, aka. mirror. + Raid1, + #[serde(alias = "RAID10")] + /// RAID 10, combining stripe and mirror. + Raid10, +} + +serde_plain::derive_display_from_serialize!(BtrfsRaidLevel); + +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +/// Possible compression algorithms usable with Btrfs. See the accompanying +/// mount option in btrfs(5). +pub enum BtrfsCompressOption { + /// Enable compression, chooses the default algorithm set by Btrfs. + On, + #[default] + /// Disable compression. + Off, + /// Use zlib for compression. + Zlib, + /// Use zlo for compression. + Lzo, + /// Use Zstandard for compression. + Zstd, +} + +serde_plain::derive_display_from_serialize!(BtrfsCompressOption); +serde_plain::derive_fromstr_from_deserialize!(BtrfsCompressOption); + +/// List of all available Btrfs compression options. +pub const BTRFS_COMPRESS_OPTIONS: &[BtrfsCompressOption] = { + use BtrfsCompressOption::*; + &[On, Off, Zlib, Lzo, Zstd] +}; + +#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +/// Available ZFS RAID levels. +pub enum ZfsRaidLevel { + #[serde(alias = "raid0")] + /// RAID 0, aka. single or striped. + Raid0, + #[serde(alias = "raid1")] + /// RAID 1, aka. mirror. + Raid1, + #[serde(alias = "raid10")] + /// RAID 10, combining stripe and mirror. + Raid10, + #[serde(alias = "raidz-1", rename = "RAIDZ-1")] + /// ZFS-specific RAID level, provides fault tolerance for one disk. + RaidZ, + #[serde(alias = "raidz-2", rename = "RAIDZ-2")] + /// ZFS-specific RAID level, provides fault tolerance for two disks. + RaidZ2, + #[serde(alias = "raidz-3", rename = "RAIDZ-3")] + /// ZFS-specific RAID level, provides fault tolerance for three disks. + RaidZ3, +} + +serde_plain::derive_display_from_serialize!(ZfsRaidLevel); + +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +/// Possible compression algorithms usable with ZFS. +pub enum ZfsCompressOption { + #[default] + /// Enable compression, chooses the default algorithm set by ZFS. + On, + /// Disable compression. + Off, + /// Use lzjb for compression. + Lzjb, + /// Use lz4 for compression. + Lz4, + /// Use zle for compression. + Zle, + /// Use gzip for compression. + Gzip, + /// Use Zstandard for compression. + Zstd, +} + +serde_plain::derive_display_from_serialize!(ZfsCompressOption); +serde_plain::derive_fromstr_from_deserialize!(ZfsCompressOption); + +/// List of all available ZFS compression options. +pub const ZFS_COMPRESS_OPTIONS: &[ZfsCompressOption] = { + use ZfsCompressOption::*; + &[On, Off, Lzjb, Lz4, Zle, Gzip, Zstd] +}; + +#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +/// Possible checksum algorithms usable with ZFS. +pub enum ZfsChecksumOption { + #[default] + /// Enable compression, chooses the default algorithm set by ZFS. + On, + /// Use Fletcher4 for checksumming. + Fletcher4, + /// Use SHA256 for checksumming. + Sha256, +} + +serde_plain::derive_display_from_serialize!(ZfsChecksumOption); +serde_plain::derive_fromstr_from_deserialize!(ZfsChecksumOption); + +/// List of all available ZFS checksumming options. +pub const ZFS_CHECKSUM_OPTIONS: &[ZfsChecksumOption] = { + use ZfsChecksumOption::*; + &[On, Fletcher4, Sha256] +}; + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +/// The filesystem to use for the installation. +pub enum FilesystemType { + #[default] + /// Fourth extended filesystem. + Ext4, + /// XFS. + Xfs, + /// ZFS, with a given RAID level. + Zfs(ZfsRaidLevel), + /// Btrfs, with a given RAID level. + Btrfs(BtrfsRaidLevel), +} + +impl FilesystemType { + /// Returns whether this filesystem is Btrfs. + pub fn is_btrfs(&self) -> bool { + matches!(self, FilesystemType::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, FilesystemType::Ext4 | FilesystemType::Xfs) + } +} + +impl fmt::Display for FilesystemType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // Values displayed to the user in the installer UI + match self { + FilesystemType::Ext4 => write!(f, "ext4"), + FilesystemType::Xfs => write!(f, "XFS"), + FilesystemType::Zfs(level) => write!(f, "ZFS ({level})"), + FilesystemType::Btrfs(level) => write!(f, "BTRFS ({level})"), + } + } +} + +impl Serialize for FilesystemType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // These values must match exactly what the low-level installer expects + let value = match self { + // proxinstall::$fssetup + FilesystemType::Ext4 => "ext4", + FilesystemType::Xfs => "xfs", + // proxinstall::get_zfs_raid_setup() + FilesystemType::Zfs(level) => &format!("zfs ({level})"), + // proxinstall::get_btrfs_raid_setup() + FilesystemType::Btrfs(level) => &format!("btrfs ({level})"), + }; + + serializer.collect_str(value) + } +} + +impl FromStr for FilesystemType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "ext4" => Ok(FilesystemType::Ext4), + "xfs" => Ok(FilesystemType::Xfs), + "zfs (RAID0)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::Raid0)), + "zfs (RAID1)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::Raid1)), + "zfs (RAID10)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::Raid10)), + "zfs (RAIDZ-1)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::RaidZ)), + "zfs (RAIDZ-2)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::RaidZ2)), + "zfs (RAIDZ-3)" => Ok(FilesystemType::Zfs(ZfsRaidLevel::RaidZ3)), + "btrfs (RAID0)" => Ok(FilesystemType::Btrfs(BtrfsRaidLevel::Raid0)), + "btrfs (RAID1)" => Ok(FilesystemType::Btrfs(BtrfsRaidLevel::Raid1)), + "btrfs (RAID10)" => Ok(FilesystemType::Btrfs(BtrfsRaidLevel::Raid10)), + _ => Err(format!("Could not find file system: {s}")), + } + } +} + +serde_plain::derive_deserialize_from_fromstr!(FilesystemType, "valid filesystem"); + +/// List of all available filesystem types. +pub const FILESYSTEM_TYPE_OPTIONS: &[FilesystemType] = { + use FilesystemType::*; + &[ + Ext4, + Xfs, + Zfs(ZfsRaidLevel::Raid0), + Zfs(ZfsRaidLevel::Raid1), + Zfs(ZfsRaidLevel::Raid10), + Zfs(ZfsRaidLevel::RaidZ), + Zfs(ZfsRaidLevel::RaidZ2), + Zfs(ZfsRaidLevel::RaidZ3), + Btrfs(BtrfsRaidLevel::Raid0), + Btrfs(BtrfsRaidLevel::Raid1), + Btrfs(BtrfsRaidLevel::Raid10), + ] +}; + +fn deserialize_non_empty_string_maybe<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let val: Option = Deserialize::deserialize(deserializer)?; + + match val { + Some(s) if !s.is_empty() => Ok(Some(s)), + _ => Ok(None), + } +} diff --git a/proxmox-installer-types/src/lib.rs b/proxmox-installer-types/src/lib.rs index 07927cb0..12679bdc 100644 --- a/proxmox-installer-types/src/lib.rs +++ b/proxmox-installer-types/src/lib.rs @@ -7,6 +7,9 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![deny(unsafe_code, missing_docs)] +pub mod answer; +pub mod post_hook; + use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap}, diff --git a/proxmox-installer-types/src/post_hook.rs b/proxmox-installer-types/src/post_hook.rs new file mode 100644 index 00000000..8fbe54f8 --- /dev/null +++ b/proxmox-installer-types/src/post_hook.rs @@ -0,0 +1,183 @@ +//! Defines API types for the proxmox-auto-installer post-installation hook. + +use serde::{Deserialize, Serialize}; + +use proxmox_network_types::ip_address::Cidr; + +use crate::{ + answer::{FilesystemType, RebootMode}, + BootType, IsoInfo, ProxmoxProduct, SystemDMI, UdevProperties, +}; + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +/// Information about the system boot status. +pub struct BootInfo { + /// Whether the system is booted using UEFI or legacy BIOS. + pub mode: BootType, + /// Whether SecureBoot is enabled for the installation. + #[serde(default, skip_serializing_if = "bool_is_false")] + secureboot: bool, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +/// Holds all the public keys for the different algorithms available. +pub struct SshPublicHostKeys { + /// ECDSA-based public host key + pub ecdsa: String, + /// ED25519-based public host key + pub ed25519: String, + /// RSA-based public host key + pub rsa: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Holds information about a single disk in the system. +pub struct DiskInfo { + /// Size in bytes + pub size: u64, + /// Set to true if the disk is used for booting. + #[serde(default, skip_serializing_if = "bool_is_false")] + pub is_bootdisk: bool, + /// Properties about the device as given by udev. + pub udev_properties: UdevProperties, +} + +/// Holds information about the management network interface. +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct NetworkInterfaceInfo { + /// Name of the interface + name: String, + /// MAC address of the interface + pub mac: String, + /// (Designated) IP address of the interface + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + /// Set to true if the interface is the chosen management interface during + /// installation. + #[serde(default, skip_serializing_if = "bool_is_false")] + pub is_management: bool, + /// Set to true if the network interface name was pinned based on the MAC + /// address during the installation. + #[serde(default, skip_serializing_if = "bool_is_false")] + is_pinned: bool, + /// Properties about the device as given by udev. + pub udev_properties: UdevProperties, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Information about the installed product itself. +pub struct ProductInfo { + /// Full name of the product + pub fullname: String, + /// Product abbreviation + pub short: ProxmoxProduct, + /// Version of the installed product + pub version: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +/// The current kernel version. +/// Aligns with the format as used by the `/nodes//status` API of each product. +pub 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, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +/// Information about the CPU(s) installed in the system +pub struct CpuInfo { + /// Number of physical CPU cores. + pub cores: u32, + /// Number of logical CPU cores aka. threads. + pub cpus: u32, + /// CPU feature flag set as a space-delimited list. + pub flags: String, + /// Whether hardware-accelerated virtualization is supported. + pub hvm: bool, + /// Reported model of the CPU(s) + pub model: String, + /// Number of physical CPU sockets + pub sockets: u32, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Metadata of the hook, such as schema version of the document. +pub struct PostHookInfoSchema { + /// major.minor version describing the schema version of this document, in a semanticy-version + /// way. + /// + /// major: Incremented for incompatible/breaking API changes, e.g. removing an existing + /// field. + /// minor: Incremented when adding functionality in a backwards-compatible matter, e.g. + /// adding a new field. + pub version: String, +} + +impl PostHookInfoSchema { + const SCHEMA_VERSION: &str = "1.2"; +} + +impl Default for PostHookInfoSchema { + fn default() -> Self { + Self { + version: Self::SCHEMA_VERSION.to_owned(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// All data sent as request payload with the post-installation-webhook POST request. +/// +/// NOTE: The format is versioned through `schema.version` (`$schema.version` in the +/// resulting JSON), ensure you update it when this struct or any of its members gets modified. +pub struct PostHookInfo { + // This field is prefixed by `$` on purpose, to indicate that it is document metadata and not + // part of the actual content itself. (E.g. JSON Schema uses a similar naming scheme) + #[serde(rename = "$schema")] + /// Schema version information for this struct instance. + pub schema: PostHookInfoSchema, + /// major.minor version of Debian as installed, retrieved from /etc/debian_version + pub debian_version: String, + /// PVE/PMG/PBS/PDM version as reported by `pveversion`, `pmgversion`, + /// `proxmox-backup-manager version` or `proxmox-datacenter-manager version`, respectively. + pub product: ProductInfo, + /// Release information for the ISO used for the installation. + pub iso: IsoInfo, + /// Installed kernel version + pub kernel_version: KernelVersionInformation, + /// Describes the boot mode of the machine and the SecureBoot status. + pub boot_info: BootInfo, + /// Information about the installed CPU(s) + pub cpu_info: CpuInfo, + /// DMI information about the system + pub dmi: SystemDMI, + /// Filesystem used for boot disk(s) + pub filesystem: FilesystemType, + /// Fully qualified domain name of the installed system + pub fqdn: String, + /// Unique systemd-id128 identifier of the installed system (128-bit, 16 bytes) + pub machine_id: String, + /// All disks detected on the system. + pub disks: Vec, + /// All network interfaces detected on the system. + pub network_interfaces: Vec, + /// Public parts of SSH host keys of the installed system + pub ssh_public_host_keys: SshPublicHostKeys, + /// Action to will be performed, i.e. either reboot or power off the machine. + pub reboot_mode: RebootMode, +} + +fn bool_is_false(value: &bool) -> bool { + !value +} -- 2.51.2 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel