From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 387C41FF184 for ; Thu, 4 Dec 2025 13:52:11 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 612B2267CC; Thu, 4 Dec 2025 13:52:38 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Date: Thu, 4 Dec 2025 13:51:17 +0100 Message-ID: <20251204125122.945961-9-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: 1764852675199 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.050 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 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 datacenter-manager 08/13] api-types: add api types for auto-installer integration 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" The `Installation` type represents an individual installation done through PDM acting as auto-install server, and `PreparedInstallationConfig` a configuration provided by the user for automatically responding to answer requests based on certain target filters. Signed-off-by: Christoph Heiss --- Cargo.toml | 4 + debian/control | 3 + lib/pdm-api-types/Cargo.toml | 3 + lib/pdm-api-types/src/auto_installer.rs | 441 ++++++++++++++++++++++++ lib/pdm-api-types/src/lib.rs | 1 + 5 files changed, 452 insertions(+) create mode 100644 lib/pdm-api-types/src/auto_installer.rs diff --git a/Cargo.toml b/Cargo.toml index f8942af..9c1f0c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ proxmox-tfa = { version = "6", features = [ "api-types" ], default-features = fa proxmox-time = "2" proxmox-upgrade-checks = "1" proxmox-uuid = "1" +proxmox-installer-types = "0.1" +proxmox-network-types = "0.1" # other proxmox crates proxmox-acme = "0.5" @@ -158,6 +160,7 @@ zstd = { version = "0.13" } # proxmox-http-error = { path = "../proxmox/proxmox-http-error" } # proxmox-http = { path = "../proxmox/proxmox-http" } # proxmox-human-byte = { path = "../proxmox/proxmox-human-byte" } +# proxmox-installer-types = { path = "../proxmox/proxmox-installer-types" } # proxmox-io = { path = "../proxmox/proxmox-io" } # proxmox-lang = { path = "../proxmox/proxmox-lang" } # proxmox-ldap = { path = "../proxmox/proxmox-ldap" } @@ -165,6 +168,7 @@ zstd = { version = "0.13" } # proxmox-log = { path = "../proxmox/proxmox-log" } # proxmox-metrics = { path = "../proxmox/proxmox-metrics" } # proxmox-network-api = { path = "../proxmox/proxmox-network-api" } +# proxmox-network-types = { path = "../proxmox/proxmox-network-types" } # proxmox-node-status = { path = "../proxmox/proxmox-node-status" } # proxmox-notify = { path = "../proxmox/proxmox-notify" } # proxmox-openid = { path = "../proxmox/proxmox-openid" } diff --git a/debian/control b/debian/control index ccb73be..b1ce92a 100644 --- a/debian/control +++ b/debian/control @@ -62,6 +62,8 @@ Build-Depends: debhelper-compat (= 13), librust-proxmox-http-1+proxmox-async-dev (>= 1.0.4-~~), librust-proxmox-http-1+websocket-dev (>= 1.0.4-~~), librust-proxmox-human-byte-1+default-dev, + librust-proxmox-installer-types-0.1+api-types-dev, + librust-proxmox-installer-types-0.1+default-dev, librust-proxmox-lang-1+default-dev (>= 1.1-~~), librust-proxmox-ldap-1+default-dev (>= 1.1-~~), librust-proxmox-ldap-1+sync-dev (>= 1.1-~~), @@ -72,6 +74,7 @@ Build-Depends: debhelper-compat (= 13), librust-proxmox-network-api-1+impl-dev, librust-proxmox-node-status-1+api-dev, librust-proxmox-openid-1+default-dev (>= 1.0.2-~~), + librust-proxmox-network-types-0.1+default-dev, librust-proxmox-product-config-1+default-dev, librust-proxmox-rest-server-1+default-dev, librust-proxmox-rest-server-1+templates-dev, diff --git a/lib/pdm-api-types/Cargo.toml b/lib/pdm-api-types/Cargo.toml index d6429e6..2999834 100644 --- a/lib/pdm-api-types/Cargo.toml +++ b/lib/pdm-api-types/Cargo.toml @@ -19,12 +19,15 @@ proxmox-auth-api = { workspace = true, features = ["api-types"] } proxmox-apt-api-types.workspace = true proxmox-lang.workspace = true proxmox-config-digest.workspace = true +proxmox-installer-types = { workspace = true, features = ["api-types"] } +proxmox-network-types = { workspace = true, features = ["api-types"] } proxmox-schema = { workspace = true, features = ["api-macro"] } proxmox-section-config.workspace = true proxmox-dns-api.workspace = true proxmox-time.workspace = true proxmox-serde.workspace = true proxmox-subscription = { workspace = true, features = ["api-types"], default-features = false } +proxmox-uuid.workspace = true pbs-api-types = { workspace = true } pve-api-types = { workspace = true } diff --git a/lib/pdm-api-types/src/auto_installer.rs b/lib/pdm-api-types/src/auto_installer.rs new file mode 100644 index 0000000..c5309fe --- /dev/null +++ b/lib/pdm-api-types/src/auto_installer.rs @@ -0,0 +1,441 @@ +//! API types used for the auto-installation configuration. + +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::{self, Display}, + str::FromStr, +}; + +use proxmox_installer_types::{ + answer::{ + self, FilesystemOptions, FilesystemType, NetworkInterfacePinningOptionsAnswer, + COUNTRY_CODE_REGEX, + }, + post_hook::PostHookInfo, + SystemInfo, +}; +use proxmox_network_types::{ + fqdn::Fqdn, + ip_address::{api_types::IpAddr, Cidr}, +}; +use proxmox_schema::{ + api, + api_types::{ + CERT_FINGERPRINT_SHA256_SCHEMA, HTTP_URL_SCHEMA, SINGLE_LINE_COMMENT_FORMAT, UUID_FORMAT, + }, + property_string::PropertyString, + ApiStringFormat, Schema, StringSchema, Updater, +}; +use proxmox_uuid::Uuid; + +pub const INSTALLATION_UUID_SCHEMA: Schema = StringSchema::new("UUID of a installation.") + .format(&UUID_FORMAT) + .schema(); + +#[api] +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Current status of an installation. +pub enum InstallationStatus { + /// An appropriate answer file was found and sent to the machine. Post-hook was unavailable, + /// so no further status is received. + AnswerSent, + /// Found no matching answer configuration and no default was set. + NoAnswerFound, + /// The installation is currently underway. + InProgress, + /// The installation was finished successfully. + Finished, +} + +#[api( + properties: { + uuid: { + schema: INSTALLATION_UUID_SCHEMA, + }, + "received-at": { + minimum: 0, + }, + }, +)] +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// A installation received from some proxmox-auto-installer instance. +pub struct Installation { + /// Unique ID of this installation. + pub uuid: Uuid, + /// Time the installation request was received (Unix Epoch). + pub received_at: i64, + /// Current status of this installation. + pub status: InstallationStatus, + /// System information about the machine to be provisioned. + pub info: SystemInfo, + /// Answer that was sent to the target machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub answer_id: Option, + /// Post-installation notification hook data, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub post_hook_data: Option, +} + +/// Filter for matching against a single property in [`answer::fetch::AnswerFetchData`]. +/// Essentially a key-value tuple, where the key is a JSON Pointer as per [RFC6901]. +/// +/// [RFC6901] https://datatracker.ietf.org/doc/html/rfc6901 +#[derive(Debug, Clone, PartialEq)] +pub struct FilterEntry { + pub key: String, + pub value: String, +} + +impl FromStr for FilterEntry { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if let Some((key, value)) = s.split_once('=') { + Ok(Self { + key: key.to_owned(), + value: value.to_owned(), + }) + } else { + bail!("missing = delimiter") + } + } +} + +impl From<(String, String)> for FilterEntry { + fn from(value: (String, String)) -> Self { + Self { + key: value.0, + value: value.1, + } + } +} + +impl Display for FilterEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}={}", self.key, self.value) + } +} + +impl FilterEntry { + pub fn new, V: Into>(key: K, value: V) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +serde_plain::derive_deserialize_from_fromstr!(FilterEntry, "filter separated by ="); +serde_plain::derive_serialize_from_display!(FilterEntry); + +#[api] +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, Updater)] +#[serde(rename_all = "lowercase")] +/// How to select the target installations disks. +pub enum DiskSelectionMode { + #[default] + /// Use the fixed list of disks. + Fixed, + /// Dynamically determine target disks based on udev filters. + Filter, +} + +serde_plain::derive_fromstr_from_deserialize!(DiskSelectionMode); + +pub const PREPARED_INSTALL_CONFIG_ID_SCHEMA: proxmox_schema::Schema = + StringSchema::new("Unique ID of prepared configuration for automated installations.") + .min_length(3) + .max_length(64) + .schema(); + +#[api( + properties: { + id: { + schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + "target-filter": { + type: Array, + optional: true, + items: { + type: String, + description: "Target filter.", + }, + }, + country: { + format: &ApiStringFormat::Pattern(&COUNTRY_CODE_REGEX), + min_length: 2, + max_length: 2, + }, + mailto: { + min_length: 2, + max_length: 256, + format: &SINGLE_LINE_COMMENT_FORMAT, + }, + "root-ssh-keys": { + type: Array, + optional: true, + items: { + type: String, + description: "SSH public key.", + }, + }, + "netdev-filter": { + type: Array, + optional: true, + items: { + type: String, + description: "Network device filter.", + }, + }, + "filesystem-type": { + type: String, + }, + "filesystem-options": { + type: String, + description: "Filesystem-specific options.", + }, + "disk-mode": { + type: String, + }, + "disk-filter": { + type: Array, + optional: true, + items: { + type: String, + description: "Udev properties filter for disks.", + }, + }, + "post-hook-base-url": { + schema: HTTP_URL_SCHEMA, + optional: true, + }, + "post-hook-cert-fp": { + schema: CERT_FINGERPRINT_SHA256_SCHEMA, + optional: true, + }, + }, +)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Updater)] +#[serde(rename_all = "kebab-case")] +/// Configuration describing an automated installation. +pub struct PreparedInstallationConfig { + #[updater(skip)] + pub id: String, + + /// Whether this is the default answer. There can only ever be one default answer. + /// `target_filter` below is ignored if this is `true`. + pub is_default: bool, + // Target filters + /// A generic list of property name -> value filter pair to check against incoming automated + /// installation requests. If this is unset, it will match any installation not matched + /// "narrower" by other prepared configurations, thus being the default. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub target_filter: Vec, + + // Keys from [`answer::GlobalOptions`], adapted to better fit the API and model of the UI. + /// Country to use for apt mirrors. + pub country: String, + /// FQDN to set for the installed system. Only used if `use_dhcp_fqdn` is true. + pub fqdn: Fqdn, + /// Whether to use the FQDN from the DHCP lease or the user-provided one. + pub use_dhcp_fqdn: bool, + /// Keyboard layout to set. + pub keyboard: answer::KeyboardLayout, + /// Mail address for `root@pam`. + pub mailto: String, + /// Timezone to set on the new system. + pub timezone: String, + /// Pre-hashed password to set for the `root` PAM account. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub root_password_hashed: Option, + /// Whether to reboot the machine if an error occurred during the + /// installation. + pub reboot_on_error: bool, + /// Action to take after the installation completed successfully. + pub reboot_mode: answer::RebootMode, + /// Newline-separated list of public SSH keys to set up for the `root` PAM account. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub root_ssh_keys: Vec, + + // Keys from [`answer::NetworkConfig`], adapted to better fit the API and model of the UI. + /// Whether to use the network configuration from the DHCP lease or not. + pub use_dhcp_network: bool, + /// IP address and netmask if not using DHCP. + #[serde(skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub cidr: Option, + /// Gateway if not using DHCP. + #[serde(skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub gateway: Option, + /// DNS server address if not using DHCP. + #[serde(skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub dns: Option, + /// Filter for network devices, to select a specific management interface. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub netdev_filter: Vec, + /// Whether to enable network interface name pinning. + pub netif_name_pinning_enabled: bool, + + /// Keys from [`answer::DiskSetup`], adapted to better fit the API and model of the UI. + /// Filesystem type to use on the root disk. + pub filesystem_type: FilesystemType, + /// Filesystem-specific options. + pub filesystem_options: PropertyString, + + /// Whether to use the fixed disk list or select disks dynamically by udev filters. + pub disk_mode: DiskSelectionMode, + /// List of raw disk identifiers to use for the root filesystem. + #[serde(skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub disk_list: Option, + /// Filter against udev properties to select the disks for the installation, + /// to allow dynamic selection of disks. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub disk_filter: Vec, + /// Whether it is enough that any filter matches on a disk or all given + /// filters must match to select a disk. Only used if `disk_list` is unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub disk_filter_match: Option, + + /// Post installations hook base URL, i.e. host PDM is reachable as from + /// the target machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub post_hook_base_url: Option, + /// Post hook certificate fingerprint, if needed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub post_hook_cert_fp: Option, +} + +impl TryFrom for answer::AutoInstallerConfig { + type Error = anyhow::Error; + + fn try_from(conf: PreparedInstallationConfig) -> Result { + let fqdn = if conf.use_dhcp_fqdn { + answer::FqdnConfig::from_dhcp(None) + } else { + answer::FqdnConfig::Simple(conf.fqdn) + }; + + let global = answer::GlobalOptions { + country: conf.country, + fqdn, + keyboard: conf.keyboard, + mailto: conf.mailto, + timezone: conf.timezone, + root_password: None, + root_password_hashed: conf.root_password_hashed, + reboot_on_error: conf.reboot_on_error, + reboot_mode: conf.reboot_mode, + root_ssh_keys: conf.root_ssh_keys.clone(), + }; + + let network = { + let interface_name_pinning = + conf.netif_name_pinning_enabled + .then_some(NetworkInterfacePinningOptionsAnswer { + enabled: true, + mapping: HashMap::new(), + }); + + if conf.use_dhcp_network { + answer::NetworkConfig::FromDhcp(answer::NetworkConfigFromDhcp { + interface_name_pinning, + }) + } else { + answer::NetworkConfig::FromAnswer(answer::NetworkConfigFromAnswer { + cidr: conf.cidr.ok_or_else(|| anyhow!("no host address"))?, + dns: conf.dns.ok_or_else(|| anyhow!("no DNS server address"))?, + gateway: conf.gateway.ok_or_else(|| anyhow!("no gateway address"))?, + filter: conf + .netdev_filter + .iter() + .map(|FilterEntry { key, value }| (key.clone(), value.clone())) + .collect(), + interface_name_pinning, + }) + } + }; + + let (disk_list, filter) = if conf.disk_mode == DiskSelectionMode::Fixed { + ( + conf.disk_list + .map(|s| s.split(",").map(|s| s.trim().to_owned()).collect()) + .unwrap_or_default(), + BTreeMap::new(), + ) + } else { + ( + vec![], + conf.disk_filter + .iter() + .map(|FilterEntry { key, value }| (key.to_owned(), value.to_owned())) + .collect(), + ) + }; + + let disks = answer::DiskSetup { + filesystem: match conf.filesystem_type { + FilesystemType::Ext4 => answer::Filesystem::Ext4, + FilesystemType::Xfs => answer::Filesystem::Xfs, + FilesystemType::Zfs(_) => answer::Filesystem::Zfs, + FilesystemType::Btrfs(_) => answer::Filesystem::Btrfs, + }, + disk_list, + filter, + filter_match: conf.disk_filter_match, + zfs: match conf.filesystem_options.clone().into_inner() { + FilesystemOptions::Zfs(opts) => Some(opts), + _ => None, + }, + lvm: match conf.filesystem_options.clone().into_inner() { + FilesystemOptions::Lvm(opts) => Some(opts), + _ => None, + }, + btrfs: match conf.filesystem_options.into_inner() { + FilesystemOptions::Btrfs(opts) => Some(opts), + _ => None, + }, + }; + + Ok(Self { + global, + network, + disks, + post_installation_webhook: None, + first_boot: None, + }) + } +} + +#[api] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum PreparedInstallationConfigDeletableProperty { + /// Target filter key=value filters + TargetFilter, + /// udev property key=value filters for the management network device + NetdevFilter, + /// udev property key=value filters for disks + DiskFilter, + /// Delete all `root` user public ssh keys. + RootSshKeys, + /// Delete the post-installation notification base url. + PostHookBaseUrl, + /// Delete the post-installation notification certificate fingerprint. + PostHookCertFp, +} diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs index 5daaa3f..98139f0 100644 --- a/lib/pdm-api-types/src/lib.rs +++ b/lib/pdm-api-types/src/lib.rs @@ -115,6 +115,7 @@ pub mod subscription; pub mod sdn; pub mod views; +pub mod auto_installer; const_regex! { // just a rough check - dummy acceptor is used before persisting -- 2.51.2 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel