* [pdm-devel] [PATCH proxmox v2 01/14] api-macro: allow $ in identifier name
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 02/14] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
` (13 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
This allows dollar-sign in renamed field names for API types.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
proxmox-api-macro/src/util.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/proxmox-api-macro/src/util.rs b/proxmox-api-macro/src/util.rs
index 9ed3fa0b..20a4d53b 100644
--- a/proxmox-api-macro/src/util.rs
+++ b/proxmox-api-macro/src/util.rs
@@ -30,7 +30,7 @@ pub struct FieldName {
impl FieldName {
pub fn new(name: String, span: Span) -> Self {
- let mut ident_str = name.replace(['-', '.', '+'].as_ref(), "_");
+ let mut ident_str = name.replace(['-', '.', '+', '$'].as_ref(), "_");
if ident_str.chars().next().unwrap().is_numeric() {
ident_str.insert(0, '_');
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 02/14] network-types: move `Fqdn` type from proxmox-installer-common
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 01/14] api-macro: allow $ in identifier name Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 03/14] network-types: implement api type for Fqdn Christoph Heiss
` (12 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
This introduces an `Fqdn` type for safely representing (valid) FQDNs on
Debian, following all relevant RFCs as well as restrictions given by
Debian.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
proxmox-network-types/Cargo.toml | 3 +-
proxmox-network-types/debian/control | 2 +
proxmox-network-types/src/fqdn.rs | 248 +++++++++++++++++++++++++++
proxmox-network-types/src/lib.rs | 1 +
4 files changed, 253 insertions(+), 1 deletion(-)
create mode 100644 proxmox-network-types/src/fqdn.rs
diff --git a/proxmox-network-types/Cargo.toml b/proxmox-network-types/Cargo.toml
index 6333a37f..25c4bcf2 100644
--- a/proxmox-network-types/Cargo.toml
+++ b/proxmox-network-types/Cargo.toml
@@ -10,9 +10,10 @@ exclude.workspace = true
rust-version.workspace = true
[dependencies]
-regex = { workspace = true, optional = true}
+regex = { workspace = true, optional = true }
serde = { workspace = true, features = [ "derive", "std" ] }
serde_with = "3.8.1"
+serde_plain.workspace = true
thiserror.workspace = true
proxmox-schema = { workspace = true, features = [ "api-macro", "api-types" ], optional = true}
diff --git a/proxmox-network-types/debian/control b/proxmox-network-types/debian/control
index 8b68deb1..08df0f9f 100644
--- a/proxmox-network-types/debian/control
+++ b/proxmox-network-types/debian/control
@@ -9,6 +9,7 @@ Build-Depends-Arch: cargo:native <!nocheck>,
librust-serde-1+default-dev <!nocheck>,
librust-serde-1+derive-dev <!nocheck>,
librust-serde-1+std-dev <!nocheck>,
+ librust-serde-plain-1+default-dev <!nocheck>,
librust-serde-with-3+default-dev (>= 3.8.1-~~) <!nocheck>,
librust-thiserror-2+default-dev <!nocheck>
Maintainer: Proxmox Support Team <support@proxmox.com>
@@ -26,6 +27,7 @@ Depends:
librust-serde-1+default-dev,
librust-serde-1+derive-dev,
librust-serde-1+std-dev,
+ librust-serde-plain-1+default-dev,
librust-serde-with-3+default-dev (>= 3.8.1-~~),
librust-thiserror-2+default-dev
Suggests:
diff --git a/proxmox-network-types/src/fqdn.rs b/proxmox-network-types/src/fqdn.rs
new file mode 100644
index 00000000..9582639d
--- /dev/null
+++ b/proxmox-network-types/src/fqdn.rs
@@ -0,0 +1,248 @@
+//! A type for safely representing fully-qualified domain names (FQDNs).
+
+use std::{fmt, str::FromStr};
+
+use serde::Deserialize;
+
+/// Possible errors that might occur when parsing FQDNs.
+#[derive(Debug, Eq, PartialEq)]
+pub enum FqdnParseError {
+ MissingHostname,
+ NumericHostname,
+ InvalidPart(String),
+ TooLong(usize),
+}
+
+impl fmt::Display for FqdnParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ use FqdnParseError::*;
+ match self {
+ MissingHostname => write!(f, "missing hostname part"),
+ NumericHostname => write!(f, "hostname cannot be purely numeric"),
+ InvalidPart(part) => write!(
+ f,
+ "FQDN must only consist of alphanumeric characters and dashes. Invalid part: '{part}'",
+ ),
+ TooLong(len) => write!(f, "FQDN too long: {len} > {}", Fqdn::MAX_LENGTH),
+ }
+ }
+}
+
+/// A type for safely representing fully-qualified domain names (FQDNs).
+///
+/// It considers following RFCs:
+/// - [RFC952] (sec. "ASSUMPTIONS", 1.)
+/// - [RFC1035] (sec. 2.3. "Conventions")
+/// - [RFC1123] (sec. 2.1. "Host Names and Numbers")
+/// - [RFC3492]
+/// - [RFC4343]
+///
+/// .. and applies some restriction given by Debian, e.g. 253 instead of 255
+/// maximum total length and maximum 63 characters per label, per the
+/// [hostname(7)].
+///
+/// Additionally:
+/// - It enforces the restriction as per Bugzilla #1054, in that
+/// purely numeric hostnames are not allowed - against RFC1123 sec. 2.1.
+///
+/// Some terminology:
+/// - "label" - a single part of a FQDN, e.g. {label}.{label}.{tld}
+///
+/// [RFC952]: <https://www.ietf.org/rfc/rfc952.txt>
+/// [RFC1035]: <https://www.ietf.org/rfc/rfc1035.txt>
+/// [RFC1123]: <https://www.ietf.org/rfc/rfc1123.txt>
+/// [RFC3492]: <https://www.ietf.org/rfc/rfc3492.txt>
+/// [RFC4343]: <https://www.ietf.org/rfc/rfc4343.txt>
+/// [hostname(7)]: <https://manpages.debian.org/stable/manpages/hostname.7.en.html>
+#[derive(Clone, Debug, Eq)]
+pub struct Fqdn {
+ parts: Vec<String>,
+}
+
+impl Fqdn {
+ /// Maximum length of a single label of the FQDN
+ const MAX_LABEL_LENGTH: usize = 63;
+ /// Maximum total length of the FQDN
+ const MAX_LENGTH: usize = 253;
+
+ pub fn from(fqdn: &str) -> Result<Self, FqdnParseError> {
+ if fqdn.len() > Self::MAX_LENGTH {
+ return Err(FqdnParseError::TooLong(fqdn.len()));
+ }
+
+ let parts = fqdn
+ .split('.')
+ .map(ToOwned::to_owned)
+ .collect::<Vec<String>>();
+
+ for part in &parts {
+ if !Self::validate_single(part) {
+ return Err(FqdnParseError::InvalidPart(part.clone()));
+ }
+ }
+
+ if parts.len() < 2 {
+ Err(FqdnParseError::MissingHostname)
+ } else if parts[0].chars().all(|c| c.is_ascii_digit()) {
+ // Do not allow a purely numeric hostname, see:
+ // https://bugzilla.proxmox.com/show_bug.cgi?id=1054
+ Err(FqdnParseError::NumericHostname)
+ } else {
+ Ok(Self { parts })
+ }
+ }
+
+ pub fn host(&self) -> Option<&str> {
+ self.has_host().then_some(&self.parts[0])
+ }
+
+ pub fn domain(&self) -> String {
+ let parts = if self.has_host() {
+ &self.parts[1..]
+ } else {
+ &self.parts
+ };
+
+ parts.join(".")
+ }
+
+ /// Checks whether the FQDN has a hostname associated with it, i.e. is has more than 1 part.
+ fn has_host(&self) -> bool {
+ self.parts.len() > 1
+ }
+
+ fn validate_single(s: &str) -> bool {
+ !s.is_empty()
+ && s.len() <= Self::MAX_LABEL_LENGTH
+ // First character must be alphanumeric
+ && s.chars()
+ .next()
+ .map(|c| c.is_ascii_alphanumeric())
+ .unwrap_or_default()
+ // .. last character as well,
+ && s.chars()
+ .last()
+ .map(|c| c.is_ascii_alphanumeric())
+ .unwrap_or_default()
+ // and anything between must be alphanumeric or -
+ && s.chars()
+ .skip(1)
+ .take(s.len().saturating_sub(2))
+ .all(|c| c.is_ascii_alphanumeric() || c == '-')
+ }
+}
+
+impl FromStr for Fqdn {
+ type Err = FqdnParseError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::from(value)
+ }
+}
+
+impl fmt::Display for Fqdn {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", self.parts.join("."))
+ }
+}
+
+serde_plain::derive_serialize_from_display!(Fqdn);
+
+impl<'de> Deserialize<'de> for Fqdn {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let s: String = Deserialize::deserialize(deserializer)?;
+ s.parse()
+ .map_err(|_| serde::de::Error::custom("invalid FQDN"))
+ }
+}
+
+impl PartialEq for Fqdn {
+ // Case-insensitive comparison, as per RFC 952 "ASSUMPTIONS", RFC 1035 sec. 2.3.3. "Character
+ // Case" and RFC 4343 as a whole
+ fn eq(&self, other: &Self) -> bool {
+ if self.parts.len() != other.parts.len() {
+ return false;
+ }
+
+ self.parts
+ .iter()
+ .zip(other.parts.iter())
+ .all(|(a, b)| a.to_lowercase() == b.to_lowercase())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn fqdn_construct() {
+ use FqdnParseError::*;
+ assert!(Fqdn::from("foo.example.com").is_ok());
+ assert!(Fqdn::from("foo-bar.com").is_ok());
+ assert!(Fqdn::from("a-b.com").is_ok());
+
+ assert_eq!(Fqdn::from("foo"), Err(MissingHostname));
+
+ assert_eq!(Fqdn::from("-foo.com"), Err(InvalidPart("-foo".to_owned())));
+ assert_eq!(Fqdn::from("foo-.com"), Err(InvalidPart("foo-".to_owned())));
+ assert_eq!(Fqdn::from("foo.com-"), Err(InvalidPart("com-".to_owned())));
+ assert_eq!(Fqdn::from("-o-.com"), Err(InvalidPart("-o-".to_owned())));
+
+ // https://bugzilla.proxmox.com/show_bug.cgi?id=1054
+ assert_eq!(Fqdn::from("123.com"), Err(NumericHostname));
+ assert!(Fqdn::from("foo123.com").is_ok());
+ assert!(Fqdn::from("123foo.com").is_ok());
+
+ assert!(Fqdn::from(&format!("{}.com", "a".repeat(63))).is_ok());
+ assert_eq!(
+ Fqdn::from(&format!("{}.com", "a".repeat(250))),
+ Err(TooLong(254)),
+ );
+ assert_eq!(
+ Fqdn::from(&format!("{}.com", "a".repeat(64))),
+ Err(InvalidPart("a".repeat(64))),
+ );
+
+ // https://bugzilla.proxmox.com/show_bug.cgi?id=5230
+ assert_eq!(
+ Fqdn::from("123@foo.com"),
+ Err(InvalidPart("123@foo".to_owned()))
+ );
+ }
+
+ #[test]
+ fn fqdn_parts() {
+ let fqdn = Fqdn::from("pve.example.com").unwrap();
+ assert_eq!(fqdn.host().unwrap(), "pve");
+ assert_eq!(fqdn.domain(), "example.com");
+ assert_eq!(
+ fqdn.parts,
+ &["pve".to_owned(), "example".to_owned(), "com".to_owned()]
+ );
+ }
+
+ #[test]
+ fn fqdn_display() {
+ assert_eq!(
+ Fqdn::from("foo.example.com").unwrap().to_string(),
+ "foo.example.com"
+ );
+ }
+
+ #[test]
+ fn fqdn_compare() {
+ assert_eq!(Fqdn::from("example.com"), Fqdn::from("example.com"));
+ assert_eq!(Fqdn::from("example.com"), Fqdn::from("ExAmPle.Com"));
+ assert_eq!(Fqdn::from("ExAmPle.Com"), Fqdn::from("example.com"));
+ assert_ne!(
+ Fqdn::from("subdomain.ExAmPle.Com"),
+ Fqdn::from("example.com")
+ );
+ assert_ne!(Fqdn::from("foo.com"), Fqdn::from("bar.com"));
+ assert_ne!(Fqdn::from("example.com"), Fqdn::from("example.net"));
+ }
+}
diff --git a/proxmox-network-types/src/lib.rs b/proxmox-network-types/src/lib.rs
index ee26b1c1..e5d31285 100644
--- a/proxmox-network-types/src/lib.rs
+++ b/proxmox-network-types/src/lib.rs
@@ -1,5 +1,6 @@
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![deny(unsafe_op_in_unsafe_fn)]
+pub mod fqdn;
pub mod ip_address;
pub mod mac_address;
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 03/14] network-types: implement api type for Fqdn
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 01/14] api-macro: allow $ in identifier name Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 02/14] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 04/14] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
` (11 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Pretty straight-forward, as we already got a fitting regex defined for a
FQDN.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
proxmox-network-types/src/fqdn.rs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/proxmox-network-types/src/fqdn.rs b/proxmox-network-types/src/fqdn.rs
index 9582639d..3c1bd475 100644
--- a/proxmox-network-types/src/fqdn.rs
+++ b/proxmox-network-types/src/fqdn.rs
@@ -2,6 +2,9 @@
use std::{fmt, str::FromStr};
+#[cfg(feature = "api-types")]
+use proxmox_schema::UpdaterType;
+
use serde::Deserialize;
/// Possible errors that might occur when parsing FQDNs.
@@ -55,6 +58,7 @@ impl fmt::Display for FqdnParseError {
/// [RFC4343]: <https://www.ietf.org/rfc/rfc4343.txt>
/// [hostname(7)]: <https://manpages.debian.org/stable/manpages/hostname.7.en.html>
#[derive(Clone, Debug, Eq)]
+#[cfg_attr(feature = "api-types", derive(UpdaterType))]
pub struct Fqdn {
parts: Vec<String>,
}
@@ -132,6 +136,16 @@ impl Fqdn {
}
}
+#[cfg(feature = "api-types")]
+impl proxmox_schema::ApiType for Fqdn {
+ const API_SCHEMA: proxmox_schema::Schema =
+ proxmox_schema::StringSchema::new("Fully-qualified domain name")
+ .format(&proxmox_schema::ApiStringFormat::Pattern(
+ &proxmox_schema::api_types::DNS_NAME_REGEX,
+ ))
+ .schema();
+}
+
impl FromStr for Fqdn {
type Err = FqdnParseError;
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 04/14] network-types: add api wrapper type for std::net::IpAddr
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (2 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 03/14] network-types: implement api type for Fqdn Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 05/14] installer-types: add common types used by the installer Christoph Heiss
` (10 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Much like the existing ones for Ipv4Addr/Ipv6Addr.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
proxmox-network-types/src/ip_address.rs | 64 ++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 2 deletions(-)
diff --git a/proxmox-network-types/src/ip_address.rs b/proxmox-network-types/src/ip_address.rs
index 1c721975..5133f5d9 100644
--- a/proxmox-network-types/src/ip_address.rs
+++ b/proxmox-network-types/src/ip_address.rs
@@ -16,8 +16,10 @@ pub mod api_types {
use std::net::AddrParseError;
use std::ops::{Deref, DerefMut};
- use proxmox_schema::api_types::IP_V6_SCHEMA;
- use proxmox_schema::{api_types::IP_V4_SCHEMA, ApiType, UpdaterType};
+ use proxmox_schema::{
+ api_types::{IP_SCHEMA, IP_V4_SCHEMA, IP_V6_SCHEMA},
+ ApiType, UpdaterType,
+ };
use serde_with::{DeserializeFromStr, SerializeDisplay};
#[derive(
@@ -135,6 +137,64 @@ pub mod api_types {
Self(value)
}
}
+
+ #[derive(
+ Debug,
+ Clone,
+ Copy,
+ Eq,
+ PartialEq,
+ Ord,
+ PartialOrd,
+ DeserializeFromStr,
+ SerializeDisplay,
+ Hash,
+ )]
+ #[repr(transparent)]
+ pub struct IpAddr(pub std::net::IpAddr);
+
+ impl ApiType for IpAddr {
+ const API_SCHEMA: proxmox_schema::Schema = IP_SCHEMA;
+ }
+
+ impl UpdaterType for IpAddr {
+ type Updater = Option<IpAddr>;
+ }
+
+ impl Deref for IpAddr {
+ type Target = std::net::IpAddr;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+ }
+
+ impl DerefMut for IpAddr {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+ }
+
+ impl std::str::FromStr for IpAddr {
+ type Err = AddrParseError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ let ip_address = std::net::IpAddr::from_str(value)?;
+ Ok(Self(ip_address))
+ }
+ }
+
+ impl std::fmt::Display for IpAddr {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+ }
+
+ impl From<std::net::IpAddr> for IpAddr {
+ fn from(value: std::net::IpAddr) -> Self {
+ Self(value)
+ }
+ }
}
/// The family (v4 or v6) of an IP address or CIDR prefix
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 05/14] installer-types: add common types used by the installer
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (3 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 04/14] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 06/14] installer-types: add types used by the auto-installer Christoph Heiss
` (9 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
These moves common type definitions used throughout in the installer in
it's own crate, so they can be re-used in other places.
Some types are also renamed to improve clarity:
- `NetdevWithMac` -> `NetworkInterface`
- `SysInfo` -> `SystemInfo`
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
Cargo.toml | 1 +
proxmox-installer-types/Cargo.toml | 21 +++
proxmox-installer-types/debian/changelog | 5 +
proxmox-installer-types/debian/control | 44 ++++++
proxmox-installer-types/debian/debcargo.toml | 7 +
proxmox-installer-types/src/lib.rs | 143 +++++++++++++++++++
6 files changed, 221 insertions(+)
create mode 100644 proxmox-installer-types/Cargo.toml
create mode 100644 proxmox-installer-types/debian/changelog
create mode 100644 proxmox-installer-types/debian/control
create mode 100644 proxmox-installer-types/debian/debcargo.toml
create mode 100644 proxmox-installer-types/src/lib.rs
diff --git a/Cargo.toml b/Cargo.toml
index 27a69afa..2df50903 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,6 +21,7 @@ members = [
"proxmox-http",
"proxmox-http-error",
"proxmox-human-byte",
+ "proxmox-installer-types",
"proxmox-io",
"proxmox-lang",
"proxmox-ldap",
diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml
new file mode 100644
index 00000000..62df7f4e
--- /dev/null
+++ b/proxmox-installer-types/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "proxmox-installer-types"
+description = "Type definitions used within the installer"
+version = "0.1.0"
+
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_plain.workspace = true
+proxmox-network-types.workspace = true
+
+[features]
+default = []
+legacy = []
diff --git a/proxmox-installer-types/debian/changelog b/proxmox-installer-types/debian/changelog
new file mode 100644
index 00000000..d2415aa0
--- /dev/null
+++ b/proxmox-installer-types/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-installer-types (0.1.0-1) unstable; urgency=medium
+
+ * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 25 Nov 2025 10:23:32 +0200
diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control
new file mode 100644
index 00000000..902977af
--- /dev/null
+++ b/proxmox-installer-types/debian/control
@@ -0,0 +1,44 @@
+Source: rust-proxmox-installer-types
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.82) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-proxmox-network-types-0.1+api-types-dev <!nocheck>,
+ librust-proxmox-network-types-0.1+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-serde-plain-1+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-installer-types
+
+Package: librust-proxmox-installer-types-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-network-types-0.1+api-types-dev,
+ librust-proxmox-network-types-0.1+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
+ librust-serde-plain-1+default-dev
+Provides:
+ librust-proxmox-installer-types+default-dev (= ${binary:Version}),
+ librust-proxmox-installer-types+legacy-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0+default-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0+legacy-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1+legacy-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1.0+legacy-dev (= ${binary:Version})
+Description: Type definitions used within the installer - Rust source code
+ Source code for Debianized Rust crate "proxmox-installer-types"
diff --git a/proxmox-installer-types/debian/debcargo.toml b/proxmox-installer-types/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-installer-types/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/proxmox-installer-types/src/lib.rs b/proxmox-installer-types/src/lib.rs
new file mode 100644
index 00000000..07927cb0
--- /dev/null
+++ b/proxmox-installer-types/src/lib.rs
@@ -0,0 +1,143 @@
+//! Defines API types used within the installer, primarily for interacting
+//! with proxmox-auto-installer.
+//!
+//! [`BTreeMap`]s are used to store certain properties to keep the order of
+//! them stable, compared to storing them in an ordinary [`HashMap`].
+
+#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
+#![deny(unsafe_code, missing_docs)]
+
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::{BTreeMap, HashMap},
+ fmt::{self, Display},
+};
+
+use proxmox_network_types::mac_address::MacAddress;
+
+/// Default placeholder value for the administrator email address.
+pub const EMAIL_DEFAULT_PLACEHOLDER: &str = "mail@example.invalid";
+
+#[derive(Copy, Clone, Eq, Deserialize, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+/// Whether the system boots using legacy BIOS or (U)EFI.
+pub enum BootType {
+ /// System boots using legacy BIOS.
+ Bios,
+ /// System boots using (U)EFI.
+ Efi,
+}
+
+/// Uses a BTreeMap to have the keys sorted
+pub type UdevProperties = BTreeMap<String, String>;
+
+#[derive(Clone, Deserialize, Debug)]
+/// Information extracted from udev about devices present in the system.
+pub struct UdevInfo {
+ /// udev information for each disk.
+ pub disks: BTreeMap<String, UdevProperties>,
+ /// udev information for each network interface card.
+ pub nics: BTreeMap<String, UdevProperties>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+/// Information about the hardware and installer in use.
+pub struct SystemInfo {
+ /// Information about the product to be installed.
+ pub product: ProductConfig,
+ /// Information about the ISO.
+ pub iso: IsoInfo,
+ /// Raw DMI information of the system.
+ pub dmi: SystemDMI,
+ /// Network devices present on the system.
+ pub network_interfaces: Vec<NetworkInterface>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+/// The per-product configuration of the installer.
+pub struct ProductConfig {
+ /// Full name of the product.
+ pub fullname: String,
+ /// The actual product the installer is for.
+ pub product: ProxmoxProduct,
+ /// Whether to enable installations on Btrfs.
+ pub enable_btrfs: bool,
+}
+
+impl ProductConfig {
+ /// A mocked ProductConfig simulating a Proxmox VE environment.
+ pub fn mocked() -> Self {
+ Self {
+ fullname: String::from("Proxmox VE (mocked)"),
+ product: ProxmoxProduct::PVE,
+ enable_btrfs: true,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+/// Information about the ISO itself.
+pub struct IsoInfo {
+ /// Version of the product.
+ pub release: String,
+ /// Version of the ISO itself, e.g. the spin.
+ pub isorelease: String,
+}
+
+impl IsoInfo {
+ /// A mocked IsoInfo with some edge case to convey that this is not necessarily purely numeric.
+ pub fn mocked() -> Self {
+ Self {
+ release: String::from("42.1"),
+ isorelease: String::from("mocked-1"),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+/// Collection of various DMI information categories.
+pub struct SystemDMI {
+ /// Information about the system baseboard.
+ pub baseboard: HashMap<String, String>,
+ /// Information about the system chassis.
+ pub chassis: HashMap<String, String>,
+ /// Information about the hardware itself, mostly identifiers.
+ pub system: HashMap<String, String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+/// A unique network interface.
+pub struct NetworkInterface {
+ /// The network link name
+ pub link: String,
+ /// The MAC address of the network device
+ pub mac: MacAddress,
+}
+
+#[allow(clippy::upper_case_acronyms)]
+#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+/// The name of the product.
+pub enum ProxmoxProduct {
+ /// Proxmox Virtual Environment
+ PVE,
+ /// Proxmox Backup Server
+ PBS,
+ /// Proxmox Mail Gateway
+ PMG,
+ /// Proxmox Datacenter Manager
+ PDM,
+}
+
+serde_plain::derive_fromstr_from_deserialize!(ProxmoxProduct);
+
+impl Display for ProxmoxProduct {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::PVE => write!(f, "Proxmox Virtualization Environment"),
+ Self::PBS => write!(f, "Proxmox Backup Server"),
+ Self::PMG => write!(f, "Proxmox Mail Gateway"),
+ Self::PDM => write!(f, "Proxmox Datacenter Manager"),
+ }
+ }
+}
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 06/14] installer-types: add types used by the auto-installer
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (4 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 05/14] installer-types: add common types used by the installer Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 07/14] installer-types: implement api type for all externally-used types Christoph Heiss
` (8 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: 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 <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
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 <!nocheck>,
rustc:native (>= 1.82) <!nocheck>,
libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
librust-proxmox-network-types-0.1+api-types-dev <!nocheck>,
librust-proxmox-network-types-0.1+default-dev <!nocheck>,
librust-serde-1+default-dev <!nocheck>,
@@ -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<PostNotificationHookInfo>,
+ #[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<FirstBootHookInfo>,
+}
+
+#[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<String>,
+ #[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<String>,
+ #[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<String>,
+}
+
+#[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<S: Into<String>>(fqdn: S) -> Result<Self> {
+ Ok(Self::Simple(
+ fqdn.into()
+ .parse::<Fqdn>()
+ .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<String>) -> 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<String>,
+}
+
+#[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<String>,
+}
+
+#[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<String>,
+ /// 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<String>,
+}
+
+/// 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<String, String>,
+}
+
+#[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<String, String>,
+ /// 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<NetworkInterfacePinningOptionsAnswer>,
+}
+
+#[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<NetworkInterfacePinningOptionsAnswer>,
+}
+
+#[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<String>),
+ /// Select disks dynamically by filtering them by udev properties.
+ Filter(BTreeMap<String, String>),
+}
+
+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<String>,
+ #[serde(default)]
+ /// Filter against udev properties to select the disks for the installation,
+ /// to allow dynamic selection of disks.
+ pub filter: BTreeMap<String, String>,
+ #[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<FilterMatch>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// ZFS-specific filesystem options.
+ pub zfs: Option<ZfsOptions>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// LVM-specific filesystem options, when using ext4 or xfs as filesystem.
+ pub lvm: Option<LvmOptions>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Btrfs-specific filesystem options.
+ pub btrfs: Option<BtrfsOptions>,
+}
+
+impl DiskSetup {
+ /// Returns the concrete disk selection made in the setup.
+ pub fn disk_selection(&self) -> Result<DiskSelection> {
+ 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<ZfsRaidLevel>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// `ashift` value to create the zpool with.
+ pub ashift: Option<u32>,
+ #[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<u32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Checksumming algorithm to create the zpool with.
+ pub checksum: Option<ZfsChecksumOption>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Compression algorithm to set on the zpool.
+ pub compress: Option<ZfsCompressOption>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// `copies` value to create the zpool with.
+ pub copies: Option<u32>,
+ #[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<f64>,
+}
+
+#[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<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Size of the swap volume. Optional, will be heuristically determined if
+ /// unset.
+ pub swapsize: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Maximum size the `root` volume. Optional, will be heuristically determined
+ /// if unset.
+ pub maxroot: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Maximum size the `data` volume. Optional, will be heuristically determined
+ /// if unset.
+ pub maxvz: Option<f64>,
+ #[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<f64>,
+}
+
+#[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<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// RAID level to use.
+ pub raid: Option<BtrfsRaidLevel>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ /// Whether to enable filesystem-level compression and what type.
+ pub compress: Option<BtrfsCompressOption>,
+}
+
+#[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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<Self, Self::Err> {
+ 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<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let val: Option<String> = 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<Cidr>,
+ /// 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/<node>/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<DiskInfo>,
+ /// All network interfaces detected on the system.
+ pub network_interfaces: Vec<NetworkInterfaceInfo>,
+ /// 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
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH proxmox v2 07/14] installer-types: implement api type for all externally-used types
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (5 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 06/14] installer-types: add types used by the auto-installer Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 08/14] api-types: add api types for auto-installer integration Christoph Heiss
` (7 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
PDM will (re-)use most of these types directly in the API, thus make
them compatible.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
proxmox-installer-types/Cargo.toml | 6 +-
proxmox-installer-types/debian/control | 21 ++++
proxmox-installer-types/src/answer.rs | 119 +++++++++++++++++++++++
proxmox-installer-types/src/lib.rs | 37 +++++++
proxmox-installer-types/src/post_hook.rs | 56 +++++++++++
5 files changed, 238 insertions(+), 1 deletion(-)
diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml
index 96413fe1..8f281e01 100644
--- a/proxmox-installer-types/Cargo.toml
+++ b/proxmox-installer-types/Cargo.toml
@@ -15,8 +15,12 @@ rust-version.workspace = true
anyhow.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_plain.workspace = true
-proxmox-network-types.workspace = true
+regex = { workspace = true, optional = true }
+proxmox-network-types = { workspace = true, features = ["api-types"] }
+proxmox-schema = { workspace = true, optional = true, features = ["api-macro"] }
+proxmox-section-config = { workspace = true, optional = true }
[features]
default = []
+api-types = ["dep:regex", "dep:proxmox-schema", "dep:proxmox-section-config", "proxmox-network-types/api-types"]
legacy = []
diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control
index d208a014..30c5e7ed 100644
--- a/proxmox-installer-types/debian/control
+++ b/proxmox-installer-types/debian/control
@@ -30,6 +30,8 @@ Depends:
librust-serde-1+default-dev,
librust-serde-1+derive-dev,
librust-serde-plain-1+default-dev
+Suggests:
+ librust-proxmox-installer-types+api-types-dev (= ${binary:Version})
Provides:
librust-proxmox-installer-types+default-dev (= ${binary:Version}),
librust-proxmox-installer-types+legacy-dev (= ${binary:Version}),
@@ -44,3 +46,22 @@ Provides:
librust-proxmox-installer-types-0.1.0+legacy-dev (= ${binary:Version})
Description: Type definitions used within the installer - Rust source code
Source code for Debianized Rust crate "proxmox-installer-types"
+
+Package: librust-proxmox-installer-types+api-types-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-installer-types-dev (= ${binary:Version}),
+ librust-proxmox-network-types-0.1+api-types-dev,
+ librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.0.1-~~),
+ librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~),
+ librust-regex-1+default-dev (>= 1.5-~~)
+Provides:
+ librust-proxmox-installer-types-0+api-types-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1+api-types-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1.0+api-types-dev (= ${binary:Version})
+Description: Type definitions used within the installer - feature "api-types"
+ This metapackage enables feature "api-types" for the Rust proxmox-installer-
+ types crate, by pulling in any additional dependencies needed by that feature.
diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs
index 7129a941..acfadcf8 100644
--- a/proxmox-installer-types/src/answer.rs
+++ b/proxmox-installer-types/src/answer.rs
@@ -15,15 +15,32 @@ use std::{
};
use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::{api, api_types::PASSWORD_FORMAT, StringSchema, Updater, UpdaterType};
+
+#[cfg(feature = "api-types")]
+type IpAddr = proxmox_network_types::ip_address::api_types::IpAddr;
+#[cfg(not(feature = "api-types"))]
type IpAddr = std::net::IpAddr;
+#[cfg(feature = "api-types")]
+proxmox_schema::const_regex! {
+ /// A unique two-letter country code, according to ISO 3166-1 (alpha-2).
+ pub COUNTRY_CODE_REGEX = r"^[a-z]{2}$";
+}
+
/// Defines API types used by proxmox-fetch-answer, the first part of the
/// auto-installer.
pub mod fetch {
use serde::{Deserialize, Serialize};
+ #[cfg(feature = "api-types")]
+ use proxmox_schema::api;
+
use crate::SystemInfo;
+ #[cfg_attr(feature = "api-types", api)]
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
/// Metadata of the HTTP POST payload, such as schema version of the document.
@@ -50,6 +67,13 @@ pub mod fetch {
}
}
+ #[cfg_attr(feature = "api-types", api(
+ properties: {
+ sysinfo: {
+ flatten: true,
+ },
+ },
+ ))]
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
/// Data sent in the body of POST request when retrieving the answer file via HTTP(S).
@@ -91,6 +115,14 @@ pub struct AutoInstallerConfig {
pub first_boot: Option<FirstBootHookInfo>,
}
+/// Machine root password schema.
+#[cfg(feature = "api-types")]
+pub const ROOT_PASSWORD_SCHEMA: proxmox_schema::Schema = StringSchema::new("Root Password.")
+ .format(&PASSWORD_FORMAT)
+ .min_length(8)
+ .max_length(64)
+ .schema();
+
#[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
@@ -130,6 +162,7 @@ pub struct GlobalOptions {
pub root_ssh_keys: Vec<String>,
}
+#[cfg_attr(feature = "api-types", api)]
#[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.
@@ -144,6 +177,7 @@ pub enum RebootMode {
serde_plain::derive_fromstr_from_deserialize!(RebootMode);
#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
+#[cfg_attr(feature = "api-types", derive(Updater))]
#[serde(
untagged,
expecting = "either a fully-qualified domain name or extendend configuration for usage with DHCP must be specified"
@@ -204,6 +238,7 @@ pub enum FqdnSourceMode {
FromDhcp,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Configuration for the post-installation hook, which runs after an
@@ -216,6 +251,7 @@ pub struct PostNotificationHookInfo {
pub cert_fingerprint: Option<String>,
}
+#[cfg_attr(feature = "api-types", api)]
#[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.
@@ -227,6 +263,7 @@ pub enum FirstBootHookSourceMode {
FromIso,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Default, Deserialize, Debug, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Possible orderings for the `proxmox-first-boot` systemd service.
@@ -256,6 +293,7 @@ impl FirstBootHookServiceOrdering {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[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
@@ -328,6 +366,13 @@ pub enum NetworkConfig {
FromAnswer(NetworkConfigFromAnswer),
}
+#[cfg_attr(feature = "api-types", api(
+ "id-property": "filesystem",
+ "id-schema": {
+ type: String,
+ description: "filesystem name",
+ },
+))]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", tag = "filesystem")]
/// Filesystem-specific options to set on the root disk.
@@ -365,6 +410,7 @@ impl Display for DiskSelection {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[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
@@ -486,6 +532,7 @@ impl DiskSetup {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
/// Available filesystem during installation.
@@ -500,6 +547,34 @@ pub enum Filesystem {
Btrfs,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ ashift: {
+ type: Integer,
+ minimum: 9,
+ maximum: 16,
+ default: 12,
+ optional: true,
+ },
+ "arc-max": {
+ type: Integer,
+ // ZFS specifies 64 MiB as the absolute minimum.
+ minimum: 64,
+ optional: true,
+ },
+ copies: {
+ type: Integer,
+ minimum: 1,
+ maximum: 3,
+ optional: true,
+ },
+ hdsize: {
+ type: Number,
+ minimum: 2.,
+ optional: true,
+ },
+ },
+))]
#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// ZFS-specific filesystem options.
@@ -530,6 +605,35 @@ pub struct ZfsOptions {
pub hdsize: Option<f64>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ hdsize: {
+ type: Number,
+ minimum: 2.,
+ optional: true,
+ },
+ swapsize: {
+ type: Number,
+ minimum: 0.,
+ optional: true,
+ },
+ maxroot: {
+ type: Number,
+ minimum: 2.,
+ optional: true,
+ },
+ maxvz: {
+ type: Number,
+ minimum: 0.,
+ optional: true,
+ },
+ minfree: {
+ type: Number,
+ minimum: 0.,
+ optional: true,
+ },
+ },
+))]
#[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.
@@ -557,6 +661,14 @@ pub struct LvmOptions {
pub minfree: Option<f64>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ hdsize: {
+ minimum: 2.,
+ optional: true,
+ },
+ },
+))]
#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Btrfs-specific filesystem options.
@@ -573,6 +685,7 @@ pub struct BtrfsOptions {
pub compress: Option<BtrfsCompressOption>,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Deserialize, Serialize, Debug, Default, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Keyboard layout of the system.
@@ -664,6 +777,7 @@ impl Display for KeyboardLayout {
serde_plain::derive_fromstr_from_deserialize!(KeyboardLayout);
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))]
/// Available Btrfs RAID levels.
@@ -681,6 +795,7 @@ pub enum BtrfsRaidLevel {
serde_plain::derive_display_from_serialize!(BtrfsRaidLevel);
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
/// Possible compression algorithms usable with Btrfs. See the accompanying
@@ -708,6 +823,7 @@ pub const BTRFS_COMPRESS_OPTIONS: &[BtrfsCompressOption] = {
&[On, Off, Zlib, Lzo, Zstd]
};
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
/// Available ZFS RAID levels.
@@ -734,6 +850,7 @@ pub enum ZfsRaidLevel {
serde_plain::derive_display_from_serialize!(ZfsRaidLevel);
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
/// Possible compression algorithms usable with ZFS.
@@ -764,6 +881,7 @@ pub const ZFS_COMPRESS_OPTIONS: &[ZfsCompressOption] = {
&[On, Off, Lzjb, Lz4, Zle, Gzip, Zstd]
};
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
/// Possible checksum algorithms usable with ZFS.
@@ -787,6 +905,7 @@ pub const ZFS_CHECKSUM_OPTIONS: &[ZfsChecksumOption] = {
};
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
+#[cfg_attr(feature = "api-types", derive(Updater, UpdaterType))]
/// The filesystem to use for the installation.
pub enum FilesystemType {
#[default]
diff --git a/proxmox-installer-types/src/lib.rs b/proxmox-installer-types/src/lib.rs
index 12679bdc..f39d05d3 100644
--- a/proxmox-installer-types/src/lib.rs
+++ b/proxmox-installer-types/src/lib.rs
@@ -10,6 +10,9 @@
pub mod answer;
pub mod post_hook;
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashMap},
@@ -21,6 +24,7 @@ use proxmox_network_types::mac_address::MacAddress;
/// Default placeholder value for the administrator email address.
pub const EMAIL_DEFAULT_PLACEHOLDER: &str = "mail@example.invalid";
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Eq, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
/// Whether the system boots using legacy BIOS or (U)EFI.
@@ -43,6 +47,16 @@ pub struct UdevInfo {
pub nics: BTreeMap<String, UdevProperties>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ network_interfaces: {
+ type: Array,
+ items: {
+ type: NetworkInterface,
+ },
+ },
+ },
+))]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// Information about the hardware and installer in use.
pub struct SystemInfo {
@@ -56,6 +70,7 @@ pub struct SystemInfo {
pub network_interfaces: Vec<NetworkInterface>,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// The per-product configuration of the installer.
pub struct ProductConfig {
@@ -78,6 +93,7 @@ impl ProductConfig {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// Information about the ISO itself.
pub struct IsoInfo {
@@ -97,6 +113,25 @@ impl IsoInfo {
}
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ baseboard: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ },
+ chassis: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ },
+ system: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ },
+ },
+))]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// Collection of various DMI information categories.
pub struct SystemDMI {
@@ -108,6 +143,7 @@ pub struct SystemDMI {
pub system: HashMap<String, String>,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// A unique network interface.
pub struct NetworkInterface {
@@ -117,6 +153,7 @@ pub struct NetworkInterface {
pub mac: MacAddress,
}
+#[cfg_attr(feature = "api-types", api)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
diff --git a/proxmox-installer-types/src/post_hook.rs b/proxmox-installer-types/src/post_hook.rs
index 8fbe54f8..b97df954 100644
--- a/proxmox-installer-types/src/post_hook.rs
+++ b/proxmox-installer-types/src/post_hook.rs
@@ -3,12 +3,21 @@
use serde::{Deserialize, Serialize};
use proxmox_network_types::ip_address::Cidr;
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
use crate::{
answer::{FilesystemType, RebootMode},
BootType, IsoInfo, ProxmoxProduct, SystemDMI, UdevProperties,
};
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "secureboot": {
+ optional: true,
+ },
+ },
+))]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// Information about the system boot status.
pub struct BootInfo {
@@ -19,6 +28,7 @@ pub struct BootInfo {
secureboot: bool,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// Holds all the public keys for the different algorithms available.
pub struct SshPublicHostKeys {
@@ -30,6 +40,18 @@ pub struct SshPublicHostKeys {
pub rsa: String,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "udev-properties": {
+ type: Object,
+ additional_properties: true,
+ properties: {},
+ },
+ "is-bootdisk": {
+ optional: true,
+ },
+ },
+))]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
/// Holds information about a single disk in the system.
@@ -43,6 +65,21 @@ pub struct DiskInfo {
pub udev_properties: UdevProperties,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "udev-properties": {
+ type: Object,
+ additional_properties: true,
+ properties: {},
+ },
+ "is-management": {
+ optional: true,
+ },
+ "is-pinned": {
+ optional: true,
+ },
+ },
+))]
/// Holds information about the management network interface.
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
@@ -66,6 +103,7 @@ pub struct NetworkInterfaceInfo {
pub udev_properties: UdevProperties,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
/// Information about the installed product itself.
@@ -78,6 +116,7 @@ pub struct ProductInfo {
pub version: String,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// The current kernel version.
/// Aligns with the format as used by the `/nodes/<node>/status` API of each product.
@@ -92,6 +131,7 @@ pub struct KernelVersionInformation {
pub machine: String,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// Information about the CPU(s) installed in the system
pub struct CpuInfo {
@@ -109,6 +149,7 @@ pub struct CpuInfo {
pub sockets: u32,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
/// Metadata of the hook, such as schema version of the document.
@@ -135,6 +176,21 @@ impl Default for PostHookInfoSchema {
}
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ filesystem: {
+ type: String,
+ },
+ disks: {
+ type: Array,
+ items: { type: DiskInfo },
+ },
+ "network-interfaces": {
+ type: Array,
+ items: { type: NetworkInterfaceInfo },
+ }
+ },
+))]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
/// All data sent as request payload with the post-installation-webhook POST request.
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 08/14] api-types: add api types for auto-installer integration
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (6 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 07/14] installer-types: implement api type for all externally-used types Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 09/14] config: add auto-installer configuration module Christoph Heiss
` (6 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: 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 <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
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 abf0b74..5021531 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<String>,
+ /// Post-installation notification hook data, if available.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub post_hook_data: Option<PostHookInfo>,
+}
+
+/// 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<Self, Self::Err> {
+ 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<K: Into<String>, V: Into<String>>(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<FilterEntry>,
+
+ // 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<String>,
+ /// 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<String>,
+
+ // 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<Cidr>,
+ /// Gateway if not using DHCP.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ pub gateway: Option<IpAddr>,
+ /// 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<IpAddr>,
+ /// 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<FilterEntry>,
+ /// 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<FilesystemOptions>,
+
+ /// 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<String>,
+ /// 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<FilterEntry>,
+ /// 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<answer::FilterMatch>,
+
+ /// 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<String>,
+ /// 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<String>,
+}
+
+impl TryFrom<PreparedInstallationConfig> for answer::AutoInstallerConfig {
+ type Error = anyhow::Error;
+
+ fn try_from(conf: PreparedInstallationConfig) -> Result<Self> {
+ 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
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 09/14] config: add auto-installer configuration module
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (7 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 08/14] api-types: add api types for auto-installer integration Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 10/14] acl: wire up new /system/auto-installation acl path Christoph Heiss
` (5 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Provides some primitives for the auto-installer integration to save
state about
- individual installations (as plain JSON, as these aren't
configurations files) and
- prepared answer file configurations, as section config
The new files (including lock files) are placed under
`/etc/proxmox-datacenter-manager/autoinst`.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
lib/pdm-config/Cargo.toml | 2 +
lib/pdm-config/src/auto_install.rs | 85 ++++++++++++++++++++++++++++++
lib/pdm-config/src/lib.rs | 1 +
lib/pdm-config/src/setup.rs | 7 +++
4 files changed, 95 insertions(+)
create mode 100644 lib/pdm-config/src/auto_install.rs
diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml
index d39c2ad..c43579d 100644
--- a/lib/pdm-config/Cargo.toml
+++ b/lib/pdm-config/Cargo.toml
@@ -12,6 +12,7 @@ nix.workspace = true
once_cell.workspace = true
openssl.workspace = true
serde.workspace = true
+serde_json.workspace = true
proxmox-config-digest = { workspace = true, features = [ "openssl" ] }
proxmox-http = { workspace = true, features = [ "http-helpers" ] }
@@ -23,5 +24,6 @@ proxmox-shared-memory.workspace = true
proxmox-simple-config.workspace = true
proxmox-sys = { workspace = true, features = [ "acl", "crypt", "timer" ] }
proxmox-acme-api.workspace = true
+proxmox-serde.workspace = true
pdm-api-types.workspace = true
pdm-buildcfg.workspace = true
diff --git a/lib/pdm-config/src/auto_install.rs b/lib/pdm-config/src/auto_install.rs
new file mode 100644
index 0000000..0374a70
--- /dev/null
+++ b/lib/pdm-config/src/auto_install.rs
@@ -0,0 +1,85 @@
+//! Implements configuration for the auto-installer integration.
+
+use anyhow::Result;
+use proxmox_schema::ApiType;
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
+use std::{fs::File, sync::LazyLock};
+
+use pdm_api_types::{
+ auto_installer::{Installation, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA},
+ ConfigDigest,
+};
+use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use proxmox_sys::error::SysError;
+
+pub const CONFIG_PATH: &str = pdm_buildcfg::configdir!("/autoinst");
+
+static CONFIG: LazyLock<SectionConfig> = LazyLock::new(|| {
+ let mut config = SectionConfig::new(&PREPARED_INSTALL_CONFIG_ID_SCHEMA);
+
+ config.register_plugin(SectionConfigPlugin::new(
+ "prepared-answer".into(),
+ Some("id".into()),
+ PreparedInstallationConfig::API_SCHEMA.unwrap_object_schema(),
+ ));
+ config
+});
+
+const PREPARED_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/prepared.cfg");
+const PREPARED_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.prepared.lock");
+
+// Information about installations themselves are not configuration files and thus are saved
+// as JSON.
+const INSTALLATIONS_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/installations.json");
+const INSTALLATIONS_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.installations.lock");
+
+pub fn installations_read_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(INSTALLATIONS_LOCK_FILE, None, false)
+}
+
+pub fn installations_write_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(INSTALLATIONS_LOCK_FILE, None, true)
+}
+
+pub fn read_installations() -> Result<(Vec<Installation>, ConfigDigest)> {
+ let content: serde_json::Value = match File::open(INSTALLATIONS_CONF_FILE) {
+ Ok(file) => serde_json::from_reader(std::io::BufReader::new(file))?,
+ Err(ref err) if err.not_found() => serde_json::json!([]),
+ Err(err) => return Err(err.into()),
+ };
+
+ let digest = proxmox_serde::json::to_canonical_json(&content).map(ConfigDigest::from_slice)?;
+ let data = serde_json::from_value(content)?;
+
+ Ok((data, digest))
+}
+
+/// Write lock must be already held.
+pub fn save_installations(config: &[Installation]) -> Result<()> {
+ let raw = serde_json::to_string(&config)?;
+ replace_config(INSTALLATIONS_CONF_FILE, raw.as_bytes())
+}
+
+pub fn prepared_answers_read_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(PREPARED_LOCK_FILE, None, false)
+}
+
+pub fn prepared_answers_write_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(PREPARED_LOCK_FILE, None, true)
+}
+
+pub fn read_prepared_answers() -> Result<(SectionConfigData, ConfigDigest)> {
+ let content =
+ proxmox_sys::fs::file_read_optional_string(PREPARED_CONF_FILE)?.unwrap_or_default();
+
+ let digest = ConfigDigest::from_slice(content.as_bytes());
+ let data = CONFIG.parse(PREPARED_CONF_FILE, &content)?;
+
+ Ok((data, digest))
+}
+
+/// Write lock must be already held.
+pub fn save_prepared_answers(config: &SectionConfigData) -> Result<()> {
+ let raw = CONFIG.write(PREPARED_CONF_FILE, config)?;
+ replace_config(PREPARED_CONF_FILE, raw.as_bytes())
+}
diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs
index 4c49054..5b9bcca 100644
--- a/lib/pdm-config/src/lib.rs
+++ b/lib/pdm-config/src/lib.rs
@@ -2,6 +2,7 @@ use anyhow::{format_err, Error};
use nix::unistd::{Gid, Group, Uid, User};
pub use pdm_buildcfg::{BACKUP_GROUP_NAME, BACKUP_USER_NAME};
+pub mod auto_install;
pub mod certificate_config;
pub mod domains;
pub mod node;
diff --git a/lib/pdm-config/src/setup.rs b/lib/pdm-config/src/setup.rs
index 5f920c8..5adb05f 100644
--- a/lib/pdm-config/src/setup.rs
+++ b/lib/pdm-config/src/setup.rs
@@ -24,6 +24,13 @@ pub fn create_configdir() -> Result<(), Error> {
0o750,
)?;
+ mkdir_perms(
+ crate::auto_install::CONFIG_PATH,
+ api_user.uid,
+ api_user.gid,
+ 0o750,
+ )?;
+
Ok(())
}
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 10/14] acl: wire up new /system/auto-installation acl path
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (8 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 09/14] config: add auto-installer configuration module Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module Christoph Heiss
` (4 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
lib/pdm-api-types/src/acl.rs | 4 ++--
ui/src/configuration/permission_path_selector.rs | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/lib/pdm-api-types/src/acl.rs b/lib/pdm-api-types/src/acl.rs
index 405982a..50a9132 100644
--- a/lib/pdm-api-types/src/acl.rs
+++ b/lib/pdm-api-types/src/acl.rs
@@ -297,8 +297,8 @@ impl proxmox_access_control::init::AccessControlConfig for AccessControlConfig {
return Ok(());
}
match components[1] {
- "certificates" | "disks" | "log" | "notifications" | "status" | "tasks"
- | "time" => {
+ "auto-installation" | "certificates" | "disks" | "log" | "notifications"
+ | "status" | "tasks" | "time" => {
if components_len == 2 {
return Ok(());
}
diff --git a/ui/src/configuration/permission_path_selector.rs b/ui/src/configuration/permission_path_selector.rs
index 59bdabb..86b1ae6 100644
--- a/ui/src/configuration/permission_path_selector.rs
+++ b/ui/src/configuration/permission_path_selector.rs
@@ -14,6 +14,7 @@ static PREDEFINED_PATHS: &[&str] = &[
"/access/users",
"/resource",
"/system",
+ "/system/auto-installation",
"/system/certificates",
"/system/disks",
"/system/log",
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (9 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 10/14] acl: wire up new /system/auto-installation acl path Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 12/14] ui: auto-installer: add installations overview panel Christoph Heiss
` (3 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Adds the required API surface for managing prepared answer files and
viewing past installations via the UI, as well as serving answer files
to proxmox-auto-installer.
Short overview:
POST /auto-install/answer
serves answer files based on target filters, if any
GET /auto-install/installations
list all in-progress and past installations
POST /auto-install/installations/{id}/post-hook
endpoint for integrating the post-installation notification webhook
GET /auto-install/prepared
list all prepared answer file configurations
POST /auto-install/prepared
create a new prepared answer file configuration
DELETE /auto-install/prepared
delete an existing prepared answer file configuration
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
The auto-installer (currently) only supports TOML as input format for
the answer, so we need to "hack" around a bit to serve TOML in the API.
See also the cover letter for a bit more discussion.
Changes v1 -> v2:
* fixed compilation error due to leftover, unresolved type
Cargo.toml | 2 +
debian/control | 2 +
server/Cargo.toml | 4 +
server/src/api/auto_installer/mod.rs | 653 +++++++++++++++++++++++++++
server/src/api/mod.rs | 2 +
5 files changed, 663 insertions(+)
create mode 100644 server/src/api/auto_installer/mod.rs
diff --git a/Cargo.toml b/Cargo.toml
index 5021531..5893db5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -105,6 +105,7 @@ async-trait = "0.1"
bitflags = "2.4"
const_format = "0.2"
futures = "0.3"
+glob = "0.3"
h2 = { version = "0.4", features = [ "stream" ] }
handlebars = "5.1"
hex = "0.4.3"
@@ -131,6 +132,7 @@ tokio = "1.6"
tokio-openssl = "0.6.1"
tokio-stream = "0.1.0"
tokio-util = { version = "0.7", features = [ "io" ] }
+toml.version = "0.8"
tower-service = "0.3.0"
tracing = "0.1"
url = "2.1"
diff --git a/debian/control b/debian/control
index b1ce92a..59d5f63 100644
--- a/debian/control
+++ b/debian/control
@@ -17,6 +17,7 @@ Build-Depends: debhelper-compat (= 13),
librust-async-trait-0.1+default-dev,
librust-const-format-0.2+default-dev,
librust-futures-0.3+default-dev,
+ librust-glob-0.3-dev,
librust-hex-0.4+default-dev (>= 0.4.3-~~),
librust-http-1+default-dev,
librust-http-body-util-0.1+default-dev (>= 0.1.2-~~),
@@ -130,6 +131,7 @@ Build-Depends: debhelper-compat (= 13),
librust-tokio-1+signal-dev (>= 1.6-~~),
librust-tokio-1+time-dev (>= 1.6-~~),
librust-tokio-stream-0.1+default-dev,
+ librust-toml-0.8+default-dev,
librust-tracing-0.1+default-dev,
librust-url-2+default-dev (>= 2.1-~~),
librust-webauthn-rs-core-0.5+default-dev,
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 6969549..4501a15 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -14,6 +14,7 @@ async-stream.workspace = true
async-trait.workspace = true
const_format.workspace = true
futures.workspace = true
+glob.workspace = true
hex.workspace = true
http.workspace = true
http-body-util.workspace = true
@@ -31,6 +32,7 @@ serde_plain.workspace = true
syslog.workspace = true
tokio = { workspace = true, features = [ "fs", "io-util", "io-std", "macros", "net", "parking_lot", "process", "rt", "rt-multi-thread", "signal", "time" ] }
tokio-stream.workspace = true
+toml.workspace = true
tracing.workspace = true
url.workspace = true
zstd.workspace = true
@@ -42,10 +44,12 @@ proxmox-base64.workspace = true
proxmox-daemon.workspace = true
proxmox-docgen.workspace = true
proxmox-http = { workspace = true, features = [ "client-trait", "proxmox-async" ] } # pbs-client doesn't use these
+proxmox-installer-types.workspace = true
proxmox-lang.workspace = true
proxmox-ldap.workspace = true
proxmox-log.workspace = true
proxmox-login.workspace = true
+proxmox-network-types.workspace = true
proxmox-openid.workspace = true
proxmox-rest-server = { workspace = true, features = [ "templates" ] }
proxmox-router = { workspace = true, features = [ "cli", "server"] }
diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs
new file mode 100644
index 0000000..ec5752a
--- /dev/null
+++ b/server/src/api/auto_installer/mod.rs
@@ -0,0 +1,653 @@
+//! Implements all the methods under `/api2/*/access/auto-install/`.
+
+use anyhow::{anyhow, Result};
+use std::time::SystemTime;
+
+use pdm_api_types::{
+ auto_installer::{
+ Installation, InstallationStatus, PreparedInstallationConfig,
+ PreparedInstallationConfigDeletableProperty, PreparedInstallationConfigUpdater,
+ INSTALLATION_UUID_SCHEMA, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA,
+};
+use proxmox_installer_types::{
+ answer::{
+ fetch::AnswerFetchData, AutoInstallerConfig, PostNotificationHookInfo, ROOT_PASSWORD_SCHEMA,
+ },
+ post_hook::PostHookInfo,
+};
+use proxmox_router::{
+ http_bail, http_err, list_subdirs_api_method, ApiHandler, ApiMethod, ApiResponseFuture,
+ Permission, Router, RpcEnvironment, SubdirMap,
+};
+use proxmox_schema::{api, AllOfSchema, ApiType, ParameterSchema, ReturnType, StringSchema};
+use proxmox_sortable_macro::sortable;
+use proxmox_uuid::Uuid;
+
+#[sortable]
+const SUBDIR_INSTALLATION_PER_ID: SubdirMap = &sorted!([(
+ "post-hook",
+ &Router::new().post(&API_METHOD_HANDLE_POST_HOOK)
+)]);
+
+#[sortable]
+const SUBDIRS: SubdirMap = &sorted!([
+ ("answer", &Router::new().post(&API_METHOD_NEW_INSTALLATION)),
+ (
+ "prepared",
+ &Router::new()
+ .get(&API_METHOD_LIST_PREPARED)
+ .post(&API_METHOD_CREATE_PREPARED)
+ .match_all(
+ "id",
+ &Router::new()
+ .get(&API_METHOD_GET_PREPARED)
+ .put(&API_METHOD_UPDATE_PREPARED)
+ .delete(&API_METHOD_DELETE_PREPARED)
+ )
+ ),
+ (
+ "installations",
+ &Router::new().get(&API_METHOD_LIST_INSTALLATIONS).match_all(
+ "uuid",
+ &Router::new()
+ .delete(&API_METHOD_DELETE_INSTALLATION)
+ .subdirs(SUBDIR_INSTALLATION_PER_ID)
+ )
+ ),
+]);
+
+pub const ROUTER: Router = Router::new()
+ .get(&list_subdirs_api_method!(SUBDIRS))
+ .subdirs(SUBDIRS);
+
+const API_METHOD_NEW_INSTALLATION: ApiMethod = ApiMethod::new_full(
+ &ApiHandler::AsyncHttpBodyParameters(&api_function_new_installation),
+ ParameterSchema::AllOf(&AllOfSchema::new(
+ r#"\
+ Handles the system information of a new machine to install.
+
+ See also
+ <https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP>"#,
+ &[&<AnswerFetchData as ApiType>::API_SCHEMA],
+ )),
+)
+.returns(ReturnType::new(
+ false,
+ &StringSchema::new(
+ "either a auto-installation configuration or a request to wait, in JSON format",
+ )
+ .schema(),
+))
+.access(
+ Some("proxmox-fetch-answer only sends unauthenticated requests."),
+ &Permission::World,
+)
+.protected(false);
+
+fn api_function_new_installation(
+ _parts: http::request::Parts,
+ param: serde_json::Value,
+ _info: &ApiMethod,
+ _rpcenv: Box<dyn RpcEnvironment>,
+) -> ApiResponseFuture {
+ Box::pin(async move {
+ let response = serde_json::from_value::<AnswerFetchData>(param)
+ .map_err(|err| anyhow!("failed to deserialize body: {err:?}"))
+ .and_then(new_installation)
+ .and_then(|x| {
+ toml::to_string(&x).map_err(|err| anyhow!("failed to deserialize body: {err:?}"))
+ });
+
+ match response {
+ Ok(value) => Ok(http::Response::builder()
+ .status(200)
+ .header(http::header::CONTENT_TYPE, "application/toml")
+ .body(value.into())?),
+ Err(err) => Ok(http::Response::builder()
+ .status(401)
+ .header(http::header::CONTENT_TYPE, "text/plain")
+ .body(format!("{err:?}").into())?),
+ }
+ })
+}
+
+/// POST /auto-install/answer
+///
+/// Either returns a auto-installer configuration if a matching one is found or errors out.
+/// The system information data is saved in any case to make them easily inspectable.
+fn new_installation(payload: AnswerFetchData) -> Result<AutoInstallerConfig> {
+ let _lock = pdm_config::auto_install::installations_write_lock();
+
+ let uuid = Uuid::generate();
+ let (mut installations, _) = pdm_config::auto_install::read_installations()?;
+
+ if installations.iter().any(|p| p.uuid == uuid) {
+ http_bail!(CONFLICT, "already exists");
+ }
+
+ let timestamp_now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)?
+ .as_secs() as i64;
+
+ if let Some(config) = find_config(&payload.sysinfo)? {
+ let status = if config.post_hook_base_url.is_some() {
+ InstallationStatus::InProgress
+ } else {
+ InstallationStatus::Finished
+ };
+
+ installations.push(Installation {
+ uuid: uuid.clone(),
+ received_at: timestamp_now,
+ status,
+ info: payload.sysinfo,
+ answer_id: Some(config.id.clone()),
+ post_hook_data: None,
+ });
+
+ // "Inject" our custom post hook if possible
+ let mut answer: AutoInstallerConfig = config.clone().try_into()?;
+ if let Some(base_url) = config.post_hook_base_url {
+ answer.post_installation_webhook = Some(PostNotificationHookInfo {
+ url: format!("{base_url}/auto-install/installations/{uuid}/post-hook"),
+ cert_fingerprint: config.post_hook_cert_fp.clone(),
+ });
+ }
+
+ pdm_config::auto_install::save_installations(&installations)?;
+ Ok(answer)
+ } else {
+ installations.push(Installation {
+ uuid: uuid.clone(),
+ received_at: timestamp_now,
+ status: InstallationStatus::NoAnswerFound,
+ info: payload.sysinfo,
+ answer_id: None,
+ post_hook_data: None,
+ });
+
+ pdm_config::auto_install::save_installations(&installations)?;
+ http_bail!(NOT_FOUND, "no answer file found");
+ }
+}
+
+#[api(
+ returns: {
+ description: "List of all automated installations.",
+ type: Array,
+ items: { type: Installation },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/installations
+///
+/// Get all automated installations.
+fn list_installations(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<Installation>> {
+ let _lock = pdm_config::auto_install::installations_read_lock();
+
+ let (config, digest) = pdm_config::auto_install::read_installations()?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+ Ok(config)
+}
+
+#[api(
+ input: {
+ properties: {
+ uuid: {
+ schema: INSTALLATION_UUID_SCHEMA,
+ }
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// DELETE /auto-install/installations/{uuid}
+///
+/// Remove an installation entry.
+fn delete_installation(uuid: Uuid) -> Result<()> {
+ let _lock = pdm_config::auto_install::installations_write_lock();
+
+ let (mut installations, _) = pdm_config::auto_install::read_installations()?;
+ if installations
+ .extract_if(.., |inst| inst.uuid == uuid)
+ .count()
+ == 0
+ {
+ http_bail!(NOT_FOUND, "no such entry {uuid:?}");
+ }
+
+ pdm_config::auto_install::save_installations(&installations)
+}
+
+#[api(
+ returns: {
+ description: "List of prepared auto-installer answer configurations.",
+ type: Array,
+ items: { type: PreparedInstallationConfig },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/prepared
+///
+/// Get all prepared auto-installer answer configurations.
+async fn list_prepared(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PreparedInstallationConfig>> {
+ let (prepared, digest) = pdm_config::auto_install::read_prepared_answers()?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+
+ Ok(prepared
+ .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+ .into_iter()
+ .map(|mut p| {
+ p.root_password_hashed = None;
+ p
+ })
+ .collect())
+}
+
+#[api(
+ input: {
+ properties: {
+ config: {
+ type: PreparedInstallationConfig,
+ flatten: true,
+ },
+ "root-password": {
+ schema: ROOT_PASSWORD_SCHEMA,
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// POST /auto-install/prepared
+///
+/// Creates a new prepared answer file.
+async fn create_prepared(
+ mut config: PreparedInstallationConfig,
+ root_password: Option<String>,
+) -> Result<()> {
+ let _lock = pdm_config::auto_install::prepared_answers_write_lock();
+ let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ if prepared
+ .lookup::<PreparedInstallationConfig>("prepared-answer", &config.id)
+ .is_ok()
+ {
+ http_bail!(
+ CONFLICT,
+ "configuration with ID {} already exists",
+ config.id
+ );
+ }
+
+ if config.is_default {
+ if let Some(p) = prepared
+ .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+ .iter()
+ .find(|p| p.is_default)
+ {
+ http_bail!(
+ CONFLICT,
+ "configuration '{}' is already the default answer",
+ p.id
+ );
+ }
+ }
+
+ if let Some(password) = root_password {
+ config.root_password_hashed = Some(proxmox_sys::crypt::encrypt_pw(&password)?);
+ } else if config.root_password_hashed.is_none() {
+ http_bail!(
+ BAD_REQUEST,
+ "either `root-password` or `root-password-hashed` must be set"
+ );
+ }
+
+ prepared.set_data(&config.id.clone(), "prepared-answer", config)?;
+ pdm_config::auto_install::save_prepared_answers(&prepared)?;
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/prepared/{id}
+///
+/// Retrieves a prepared auto-installer answer configuration.
+async fn get_prepared(id: String) -> Result<PreparedInstallationConfig> {
+ let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ if let Ok(mut p) = prepared.lookup::<PreparedInstallationConfig>("prepared-answer", &id) {
+ // Don't send the hashed password, the user cannot do anything with it anyway
+ p.root_password_hashed = None;
+ Ok(p)
+ } else {
+ http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}");
+ }
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ update: {
+ type: PreparedInstallationConfigUpdater,
+ flatten: true,
+ },
+ "root-password": {
+ schema: ROOT_PASSWORD_SCHEMA,
+ optional: true,
+ },
+ delete: {
+ description: "List of properties to delete.",
+ type: Array,
+ optional: true,
+ items: {
+ type: PreparedInstallationConfigDeletableProperty,
+ }
+ },
+ digest: {
+ optional: true,
+ schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// PUT /auto-install/prepared/{id}
+///
+/// Updates a prepared auto-installer answer configuration.
+async fn update_prepared(
+ id: String,
+ update: PreparedInstallationConfigUpdater,
+ root_password: Option<String>,
+ delete: Option<Vec<PreparedInstallationConfigDeletableProperty>>,
+ digest: Option<ConfigDigest>,
+) -> Result<()> {
+ let _lock = pdm_config::auto_install::prepared_answers_write_lock();
+
+ let (mut prepared, config_digest) = pdm_config::auto_install::read_prepared_answers()?;
+ config_digest.detect_modification(digest.as_ref())?;
+
+ let mut p = prepared
+ .lookup::<PreparedInstallationConfig>("prepared-answer", &id)
+ .map_err(|_| http_err!(NOT_FOUND, "no such prepared answer configuration: {id}"))?;
+
+ if let Some(delete) = delete {
+ for prop in delete {
+ match prop {
+ PreparedInstallationConfigDeletableProperty::TargetFilter => {
+ p.target_filter = Vec::new();
+ }
+ PreparedInstallationConfigDeletableProperty::NetdevFilter => {
+ p.netdev_filter = Vec::new();
+ }
+ PreparedInstallationConfigDeletableProperty::DiskFilter => {
+ p.disk_filter = Vec::new()
+ }
+ PreparedInstallationConfigDeletableProperty::RootSshKeys => {
+ p.root_ssh_keys = Vec::new();
+ }
+ PreparedInstallationConfigDeletableProperty::PostHookBaseUrl => {
+ p.post_hook_base_url = None;
+ }
+ PreparedInstallationConfigDeletableProperty::PostHookCertFp => {
+ p.post_hook_cert_fp = None;
+ }
+ }
+ }
+ }
+
+ if let Some(target_filter) = update.target_filter {
+ p.target_filter = target_filter;
+ }
+
+ if let Some(is_default) = update.is_default {
+ if is_default {
+ if let Some(other) = prepared
+ .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+ .iter()
+ .find(|p| p.is_default)
+ {
+ http_bail!(
+ CONFLICT,
+ "configuration '{}' is already the default answer",
+ other.id
+ );
+ }
+ }
+
+ p.is_default = is_default;
+ }
+
+ if let Some(country) = update.country {
+ p.country = country;
+ }
+
+ if let Some(fqdn) = update.fqdn {
+ p.fqdn = fqdn;
+ }
+
+ if let Some(use_dhcp) = update.use_dhcp_fqdn {
+ p.use_dhcp_fqdn = use_dhcp;
+ }
+
+ if let Some(keyboard) = update.keyboard {
+ p.keyboard = keyboard;
+ }
+
+ if let Some(mailto) = update.mailto {
+ p.mailto = mailto;
+ }
+
+ if let Some(timezone) = update.timezone {
+ p.timezone = timezone;
+ }
+
+ if let Some(password) = root_password {
+ p.root_password_hashed = Some(proxmox_sys::crypt::encrypt_pw(&password)?);
+ } else if let Some(password) = update.root_password_hashed {
+ p.root_password_hashed = Some(password);
+ }
+
+ if let Some(reboot_on_error) = update.reboot_on_error {
+ p.reboot_on_error = reboot_on_error;
+ }
+
+ if let Some(reboot_mode) = update.reboot_mode {
+ p.reboot_mode = reboot_mode;
+ }
+
+ if let Some(ssh_keys) = update.root_ssh_keys {
+ p.root_ssh_keys = if ssh_keys.is_empty() {
+ Vec::new()
+ } else {
+ ssh_keys
+ };
+ }
+
+ if let Some(use_dhcp) = update.use_dhcp_network {
+ p.use_dhcp_network = use_dhcp;
+ }
+
+ if let Some(cidr) = update.cidr {
+ p.cidr = Some(cidr);
+ }
+
+ if let Some(gateway) = update.gateway {
+ p.gateway = Some(gateway);
+ }
+
+ if let Some(dns) = update.dns {
+ p.dns = Some(dns);
+ }
+
+ if let Some(filter) = update.netdev_filter {
+ p.netdev_filter = filter;
+ }
+
+ if let Some(enabled) = update.netif_name_pinning_enabled {
+ p.netif_name_pinning_enabled = enabled;
+ }
+
+ if let Some(typ) = update.filesystem_type {
+ p.filesystem_type = typ;
+ }
+
+ if let Some(options) = update.filesystem_options {
+ p.filesystem_options = options;
+ }
+
+ if let Some(mode) = update.disk_mode {
+ p.disk_mode = mode;
+ }
+
+ if let Some(list) = update.disk_list {
+ p.disk_list = if list.is_empty() { None } else { Some(list) };
+ }
+
+ if let Some(filter) = update.disk_filter {
+ p.disk_filter = filter;
+ }
+
+ if let Some(filter_match) = update.disk_filter_match {
+ p.disk_filter_match = Some(filter_match);
+ }
+
+ if let Some(url) = update.post_hook_base_url {
+ p.post_hook_base_url = Some(url);
+ }
+
+ if let Some(fp) = update.post_hook_cert_fp {
+ p.post_hook_cert_fp = Some(fp);
+ }
+
+ prepared.set_data(&id, "prepared-answer", p)?;
+ pdm_config::auto_install::save_prepared_answers(&prepared)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// DELETE /auto-install/prepared/{id}
+///
+/// Deletes a prepared auto-installer answer configuration.
+async fn delete_prepared(id: String) -> Result<()> {
+ let _lock = pdm_config::auto_install::prepared_answers_write_lock();
+
+ let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+ if prepared.sections.remove(&id).is_none() {
+ http_bail!(NOT_FOUND, "no such entry '{id:?}'");
+ }
+
+ pdm_config::auto_install::save_prepared_answers(&prepared)
+}
+
+#[api(
+ input: {
+ properties: {
+ uuid: {
+ schema: INSTALLATION_UUID_SCHEMA,
+ },
+ info: {
+ type: PostHookInfo,
+ flatten: true,
+ }
+ },
+ },
+ access: {
+ permission: &Permission::World,
+ },
+)]
+/// POST /auto-install/installations/{uuid}/post-hook
+///
+/// Handles the post-installation hook for all installations.
+async fn handle_post_hook(uuid: Uuid, info: PostHookInfo) -> Result<()> {
+ let _lock = pdm_config::auto_install::installations_write_lock();
+ let (mut installations, _) = pdm_config::auto_install::read_installations()?;
+
+ if let Some(install) = installations.iter_mut().find(|p| p.uuid == uuid) {
+ install.status = InstallationStatus::Finished;
+ install.post_hook_data = Some(info);
+ pdm_config::auto_install::save_installations(&installations)?;
+ } else {
+ http_bail!(NOT_FOUND, "installation {uuid} not found");
+ }
+
+ Ok(())
+}
+
+fn find_config(
+ info: &proxmox_installer_types::SystemInfo,
+) -> Result<Option<PreparedInstallationConfig>> {
+ let info = serde_json::to_value(info)?;
+ let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ let mut default_answer = None;
+ for p in prepared.convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")? {
+ if p.is_default {
+ default_answer = Some(p.clone());
+ continue;
+ }
+
+ if p.target_filter.is_empty() {
+ // Not default answer and empty target filter, can never match
+ continue;
+ }
+
+ let matched_all = p.target_filter.iter().all(|filter| {
+ let pattern = match glob::Pattern::new(&filter.value) {
+ Ok(pattern) => pattern,
+ _ => return false,
+ };
+
+ if let Some(value) = info.pointer(&filter.key).and_then(|v| v.as_str()) {
+ pattern.matches(value)
+ } else {
+ false
+ }
+ });
+
+ if matched_all {
+ return Ok(Some(p.clone()));
+ }
+ }
+
+ // If no specific target filter(s) matched, return the default answer, if there is one
+ Ok(default_answer)
+}
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 5688871..0fa26da 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -9,6 +9,7 @@ use proxmox_schema::api;
use proxmox_sortable_macro::sortable;
pub mod access;
+pub mod auto_installer;
pub mod config;
pub mod metric_collection;
pub mod nodes;
@@ -25,6 +26,7 @@ pub mod sdn;
#[sortable]
const SUBDIRS: SubdirMap = &sorted!([
("access", &access::ROUTER),
+ ("auto-install", &auto_installer::ROUTER),
("config", &config::ROUTER),
("ping", &Router::new().get(&API_METHOD_PING)),
("pve", &pve::ROUTER),
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 12/14] ui: auto-installer: add installations overview panel
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (10 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
` (2 subsequent siblings)
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
A simple overview panel with a list of in-progress and on-going installations.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
lib/pdm-api-types/src/lib.rs | 2 +-
ui/src/auto_installer/installations_panel.rs | 289 +++++++++++++++++++
ui/src/auto_installer/mod.rs | 4 +
ui/src/lib.rs | 2 +
ui/src/main_menu.rs | 13 +
5 files changed, 309 insertions(+), 1 deletion(-)
create mode 100644 ui/src/auto_installer/installations_panel.rs
create mode 100644 ui/src/auto_installer/mod.rs
diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs
index 98139f0..84c38ce 100644
--- a/lib/pdm-api-types/src/lib.rs
+++ b/lib/pdm-api-types/src/lib.rs
@@ -114,8 +114,8 @@ pub mod subscription;
pub mod sdn;
-pub mod views;
pub mod auto_installer;
+pub mod views;
const_regex! {
// just a rough check - dummy acceptor is used before persisting
diff --git a/ui/src/auto_installer/installations_panel.rs b/ui/src/auto_installer/installations_panel.rs
new file mode 100644
index 0000000..9c57bad
--- /dev/null
+++ b/ui/src/auto_installer/installations_panel.rs
@@ -0,0 +1,289 @@
+//! Implements the UI components for displaying an overview view of all finished/in-progress
+//! installations.
+
+use anyhow::{anyhow, Result};
+use std::{future::Future, pin::Pin, rc::Rc};
+
+use pdm_api_types::auto_installer::{Installation, InstallationStatus};
+use proxmox_installer_types::{post_hook::PostHookInfo, SystemInfo};
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, ConfirmButton, DataViewWindow, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::{
+ css::{Flex, Overflow},
+ props::{
+ ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder,
+ WidgetStyleBuilder,
+ },
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent},
+ form::TextArea,
+ Button, Toolbar,
+ },
+};
+use yew::{
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AutoInstallerPanel {}
+
+impl From<AutoInstallerPanel> for VNode {
+ fn from(value: AutoInstallerPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<AutoInstallerPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+pub enum Message {
+ Refresh,
+ SelectionChange,
+ RemoveEntry,
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ ShowRawSystemInfo,
+ ShowRawPostHookData,
+}
+
+#[derive(PartialEq, Properties)]
+pub struct AutoInstallerPanelComponent {
+ selection: Selection,
+ store: Store<Installation>,
+ columns: Rc<Vec<DataTableHeader<Installation>>>,
+}
+
+impl LoadableComponent for AutoInstallerPanelComponent {
+ type Properties = AutoInstallerPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let selection =
+ Selection::new().on_select(ctx.link().callback(|_| Message::SelectionChange));
+
+ let store =
+ Store::with_extract_key(|record: &Installation| Key::from(record.uuid.to_string()));
+ store.set_sorter(|a: &Installation, b: &Installation| a.received_at.cmp(&b.received_at));
+
+ Self {
+ selection,
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = proxmox_yew_comp::http_get("/auto-install/installations", None).await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::Refresh => {
+ ctx.link().send_reload();
+ false
+ }
+ Self::Message::SelectionChange => true,
+ Self::Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ let link = ctx.link();
+ link.clone().spawn(async move {
+ if let Err(err) = delete_entry(key).await {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link();
+
+ let selection_has_post_hook_data = self
+ .selection
+ .selected_key()
+ .and_then(|key| {
+ self.store
+ .read()
+ .lookup_record(&key)
+ .map(|data| data.post_hook_data.is_some())
+ })
+ .unwrap_or(false);
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Raw system information"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::ShowRawSystemInfo))),
+ )
+ .with_child(
+ Button::new(tr!("Post-installation webhook data"))
+ .disabled(self.selection.is_empty() || !selection_has_post_hook_data)
+ .onclick(link.change_view_callback(|_| Some(ViewState::ShowRawPostHookData))),
+ )
+ .with_spacer()
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ )
+ .with_flex_spacer()
+ .with_child(
+ Button::refresh(ctx.loading()).onclick(ctx.link().callback(|_| Message::Refresh)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick({
+ let link = ctx.link();
+ move |_: &mut DataTableMouseEvent| {
+ link.change_view(Some(Self::ViewState::ShowRawSystemInfo));
+ }
+ })
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ let on_done = ctx.link().clone().change_view_callback(|_| None);
+
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ Some(match view_state {
+ Self::ViewState::ShowRawSystemInfo => {
+ DataViewWindow::new(tr!("Raw system information"))
+ .on_done(on_done)
+ .loader({
+ move || {
+ let info = record.info.clone();
+ async move { Ok(info) }
+ }
+ })
+ .renderer(|data: &SystemInfo| -> yew::Html {
+ let value = serde_json::to_string_pretty(data)
+ .unwrap_or_else(|_| "<failed to decode>".to_owned());
+ render_raw_info_container(value)
+ })
+ .resizable(true)
+ .into()
+ }
+ Self::ViewState::ShowRawPostHookData => {
+ DataViewWindow::new(tr!("Raw post-installation webhook data"))
+ .on_done(on_done)
+ .loader({
+ move || {
+ let data = record.post_hook_data.clone();
+ async move {
+ data.ok_or_else(|| anyhow!("no post-installation webhook data"))
+ }
+ }
+ })
+ .renderer(|data: &PostHookInfo| -> yew::Html {
+ let value = serde_json::to_string_pretty(data)
+ .unwrap_or_else(|_| "<failed to decode>".to_owned());
+ render_raw_info_container(value)
+ })
+ .resizable(true)
+ .into()
+ }
+ })
+ }
+}
+
+async fn delete_entry(key: Key) -> Result<()> {
+ let url = format!(
+ "/auto-install/installations/{}",
+ percent_encode_component(&key.to_string())
+ );
+ proxmox_yew_comp::http_delete(&url, None).await
+}
+
+fn render_raw_info_container(value: String) -> yew::Html {
+ pwt::widget::Container::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_child(
+ TextArea::new()
+ .width("800px")
+ .read_only(true)
+ .attribute("rows", "40")
+ .value(value),
+ )
+ .into()
+}
+
+fn columns() -> Vec<DataTableHeader<Installation>> {
+ vec![
+ DataTableColumn::new(tr!("Received"))
+ .width("170px")
+ .render(|item: &Installation| {
+ proxmox_yew_comp::utils::render_epoch(item.received_at).into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Product"))
+ .width("300px")
+ .render(|item: &Installation| {
+ format!(
+ "{} {}-{}",
+ item.info.product.fullname, item.info.iso.release, item.info.iso.isorelease
+ )
+ .into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .width("200px")
+ .render(|item: &Installation| {
+ match item.status {
+ InstallationStatus::AnswerSent => tr!("Answer sent"),
+ InstallationStatus::NoAnswerFound => tr!("No matching answer found"),
+ InstallationStatus::InProgress => tr!("In Progress"),
+ InstallationStatus::Finished => tr!("Finished"),
+ }
+ .into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Matched answer"))
+ .flex(1)
+ .render(|item: &Installation| match &item.answer_id {
+ Some(s) => s.into(),
+ None => "-".into(),
+ })
+ .into(),
+ ]
+}
diff --git a/ui/src/auto_installer/mod.rs b/ui/src/auto_installer/mod.rs
new file mode 100644
index 0000000..810eade
--- /dev/null
+++ b/ui/src/auto_installer/mod.rs
@@ -0,0 +1,4 @@
+//! Implements the UI for the proxmox-auto-installer integration.
+
+mod installations_panel;
+pub use installations_panel::*;
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 1aac757..e5a8826 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -59,6 +59,8 @@ pub use tasks::register_pve_tasks;
mod view_list_context;
pub use view_list_context::ViewListContext;
+mod auto_installer;
+
pub fn pdm_client() -> pdm_client::PdmClient<std::rc::Rc<proxmox_yew_comp::HttpClientWasm>> {
pdm_client::PdmClient(proxmox_yew_comp::CLIENT.with(|c| std::rc::Rc::clone(&c.borrow())))
}
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 18988ea..073b84d 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -14,6 +14,7 @@ use proxmox_yew_comp::{AclContext, NotesView, XTermJs};
use pdm_api_types::remotes::RemoteType;
use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
+use crate::auto_installer::AutoInstallerPanel;
use crate::configuration::subscription_panel::SubscriptionPanel;
use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
@@ -378,6 +379,18 @@ impl Component for PdmMainMenu {
remote_submenu,
);
+ let mut autoinstaller_submenu = Menu::new();
+
+ register_submenu(
+ &mut menu,
+ &mut content,
+ tr!("Automated Installations"),
+ "auto-installer",
+ Some("fa fa-cubes"),
+ |_| AutoInstallerPanel::default().into(),
+ autoinstaller_submenu,
+ );
+
let drawer = NavigationDrawer::new(menu)
.aria_label("Datacenter Manager")
.class("pwt-border-end")
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (11 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 12/14] ui: auto-installer: add installations overview panel Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 14/14] docs: add documentation for auto-installer integration Christoph Heiss
2025-12-05 11:53 ` [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Thomas Lamprecht
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Adds a pretty typical CRUD panel, allowing users to add/edit/remove
prepared answer file configurations for the auto-install server.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* no changes
ui/Cargo.toml | 4 +
ui/src/auto_installer/add_wizard.rs | 142 +++
ui/src/auto_installer/answer_form.rs | 849 ++++++++++++++++++
ui/src/auto_installer/edit_window.rs | 105 +++
ui/src/auto_installer/mod.rs | 11 +
.../auto_installer/prepared_answers_panel.rs | 233 +++++
ui/src/main_menu.rs | 11 +-
7 files changed, 1354 insertions(+), 1 deletion(-)
create mode 100644 ui/src/auto_installer/add_wizard.rs
create mode 100644 ui/src/auto_installer/answer_form.rs
create mode 100644 ui/src/auto_installer/edit_window.rs
create mode 100644 ui/src/auto_installer/prepared_answers_panel.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 2b4713c..4ba38c5 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -42,6 +42,8 @@ proxmox-schema = "5"
proxmox-subscription = { version = "1.0.1", features = ["api-types"], default-features = false }
proxmox-rrd-api-types = "1"
proxmox-node-status = "1"
+proxmox-network-types = "0.1"
+proxmox-installer-types = "0.1"
pbs-api-types = { version = "1.0.3", features = [ "enum-fallback" ] }
pdm-api-types = { version = "1.0", path = "../lib/pdm-api-types" }
@@ -54,6 +56,8 @@ pdm-search = { version = "0.2", path = "../lib/pdm-search" }
[patch.crates-io]
# proxmox-client = { path = "../../proxmox/proxmox-client" }
# proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" }
+# proxmox-network-types = { path = "../proxmox/proxmox-network-types" }
+# proxmox-installer-types = { path = "../proxmox/proxmox-installer-types" }
# proxmox-login = { path = "../../proxmox/proxmox-login" }
# proxmox-rrd-api-types = { path = "../../proxmox/proxmox-rrd-api-types" }
# proxmox-schema = { path = "../../proxmox/proxmox-schema" }
diff --git a/ui/src/auto_installer/add_wizard.rs b/ui/src/auto_installer/add_wizard.rs
new file mode 100644
index 0000000..5e392a5
--- /dev/null
+++ b/ui/src/auto_installer/add_wizard.rs
@@ -0,0 +1,142 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use proxmox_network_types::fqdn::Fqdn;
+use proxmox_schema::property_string::PropertyString;
+use std::rc::Rc;
+use wasm_bindgen::JsValue;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::auto_installer::answer_form::*;
+use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use proxmox_yew_comp::{Wizard, WizardPageRenderInfo};
+use pwt::prelude::*;
+use pwt::widget::TabBarItem;
+use pwt_macros::builder;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct AddAnswerWizardProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl AddAnswerWizardProperties {
+ pub fn with(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl Default for AddAnswerWizardProperties {
+ fn default() -> Self {
+ let config = PreparedInstallationConfig {
+ id: String::new(),
+ // target filter
+ is_default: false,
+ target_filter: Vec::new(),
+ // global options
+ country: "at".to_owned(),
+ fqdn: "host.example.com"
+ .parse::<Fqdn>()
+ .expect("known valid fqdn"),
+ use_dhcp_fqdn: false,
+ keyboard: answer::KeyboardLayout::default(),
+ mailto: "root@example.invalid".to_owned(),
+ timezone: get_js_timezone().unwrap_or_else(|| "Etc/UTC".to_owned()),
+ root_password_hashed: None,
+ reboot_on_error: false,
+ reboot_mode: answer::RebootMode::default(),
+ root_ssh_keys: Vec::new(),
+ // network options
+ use_dhcp_network: true,
+ cidr: None,
+ gateway: None,
+ dns: None,
+ netdev_filter: Vec::new(),
+ netif_name_pinning_enabled: true,
+ // disk options
+ filesystem_type: answer::FilesystemType::default(),
+ filesystem_options: PropertyString::new(answer::FilesystemOptions::Lvm(
+ answer::LvmOptions::default(),
+ )),
+ disk_mode: DiskSelectionMode::default(),
+ disk_list: None,
+ disk_filter: Vec::new(),
+ disk_filter_match: None,
+ post_hook_base_url: None,
+ post_hook_cert_fp: None,
+ };
+
+ yew::props!(Self { config })
+ }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+ fn from(value: AddAnswerWizardProperties) -> Self {
+ let comp = VComp::new::<AddAnswerWizardComponent>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+pub struct AddAnswerWizardComponent {}
+
+impl Component for AddAnswerWizardComponent {
+ type Message = ();
+ type Properties = AddAnswerWizardProperties;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let url = "/auto-install/prepared";
+
+ Wizard::new(tr!("Add Prepared Answer"))
+ .width(900)
+ .on_done(props.on_done.clone())
+ .on_submit({
+ move |config: serde_json::Value| async move { submit(url, None, config).await }
+ })
+ .with_page(
+ TabBarItem::new().key("global").label(tr!("Global options")),
+ {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_global_options_form(&config, true)
+ },
+ )
+ .with_page(TabBarItem::new().label(tr!("Network options")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_network_options_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Disk Setup")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_disk_setup_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Target filter")), {
+ let config = props.config.clone();
+ move |p: &WizardPageRenderInfo| render_target_filter_form(&p.form_ctx, &config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Post-installation")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_post_hook_form(&config)
+ })
+ .into()
+ }
+}
+
+fn get_js_timezone() -> Option<String> {
+ let datetime_options = Intl::DateTimeFormat::default().resolved_options();
+ js_sys::Reflect::get(&datetime_options, &JsValue::from_str("timeZone"))
+ .ok()
+ .and_then(|v| v.as_string())
+}
diff --git a/ui/src/auto_installer/answer_form.rs b/ui/src/auto_installer/answer_form.rs
new file mode 100644
index 0000000..44a3ade
--- /dev/null
+++ b/ui/src/auto_installer/answer_form.rs
@@ -0,0 +1,849 @@
+use anyhow::{anyhow, bail, Result};
+use proxmox_network_types::fqdn::Fqdn;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock};
+
+use pdm_api_types::auto_installer::{
+ DiskSelectionMode, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+};
+use proxmox_installer_types::{
+ answer::{
+ BtrfsCompressOption, BtrfsOptions, FilesystemOptions, FilesystemType, FilterMatch,
+ KeyboardLayout, LvmOptions, RebootMode, ZfsChecksumOption, ZfsCompressOption, ZfsOptions,
+ BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, ROOT_PASSWORD_SCHEMA,
+ ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
+ },
+ ProxmoxProduct, EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::{
+ api_types::{CIDR_SCHEMA, IP_SCHEMA},
+ property_string::PropertyString,
+};
+use proxmox_yew_comp::{form::delete_empty_values, SchemaValidation};
+use pwt::widget::{
+ form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType},
+ Container, FieldPosition, InputPanel,
+};
+use pwt::{
+ css::{Flex, Overflow},
+ prelude::*,
+ widget::form::{Number, TextArea},
+};
+
+pub async fn submit(
+ url: &str,
+ existing_id: Option<&str>,
+ mut config: serde_json::Value,
+) -> Result<()> {
+ let obj = config.as_object_mut().expect("always an object");
+
+ let fs_opts = collect_fs_options_into_propstring(obj);
+ obj.insert("filesystem-options".to_owned(), json!(fs_opts));
+
+ let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+ let target_filter = collect_lines_into_array(obj.remove("target-filter"));
+ let disk_filter = collect_lines_into_array(obj.remove("disk-filter-text"));
+ let netdev_filter = collect_lines_into_array(obj.remove("netdev-filter-text"));
+
+ config["root-ssh-keys"] = root_ssh_keys;
+ config["target-filter"] = target_filter;
+ config["disk-filter"] = disk_filter;
+ config["netdev-filter"] = netdev_filter;
+
+ if let Some(id) = existing_id {
+ config["id"] = json!(id);
+ let data = delete_empty_values(
+ &config,
+ &[
+ "root-ssh-keys",
+ "post-hook-base-url",
+ "post-hook-cert-fp",
+ "disk-filter",
+ "netdev-filter",
+ ],
+ true,
+ );
+ proxmox_yew_comp::http_put(url, Some(data)).await
+ } else {
+ proxmox_yew_comp::http_post(url, Some(config)).await
+ }
+}
+
+fn collect_fs_options_into_propstring(
+ obj: &mut serde_json::Map<String, Value>,
+) -> PropertyString<FilesystemOptions> {
+ let fs_type = obj
+ .get("filesystem-type")
+ .and_then(|s| s.as_str())
+ .and_then(|s| s.parse::<FilesystemType>().ok())
+ .unwrap_or_default();
+
+ match fs_type {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
+ PropertyString::new(FilesystemOptions::Lvm(LvmOptions {
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ swapsize: obj.remove("swapsize").and_then(|v| v.as_f64()),
+ maxroot: obj.remove("maxroot").and_then(|v| v.as_f64()),
+ maxvz: obj.remove("maxvz").and_then(|v| v.as_f64()),
+ minfree: obj.remove("minfree").and_then(|v| v.as_f64()),
+ }))
+ }
+ FilesystemType::Zfs(level) => PropertyString::new(FilesystemOptions::Zfs(ZfsOptions {
+ raid: Some(level),
+ ashift: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ arc_max: obj
+ .remove("ashift")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ checksum: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsChecksumOption>().ok()),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<ZfsCompressOption>().ok()),
+ copies: obj
+ .remove("copies")
+ .and_then(|v| v.as_u64())
+ .map(|v| v as u32),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ })),
+ FilesystemType::Btrfs(level) => {
+ PropertyString::new(FilesystemOptions::Btrfs(BtrfsOptions {
+ raid: Some(level),
+ compress: obj
+ .remove("checksum")
+ .and_then(|v| v.as_str().map(ToOwned::to_owned))
+ .and_then(|s| s.parse::<BtrfsCompressOption>().ok()),
+ hdsize: obj.remove("hdsize").and_then(|v| v.as_f64()),
+ }))
+ }
+ }
+}
+
+fn collect_lines_into_array(value: Option<Value>) -> Value {
+ value
+ .and_then(|v| v.as_str().map(|s| s.to_owned()))
+ .map(|s| json!(s.split('\n').collect::<Vec<&str>>()))
+ .unwrap_or_else(|| json!([]))
+}
+
+pub fn render_global_options_form(
+ config: &PreparedInstallationConfig,
+ is_create: bool,
+) -> yew::Html {
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if is_create {
+ panel.add_field(
+ tr!("Installation ID"),
+ Field::new()
+ .name("id")
+ .value(config.id.clone())
+ .schema(&PREPARED_INSTALL_CONFIG_ID_SCHEMA)
+ .required(true),
+ );
+ } else {
+ panel.add_field(
+ tr!("Installation ID"),
+ DisplayField::new().value(config.id.clone()),
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Country"),
+ Combobox::new()
+ .name("country")
+ .placeholder(tr!("Two-letter country code, e.g. at"))
+ .items(Rc::new(
+ COUNTRY_INFO
+ .deref()
+ .keys()
+ .map(|s| s.as_str().into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ if let Some(s) = COUNTRY_INFO.deref().get(&v.to_string()) {
+ s.into()
+ } else {
+ v.into()
+ }
+ })
+ .value(config.country.clone())
+ .required(true),
+ )
+ .with_field(
+ tr!("Timezone"),
+ Field::new()
+ .name("timezone")
+ .value(config.timezone.clone())
+ .placeholder(tr!("Timezone name, e.g. Europe/Vienna"))
+ .required(true),
+ )
+ .with_field(
+ tr!("Root password"),
+ Field::new()
+ .name("root-password")
+ .input_type(InputType::Password)
+ .schema(&ROOT_PASSWORD_SCHEMA)
+ .placeholder((!is_create).then(|| tr!("Keep current")))
+ .required(is_create),
+ )
+ .with_field(
+ tr!("Keyboard Layout"),
+ Combobox::new()
+ .name("keyboard")
+ .items(Rc::new(
+ KEYBOARD_LAYOUTS
+ .iter()
+ .map(|l| serde_variant_name(l).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<KeyboardLayout>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.keyboard))
+ .required(true),
+ )
+ .with_field(
+ tr!("Administrator email address"),
+ Field::new()
+ .name("mailto")
+ .placeholder(EMAIL_DEFAULT_PLACEHOLDER.to_owned())
+ .input_type(InputType::Email)
+ .value(config.mailto.clone())
+ .validate(|s: &String| {
+ if s.ends_with(".invalid") {
+ bail!(tr!("Invalid (default) email address"))
+ } else {
+ Ok(())
+ }
+ })
+ .required(true),
+ )
+ .with_field(
+ tr!("Root SSH public keys"),
+ TextArea::new()
+ .name("root-ssh-keys")
+ .class("pwt-w-100")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("One per line, usually begins with \"ssh-\", \"sk-ssh-\", \"ecdsa-\" or \"sk-ecdsa\""))
+ .value(config.root_ssh_keys.join("\n")),
+ )
+ .with_field(
+ tr!("Reboot on error"),
+ Checkbox::new().name("reboot-on-error"),
+ )
+ .with_field(
+ tr!("Post-Installation action"),
+ Combobox::new()
+ .name("reboot-mode")
+ .items(Rc::new(
+ [RebootMode::Reboot, RebootMode::PowerOff]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<RebootMode>() {
+ Ok(RebootMode::Reboot) => tr!("Reboot").into(),
+ Ok(RebootMode::PowerOff) => tr!("Power off").into(),
+ _ => v.into(),
+ })
+ .value(serde_variant_name(config.reboot_mode))
+ .required(true),
+ )
+ .into()
+}
+
+pub fn render_network_options_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let use_dhcp_network = form_ctx
+ .read()
+ .get_field_value("use-dhcp-network")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ let use_dhcp_fqdn = form_ctx
+ .read()
+ .get_field_value("use-dhcp-fqdn")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Use DHCP network information"),
+ Checkbox::new().name("use-dhcp-network").default(true),
+ )
+ .with_field(
+ tr!("IP address (CIDR)"),
+ Field::new()
+ .name("cidr")
+ .placeholder(tr!("E.g. 192.168.0.100/24"))
+ .schema(&CIDR_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("Gateway address"),
+ Field::new()
+ .name("gateway")
+ .placeholder(tr!("E.g. 192.168.0.1"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_field(
+ tr!("DNS server address"),
+ Field::new()
+ .name("dns")
+ .placeholder(tr!("E.g. 192.168.0.254"))
+ .schema(&IP_SCHEMA)
+ .disabled(use_dhcp_network)
+ .required(!use_dhcp_network),
+ )
+ .with_right_field(
+ tr!("Use FQDN from DHCP"),
+ Checkbox::new().name("use-dhcp-fqdn").default(false),
+ )
+ .with_right_field(
+ tr!("Fully-qualified domain name (FQDN)"),
+ Field::new()
+ .name("fqdn")
+ .placeholder("machine.example.com")
+ .value(config.fqdn.to_string())
+ .disabled(use_dhcp_fqdn)
+ .validate(|s: &String| {
+ s.parse::<Fqdn>()
+ .map_err(|err| anyhow!("{err}"))
+ .map(|_| ())
+ })
+ .required(!use_dhcp_fqdn),
+ )
+ .with_right_field("", DisplayField::new()) // dummy
+ .with_right_field(
+ tr!("Enable network interface name pinning"),
+ Checkbox::new()
+ .name("netif-name-pinning-enabled")
+ .default(config.netif_name_pinning_enabled),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Network device udev filters"),
+ TextArea::new()
+ .name("netdev-filter-text")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!("UDEV_PROP=glob*, e.g. ID_NET_DRIVER=foo, one per line"))
+ .value(config.netdev_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ }))
+ .disabled(use_dhcp_fqdn),
+ )
+ .into()
+}
+
+pub fn render_disk_setup_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let disk_mode = form_ctx
+ .read()
+ .get_field_value("disk-mode")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<DiskSelectionMode>().ok()))
+ .unwrap_or_default();
+
+ let fs_type = form_ctx
+ .read()
+ .get_field_value("filesystem-type")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<FilesystemType>().ok()))
+ .unwrap_or_default();
+
+ let product_filter = form_ctx
+ .read()
+ .get_field_value("target-filter-product")
+ .and_then(|v| v.as_str().and_then(|s| s.parse::<ProxmoxProduct>().ok()));
+
+ // Btrfs is only enabled for PVE installations
+ let filter_btrfs = |fstype: &&FilesystemType| -> bool {
+ product_filter == Some(ProxmoxProduct::PVE) || !fstype.is_btrfs()
+ };
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .show_advanced(form_ctx.get_show_advanced())
+ .with_field(
+ tr!("Filesystem"),
+ Combobox::new()
+ .name("filesystem-type")
+ .items(Rc::new(
+ FILESYSTEM_TYPE_OPTIONS
+ .iter()
+ .filter(filter_btrfs)
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<FilesystemType>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .value(serde_variant_name(config.filesystem_type))
+ .required(true)
+ .show_filter(false),
+ )
+ .with_right_field(
+ tr!("Disk selection mode"),
+ Combobox::new()
+ .name("disk-mode")
+ .with_item("fixed")
+ .with_item("filter")
+ .default("fixed")
+ .render_value(|v: &AttrValue| match v.parse::<DiskSelectionMode>() {
+ Ok(DiskSelectionMode::Fixed) => tr!("Fixed list of disk names").into(),
+ Ok(DiskSelectionMode::Filter) => tr!("Dynamically by udev filter").into(),
+ _ => v.into(),
+ })
+ .required(true)
+ .value(serde_variant_name(config.disk_mode)),
+ )
+ .with_spacer()
+ .with_field(
+ tr!("Disk names"),
+ Field::new()
+ .name("disk-list")
+ .placeholder(tr!("E.g.") + " sda, sdb")
+ .value(
+ config
+ .disk_list
+ .as_ref()
+ .map(|s| s.to_owned())
+ .unwrap_or_default(),
+ )
+ .disabled(disk_mode != DiskSelectionMode::Fixed)
+ .required(disk_mode == DiskSelectionMode::Fixed),
+ )
+ .with_field(
+ tr!("Disk udev filter mode"),
+ Combobox::new()
+ .name("disk-filter-match")
+ .items(Rc::new(
+ [FilterMatch::Any, FilterMatch::All]
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| match v.parse::<FilterMatch>() {
+ Ok(FilterMatch::Any) => tr!("Match any filter").into(),
+ Ok(FilterMatch::All) => tr!("Match all filters").into(),
+ _ => v.into(),
+ })
+ .default(serde_variant_name(FilterMatch::default()))
+ .value(config.disk_filter_match.and_then(serde_variant_name))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ )
+ .with_large_field(
+ tr!("Disk udev filters"),
+ TextArea::new()
+ .name("disk-filter-text")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "3")
+ .placeholder(tr!(
+ "UDEV_PROP=glob*, e.g. ID_MODEL=VENDORFOO_SSD0, one per line"
+ ))
+ .value(config.disk_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ }))
+ .disabled(disk_mode != DiskSelectionMode::Filter),
+ );
+
+ let warning = match fs_type {
+ FilesystemType::Zfs(_) => Some(
+ tr!("ZFS is not compatible with hardware RAID controllers, for details see the documentation.")
+ ),
+ FilesystemType::Btrfs(_) => Some(tr!("Btrfs integration is a technology preview.")),
+ _ => None,
+ };
+
+ if let Some(text) = warning {
+ panel.add_large_custom_child(html! {
+ <span class="pwt-color-warning pwt-mt-2 pwt-d-block">
+ <i class="fa fa-fw fa-exclamation-circle"/>
+ {text}
+ </span>
+ });
+ }
+
+ panel.add_spacer(true);
+
+ add_fs_advanced_form_fields(&mut panel, &config.filesystem_options);
+ panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+ match fs_opts {
+ FilesystemOptions::Lvm(opts) => add_lvm_advanced_form_fields(panel, opts),
+ FilesystemOptions::Zfs(opts) => add_zfs_advanced_form_fields(panel, opts),
+ FilesystemOptions::Btrfs(opts) => add_btrfs_advanced_form_fields(panel, opts),
+ }
+}
+
+fn add_lvm_advanced_form_fields(panel: &mut InputPanel, fs_opts: &LvmOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Harddisk size to use (GB)"),
+ Field::new()
+ .name("hdsize")
+ .number(4., None, 0.1)
+ .submit_empty(false)
+ .value(fs_opts.hdsize.map(|v| v.to_string())),
+ );
+
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("Swap size (GB)"),
+ Field::new()
+ .name("swapsize")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.swapsize.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum root volume size (GB)"),
+ Field::new()
+ .name("maxroot")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.maxroot.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Maximum data volume size (GB)"),
+ Field::new()
+ .name("maxvz")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.maxvz.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Minimum free space in LVM volume group (GB)"),
+ Field::new()
+ .name("minfree")
+ .number(0., fs_opts.hdsize.map(|v| v / 2.), 1.)
+ .submit_empty(false)
+ .value(fs_opts.minfree.map(|v| v.to_string())),
+ );
+}
+
+fn add_zfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &ZfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ "ashift",
+ Number::<u64>::new()
+ .name("ashift")
+ .min(9)
+ .max(16)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.ashift.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Left,
+ true,
+ false,
+ tr!("ARC maximum size (MiB)"),
+ Field::new()
+ .name("arc-max")
+ .number(64., None, 1.)
+ .submit_empty(false)
+ .value(fs_opts.arc_max.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Checksumming algorithm"),
+ Combobox::new()
+ .name("checksum")
+ .items(Rc::new(
+ ZFS_CHECKSUM_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsChecksumOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.checksum.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ ZFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<ZfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Copies"),
+ Number::<u32>::new()
+ .name("copies")
+ .min(1)
+ .max(3)
+ .step(1)
+ .submit_empty(false)
+ .value(fs_opts.copies.map(|v| v.to_string())),
+ );
+}
+
+fn add_btrfs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &BtrfsOptions) {
+ panel.add_field_with_options(
+ FieldPosition::Right,
+ true,
+ false,
+ tr!("Compression algorithm"),
+ Combobox::new()
+ .name("compress")
+ .items(Rc::new(
+ BTRFS_COMPRESS_OPTIONS
+ .iter()
+ .map(|opt| serde_variant_name(opt).expect("valid variant").into())
+ .collect(),
+ ))
+ .render_value(|v: &AttrValue| {
+ v.parse::<BtrfsCompressOption>()
+ .map(|v| v.to_string())
+ .unwrap_or_default()
+ .into()
+ })
+ .submit_empty(false)
+ .value(fs_opts.compress.map(|v| v.to_string())),
+ );
+}
+
+pub fn render_target_filter_form(
+ form_ctx: &FormContext,
+ config: &PreparedInstallationConfig,
+) -> yew::Html {
+ let is_default = form_ctx
+ .read()
+ .get_field_value("is-default")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(config.is_default);
+
+ let target_filter = form_ctx
+ .read()
+ .get_field_value("target-filter")
+ .and_then(|v| v.as_str().map(|s| s.to_string()))
+ .unwrap_or_else(|| {
+ config.target_filter.iter().fold(String::new(), |acc, s| {
+ if acc.is_empty() {
+ s.to_string()
+ } else {
+ format!("{acc}\n{s}")
+ }
+ })
+ });
+
+ let mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if !is_default && target_filter.is_empty() {
+ panel.add_large_custom_child(html! {
+ <span class="pwt-color-warning pwt-mb-2 pwt-d-block">
+ <i class="fa fa-fw fa-exclamation-circle"/>
+ {tr!("Not marked as default answer and target filter are empty, answer will never be matched.")}
+ </span>
+ });
+ }
+
+ panel
+ .with_field(
+ tr!("Default answer"),
+ Checkbox::new()
+ .name("is-default")
+ .default(config.is_default),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Target filters"),
+ TextArea::new()
+ .name("target-filter")
+ .class("pwt-w-100")
+ .style("resize", "vertical")
+ .submit_empty(false)
+ .attribute("rows", "4")
+ .placeholder(tr!(
+ "/json/pointer=value, one per line, for example: /product/product=pve"
+ ))
+ .default(target_filter)
+ .disabled(is_default),
+ )
+ .with_right_custom_child(Container::new().with_child(html! {
+ <span>
+ {tr!("Target filter keys are JSON pointers according to")}
+ {" "}
+ <a href="https://www.rfc-editor.org/rfc/rfc6901">{"RFC 6906"}</a>
+ {"."}
+ </span>
+ }))
+ .into()
+}
+
+pub fn render_post_hook_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(html! {
+ <span class="pwt-mb-2 pwt-d-block">
+ <i class="fa fa-fw fa-info-circle"/>
+ {tr!("Optional. If provided, progress reporting is enabled.")}
+ </span>
+ })
+ .with_field(
+ tr!("PDM API base URL"),
+ Field::new()
+ .name("post-hook-base-url")
+ .tip(tr!(
+ "Base URL this PDM instance is reachable from the target host"
+ ))
+ .value(
+ config
+ .post_hook_base_url
+ .clone()
+ .or_else(|| pdm_origin().map(|s| format!("{s}/api2"))),
+ ),
+ )
+ .with_field(
+ tr!("SHA256 certificate fingerprint"),
+ Field::new()
+ .name("post-hook-cert-fp")
+ .tip(tr!("Optional certificate fingerprint"))
+ .value(config.post_hook_cert_fp.clone()),
+ )
+ .into()
+}
+
+fn serde_variant_name<T: Serialize>(ty: T) -> Option<String> {
+ match serde_json::to_value(ty) {
+ Ok(Value::String(s)) => Some(s),
+ other => {
+ log::warn!(
+ "expected string of type {}, got {other:?}",
+ std::any::type_name::<T>()
+ );
+ None
+ }
+ }
+}
+
+fn pdm_origin() -> Option<String> {
+ gloo_utils::document()
+ .url()
+ .and_then(|s| web_sys::Url::new(&s))
+ .map(|url| url.origin())
+ .ok()
+}
+
+const KEYBOARD_LAYOUTS: &[KeyboardLayout] = {
+ use KeyboardLayout::*;
+ &[
+ De, DeCh, Dk, EnGb, EnUs, Es, Fi, Fr, FrBe, FrCa, FrCh, Hu, Is, It, Jp, Lt, Mk, Nl, No, Pl,
+ Pt, PtBr, Se, Si, Tr,
+ ]
+};
+
+static COUNTRY_INFO: LazyLock<BTreeMap<String, String>> = LazyLock::new(|| {
+ #[derive(Deserialize)]
+ struct Iso3611CountryInfo {
+ alpha_2: String,
+ common_name: Option<String>,
+ name: String,
+ }
+
+ #[derive(Deserialize)]
+ struct Iso3611Info {
+ #[serde(rename = "3166-1")]
+ list: Vec<Iso3611CountryInfo>,
+ }
+
+ let raw: Iso3611Info =
+ serde_json::from_str(include_str!("/usr/share/iso-codes/json/iso_3166-1.json"))
+ .expect("valid country-info json");
+
+ raw.list
+ .into_iter()
+ .map(|c| (c.alpha_2.to_lowercase(), c.common_name.unwrap_or(c.name)))
+ .collect()
+});
diff --git a/ui/src/auto_installer/edit_window.rs b/ui/src/auto_installer/edit_window.rs
new file mode 100644
index 0000000..7054eea
--- /dev/null
+++ b/ui/src/auto_installer/edit_window.rs
@@ -0,0 +1,105 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use std::rc::Rc;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::auto_installer::answer_form::*;
+use pdm_api_types::auto_installer::PreparedInstallationConfig;
+use proxmox_yew_comp::{percent_encoding::percent_encode_component, EditWindow};
+use pwt::prelude::*;
+use pwt::widget::{form::FormContext, TabBarItem, TabPanel};
+use pwt_macros::builder;
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct EditAnswerWindowProperties {
+ /// Dialog close callback.
+ #[builder_cb(IntoEventCallback, into_event_callback, ())]
+ #[prop_or_default]
+ pub on_done: Option<Callback<()>>,
+
+ /// Auto-installer answer configuration.
+ config: PreparedInstallationConfig,
+}
+
+impl EditAnswerWindowProperties {
+ pub fn new(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<EditAnswerWindowProperties> for VNode {
+ fn from(value: EditAnswerWindowProperties) -> Self {
+ let comp = VComp::new::<EditAnswerWindowComponent>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+pub struct EditAnswerWindowComponent {}
+
+impl Component for EditAnswerWindowComponent {
+ type Message = ();
+ type Properties = EditAnswerWindowProperties;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let url = format!(
+ "/auto-install/prepared/{}",
+ percent_encode_component(&props.config.id)
+ );
+
+ EditWindow::new(tr!("Edit Prepared Answer"))
+ .width(900)
+ .on_done(props.on_done.clone())
+ .renderer({
+ let props = props.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props)
+ })
+ .edit(true)
+ .submit_digest(true)
+ .on_submit({
+ let url = url.clone();
+ let id = props.config.id.clone();
+ move |form_ctx: FormContext| {
+ let url = url.clone();
+ let id = id.clone();
+ let config = form_ctx.get_submit_data();
+ async move { submit(&url, Some(&id), config).await }
+ }
+ })
+ .advanced_checkbox(true)
+ .into()
+ }
+}
+
+fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+ TabPanel::new()
+ .with_item(
+ TabBarItem::new().key("global").label(tr!("Global options")),
+ render_global_options_form(&props.config, false),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Network options")),
+ render_network_options_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Disk Setup")),
+ render_disk_setup_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Target filter")),
+ render_target_filter_form(form_ctx, &props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Post-installation")),
+ render_post_hook_form(&props.config),
+ )
+ .into()
+}
diff --git a/ui/src/auto_installer/mod.rs b/ui/src/auto_installer/mod.rs
index 810eade..1702e68 100644
--- a/ui/src/auto_installer/mod.rs
+++ b/ui/src/auto_installer/mod.rs
@@ -1,4 +1,15 @@
//! Implements the UI for the proxmox-auto-installer integration.
+mod answer_form;
+
+mod add_wizard;
+pub use add_wizard::*;
+
+mod edit_window;
+pub use edit_window::*;
+
+mod prepared_answers_panel;
+pub use prepared_answers_panel::*;
+
mod installations_panel;
pub use installations_panel::*;
diff --git a/ui/src/auto_installer/prepared_answers_panel.rs b/ui/src/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..32de81a
--- /dev/null
+++ b/ui/src/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,233 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html,
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+use pdm_api_types::auto_installer::PreparedInstallationConfig;
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, ConfirmButton, LoadableComponent,
+ LoadableComponentContext, LoadableComponentMaster,
+};
+use pwt::{
+ props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, DataTableMouseEvent},
+ Button, Fa, Toolbar,
+ },
+};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AutoInstallerPreparedAnswersPanel {}
+
+impl From<AutoInstallerPreparedAnswersPanel> for VNode {
+ fn from(value: AutoInstallerPreparedAnswersPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<AutoInstallerPreparedAnswersPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ Create,
+ Copy,
+ Edit,
+}
+
+#[derive(PartialEq)]
+pub enum Message {
+ SelectionChange,
+ RemoveEntry,
+}
+
+#[derive(PartialEq, Properties)]
+pub struct AutoInstallerPreparedAnswersPanelComponent {
+ selection: Selection,
+ store: Store<PreparedInstallationConfig>,
+ columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+impl LoadableComponent for AutoInstallerPreparedAnswersPanelComponent {
+ type Properties = AutoInstallerPreparedAnswersPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::with_extract_key(|record: &PreparedInstallationConfig| {
+ Key::from(record.id.to_string())
+ });
+ store.set_sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ );
+
+ Self {
+ selection: Selection::new()
+ .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let path = "/auto-install/prepared".to_string();
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = proxmox_yew_comp::http_get(&path, None).await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ let link = ctx.link();
+ link.clone().spawn(async move {
+ if let Err(err) = delete_entry(key).await {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(Button::new(tr!("Add")).onclick({
+ let link = ctx.link();
+ move |_| {
+ link.change_view(Some(ViewState::Create));
+ }
+ }))
+ .with_spacer()
+ .with_child(Button::new(tr!("Copy")).onclick({
+ let link = ctx.link();
+ move |_| {
+ link.change_view(Some(ViewState::Copy));
+ }
+ }))
+ .with_child(
+ Button::new(tr!("Edit"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+ )
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick({
+ let link = ctx.link();
+ move |_: &mut DataTableMouseEvent| {
+ link.change_view(Some(Self::ViewState::Edit));
+ }
+ })
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ let on_done = ctx.link().clone().change_view_callback(|_| None);
+
+ Some(match view_state {
+ Self::ViewState::Create => super::AddAnswerWizardProperties::default()
+ .on_done(on_done)
+ .into(),
+ Self::ViewState::Copy => {
+ let mut record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ record.id += " (copy)";
+ super::AddAnswerWizardProperties::with(record)
+ .on_done(on_done)
+ .into()
+ }
+ Self::ViewState::Edit => {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ super::EditAnswerWindowProperties::new(record)
+ .on_done(on_done)
+ .into()
+ }
+ })
+ }
+}
+
+async fn delete_entry(key: Key) -> Result<()> {
+ let url = format!(
+ "/auto-install/prepared/{}",
+ percent_encode_component(&key.to_string())
+ );
+ proxmox_yew_comp::http_delete(&url, None).await
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+ vec![
+ DataTableColumn::new(tr!("ID"))
+ .width("320px")
+ .render(|item: &PreparedInstallationConfig| html! { &item.id })
+ .into(),
+ DataTableColumn::new(tr!("Default"))
+ .width("170px")
+ .render(|item: &PreparedInstallationConfig| {
+ if item.is_default {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .into(),
+ DataTableColumn::new(tr!("Target filter"))
+ .flex(1)
+ .render(|item: &PreparedInstallationConfig| {
+ item.target_filter
+ .iter()
+ .map(|s| s.to_string())
+ .reduce(|acc, s| format!("{acc}, {s}"))
+ .unwrap_or_else(|| "-".to_owned())
+ .into()
+ })
+ .into(),
+ ]
+}
diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 073b84d..531174b 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -14,7 +14,7 @@ use proxmox_yew_comp::{AclContext, NotesView, XTermJs};
use pdm_api_types::remotes::RemoteType;
use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
-use crate::auto_installer::AutoInstallerPanel;
+use crate::auto_installer::{AutoInstallerPanel, AutoInstallerPreparedAnswersPanel};
use crate::configuration::subscription_panel::SubscriptionPanel;
use crate::configuration::views::ViewGrid;
use crate::dashboard::view::View;
@@ -381,6 +381,15 @@ impl Component for PdmMainMenu {
let mut autoinstaller_submenu = Menu::new();
+ register_view(
+ &mut autoinstaller_submenu,
+ &mut content,
+ tr!("Prepared Answers"),
+ "auto-installer-prepared",
+ Some("fa fa-files-o"),
+ |_| AutoInstallerPreparedAnswersPanel::default().into(),
+ );
+
register_submenu(
&mut menu,
&mut content,
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* [pdm-devel] [PATCH datacenter-manager v2 14/14] docs: add documentation for auto-installer integration
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (12 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
@ 2025-12-05 11:25 ` Christoph Heiss
2025-12-05 11:53 ` [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Thomas Lamprecht
14 siblings, 0 replies; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:25 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
* new patch
docs/automated-installations.rst | 83 ++++++++++++++++++++++++++++++++
docs/index.rst | 1 +
2 files changed, 84 insertions(+)
create mode 100644 docs/automated-installations.rst
diff --git a/docs/automated-installations.rst b/docs/automated-installations.rst
new file mode 100644
index 0000000..eef591b
--- /dev/null
+++ b/docs/automated-installations.rst
@@ -0,0 +1,83 @@
+.. _automated_installations:
+
+Automated Installations
+=======================
+
+The Proxmox Datacenter Manager provides integration with the automated
+installer available across all Proxmox products.
+
+A detailed documentation for all available options can be found on
+`our wiki <https://pve.proxmox.com/wiki/Automated_Installation>`_.
+
+.. _autoinst_overview:
+
+Overview
+~~~~~~~~
+
+The overview shows all past and ongoing installations using the Proxmox
+Datacenter Manager. It allows access to the raw system information data as sent
+by the automated installer before the actual installation and (if configured)
+post-installation notification hook data, containing extensive information about
+the newly installed system.
+
+.. _autoinst_answers:
+
+Prepared Answers
+~~~~~~~~~~~~~~~~
+
+This view provides an overview over all defined answer files and allows editing,
+copying into new answers and deleting them. For a quick overview, it shows
+whether an answer is the default and/or what target filters have been defined.
+
+Target filter
+^^^^^^^^^^^^^
+
+Target filter allow you to control what systems should match.
+
+Filters are key-value pairs in the format ``key=format``, with keys being `JSON
+Pointers`_, and match systems based the identifying information sent by the
+installer as JSON document. An example of such a document is provided `on the
+wiki <https://pve.proxmox.com/wiki/Automated_Installation#System_information_POST_data>`_.
+
+JSON Pointers allow for identifying specific values within a JSON document. For
+example, to match only Proxmox VE installations by the product name, a filter
+entry like ``/product/product=pve`` can be used.
+
+Values are *globs* and use the same syntax as the automated installer itself.
+The following special characters can be used in filters:
+
+* ``?`` -- matches any single character
+* ``*`` -- matches any number of characters, can be none
+* ``[a]``, ``[abc]``, ``[0-9]`` -- matches any single character inside the brackets, ranges are possible
+* ``[!a]`` -- negate the filter, any single character but the ones specified
+
+A prepared answer can be also set as default, in which case it will be used if
+no other more specific answer matches based on its configured target filters.
+
+.. _autoinst_preparing_iso:
+
+Preparing an ISO
+~~~~~~~~~~~~~~~~
+
+To use an installation ISO of a Proxmox product with the Proxmox Datacenter
+Manager functionality, the ISO must be appropriately prepared to `fetch an
+answer via HTTP`_ from the Proxmox Datacenter Manager using the
+``proxmox-auto-install-assistant`` tool, available from the Proxmox VE package
+repositories.
+
+The `target URL`_ for the automated installer must point to
+``https://<pdm>/api2/json/auto-install/answer``, where ``<pdm>`` is the address
+under which the Proxmox Datacenter Manager is reachable from the systems to be
+installed.
+
+For example:
+
+.. code-block:: shell
+
+ proxmox-auto-install-assistant prepare-iso /path/to/source.iso \
+ --fetch-from http \
+ --url "https://<pdm>/api2/json/auto-install/answer"
+
+.. _JSON Pointers: https://www.rfc-editor.org/rfc/rfc6901
+.. _fetch an answer via HTTP: https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP
+.. _target URL: https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP
diff --git a/docs/index.rst b/docs/index.rst
index 8398f57..2fc8a5d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -25,6 +25,7 @@ in the section entitled "GNU Free Documentation License".
web-ui.rst
sdn-integration.rst
remotes.rst
+ automated-installations.rst
views.rst
access-control.rst
sysadmin.rst
--
2.51.2
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* Re: [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration Christoph Heiss
` (13 preceding siblings ...)
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 14/14] docs: add documentation for auto-installer integration Christoph Heiss
@ 2025-12-05 11:53 ` Thomas Lamprecht
2025-12-05 15:50 ` Christoph Heiss
14 siblings, 1 reply; 18+ messages in thread
From: Thomas Lamprecht @ 2025-12-05 11:53 UTC (permalink / raw)
To: Proxmox Datacenter Manager development discussion, Christoph Heiss
Am 05.12.25 um 12:25 schrieb Christoph Heiss:
> This series adds integration with our automated installer [0] for all our
> products. With this, Proxmox Datacenter Manager can be used for serving
> answer files via HTTP(S) in an automated fashion.
>
Thanks for tackling this; IMO this work has the chance to become a very cool
feature!
> TOML API
> ========
>
> The auto-installer (currently) only supports TOML as input format for
> the answer, so we need to hack around a bit to serve TOML in the API.
> This is done in patch #12, by implementing the api method
> directly.
>
> Serving TOML from an endpoint under /api2/json/ obviously is rather
> wrong, so I'm definitely open for suggestions.
> We probably also don't want to implement `/api2/toml/` if I'd have to
> guess (or would that be possible selectively, i.e. just for this one
> endpoint?).
Making that selective should be possible in any case; another option
might be to add a "raw" one where the endpoints does the serialization
and returns a string directly, that would at least make it a bit more
flexible for the future; but not something we need to jump the gun now,
especially as this needs some care w.r.t. security – just giving the code
full control over content/type and content might be a bad idea; but the
content type could be part of the API schema definition.
> Adding JSON support to proxmox-fetch-answer is on my list, but this
> would still be needed if we want to keep compatibility with older ISOs.
FWIW; in general I'd be fine with this only working with newer ISOs, at
least as long as we do have published an ISO that has support for all our
projects; if we can support older ones without having to bend backwards
too much it would be naturally great too, and for that returning TOML
for json format can IMO be an OK stop-gap if the "raw" format is too
much scope creep. It would be nice if we could make that opt-in so that
newer ISOs can default to use JSON directly if they support it.
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread* Re: [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration
2025-12-05 11:53 ` [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Thomas Lamprecht
@ 2025-12-05 15:50 ` Christoph Heiss
2025-12-05 15:57 ` Thomas Lamprecht
0 siblings, 1 reply; 18+ messages in thread
From: Christoph Heiss @ 2025-12-05 15:50 UTC (permalink / raw)
To: Thomas Lamprecht; +Cc: Proxmox Datacenter Manager development discussion
On Fri Dec 5, 2025 at 12:53 PM CET, Thomas Lamprecht wrote:
> Am 05.12.25 um 12:25 schrieb Christoph Heiss:
[..]
>> Serving TOML from an endpoint under /api2/json/ obviously is rather
>> wrong, so I'm definitely open for suggestions.
>> We probably also don't want to implement `/api2/toml/` if I'd have to
>> guess (or would that be possible selectively, i.e. just for this one
>> endpoint?).
>
> Making that selective should be possible in any case; another option
> might be to add a "raw" one where the endpoints does the serialization
> and returns a string directly, that would at least make it a bit more
> flexible for the future; but not something we need to jump the gun now,
> especially as this needs some care w.r.t. security – just giving the code
> full control over content/type and content might be a bad idea; but the
> content type could be part of the API schema definition.
Having the selective "raw" endpoint feature with the actual content type
part of the schema definition sounds definitely workable! I'll see if
it's possible to implement with reasonable effort.
>
>> Adding JSON support to proxmox-fetch-answer is on my list, but this
>> would still be needed if we want to keep compatibility with older ISOs.
>
> FWIW; in general I'd be fine with this only working with newer ISOs, at
> least as long as we do have published an ISO that has support for all our
> projects; if we can support older ones without having to bend backwards
> too much it would be naturally great too, and for that returning TOML
> for json format can IMO be an OK stop-gap if the "raw" format is too
> much scope creep. It would be nice if we could make that opt-in so that
> newer ISOs can default to use JSON directly if they support it.
My plan here would have been using the `Accept` header, i.e.
(newer) proxmox-fetch-answer would include
`Accept: application/json, application/toml;q=0.5`, so that JSON is
preferred if available. If the header is missing, TOML is sent.
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread
* Re: [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial auto-installer integration
2025-12-05 15:50 ` Christoph Heiss
@ 2025-12-05 15:57 ` Thomas Lamprecht
0 siblings, 0 replies; 18+ messages in thread
From: Thomas Lamprecht @ 2025-12-05 15:57 UTC (permalink / raw)
To: Christoph Heiss; +Cc: Proxmox Datacenter Manager development discussion
Am 05.12.25 um 16:50 schrieb Christoph Heiss:
> My plan here would have been using the `Accept` header, i.e.
> (newer) proxmox-fetch-answer would include
> `Accept: application/json, application/toml;q=0.5`, so that JSON is
> preferred if available. If the header is missing, TOML is sent.
Good idea, that avoids adding an extra param and still should work out
nicely.
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 18+ messages in thread