* [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
` (13 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn Christoph Heiss
` (12 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
` (11 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (2 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer Christoph Heiss
` (10 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 UTC (permalink / raw)
To: pdm-devel
Much like the existing ones for Ipv4Addr/Ipv6Addr.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (3 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer Christoph Heiss
` (9 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (4 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types Christoph Heiss
` (8 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (5 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration Christoph Heiss
` (7 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (6 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module Christoph Heiss
` (6 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
Cargo.toml | 4 +
debian/control | 3 +
lib/pdm-api-types/Cargo.toml | 3 +
lib/pdm-api-types/src/auto_installer.rs | 441 ++++++++++++++++++++++++
lib/pdm-api-types/src/lib.rs | 1 +
5 files changed, 452 insertions(+)
create mode 100644 lib/pdm-api-types/src/auto_installer.rs
diff --git a/Cargo.toml b/Cargo.toml
index f8942af..9c1f0c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -66,6 +66,8 @@ proxmox-tfa = { version = "6", features = [ "api-types" ], default-features = fa
proxmox-time = "2"
proxmox-upgrade-checks = "1"
proxmox-uuid = "1"
+proxmox-installer-types = "0.1"
+proxmox-network-types = "0.1"
# other proxmox crates
proxmox-acme = "0.5"
@@ -158,6 +160,7 @@ zstd = { version = "0.13" }
# proxmox-http-error = { path = "../proxmox/proxmox-http-error" }
# proxmox-http = { path = "../proxmox/proxmox-http" }
# proxmox-human-byte = { path = "../proxmox/proxmox-human-byte" }
+# proxmox-installer-types = { path = "../proxmox/proxmox-installer-types" }
# proxmox-io = { path = "../proxmox/proxmox-io" }
# proxmox-lang = { path = "../proxmox/proxmox-lang" }
# proxmox-ldap = { path = "../proxmox/proxmox-ldap" }
@@ -165,6 +168,7 @@ zstd = { version = "0.13" }
# proxmox-log = { path = "../proxmox/proxmox-log" }
# proxmox-metrics = { path = "../proxmox/proxmox-metrics" }
# proxmox-network-api = { path = "../proxmox/proxmox-network-api" }
+# proxmox-network-types = { path = "../proxmox/proxmox-network-types" }
# proxmox-node-status = { path = "../proxmox/proxmox-node-status" }
# proxmox-notify = { path = "../proxmox/proxmox-notify" }
# proxmox-openid = { path = "../proxmox/proxmox-openid" }
diff --git a/debian/control b/debian/control
index ccb73be..b1ce92a 100644
--- a/debian/control
+++ b/debian/control
@@ -62,6 +62,8 @@ Build-Depends: debhelper-compat (= 13),
librust-proxmox-http-1+proxmox-async-dev (>= 1.0.4-~~),
librust-proxmox-http-1+websocket-dev (>= 1.0.4-~~),
librust-proxmox-human-byte-1+default-dev,
+ librust-proxmox-installer-types-0.1+api-types-dev,
+ librust-proxmox-installer-types-0.1+default-dev,
librust-proxmox-lang-1+default-dev (>= 1.1-~~),
librust-proxmox-ldap-1+default-dev (>= 1.1-~~),
librust-proxmox-ldap-1+sync-dev (>= 1.1-~~),
@@ -72,6 +74,7 @@ Build-Depends: debhelper-compat (= 13),
librust-proxmox-network-api-1+impl-dev,
librust-proxmox-node-status-1+api-dev,
librust-proxmox-openid-1+default-dev (>= 1.0.2-~~),
+ librust-proxmox-network-types-0.1+default-dev,
librust-proxmox-product-config-1+default-dev,
librust-proxmox-rest-server-1+default-dev,
librust-proxmox-rest-server-1+templates-dev,
diff --git a/lib/pdm-api-types/Cargo.toml b/lib/pdm-api-types/Cargo.toml
index d6429e6..2999834 100644
--- a/lib/pdm-api-types/Cargo.toml
+++ b/lib/pdm-api-types/Cargo.toml
@@ -19,12 +19,15 @@ proxmox-auth-api = { workspace = true, features = ["api-types"] }
proxmox-apt-api-types.workspace = true
proxmox-lang.workspace = true
proxmox-config-digest.workspace = true
+proxmox-installer-types = { workspace = true, features = ["api-types"] }
+proxmox-network-types = { workspace = true, features = ["api-types"] }
proxmox-schema = { workspace = true, features = ["api-macro"] }
proxmox-section-config.workspace = true
proxmox-dns-api.workspace = true
proxmox-time.workspace = true
proxmox-serde.workspace = true
proxmox-subscription = { workspace = true, features = ["api-types"], default-features = false }
+proxmox-uuid.workspace = true
pbs-api-types = { workspace = true }
pve-api-types = { workspace = true }
diff --git a/lib/pdm-api-types/src/auto_installer.rs b/lib/pdm-api-types/src/auto_installer.rs
new file mode 100644
index 0000000..c5309fe
--- /dev/null
+++ b/lib/pdm-api-types/src/auto_installer.rs
@@ -0,0 +1,441 @@
+//! API types used for the auto-installation configuration.
+
+use anyhow::{anyhow, bail, Result};
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::{BTreeMap, HashMap},
+ fmt::{self, Display},
+ str::FromStr,
+};
+
+use proxmox_installer_types::{
+ answer::{
+ self, FilesystemOptions, FilesystemType, NetworkInterfacePinningOptionsAnswer,
+ COUNTRY_CODE_REGEX,
+ },
+ post_hook::PostHookInfo,
+ SystemInfo,
+};
+use proxmox_network_types::{
+ fqdn::Fqdn,
+ ip_address::{api_types::IpAddr, Cidr},
+};
+use proxmox_schema::{
+ api,
+ api_types::{
+ CERT_FINGERPRINT_SHA256_SCHEMA, HTTP_URL_SCHEMA, SINGLE_LINE_COMMENT_FORMAT, UUID_FORMAT,
+ },
+ property_string::PropertyString,
+ ApiStringFormat, Schema, StringSchema, Updater,
+};
+use proxmox_uuid::Uuid;
+
+pub const INSTALLATION_UUID_SCHEMA: Schema = StringSchema::new("UUID of a installation.")
+ .format(&UUID_FORMAT)
+ .schema();
+
+#[api]
+#[derive(Clone, Deserialize, Serialize, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// Current status of an installation.
+pub enum InstallationStatus {
+ /// An appropriate answer file was found and sent to the machine. Post-hook was unavailable,
+ /// so no further status is received.
+ AnswerSent,
+ /// Found no matching answer configuration and no default was set.
+ NoAnswerFound,
+ /// The installation is currently underway.
+ InProgress,
+ /// The installation was finished successfully.
+ Finished,
+}
+
+#[api(
+ properties: {
+ uuid: {
+ schema: INSTALLATION_UUID_SCHEMA,
+ },
+ "received-at": {
+ minimum: 0,
+ },
+ },
+)]
+#[derive(Clone, Deserialize, Serialize, PartialEq)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+/// A installation received from some proxmox-auto-installer instance.
+pub struct Installation {
+ /// Unique ID of this installation.
+ pub uuid: Uuid,
+ /// Time the installation request was received (Unix Epoch).
+ pub received_at: i64,
+ /// Current status of this installation.
+ pub status: InstallationStatus,
+ /// System information about the machine to be provisioned.
+ pub info: SystemInfo,
+ /// Answer that was sent to the target machine.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub answer_id: Option<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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (7 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path Christoph Heiss
` (5 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (8 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module Christoph Heiss
` (4 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (9 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel Christoph Heiss
` (3 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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.
Cargo.toml | 2 +
debian/control | 2 +
server/Cargo.toml | 4 +
server/src/api/auto_installer/mod.rs | 654 +++++++++++++++++++++++++++
server/src/api/mod.rs | 2 +
5 files changed, 664 insertions(+)
create mode 100644 server/src/api/auto_installer/mod.rs
diff --git a/Cargo.toml b/Cargo.toml
index 9c1f0c2..c897eda 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..1412e99
--- /dev/null
+++ b/server/src/api/auto_installer/mod.rs
@@ -0,0 +1,654 @@
+//! 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, AnswerFetchResponse},
+ 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<AnswerFetchResponse> {
+ 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(AnswerFetchResponse::Configuration(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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (10 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
` (2 subsequent siblings)
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (11 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel Christoph Heiss
@ 2025-12-04 12:51 ` Christoph Heiss
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
2025-12-05 11:26 ` Christoph Heiss
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 12:51 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>
---
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] 17+ messages in thread* Re: [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (12 preceding siblings ...)
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
@ 2025-12-04 14:17 ` Lukas Wagner
2025-12-04 15:06 ` Christoph Heiss
2025-12-05 11:26 ` Christoph Heiss
14 siblings, 1 reply; 17+ messages in thread
From: Lukas Wagner @ 2025-12-04 14:17 UTC (permalink / raw)
To: Proxmox Datacenter Manager development discussion, Christoph Heiss
On Thu Dec 4, 2025 at 1:51 PM CET, Christoph Heiss wrote:
> 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.
>
> It provides two new panels:
>
> - Automated Installations: List all past and currently ongoing
> installations.
> - Prepared Answers: Enables users to do the whole CRUD cycle for
> prepared answers, i.e. create new answers (from scratch or based on
> existing ones), edit and delete.
>
> Permission-wise, everything is currently scoped on
> /system/auto-installation.
>
> UI
> ==
>
> Happy about feedback regarding the UI, especially the editing dialog! By
> the nature of it, the auto-installer has a lot more options than the
> standard GUI/TUI installer, and it's kind of hard to not fully cramp up
> the UI while still providing all options.
>
> 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?).
>
> 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.
>
Cool stuff, I didn't even know that somebody was working on this!
Couldn't really try it out, I think there is some issue in the patches
that you've posted. I'm getting a 'unresolved import
`proxmox_installer_types::answer::fetch::AnswerFetchResponse' error when
trying to compile PDM. Checking the code in the installer-types crate, I
also couldn't find a type like this - could you cross-check?
Thanks!
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 17+ messages in thread* Re: [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
@ 2025-12-04 15:06 ` Christoph Heiss
0 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-04 15:06 UTC (permalink / raw)
To: Lukas Wagner; +Cc: Proxmox Datacenter Manager development discussion
On Thu Dec 4, 2025 at 3:17 PM CET, Lukas Wagner wrote:
> On Thu Dec 4, 2025 at 1:51 PM CET, Christoph Heiss wrote:
>> 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.
>>
>> It provides two new panels:
>>
>> - Automated Installations: List all past and currently ongoing
>> installations.
>> - Prepared Answers: Enables users to do the whole CRUD cycle for
>> prepared answers, i.e. create new answers (from scratch or based on
>> existing ones), edit and delete.
>>
>> Permission-wise, everything is currently scoped on
>> /system/auto-installation.
>>
>> UI
>> ==
>>
>> Happy about feedback regarding the UI, especially the editing dialog! By
>> the nature of it, the auto-installer has a lot more options than the
>> standard GUI/TUI installer, and it's kind of hard to not fully cramp up
>> the UI while still providing all options.
>>
>> 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?).
>>
>> 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.
>>
>
> Cool stuff, I didn't even know that somebody was working on this!
>
> Couldn't really try it out, I think there is some issue in the patches
> that you've posted. I'm getting a 'unresolved import
> `proxmox_installer_types::answer::fetch::AnswerFetchResponse' error when
> trying to compile PDM. Checking the code in the installer-types crate, I
> also couldn't find a type like this - could you cross-check?
Thanks for the report, and sorry!
Yeah, definitely a mistake on my end - some leftover "gunk" from an
earlier iteration I had still lying around (for later usage with the
wait-mechanism). I'll send a v2 shortly!
>
> Thanks!
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 17+ messages in thread
* Re: [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
` (13 preceding siblings ...)
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
@ 2025-12-05 11:26 ` Christoph Heiss
14 siblings, 0 replies; 17+ messages in thread
From: Christoph Heiss @ 2025-12-05 11:26 UTC (permalink / raw)
To: Proxmox Datacenter Manager development discussion
v2 posted: https://lore.proxmox.com/pdm-devel/20251205112528.373387-1-c.heiss@proxmox.com/
On Thu Dec 4, 2025 at 1:51 PM CET, Christoph Heiss wrote:
> 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.
>
> It provides two new panels:
>
> - Automated Installations: List all past and currently ongoing
> installations.
> - Prepared Answers: Enables users to do the whole CRUD cycle for
> prepared answers, i.e. create new answers (from scratch or based on
> existing ones), edit and delete.
>
> Permission-wise, everything is currently scoped on
> /system/auto-installation.
>
> UI
> ==
>
> Happy about feedback regarding the UI, especially the editing dialog! By
> the nature of it, the auto-installer has a lot more options than the
> standard GUI/TUI installer, and it's kind of hard to not fully cramp up
> the UI while still providing all options.
>
> 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?).
>
> 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.
>
> Other future work
> =================
>
> - Switch the installer to use the `proxmox-installer-types` crate too
> Left this out for now (though I have a mostly-done branch locally
> already), to ease review.
>
> - Target filter selection by dropdown.
> Based on the system information schema, a list of all possible
> JSON pointers could be generated and displayed to the user, in a form
> consisting of the key, a text input for the value/glob to match and a
> set of add/remove buttons for each filter.
>
> - Display/saving the installation progress/log. The progress log
> generated by the auto-installer can be sent to PDM, showing it there
> similar to tasks.
> Requires changes to the auto-installer, so this would not be
> backwards-compatible with older ISOs.
>
> - Implement a "wait for answer"-ish system. Core idea here is that the
> installer waits for PDM to provide in answer by implemented e.g. an
> retry system. The use case is for to be able to effectively install
> systems on demand, without the need for preparations.
> Again requires changes to the auto-installer and wouldn't be
> backwards-compatible.
>
> [0] https://pve.proxmox.com/wiki/Automated_Installation
>
> Diffstat
> ========
>
> proxmox:
>
> Christoph Heiss (8):
> api-macro: allow $ in identifier name
> network-types: move `Fqdn` type from proxmox-installer-common
> network-types: implement api type for Fqdn
> network-types: cidr: add family-independent constructor and access
> network-types: add api wrapper type for std::net::IpAddr
> installer-types: add common types used by the installer
> installer-types: add types used by the auto-installer
> installer-types: implement api type for all externally-used types
>
> Cargo.toml | 1 +
> proxmox-api-macro/src/util.rs | 2 +-
> proxmox-installer-types/Cargo.toml | 26 +
> proxmox-installer-types/debian/changelog | 5 +
> proxmox-installer-types/debian/control | 67 ++
> proxmox-installer-types/debian/debcargo.toml | 7 +
> proxmox-installer-types/src/answer.rs | 1017 ++++++++++++++++++
> proxmox-installer-types/src/lib.rs | 183 ++++
> proxmox-installer-types/src/post_hook.rs | 239 ++++
> proxmox-network-types/Cargo.toml | 3 +-
> proxmox-network-types/debian/control | 2 +
> proxmox-network-types/src/fqdn.rs | 262 +++++
> proxmox-network-types/src/ip_address.rs | 98 +-
> proxmox-network-types/src/lib.rs | 1 +
> 14 files changed, 1909 insertions(+), 4 deletions(-)
> 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/answer.rs
> create mode 100644 proxmox-installer-types/src/lib.rs
> create mode 100644 proxmox-installer-types/src/post_hook.rs
> create mode 100644 proxmox-network-types/src/fqdn.rs
>
> proxmox-datacenter-manager:
>
> Christoph Heiss (6):
> api-types: add api types for auto-installer integration
> config: add auto-installer configuration module
> acl: wire up new /system/auto-installation acl path
> server: api: add auto-installer integration module
> ui: auto-installer: add installations overview panel
> ui: auto-installer: add prepared answer configuration panel
>
> Cargo.toml | 6 +
> debian/control | 5 +
> lib/pdm-api-types/Cargo.toml | 3 +
> lib/pdm-api-types/src/acl.rs | 4 +-
> lib/pdm-api-types/src/auto_installer.rs | 441 +++++++++
> lib/pdm-api-types/src/lib.rs | 1 +
> 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 +
> server/Cargo.toml | 4 +
> server/src/api/auto_installer/mod.rs | 654 ++++++++++++++
> server/src/api/mod.rs | 2 +
> 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/installations_panel.rs | 289 ++++++
> ui/src/auto_installer/mod.rs | 15 +
> .../auto_installer/prepared_answers_panel.rs | 233 +++++
> .../configuration/permission_path_selector.rs | 1 +
> ui/src/lib.rs | 2 +
> ui/src/main_menu.rs | 22 +
> 23 files changed, 2875 insertions(+), 2 deletions(-)
> create mode 100644 lib/pdm-api-types/src/auto_installer.rs
> create mode 100644 lib/pdm-config/src/auto_install.rs
> create mode 100644 server/src/api/auto_installer/mod.rs
> 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/installations_panel.rs
> create mode 100644 ui/src/auto_installer/mod.rs
> create mode 100644 ui/src/auto_installer/prepared_answers_panel.rs
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 17+ messages in thread