* [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 02/38] schema: oneOf: allow single string variant Christoph Heiss
` (36 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
This allows dollar-sign in renamed field names for API types.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* no changes
Changes v1 -> v2:
* no changes
proxmox-api-macro/src/util.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/proxmox-api-macro/src/util.rs b/proxmox-api-macro/src/util.rs
index db2acdf2..df2b6da8 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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 02/38] schema: oneOf: allow single string variant
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
` (35 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
This allows a `OneOfSchema` to additionally have a single string
variant, i.e. allows to deserialize from either a plain string or some
object.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-schema/src/schema.rs | 68 +++++++++++++++++++++++--
proxmox-schema/tests/schema.rs | 91 +++++++++++++++++++++++++++++++++-
2 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/proxmox-schema/src/schema.rs b/proxmox-schema/src/schema.rs
index 47ee94df..24815bdb 100644
--- a/proxmox-schema/src/schema.rs
+++ b/proxmox-schema/src/schema.rs
@@ -912,6 +912,20 @@ const fn assert_one_of_list_is_sorted(list: &[(&str, &Schema)]) {
}
}
+const fn assert_one_of_zero_or_one_string_schema(list: &[(&str, &Schema)]) {
+ let mut i = 0;
+ let mut already_seen = false;
+ while i != list.len() {
+ if let Schema::String(_) = list[i].1 {
+ if already_seen {
+ panic!("oneOf can have only zero or one string variants");
+ }
+ already_seen = true;
+ }
+ i += 1;
+ }
+}
+
impl OneOfSchema {
/// Create a new `oneOf` schema.
///
@@ -947,6 +961,27 @@ impl OneOfSchema {
/// ).schema();
/// ```
///
+ /// There is also support for the data to be either a string or some object:
+ ///
+ /// ```
+ /// # use proxmox_schema::{OneOfSchema, ObjectSchema, Schema, StringSchema};
+ /// # const SCHEMA_V1: Schema = ObjectSchema::new(
+ /// # "Some Object",
+ /// # &[
+ /// # ("key1", false, &StringSchema::new("A String").schema()),
+ /// # ("key2", false, &StringSchema::new("Another String").schema()),
+ /// # ],
+ /// # ).schema();
+ /// const SCHEMA: Schema = OneOfSchema::new(
+ /// "A plain string or some enum",
+ /// &("type", false, &StringSchema::new("v1 or v2").schema()),
+ /// &[
+ /// ("plain-string", &StringSchema::new("some string").schema()),
+ /// ("v1", &SCHEMA_V1),
+ /// ],
+ /// ).schema();
+ /// ```
+ ///
/// These will panic:
///
/// ```compile_fail,E0080
@@ -1001,12 +1036,28 @@ impl OneOfSchema {
/// ],
/// ).schema();
/// ```
+ ///
+ /// ```compile_fail,E0080
+ /// # use proxmox_schema::{OneOfSchema, ObjectSchema, Schema, StringSchema};
+ /// # const SCHEMA_V1: Schema = &StringSchema::new("A String").schema()
+ /// # const SCHEMA_V2: Schema = &StringSchema::new("Another String").schema()
+ /// const SCHEMA: Schema = OneOfSchema::new(
+ /// "Some enum",
+ /// &("type", false, &StringSchema::new("v1 or v2").schema()),
+ /// &[
+ /// ("v1", &SCHEMA_V1),
+ /// // more than one string schema:
+ /// ("v2", &SCHEMA_V2),
+ /// ],
+ /// ).schema();
+ /// ```
pub const fn new(
description: &'static str,
type_property_entry: &'static SchemaPropertyEntry,
list: &'static [(&'static str, &'static Schema)],
) -> Self {
assert_one_of_list_is_sorted(list);
+ assert_one_of_zero_or_one_string_schema(list);
Self {
description,
type_property_entry,
@@ -1065,6 +1116,12 @@ impl OneOfSchema {
) -> Result<Value, ParameterError> {
ParameterSchema::from(self).parse_parameter_strings(data, test_required)
}
+
+ fn string_variant(&self) -> Option<&Schema> {
+ self.list
+ .iter()
+ .find_map(|(_, item)| matches!(item, Schema::String(_)).then_some(&**item))
+ }
}
mod private {
@@ -1271,11 +1328,12 @@ impl ObjectSchemaType for OneOfSchema {
}
fn additional_properties(&self) -> bool {
- self.list.iter().any(|(_, schema)| {
- schema
+ self.list.iter().any(|(_, schema)| match schema {
+ Schema::String(_) => false,
+ _ => schema
.any_object()
.expect("non-object-schema in `OneOfSchema`")
- .additional_properties()
+ .additional_properties(),
})
}
@@ -1286,6 +1344,10 @@ impl ObjectSchemaType for OneOfSchema {
fn verify_json(&self, data: &Value) -> Result<(), Error> {
let map = match data {
Value::Object(map) => map,
+ Value::String(_) => match self.string_variant() {
+ Some(schema) => return schema.verify_json(data),
+ None => bail!("Expected object - got string value."),
+ },
Value::Array(_) => bail!("Expected object - got array."),
_ => bail!("Expected object - got scalar value."),
};
diff --git a/proxmox-schema/tests/schema.rs b/proxmox-schema/tests/schema.rs
index 24c32bef..22d6538e 100644
--- a/proxmox-schema/tests/schema.rs
+++ b/proxmox-schema/tests/schema.rs
@@ -1,5 +1,5 @@
use anyhow::bail;
-use serde_json::Value;
+use serde_json::{json, Value};
use url::form_urlencoded;
use proxmox_schema::*;
@@ -390,3 +390,92 @@ fn test_verify_complex_array() {
assert!(res.is_err());
}
}
+
+#[test]
+fn test_one_of_schema_string_variant() {
+ const OBJECT1_SCHEMA: Schema = ObjectSchema::new(
+ "Object 1",
+ &[
+ ("a", false, &StringSchema::new("A property").schema()),
+ ("type", false, &StringSchema::new("v1 or v2").schema()),
+ ],
+ )
+ .schema();
+ const OBJECT2_SCHEMA: Schema = ObjectSchema::new(
+ "Object 2",
+ &[
+ (
+ "b",
+ true,
+ &StringSchema::new("A optional property").schema(),
+ ),
+ ("type", false, &StringSchema::new("v1 or v2").schema()),
+ ],
+ )
+ .schema();
+
+ const NO_STRING_VARIANT_SCHEMA: OneOfSchema = OneOfSchema::new(
+ "An oneOf schema",
+ &("type", false, &StringSchema::new("v1 or v2").schema()),
+ &[("v1", &OBJECT1_SCHEMA), ("v2", &OBJECT2_SCHEMA)],
+ );
+
+ const ONE_STRING_VARIANT_SCHEMA: OneOfSchema = OneOfSchema::new(
+ "An oneOf schema with a string variant",
+ &(
+ "type",
+ false,
+ &StringSchema::new("string or v1 or v2").schema(),
+ ),
+ &[
+ (
+ "name does not matter",
+ &StringSchema::new("A string").schema(),
+ ),
+ ("v1", &OBJECT1_SCHEMA),
+ ("v2", &OBJECT2_SCHEMA),
+ ],
+ );
+
+ NO_STRING_VARIANT_SCHEMA
+ .verify_json(&json!({
+ "type": "v1", "a": "foo"
+ }))
+ .expect("should verify");
+
+ ONE_STRING_VARIANT_SCHEMA
+ .verify_json(&json!({
+ "type": "v2", "b": "foo"
+ }))
+ .expect("should verify");
+
+ ONE_STRING_VARIANT_SCHEMA
+ .verify_json(&json!("plain string"))
+ .expect("should verify");
+}
+
+#[test]
+#[should_panic(expected = "oneOf can have only zero or one string variants")]
+fn test_one_of_schema_with_multiple_string_variant() {
+ const OBJECT1_SCHEMA: Schema = ObjectSchema::new(
+ "Object 1",
+ &[
+ ("a", false, &StringSchema::new("A property").schema()),
+ ("type", false, &StringSchema::new("v1 or v2").schema()),
+ ],
+ )
+ .schema();
+ const TYPE_SCHEMA: Schema = StringSchema::new("string or string or v1").schema();
+ const STRING1_SCHEMA: Schema = StringSchema::new("A string").schema();
+ const STRING2_SCHEMA: Schema = StringSchema::new("Another string").schema();
+
+ let _ = OneOfSchema::new(
+ "An invalid oneOf schema with multiple string variant",
+ &("type", false, &TYPE_SCHEMA),
+ &[
+ ("string variant 1", &STRING1_SCHEMA),
+ ("v1", &OBJECT1_SCHEMA),
+ ("whoops", &STRING2_SCHEMA),
+ ],
+ );
+}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 02/38] schema: oneOf: allow single string variant Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 04/38] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
` (34 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-schema/src/schema.rs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/proxmox-schema/src/schema.rs b/proxmox-schema/src/schema.rs
index 24815bdb..cff868db 100644
--- a/proxmox-schema/src/schema.rs
+++ b/proxmox-schema/src/schema.rs
@@ -4,7 +4,7 @@
//! completely static API definitions that can be included within the programs read-only text
//! segment.
-use std::collections::HashSet;
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use anyhow::{bail, format_err, Error};
@@ -2139,6 +2139,14 @@ impl<T> UpdaterType for crate::property_string::PropertyString<T> {
type Updater = Option<Self>;
}
+impl<K, V> UpdaterType for HashMap<K, V> {
+ type Updater = Option<Self>;
+}
+
+impl<K, V> UpdaterType for BTreeMap<K, V> {
+ type Updater = Option<Self>;
+}
+
/// Trait signifying that a type contains an API schema.
pub trait ApiType {
const API_SCHEMA: Schema;
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 04/38] network-types: move `Fqdn` type from proxmox-installer-common
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (2 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn Christoph Heiss
` (33 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
This introduces an `Fqdn` type for safely representing (valid) FQDNs on
Debian, following all relevant RFCs as well as restrictions given by
Debian.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* make the presence of the hostname an invariant (thanks Lukas!)
Changes v1 -> v2:
* no changes
proxmox-network-types/Cargo.toml | 3 +-
proxmox-network-types/debian/control | 8 +-
proxmox-network-types/src/fqdn.rs | 241 +++++++++++++++++++++++++++
proxmox-network-types/src/lib.rs | 1 +
4 files changed, 249 insertions(+), 4 deletions(-)
create mode 100644 proxmox-network-types/src/fqdn.rs
diff --git a/proxmox-network-types/Cargo.toml b/proxmox-network-types/Cargo.toml
index 9f77d40c..c16be8de 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 546436e5..b221e701 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:
@@ -47,9 +49,9 @@ Multi-Arch: same
Depends:
${misc:Depends},
librust-proxmox-network-types-dev (= ${binary:Version}),
- librust-proxmox-schema-5+api-macro-dev (>= 5.1.0-~~),
- librust-proxmox-schema-5+api-types-dev (>= 5.1.0-~~),
- librust-proxmox-schema-5+default-dev (>= 5.1.0-~~),
+ librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~),
+ librust-proxmox-schema-5+api-types-dev (>= 5.1.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.1.1-~~),
librust-regex-1+default-dev (>= 1.5-~~)
Provides:
librust-proxmox-network-types-1+api-types-dev (= ${binary:Version}),
diff --git a/proxmox-network-types/src/fqdn.rs b/proxmox-network-types/src/fqdn.rs
new file mode 100644
index 00000000..2b5fc7b2
--- /dev/null
+++ b/proxmox-network-types/src/fqdn.rs
@@ -0,0 +1,241 @@
+//! 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 })
+ }
+ }
+
+ /// Returns the host part of the FQDN, i.e. up to the first dot.
+ pub fn host(&self) -> &str {
+ // INVARIANT: A constructed FQDN must always have a host name, estalished by the parser in
+ // [`Fqdn::from`].
+ &self.parts[0]
+ }
+
+ /// Returns the domain part of the FQDN, i.e. everything starting from the first dot.
+ pub fn domain(&self) -> String {
+ self.parts[1..].join(".")
+ }
+
+ 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(), "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 3b17488b..fca80691 100644
--- a/proxmox-network-types/src/lib.rs
+++ b/proxmox-network-types/src/lib.rs
@@ -2,6 +2,7 @@
#![deny(unsafe_op_in_unsafe_fn)]
pub mod endpoint;
+pub mod fqdn;
pub mod ip_address;
pub use ip_address::*;
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (3 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 04/38] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 06/38] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
` (32 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Pretty straight-forward, as we already got a fitting regex defined for a
FQDN.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* no changes
Changes v1 -> v2:
* no changes
proxmox-network-types/src/fqdn.rs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/proxmox-network-types/src/fqdn.rs b/proxmox-network-types/src/fqdn.rs
index 2b5fc7b2..24979601 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>,
}
@@ -125,6 +129,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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 06/38] network-types: add api wrapper type for std::net::IpAddr
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (4 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
` (31 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Much like the existing ones for Ipv4Addr/Ipv6Addr.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* no changes
Changes v1 -> v2:
* no changes
proxmox-network-types/src/ip_address.rs | 64 ++++++++++++++++++++++++-
1 file changed, 62 insertions(+), 2 deletions(-)
diff --git a/proxmox-network-types/src/ip_address.rs b/proxmox-network-types/src/ip_address.rs
index 79a92aae..d05877ee 100644
--- a/proxmox-network-types/src/ip_address.rs
+++ b/proxmox-network-types/src/ip_address.rs
@@ -99,8 +99,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};
/// A wrapper around [`std::net::Ipv4Addr`] that implements [`ApiType`].
@@ -220,6 +222,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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (5 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 06/38] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 08/38] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
` (30 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-network-types/src/ip_address.rs | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/proxmox-network-types/src/ip_address.rs b/proxmox-network-types/src/ip_address.rs
index d05877ee..15a73176 100644
--- a/proxmox-network-types/src/ip_address.rs
+++ b/proxmox-network-types/src/ip_address.rs
@@ -360,6 +360,15 @@ impl Cidr {
Ok(Cidr::Ipv6(Ipv6Cidr::new(addr, mask)?))
}
+ /// Constructs a new [`Cidr`] from an generic [`IpAddr`], which can either be a IPv4 or IPv6
+ /// address
+ pub fn new(addr: impl Into<IpAddr>, mask: u8) -> Result<Self, CidrError> {
+ match addr.into() {
+ IpAddr::V4(v4) => Self::new_v4(v4, mask),
+ IpAddr::V6(v6) => Self::new_v6(v6, mask),
+ }
+ }
+
/// Returns the [`Family`] (v4 or v6) this CIDR belongs to.
pub const fn family(&self) -> Family {
match self {
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 08/38] network-types: fqdn: implement standard library Error for Fqdn
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (6 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
` (29 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Greatly improves intercompatibility with e.g. anyhow, which relies on
this trait for automatic conversions.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-network-types/src/fqdn.rs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/proxmox-network-types/src/fqdn.rs b/proxmox-network-types/src/fqdn.rs
index 24979601..44ab6538 100644
--- a/proxmox-network-types/src/fqdn.rs
+++ b/proxmox-network-types/src/fqdn.rs
@@ -31,6 +31,8 @@ impl fmt::Display for FqdnParseError {
}
}
+impl std::error::Error for FqdnParseError {}
+
/// A type for safely representing fully-qualified domain names (FQDNs).
///
/// It considers following RFCs:
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (7 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 08/38] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 10/38] installer-types: add common types used by the installer Christoph Heiss
` (28 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Needed by the new proxmox-installer-types crate.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-node-status/src/types.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/proxmox-node-status/src/types.rs b/proxmox-node-status/src/types.rs
index cc0ba424..6b3130f9 100644
--- a/proxmox-node-status/src/types.rs
+++ b/proxmox-node-status/src/types.rs
@@ -27,7 +27,7 @@ pub struct BootModeInformation {
}
#[api]
-#[derive(Serialize, Deserialize, Default)]
+#[derive(Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
/// The current kernel version (output of `uname`)
pub struct KernelVersionInformation {
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 10/38] installer-types: add common types used by the installer
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (8 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer Christoph Heiss
` (27 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
These moves common type definitions used throughout in the installer in
it's own crate, so they can be re-used in other places.
Some types are also renamed to improve clarity:
- `NetdevWithMac` -> `NetworkInterface`
- `SysInfo` -> `SystemInfo`
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* changed `ProxmoxProduct` variants to PascalCase
* fixed typo in `Display` impl
Changes v1 -> v2:
* no changes
Note: The custom `BootType` enum was retained, as using an existing
variant proved to be much more work/hacky due to different
(de-)serialization expectations.
Cargo.toml | 1 +
proxmox-installer-types/Cargo.toml | 20 +++
proxmox-installer-types/debian/changelog | 5 +
proxmox-installer-types/debian/control | 38 +++++
proxmox-installer-types/debian/debcargo.toml | 7 +
proxmox-installer-types/src/lib.rs | 142 +++++++++++++++++++
6 files changed, 213 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 762fae36..bb1091a6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ members = [
"proxmox-http-error",
"proxmox-human-byte",
"proxmox-ini",
+ "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..7b4db506
--- /dev/null
+++ b/proxmox-installer-types/Cargo.toml
@@ -0,0 +1,20 @@
+[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 = []
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..d7e72f64
--- /dev/null
+++ b/proxmox-installer-types/debian/control
@@ -0,0 +1,38 @@
+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.85) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~) <!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-1+default-dev (>= 1.0.2-~~),
+ 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-0-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0+default-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.0-dev (= ${binary:Version}),
+ librust-proxmox-installer-types-0.1.0+default-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..adce4166
--- /dev/null
+++ b/proxmox-installer-types/src/lib.rs
@@ -0,0 +1,142 @@
+//! 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};
+
+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, Eq, PartialOrd, Ord, 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);
+serde_plain::derive_display_from_serialize!(ProxmoxProduct);
+
+impl ProxmoxProduct {
+ /// Returns the full name for the given product.
+ pub fn full_name(&self) -> &str {
+ match self {
+ Self::Pve => "Proxmox Virtual Environment",
+ Self::Pbs => "Proxmox Backup Server",
+ Self::Pmg => "Proxmox Mail Gateway",
+ Self::Pdm => "Proxmox Datacenter Manager",
+ }
+ }
+}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (9 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 10/38] installer-types: add common types used by the installer Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 12/38] installer-types: implement api type for all externally-used types Christoph Heiss
` (26 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 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.
Some types are also renamed for clarity:
- `Answer` -> `AutoInstallerConfig`
- `FqdnExtendedConfig` -> `FqdnFromDhcpConfig`
- `FsOptions` -> `FilesystemOptions`
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* `KeyboardLayout` now de/serializes strictly into the shortcode,
the human-readable name was moved to a dedicated method
* `FilesystemOptions` now has separate variants for ext4 and xfs
instead of a combined LVM variant, easing usage
* use `KernelVersionInformation` struct from proxmox-node-status
instead for NIH
Changes v1 -> v2:
* no changes
Cargo.toml | 1 +
proxmox-installer-types/Cargo.toml | 4 +
proxmox-installer-types/debian/control | 10 +-
proxmox-installer-types/src/answer.rs | 933 +++++++++++++++++++++++
proxmox-installer-types/src/lib.rs | 3 +
proxmox-installer-types/src/post_hook.rs | 160 ++++
6 files changed, 1110 insertions(+), 1 deletion(-)
create mode 100644 proxmox-installer-types/src/answer.rs
create mode 100644 proxmox-installer-types/src/post_hook.rs
diff --git a/Cargo.toml b/Cargo.toml
index bb1091a6..e386eabb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -191,6 +191,7 @@ proxmox-tfa = { version = "6.0.0", path = "proxmox-tfa" }
proxmox-time = { version = "2.1.0", path = "proxmox-time" }
proxmox-uuid = { version = "1.1.0", path = "proxmox-uuid" }
proxmox-worker-task = { version = "1.0.0", path = "proxmox-worker-task" }
+proxmox-node-status = { version = "1.0.0", path = "proxmox-node-status" }
[workspace.dependencies.http_1]
package = "http"
diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml
index 7b4db506..b4906fc9 100644
--- a/proxmox-installer-types/Cargo.toml
+++ b/proxmox-installer-types/Cargo.toml
@@ -12,9 +12,13 @@ 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
+proxmox-node-status.workspace = true
[features]
default = []
+# enable old-style answer file keys with underscores for backwards compatibility
+legacy = []
diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control
index d7e72f64..5971fd6a 100644
--- a/proxmox-installer-types/debian/control
+++ b/proxmox-installer-types/debian/control
@@ -6,7 +6,9 @@ Build-Depends: debhelper-compat (= 13),
Build-Depends-Arch: cargo:native <!nocheck>,
rustc:native (>= 1.85) <!nocheck>,
libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~) <!nocheck>,
+ librust-proxmox-node-status-1+default-dev <!nocheck>,
librust-serde-1+default-dev <!nocheck>,
librust-serde-1+derive-dev <!nocheck>,
librust-serde-plain-1+default-dev <!nocheck>
@@ -22,17 +24,23 @@ Architecture: any
Multi-Arch: same
Depends:
${misc:Depends},
+ librust-anyhow-1+default-dev,
librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~),
+ librust-proxmox-node-status-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+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/src/answer.rs b/proxmox-installer-types/src/answer.rs
new file mode 100644
index 00000000..10cd57a0
--- /dev/null
+++ b/proxmox-installer-types/src/answer.rs
@@ -0,0 +1,933 @@
+//! 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>,
+}
+
+#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+/// Options controlling the behaviour of the network interface pinning (by
+/// creating appropriate systemd.link files) during the installation.
+pub struct NetworkInterfacePinningOptionsAnswer {
+ /// Whether interfaces should be pinned during the installation.
+ pub enabled: bool,
+ /// Maps MAC address to custom name
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ 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, skip_serializing_if = "BTreeMap::is_empty")]
+ /// 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, skip_serializing_if = "Option::is_none")]
+ 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, skip_serializing_if = "Option::is_none")]
+ 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),
+}
+
+impl NetworkConfig {
+ /// Returns the network interface pinning option answer, if any.
+ pub fn interface_name_pinning(&self) -> Option<&NetworkInterfacePinningOptionsAnswer> {
+ match self {
+ Self::FromDhcp(dhcp) => dhcp.interface_name_pinning.as_ref(),
+ Self::FromAnswer(answer) => answer.interface_name_pinning.as_ref(),
+ }
+ }
+}
+
+#[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 {
+ /// Ext4-specific options.
+ Ext4(LvmOptions),
+ /// Ext4-specific options.
+ Xfs(LvmOptions),
+ /// Btrfs-specific options.
+ Btrfs(BtrfsOptions),
+ /// ZFS-specific options.
+ Zfs(ZfsOptions),
+}
+
+impl FilesystemOptions {
+ /// Returns the accompanying [`FilesystemType`] for this configuration.
+ pub fn to_type(&self) -> FilesystemType {
+ match self {
+ FilesystemOptions::Ext4(_) => FilesystemType::Ext4,
+ FilesystemOptions::Xfs(_) => FilesystemType::Xfs,
+ FilesystemOptions::Zfs(ZfsOptions { raid, .. }) => {
+ FilesystemType::Zfs(raid.unwrap_or_default())
+ }
+ FilesystemOptions::Btrfs(BtrfsOptions { raid, .. }) => {
+ FilesystemType::Btrfs(raid.unwrap_or_default())
+ }
+ }
+ }
+}
+
+#[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<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(FilesystemOptions::Xfs(self.lvm.unwrap_or_default()))
+ }
+ Filesystem::Ext4 => {
+ lvm_checks()?;
+ Ok(FilesystemOptions::Ext4(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(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(FilesystemOptions::Btrfs(opts)),
+ }
+ }
+ }
+ }
+}
+
+
+#[derive(Copy, 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,
+}
+
+impl From<FilesystemType> for Filesystem {
+ fn from(value: FilesystemType) -> Self {
+ match value {
+ FilesystemType::Ext4 => Self::Ext4,
+ FilesystemType::Xfs => Self::Xfs,
+ FilesystemType::Zfs(_) => Self::Zfs,
+ FilesystemType::Btrfs(_) => Self::Btrfs,
+ }
+ }
+}
+
+serde_plain::derive_display_from_serialize!(Filesystem);
+serde_plain::derive_fromstr_from_deserialize!(Filesystem);
+
+#[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 KeyboardLayout {
+ /// Returns the human-readable name for this [`KeyboardLayout`].
+ pub fn human_name(&self) -> &str {
+ match self {
+ Self::Dk => "Danish",
+ Self::De => "German",
+ Self::DeCh => "Swiss-German",
+ Self::EnGb => "United Kingdom",
+ Self::EnUs => "U.S. English",
+ Self::Es => "Spanish",
+ Self::Fi => "Finnish",
+ Self::Fr => "French",
+ Self::FrBe => "Belgium-French",
+ Self::FrCa => "Canada-French",
+ Self::FrCh => "Swiss-French",
+ Self::Hu => "Hungarian",
+ Self::Is => "Icelandic",
+ Self::It => "Italian",
+ Self::Jp => "Japanese",
+ Self::Lt => "Lithuanian",
+ Self::Mk => "Macedonian",
+ Self::Nl => "Dutch",
+ Self::No => "Norwegian",
+ Self::Pl => "Polish",
+ Self::Pt => "Portuguese",
+ Self::PtBr => "Brazil-Portuguese",
+ Self::Si => "Slovenian",
+ Self::Se => "Swedish",
+ Self::Tr => "Turkish",
+ }
+ }
+}
+
+serde_plain::derive_fromstr_from_deserialize!(KeyboardLayout);
+serde_plain::derive_display_from_serialize!(KeyboardLayout);
+
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
+#[serde(rename_all = "UPPERCASE")]
+/// Available Btrfs RAID levels.
+pub enum BtrfsRaidLevel {
+ #[default]
+ #[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, Default, Deserialize, Serialize, Eq, PartialEq)]
+#[serde(rename_all = "UPPERCASE")]
+/// Available ZFS RAID levels.
+pub enum ZfsRaidLevel {
+ #[default]
+ #[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 adce4166..40c61252 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..a307cf7b
--- /dev/null
+++ b/proxmox-installer-types/src/post_hook.rs
@@ -0,0 +1,160 @@
+//! 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,
+};
+
+/// Re-export for convenience, since this is public API
+pub use proxmox_node_status::KernelVersionInformation;
+
+#[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")]
+ pub 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
+ pub 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")]
+ pub 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)]
+/// Information about the CPU(s) installed in the system
+pub struct CpuInfo {
+ /// Number of physical CPU cores.
+ pub cores: usize,
+ /// Number of logical CPU cores aka. threads.
+ pub cpus: usize,
+ /// 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: usize,
+}
+
+#[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,
+}
+
+#[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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH proxmox v3 12/38] installer-types: implement api type for all externally-used types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (10 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
` (25 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
PDM will (re-)use most of these types directly in the API, thus make
them compatible.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* describe all API properties, some fields were previously missing
* implement api for a few more types as necessary
Changes v1 -> v2:
* no changes
proxmox-installer-types/Cargo.toml | 4 +
proxmox-installer-types/debian/control | 21 ++
proxmox-installer-types/src/answer.rs | 268 ++++++++++++++++++++++-
proxmox-installer-types/src/lib.rs | 37 ++++
proxmox-installer-types/src/post_hook.rs | 55 +++++
5 files changed, 384 insertions(+), 1 deletion(-)
diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml
index b4906fc9..c37e1baf 100644
--- a/proxmox-installer-types/Cargo.toml
+++ b/proxmox-installer-types/Cargo.toml
@@ -15,10 +15,14 @@ rust-version.workspace = true
anyhow.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_plain.workspace = true
+regex = { workspace = true, optional = true }
proxmox-network-types.workspace = true
+proxmox-schema = { workspace = true, optional = true, features = ["api-macro"] }
+proxmox-section-config = { workspace = true, optional = true }
proxmox-node-status.workspace = true
[features]
default = []
+api-types = ["dep:regex", "dep:proxmox-schema", "dep:proxmox-section-config", "proxmox-network-types/api-types"]
# enable old-style answer file keys with underscores for backwards compatibility
legacy = []
diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control
index 5971fd6a..2c6be448 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-1+api-types-dev (>= 1.0.2-~~),
+ librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.1.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 10cd57a0..975063ba 100644
--- a/proxmox-installer-types/src/answer.rs
+++ b/proxmox-installer-types/src/answer.rs
@@ -15,15 +15,37 @@ use std::{
};
use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::{
+ api,
+ api_types::{DISK_ARRAY_SCHEMA, PASSWORD_FORMAT},
+ ApiType, IntegerSchema, NumberSchema, ObjectSchema, OneOfSchema, Schema, 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 +72,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).
@@ -68,6 +97,7 @@ pub mod fetch {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[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
@@ -91,6 +121,25 @@ 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();
+
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "root-ssh-keys": {
+ type: Array,
+ items: {
+ description: "Public SSH key.",
+ type: String,
+ }
+ },
+ },
+))]
#[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 +179,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 +194,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"
@@ -163,6 +214,23 @@ impl Default for FqdnConfig {
}
}
+#[cfg(feature = "api-types")]
+impl ApiType for FqdnConfig {
+ const API_SCHEMA: Schema = OneOfSchema::new(
+ "Either a FQDN as string or an object describing the retrieval method.",
+ &(
+ "type",
+ false,
+ &StringSchema::new("A string or an object").schema(),
+ ),
+ &[
+ ("from-dhcp", &<FqdnFromDhcpConfig as ApiType>::API_SCHEMA),
+ ("simple", &StringSchema::new("Plain FQDN").schema()),
+ ],
+ )
+ .schema();
+}
+
impl FqdnConfig {
/// Constructs a new "simple" FQDN configuration, i.e. a fixed hostname.
pub fn simple<S: Into<String>>(fqdn: S) -> Result<Self> {
@@ -183,6 +251,7 @@ impl FqdnConfig {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Default, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Extended configuration for retrieving the FQDN from external sources.
@@ -195,6 +264,7 @@ pub struct FqdnFromDhcpConfig {
pub domain: Option<String>,
}
+#[cfg_attr(feature = "api-types", api)]
#[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.
@@ -204,6 +274,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 +287,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 +299,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 +329,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
@@ -276,6 +350,15 @@ pub struct FirstBootHookInfo {
pub cert_fingerprint: Option<String>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ mapping: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ }
+ },
+))]
#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Options controlling the behaviour of the network interface pinning (by
@@ -288,6 +371,15 @@ pub struct NetworkInterfacePinningOptionsAnswer {
pub mapping: HashMap<String, String>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ filter: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ }
+ },
+))]
#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Static network configuration given by the user.
@@ -307,6 +399,7 @@ pub struct NetworkConfigFromAnswer {
pub interface_name_pinning: Option<NetworkInterfacePinningOptionsAnswer>,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Use the network configuration received from the DHCP server.
@@ -317,6 +410,13 @@ pub struct NetworkConfigFromDhcp {
pub interface_name_pinning: Option<NetworkInterfacePinningOptionsAnswer>,
}
+#[cfg_attr(feature = "api-types", api(
+ "id-property": "source",
+ "id-schema": {
+ type: String,
+ description: "'from-dhcp' or 'from-answer'",
+ }
+))]
#[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.
@@ -339,6 +439,7 @@ impl NetworkConfig {
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[cfg_attr(feature = "api-types", derive(UpdaterType))]
#[serde(rename_all = "kebab-case", tag = "filesystem")]
/// Filesystem-specific options to set on the root disk.
pub enum FilesystemOptions {
@@ -368,6 +469,66 @@ impl FilesystemOptions {
}
}
+#[cfg(feature = "api-types")]
+impl ApiType for FilesystemOptions {
+ // FIXME: proxmox-schema can not correctly differentiate between different
+ // enums in struct members with the same name.
+ const API_SCHEMA: Schema = ObjectSchema::new(
+ "Filesystem-specific options to set on the root disk.",
+ &[
+ (
+ "ashift",
+ true,
+ &IntegerSchema::new("`ashift` value to create the zpool with.")
+ .minimum(9)
+ .maximum(16)
+ .default(12)
+ .schema(),
+ ),
+ ("filesystem", false, &Filesystem::API_SCHEMA),
+ (
+ "hdsize",
+ true,
+ &NumberSchema::new("Size of the root disk to use, in GiB.")
+ .minimum(2.)
+ .schema(),
+ ),
+ (
+ "maxfree",
+ true,
+ &NumberSchema::new(
+ "Minimum amount of free space to leave on the LVM volume group, in GiB.",
+ )
+ .minimum(0.)
+ .schema(),
+ ),
+ (
+ "maxroot",
+ true,
+ &NumberSchema::new("Maximum size of the `root` volume, in GiB.")
+ .minimum(2.)
+ .schema(),
+ ),
+ (
+ "maxvz",
+ true,
+ &NumberSchema::new("Maximum size of the `data` volume, in GiB.")
+ .minimum(0.)
+ .schema(),
+ ),
+ (
+ "swapsize",
+ true,
+ &NumberSchema::new("Size of the swap volume, in GiB.")
+ .minimum(0.)
+ .schema(),
+ ),
+ ],
+ )
+ .additional_properties(true)
+ .schema();
+}
+
#[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.
@@ -393,6 +554,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
@@ -407,6 +569,18 @@ pub enum FilterMatch {
serde_plain::derive_fromstr_from_deserialize!(FilterMatch);
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "disk-list": {
+ schema: DISK_ARRAY_SCHEMA,
+ },
+ filter: {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ }
+ },
+))]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Disk configuration for the target installation.
@@ -502,7 +676,7 @@ impl DiskSetup {
}
}
-
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Deserialize, Serialize, Debug, PartialEq)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
/// Available filesystem during installation.
@@ -531,6 +705,46 @@ impl From<FilesystemType> for Filesystem {
serde_plain::derive_display_from_serialize!(Filesystem);
serde_plain::derive_fromstr_from_deserialize!(Filesystem);
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ raid: {
+ type: ZfsRaidLevel,
+ optional: true,
+ },
+ 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,
+ },
+ checksum: {
+ type: ZfsChecksumOption,
+ optional: true,
+ },
+ compress: {
+ type: ZfsChecksumOption,
+ optional: true,
+ },
+ copies: {
+ type: Integer,
+ minimum: 1,
+ maximum: 3,
+ optional: true,
+ },
+ hdsize: {
+ type: Number,
+ minimum: 2.,
+ optional: true,
+ },
+ },
+), derive(Updater))]
#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// ZFS-specific filesystem options.
@@ -561,6 +775,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(Updater))]
#[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.
@@ -588,6 +831,23 @@ pub struct LvmOptions {
pub minfree: Option<f64>,
}
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ hdsize: {
+ type: Number,
+ minimum: 2.,
+ optional: true,
+ },
+ raid: {
+ type: BtrfsRaidLevel,
+ optional: true,
+ },
+ compress: {
+ type: BtrfsCompressOption,
+ optional: true,
+ },
+ },
+), derive(Updater))]
#[derive(Clone, Copy, Default, Deserialize, Debug, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
/// Btrfs-specific filesystem options.
@@ -604,6 +864,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.
@@ -697,6 +958,7 @@ impl KeyboardLayout {
serde_plain::derive_fromstr_from_deserialize!(KeyboardLayout);
serde_plain::derive_display_from_serialize!(KeyboardLayout);
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
/// Available Btrfs RAID levels.
@@ -715,6 +977,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
@@ -742,6 +1005,7 @@ pub const BTRFS_COMPRESS_OPTIONS: &[BtrfsCompressOption] = {
&[On, Off, Zlib, Lzo, Zstd]
};
+#[cfg_attr(feature = "api-types", api)]
#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
/// Available ZFS RAID levels.
@@ -769,6 +1033,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.
@@ -799,6 +1064,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.
diff --git a/proxmox-installer-types/src/lib.rs b/proxmox-installer-types/src/lib.rs
index 40c61252..df1f7944 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};
@@ -18,6 +21,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.
@@ -40,6 +44,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 {
@@ -53,6 +67,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 {
@@ -75,6 +90,7 @@ impl ProductConfig {
}
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
/// Information about the ISO itself.
pub struct IsoInfo {
@@ -94,6 +110,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 {
@@ -105,6 +140,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 {
@@ -114,6 +150,7 @@ pub struct NetworkInterface {
pub mac: MacAddress,
}
+#[cfg_attr(feature = "api-types", api)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
diff --git a/proxmox-installer-types/src/post_hook.rs b/proxmox-installer-types/src/post_hook.rs
index a307cf7b..e505a5cf 100644
--- a/proxmox-installer-types/src/post_hook.rs
+++ b/proxmox-installer-types/src/post_hook.rs
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize};
use proxmox_network_types::ip_address::Cidr;
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
use crate::{
answer::{FilesystemType, RebootMode},
@@ -12,6 +14,13 @@ use crate::{
/// Re-export for convenience, since this is public API
pub use proxmox_node_status::KernelVersionInformation;
+#[cfg_attr(feature = "api-types", api(
+ properties: {
+ "secureboot": {
+ optional: true,
+ },
+ },
+))]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// Information about the system boot status.
pub struct BootInfo {
@@ -22,6 +31,7 @@ pub struct BootInfo {
pub 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 {
@@ -33,6 +43,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.
@@ -46,6 +68,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")]
@@ -69,6 +106,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.
@@ -81,6 +119,7 @@ pub struct ProductInfo {
pub version: String,
}
+#[cfg_attr(feature = "api-types", api)]
#[derive(Clone, Serialize, Deserialize, PartialEq)]
/// Information about the CPU(s) installed in the system
pub struct CpuInfo {
@@ -98,6 +137,7 @@ pub struct CpuInfo {
pub sockets: usize,
}
+#[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.
@@ -112,6 +152,21 @@ pub struct PostHookInfoSchema {
pub version: String,
}
+#[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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (11 preceding siblings ...)
2026-04-03 16:53 ` [PATCH proxmox v3 12/38] installer-types: implement api type for all externally-used types Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 14/38] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
` (24 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
A yew-based variant of the existing extjs
`Proxmox.form.WebhookKeyValueList`, but also generic over the value
type.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
src/widget/key_value_list.rs | 429 +++++++++++++++++++++++++++++++++++
src/widget/mod.rs | 3 +
2 files changed, 432 insertions(+)
create mode 100644 src/widget/key_value_list.rs
diff --git a/src/widget/key_value_list.rs b/src/widget/key_value_list.rs
new file mode 100644
index 0000000..80f69a0
--- /dev/null
+++ b/src/widget/key_value_list.rs
@@ -0,0 +1,429 @@
+use anyhow::{Error, bail};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+use serde_json::Value;
+use std::{
+ fmt::{Debug, Display},
+ ops::{Deref, DerefMut},
+ rc::Rc,
+ str::FromStr,
+};
+use yew::virtual_dom::Key;
+
+use crate::{
+ css::{AlignItems, ColorScheme, FlexFit, FontColor},
+ prelude::*,
+ state::Store,
+ widget::{
+ ActionIcon, Button, Column, Container, Fa, Row,
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ form::{
+ Field, InputType, IntoSubmitValidateFn, ManagedField, ManagedFieldContext,
+ ManagedFieldMaster, ManagedFieldScopeExt, ManagedFieldState, SubmitValidateFn,
+ },
+ },
+};
+use pwt_macros::{builder, widget};
+
+#[widget(pwt = crate, comp = ManagedFieldMaster<KeyValueListField<T>>, @input)]
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+/// A [`DataTable`]-based grid to hold a list of user-enterable key-value pairs.
+///
+/// Displays a [`DataTable`] with three columns; key, value and a delete button, with an add button
+/// below to create new rows.
+/// Both key and value are modifiable by the user.
+pub struct KeyValueList<
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+> {
+ #[builder]
+ #[prop_or_default]
+ /// Initial value pairs to display.
+ pub value: Vec<(String, T)>,
+
+ #[builder]
+ #[prop_or(tr!("Name"))]
+ /// Label for the key column, defaults to "Name".
+ pub key_label: String,
+
+ #[builder]
+ #[prop_or_default]
+ /// Placeholder to display in the key columns fields, default is no placeholder.
+ pub key_placeholder: String,
+
+ #[builder]
+ #[prop_or(tr!("Value"))]
+ /// Label for the value column.
+ pub value_label: String,
+
+ #[builder]
+ #[prop_or_default]
+ /// Placeholder to display in the value columns fields, default is no placeholder.
+ pub value_placeholder: String,
+
+ #[builder]
+ #[prop_or_default]
+ /// Input type to set on the value columns fields, default is text.
+ pub value_input_type: InputType,
+
+ #[builder_cb(IntoSubmitValidateFn, into_submit_validate_fn, Vec<(String, T)>)]
+ #[prop_or_default]
+ /// Callback to run on submit on the data in the table.
+ pub submit_validate: Option<SubmitValidateFn<Vec<(String, T)>>>,
+}
+
+impl<T> KeyValueList<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+struct Entry<T: Clone + Debug + PartialEq> {
+ index: usize,
+ key: String,
+ value: T,
+}
+
+pub struct KeyValueListField<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ state: ManagedFieldState,
+ store: Store<Entry<T>>,
+}
+
+impl<T> Deref for KeyValueListField<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ type Target = ManagedFieldState;
+
+ fn deref(&self) -> &Self::Target {
+ &self.state
+ }
+}
+
+impl<T> DerefMut for KeyValueListField<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.state
+ }
+}
+
+pub enum Message {
+ DataChange,
+ UpdateKey(usize, String),
+ UpdateValue(usize, String),
+ RemoveEntry(usize),
+}
+
+impl<T> KeyValueListField<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + for<'d> Deserialize<'d>
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ fn set_data(&mut self, data: Vec<(String, T)>) {
+ self.store.set_data(
+ data.into_iter()
+ .enumerate()
+ .map(|(index, (key, value))| Entry { index, key, value })
+ .collect(),
+ );
+ }
+
+ pub fn sync_from_value(&mut self, value: Value) {
+ match serde_json::from_value::<Vec<(String, T)>>(value) {
+ Ok(items) => self.set_data(items),
+ Err(_err) => {
+ // unable to parse list, likely caused by the user editing items.
+ // simply ignore errors
+ }
+ }
+ }
+
+ fn columns(
+ ctx: &ManagedFieldContext<KeyValueListField<T>>,
+ ) -> Rc<Vec<DataTableHeader<Entry<T>>>> {
+ let props = ctx.props().clone();
+ let link = ctx.link().clone();
+
+ Rc::new(vec![
+ DataTableColumn::new(props.key_label)
+ .flex(1)
+ .render({
+ let link = link.clone();
+ move |item: &Entry<T>| {
+ let index = item.index;
+ Field::new()
+ .on_change(link.callback(move |value| Message::UpdateKey(index, value)))
+ .required(true)
+ .disabled(props.input_props.disabled)
+ .placeholder(props.key_placeholder.clone())
+ .validate(|s: &String| {
+ if s.is_empty() {
+ bail!("Field may not be empty");
+ } else {
+ Ok(())
+ }
+ })
+ .value(item.key.clone())
+ .into()
+ }
+ })
+ .sorter(|a: &Entry<T>, b: &Entry<T>| a.key.cmp(&b.key))
+ .into(),
+ DataTableColumn::new(props.value_label)
+ .flex(1)
+ .render({
+ let link = link.clone();
+ move |item: &Entry<T>| {
+ let index = item.index;
+ let value = &item.value;
+ Field::new()
+ .input_type(props.value_input_type)
+ .on_change(
+ link.callback(move |value| Message::UpdateValue(index, value)),
+ )
+ .disabled(props.input_props.disabled)
+ .placeholder(props.value_placeholder.clone())
+ .value(value.to_string())
+ .into()
+ }
+ })
+ .into(),
+ DataTableColumn::new("")
+ .width("50px")
+ .render(move |item: &Entry<T>| {
+ let index = item.index;
+ ActionIcon::new("fa fa-lg fa-trash-o")
+ .tabindex(0)
+ .on_activate(link.callback(move |_| Message::RemoveEntry(index)))
+ .disabled(props.input_props.disabled)
+ .into()
+ })
+ .into(),
+ ])
+ }
+}
+
+impl<T> ManagedField for KeyValueListField<T>
+where
+ T: 'static
+ + Clone
+ + Debug
+ + Default
+ + DeserializeOwned
+ + Display
+ + FromStr
+ + PartialEq
+ + Serialize,
+{
+ type Message = Message;
+ type Properties = KeyValueList<T>;
+ type ValidateClosure = (bool, Option<SubmitValidateFn<Vec<(String, T)>>>);
+
+ fn create(ctx: &ManagedFieldContext<Self>) -> Self {
+ let store = Store::with_extract_key(|entry: &Entry<T>| Key::from(entry.index))
+ .on_change(ctx.link().callback(|_| Message::DataChange));
+
+ let value = Value::Null;
+
+ // put the default value through the validator fn, to allow for correct dirty checking
+ let default = if let Some(f) = &ctx.props().submit_validate {
+ f.apply(&ctx.props().value).unwrap_or_default()
+ } else {
+ serde_json::to_value(ctx.props().value.clone()).unwrap_or_default()
+ };
+
+ let mut this = Self {
+ state: ManagedFieldState::new(value, default),
+ store,
+ };
+
+ this.set_data(ctx.props().value.clone());
+ this
+ }
+
+ fn validation_args(props: &Self::Properties) -> Self::ValidateClosure {
+ (props.input_props.required, props.submit_validate.clone())
+ }
+
+ fn validator(props: &Self::ValidateClosure, value: &Value) -> Result<Value, Error> {
+ let data = serde_json::from_value::<Vec<(String, T)>>(value.clone())?;
+
+ if data.is_empty() && props.0 {
+ bail!("at least one entry required!")
+ }
+
+ if data.iter().any(|(k, _)| k.is_empty()) {
+ bail!("Name must not be empty!");
+ }
+
+ if let Some(cb) = &props.1 {
+ cb.apply(&data)
+ } else {
+ Ok(value.clone())
+ }
+ }
+
+ fn changed(&mut self, ctx: &ManagedFieldContext<Self>, old_props: &Self::Properties) -> bool {
+ let props = ctx.props();
+ if old_props.value != props.value {
+ let default: Value = props
+ .value
+ .iter()
+ .filter_map(|n| serde_json::to_value(n).ok())
+ .collect();
+ ctx.link().update_default(default.clone());
+ self.sync_from_value(default);
+ }
+ true
+ }
+
+ fn value_changed(&mut self, _ctx: &ManagedFieldContext<Self>) {
+ match self.state.value {
+ Value::Null => self.sync_from_value(self.state.default.clone()),
+ _ => self.sync_from_value(self.state.value.clone()),
+ }
+ }
+
+ fn update(&mut self, ctx: &ManagedFieldContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Message::DataChange => {
+ let list: Vec<(String, T)> = self
+ .store
+ .read()
+ .iter()
+ .map(|Entry { key, value, .. }| (key.clone(), value.clone()))
+ .collect();
+ ctx.link().update_value(serde_json::to_value(list).unwrap());
+ true
+ }
+ Message::RemoveEntry(index) => {
+ let data: Vec<(String, T)> = self
+ .store
+ .read()
+ .iter()
+ .filter(move |item| item.index != index)
+ .map(|Entry { key, value, .. }| (key.clone(), value.clone()))
+ .collect();
+ self.set_data(data);
+ true
+ }
+ Message::UpdateKey(index, key) => {
+ let mut data = self.store.write();
+ if let Some(item) = data.get_mut(index) {
+ item.key = key;
+ }
+ true
+ }
+ Message::UpdateValue(index, value) => {
+ let mut data = self.store.write();
+ if let Some(item) = data.get_mut(index) {
+ if let Ok(v) = T::from_str(&value) {
+ item.value = v;
+ }
+ }
+ true
+ }
+ }
+ }
+
+ fn view(&self, ctx: &ManagedFieldContext<Self>) -> Html {
+ let table = DataTable::new(Self::columns(ctx), self.store.clone())
+ .border(true)
+ .class(FlexFit);
+
+ let button_row = Row::new()
+ .with_child(
+ Button::new(tr!("Add"))
+ .class(ColorScheme::Primary)
+ .icon_class("fa fa-plus-circle")
+ .on_activate({
+ let store = self.store.clone();
+ move |_| {
+ let mut data = store.write();
+ let index = data.len();
+
+ data.push(Entry {
+ index,
+ key: String::new(),
+ value: T::default(),
+ })
+ }
+ }),
+ )
+ .with_flex_spacer()
+ .with_optional_child(self.state.result.clone().err().map(|err| {
+ Row::new()
+ .class(AlignItems::Center)
+ .gap(2)
+ .with_child(Fa::new("exclamation-triangle").class(FontColor::Error))
+ .with_child(err)
+ }));
+
+ Column::new()
+ .class(FlexFit)
+ .gap(2)
+ .with_child(
+ Container::from_widget_props(ctx.props().std_props.clone(), None)
+ .class(FlexFit)
+ .with_child(table),
+ )
+ .with_child(button_row)
+ .into()
+ }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 0df2cbf..a6f2836 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -189,6 +189,9 @@ pub use tooltip::Tooltip;
mod visibility_observer;
pub use visibility_observer::VisibilityObserver;
+mod key_value_list;
+pub use key_value_list::KeyValueList;
+
use std::sync::atomic::{AtomicUsize, Ordering};
static UNIQUE_ELEMENT_ID: AtomicUsize = AtomicUsize::new(0);
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 14/38] api-types, cli: use ReturnType::new() instead of constructing it manually
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (12 preceding siblings ...)
2026-04-03 16:53 ` [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 15/38] api-types: add api types for auto-installer integration Christoph Heiss
` (23 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
.. like everywhere else.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
cli/client/src/pbs.rs | 10 ++--------
cli/client/src/pve.rs | 15 +++------------
cli/client/src/remotes.rs | 5 +----
lib/pdm-api-types/src/lib.rs | 8 ++++----
4 files changed, 10 insertions(+), 28 deletions(-)
diff --git a/cli/client/src/pbs.rs b/cli/client/src/pbs.rs
index 9972824..c59e0be 100644
--- a/cli/client/src/pbs.rs
+++ b/cli/client/src/pbs.rs
@@ -239,10 +239,7 @@ async fn list_tasks(remote: String) -> Result<(), Error> {
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &TASK_LIST_SCHEMA,
- },
+ &ReturnType::new(false, &TASK_LIST_SCHEMA),
&env().format_args.output_format.to_string(),
&proxmox_router::cli::default_table_format_options(),
);
@@ -266,10 +263,7 @@ async fn task_status(remote: String, upid: String) -> Result<(), Error> {
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &pdm_api_types::pbs::TaskStatus::API_SCHEMA,
- },
+ &ReturnType::new(false, &pdm_api_types::pbs::TaskStatus::API_SCHEMA),
&env().format_args.output_format.to_string(),
&Default::default(),
);
diff --git a/cli/client/src/pve.rs b/cli/client/src/pve.rs
index 19bd098..3bbc399 100644
--- a/cli/client/src/pve.rs
+++ b/cli/client/src/pve.rs
@@ -238,10 +238,7 @@ async fn cluster_resources(
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &CLUSTER_LIST_SCHEMA,
- },
+ &ReturnType::new(false, &CLUSTER_LIST_SCHEMA),
&env().format_args.output_format.to_string(),
&Default::default(),
);
@@ -1077,10 +1074,7 @@ async fn list_tasks(remote: String, node: Option<String>) -> Result<(), Error> {
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &TASK_LIST_SCHEMA,
- },
+ &ReturnType::new(false, &TASK_LIST_SCHEMA),
&env().format_args.output_format.to_string(),
&proxmox_router::cli::default_table_format_options(),
);
@@ -1104,10 +1098,7 @@ async fn task_status(remote: String, upid: String) -> Result<(), Error> {
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &pve_api_types::TaskStatus::API_SCHEMA,
- },
+ &ReturnType::new(false, &pve_api_types::TaskStatus::API_SCHEMA),
&env().format_args.output_format.to_string(),
&Default::default(),
);
diff --git a/cli/client/src/remotes.rs b/cli/client/src/remotes.rs
index e087809..693774f 100644
--- a/cli/client/src/remotes.rs
+++ b/cli/client/src/remotes.rs
@@ -142,10 +142,7 @@ async fn remote_version(id: String) -> Result<(), Error> {
let data = client()?.remote_version(&id).await?;
format_and_print_result_full(
&mut serde_json::to_value(data)?,
- &ReturnType {
- optional: false,
- schema: &pve_api_types::VersionResponse::API_SCHEMA,
- },
+ &ReturnType::new(false, &pve_api_types::VersionResponse::API_SCHEMA),
&env().format_args.output_format.to_string(),
&Default::default(),
);
diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs
index fec3c8f..aea1b5d 100644
--- a/lib/pdm-api-types/src/lib.rs
+++ b/lib/pdm-api-types/src/lib.rs
@@ -322,10 +322,10 @@ pub struct TaskStatistics {
pub by_remote: HashMap<String, TaskCount>,
}
-pub const NODE_TASKS_LIST_TASKS_RETURN_TYPE: ReturnType = ReturnType {
- optional: false,
- schema: &ArraySchema::new("A list of tasks.", &TaskListItem::API_SCHEMA).schema(),
-};
+pub const NODE_TASKS_LIST_TASKS_RETURN_TYPE: ReturnType = ReturnType::new(
+ false,
+ &ArraySchema::new("A list of tasks.", &TaskListItem::API_SCHEMA).schema(),
+);
#[api]
#[derive(Deserialize, Serialize, Copy, Clone, PartialEq, Eq)]
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 15/38] api-types: add api types for auto-installer integration
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (13 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 14/38] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Christoph Heiss
` (22 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
The `Installation` type represents an individual installation done
through PDM acting as auto-install server, and
`PreparedInstallationConfig` a configuration provided by the user for
automatically responding to answer requests based on certain target
filters.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* added answer authentication token types
* added template counter support
* replaced property strings with proper maps for all filter entries
* added #[updater(..)] for some more `PreparedInstallationConfig`
fields
Changes v1 -> v2:
* no changes
Cargo.toml | 4 +
debian/control | 3 +
lib/pdm-api-types/Cargo.toml | 3 +
lib/pdm-api-types/src/auto_installer.rs | 415 ++++++++++++++++++++++++
lib/pdm-api-types/src/lib.rs | 2 +
5 files changed, 427 insertions(+)
create mode 100644 lib/pdm-api-types/src/auto_installer.rs
diff --git a/Cargo.toml b/Cargo.toml
index ec2aa3d..77b10af 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 = "1.0"
# other proxmox crates
proxmox-acme = "1.0"
@@ -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 4ddc9ef..6c9ec38 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-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 7aa7b64..7929504 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, features = ["serde"] }
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..fbdc7dc
--- /dev/null
+++ b/lib/pdm-api-types/src/auto_installer.rs
@@ -0,0 +1,415 @@
+//! API types used for the auto-installation configuration.
+
+use serde::{Deserialize, Serialize};
+use std::{collections::BTreeMap, fmt::Debug};
+
+use proxmox_auth_api::types::Userid;
+use proxmox_installer_types::{post_hook::PostHookInfo, SystemInfo};
+use proxmox_network_types::ip_address::{api_types::IpAddr, Cidr};
+use proxmox_schema::{
+ api,
+ api_types::{
+ CERT_FINGERPRINT_SHA256_SCHEMA, COMMENT_SCHEMA, DISK_ARRAY_SCHEMA, HTTP_URL_SCHEMA,
+ SINGLE_LINE_COMMENT_FORMAT, UUID_FORMAT,
+ },
+ ApiStringFormat, Schema, StringSchema, Updater,
+};
+use proxmox_uuid::Uuid;
+
+use crate::PROXMOX_TOKEN_NAME_SCHEMA;
+
+/// Re-export for convenience, as these types are used within [`PreparedInstallationConfig`].
+pub use proxmox_installer_types::answer;
+
+pub const INSTALLATION_UUID_SCHEMA: Schema = StringSchema::new("UUID of a installation.")
+ .format(&UUID_FORMAT)
+ .schema();
+
+#[api]
+#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
+#[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>,
+}
+
+#[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("ID of prepared configuration for automated installations.")
+ .min_length(3)
+ .max_length(64)
+ .schema();
+
+#[api(
+ properties: {
+ id: {
+ schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ "authorized-tokens": {
+ type: Array,
+ optional: true,
+ items: {
+ schema: PROXMOX_TOKEN_NAME_SCHEMA,
+ },
+ },
+ "is-default": {
+ optional: true,
+ },
+ "target-filter": {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ optional: true,
+ },
+ country: {
+ format: &ApiStringFormat::Pattern(&answer::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: Object,
+ properties: {},
+ additional_properties: true,
+ optional: true,
+ },
+ "disk-mode": {
+ type: String,
+ },
+ "disk-list": {
+ schema: DISK_ARRAY_SCHEMA,
+ optional: true,
+ },
+ "disk-filter": {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ optional: true,
+ },
+ "post-hook-base-url": {
+ schema: HTTP_URL_SCHEMA,
+ optional: true,
+ },
+ "post-hook-cert-fp": {
+ schema: CERT_FINGERPRINT_SHA256_SCHEMA,
+ optional: true,
+ },
+ "template-counters": {
+ type: Object,
+ properties: {},
+ additional_properties: true,
+ optional: true,
+ }
+ },
+)]
+#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Configuration describing an automated installation.
+///
+/// Certain fields support simple templating via [Handlebars]. Currently, following fields will
+/// resolve handlebars expression upon instantiation of an answer:
+///
+/// * `fqdn`
+/// * `mailto`
+/// * `cidr`
+/// * `gateway`
+/// * `dns
+///
+/// [Handlebars]: https://handlebarsjs.com/guide/
+pub struct PreparedInstallationConfig {
+ #[updater(skip)]
+ pub id: String,
+
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ /// List of token IDs that are authoried to retrieve this answer.
+ pub authorized_tokens: Vec<String>,
+
+ /// Whether this is the default answer. There can only ever be one default answer.
+ /// `target_filter` below is ignored if this is `true`.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub is_default: bool,
+
+ // Target filters
+ /// Map of filters for matching against a property in [`answer::fetch::AnswerFetchData`].
+ /// The keys are JSON Pointers as per [RFC6901], the values globs as accepted
+ /// by the [glob] crate.
+ ///
+ /// Used to check this configuration 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.
+ ///
+ /// [RFC6901] https://datatracker.ietf.org/doc/html/rfc6901
+ /// [glob crate] https://docs.rs/glob/
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub target_filter: BTreeMap<String, String>,
+
+ // Keys from [`answer::GlobalOptions`], adapted to better fit the API and model of the UI.
+ /// Country to use for apt mirrors.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub country: String,
+ /// FQDN to set for the installed system. Only used if `use_dhcp_fqdn` is true.
+ ///
+ /// Supports templating via Handlebars.
+ /// The [`proxmox_network_types::fqdn::Fqdn`] type cannot be used here
+ /// because of that, as curly brackets are not valid in hostnames.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub fqdn: String,
+ /// Whether to use the FQDN from the DHCP lease or the user-provided one.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub use_dhcp_fqdn: bool,
+ /// Keyboard layout to set.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub keyboard: answer::KeyboardLayout,
+ /// Mail address for `root@pam`.
+ ///
+ /// Supports templating via Handlebars.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub mailto: String,
+ /// Timezone to set on the new system.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ 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.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub reboot_on_error: bool,
+ /// Action to take after the installation completed successfully.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ 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.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub use_dhcp_network: bool,
+ /// IP address and netmask if not using DHCP.
+ ///
+ /// Supports templating via Handlebars.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ pub cidr: Option<Cidr>,
+ /// Gateway if not using DHCP.
+ ///
+ /// Supports templating via Handlebars.
+ #[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.
+ ///
+ /// Supports templating via Handlebars.
+ #[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 = "BTreeMap::is_empty")]
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub netdev_filter: BTreeMap<String, String>,
+ /// Whether to enable network interface name pinning.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub netif_name_pinning_enabled: bool,
+
+ /// Root filesystem options.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub filesystem: answer::FilesystemOptions,
+
+ /// Whether to use the fixed disk list or select disks dynamically by udev filters.
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub disk_mode: DiskSelectionMode,
+ /// List of raw disk identifiers to use for the root filesystem.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ pub disk_list: Vec<String>,
+ /// Filter against udev properties to select the disks for the installation,
+ /// to allow dynamic selection of disks.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub disk_filter: BTreeMap<String, String>,
+ /// 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>,
+
+ /// Key-value pairs of (auto-incrementing) counters.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ #[updater(serde(default, skip_serializing_if = "Option::is_none"))]
+ pub template_counters: BTreeMap<String, i32>,
+}
+
+#[api]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property names for [`PreparedInstallationConfig`]
+pub enum DeletablePreparedInstallationConfigProperty {
+ /// Delete all target filters
+ TargetFilter,
+ /// Delete all udev property filters for the management network device
+ NetdevFilter,
+ /// Delete all udev property 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,
+ /// Delete all templating counters.
+ TemplateCounters,
+}
+
+serde_plain::derive_display_from_serialize!(DeletablePreparedInstallationConfigProperty);
+serde_plain::derive_fromstr_from_deserialize!(DeletablePreparedInstallationConfigProperty);
+
+#[api(
+ properties: {
+ id: {
+ schema: PROXMOX_TOKEN_NAME_SCHEMA,
+ },
+ "created-by": {
+ type: String,
+ },
+ comment: {
+ optional: true,
+ schema: COMMENT_SCHEMA,
+ },
+ enabled: {
+ type: bool,
+ optional: true,
+ default: true,
+ },
+ "expire-at": {
+ type: Integer,
+ optional: true,
+ minimum: 0,
+ description: "Token expiration date (seconds since epoch). '0' means no expiration date.",
+ },
+ }
+)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// An auth token for authenticating requests from the automated installer.
+pub struct AnswerAuthToken {
+ #[updater(skip)]
+ /// Name of the auth token
+ pub id: String,
+ #[updater(skip)]
+ /// Name of the user that created it
+ pub created_by: Userid,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ /// Optional comment
+ pub comment: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ /// Whether this token is enabled
+ pub enabled: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ /// Expiration time of this token, if any
+ pub expire_at: Option<i64>,
+}
+
+impl AnswerAuthToken {
+ pub fn is_active(&self) -> bool {
+ self.enabled.unwrap_or(false)
+ && self
+ .expire_at
+ .map(|exp| exp > 0 && exp <= proxmox_time::epoch_i64())
+ .unwrap_or(true)
+ }
+}
+
+#[api]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property names for [`AnswerAuthToken`].
+pub enum DeletableAnswerAuthTokenProperty {
+ /// Delete the comment
+ Comment,
+ /// Delete the expiration date
+ ExpireAt,
+}
+
+serde_plain::derive_display_from_serialize!(DeletableAnswerAuthTokenProperty);
+serde_plain::derive_fromstr_from_deserialize!(DeletableAnswerAuthTokenProperty);
diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs
index aea1b5d..b88f868 100644
--- a/lib/pdm-api-types/src/lib.rs
+++ b/lib/pdm-api-types/src/lib.rs
@@ -100,6 +100,8 @@ pub use proxmox_schema::upid::*;
mod openid;
pub use openid::*;
+pub mod auto_installer;
+
pub mod firewall;
pub mod remotes;
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (14 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 15/38] api-types: add api types for auto-installer integration Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 17/38] acl: wire up new /system/auto-installation acl path Christoph Heiss
` (21 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Provides some primitives for the auto-installer integration to save
state about
- individual installations (as plain JSON, as these aren't
configurations files) and
- prepared answer file configurations, as section config
The new files (including lock files) are placed under
`/etc/proxmox-datacenter-manager/autoinst`.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* added answer authentication token interface
* added separate type from `PreparedInstallationConfig` api type for
use with configuration, mapping maps to propertystrings
* moved state files to /var/lib/proxmox-datacenter-manager
Changes v1 -> v2:
* no changes
lib/pdm-buildcfg/src/lib.rs | 10 +
lib/pdm-config/Cargo.toml | 3 +
lib/pdm-config/src/auto_install.rs | 559 +++++++++++++++++++++++++++++
lib/pdm-config/src/lib.rs | 1 +
lib/pdm-config/src/setup.rs | 7 +
5 files changed, 580 insertions(+)
create mode 100644 lib/pdm-config/src/auto_install.rs
diff --git a/lib/pdm-buildcfg/src/lib.rs b/lib/pdm-buildcfg/src/lib.rs
index 9380972..734e95d 100644
--- a/lib/pdm-buildcfg/src/lib.rs
+++ b/lib/pdm-buildcfg/src/lib.rs
@@ -106,3 +106,13 @@ macro_rules! rundir {
concat!($crate::PDM_RUN_DIR_M!(), $subdir)
};
}
+
+/// Prepend the state directory to a file name.
+///
+/// This is a simply way to get the full path for files in `/var/lib/`.
+#[macro_export]
+macro_rules! statedir {
+ ($subdir:expr) => {
+ concat!($crate::PDM_STATE_DIR_M!(), $subdir)
+ };
+}
diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml
index d39c2ad..17ca27e 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,7 @@ 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
+proxmox-network-types.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..fe32d30
--- /dev/null
+++ b/lib/pdm-config/src/auto_install.rs
@@ -0,0 +1,559 @@
+//! Implements configuration for the auto-installer integration.
+
+use anyhow::{bail, Result};
+use std::collections::HashMap;
+
+use pdm_api_types::{
+ auto_installer::{AnswerAuthToken, Installation},
+ ConfigDigest,
+};
+use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData};
+
+use crate::auto_install::types::AnswerAuthTokenWrapper;
+
+pub const CONFIG_PATH: &str = pdm_buildcfg::configdir!("/autoinst");
+
+const PREPARED_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/prepared.cfg");
+const PREPARED_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.prepared.lock");
+
+const TOKENS_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/tokens.cfg");
+const TOKENS_SHADOW_FILE: &str = pdm_buildcfg::configdir!("/autoinst/tokens.shadow");
+const TOKENS_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.tokens.lock");
+
+const INSTALLATIONS_STATE_FILE: &str = pdm_buildcfg::statedir!("/automated-installations.json");
+const INSTALLATIONS_LOCK_FILE: &str = pdm_buildcfg::statedir!("/.automated-installations.lock");
+
+pub mod types {
+ use serde::{Deserialize, Serialize};
+ use std::{collections::BTreeMap, fmt::Debug};
+
+ use pdm_api_types::{
+ auto_installer::{
+ answer, AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig,
+ PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ CERT_FINGERPRINT_SHA256_SCHEMA, DISK_ARRAY_SCHEMA, HTTP_URL_SCHEMA,
+ PROXMOX_TOKEN_NAME_SCHEMA, SINGLE_LINE_COMMENT_FORMAT,
+ };
+ use proxmox_network_types::{api_types::IpAddr, Cidr};
+ use proxmox_schema::{api, ApiStringFormat, ApiType, PropertyString};
+
+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+ /// API wrapper for a [`BTreeMap`].
+ pub struct BTreeMapWrapper<T>(BTreeMap<String, T>);
+
+ impl<T> std::ops::Deref for BTreeMapWrapper<T> {
+ type Target = BTreeMap<String, T>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+ }
+
+ impl<T> std::ops::DerefMut for BTreeMapWrapper<T> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+ }
+
+ impl<T: for<'de> Deserialize<'de> + Serialize> ApiType for BTreeMapWrapper<T> {
+ const API_SCHEMA: proxmox_schema::Schema =
+ proxmox_schema::ObjectSchema::new("Map of key-value pairs", &[])
+ .additional_properties(true)
+ .schema();
+ }
+
+ #[api(
+ "id-property": "id",
+ "id-schema": {
+ type: String,
+ description: "ID of prepared configuration for automated installations.",
+ min_length: 3,
+ max_length: 64
+ }
+ )]
+ #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+ #[serde(rename_all = "kebab-case", tag = "type")]
+ /// Wrapper type for using [`PreparedInstallationConfig`] with
+ /// [`proxmox_schema::typed::SectionConfigData`].
+ pub enum PreparedInstallationSectionConfigWrapper {
+ PreparedConfig(PreparedInstallationSectionConfig),
+ }
+
+ #[api(
+ properties: {
+ id: {
+ schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ "authorized-tokens": {
+ type: Array,
+ optional: true,
+ items: {
+ schema: PROXMOX_TOKEN_NAME_SCHEMA,
+ },
+ },
+ "is-default": {
+ optional: true,
+ },
+ "target-filter": {
+ type: String,
+ optional: true,
+ },
+ country: {
+ format: &ApiStringFormat::Pattern(&answer::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: String,
+ optional: true,
+ },
+ filesystem: {
+ type: String,
+ },
+ "disk-mode": {
+ type: String,
+ },
+ "disk-list": {
+ schema: DISK_ARRAY_SCHEMA,
+ optional: true,
+ },
+ "disk-filter": {
+ type: String,
+ optional: true,
+ },
+ "post-hook-base-url": {
+ schema: HTTP_URL_SCHEMA,
+ optional: true,
+ },
+ "post-hook-cert-fp": {
+ schema: CERT_FINGERPRINT_SHA256_SCHEMA,
+ optional: true,
+ },
+ "template-counters": {
+ type: String,
+ optional: true,
+ },
+ },
+ )]
+ #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+ #[serde(rename_all = "kebab-case")]
+ /// Configuration describing an automated installation.
+ ///
+ /// Certain fields support simple templating via [Handlebars]. Currently, following fields will
+ /// resolve handlebars expression upon instantiation of an answer:
+ ///
+ /// * `fqdn`
+ /// * `mailto`
+ /// * `cidr`
+ /// * `gateway`
+ /// * `dns
+ ///
+ /// [Handlebars]: https://handlebarsjs.com/guide/
+ pub struct PreparedInstallationSectionConfig {
+ pub id: String,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ /// List of token IDs that are authoried to retrieve this answer.
+ pub authorized_tokens: Vec<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
+ /// Map of filters for matching against a property in [`answer::fetch::AnswerFetchData`].
+ /// The keys are JSON Pointers as per [RFC6901], the values globs as accepted
+ /// by the [glob] crate.
+ ///
+ /// Used to check this configuration 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.
+ ///
+ /// [RFC6901] https://datatracker.ietf.org/doc/html/rfc6901
+ /// [glob crate] https://docs.rs/glob/
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub target_filter: PropertyString<BTreeMapWrapper<String>>,
+
+ // 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.
+ ///
+ /// Supports templating via Handlebars.
+ /// The [`proxmox_network_types::fqdn::Fqdn`] type cannot be used here
+ /// because of that, as curly brackets are not valid in hostnames.
+ pub fqdn: String,
+ /// 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`.
+ ///
+ /// Supports templating via Handlebars.
+ 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")]
+ 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")]
+ 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.
+ ///
+ /// Supports templating via Handlebars.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cidr: Option<Cidr>,
+ /// Gateway if not using DHCP.
+ ///
+ /// Supports templating via Handlebars.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub gateway: Option<IpAddr>,
+ /// DNS server address if not using DHCP.
+ ///
+ /// Supports templating via Handlebars.
+ #[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 = "BTreeMap::is_empty")]
+ pub netdev_filter: PropertyString<BTreeMapWrapper<String>>,
+ /// Whether to enable network interface name pinning.
+ pub netif_name_pinning_enabled: bool,
+
+ /// Root filesystem options.
+ pub filesystem: PropertyString<answer::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(default, skip_serializing_if = "Vec::is_empty")]
+ pub disk_list: Vec<String>,
+ /// Filter against udev properties to select the disks for the installation,
+ /// to allow dynamic selection of disks.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub disk_filter: PropertyString<BTreeMapWrapper<String>>,
+ /// 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")]
+ 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")]
+ pub post_hook_base_url: Option<String>,
+ /// Post hook certificate fingerprint, if needed.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub post_hook_cert_fp: Option<String>,
+
+ /// Key-value pairs of (auto-incrementing) counters.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub template_counters: PropertyString<BTreeMapWrapper<i32>>,
+ }
+
+ impl TryFrom<PreparedInstallationConfig> for PreparedInstallationSectionConfig {
+ type Error = anyhow::Error;
+
+ fn try_from(conf: PreparedInstallationConfig) -> Result<Self, Self::Error> {
+ Ok(Self {
+ id: conf.id,
+ authorized_tokens: conf.authorized_tokens,
+ // target filter
+ is_default: conf.is_default,
+ target_filter: PropertyString::new(BTreeMapWrapper(conf.target_filter)),
+ // global options
+ country: conf.country,
+ fqdn: conf.fqdn,
+ use_dhcp_fqdn: conf.use_dhcp_fqdn,
+ keyboard: conf.keyboard,
+ mailto: conf.mailto,
+ timezone: conf.timezone,
+ 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,
+ // network options
+ use_dhcp_network: conf.use_dhcp_network,
+ cidr: conf.cidr,
+ gateway: conf.gateway,
+ dns: conf.dns,
+ netdev_filter: PropertyString::new(BTreeMapWrapper(conf.netdev_filter)),
+ netif_name_pinning_enabled: conf.netif_name_pinning_enabled,
+ // disk options
+ filesystem: PropertyString::new(conf.filesystem),
+ disk_mode: conf.disk_mode,
+ disk_list: conf.disk_list,
+ disk_filter: PropertyString::new(BTreeMapWrapper(conf.disk_filter)),
+ disk_filter_match: conf.disk_filter_match,
+ // post hook
+ post_hook_base_url: conf.post_hook_base_url,
+ post_hook_cert_fp: conf.post_hook_cert_fp,
+ // templating
+ template_counters: PropertyString::new(BTreeMapWrapper(conf.template_counters)),
+ })
+ }
+ }
+
+ impl TryFrom<PreparedInstallationConfig> for PreparedInstallationSectionConfigWrapper {
+ type Error = anyhow::Error;
+
+ fn try_from(conf: PreparedInstallationConfig) -> Result<Self, Self::Error> {
+ Ok(Self::PreparedConfig(conf.try_into()?))
+ }
+ }
+
+ impl TryInto<PreparedInstallationConfig> for PreparedInstallationSectionConfig {
+ type Error = anyhow::Error;
+
+ fn try_into(self) -> Result<PreparedInstallationConfig, Self::Error> {
+ Ok(PreparedInstallationConfig {
+ id: self.id,
+ authorized_tokens: self.authorized_tokens,
+ // target filter
+ is_default: self.is_default,
+ target_filter: self.target_filter.into_inner().0,
+ // global options
+ country: self.country,
+ fqdn: self.fqdn,
+ use_dhcp_fqdn: self.use_dhcp_fqdn,
+ keyboard: self.keyboard,
+ mailto: self.mailto,
+ timezone: self.timezone,
+ root_password_hashed: self.root_password_hashed,
+ reboot_on_error: self.reboot_on_error,
+ reboot_mode: self.reboot_mode,
+ root_ssh_keys: self.root_ssh_keys,
+ // network options
+ use_dhcp_network: self.use_dhcp_network,
+ cidr: self.cidr,
+ gateway: self.gateway,
+ dns: self.dns,
+ netdev_filter: self.netdev_filter.into_inner().0,
+ netif_name_pinning_enabled: self.netif_name_pinning_enabled,
+ // disk options
+ filesystem: self.filesystem.into_inner(),
+ disk_mode: self.disk_mode,
+ disk_list: self.disk_list,
+ disk_filter: self.disk_filter.into_inner().0,
+ disk_filter_match: self.disk_filter_match,
+ // post hook
+ post_hook_base_url: self.post_hook_base_url,
+ post_hook_cert_fp: self.post_hook_cert_fp,
+ // templating
+ template_counters: self.template_counters.into_inner().0,
+ })
+ }
+ }
+
+ impl TryInto<PreparedInstallationConfig> for PreparedInstallationSectionConfigWrapper {
+ type Error = anyhow::Error;
+
+ fn try_into(self) -> Result<PreparedInstallationConfig, Self::Error> {
+ let PreparedInstallationSectionConfigWrapper::PreparedConfig(conf) = self;
+ conf.try_into()
+ }
+ }
+
+ #[api(
+ "id-property": "id",
+ "id-schema": {
+ type: String,
+ description: "Access token name.",
+ min_length: 3,
+ max_length: 64,
+ },
+ )]
+ #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
+ #[serde(rename_all = "kebab-case", tag = "type")]
+ /// Access token for authenticating against the /answer endpoint.
+ pub enum AnswerAuthTokenWrapper {
+ /// API-token (like).
+ AccessToken(AnswerAuthToken),
+ }
+
+ impl From<AnswerAuthTokenWrapper> for AnswerAuthToken {
+ fn from(value: AnswerAuthTokenWrapper) -> Self {
+ let AnswerAuthTokenWrapper::AccessToken(token) = value;
+ token
+ }
+ }
+
+ impl From<AnswerAuthToken> for AnswerAuthTokenWrapper {
+ fn from(value: AnswerAuthToken) -> Self {
+ AnswerAuthTokenWrapper::AccessToken(value)
+ }
+ }
+}
+
+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 = serde_json::from_str(
+ &proxmox_sys::fs::file_read_optional_string(INSTALLATIONS_STATE_FILE)?
+ .unwrap_or_else(|| "[]".to_owned()),
+ )?;
+
+ 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_STATE_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<types::PreparedInstallationSectionConfigWrapper>,
+ 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 = types::PreparedInstallationSectionConfigWrapper::parse_section_config(
+ PREPARED_CONF_FILE,
+ &content,
+ )?;
+
+ Ok((data, digest))
+}
+
+/// Write lock must be already held.
+pub fn save_prepared_answers(
+ config: &SectionConfigData<types::PreparedInstallationSectionConfigWrapper>,
+) -> Result<()> {
+ let raw = types::PreparedInstallationSectionConfigWrapper::write_section_config(
+ PREPARED_CONF_FILE,
+ config,
+ )?;
+ replace_config(PREPARED_CONF_FILE, raw.as_bytes())
+}
+
+pub fn token_read_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(TOKENS_LOCK_FILE, None, false)
+}
+
+pub fn token_write_lock() -> Result<ApiLockGuard> {
+ open_api_lockfile(TOKENS_LOCK_FILE, None, true)
+}
+
+pub fn read_tokens() -> Result<(
+ SectionConfigData<types::AnswerAuthTokenWrapper>,
+ ConfigDigest,
+)> {
+ let content = proxmox_sys::fs::file_read_optional_string(TOKENS_CONF_FILE)?.unwrap_or_default();
+
+ let digest = ConfigDigest::from_slice(content.as_bytes());
+ let data = types::AnswerAuthTokenWrapper::parse_section_config(TOKENS_CONF_FILE, &content)?;
+
+ Ok((data, digest))
+}
+
+/// Write lock must be already held.
+pub fn add_token(token: &AnswerAuthToken, secret: &str) -> Result<()> {
+ let (mut auths, _) = read_tokens()?;
+
+ if auths.contains_key(&token.id.to_string()) {
+ bail!("token already exists");
+ }
+
+ let auth: AnswerAuthTokenWrapper = token.clone().into();
+ auths.insert(token.id.to_string(), auth);
+
+ let mut shadow = read_tokens_shadow()?;
+ let hashed = proxmox_sys::crypt::encrypt_pw(secret)?;
+ shadow.insert(token.id.clone(), hashed);
+ write_tokens_shadow(shadow)?;
+
+ write_tokens(auths)
+}
+
+/// Write lock must be already held.
+pub fn update_token(token: &AnswerAuthToken) -> Result<()> {
+ let (mut auths, _) = read_tokens()?;
+
+ let auth: AnswerAuthTokenWrapper = token.clone().into();
+ auths.insert(token.id.to_string(), auth);
+
+ write_tokens(auths)
+}
+
+/// Write lock must be already held.
+pub fn delete_token(id: &str) -> Result<()> {
+ let (mut tokens, _) = read_tokens()?;
+ tokens.remove(&id.to_string());
+
+ let mut shadow = read_tokens_shadow()?;
+ shadow.remove(id);
+ write_tokens_shadow(shadow)?;
+
+ write_tokens(tokens)
+}
+
+/// Write lock must be already held.
+fn write_tokens(data: SectionConfigData<types::AnswerAuthTokenWrapper>) -> Result<()> {
+ let raw = types::AnswerAuthTokenWrapper::write_section_config(TOKENS_CONF_FILE, &data)?;
+ replace_config(TOKENS_CONF_FILE, raw.as_bytes())
+}
+
+/// At least read lock must be held.
+pub fn verify_token_secret(id: &str, secret: &str) -> Result<()> {
+ let data = read_tokens_shadow()?;
+ match data.get(id) {
+ Some(hashed) => proxmox_sys::crypt::verify_crypt_pw(secret, hashed),
+ None => bail!("invalid access token"),
+ }
+}
+
+fn read_tokens_shadow() -> Result<HashMap<String, String>> {
+ Ok(serde_json::from_str(
+ &proxmox_sys::fs::file_read_optional_string(TOKENS_SHADOW_FILE)?
+ .unwrap_or_else(|| "{}".to_owned()),
+ )?)
+}
+
+/// Write lock must be already held.
+fn write_tokens_shadow(data: HashMap<String, String>) -> Result<()> {
+ let raw = serde_json::to_string(&data)?;
+ replace_config(TOKENS_SHADOW_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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 17/38] acl: wire up new /system/auto-installation acl path
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (15 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Christoph Heiss
` (20 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* no changes
Changes v1 -> v2:
* no changes
lib/pdm-api-types/src/acl.rs | 4 ++--
ui/src/configuration/permission_path_selector.rs | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/lib/pdm-api-types/src/acl.rs b/lib/pdm-api-types/src/acl.rs
index 405982a..50a9132 100644
--- a/lib/pdm-api-types/src/acl.rs
+++ b/lib/pdm-api-types/src/acl.rs
@@ -297,8 +297,8 @@ impl proxmox_access_control::init::AccessControlConfig for AccessControlConfig {
return Ok(());
}
match components[1] {
- "certificates" | "disks" | "log" | "notifications" | "status" | "tasks"
- | "time" => {
+ "auto-installation" | "certificates" | "disks" | "log" | "notifications"
+ | "status" | "tasks" | "time" => {
if components_len == 2 {
return Ok(());
}
diff --git a/ui/src/configuration/permission_path_selector.rs b/ui/src/configuration/permission_path_selector.rs
index ad99177..ababd42 100644
--- a/ui/src/configuration/permission_path_selector.rs
+++ b/ui/src/configuration/permission_path_selector.rs
@@ -17,6 +17,7 @@ static PREDEFINED_PATHS: &[&str] = &[
"/access/users",
"/resource",
"/system",
+ "/system/auto-installation",
"/system/certificates",
"/system/disks",
"/system/log",
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (16 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 17/38] acl: wire up new /system/auto-installation acl path Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 19/38] server: api: auto-installer: add access token management endpoints Christoph Heiss
` (19 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 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.
Quick 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
DELETE /auto-install/installations/{id}
delete the giving past installation
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
GET /auto-install/prepared/{id}
get a specific prepared answer file configuration
PUT /auto-install/prepared/{id}
update a specific prepared answer file configuration
DELETE /auto-install/prepared/{id}
delete an existing prepared answer file configuration
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* added authentication using custom token to /auto-install/answer
endpoint (see also [0], esp. on why a separate token system)
* added templating support for some answer fields
* the /answer endpoint now lives under /api/json as normal
* adapted as necessary to changed types from `proxmox-installer-types`
* append full /api2/json/ path to post-hook url
* destructure updater to future-proof against missing any fields in
the future
* replace manual `SystemTime::now()` with `proxmox_time::epoch_i64()`
Changes v1 -> v2:
* fixed compilation error due to leftover, unresolved type
[0] https://lore.proxmox.com/pdm-devel/DETMUXY1Q877.32G593TWC52WW@proxmox.com/#:~:text=%20I%20think%20this%20is%20dangerous.
Cargo.toml | 1 +
debian/control | 2 +
server/Cargo.toml | 4 +
server/src/api/auto_installer/mod.rs | 945 +++++++++++++++++++++++++++
server/src/api/mod.rs | 2 +
5 files changed, 954 insertions(+)
create mode 100644 server/src/api/auto_installer/mod.rs
diff --git a/Cargo.toml b/Cargo.toml
index 77b10af..0f0bcf5 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"
diff --git a/debian/control b/debian/control
index 6c9ec38..5442c1c 100644
--- a/debian/control
+++ b/debian/control
@@ -17,7 +17,9 @@ 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-handlebars-5+default-dev,
librust-http-1+default-dev,
librust-http-body-util-0.1+default-dev (>= 0.1.2-~~),
librust-hyper-1+default-dev,
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 6969549..e1ee697 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -14,6 +14,8 @@ async-stream.workspace = true
async-trait.workspace = true
const_format.workspace = true
futures.workspace = true
+glob.workspace = true
+handlebars.workspace = true
hex.workspace = true
http.workspace = true
http-body-util.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..60eccd8
--- /dev/null
+++ b/server/src/api/auto_installer/mod.rs
@@ -0,0 +1,945 @@
+//! Implements all the methods under `/api2/json/auto-install/`.
+
+use anyhow::{anyhow, Result};
+use handlebars::Handlebars;
+use http::StatusCode;
+use std::collections::{BTreeMap, HashMap};
+
+use pdm_api_types::{
+ auto_installer::{
+ DeletablePreparedInstallationConfigProperty, Installation, InstallationStatus,
+ PreparedInstallationConfig, PreparedInstallationConfigUpdater, INSTALLATION_UUID_SCHEMA,
+ PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ },
+ ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA,
+};
+use pdm_config::auto_install::types::PreparedInstallationSectionConfigWrapper;
+use proxmox_installer_types::{
+ answer::{
+ self, fetch::AnswerFetchData, AutoInstallerConfig, PostNotificationHookInfo,
+ ROOT_PASSWORD_SCHEMA,
+ },
+ post_hook::PostHookInfo,
+ SystemInfo,
+};
+use proxmox_network_types::fqdn::Fqdn;
+use proxmox_router::{
+ http_bail, 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)),
+ (
+ "installations",
+ &Router::new().get(&API_METHOD_LIST_INSTALLATIONS).match_all(
+ "uuid",
+ &Router::new()
+ .delete(&API_METHOD_DELETE_INSTALLATION)
+ .subdirs(SUBDIR_INSTALLATION_PER_ID)
+ )
+ ),
+ (
+ "prepared",
+ &Router::new()
+ .get(&API_METHOD_LIST_PREPARED_ANSWERS)
+ .post(&API_METHOD_CREATE_PREPARED_ANSWER)
+ .match_all(
+ "id",
+ &Router::new()
+ .get(&API_METHOD_GET_PREPARED_ANSWER)
+ .put(&API_METHOD_UPDATE_PREPARED_ANSWER)
+ .delete(&API_METHOD_DELETE_PREPARED_ANSWER)
+ )
+ ),
+]);
+
+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("Implemented through specialized secret tokens."),
+ &Permission::World,
+)
+.protected(false);
+
+/// Implements the "upper" API handling for /auto-install/answer, most importantly
+/// the authentication through secret tokens.
+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 auth_header = parts
+ .headers
+ .get(http::header::AUTHORIZATION)
+ .and_then(|h| h.to_str().ok())
+ .unwrap_or_default();
+
+ let token_id = match verify_answer_authorization_header(auth_header) {
+ Some(token_id) => token_id,
+ None => {
+ return Ok(http::Response::builder()
+ .status(StatusCode::UNAUTHORIZED)
+ .body(String::new().into())?)
+ }
+ };
+
+ let response = serde_json::from_value::<AnswerFetchData>(param)
+ .map_err(|err| anyhow!("failed to deserialize body: {err:?}"))
+ .and_then(|data| new_installation(&token_id, data))
+ .map_err(|err| err.to_string())
+ .and_then(|result| serde_json::to_string(&result).map_err(|err| err.to_string()));
+
+ match response {
+ Ok(body) => Ok(http::Response::builder()
+ .status(StatusCode::OK)
+ .header(
+ http::header::CONTENT_TYPE,
+ "application/json; charset=utf-8",
+ )
+ .body(body.into())?),
+ Err(err) => Ok(http::Response::builder()
+ .status(StatusCode::BAD_REQUEST)
+ .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
+ .body(format!("{err:#}").into())?),
+ }
+ })
+}
+
+/// Verifies the given `Authorization` HTTP header value whether
+/// a) It matches the required format, i.e. PmxInstallerToken <token-id>:<secret>
+/// b) The token secret is known and verifies successfully.
+///
+/// # Parameters
+///
+/// * `header` - The value of the `Authorization` header sent by the client
+fn verify_answer_authorization_header(header: &str) -> Option<String> {
+ let (scheme, token) = header.split_once(' ').unwrap_or_default();
+ if scheme.to_lowercase() != "proxmoxinstallertoken" {
+ return None;
+ }
+
+ let _lock = pdm_config::auto_install::token_read_lock();
+
+ let (id, secret) = token.split_once(':').unwrap_or_default();
+ pdm_config::auto_install::verify_token_secret(id, secret).ok()?;
+
+ Some(id.to_owned())
+}
+
+/// POST /auto-install/answer
+///
+/// Handles the system information of a new machine to install.
+///
+/// See also
+/// <https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP>
+///
+/// Returns a auto-installer configuration if a matching one is found, otherwise errors out.
+///
+/// The system information data is saved in any case to make them easily inspectable.
+fn new_installation(token_id: &String, payload: AnswerFetchData) -> Result<AutoInstallerConfig> {
+ let _lock = pdm_config::auto_install::installations_write_lock();
+
+ let uuid = Uuid::generate();
+ let (mut installations, _) = pdm_config::auto_install::read_installations()?;
+
+ if installations.iter().any(|p| p.uuid == uuid) {
+ http_bail!(CONFLICT, "already exists");
+ }
+
+ let timestamp_now = proxmox_time::epoch_i64();
+
+ if let Some(config) = find_config(token_id, &payload.sysinfo)? {
+ let status = if config.post_hook_base_url.is_some() {
+ InstallationStatus::InProgress
+ } else {
+ InstallationStatus::AnswerSent
+ };
+
+ let mut answer: AutoInstallerConfig = render_prepared_config(&config, &payload.sysinfo)?;
+
+ 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
+ if let Some(base_url) = config.post_hook_base_url {
+ answer.post_installation_webhook = Some(PostNotificationHookInfo {
+ url: format!("{base_url}/api2/json/auto-install/installations/{uuid}/post-hook"),
+ cert_fingerprint: config.post_hook_cert_fp.clone(),
+ });
+ }
+
+ increment_template_counters(&config.id)?;
+ pdm_config::auto_install::save_installations(&installations)?;
+ Ok(answer)
+ } else {
+ installations.push(Installation {
+ uuid: uuid.clone(),
+ received_at: timestamp_now,
+ status: InstallationStatus::NoAnswerFound,
+ info: payload.sysinfo,
+ answer_id: None,
+ post_hook_data: None,
+ });
+
+ pdm_config::auto_install::save_installations(&installations)?;
+ http_bail!(NOT_FOUND, "no answer file found");
+ }
+}
+
+#[api(
+ returns: {
+ description: "List of all automated installations.",
+ type: Array,
+ items: { type: Installation },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/installations
+///
+/// Get all automated installations.
+fn list_installations(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<Installation>> {
+ let _lock = pdm_config::auto_install::installations_read_lock();
+
+ let (config, digest) = pdm_config::auto_install::read_installations()?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+ Ok(config)
+}
+
+#[api(
+ input: {
+ properties: {
+ uuid: {
+ schema: INSTALLATION_UUID_SCHEMA,
+ }
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// DELETE /auto-install/installations/{uuid}
+///
+/// Remove an installation entry.
+fn delete_installation(uuid: Uuid) -> Result<()> {
+ let _lock = pdm_config::auto_install::installations_write_lock();
+
+ let (mut installations, _) = pdm_config::auto_install::read_installations()?;
+ if installations
+ .extract_if(.., |inst| inst.uuid == uuid)
+ .count()
+ == 0
+ {
+ http_bail!(NOT_FOUND, "no such entry {uuid:?}");
+ }
+
+ pdm_config::auto_install::save_installations(&installations)
+}
+
+#[api(
+ returns: {
+ description: "List of prepared auto-installer answer configurations.",
+ type: Array,
+ items: { type: PreparedInstallationConfig },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/prepared
+///
+/// Get all prepared auto-installer answer configurations.
+async fn list_prepared_answers(
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<PreparedInstallationConfig>> {
+ let (prepared, digest) = pdm_config::auto_install::read_prepared_answers()?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+
+ prepared.values().try_fold(
+ Vec::with_capacity(prepared.len()),
+ |mut v, p| -> Result<Vec<PreparedInstallationConfig>, anyhow::Error> {
+ let mut p: PreparedInstallationConfig = p.clone().try_into()?;
+ p.root_password_hashed = None;
+ v.push(p);
+ Ok(v)
+ },
+ )
+}
+
+#[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_answer(
+ 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.contains_key(&config.id) {
+ http_bail!(
+ CONFLICT,
+ "configuration with ID {} already exists",
+ config.id
+ );
+ }
+
+ if config.is_default {
+ if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) = prepared
+ .values()
+ .find(|PreparedInstallationSectionConfigWrapper::PreparedConfig(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.insert(config.id.clone(), config.try_into()?);
+ pdm_config::auto_install::save_prepared_answers(&prepared)
+}
+
+#[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_answer(id: String) -> Result<PreparedInstallationConfig> {
+ let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(mut p)) =
+ prepared.get(&id).cloned()
+ {
+ // Don't send the hashed password, the user cannot do anything with it anyway
+ p.root_password_hashed = None;
+ p.try_into()
+ } 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: DeletablePreparedInstallationConfigProperty,
+ }
+ },
+ 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_answer(
+ id: String,
+ update: PreparedInstallationConfigUpdater,
+ root_password: Option<String>,
+ delete: Option<Vec<DeletablePreparedInstallationConfigProperty>>,
+ 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())?;
+
+ if update.is_default.unwrap_or(false) {
+ if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(other)) =
+ prepared.values().find(
+ |PreparedInstallationSectionConfigWrapper::PreparedConfig(p)| {
+ p.is_default && p.id != id
+ },
+ )
+ {
+ http_bail!(
+ CONFLICT,
+ "configuration '{}' is already the default answer",
+ other.id
+ );
+ }
+ }
+
+ let p = match prepared.get_mut(&id) {
+ Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) => p,
+ None => http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}"),
+ };
+
+ if let Some(delete) = delete {
+ for prop in delete {
+ match prop {
+ DeletablePreparedInstallationConfigProperty::TargetFilter => {
+ p.target_filter.clear();
+ }
+ DeletablePreparedInstallationConfigProperty::NetdevFilter => {
+ p.netdev_filter.clear();
+ }
+ DeletablePreparedInstallationConfigProperty::DiskFilter => {
+ p.disk_filter.clear();
+ }
+ DeletablePreparedInstallationConfigProperty::RootSshKeys => {
+ p.root_ssh_keys.clear();
+ }
+ DeletablePreparedInstallationConfigProperty::PostHookBaseUrl => {
+ p.post_hook_base_url = None;
+ }
+ DeletablePreparedInstallationConfigProperty::PostHookCertFp => {
+ p.post_hook_cert_fp = None;
+ }
+ DeletablePreparedInstallationConfigProperty::TemplateCounters => {
+ p.template_counters.clear();
+ }
+ }
+ }
+ }
+
+ // Destructuring makes sure we don't forget any member
+ let PreparedInstallationConfigUpdater {
+ authorized_tokens,
+ is_default,
+ target_filter,
+ country,
+ fqdn,
+ use_dhcp_fqdn,
+ keyboard,
+ mailto,
+ timezone,
+ root_password_hashed,
+ reboot_on_error,
+ reboot_mode,
+ root_ssh_keys,
+ use_dhcp_network,
+ cidr,
+ gateway,
+ dns,
+ netdev_filter,
+ netif_name_pinning_enabled,
+ filesystem,
+ disk_mode,
+ disk_list,
+ disk_filter,
+ disk_filter_match,
+ post_hook_base_url,
+ post_hook_cert_fp,
+ template_counters,
+ } = update;
+
+ if let Some(tokens) = authorized_tokens {
+ p.authorized_tokens = tokens;
+ }
+
+ if let Some(is_default) = is_default {
+ p.is_default = is_default;
+ }
+
+ if let Some(target_filter) = target_filter {
+ **p.target_filter = target_filter;
+ }
+
+ if let Some(country) = country {
+ p.country = country;
+ }
+
+ if let Some(fqdn) = fqdn {
+ p.fqdn = fqdn;
+ }
+
+ if let Some(use_dhcp) = use_dhcp_fqdn {
+ p.use_dhcp_fqdn = use_dhcp;
+ }
+
+ if let Some(keyboard) = keyboard {
+ p.keyboard = keyboard;
+ }
+
+ if let Some(mailto) = mailto {
+ p.mailto = mailto;
+ }
+
+ if let Some(timezone) = 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) = root_password_hashed {
+ p.root_password_hashed = Some(password);
+ }
+
+ if let Some(reboot_on_error) = reboot_on_error {
+ p.reboot_on_error = reboot_on_error;
+ }
+
+ if let Some(reboot_mode) = reboot_mode {
+ p.reboot_mode = reboot_mode;
+ }
+
+ if let Some(ssh_keys) = root_ssh_keys {
+ p.root_ssh_keys = ssh_keys;
+ }
+
+ if let Some(use_dhcp) = use_dhcp_network {
+ p.use_dhcp_network = use_dhcp;
+ }
+
+ if let Some(cidr) = cidr {
+ p.cidr = Some(cidr);
+ }
+
+ if let Some(gateway) = gateway {
+ p.gateway = Some(gateway);
+ }
+
+ if let Some(dns) = dns {
+ p.dns = Some(dns);
+ }
+
+ if let Some(filter) = netdev_filter {
+ **p.netdev_filter = filter;
+ }
+
+ if let Some(enabled) = netif_name_pinning_enabled {
+ p.netif_name_pinning_enabled = enabled;
+ }
+
+ if let Some(fs) = filesystem {
+ *p.filesystem = fs;
+ }
+
+ if let Some(mode) = disk_mode {
+ p.disk_mode = mode;
+ }
+
+ if let Some(list) = disk_list {
+ p.disk_list = list;
+ }
+
+ if let Some(filter) = disk_filter {
+ **p.disk_filter = filter;
+ }
+
+ if let Some(filter_match) = disk_filter_match {
+ p.disk_filter_match = Some(filter_match);
+ }
+
+ if let Some(url) = post_hook_base_url {
+ p.post_hook_base_url = Some(url);
+ }
+
+ if let Some(fp) = post_hook_cert_fp {
+ p.post_hook_cert_fp = Some(fp);
+ }
+
+ if let Some(counters) = template_counters {
+ **p.template_counters = counters;
+ }
+
+ pdm_config::auto_install::save_prepared_answers(&prepared)
+}
+
+#[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_answer(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.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(())
+}
+
+/// Tries to find a prepared answer configuration matching the given target node system
+/// information.
+///
+/// # Parameters
+///
+/// * `token_id` - ID of the authorization token.
+/// * `info` - System information of the machine to be installed.
+///
+/// # Returns
+///
+/// * `Ok(Some(answer))` if a matching answer was found, containing the most specified answer that
+/// matched.
+/// * `Ok(None)` if no answer was matched and no default one exists, either.
+/// * `Err(..)` if some error occurred.
+fn find_config(
+ token_id: &String,
+ 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 sc in prepared.values() {
+ let PreparedInstallationSectionConfigWrapper::PreparedConfig(p) = sc;
+
+ if !p.authorized_tokens.contains(token_id) {
+ continue;
+ }
+
+ if p.is_default {
+ // Save the default answer for later and use it if no other matched before that
+ 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| {
+ // Retrieve the value the key (aka. a JSON pointer) points to
+ if let Some(value) = info.pointer(filter.0).and_then(|v| v.as_str()) {
+ // .. and match it against the given value glob
+ match glob::Pattern::new(filter.1) {
+ Ok(pattern) => pattern.matches(value),
+ _ => false,
+ }
+ } else {
+ false
+ }
+ });
+
+ if matched_all {
+ return Ok(Some(p.clone().try_into()?));
+ }
+ }
+
+ // If no specific target filter(s) matched, return the default answer, if there is one
+ default_answer.map(|a| a.try_into()).transpose()
+}
+
+/// Renders a given [`PreparedInstallationConfig`] into the target [`AutoInstallerConfig`] struct.
+///
+/// Converts all types as needed and renders out Handlebar templates in applicable fields.
+/// Currently, templating is supported for the following fields:
+///
+/// * `fqdn`
+/// * `mailto`
+/// * `cidr`
+/// * `dns`
+/// * `gateway`
+fn render_prepared_config(
+ conf: &PreparedInstallationConfig,
+ sysinfo: &SystemInfo,
+) -> Result<AutoInstallerConfig> {
+ use pdm_api_types::auto_installer::DiskSelectionMode;
+ use proxmox_installer_types::answer::{Filesystem, FilesystemOptions};
+
+ let mut handlebars = Handlebars::new();
+ handlebars.register_helper("zeropad", Box::new(handlebars_zeropad_int_helper));
+
+ let mut template_data = serde_json::to_value(sysinfo)?;
+ if let Some(obj) = template_data.as_object_mut() {
+ for (k, v) in conf.template_counters.iter() {
+ obj.insert(k.clone(), (*v).into());
+ }
+ }
+ let hb_context = handlebars::Context::from(template_data);
+
+ let fqdn = if conf.use_dhcp_fqdn {
+ answer::FqdnConfig::from_dhcp(None)
+ } else {
+ let fqdn = handlebars.render_template_with_context(&conf.fqdn.to_string(), &hb_context)?;
+ answer::FqdnConfig::Simple(Fqdn::from(&fqdn)?)
+ };
+
+ let mailto = handlebars.render_template_with_context(&conf.mailto, &hb_context)?;
+
+ let global = answer::GlobalOptions {
+ country: conf.country.clone(),
+ fqdn,
+ keyboard: conf.keyboard,
+ mailto,
+ timezone: conf.timezone.clone(),
+ root_password: None,
+ root_password_hashed: conf.root_password_hashed.clone(),
+ 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(
+ answer::NetworkInterfacePinningOptionsAnswer {
+ enabled: true,
+ mapping: HashMap::new(),
+ },
+ );
+
+ if conf.use_dhcp_network {
+ answer::NetworkConfig::FromDhcp(answer::NetworkConfigFromDhcp {
+ interface_name_pinning,
+ })
+ } else {
+ let cidr = conf
+ .cidr
+ .ok_or_else(|| anyhow!("no host address"))
+ .and_then(|cidr| {
+ Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?)
+ })
+ .and_then(|s| Ok(s.parse()?))?;
+
+ let dns = conf
+ .dns
+ .ok_or_else(|| anyhow!("no DNS server address"))
+ .and_then(|cidr| {
+ Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?)
+ })
+ .and_then(|s| Ok(s.parse()?))?;
+
+ let gateway = conf
+ .gateway
+ .ok_or_else(|| anyhow!("no gateway address"))
+ .and_then(|cidr| {
+ Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?)
+ })
+ .and_then(|s| Ok(s.parse()?))?;
+
+ answer::NetworkConfig::FromAnswer(answer::NetworkConfigFromAnswer {
+ cidr,
+ dns,
+ gateway,
+ filter: conf.netdev_filter.clone(),
+ interface_name_pinning,
+ })
+ }
+ };
+
+ let (disk_list, filter) = if conf.disk_mode == DiskSelectionMode::Fixed {
+ (conf.disk_list.clone(), BTreeMap::new())
+ } else {
+ (vec![], conf.disk_filter.clone())
+ };
+
+ let disks = answer::DiskSetup {
+ filesystem: match conf.filesystem {
+ FilesystemOptions::Ext4(_) => Filesystem::Ext4,
+ FilesystemOptions::Xfs(_) => Filesystem::Xfs,
+ FilesystemOptions::Zfs(_) => Filesystem::Zfs,
+ FilesystemOptions::Btrfs(_) => Filesystem::Btrfs,
+ },
+ disk_list,
+ filter,
+ filter_match: conf.disk_filter_match,
+ zfs: match conf.filesystem {
+ FilesystemOptions::Zfs(opts) => Some(opts),
+ _ => None,
+ },
+ lvm: match conf.filesystem {
+ FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(opts) => Some(opts),
+ _ => None,
+ },
+ btrfs: match conf.filesystem {
+ FilesystemOptions::Btrfs(opts) => Some(opts),
+ _ => None,
+ },
+ };
+
+ Ok(AutoInstallerConfig {
+ global,
+ network,
+ disks,
+ post_installation_webhook: None,
+ first_boot: None,
+ })
+}
+
+/// Increments all counters of a given template by one.
+///
+/// # Parameters
+///
+/// `id` - ID of the template to update.
+fn increment_template_counters(id: &str) -> Result<()> {
+ let _lock = pdm_config::auto_install::prepared_answers_write_lock();
+ let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ let conf = match prepared.get_mut(id) {
+ Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) => p,
+ None => http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}"),
+ };
+
+ conf.template_counters
+ .values_mut()
+ .for_each(|v| *v = v.saturating_add(1));
+
+ pdm_config::auto_install::save_prepared_answers(&prepared)?;
+ Ok(())
+}
+
+/// Handlebars handler for the "zeropad" helper.
+///
+/// Takes an integer as first argument and target width as second argument, and returns the integer
+/// formatted as string padded with leading zeros, such that it is exactly as long as specified in
+/// the target width.
+fn handlebars_zeropad_int_helper(
+ h: &handlebars::Helper,
+ _: &Handlebars,
+ _: &handlebars::Context,
+ _rc: &mut handlebars::RenderContext,
+ out: &mut dyn handlebars::Output,
+) -> handlebars::HelperResult {
+ let value = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| {
+ handlebars::RenderErrorReason::ParamNotFoundForIndex("integer to format", 0)
+ })?;
+
+ let width: usize = h
+ .param(1)
+ .and_then(|v| v.value().as_u64())
+ .and_then(|v| v.try_into().ok())
+ .ok_or_else(|| handlebars::RenderErrorReason::ParamNotFoundForIndex("target width", 0))?;
+
+ out.write(&format!("{value:00$}", width))?;
+ Ok(())
+}
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.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 19/38] server: api: auto-installer: add access token management endpoints
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (17 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints Christoph Heiss
` (18 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Quick overview:
GET /auto-install/tokens
list all available answer authentication tokens
POST /auto-install/tokens
create a new token
PUT /auto-install/tokens/{id}
update an existing token
DELETE /auto-install/tokens/{id}
delete an existing token
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
server/src/api/auto_installer/mod.rs | 279 ++++++++++++++++++++++++++-
1 file changed, 276 insertions(+), 3 deletions(-)
diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs
index 60eccd8..fed88aa 100644
--- a/server/src/api/auto_installer/mod.rs
+++ b/server/src/api/auto_installer/mod.rs
@@ -1,17 +1,18 @@
//! Implements all the methods under `/api2/json/auto-install/`.
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context, Result};
use handlebars::Handlebars;
use http::StatusCode;
use std::collections::{BTreeMap, HashMap};
use pdm_api_types::{
auto_installer::{
+ AnswerAuthToken, AnswerAuthTokenUpdater, DeletableAnswerAuthTokenProperty,
DeletablePreparedInstallationConfigProperty, Installation, InstallationStatus,
PreparedInstallationConfig, PreparedInstallationConfigUpdater, INSTALLATION_UUID_SCHEMA,
PREPARED_INSTALL_CONFIG_ID_SCHEMA,
},
- ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA,
+ Authid, ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA,
};
use pdm_config::auto_install::types::PreparedInstallationSectionConfigWrapper;
use proxmox_installer_types::{
@@ -27,7 +28,9 @@ use proxmox_router::{
http_bail, list_subdirs_api_method, ApiHandler, ApiMethod, ApiResponseFuture, Permission,
Router, RpcEnvironment, SubdirMap,
};
-use proxmox_schema::{api, AllOfSchema, ApiType, ParameterSchema, ReturnType, StringSchema};
+use proxmox_schema::{
+ api, api_types::COMMENT_SCHEMA, AllOfSchema, ApiType, ParameterSchema, ReturnType, StringSchema,
+};
use proxmox_sortable_macro::sortable;
use proxmox_uuid::Uuid;
@@ -62,6 +65,18 @@ const SUBDIRS: SubdirMap = &sorted!([
.delete(&API_METHOD_DELETE_PREPARED_ANSWER)
)
),
+ (
+ "tokens",
+ &Router::new()
+ .get(&API_METHOD_LIST_TOKENS)
+ .post(&API_METHOD_CREATE_TOKEN)
+ .match_all(
+ "id",
+ &Router::new()
+ .put(&API_METHOD_UPDATE_TOKEN)
+ .delete(&API_METHOD_DELETE_TOKEN)
+ )
+ ),
]);
pub const ROUTER: Router = Router::new()
@@ -698,6 +713,264 @@ async fn handle_post_hook(uuid: Uuid, info: PostHookInfo) -> Result<()> {
Ok(())
}
+#[api(
+ returns: {
+ description: "List of secrets for authenticating automated installations requests.",
+ type: Array,
+ items: {
+ type: AnswerAuthToken,
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// GET /auto-install/tokens
+///
+/// Get all tokens that can be used for authenticating automated installations requests.
+async fn list_tokens(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<AnswerAuthToken>> {
+ let (secrets, digest) = pdm_config::auto_install::read_tokens()?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+
+ Ok(secrets.values().map(|t| t.clone().into()).collect())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ type: String,
+ description: "Token ID.",
+ },
+ comment: {
+ schema: COMMENT_SCHEMA,
+ optional: true,
+ },
+ enabled: {
+ type: bool,
+ description: "Whether the token is enabled.",
+ default: true,
+ optional: true,
+ },
+ "expire-at": {
+ type: Integer,
+ description: "Token expiration date, in seconds since the epoch. '0' means no expiration.",
+ default: 0,
+ minimum: 0,
+ optional: true,
+ },
+ },
+ },
+ returns: {
+ type: Object,
+ description: "Secret of the newly created token.",
+ properties: {
+ token: {
+ type: AnswerAuthToken,
+ },
+ secret: {
+ type: String,
+ description: "Secret of the newly created token.",
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// POST /auto-install/tokens
+///
+/// Creates a new token for authenticating automated installations.
+async fn create_token(
+ id: String,
+ comment: Option<String>,
+ enabled: Option<bool>,
+ expire_at: Option<i64>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<serde_json::Value> {
+ let _lock = pdm_config::auto_install::token_write_lock();
+
+ let authid = rpcenv
+ .get_auth_id()
+ .ok_or_else(|| anyhow!("no authid"))?
+ .parse::<Authid>()?;
+
+ let token = AnswerAuthToken {
+ id,
+ created_by: authid.user().clone(),
+ comment,
+ enabled,
+ expire_at,
+ };
+ let secret = Uuid::generate();
+
+ pdm_config::auto_install::add_token(&token, &secret.to_string())
+ .context("failed to create new token")?;
+
+ Ok(serde_json::json!({
+ "token": token,
+ "secret": secret,
+ }))
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ type: String,
+ description: "Token ID.",
+ },
+ update: {
+ type: AnswerAuthTokenUpdater,
+ flatten: true,
+ },
+ delete: {
+ type: Array,
+ description: "List of properties to delete.",
+ optional: true,
+ items: {
+ type: DeletableAnswerAuthTokenProperty,
+ }
+ },
+ "regenerate-secret": {
+ type: bool,
+ description: "Whether to regenerate the current secret, invalidating the old one.",
+ optional: true,
+ default: false,
+ },
+ digest: {
+ type: ConfigDigest,
+ optional: true,
+ },
+ },
+ },
+ returns: {
+ type: Object,
+ description: "The updated access token information.",
+ properties: {
+ token: {
+ type: AnswerAuthToken,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// PUT /auto-install/tokens/{id}
+///
+/// Updates an existing access token.
+async fn update_token(
+ id: String,
+ update: AnswerAuthTokenUpdater,
+ delete: Option<Vec<DeletableAnswerAuthTokenProperty>>,
+ regenerate_secret: bool,
+ digest: Option<ConfigDigest>,
+) -> Result<serde_json::Value> {
+ let _lock = pdm_config::auto_install::token_write_lock();
+ let (tokens, config_digest) = pdm_config::auto_install::read_tokens()?;
+
+ config_digest.detect_modification(digest.as_ref())?;
+
+ let mut token: AnswerAuthToken = match tokens.get(&id.to_string()).cloned() {
+ Some(token) => token.into(),
+ None => http_bail!(NOT_FOUND, "no such access token: {id}"),
+ };
+
+ if let Some(delete) = delete {
+ for prop in delete {
+ match prop {
+ DeletableAnswerAuthTokenProperty::Comment => token.comment = None,
+ DeletableAnswerAuthTokenProperty::ExpireAt => token.expire_at = None,
+ }
+ }
+ }
+
+ let AnswerAuthTokenUpdater {
+ comment,
+ enabled,
+ expire_at,
+ } = update;
+
+ if let Some(comment) = comment {
+ token.comment = Some(comment);
+ }
+
+ if let Some(enabled) = enabled {
+ token.enabled = Some(enabled);
+ }
+
+ if let Some(expire_at) = expire_at {
+ token.expire_at = Some(expire_at);
+ }
+
+ if regenerate_secret {
+ // If the user instructed to update secret, just delete + re-create the token and let
+ // the config implementation handle updating the shadow
+ pdm_config::auto_install::delete_token(&token.id)?;
+
+ let secret = Uuid::generate();
+ pdm_config::auto_install::add_token(&token, &secret.to_string())?;
+
+ Ok(serde_json::json!({
+ "token": token,
+ "secret": secret,
+ }))
+ } else {
+ pdm_config::auto_install::update_token(&token).context("failed to update token")?;
+
+ Ok(serde_json::json!({
+ "token": token,
+ }))
+ }
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ type: String,
+ description: "Token ID.",
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// DELETE /auto-install/tokens/{id}
+///
+/// Deletes a prepared auto-installer answer configuration.
+///
+/// If the token is currently in use by any prepared answer configuration, the deletion will fail.
+async fn delete_token(id: String) -> Result<()> {
+ // first check if the token is used anywhere
+ let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+ let used = prepared
+ .values()
+ .filter_map(|p| {
+ let PreparedInstallationSectionConfigWrapper::PreparedConfig(p) = p;
+ p.authorized_tokens.contains(&id).then(|| p.id.clone())
+ })
+ .collect::<Vec<String>>();
+
+ if !used.is_empty() {
+ http_bail!(
+ CONFLICT,
+ "token still in use by answer configurations: {}",
+ used.join(", ")
+ );
+ }
+
+ let _lock = pdm_config::auto_install::token_write_lock();
+ pdm_config::auto_install::delete_token(&id)
+}
+
/// Tries to find a prepared answer configuration matching the given target node system
/// information.
///
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (18 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 19/38] server: api: auto-installer: add access token management endpoints Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel Christoph Heiss
` (17 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
lib/pdm-client/src/lib.rs | 232 ++++++++++++++++++++++++++++++++++++++
1 file changed, 232 insertions(+)
diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs
index 1565869..190d1f5 100644
--- a/lib/pdm-client/src/lib.rs
+++ b/lib/pdm-client/src/lib.rs
@@ -3,6 +3,11 @@
use std::collections::HashMap;
use std::time::Duration;
+use pdm_api_types::auto_installer::{
+ AnswerAuthToken, AnswerAuthTokenUpdater, DeletableAnswerAuthTokenProperty,
+ DeletablePreparedInstallationConfigProperty, Installation, PreparedInstallationConfig,
+ PreparedInstallationConfigUpdater,
+};
use pdm_api_types::remote_updates::RemoteUpdateSummary;
use pdm_api_types::remotes::{RemoteType, TlsProbeOutcome};
use pdm_api_types::resource::{PveResource, RemoteResources, ResourceType, TopEntities};
@@ -1376,6 +1381,233 @@ impl<T: HttpApiClient> PdmClient<T> {
.expect_json()?
.data)
}
+
+ /// Retrieves all known installations done by auto-installer.
+ pub async fn get_autoinst_installations(&self) -> Result<Vec<Installation>, Error> {
+ Ok(self
+ .0
+ .get("/api2/extjs/auto-install/installations")
+ .await?
+ .expect_json()?
+ .data)
+ }
+
+ /// Deletes a saved auto-installation.
+ ///
+ /// # Parameters
+ ///
+ /// * `id` - ID of the entry to delete. Must be percent-encoded.
+ pub async fn delete_autoinst_installation(&self, id: &str) -> Result<(), Error> {
+ self.0
+ .delete(&format!("/api2/extjs/auto-install/installations/{id}"))
+ .await?
+ .nodata()?;
+ Ok(())
+ }
+
+ /// Retrieves all prepared answer configurations.
+ pub async fn get_autoinst_prepared_answers(
+ &self,
+ ) -> Result<Vec<PreparedInstallationConfig>, Error> {
+ Ok(self
+ .0
+ .get("/api2/extjs/auto-install/prepared")
+ .await?
+ .expect_json()?
+ .data)
+ }
+
+ /// Adds a new prepared answer file configuration for automated installations.
+ ///
+ /// # Arguments
+ ///
+ /// * `answer` - Answer to create.
+ pub async fn add_autoinst_prepared_answer(
+ &self,
+ answer: &PreparedInstallationConfig,
+ ) -> Result<(), Error> {
+ self.0
+ .post("/api2/extjs/auto-install/prepared", answer)
+ .await?
+ .nodata()
+ }
+
+ /// Update an existing prepared answer file configuration for automated installations.
+ ///
+ /// # Arguments
+ ///
+ /// * `id` - ID of the entry to delete. Must be percent-encoded.
+ /// * `updater` - Field values to update.
+ /// * `root_password` - Optional root password to set for this answer.
+ /// * `delete` - List of properties to delete.
+ pub async fn update_autoinst_prepared_answer(
+ &self,
+ id: &str,
+ updater: &PreparedInstallationConfigUpdater,
+ root_password: Option<&str>,
+ delete: &[DeletablePreparedInstallationConfigProperty],
+ ) -> Result<(), Error> {
+ #[derive(Serialize)]
+ struct UpdatePreparedAnswer<'a> {
+ #[serde(flatten)]
+ updater: &'a PreparedInstallationConfigUpdater,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ root_password: Option<&'a str>,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ delete: Vec<String>,
+ }
+
+ let delete = delete
+ .iter()
+ .map(DeletablePreparedInstallationConfigProperty::to_string)
+ .collect();
+
+ self.0
+ .put(
+ &format!("/api2/extjs/auto-install/prepared/{id}"),
+ &UpdatePreparedAnswer {
+ updater,
+ root_password,
+ delete,
+ },
+ )
+ .await?
+ .nodata()
+ }
+
+ /// Deletes a prepared answer for automated installations.
+ ///
+ /// # Parameters
+ ///
+ /// * `id` - ID of the entry to delete. Must be percent-encoded.
+ pub async fn delete_autoinst_prepared_answer(&self, id: &str) -> Result<(), Error> {
+ self.0
+ .delete(&format!("/api2/extjs/auto-install/prepared/{id}"))
+ .await?
+ .nodata()?;
+ Ok(())
+ }
+
+ /// Retrieves all access tokens for the auto-installer server.
+ pub async fn get_autoinst_auth_tokens(&self) -> Result<Vec<AnswerAuthToken>, Error> {
+ Ok(self
+ .0
+ .get("/api2/extjs/auto-install/tokens")
+ .await?
+ .expect_json()?
+ .data)
+ }
+
+ /// Adds a new access token for authenticating requests from the automated installer.
+ ///
+ /// # Parameters
+ ///
+ /// * `id` - Name of the token to create.
+ /// * `comment` - Optional comment for the token.
+ /// * `enabled` - Whether this token is enabled.
+ /// * `expire_at` - Optional expiration date for this token.
+ pub async fn add_autoinst_auth_token(
+ &self,
+ id: &str,
+ comment: Option<String>,
+ enabled: Option<bool>,
+ expire_at: Option<i64>,
+ ) -> Result<(AnswerAuthToken, String), Error> {
+ #[derive(Serialize)]
+ #[serde(rename_all = "kebab-case")]
+ struct CreateTokenRequest<'a> {
+ id: &'a str,
+ comment: &'a Option<String>,
+ enabled: Option<bool>,
+ expire_at: Option<i64>,
+ }
+
+ #[derive(Deserialize)]
+ struct CreateTokenResponse {
+ token: AnswerAuthToken,
+ secret: String,
+ }
+
+ let response = self
+ .0
+ .post(
+ "/api2/extjs/auto-install/tokens",
+ &CreateTokenRequest {
+ id,
+ comment: &comment,
+ enabled,
+ expire_at,
+ },
+ )
+ .await?
+ .expect_json::<CreateTokenResponse>()?;
+
+ Ok((response.data.token, response.data.secret))
+ }
+
+ /// Updates an existing access token for authenticating requests from the automated installer.
+ ///
+ /// # Parameters
+ ///
+ /// * `id` - Name of the token to update.
+ /// * `updater` - Fields to update.
+ /// * `delete` - Fields to delete.
+ pub async fn update_autoinst_auth_token(
+ &self,
+ id: &str,
+ updater: &AnswerAuthTokenUpdater,
+ delete: &[DeletableAnswerAuthTokenProperty],
+ regenerate_secret: bool,
+ ) -> Result<(AnswerAuthToken, Option<String>), Error> {
+ #[derive(Serialize)]
+ #[serde(rename_all = "kebab-case")]
+ struct UpdateToken<'a> {
+ #[serde(flatten)]
+ updater: &'a AnswerAuthTokenUpdater,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ delete: Vec<String>,
+ regenerate_secret: bool,
+ }
+
+ #[derive(Deserialize)]
+ struct UpdateTokenResponse {
+ token: AnswerAuthToken,
+ secret: Option<String>,
+ }
+
+ let delete = delete
+ .iter()
+ .map(DeletableAnswerAuthTokenProperty::to_string)
+ .collect();
+
+ let response = self
+ .0
+ .put(
+ &format!("/api2/extjs/auto-install/tokens/{id}"),
+ &UpdateToken {
+ updater,
+ delete,
+ regenerate_secret,
+ },
+ )
+ .await?
+ .expect_json::<UpdateTokenResponse>()?;
+
+ Ok((response.data.token, response.data.secret))
+ }
+
+ /// Deletes an access token used for authenticating automated installations.
+ ///
+ /// # Parameters
+ ///
+ /// * `id` - Name of the token to delete.
+ pub async fn delete_autoinst_auth_token(&self, id: &str) -> Result<(), Error> {
+ self.0
+ .delete(&format!("/api2/extjs/auto-install/tokens/{id}"))
+ .await?
+ .nodata()?;
+ Ok(())
+ }
}
/// Builder for migration parameters.
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (19 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
` (16 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
A simple overview panel with a list of in-progress and on-going installations.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* the panel now lives under the "Remotes" menu
Changes v1 -> v2:
* no changes
ui/Cargo.toml | 2 +
.../auto_installer/installations_panel.rs | 305 ++++++++++++++++++
ui/src/remotes/auto_installer/mod.rs | 53 +++
ui/src/remotes/mod.rs | 10 +
4 files changed, 370 insertions(+)
create mode 100644 ui/src/remotes/auto_installer/installations_panel.rs
create mode 100644 ui/src/remotes/auto_installer/mod.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index a0215c1..7d00133 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -37,6 +37,7 @@ proxmox-acme-api = "1"
proxmox-deb-version = "0.1"
proxmox-client = "1"
proxmox-human-byte = "1"
+proxmox-installer-types = "0.1"
proxmox-login = "1"
proxmox-schema = "5"
proxmox-subscription = { version = "1.0.1", features = ["api-types"], default-features = false }
@@ -55,6 +56,7 @@ 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-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/remotes/auto_installer/installations_panel.rs b/ui/src/remotes/auto_installer/installations_panel.rs
new file mode 100644
index 0000000..07b61a5
--- /dev/null
+++ b/ui/src/remotes/auto_installer/installations_panel.rs
@@ -0,0 +1,305 @@
+//! Implements the UI components for displaying an overview view of all finished/in-progress
+//! installations.
+
+use anyhow::{anyhow, Result};
+use core::clone::Clone;
+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, LoadableComponentScopeExt,
+ LoadableComponentState,
+};
+use pwt::{
+ css::{Flex, FlexFit, Overflow},
+ props::{
+ ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder,
+ WidgetStyleBuilder,
+ },
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ form::TextArea,
+ Button, Toolbar,
+ },
+};
+use yew::{
+ virtual_dom::{Key, VComp, VNode},
+ Properties,
+};
+
+use crate::pdm_client;
+
+#[derive(Default, PartialEq, Properties)]
+pub struct InstallationsPanel {}
+
+impl From<InstallationsPanel> for VNode {
+ fn from(value: InstallationsPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<InstallationsPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+enum Message {
+ Refresh,
+ SelectionChange,
+ RemoveEntry,
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ ShowRawSystemInfo,
+ ShowRawPostHookData,
+}
+
+struct InstallationsPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<Installation>,
+ columns: Rc<Vec<DataTableHeader<Installation>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ InstallationsPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for InstallationsPanelComponent {
+ type Properties = InstallationsPanel;
+ 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 {
+ state: LoadableComponentState::new(),
+ 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 = pdm_client().get_autoinst_installations().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().clone();
+ self.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(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(self.loading()).onclick(ctx.link().callback(|_| Message::Refresh)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick({
+ move |_: &mut _| {
+ 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 id = percent_encode_component(&key.to_string());
+ Ok(pdm_client().delete_autoinst_installation(&id).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()
+ })
+ .sorter(|a: &Installation, b: &Installation| a.received_at.cmp(&b.received_at))
+ .sort_order(Some(false))
+ .into(),
+ DataTableColumn::new(tr!("Product"))
+ .width("300px")
+ .render(|item: &Installation| {
+ format!(
+ "{} {}-{}",
+ item.info.product.fullname, item.info.iso.release, item.info.iso.isorelease
+ )
+ .into()
+ })
+ .sorter(|a: &Installation, b: &Installation| {
+ a.info.product.product.cmp(&b.info.product.product)
+ })
+ .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()
+ })
+ .sorter(|a: &Installation, b: &Installation| a.status.cmp(&b.status))
+ .into(),
+ DataTableColumn::new(tr!("Matched answer"))
+ .flex(1)
+ .render(|item: &Installation| match &item.answer_id {
+ Some(s) => s.into(),
+ None => "-".into(),
+ })
+ .sorter(|a: &Installation, b: &Installation| a.answer_id.cmp(&b.answer_id))
+ .into(),
+ ]
+}
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
new file mode 100644
index 0000000..8155a9b
--- /dev/null
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -0,0 +1,53 @@
+//! Implements the UI for the proxmox-auto-installer integration.
+
+mod installations_panel;
+
+use std::rc::Rc;
+use yew::virtual_dom::{VComp, VNode};
+
+use pwt::{
+ css::{self, AlignItems, Fit},
+ prelude::*,
+ props::{ContainerBuilder, WidgetBuilder},
+ widget::{Container, Fa, Panel, Row},
+};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AutoInstallerPanel {}
+
+impl From<AutoInstallerPanel> for VNode {
+ fn from(value: AutoInstallerPanel) -> Self {
+ VComp::new::<AutoInstallerPanelComponent>(Rc::new(value), None).into()
+ }
+}
+
+pub struct AutoInstallerPanelComponent {}
+
+impl Component for AutoInstallerPanelComponent {
+ type Message = ();
+ type Properties = AutoInstallerPanel;
+
+ fn create(_: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, _: &Context<Self>) -> Html {
+ let installations_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("cubes"))
+ .with_child(tr!("Installations"))
+ .into();
+
+ Container::new()
+ .class("pwt-content-spacer")
+ .class(Fit)
+ .class(css::Display::Grid)
+ .with_child(
+ Panel::new()
+ .title(installations_title)
+ .with_child(installations_panel::InstallationsPanel::default()),
+ )
+ .into()
+ }
+}
diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs
index bfe9dc0..14b2dd0 100644
--- a/ui/src/remotes/mod.rs
+++ b/ui/src/remotes/mod.rs
@@ -32,6 +32,9 @@ mod remove_remote;
mod firewall;
pub use firewall::FirewallTree;
+mod auto_installer;
+use auto_installer::AutoInstallerPanel;
+
use yew::{function_component, Html};
use pwt::prelude::*;
@@ -75,6 +78,13 @@ pub fn system_configuration() -> Html {
.label(tr!("Firewall"))
.icon_class("fa fa-shield"),
|_| FirewallTree::new().into(),
+ )
+ .with_item_builder(
+ TabBarItem::new()
+ .key("auto-installer")
+ .label(tr!("Automated Installations"))
+ .icon_class("fa fa-cubes"),
+ |_| AutoInstallerPanel::default().into(),
);
NavigationContainer::new().with_child(panel).into()
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (20 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token " Christoph Heiss
` (15 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Adds a pretty typical CRUD panel, allowing users to add/edit/remove
prepared answer file configurations for the auto-install server.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* filters are now proper key-value `DataTable`s instead of plain text
areas
* added new "Templating" and "Authentication" tabs
* adapted as necessary to changed types from `proxmox-installer-types`
* use new `PdmClient` methods instead of manual post/put
* removed automatic `/api2` suffix from pdm base url
* set _target="blank" for RFC 6901 link
Changes v1 -> v2:
* new patch
ui/src/remotes/auto_installer/mod.rs | 19 +
.../prepared_answer_add_wizard.rs | 173 ++++
.../prepared_answer_edit_window.rs | 165 ++++
.../auto_installer/prepared_answer_form.rs | 857 ++++++++++++++++++
.../auto_installer/prepared_answers_panel.rs | 248 +++++
5 files changed, 1462 insertions(+)
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answer_form.rs
create mode 100644 ui/src/remotes/auto_installer/prepared_answers_panel.rs
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 8155a9b..1a85978 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -1,6 +1,10 @@
//! Implements the UI for the proxmox-auto-installer integration.
mod installations_panel;
+mod prepared_answer_add_wizard;
+mod prepared_answer_edit_window;
+mod prepared_answer_form;
+mod prepared_answers_panel;
use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -39,15 +43,30 @@ impl Component for AutoInstallerPanelComponent {
.with_child(tr!("Installations"))
.into();
+ let answers_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("files-o"))
+ .with_child(tr!("Prepared Answers"))
+ .into();
+
Container::new()
.class("pwt-content-spacer")
.class(Fit)
.class(css::Display::Grid)
+ .style("grid-template-columns", "repeat(2, 1fr)")
+ .style("grid-template-rows", "repeat(1, 1fr)")
.with_child(
Panel::new()
+ .style("grid-row", "span 2 / span 1")
.title(installations_title)
.with_child(installations_panel::InstallationsPanel::default()),
)
+ .with_child(
+ Panel::new()
+ .title(answers_title)
+ .with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
+ )
.into()
}
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
new file mode 100644
index 0000000..5d15a43
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -0,0 +1,173 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use js_sys::Intl;
+use proxmox_installer_types::answer;
+use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
+use wasm_bindgen::JsValue;
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use proxmox_yew_comp::{
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+ Wizard, WizardPageRenderInfo,
+};
+use pwt::{prelude::*, widget::TabBarItem};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+use crate::pdm_client;
+
+#[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 new() -> Self {
+ let config = PreparedInstallationConfig {
+ id: String::new(),
+ authorized_tokens: Vec::new(),
+ // target filter
+ is_default: false,
+ target_filter: BTreeMap::new(),
+ // global options
+ country: "at".to_owned(),
+ fqdn: "host.example.com".to_owned(),
+ use_dhcp_fqdn: false,
+ keyboard: answer::KeyboardLayout::default(),
+ mailto: String::new(),
+ timezone: 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: BTreeMap::new(),
+ netif_name_pinning_enabled: true,
+ // disk options
+ filesystem: answer::FilesystemOptions::Ext4(answer::LvmOptions::default()),
+ disk_mode: DiskSelectionMode::default(),
+ disk_list: Vec::new(),
+ disk_filter: BTreeMap::new(),
+ disk_filter_match: None,
+ // post hook
+ post_hook_base_url: pdm_origin(),
+ post_hook_cert_fp: None,
+ // templating
+ template_counters: BTreeMap::new(),
+ };
+
+ yew::props!(Self { config })
+ }
+
+ pub fn with(config: PreparedInstallationConfig) -> Self {
+ yew::props!(Self { config })
+ }
+}
+
+impl From<AddAnswerWizardProperties> for VNode {
+ fn from(value: AddAnswerWizardProperties) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<AddAnswerWizardComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct AddAnswerWizardComponent {
+ state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for AddAnswerWizardComponent {
+ type Properties = AddAnswerWizardProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ Self {
+ state: LoadableComponentState::new(),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ Box::pin(async move { Ok(()) })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+
+ Wizard::new(tr!("Add Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .on_done(props.on_done.clone())
+ .on_submit(|config: serde_json::Value| async move { submit(config).await })
+ .with_page(TabBarItem::new().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!("Templating")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_templating_form(&config)
+ })
+ .with_page(TabBarItem::new().label(tr!("Authentication")), {
+ let config = props.config.clone();
+ move |_: &WizardPageRenderInfo| render_auth_form(&config)
+ })
+ .into()
+ }
+}
+
+async fn submit(form_data: serde_json::Value) -> Result<()> {
+ let data = prepare_form_data(form_data)?;
+
+ pdm_client()
+ .add_autoinst_prepared_answer(&serde_json::from_value(data)?)
+ .await?;
+ Ok(())
+}
+
+fn 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())
+}
+
+fn pdm_origin() -> Option<String> {
+ gloo_utils::document()
+ .url()
+ .and_then(|s| web_sys::Url::new(&s))
+ .map(|url| url.origin())
+ .ok()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
new file mode 100644
index 0000000..3fb9766
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -0,0 +1,165 @@
+//! Implements the configuration dialog UI for the auto-installer integration.
+
+use anyhow::Result;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html::IntoEventCallback,
+ virtual_dom::{VComp, VNode},
+};
+
+use crate::pdm_client;
+use pdm_api_types::auto_installer::{
+ DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
+};
+use proxmox_yew_comp::{
+ form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
+ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
+};
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ widget::{form::FormContext, TabBarItem, TabPanel},
+};
+use pwt_macros::builder;
+
+use super::prepared_answer_form::*;
+
+#[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::<LoadableComponentMaster<EditAnswerWindowComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+struct EditAnswerWindowComponent {
+ state: LoadableComponentState<()>,
+}
+
+pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
+
+impl LoadableComponent for EditAnswerWindowComponent {
+ type Properties = EditAnswerWindowProperties;
+ type Message = ();
+ type ViewState = ();
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ Self {
+ state: LoadableComponentState::new(),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ Box::pin(async move { Ok(()) })
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let props = ctx.props();
+
+ EditWindow::new(tr!("Edit Prepared Answer"))
+ .width(900)
+ .resizable(true)
+ .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 id = props.config.id.clone();
+ move |form_ctx: FormContext| {
+ let id = id.clone();
+ let config = form_ctx.get_submit_data();
+ async move { submit(&percent_encode_component(&id), config).await }
+ }
+ })
+ .advanced_checkbox(true)
+ .into()
+ }
+}
+
+async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> {
+ let data = delete_empty_values(
+ &prepare_form_data(form_data)?,
+ &[
+ "root-ssh-keys",
+ "post-hook-base-url",
+ "post-hook-cert-fp",
+ "disk-filter",
+ "netdev-filter",
+ ],
+ true,
+ );
+
+ let root_password = data["root-password"].as_str().map(ToOwned::to_owned);
+ let delete = data["delete"]
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .flat_map(|s| s.as_str().and_then(|s| s.parse().ok()))
+ .collect::<Vec<DeletablePreparedInstallationConfigProperty>>();
+
+ pdm_client()
+ .update_autoinst_prepared_answer(
+ id,
+ &serde_json::from_value(data)?,
+ root_password.as_deref(),
+ &delete,
+ )
+ .await?;
+ Ok(())
+}
+
+fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+ TabPanel::new()
+ .class(FlexFit)
+ .force_render_all(true)
+ .with_item(
+ TabBarItem::new().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!("Templating")),
+ render_templating_form(&props.config),
+ )
+ .with_item(
+ TabBarItem::new().label(tr!("Authentication")),
+ render_auth_form(&props.config),
+ )
+ .into()
+}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
new file mode 100644
index 0000000..29bc768
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -0,0 +1,857 @@
+//! Provides all shared components for the prepared answer create wizard and the corresponding
+//! edit window, as well as some utility to collect and prepare the form data for submission.
+
+use anyhow::{anyhow, bail, Result};
+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,
+ },
+ EMAIL_DEFAULT_PLACEHOLDER,
+};
+use proxmox_schema::api_types::{CIDR_SCHEMA, IP_SCHEMA};
+use proxmox_yew_comp::SchemaValidation;
+use pwt::{
+ css::{Flex, FlexFit, Overflow},
+ prelude::*,
+ widget::{
+ form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
+ Container, Fa, FieldPosition, InputPanel, KeyValueList,
+ },
+};
+
+pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
+ let obj = value
+ .as_object_mut()
+ .ok_or_else(|| anyhow!("form data must always be an object"))?;
+
+ let fs_opts = collect_fs_options(obj);
+ let disk_list: Vec<String> = obj
+ .remove("disk-list")
+ .and_then(|s| {
+ s.as_str()
+ .map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
+ })
+ .unwrap_or_default();
+
+ let root_ssh_keys = collect_lines_into_array(obj.remove("root-ssh-keys"));
+
+ value["filesystem"] = json!(fs_opts);
+ value["disk-list"] = json!(disk_list);
+ value["root-ssh-keys"] = root_ssh_keys;
+ Ok(value)
+}
+
+fn collect_fs_options(obj: &mut serde_json::Map<String, Value>) -> FilesystemOptions {
+ let fs_type = obj
+ .get("filesystem-type")
+ .and_then(|s| s.as_str())
+ .and_then(|s| s.parse::<FilesystemType>().ok())
+ .unwrap_or_default();
+
+ let lvm_options = 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()),
+ };
+
+ match fs_type {
+ FilesystemType::Ext4 => FilesystemOptions::Ext4(lvm_options),
+ FilesystemType::Xfs => FilesystemOptions::Xfs(lvm_options),
+ FilesystemType::Zfs(level) => 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) => 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')
+ .filter(|s| !s.is_empty())
+ .collect::<Vec<&str>>())
+ })
+ .unwrap_or(Value::Null)
+}
+
+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.human_name().to_owned())
+ .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"),
+ 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!("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("{{product.product}}{{installation-nr}}.example.com")
+ .value(config.fqdn.to_string())
+ .disabled(use_dhcp_fqdn)
+ .tip(tr!(
+ "Hostname and domain to set for the target installation. Allows templating."
+ ))
+ .required(!use_dhcp_fqdn),
+ )
+ .with_right_field("", DisplayField::new())
+ .with_right_field(
+ tr!("Pin network interfaces"),
+ Checkbox::new()
+ .name("netif-name-pinning-enabled")
+ .default(config.netif_name_pinning_enabled),
+ )
+ .with_advanced_spacer()
+ .with_large_advanced_field(
+ tr!("Network device filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .netdev_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name"))
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("netdev-filter")
+ .class(FlexFit)
+ .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 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()
+ .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.to_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_field(
+ tr!("Disk names"),
+ Field::new()
+ .name("disk-list")
+ .placeholder("sda, sdb")
+ .value(config.disk_list.join(", "))
+ .disabled(disk_mode != DiskSelectionMode::Fixed)
+ .required(disk_mode == DiskSelectionMode::Fixed),
+ )
+ .with_spacer()
+ .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"),
+ KeyValueList::new()
+ .value(
+ config
+ .disk_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("Property name"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder(tr!("udev property name"))
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("disk-filter")
+ .class(FlexFit)
+ .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 and only available for Proxmox Virtual Environment installations."
+ )),
+ _ => None,
+ };
+
+ if let Some(text) = warning {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-color-warning pwt-mt-2 pwt-d-block")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(text),
+ );
+ }
+
+ panel.add_spacer(true);
+
+ add_fs_advanced_form_fields(&mut panel, &config.filesystem);
+ panel.into()
+}
+
+fn add_fs_advanced_form_fields(panel: &mut InputPanel, fs_opts: &FilesystemOptions) {
+ match fs_opts {
+ FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(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)"),
+ Number::new()
+ .name("hdsize")
+ .min(4.)
+ .step(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)"),
+ Number::new()
+ .name("swapsize")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.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)"),
+ Number::new()
+ .name("maxroot")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.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)"),
+ Number::new()
+ .name("maxvz")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.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)"),
+ Number::new()
+ .name("minfree")
+ .min(0.)
+ .max(fs_opts.hdsize.map(|v| v / 2.))
+ .step(0.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)"),
+ Number::new()
+ .name("arc-max")
+ .min(64.)
+ .step(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 mut panel = InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4);
+
+ if !is_default && config.target_filter.is_empty() {
+ panel.add_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-color-warning pwt-mb-2 pwt-d-block")
+ .with_child(Fa::new("exclamation-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Not marked as default answer and target filter are empty, answer will never be matched."
+ ))
+ );
+ }
+
+ panel
+ .with_field(
+ tr!("Default answer"),
+ Checkbox::new()
+ .name("is-default")
+ .default(config.is_default),
+ )
+ .with_spacer()
+ .with_large_field(
+ tr!("Target filters"),
+ KeyValueList::new()
+ .value(
+ config
+ .target_filter
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ )
+ .key_label(tr!("JSON pointer"))
+ .value_label(tr!("Value to match"))
+ .key_placeholder("/json/pointer".into())
+ .value_placeholder(tr!("glob to match"))
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("target-filter")
+ .class(FlexFit)
+ .disabled(is_default),
+ )
+ .with_right_custom_child(Container::new().with_child(html! {
+ <span style="float: right;">
+ {tr!("references RFC 6901" => "Target filter keys are JSON pointers according to")}
+ {" "}
+ <a href="https://www.rfc-editor.org/rfc/rfc6901" target="_blank">{"RFC 6901"}</a>
+ {"."}
+ </span>
+ }))
+ .into()
+}
+
+pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mb-2 pwt-d-block")
+ .with_child(tr!(
+ "Numerical template counters can be used to provide unique values across installations."
+ )),
+ )
+ .with_large_custom_child(
+ KeyValueList::new()
+ .value(
+ config
+ .template_counters
+ .iter()
+ .map(|(k, v)| (k.clone(), *v))
+ .collect(),
+ )
+ .value_label(tr!("Current value"))
+ .value_input_type(InputType::Number)
+ .submit_validate(kv_list_to_map)
+ .submit_empty(false)
+ .name("template-counters")
+ .class(FlexFit),
+ )
+ .with_right_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mt-2 pwt-d-block")
+ .style("float", "right")
+ .with_child(tr!(
+ "Counters are automatically incremented each time an answer is served."
+ )),
+ )
+ .into()
+}
+
+pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html {
+ InputPanel::new()
+ .class(Flex::Fill)
+ .class(Overflow::Auto)
+ .padding(4)
+ .with_large_custom_child(
+ Container::from_tag("span")
+ .class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary")
+ .with_child(Fa::new("info-circle").class("fa-fw"))
+ .with_child(tr!(
+ "Optional. If provided, status reporting will be enabled."
+ )),
+ )
+ .with_large_field(
+ tr!("Proxmox Datacenter Manager 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()),
+ )
+ .with_large_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()
+}
+
+#[allow(clippy::ptr_arg)]
+fn kv_list_to_map<T: Clone + Serialize>(v: &Vec<(String, T)>) -> Result<Value> {
+ let map: BTreeMap<String, T> = v.iter().cloned().collect();
+ Ok(serde_json::to_value(map)?)
+}
+
+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
+ }
+ }
+}
+
+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/remotes/auto_installer/prepared_answers_panel.rs b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
new file mode 100644
index 0000000..975cab9
--- /dev/null
+++ b/ui/src/remotes/auto_installer/prepared_answers_panel.rs
@@ -0,0 +1,248 @@
+//! Implements the UI for the auto-installer answer editing panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+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, LoadableComponentScopeExt,
+ LoadableComponentState,
+};
+use pwt::{
+ props::{ContainerBuilder, EventSubscriber, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ Button, Fa, Toolbar,
+ },
+};
+
+use super::{
+ prepared_answer_add_wizard::AddAnswerWizardProperties,
+ prepared_answer_edit_window::EditAnswerWindowProperties,
+};
+use crate::pdm_client;
+
+#[derive(Default, PartialEq, Properties)]
+pub struct PreparedAnswersPanel {}
+
+impl From<PreparedAnswersPanel> for VNode {
+ fn from(value: PreparedAnswersPanel) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<PreparedAnswersPanelComponent>>(
+ Rc::new(value),
+ None,
+ );
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ Create,
+ Copy,
+ Edit,
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+}
+
+struct PreparedAnswersPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<PreparedInstallationConfig>,
+ columns: Rc<Vec<DataTableHeader<PreparedInstallationConfig>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ PreparedAnswersPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for PreparedAnswersPanelComponent {
+ type Properties = PreparedAnswersPanel;
+ 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 {
+ state: LoadableComponentState::new(),
+ 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 store = self.store.clone();
+ Box::pin(async move {
+ let data = pdm_client().get_autoinst_prepared_answers().await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ let link = ctx.link().clone();
+
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ if let Err(err) = pdm_client()
+ .delete_autoinst_prepared_answer(&percent_encode_component(
+ &key.to_string(),
+ ))
+ .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().clone();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Add"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Copy"))
+ .onclick(link.change_view_callback(|_| 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 {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick(move |_: &mut _| 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 => AddAnswerWizardProperties::new().on_done(on_done).into(),
+ Self::ViewState::Copy => {
+ let mut record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ record.id += " (copy)";
+ AddAnswerWizardProperties::with(record)
+ .on_done(on_done)
+ .into()
+ }
+ Self::ViewState::Edit => {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ EditAnswerWindowProperties::new(record)
+ .on_done(on_done)
+ .into()
+ }
+ })
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<PreparedInstallationConfig>> {
+ vec![
+ DataTableColumn::new(tr!("ID"))
+ .width("320px")
+ .render(|item: &PreparedInstallationConfig| html! { &item.id })
+ .sorter(
+ |a: &PreparedInstallationConfig, b: &PreparedInstallationConfig| a.id.cmp(&b.id),
+ )
+ .sort_order(Some(true))
+ .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| {
+ if item.target_filter.is_empty() {
+ "-".into()
+ } else {
+ item.target_filter
+ .iter()
+ .fold(String::new(), |acc, (k, v)| {
+ if acc.is_empty() {
+ format!("{k}={v}")
+ } else {
+ format!("{acc}, {k}={v}")
+ }
+ })
+ .into()
+ }
+ })
+ .into(),
+ ]
+}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token configuration panel
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (21 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 24/38] docs: add documentation for auto-installer integration Christoph Heiss
` (14 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
ui/src/remotes/auto_installer/mod.rs | 18 +-
.../prepared_answer_add_wizard.rs | 29 +-
.../prepared_answer_edit_window.rs | 34 +-
.../auto_installer/prepared_answer_form.rs | 22 +-
ui/src/remotes/auto_installer/token_panel.rs | 476 ++++++++++++++++++
.../remotes/auto_installer/token_selector.rs | 137 +++++
6 files changed, 701 insertions(+), 15 deletions(-)
create mode 100644 ui/src/remotes/auto_installer/token_panel.rs
create mode 100644 ui/src/remotes/auto_installer/token_selector.rs
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 1a85978..447c04f 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -5,6 +5,8 @@ mod prepared_answer_add_wizard;
mod prepared_answer_edit_window;
mod prepared_answer_form;
mod prepared_answers_panel;
+mod token_panel;
+mod token_selector;
use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -50,15 +52,22 @@ impl Component for AutoInstallerPanelComponent {
.with_child(tr!("Prepared Answers"))
.into();
+ let secrets_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("key"))
+ .with_child(tr!("Authentication tokens"))
+ .into();
+
Container::new()
.class("pwt-content-spacer")
.class(Fit)
.class(css::Display::Grid)
.style("grid-template-columns", "repeat(2, 1fr)")
- .style("grid-template-rows", "repeat(1, 1fr)")
+ .style("grid-template-rows", "repeat(2, 1fr)")
.with_child(
Panel::new()
- .style("grid-row", "span 2 / span 1")
+ .style("grid-row", "span 2 / span 2")
.title(installations_title)
.with_child(installations_panel::InstallationsPanel::default()),
)
@@ -67,6 +76,11 @@ impl Component for AutoInstallerPanelComponent {
.title(answers_title)
.with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
)
+ .with_child(
+ Panel::new()
+ .title(secrets_title)
+ .with_child(token_panel::AuthTokenPanel::default()),
+ )
.into()
}
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
index 5d15a43..dd3869e 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -7,15 +7,17 @@ use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
use wasm_bindgen::JsValue;
use yew::{
html::IntoEventCallback,
- virtual_dom::{VComp, VNode},
+ virtual_dom::{Key, VComp, VNode},
};
-use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use pdm_api_types::auto_installer::{
+ AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig,
+};
use proxmox_yew_comp::{
LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
Wizard, WizardPageRenderInfo,
};
-use pwt::{prelude::*, widget::TabBarItem};
+use pwt::{prelude::*, state::Store, widget::TabBarItem};
use pwt_macros::builder;
use super::prepared_answer_form::*;
@@ -90,6 +92,7 @@ impl From<AddAnswerWizardProperties> for VNode {
struct AddAnswerWizardComponent {
state: LoadableComponentState<()>,
+ token_store: Store<AnswerAuthToken>,
}
pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
@@ -100,8 +103,13 @@ impl LoadableComponent for AddAnswerWizardComponent {
type ViewState = ();
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let store =
+ Store::with_extract_key(|record: &AnswerAuthToken| Key::from(record.id.to_owned()));
+ store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id));
+
Self {
state: LoadableComponentState::new(),
+ token_store: store,
}
}
@@ -109,7 +117,17 @@ impl LoadableComponent for AddAnswerWizardComponent {
&self,
_ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
- Box::pin(async move { Ok(()) })
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_auth_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
@@ -142,7 +160,8 @@ impl LoadableComponent for AddAnswerWizardComponent {
})
.with_page(TabBarItem::new().label(tr!("Authentication")), {
let config = props.config.clone();
- move |_: &WizardPageRenderInfo| render_auth_form(&config)
+ let token_store = self.token_store.clone();
+ move |_: &WizardPageRenderInfo| render_auth_form(&config, token_store.clone())
})
.into()
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
index 3fb9766..71e81c4 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -4,12 +4,12 @@ use anyhow::Result;
use std::{future::Future, pin::Pin, rc::Rc};
use yew::{
html::IntoEventCallback,
- virtual_dom::{VComp, VNode},
+ virtual_dom::{Key, VComp, VNode},
};
use crate::pdm_client;
use pdm_api_types::auto_installer::{
- DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
+ AnswerAuthToken, DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
};
use proxmox_yew_comp::{
form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
@@ -18,6 +18,7 @@ use proxmox_yew_comp::{
use pwt::{
css::FlexFit,
prelude::*,
+ state::Store,
widget::{form::FormContext, TabBarItem, TabPanel},
};
use pwt_macros::builder;
@@ -52,6 +53,7 @@ impl From<EditAnswerWindowProperties> for VNode {
struct EditAnswerWindowComponent {
state: LoadableComponentState<()>,
+ token_store: Store<AnswerAuthToken>,
}
pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
@@ -62,8 +64,13 @@ impl LoadableComponent for EditAnswerWindowComponent {
type ViewState = ();
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let token_store =
+ Store::with_extract_key(|record: &AnswerAuthToken| Key::from(record.id.to_owned()));
+ token_store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id));
+
Self {
state: LoadableComponentState::new(),
+ token_store,
}
}
@@ -71,7 +78,17 @@ impl LoadableComponent for EditAnswerWindowComponent {
&self,
_ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
- Box::pin(async move { Ok(()) })
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_auth_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
@@ -83,7 +100,8 @@ impl LoadableComponent for EditAnswerWindowComponent {
.on_done(props.on_done.clone())
.renderer({
let props = props.clone();
- move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props)
+ let token_store = self.token_store.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props, token_store.clone())
})
.edit(true)
.submit_digest(true)
@@ -133,7 +151,11 @@ async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> {
Ok(())
}
-fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+fn render_tabpanel(
+ form_ctx: &FormContext,
+ props: &EditAnswerWindowProperties,
+ token_store: Store<AnswerAuthToken>,
+) -> yew::Html {
TabPanel::new()
.class(FlexFit)
.force_render_all(true)
@@ -159,7 +181,7 @@ fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -
)
.with_item(
TabBarItem::new().label(tr!("Authentication")),
- render_auth_form(&props.config),
+ render_auth_form(&props.config, token_store),
)
.into()
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
index 29bc768..f8ade20 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_form.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -7,7 +7,8 @@ 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,
+ AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig,
+ PREPARED_INSTALL_CONFIG_ID_SCHEMA,
};
use proxmox_installer_types::{
answer::{
@@ -23,12 +24,15 @@ use proxmox_yew_comp::SchemaValidation;
use pwt::{
css::{Flex, FlexFit, Overflow},
prelude::*,
+ state::Store,
widget::{
form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
Container, Fa, FieldPosition, InputPanel, KeyValueList,
},
};
+use crate::remotes::auto_installer::token_selector::TokenSelector;
+
pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
let obj = value
.as_object_mut()
@@ -773,11 +777,25 @@ pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html
.into()
}
-pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html {
+pub fn render_auth_form(
+ config: &PreparedInstallationConfig,
+ tokens: Store<AnswerAuthToken>,
+) -> yew::Html {
InputPanel::new()
.class(Flex::Fill)
.class(Overflow::Auto)
.padding(4)
+ .with_custom_child(
+ Container::from_tag("span")
+ .class("pwt-font-title-medium")
+ .with_child(tr!("Authorized tokens")),
+ )
+ .with_large_custom_child(
+ TokenSelector::new(tokens)
+ .selected_keys(config.authorized_tokens.clone())
+ .name("authorized-tokens"),
+ )
+ .with_spacer()
.with_large_custom_child(
Container::from_tag("span")
.class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary")
diff --git a/ui/src/remotes/auto_installer/token_panel.rs b/ui/src/remotes/auto_installer/token_panel.rs
new file mode 100644
index 0000000..18d920a
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_panel.rs
@@ -0,0 +1,476 @@
+//! Implements the UI for the auto-installer authentication authentication token panel.
+
+use anyhow::{bail, Result};
+use core::clone::Clone;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html,
+ virtual_dom::{Key, VComp, VNode},
+ Html, Properties,
+};
+
+use pdm_api_types::auto_installer::{AnswerAuthToken, AnswerAuthTokenUpdater};
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component,
+ utils::{copy_text_to_clipboard, render_epoch_short},
+ ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+ css::ColorScheme,
+ props::{
+ ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder,
+ WidgetBuilder,
+ },
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ form::{Checkbox, DisplayField, Field, FormContext, InputType},
+ Button, Column, Container, Dialog, Fa, FieldLabel, InputPanel, Row, Toolbar, Tooltip,
+ },
+};
+
+use crate::pdm_client;
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AuthTokenPanel {}
+
+impl From<AuthTokenPanel> for VNode {
+ fn from(value: AuthTokenPanel) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<AuthTokenPanelComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ Create,
+ Edit,
+ DisplaySecret(String, String),
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+ RegenerateSecret,
+}
+
+struct AuthTokenPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<AnswerAuthToken>,
+ columns: Rc<Vec<DataTableHeader<AnswerAuthToken>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ AuthTokenPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for AuthTokenPanelComponent {
+ type Properties = AuthTokenPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store =
+ Store::with_extract_key(|record: &AnswerAuthToken| Key::from(record.id.to_string()));
+ store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id));
+
+ Self {
+ state: LoadableComponentState::new(),
+ 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 store = self.store.clone();
+ Box::pin(async move {
+ let data = pdm_client().get_autoinst_auth_tokens().await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ let link = ctx.link().clone();
+
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ if let Err(err) = pdm_client()
+ .delete_autoinst_auth_token(&percent_encode_component(&key.to_string()))
+ .await
+ {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ Message::RegenerateSecret => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ match regenerate_token_secret(&key.to_string()).await {
+ Ok((token, secret)) => {
+ link.change_view(Some(ViewState::DisplaySecret(token.id, secret)))
+ }
+ Err(err) => {
+ link.show_error(tr!("Failed to regenerate secret"), err, true)
+ }
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link().clone();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Add"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+ )
+ .with_spacer()
+ .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)),
+ )
+ .with_spacer()
+ .with_child(
+ ConfirmButton::new(tr!("Regenerate Secret"))
+ .confirm_message(tr!(
+ "Do you want to regenerate the secret of the selected token? \
+ All existing ISOs with this token will lose access!"
+ ))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RegenerateSecret)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit)))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ match view_state {
+ Self::ViewState::Create => self.create_add_dialog(ctx),
+ Self::ViewState::Edit => self.create_edit_dialog(ctx),
+ Self::ViewState::DisplaySecret(token_id, secret) => {
+ self.show_secret_dialog(ctx, token_id.into(), secret.into())
+ }
+ }
+ }
+}
+
+impl AuthTokenPanelComponent {
+ fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let window = EditWindow::new(tr!("Add") + ": " + &tr!("Token"))
+ .renderer(add_input_panel)
+ .on_submit({
+ let link = ctx.link().clone();
+ move |form_ctx| {
+ let link = link.clone();
+ async move {
+ match create_token(form_ctx).await {
+ Ok((token, secret)) => {
+ link.change_view(Some(ViewState::DisplaySecret(token.id, secret)));
+ Ok(())
+ }
+ Err(err) => Err(err),
+ }
+ }
+ }
+ })
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(window)
+ }
+
+ fn create_edit_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ let window = EditWindow::new(tr!("Edit") + ": " + &tr!("Token"))
+ .renderer({
+ let record = record.clone();
+ move |_| edit_input_panel(&record)
+ })
+ .submit_text(tr!("Update"))
+ .on_submit({
+ let id = record.id.clone();
+ move |form_ctx| {
+ let id = id.clone();
+ async move { update_token(form_ctx, &id).await }
+ }
+ })
+ .on_done(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(window)
+ }
+
+ fn show_secret_dialog(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ token_id: String,
+ secret: String,
+ ) -> Option<yew::Html> {
+ let copy_secret_view = Container::new()
+ .class("pwt-form-grid-col4")
+ .with_child(FieldLabel::new(tr!("Secret")))
+ .with_child(
+ Row::new()
+ .class("pwt-fill-grid-row")
+ .gap(2)
+ .with_child(
+ Field::new()
+ .input_type(InputType::Password)
+ .class(pwt::css::FlexFit)
+ .value(secret.clone())
+ .read_only(true),
+ )
+ .with_child(
+ Tooltip::new(
+ Button::new_icon("fa fa-clipboard")
+ .class(ColorScheme::Primary)
+ .on_activate(move |_| copy_text_to_clipboard(&secret)),
+ )
+ .tip(tr!("Copy token secret to clipboard.")),
+ ),
+ );
+
+ let dialog = Dialog::new(tr!("Token Secret"))
+ .with_child(
+ Column::new().with_child(
+ InputPanel::new()
+ .padding(4)
+ .with_large_field(
+ tr!("Token ID"),
+ DisplayField::new().value(token_id).border(true),
+ )
+ .with_large_custom_child(copy_secret_view),
+ ),
+ )
+ .with_child(
+ Container::new()
+ .padding(4)
+ .class(pwt::css::FlexFit)
+ .class(ColorScheme::WarningContainer)
+ .class("pwt-default-colors")
+ .with_child(tr!(
+ "Please record the token secret - it will only be displayed once."
+ )),
+ )
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(dialog)
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<AnswerAuthToken>> {
+ vec![
+ DataTableColumn::new(tr!("Name"))
+ .width("200px")
+ .render(|item: &AnswerAuthToken| html! { &item.id })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Created by"))
+ .width("150px")
+ .render(|item: &AnswerAuthToken| html! { &item.created_by })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.created_by.cmp(&b.created_by))
+ .into(),
+ DataTableColumn::new(tr!("Enabled"))
+ .width("80px")
+ .render(|item: &AnswerAuthToken| {
+ if item.enabled.unwrap_or(false) {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.enabled.cmp(&b.enabled))
+ .into(),
+ DataTableColumn::new(tr!("Expire"))
+ .width("200px")
+ .render({
+ move |item: &AnswerAuthToken| {
+ html! {
+ match item.expire_at {
+ Some(epoch) if epoch != 0 => render_epoch_short(epoch),
+ _ => tr!("never"),
+ }
+ }
+ }
+ })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| {
+ let a = a
+ .expire_at
+ .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+ let b = b
+ .expire_at
+ .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+
+ a.cmp(&b)
+ })
+ .into(),
+ DataTableColumn::new("Comment")
+ .flex(1)
+ .render(|item: &AnswerAuthToken| html! { item.comment.clone().unwrap_or_default() })
+ .into(),
+ ]
+}
+
+fn edit_input_panel(token: &AnswerAuthToken) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_right_field(
+ tr!("Expire"),
+ Field::new()
+ .name("expire-at")
+ .value(
+ token
+ .expire_at
+ .and_then(|exp| proxmox_time::epoch_to_rfc3339(exp).ok()),
+ )
+ .placeholder(tr!("never"))
+ .input_type(InputType::DatetimeLocal),
+ )
+ .with_field(
+ tr!("Token Name"),
+ Field::new()
+ .name("id")
+ .value(token.id.clone())
+ .submit(false)
+ .disabled(true)
+ .required(true),
+ )
+ .with_right_field(
+ tr!("Enabled"),
+ Checkbox::new().name("enabled").checked(token.enabled),
+ )
+ .with_large_field(
+ tr!("Comment"),
+ Field::new()
+ .name("comment")
+ .value(token.comment.clone())
+ .submit_empty(true),
+ )
+ .into()
+}
+
+fn add_input_panel(_form_ctx: &FormContext) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_field(
+ tr!("Token Name"),
+ Field::new().name("id").submit(false).required(true),
+ )
+ .with_right_field(
+ tr!("Expire"),
+ Field::new()
+ .name("expire-at")
+ .placeholder(tr!("never"))
+ .input_type(InputType::DatetimeLocal),
+ )
+ .with_right_field(
+ tr!("Enabled"),
+ Checkbox::new().name("enabled").default(true),
+ )
+ .with_large_field(tr!("Comment"), Field::new().name("comment"))
+ .into()
+}
+
+async fn create_token(form_ctx: FormContext) -> Result<(AnswerAuthToken, String)> {
+ let id = form_ctx.read().get_field_text("id");
+ let comment = form_ctx.read().get_field_text("comment");
+ let enable = form_ctx.read().get_field_checked("enabled");
+ let expire =
+ proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0);
+
+ let result = pdm_client()
+ .add_autoinst_auth_token(
+ &percent_encode_component(&id),
+ Some(comment),
+ Some(enable),
+ Some(expire),
+ )
+ .await?;
+ Ok(result)
+}
+
+async fn update_token(form_ctx: FormContext, id: &str) -> Result<()> {
+ let updater = AnswerAuthTokenUpdater {
+ comment: Some(form_ctx.read().get_field_text("comment")),
+ enabled: Some(form_ctx.read().get_field_checked("enabled")),
+ expire_at: Some(
+ proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0),
+ ),
+ };
+
+ pdm_client()
+ .update_autoinst_auth_token(&percent_encode_component(id), &updater, &[], false)
+ .await?;
+ Ok(())
+}
+
+async fn regenerate_token_secret(id: &str) -> Result<(AnswerAuthToken, String)> {
+ let result = pdm_client()
+ .update_autoinst_auth_token(
+ &percent_encode_component(id),
+ &AnswerAuthTokenUpdater::default(),
+ &[],
+ true,
+ )
+ .await?;
+
+ match result {
+ (token, Some(secret)) => Ok((token, secret)),
+ _ => bail!(tr!("No new secret received")),
+ }
+}
diff --git a/ui/src/remotes/auto_installer/token_selector.rs b/ui/src/remotes/auto_installer/token_selector.rs
new file mode 100644
index 0000000..5b0eaad
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_selector.rs
@@ -0,0 +1,137 @@
+//! A [`GridPicker`]-based selector for access tokens for the automated installer.
+
+use pdm_api_types::auto_installer::AnswerAuthToken;
+use serde_json::Value;
+use std::{collections::HashSet, rc::Rc};
+use yew::{html, virtual_dom::Key, Properties};
+
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ state::{Selection, Store},
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode},
+ form::{
+ ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt,
+ ManagedFieldState,
+ },
+ GridPicker,
+ },
+};
+use pwt_macros::{builder, widget};
+
+#[widget(comp = ManagedFieldMaster<TokenSelectorField>, @input)]
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct TokenSelector {
+ /// All available tokens to select.
+ store: Store<AnswerAuthToken>,
+
+ #[builder]
+ #[prop_or_default]
+ /// Keys of entries to pre-select.
+ pub selected_keys: Vec<String>,
+}
+
+impl TokenSelector {
+ pub fn new(store: Store<AnswerAuthToken>) -> Self {
+ yew::props!(Self { store })
+ }
+}
+
+pub struct TokenSelectorField {
+ state: ManagedFieldState,
+ store: Store<AnswerAuthToken>,
+ selection: Selection,
+ columns: Rc<Vec<DataTableHeader<AnswerAuthToken>>>,
+}
+
+pwt::impl_deref_mut_property!(TokenSelectorField, state, ManagedFieldState);
+
+pub enum Message {
+ UpdateSelection,
+}
+
+impl TokenSelectorField {
+ fn columns() -> Rc<Vec<DataTableHeader<AnswerAuthToken>>> {
+ Rc::new(vec![
+ DataTableColumn::selection_indicator().into(),
+ DataTableColumn::new(tr!("Token"))
+ .flex(1)
+ .render(|item: &AnswerAuthToken| html! { &item.id })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Comment"))
+ .flex(1)
+ .render(|item: &AnswerAuthToken| html! { item.comment.as_deref().unwrap_or("") })
+ .into(),
+ ])
+ }
+}
+
+impl ManagedField for TokenSelectorField {
+ type Message = Message;
+ type Properties = TokenSelector;
+ type ValidateClosure = ();
+
+ fn create(ctx: &ManagedFieldContext<Self>) -> Self {
+ let selection = Selection::new()
+ .multiselect(true)
+ .on_select(ctx.link().callback(|_| Message::UpdateSelection));
+
+ let store = ctx.props().store.clone().on_change(ctx.link().callback({
+ let selection = selection.clone();
+ let selected = ctx
+ .props()
+ .selected_keys
+ .iter()
+ .map(|s| Key::from(s.clone()))
+ .collect::<HashSet<Key>>();
+
+ move |_| {
+ selection.bulk_select(selected.clone());
+ Message::UpdateSelection
+ }
+ }));
+
+ Self {
+ state: ManagedFieldState::new(Value::Array(Vec::new()), Value::Array(Vec::new())),
+ store,
+ selection,
+ columns: Self::columns(),
+ }
+ }
+
+ fn validation_args(_props: &Self::Properties) -> Self::ValidateClosure {}
+
+ fn validator(_props: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
+ Ok(value.clone())
+ }
+
+ fn update(&mut self, ctx: &ManagedFieldContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::UpdateSelection => {
+ ctx.link().update_value(
+ self.selection
+ .selected_keys()
+ .iter()
+ .map(|k| k.to_string())
+ .collect::<Vec<_>>(),
+ );
+ true
+ }
+ }
+ }
+
+ fn view(&self, _ctx: &ManagedFieldContext<Self>) -> Html {
+ GridPicker::new(
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .multiselect_mode(MultiSelectMode::Simple)
+ .border(true)
+ .class(FlexFit),
+ )
+ .selection(self.selection.clone())
+ .into()
+ }
+}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH datacenter-manager v3 24/38] docs: add documentation for auto-installer integration
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (22 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token " Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config Christoph Heiss
` (13 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* use concrete URL in example
* add section about templating and token authentication
Changes v1 -> v2:
* new patch
docs/automated-installations.rst | 124 +++++++++++++++++++++++++++++++
docs/index.rst | 1 +
2 files changed, 125 insertions(+)
create mode 100644 docs/automated-installations.rst
diff --git a/docs/automated-installations.rst b/docs/automated-installations.rst
new file mode 100644
index 0000000..7c0f828
--- /dev/null
+++ b/docs/automated-installations.rst
@@ -0,0 +1,124 @@
+.. _automated_installations:
+
+Automated Installations
+=======================
+
+The Proxmox Datacenter Manager provides integration with the automated
+installer for all Proxmox products.
+
+A detailed documentation of all available options can be found on `our dedicated
+wiki page <https://pve.proxmox.com/wiki/Automated_Installation>`_.
+
+.. _autoinst_overview:
+
+Overview
+~~~~~~~~
+
+The overview shows all past and ongoing installations done using the Proxmox
+Datacenter Manager. It allows access to the raw system information data as sent
+by the automated installer before the actual installation, and (if configured)
+post-installation notification hook data, containing extensive information about
+the newly installed system.
+
+.. _autoinst_answers:
+
+Prepared Answers
+~~~~~~~~~~~~~~~~
+
+This view provides an overview over all defined answer files and allows editing,
+copying into new answers and deleting them. For a quick overview, it shows
+whether an answer is the default and what target filters have been defined for
+that particular configuration.
+
+Target filter
+^^^^^^^^^^^^^
+
+Target filter allow you to control what systems should match.
+
+`Filters`_ are key-value pairs in the format ``key=format``, with keys being
+`JSON Pointers`_, and match systems based the identifying information sent by
+the installer as JSON document. An example of such a document is provided `on
+the wiki
+<https://pve.proxmox.com/wiki/Automated_Installation#System_information_POST_data>`_.
+
+JSON Pointers allow for identifying specific values within a JSON document. For
+example, to match only Proxmox VE installations by the product name, a filter
+entry like ``/product/product=pve`` can be used.
+
+Values are *globs* and use the same syntax as the automated installer itself.
+The following special characters can be used in filters:
+
+* ``?`` -- matches any single character
+* ``*`` -- matches any number of characters, can be none
+* ``[a]``, ``[abc]``, ``[0-9]`` -- matches any single character inside the
+ brackets, ranges are possible
+
+* ``[!a]`` -- negate the filter, any single character but the ones specified
+
+A prepared answer can be also set as default, in which case it will be used if
+no other more specific answer matches based on its configured target filters.
+
+.. _autoinst_preparing_iso:
+
+Templating
+^^^^^^^^^^
+
+Certain fields support templating via `Handlebars`_ and definable *counters*.
+Counters are automatically incremented each time an answer file is served to a
+client, allowing for easy provisioning of unique fields, such as per-system
+hostnames.
+
+The following counter is automatically defined when creating a new prepared
+answer configuration:
+
+* ``installation-nr`` -- The current counter value of the number of
+ installations done with this particular answer configuration.
+
+This mechanism allows templating on the following fields for prepared answer
+configurations:
+
+* **Administrator email address**
+* **Hostname/FQDN**
+* **Network IP address (CIDR)**
+* **Network gateway**
+* **DNS Server address**
+
+Authentication token management
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To use the automated installer integration of Proxmox Datacenter Manager, an
+installation process must authenticate itself. This also provides for an
+additional scoping mechanism for prepared answer configurations.
+
+The automated installer integration uses a dedicated token mechanism, separate
+from the normal API tokens.
+
+Preparing an ISO
+~~~~~~~~~~~~~~~~
+
+To use an installation ISO of a Proxmox product with the Proxmox Datacenter
+Manager functionality, the ISO must be appropriately prepared to `fetch an
+answer via HTTP`_ from the Proxmox Datacenter Manager using the
+``proxmox-auto-install-assistant`` tool, available from the Proxmox VE package
+repositories.
+
+The `target URL`_ for the automated installer must point to
+``https://<pdm>/api2/json/auto-install/answer``, where ``<pdm>`` is the address
+under which the Proxmox Datacenter Manager is reachable from the systems to be
+installed.
+
+For example:
+
+.. code-block:: shell
+
+ proxmox-auto-install-assistant prepare-iso /path/to/source.iso \
+ --fetch-from http \
+ --url 'https://datacenter.example.com/api2/json/auto-install/answer' \
+ --cert-fingerprint 'ab:cd:ef:12:34:56:78:90:a1:b2:c3:d4:e5:f6:7a:8b:9c:0d:aa:bb:cc:dd:ee:ff:21:43:65:87:09:af:bd:ce' \
+ --answer-auth-token 'mytoken!ee2a5901-1910-4eb0-b0a2-c914f4adbb75'
+
+.. _JSON Pointers: https://www.rfc-editor.org/rfc/rfc6901
+.. _fetch an answer via HTTP: https://pve.proxmox.com/edwiki/Automated_Installation#Answer_Fetched_via_HTTP
+.. _Filters: https://pve.proxmox.com/wiki/Automated_Installation#Filters
+.. _target URL: https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP
+.. _Handlebars: https://handlebarsjs.com/guide/
diff --git a/docs/index.rst b/docs/index.rst
index 8398f57..2fc8a5d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -25,6 +25,7 @@ in the section entitled "GNU Free Documentation License".
web-ui.rst
sdn-integration.rst
remotes.rst
+ automated-installations.rst
views.rst
access-control.rst
sysadmin.rst
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (23 preceding siblings ...)
2026-04-03 16:53 ` [PATCH datacenter-manager v3 24/38] docs: add documentation for auto-installer integration Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 26/38] common: http: allow passing custom headers to post() Christoph Heiss
` (12 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
For Perl, they act the same as the previous `0`/`1` literals, but
create proper `true`/`false` literals when serializing to JSON.
This makes the final JSON more consistent and easier to parse.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
Proxmox/Install/ISOEnv.pm | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/Proxmox/Install/ISOEnv.pm b/Proxmox/Install/ISOEnv.pm
index a76077b..d2853fe 100644
--- a/Proxmox/Install/ISOEnv.pm
+++ b/Proxmox/Install/ISOEnv.pm
@@ -16,26 +16,26 @@ my $product_cfg = {
pve => {
fullname => 'Proxmox VE',
port => '8006',
- enable_btrfs => 1,
- bridged_network => 1,
+ enable_btrfs => JSON::true,
+ bridged_network => JSON::true,
},
pmg => {
fullname => 'Proxmox Mail Gateway',
port => '8006',
- enable_btrfs => 0,
- bridged_network => 0,
+ enable_btrfs => JSON::false,
+ bridged_network => JSON::false,
},
pbs => {
fullname => 'Proxmox Backup Server',
port => '8007',
- enable_btrfs => 0,
- bridged_network => 0,
+ enable_btrfs => JSON::false,
+ bridged_network => JSON::false,
},
pdm => {
fullname => 'Proxmox Datacenter Manager',
port => '8443', # TODO: confirm
- enable_btrfs => 0,
- bridged_network => 0,
+ enable_btrfs => JSON::false,
+ bridged_network => JSON::false,
},
};
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 26/38] common: http: allow passing custom headers to post()
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (24 preceding siblings ...)
2026-04-03 16:53 ` [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 27/38] common: options: move regex construction out of loop Christoph Heiss
` (11 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
Add an additional parameter to allow passing in additional headers.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
.../src/fetch_plugins/http.rs | 12 ++++--
proxmox-installer-common/src/http.rs | 40 ++++++++++++++++---
proxmox-post-hook/src/main.rs | 22 ++++------
3 files changed, 51 insertions(+), 23 deletions(-)
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index e2fd633..b958a35 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -7,6 +7,7 @@ use std::{
};
use proxmox_auto_installer::{sysinfo::SysInfo, utils::HttpOptions};
+use proxmox_installer_common::http::{self, header::HeaderMap};
static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
@@ -130,9 +131,14 @@ impl FetchFromHTTP {
let payload = HttpFetchPayload::as_json()?;
info!("Sending POST request to '{answer_url}'.");
- let answer =
- proxmox_installer_common::http::post(&answer_url, fingerprint.as_deref(), payload)?;
- Ok(answer)
+
+ Ok(http::post(
+ &answer_url,
+ fingerprint.as_deref(),
+ HeaderMap::new(),
+ payload,
+ )?
+ .0)
}
/// Fetches search domain from resolv.conf file
diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs
index 7662673..f04552a 100644
--- a/proxmox-installer-common/src/http.rs
+++ b/proxmox-installer-common/src/http.rs
@@ -13,6 +13,9 @@ use ureq::unversioned::transport::{
Transport, TransportAdapter,
};
+// Re-export for conviencence when using post()
+pub use ureq::http::header;
+
/// Builds an [`Agent`] with TLS suitable set up, depending whether a custom fingerprint was
/// supplied or not. If a fingerprint was supplied, only matching certificates will be accepted.
/// Otherwise, the system certificate store is loaded.
@@ -95,18 +98,43 @@ pub fn get_as_bytes(url: &str, fingerprint: Option<&str>, max_size: usize) -> Re
/// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin
/// ```
///
+/// The `Content-Type` header is automatically set to `application/json`.
+///
/// # Arguments
/// * `url` - URL to call
/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `headers` - Additional headers to add to the request.
/// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
-pub fn post(url: &str, fingerprint: Option<&str>, payload: String) -> Result<String> {
+///
+/// # Returns
+///
+/// A tuple containing
+/// * The body contents, as returned by the server
+/// * The content type of the response, if set in the response headers
+pub fn post(
+ url: &str,
+ fingerprint: Option<&str>,
+ headers: header::HeaderMap,
+ payload: String,
+) -> Result<(String, Option<String>)> {
// TODO: read_to_string limits the size to 10 MB, should be increase that?
- Ok(build_agent(fingerprint)?
+
+ let mut request = build_agent(fingerprint)?
.post(url)
- .header("Content-Type", "application/json; charset=utf-8")
- .send(&payload)?
- .body_mut()
- .read_to_string()?)
+ .header("Content-Type", "application/json; charset=utf-8");
+
+ for (name, value) in headers.iter() {
+ request = request.header(name, value);
+ }
+
+ let mut response = request.send(&payload)?;
+ let content_type = response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .and_then(|h| h.to_str().ok())
+ .map(|s| s.to_owned());
+
+ Ok((response.body_mut().read_to_string()?, content_type))
}
#[derive(Debug)]
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index a792b6d..2ee0231 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -9,6 +9,8 @@
//! Relies on `proxmox-chroot` as an external dependency to (bind-)mount the
//! previously installed system.
+use anyhow::{Context, Result, anyhow, bail};
+use serde::Serialize;
use std::{
collections::HashSet,
ffi::CStr,
@@ -19,7 +21,6 @@ use std::{
process::{Command, ExitCode},
};
-use anyhow::{Context, Result, anyhow, bail};
use proxmox_auto_installer::{
answer::{
Answer, FqdnConfig, FqdnExtendedConfig, FqdnSourceMode, PostNotificationHookInfo,
@@ -27,6 +28,7 @@ use proxmox_auto_installer::{
},
udevinfo::{UdevInfo, UdevProperties},
};
+use proxmox_installer_common::http::{self, header::HeaderMap};
use proxmox_installer_common::{
options::{Disk, FsType, NetworkOptions},
setup::{
@@ -36,7 +38,6 @@ use proxmox_installer_common::{
sysinfo::SystemDMI,
utils::CidrAddress,
};
-use serde::Serialize;
/// Information about the system boot status.
#[derive(Serialize)]
@@ -536,11 +537,7 @@ impl PostHookInfo {
.map(|v| {
// /proc/version: "Linux version 6.17.2-1-pve (...) #1 SMP ..."
// extract everything after the second space
- v.splitn(3, ' ')
- .nth(2)
- .unwrap_or("")
- .trim()
- .to_owned()
+ v.splitn(3, ' ').nth(2).unwrap_or("").trim().to_owned()
})
.unwrap_or_default();
@@ -640,16 +637,12 @@ impl PostHookInfo {
sockets.insert(value);
}
// x86: "flags", ARM64: "Features"
- Some((key, value))
- if key.trim() == "flags"
- || key.trim() == "Features" =>
- {
+ Some((key, value)) if key.trim() == "flags" || key.trim() == "Features" => {
value.trim().clone_into(&mut result.flags);
}
// x86: "model name", ARM64: "CPU implementer"
Some((key, value))
- if key.trim() == "model name"
- || key.trim() == "CPU implementer" =>
+ if key.trim() == "model name" || key.trim() == "CPU implementer" =>
{
if result.model.is_empty() {
value.trim().clone_into(&mut result.model);
@@ -727,9 +720,10 @@ fn do_main() -> Result<()> {
);
}
- proxmox_installer_common::http::post(
+ http::post(
url,
cert_fingerprint.as_deref(),
+ HeaderMap::new(),
serde_json::to_string(&info)?,
)?;
} else {
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 27/38] common: options: move regex construction out of loop
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (25 preceding siblings ...)
2026-04-03 16:53 ` [PATCH installer v3 26/38] common: http: allow passing custom headers to post() Christoph Heiss
@ 2026-04-03 16:53 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 28/38] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
` (10 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
.. and make clippy happy.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-installer-common/src/options.rs | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index fbc0207..dcf4fe7 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -502,6 +502,15 @@ impl NetworkInterfacePinningOptions {
/// - only contains ASCII alphanumeric characters and underscore, as
/// enforced by our `pve-iface` json schema.
pub fn verify(&self) -> Result<()> {
+ // Mimicking the `pve-iface` schema verification
+ static RE: OnceLock<Regex> = OnceLock::new();
+ let re = RE.get_or_init(|| {
+ RegexBuilder::new(r"^[a-z][a-z0-9_]{1,20}([:\.]\d+)?$")
+ .case_insensitive(true)
+ .build()
+ .unwrap()
+ });
+
let mut reverse_mapping = HashMap::<String, String>::new();
for (mac, name) in self.mapping.iter() {
if name.len() < MIN_IFNAME_LEN {
@@ -517,15 +526,6 @@ impl NetworkInterfacePinningOptions {
);
}
- // Mimicking the `pve-iface` schema verification
- static RE: OnceLock<Regex> = OnceLock::new();
- let re = RE.get_or_init(|| {
- RegexBuilder::new(r"^[a-z][a-z0-9_]{1,20}([:\.]\d+)?$")
- .case_insensitive(true)
- .build()
- .unwrap()
- });
-
if !re.is_match(name) {
bail!(
"interface name '{name}' for '{mac}' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 28/38] assistant: support adding an authorization token for HTTP-based answers
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (26 preceding siblings ...)
2026-04-03 16:53 ` [PATCH installer v3 27/38] common: options: move regex construction out of loop Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
` (9 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
If '--answer-auth-token <token-name>:<token-secret>' is passed, the
token will be saved to the internal auto-installer HTTP settings.
The `HttpOptions` is not marked with `deny_unknown_fields`, so adding an
additional field is also backwards-compatible.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-auto-install-assistant/src/main.rs | 18 ++++++++++++++++++
proxmox-auto-installer/src/utils.rs | 2 ++
proxmox-fetch-answer/src/main.rs | 1 +
3 files changed, 21 insertions(+)
diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
index 22a8e39..901ab81 100644
--- a/proxmox-auto-install-assistant/src/main.rs
+++ b/proxmox-auto-install-assistant/src/main.rs
@@ -271,6 +271,13 @@ struct CommandPrepareISOArgs {
///
/// Implies '--pxe'.
pxe_loader: Option<PxeLoader>,
+
+ /// Only useful in combination with '--fetch-from http'. Token the automated installer should
+ /// use for retrieving an answer file.
+ ///
+ /// If set, the automated installer will include an 'Authorization' header in the HTTP POST
+ /// for retrieving the answer, in the format 'Authorization: ProxmoxInstallerToken <TOKEN>'.
+ answer_auth_token: Option<String>,
}
impl cli::Subcommand for CommandPrepareISOArgs {
@@ -290,6 +297,7 @@ impl cli::Subcommand for CommandPrepareISOArgs {
on_first_boot: args.opt_value_from_str("--on-first-boot")?,
pxe: args.contains("--pxe") || pxe_loader.is_some(),
pxe_loader,
+ answer_auth_token: args.opt_value_from_str("--answer-auth-token")?,
// Needs to be last
input: args.free_from_str()?,
})
@@ -382,6 +390,15 @@ OPTIONS:
Implies '--pxe'.
+ --answer-auth-token <TOKEN>
+ Only useful in combination with '--fetch-from http'. Token the automated installer should
+ use for retrieving an answer file.
+
+ <TOKEN> must be of format '<name>:<secret>'.
+
+ If set, the automated installer will include an 'Authorization' header in the HTTP POST
+ for retrieving the answer, in the format 'Authorization: ProxmoxInstallerToken <TOKEN>'.
+
-h, --help Print this help
-V, --version Print version
"#,
@@ -744,6 +761,7 @@ fn prepare_iso(args: &CommandPrepareISOArgs) -> Result<()> {
http: HttpOptions {
url: args.url.clone(),
cert_fingerprint: args.cert_fingerprint.clone(),
+ token: args.answer_auth_token.clone(),
},
};
let mut instmode_file_tmp = tmp_base.clone();
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 884a08e..09b3408 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -131,6 +131,8 @@ pub struct HttpOptions {
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub token: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
diff --git a/proxmox-fetch-answer/src/main.rs b/proxmox-fetch-answer/src/main.rs
index c599bef..18b27e7 100644
--- a/proxmox-fetch-answer/src/main.rs
+++ b/proxmox-fetch-answer/src/main.rs
@@ -98,6 +98,7 @@ fn settings_from_cli_args(args: &[String]) -> Result<AutoInstSettings> {
http: HttpOptions {
url: args.get(2).cloned(),
cert_fingerprint: args.get(3).cloned(),
+ token: None,
},
})
}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (27 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 28/38] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 30/38] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
` (8 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
Now that the `Fqdn` has been moved to the proxmox-network-types crate,
use it from there.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
Cargo.toml | 6 +
proxmox-auto-installer/Cargo.toml | 1 +
proxmox-auto-installer/src/answer.rs | 4 +-
proxmox-auto-installer/src/utils.rs | 6 +-
proxmox-installer-common/Cargo.toml | 1 +
proxmox-installer-common/src/options.rs | 3 +-
proxmox-installer-common/src/utils.rs | 241 ---------------------
proxmox-tui-installer/Cargo.toml | 1 +
proxmox-tui-installer/src/setup.rs | 5 +-
proxmox-tui-installer/src/views/network.rs | 6 +-
10 files changed, 20 insertions(+), 254 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 3075bcc..379ee6b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,3 +27,9 @@ serde_plain = "1.0"
toml = "0.8"
proxmox-auto-installer.path = "./proxmox-auto-installer"
proxmox-installer-common.path = "./proxmox-installer-common"
+proxmox-network-types = "1.0"
+
+# Local path overrides
+# NOTE: You must run `cargo update` after changing this for it to take effect!
+[patch.crates-io]
+# proxmox-network-types.path = "../proxmox/proxmox-network-types"
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
index 8a5283e..0086e5d 100644
--- a/proxmox-auto-installer/Cargo.toml
+++ b/proxmox-auto-installer/Cargo.toml
@@ -14,6 +14,7 @@ homepage = "https://www.proxmox.com"
anyhow.workspace = true
log.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
+proxmox-network-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_plain.workspace = true
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index d12e088..40e6557 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -4,8 +4,10 @@ use proxmox_installer_common::{
BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions,
ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
},
- utils::{CidrAddress, Fqdn},
+ utils::CidrAddress,
};
+use proxmox_network_types::fqdn::Fqdn;
+
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashMap},
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 09b3408..9998491 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -535,11 +535,7 @@ pub fn parse_answer(
.map(|o| o.mapping)
.unwrap_or_default(),
- hostname: network_settings
- .fqdn
- .host()
- .unwrap_or(setup_info.config.product.default_hostname())
- .to_string(),
+ hostname: network_settings.fqdn.host().to_owned(),
domain: network_settings.fqdn.domain(),
cidr: network_settings.address,
gateway: network_settings.gateway,
diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml
index b3ce3d7..7469627 100644
--- a/proxmox-installer-common/Cargo.toml
+++ b/proxmox-installer-common/Cargo.toml
@@ -13,6 +13,7 @@ regex.workspace = true
serde = { workspace = true, features = [ "derive" ] }
serde_json.workspace = true
serde_plain.workspace = true
+proxmox-network-types.workspace = true
# `http` feature
hex = { version = "0.4", optional = true }
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index dcf4fe7..feb0dc4 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -10,7 +10,8 @@ use std::{cmp, fmt};
use crate::disk_checks::check_raid_min_disks;
use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN};
use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
-use crate::utils::{CidrAddress, Fqdn};
+use crate::utils::CidrAddress;
+use proxmox_network_types::fqdn::Fqdn;
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))]
diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs
index ffc862e..e86abdf 100644
--- a/proxmox-installer-common/src/utils.rs
+++ b/proxmox-installer-common/src/utils.rs
@@ -139,244 +139,3 @@ fn check_mask_limit(addr: &IpAddr, mask: usize) -> Result<(), CidrAddressParseEr
Ok(())
}
}
-
-/// 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("."))
- }
-}
-
-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-tui-installer/Cargo.toml b/proxmox-tui-installer/Cargo.toml
index cc2baeb..1ca91cb 100644
--- a/proxmox-tui-installer/Cargo.toml
+++ b/proxmox-tui-installer/Cargo.toml
@@ -9,6 +9,7 @@ homepage = "https://www.proxmox.com"
[dependencies]
proxmox-installer-common.workspace = true
+proxmox-network-types.workspace = true
anyhow.workspace = true
serde_json.workspace = true
diff --git a/proxmox-tui-installer/src/setup.rs b/proxmox-tui-installer/src/setup.rs
index 3ab1869..98dbcac 100644
--- a/proxmox-tui-installer/src/setup.rs
+++ b/proxmox-tui-installer/src/setup.rs
@@ -36,10 +36,7 @@ impl From<InstallerOptions> for InstallConfig {
mngmt_nic: options.network.ifname,
network_interface_pin_map: pinning_opts.map(|o| o.mapping.clone()).unwrap_or_default(),
- // Safety: At this point, it is know that we have a valid FQDN, as
- // this is set by the TUI network panel, which only lets the user
- // continue if a valid FQDN is provided.
- hostname: options.network.fqdn.host().expect("valid FQDN").to_owned(),
+ hostname: options.network.fqdn.host().to_owned(),
domain: options.network.fqdn.domain(),
cidr: options.network.address,
gateway: options.network.gateway,
diff --git a/proxmox-tui-installer/src/views/network.rs b/proxmox-tui-installer/src/views/network.rs
index 970c353..53e0d65 100644
--- a/proxmox-tui-installer/src/views/network.rs
+++ b/proxmox-tui-installer/src/views/network.rs
@@ -12,13 +12,15 @@ use std::{
sync::{Arc, Mutex},
};
-use super::{CidrAddressEditView, FormView};
use proxmox_installer_common::{
net::MAX_IFNAME_LEN,
options::{NetworkInterfacePinningOptions, NetworkOptions},
setup::{Interface, NetworkInfo},
- utils::{CidrAddress, Fqdn},
+ utils::CidrAddress,
};
+use proxmox_network_types::fqdn::Fqdn;
+
+use super::{CidrAddressEditView, FormView};
struct NetworkViewOptions {
selected_mac: String,
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 30/38] tree-wide: use `Cidr` type from proxmox-network-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (28 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
` (7 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
.. instead of our own variant here, as we now already depend on
proxmox-network-types anyway.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-auto-installer/src/answer.rs | 19 +--
proxmox-auto-installer/src/utils.rs | 2 +-
.../ipv4_and_subnet_mask_33.json | 2 +-
proxmox-installer-common/src/lib.rs | 1 -
proxmox-installer-common/src/options.rs | 34 ++---
proxmox-installer-common/src/setup.rs | 21 ++-
proxmox-installer-common/src/utils.rs | 141 ------------------
proxmox-post-hook/Cargo.toml | 1 +
proxmox-post-hook/src/main.rs | 7 +-
proxmox-tui-installer/src/views/mod.rs | 21 ++-
proxmox-tui-installer/src/views/network.rs | 13 +-
11 files changed, 53 insertions(+), 209 deletions(-)
delete mode 100644 proxmox-installer-common/src/utils.rs
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index 40e6557..acb0d5b 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -1,13 +1,4 @@
use anyhow::{Result, bail, format_err};
-use proxmox_installer_common::{
- options::{
- BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions,
- ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
- },
- utils::CidrAddress,
-};
-use proxmox_network_types::fqdn::Fqdn;
-
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashMap},
@@ -15,6 +6,12 @@ use std::{
net::IpAddr,
};
+use proxmox_installer_common::options::{
+ BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions, ZfsChecksumOption,
+ ZfsCompressOption, ZfsRaidLevel,
+};
+use proxmox_network_types::{Cidr, fqdn::Fqdn};
+
// NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards
// compatibility. TODO Remove the snake_cased variants in a future major version (e.g. PVE 10).
@@ -201,7 +198,7 @@ pub struct NetworkInterfacePinningOptionsAnswer {
struct NetworkInAnswer {
#[serde(default)]
pub source: NetworkConfigMode,
- pub cidr: Option<CidrAddress>,
+ pub cidr: Option<Cidr>,
pub dns: Option<IpAddr>,
pub gateway: Option<IpAddr>,
#[serde(default)]
@@ -293,7 +290,7 @@ pub enum NetworkSettings {
#[derive(Clone, Debug)]
pub struct NetworkManual {
- pub cidr: CidrAddress,
+ pub cidr: Cidr,
pub dns: IpAddr,
pub gateway: IpAddr,
pub filter: BTreeMap<String, String>,
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 9998491..f9cfcdd 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -73,7 +73,7 @@ fn get_network_settings(
};
if let answer::NetworkSettings::Manual(settings) = &answer.network.network_settings {
- network_options.address = settings.cidr.clone();
+ network_options.address = settings.cidr;
network_options.dns_server = settings.dns;
network_options.gateway = settings.gateway;
network_options.ifname = get_single_udev_index(&settings.filter, &udev_info.nics)?;
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/ipv4_and_subnet_mask_33.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/ipv4_and_subnet_mask_33.json
index 6b2888b..45e1abe 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer_fail/ipv4_and_subnet_mask_33.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/ipv4_and_subnet_mask_33.json
@@ -1,3 +1,3 @@
{
- "parse-error": "error parsing answer.toml: Invalid CIDR: mask cannot be greater than 32"
+ "parse-error": "error parsing answer.toml: invalid IP address"
}
diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs
index b380f1c..7cdb1de 100644
--- a/proxmox-installer-common/src/lib.rs
+++ b/proxmox-installer-common/src/lib.rs
@@ -2,7 +2,6 @@ pub mod disk_checks;
pub mod options;
pub mod setup;
pub mod sysinfo;
-pub mod utils;
#[cfg(feature = "http")]
pub mod http;
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index feb0dc4..f903f7e 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -10,8 +10,7 @@ use std::{cmp, fmt};
use crate::disk_checks::check_raid_min_disks;
use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN};
use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
-use crate::utils::CidrAddress;
-use proxmox_network_types::fqdn::Fqdn;
+use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))]
@@ -550,7 +549,7 @@ impl NetworkInterfacePinningOptions {
pub struct NetworkOptions {
pub ifname: String,
pub fqdn: Fqdn,
- pub address: CidrAddress,
+ pub address: Cidr,
pub gateway: IpAddr,
pub dns_server: IpAddr,
pub pinning_opts: Option<NetworkInterfacePinningOptions>,
@@ -576,7 +575,7 @@ impl NetworkOptions {
),
// Safety: The provided IP address/mask is always valid.
// These are the same as used in the GTK-based installer.
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 100, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 100, 2], 24).unwrap(),
gateway: Ipv4Addr::new(192, 168, 100, 1).into(),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: pinning_opts.cloned(),
@@ -602,7 +601,7 @@ impl NetworkOptions {
if let Some(addr) = iface.addresses.iter().find(|addr| addr.is_ipv4()) {
this.gateway = gw.gateway;
- this.address = addr.clone();
+ this.address = *addr;
} else if let Some(gw) = &routes.gateway6
&& let Some(iface) = network.interfaces.get(&gw.dev)
&& let Some(addr) = iface.addresses.iter().find(|addr| addr.is_ipv6())
@@ -617,7 +616,7 @@ impl NetworkOptions {
}
this.gateway = gw.gateway;
- this.address = addr.clone();
+ this.address = *addr;
}
}
@@ -702,10 +701,7 @@ pub fn email_validate(email: &str) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::{
- setup::{Dns, Gateway, Interface, InterfaceState, NetworkInfo, Routes, SetupInfo},
- utils::CidrAddress,
- };
+ use crate::setup::{Dns, Gateway, Interface, InterfaceState, NetworkInfo, Routes, SetupInfo};
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr};
@@ -775,7 +771,7 @@ mod tests {
state: InterfaceState::Up,
driver: "dummy".to_owned(),
mac: "01:23:45:67:89:ab".to_owned(),
- addresses: vec![CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap()],
+ addresses: vec![Cidr::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap()],
},
);
@@ -807,7 +803,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("foo.bar.com").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -820,7 +816,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("pve.bar.com").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -833,7 +829,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("pve.example.invalid").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -846,7 +842,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("foo.example.invalid").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -863,7 +859,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("foo.bar.com").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -876,7 +872,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("foo.custom.local").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -889,7 +885,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("foo.custom.local").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 0, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
@@ -930,7 +926,7 @@ mod tests {
NetworkOptions {
ifname: "eth0".to_owned(),
fqdn: Fqdn::from("pve.example.invalid").unwrap(),
- address: CidrAddress::new(Ipv4Addr::new(192, 168, 100, 2), 24).unwrap(),
+ address: Cidr::new_v4([192, 168, 100, 2], 24).unwrap(),
gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 100, 1)),
dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
pinning_opts: None,
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index 35949f0..35a5436 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -10,14 +10,12 @@ use std::{
process::{self, Command, Stdio},
};
+use proxmox_network_types::Cidr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
-use crate::{
- options::{
- BtrfsBootdiskOptions, BtrfsCompressOption, Disk, FsType, NetworkInterfacePinningOptions,
- ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption,
- },
- utils::CidrAddress,
+use crate::options::{
+ BtrfsBootdiskOptions, BtrfsCompressOption, Disk, FsType, NetworkInterfacePinningOptions,
+ ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption,
};
#[allow(clippy::upper_case_acronyms)]
@@ -314,14 +312,14 @@ where
.collect())
}
-fn deserialize_cidr_list<'de, D>(deserializer: D) -> Result<Vec<CidrAddress>, D::Error>
+fn deserialize_cidr_list<'de, D>(deserializer: D) -> Result<Vec<Cidr>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct CidrDescriptor {
address: String,
- prefix: usize,
+ prefix: u8,
// family is implied anyway by parsing the address
}
@@ -335,7 +333,7 @@ where
.map_err(|err| de::Error::custom(format!("{:?}", err)))?;
result.push(
- CidrAddress::new(ip_addr, desc.prefix)
+ Cidr::new(ip_addr, desc.prefix)
.map_err(|err| de::Error::custom(format!("{:?}", err)))?,
);
}
@@ -478,7 +476,7 @@ pub struct Interface {
#[serde(default)]
#[serde(deserialize_with = "deserialize_cidr_list")]
- pub addresses: Vec<CidrAddress>,
+ pub addresses: Vec<Cidr>,
}
impl Interface {
@@ -613,8 +611,7 @@ pub struct InstallConfig {
pub hostname: String,
pub domain: String,
- #[serde(serialize_with = "serialize_as_display")]
- pub cidr: CidrAddress,
+ pub cidr: Cidr,
pub gateway: IpAddr,
pub dns: IpAddr,
diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs
deleted file mode 100644
index e86abdf..0000000
--- a/proxmox-installer-common/src/utils.rs
+++ /dev/null
@@ -1,141 +0,0 @@
-use std::{
- error::Error,
- fmt,
- net::{AddrParseError, IpAddr},
- str::FromStr,
-};
-
-use serde::Deserialize;
-
-/// Possible errors that might occur when parsing CIDR addresses.
-#[derive(Debug)]
-pub enum CidrAddressParseError {
- /// No delimiter for separating address and mask was found.
- NoDelimiter,
- /// The IP address part could not be parsed.
- InvalidAddr(AddrParseError),
- /// The mask could not be parsed.
- InvalidMask(Box<dyn Error>),
-}
-
-impl fmt::Display for CidrAddressParseError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "Invalid CIDR: ")?;
-
- match self {
- CidrAddressParseError::NoDelimiter => {
- write!(f, "no delimiter for separating address and mask was found")
- }
- CidrAddressParseError::InvalidAddr(err) => write!(f, "{err}"),
- CidrAddressParseError::InvalidMask(err) => write!(f, "{err}"),
- }
- }
-}
-
-/// An IP address (IPv4 or IPv6), including network mask.
-///
-/// See the [`IpAddr`] type for more information how IP addresses are handled.
-/// The mask is appropriately enforced to be `0 <= mask <= 32` for IPv4 or
-/// `0 <= mask <= 128` for IPv6 addresses.
-///
-/// # Examples
-/// ```
-/// use std::net::{Ipv4Addr, Ipv6Addr};
-/// use proxmox_installer_common::utils::CidrAddress;
-/// let ipv4 = CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwrap();
-/// let ipv6 = CidrAddress::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0xc0a8, 1), 32).unwrap();
-///
-/// assert_eq!(ipv4.to_string(), "192.168.0.1/24");
-/// assert_eq!(ipv6.to_string(), "2001:db8::c0a8:1/32");
-/// ```
-#[derive(Clone, Debug, PartialEq)]
-pub struct CidrAddress {
- addr: IpAddr,
- mask: usize,
-}
-
-impl CidrAddress {
- /// Constructs a new CIDR address.
- ///
- /// It fails if the mask is invalid for the given IP address.
- pub fn new<T: Into<IpAddr>>(addr: T, mask: usize) -> Result<Self, CidrAddressParseError> {
- let addr = addr.into();
-
- check_mask_limit(&addr, mask)?;
-
- Ok(Self { addr, mask })
- }
-
- /// Returns only the IP address part of the address.
- pub fn addr(&self) -> IpAddr {
- self.addr
- }
-
- /// Returns `true` if this address is an IPv4 address, `false` otherwise.
- pub fn is_ipv4(&self) -> bool {
- self.addr.is_ipv4()
- }
-
- /// Returns `true` if this address is an IPv6 address, `false` otherwise.
- pub fn is_ipv6(&self) -> bool {
- self.addr.is_ipv6()
- }
-
- /// Returns only the mask part of the address.
- pub fn mask(&self) -> usize {
- self.mask
- }
-}
-
-impl FromStr for CidrAddress {
- type Err = CidrAddressParseError;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- let (addr, mask) = s
- .split_once('/')
- .ok_or(CidrAddressParseError::NoDelimiter)?;
-
- let addr = addr.parse().map_err(CidrAddressParseError::InvalidAddr)?;
-
- let mask = mask
- .parse()
- .map_err(|err| CidrAddressParseError::InvalidMask(Box::new(err)))?;
-
- check_mask_limit(&addr, mask)?;
-
- Ok(Self { addr, mask })
- }
-}
-
-impl fmt::Display for CidrAddress {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}/{}", self.addr, self.mask)
- }
-}
-
-impl<'de> Deserialize<'de> for CidrAddress {
- 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)
- }
-}
-
-serde_plain::derive_serialize_from_display!(CidrAddress);
-
-fn mask_limit(addr: &IpAddr) -> usize {
- if addr.is_ipv4() { 32 } else { 128 }
-}
-
-fn check_mask_limit(addr: &IpAddr, mask: usize) -> Result<(), CidrAddressParseError> {
- let limit = mask_limit(addr);
- if mask > limit {
- Err(CidrAddressParseError::InvalidMask(
- format!("mask cannot be greater than {limit}").into(),
- ))
- } else {
- Ok(())
- }
-}
diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml
index 1917f38..beaaa26 100644
--- a/proxmox-post-hook/Cargo.toml
+++ b/proxmox-post-hook/Cargo.toml
@@ -14,5 +14,6 @@ homepage = "https://www.proxmox.com"
anyhow.workspace = true
proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
+proxmox-network-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 2ee0231..71fdde2 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -20,7 +20,6 @@ use std::{
path::PathBuf,
process::{Command, ExitCode},
};
-
use proxmox_auto_installer::{
answer::{
Answer, FqdnConfig, FqdnExtendedConfig, FqdnSourceMode, PostNotificationHookInfo,
@@ -36,8 +35,8 @@ use proxmox_installer_common::{
load_installer_setup_files,
},
sysinfo::SystemDMI,
- utils::CidrAddress,
};
+use proxmox_network_types::ip_address::Cidr;
/// Information about the system boot status.
#[derive(Serialize)]
@@ -83,7 +82,7 @@ struct NetworkInterfaceInfo {
mac: String,
/// (Designated) IP address of the interface
#[serde(skip_serializing_if = "Option::is_none")]
- address: Option<CidrAddress>,
+ address: Option<Cidr>,
/// Set to true if the interface is the chosen management interface during
/// installation.
#[serde(skip_serializing_if = "bool_is_false")]
@@ -406,7 +405,7 @@ impl PostHookInfo {
mac: nic.mac.clone(),
// Use the actual IP address from the low-level install config, as the runtime info
// contains the original IP address from DHCP.
- address: is_management.then_some(config.cidr.clone()),
+ address: is_management.then_some(config.cidr),
is_management,
is_pinned,
udev_properties,
diff --git a/proxmox-tui-installer/src/views/mod.rs b/proxmox-tui-installer/src/views/mod.rs
index 35cf53a..a343e60 100644
--- a/proxmox-tui-installer/src/views/mod.rs
+++ b/proxmox-tui-installer/src/views/mod.rs
@@ -8,8 +8,6 @@ use cursive::{
views::{EditView, LinearLayout, NamedView, ResizedView, SelectView, TextView},
};
-use proxmox_installer_common::utils::CidrAddress;
-
mod bootdisk;
pub use bootdisk::*;
@@ -20,6 +18,7 @@ mod network;
pub use network::*;
mod tabbed_view;
+use proxmox_network_types::ip_address::Cidr;
pub use tabbed_view::*;
mod table_view;
@@ -391,9 +390,9 @@ where
}
}
-impl FormViewGetValue<(IpAddr, usize)> for CidrAddressEditView {
- // FIXME: return CidrAddress (again) with proper error handling through Result
- fn get_value(&self) -> Option<(IpAddr, usize)> {
+impl FormViewGetValue<(IpAddr, u8)> for CidrAddressEditView {
+ // FIXME: return Cidr (again) with proper error handling through Result
+ fn get_value(&self) -> Option<(IpAddr, u8)> {
self.get_values()
}
}
@@ -572,14 +571,14 @@ impl CidrAddressEditView {
Self { view }
}
- pub fn content(mut self, cidr: CidrAddress) -> Self {
+ pub fn content(mut self, cidr: Cidr) -> Self {
if let Some(view) = self
.view
.get_child_mut(0)
.and_then(|v| v.downcast_mut::<ResizedView<EditView>>())
{
*view = EditView::new()
- .content(cidr.addr().to_string())
+ .content(cidr.address().to_string())
.full_width();
}
@@ -594,15 +593,15 @@ impl CidrAddressEditView {
self
}
- fn mask_edit_view(content: usize) -> ResizedView<IntegerEditView> {
+ fn mask_edit_view(content: u8) -> ResizedView<IntegerEditView> {
IntegerEditView::new()
.max_value(128)
.max_content_width(3)
- .content(content)
+ .content(content.into())
.fixed_width(4)
}
- fn get_values(&self) -> Option<(IpAddr, usize)> {
+ fn get_values(&self) -> Option<(IpAddr, u8)> {
let addr = self
.view
.get_child(0)?
@@ -620,7 +619,7 @@ impl CidrAddressEditView {
.get_content()
.ok()?;
- Some((addr, mask))
+ Some((addr, mask as u8))
}
}
diff --git a/proxmox-tui-installer/src/views/network.rs b/proxmox-tui-installer/src/views/network.rs
index 53e0d65..12cef19 100644
--- a/proxmox-tui-installer/src/views/network.rs
+++ b/proxmox-tui-installer/src/views/network.rs
@@ -16,9 +16,8 @@ use proxmox_installer_common::{
net::MAX_IFNAME_LEN,
options::{NetworkInterfacePinningOptions, NetworkOptions},
setup::{Interface, NetworkInfo},
- utils::CidrAddress,
};
-use proxmox_network_types::fqdn::Fqdn;
+use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
use super::{CidrAddressEditView, FormView};
@@ -85,7 +84,7 @@ impl NetworkOptionsView {
)
.child(
"IP address (CIDR)",
- CidrAddressEditView::new().content(options.address.clone()),
+ CidrAddressEditView::new().content(options.address),
)
.child(
"Gateway address",
@@ -169,9 +168,7 @@ impl NetworkOptionsView {
let address = form
.get_value::<CidrAddressEditView, _>(2)
.ok_or("failed to retrieve host address".to_string())
- .and_then(|(ip_addr, mask)| {
- CidrAddress::new(ip_addr, mask).map_err(|err| err.to_string())
- })?;
+ .and_then(|(ip_addr, mask)| Cidr::new(ip_addr, mask).map_err(|err| err.to_string()))?;
let gateway = form
.get_value::<EditView, _>(3)
@@ -199,9 +196,9 @@ impl NetworkOptionsView {
iface.name
};
- if address.addr().is_ipv4() != gateway.is_ipv4() {
+ if address.address().is_ipv4() != gateway.is_ipv4() {
Err("host and gateway IP address version must not differ".to_owned())
- } else if address.addr().is_ipv4() != dns_server.is_ipv4() {
+ } else if address.address().is_ipv4() != dns_server.is_ipv4() {
Err("host and DNS IP address version must not differ".to_owned())
} else if fqdn.to_string().ends_with(".invalid") {
Err("hostname does not look valid".to_owned())
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (29 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 30/38] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 32/38] post-hook: switch to types in proxmox-installer-types Christoph Heiss
` (6 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
Cargo.toml | 2 +
proxmox-auto-installer/Cargo.toml | 1 +
proxmox-auto-installer/src/answer.rs | 26 ++-
proxmox-auto-installer/src/utils.rs | 16 +-
proxmox-chroot/Cargo.toml | 1 +
proxmox-chroot/src/main.rs | 60 ++----
proxmox-installer-common/Cargo.toml | 1 +
proxmox-installer-common/src/options.rs | 197 ++++----------------
proxmox-installer-common/src/setup.rs | 5 +-
proxmox-post-hook/Cargo.toml | 1 +
proxmox-post-hook/src/main.rs | 37 ++--
proxmox-tui-installer/Cargo.toml | 1 +
proxmox-tui-installer/src/options.rs | 21 +--
proxmox-tui-installer/src/views/bootdisk.rs | 30 +--
14 files changed, 132 insertions(+), 267 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 379ee6b..9d95796 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,8 +28,10 @@ toml = "0.8"
proxmox-auto-installer.path = "./proxmox-auto-installer"
proxmox-installer-common.path = "./proxmox-installer-common"
proxmox-network-types = "1.0"
+proxmox-installer-types = "0.1"
# Local path overrides
# NOTE: You must run `cargo update` after changing this for it to take effect!
[patch.crates-io]
# proxmox-network-types.path = "../proxmox/proxmox-network-types"
+# proxmox-installer-types.path = "../proxmox/proxmox-installer-types"
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
index 0086e5d..5ef2f4f 100644
--- a/proxmox-auto-installer/Cargo.toml
+++ b/proxmox-auto-installer/Cargo.toml
@@ -15,6 +15,7 @@ anyhow.workspace = true
log.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
proxmox-network-types.workspace = true
+proxmox-installer-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_plain.workspace = true
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index acb0d5b..eec5b58 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -7,9 +7,9 @@ use std::{
};
use proxmox_installer_common::options::{
- BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions, ZfsChecksumOption,
- ZfsCompressOption, ZfsRaidLevel,
+ BtrfsCompressOption, NetworkInterfacePinningOptions, ZfsChecksumOption, ZfsCompressOption,
};
+use proxmox_installer_types::answer::{BtrfsRaidLevel, FilesystemType, ZfsRaidLevel};
use proxmox_network_types::{Cidr, fqdn::Fqdn};
// NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards
@@ -314,7 +314,7 @@ pub struct DiskSetup {
#[derive(Clone, Debug, Deserialize)]
#[serde(try_from = "DiskSetup", deny_unknown_fields)]
pub struct Disks {
- pub fs_type: FsType,
+ pub fs_type: FilesystemType,
pub disk_selection: DiskSelection,
pub filter_match: Option<FilterMatch>,
pub fs_options: FsOptions,
@@ -351,11 +351,17 @@ impl TryFrom<DiskSetup> for Disks {
let (fs, fs_options) = match source.filesystem {
Filesystem::Xfs => {
lvm_checks(&source)?;
- (FsType::Xfs, FsOptions::LVM(source.lvm.unwrap_or_default()))
+ (
+ FilesystemType::Xfs,
+ FsOptions::LVM(source.lvm.unwrap_or_default()),
+ )
}
Filesystem::Ext4 => {
lvm_checks(&source)?;
- (FsType::Ext4, FsOptions::LVM(source.lvm.unwrap_or_default()))
+ (
+ FilesystemType::Ext4,
+ FsOptions::LVM(source.lvm.unwrap_or_default()),
+ )
}
Filesystem::Zfs => {
if source.lvm.is_some() || source.btrfs.is_some() {
@@ -365,7 +371,10 @@ impl TryFrom<DiskSetup> for Disks {
None | Some(ZfsOptions { raid: None, .. }) => {
return Err("ZFS raid level 'zfs.raid' must be set");
}
- Some(opts) => (FsType::Zfs(opts.raid.unwrap()), FsOptions::ZFS(opts)),
+ Some(opts) => (
+ FilesystemType::Zfs(opts.raid.unwrap()),
+ FsOptions::ZFS(opts),
+ ),
}
}
Filesystem::Btrfs => {
@@ -376,7 +385,10 @@ impl TryFrom<DiskSetup> for Disks {
None | Some(BtrfsOptions { raid: None, .. }) => {
return Err("BTRFS raid level 'btrfs.raid' must be set");
}
- Some(opts) => (FsType::Btrfs(opts.raid.unwrap()), FsOptions::BTRFS(opts)),
+ Some(opts) => (
+ FilesystemType::Btrfs(opts.raid.unwrap()),
+ FsOptions::BTRFS(opts),
+ ),
}
}
};
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index f9cfcdd..83be913 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -16,12 +16,13 @@ use crate::{
use proxmox_installer_common::{
ROOT_PASSWORD_MIN_LENGTH,
disk_checks::check_swapsize,
- options::{FsType, NetworkOptions, ZfsChecksumOption, ZfsCompressOption, email_validate},
+ options::{NetworkOptions, RaidLevel, ZfsChecksumOption, ZfsCompressOption, email_validate},
setup::{
InstallBtrfsOption, InstallConfig, InstallFirstBootSetup, InstallRootPassword,
InstallZfsOption, LocaleInfo, RuntimeInfo, SetupInfo,
},
};
+use proxmox_installer_types::answer::FilesystemType;
use serde::{Deserialize, Serialize};
fn get_network_settings(
@@ -211,8 +212,10 @@ fn set_disks(
config: &mut InstallConfig,
) -> Result<()> {
match config.filesys {
- FsType::Ext4 | FsType::Xfs => set_single_disk(answer, udev_info, runtime_info, config),
- FsType::Zfs(_) | FsType::Btrfs(_) => {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
+ set_single_disk(answer, udev_info, runtime_info, config)
+ }
+ FilesystemType::Zfs(_) | FilesystemType::Btrfs(_) => {
set_selected_disks(answer, udev_info, runtime_info, config)
}
}
@@ -410,7 +413,12 @@ pub fn verify_email_and_root_password_settings(answer: &Answer) -> Result<()> {
pub fn verify_disks_settings(answer: &Answer) -> Result<()> {
if let DiskSelection::Selection(selection) = &answer.disks.disk_selection {
- let min_disks = answer.disks.fs_type.get_min_disks();
+ let min_disks = match answer.disks.fs_type {
+ FilesystemType::Ext4 | FilesystemType::Xfs => 1,
+ FilesystemType::Zfs(level) => level.get_min_disks(),
+ FilesystemType::Btrfs(level) => level.get_min_disks(),
+ };
+
if selection.len() < min_disks {
bail!(
"{}: need at least {} disks",
diff --git a/proxmox-chroot/Cargo.toml b/proxmox-chroot/Cargo.toml
index a6a705d..e1e0e4c 100644
--- a/proxmox-chroot/Cargo.toml
+++ b/proxmox-chroot/Cargo.toml
@@ -10,5 +10,6 @@ homepage = "https://www.proxmox.com"
[dependencies]
anyhow.workspace = true
proxmox-installer-common = { workspace = true, features = [ "cli" ] }
+proxmox-installer-types.workspace = true
serde = { workspace = true, features = [ "derive" ] }
serde_json.workspace = true
diff --git a/proxmox-chroot/src/main.rs b/proxmox-chroot/src/main.rs
index 2cff630..5f087bb 100644
--- a/proxmox-chroot/src/main.rs
+++ b/proxmox-chroot/src/main.rs
@@ -5,20 +5,19 @@
#![forbid(unsafe_code)]
+use anyhow::{Result, bail};
+use serde::Deserialize;
use std::{
env, fs, io,
path::{self, Path, PathBuf},
process::{self, Command},
- str::FromStr,
};
-use anyhow::{Result, bail};
use proxmox_installer_common::{
RUNTIME_DIR, cli,
- options::FsType,
setup::{InstallConfig, SetupInfo},
};
-use serde::Deserialize;
+use proxmox_installer_types::answer::Filesystem;
const ANSWER_MP: &str = "answer";
static BINDMOUNTS: [&str; 4] = ["dev", "proc", "run", "sys"];
@@ -29,7 +28,7 @@ const ZPOOL_NAME: &str = "rpool";
struct CommandPrepareArgs {
/// Filesystem used for the installation. Will try to automatically detect it after a
/// successful installation.
- filesystem: Option<Filesystems>,
+ filesystem: Option<Filesystem>,
/// Numerical ID of the `rpool` ZFS pool to import. Needed if multiple pools of name `rpool`
/// are present.
@@ -74,7 +73,7 @@ OPTIONS:
/// Arguments for the `cleanup` command.
struct CommandCleanupArgs {
/// Filesystem used for the installation. Will try to automatically detect it by default.
- filesystem: Option<Filesystems>,
+ filesystem: Option<Filesystem>,
}
impl cli::Subcommand for CommandCleanupArgs {
@@ -105,39 +104,6 @@ OPTIONS:
}
}
-#[derive(Copy, Clone, Debug)]
-enum Filesystems {
- Zfs,
- Ext4,
- Xfs,
- Btrfs,
-}
-
-impl From<FsType> for Filesystems {
- fn from(fs: FsType) -> Self {
- match fs {
- FsType::Xfs => Self::Xfs,
- FsType::Ext4 => Self::Ext4,
- FsType::Zfs(_) => Self::Zfs,
- FsType::Btrfs(_) => Self::Btrfs,
- }
- }
-}
-
-impl FromStr for Filesystems {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self> {
- match s {
- "ext4" => Ok(Filesystems::Ext4),
- "xfs" => Ok(Filesystems::Xfs),
- _ if s.starts_with("zfs") => Ok(Filesystems::Zfs),
- _ if s.starts_with("btrfs") => Ok(Filesystems::Btrfs),
- _ => bail!("unknown filesystem"),
- }
- }
-}
-
fn main() -> process::ExitCode {
cli::run(cli::AppInfo {
global_help: &format!(
@@ -171,10 +137,10 @@ fn prepare(args: &CommandPrepareArgs) -> Result<()> {
fs::create_dir_all(TARGET_DIR)?;
match fs {
- Filesystems::Zfs => mount_zpool(args.rpool_id)?,
- Filesystems::Xfs => mount_fs()?,
- Filesystems::Ext4 => mount_fs()?,
- Filesystems::Btrfs => mount_btrfs(args.btrfs_uuid.clone())?,
+ Filesystem::Zfs => mount_zpool(args.rpool_id)?,
+ Filesystem::Xfs => mount_fs()?,
+ Filesystem::Ext4 => mount_fs()?,
+ Filesystem::Btrfs => mount_btrfs(args.btrfs_uuid.clone())?,
}
if let Err(e) = bindmount() {
@@ -193,15 +159,15 @@ fn cleanup(args: &CommandCleanupArgs) -> Result<()> {
}
match fs {
- Filesystems::Zfs => umount_zpool(),
- Filesystems::Btrfs | Filesystems::Xfs | Filesystems::Ext4 => umount(Path::new(TARGET_DIR))?,
+ Filesystem::Zfs => umount_zpool(),
+ Filesystem::Btrfs | Filesystem::Xfs | Filesystem::Ext4 => umount(Path::new(TARGET_DIR))?,
}
println!("Chroot cleanup done. You can now reboot or leave the shell.");
Ok(())
}
-fn get_fs(filesystem: Option<Filesystems>) -> Result<Filesystems> {
+fn get_fs(filesystem: Option<Filesystem>) -> Result<Filesystem> {
let fs = match filesystem {
None => {
let low_level_config = match get_low_level_config() {
@@ -210,7 +176,7 @@ fn get_fs(filesystem: Option<Filesystems>) -> Result<Filesystems> {
"Could not fetch config from previous installation. Please specify file system with -f."
),
};
- Filesystems::from(low_level_config.filesys)
+ low_level_config.filesys.into()
}
Some(fs) => fs,
};
diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml
index 7469627..7682680 100644
--- a/proxmox-installer-common/Cargo.toml
+++ b/proxmox-installer-common/Cargo.toml
@@ -14,6 +14,7 @@ serde = { workspace = true, features = [ "derive" ] }
serde_json.workspace = true
serde_plain.workspace = true
proxmox-network-types.workspace = true
+proxmox-installer-types.workspace = true
# `http` feature
hex = { version = "0.4", optional = true }
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index f903f7e..8e19663 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -1,36 +1,23 @@
use anyhow::{Result, bail};
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
-use std::collections::HashMap;
-use std::net::{IpAddr, Ipv4Addr};
-use std::str::FromStr;
-use std::sync::OnceLock;
-use std::{cmp, fmt};
+use std::{
+ cmp,
+ collections::HashMap,
+ fmt,
+ net::{IpAddr, Ipv4Addr},
+ sync::OnceLock,
+};
use crate::disk_checks::check_raid_min_disks;
use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN};
use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
+use proxmox_installer_types::answer::{BtrfsRaidLevel, FilesystemType, ZfsRaidLevel};
use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
-#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
-#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))]
-pub enum BtrfsRaidLevel {
- #[serde(alias = "RAID0")]
- Raid0,
- #[serde(alias = "RAID1")]
- Raid1,
- #[serde(alias = "RAID10")]
- Raid10,
-}
-
-impl BtrfsRaidLevel {
- pub fn get_min_disks(&self) -> usize {
- match self {
- BtrfsRaidLevel::Raid0 => 1,
- BtrfsRaidLevel::Raid1 => 2,
- BtrfsRaidLevel::Raid10 => 4,
- }
- }
+pub trait RaidLevel {
+ /// Returns the minimum number of disks needed for this RAID level.
+ fn get_min_disks(&self) -> usize;
/// Checks whether a user-supplied Btrfs RAID setup is valid or not, such as minimum
/// number of disks.
@@ -38,42 +25,31 @@ impl BtrfsRaidLevel {
/// # Arguments
///
/// * `disks` - List of disks designated as RAID targets.
- pub fn check_raid_disks_setup(&self, disks: &[Disk]) -> Result<(), String> {
+ fn check_raid_disks_setup(&self, disks: &[Disk]) -> Result<(), String>;
+
+ /// Checks whether the given disk sizes are compatible for the RAID level, if it is a mirror.
+ fn check_mirror_size(&self, _disk1: &Disk, _disk2: &Disk) -> Result<(), String> {
+ Ok(())
+ }
+}
+
+impl RaidLevel for BtrfsRaidLevel {
+ fn get_min_disks(&self) -> usize {
+ match self {
+ Self::Raid0 => 1,
+ Self::Raid1 => 2,
+ Self::Raid10 => 4,
+ }
+ }
+
+ fn check_raid_disks_setup(&self, disks: &[Disk]) -> Result<(), String> {
check_raid_min_disks(disks, self.get_min_disks())?;
Ok(())
}
}
-serde_plain::derive_display_from_serialize!(BtrfsRaidLevel);
-
-#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
-#[serde(rename_all(deserialize = "lowercase", serialize = "UPPERCASE"))]
-pub enum ZfsRaidLevel {
- #[serde(alias = "RAID0")]
- Raid0,
- #[serde(alias = "RAID1")]
- Raid1,
- #[serde(alias = "RAID10")]
- Raid10,
- #[serde(
- alias = "RAIDZ-1",
- rename(deserialize = "raidz-1", serialize = "RAIDZ-1")
- )]
- RaidZ,
- #[serde(
- alias = "RAIDZ-2",
- rename(deserialize = "raidz-2", serialize = "RAIDZ-2")
- )]
- RaidZ2,
- #[serde(
- alias = "RAIDZ-3",
- rename(deserialize = "raidz-3", serialize = "RAIDZ-3")
- )]
- RaidZ3,
-}
-
-impl ZfsRaidLevel {
- pub fn get_min_disks(&self) -> usize {
+impl RaidLevel for ZfsRaidLevel {
+ fn get_min_disks(&self) -> usize {
match self {
ZfsRaidLevel::Raid0 => 1,
ZfsRaidLevel::Raid1 => 2,
@@ -84,23 +60,7 @@ impl ZfsRaidLevel {
}
}
- fn check_mirror_size(&self, disk1: &Disk, disk2: &Disk) -> Result<(), String> {
- if (disk1.size - disk2.size).abs() > disk1.size / 10. {
- Err(format!(
- "Mirrored disks must have same size:\n\n * {disk1}\n * {disk2}"
- ))
- } else {
- Ok(())
- }
- }
-
- /// Checks whether a user-supplied ZFS RAID setup is valid or not, such as disk sizes andminimum
- /// number of disks.
- ///
- /// # Arguments
- ///
- /// * `disks` - List of disks designated as RAID targets.
- pub fn check_raid_disks_setup(&self, disks: &[Disk]) -> Result<(), String> {
+ fn check_raid_disks_setup(&self, disks: &[Disk]) -> Result<(), String> {
check_raid_min_disks(disks, self.get_min_disks())?;
match self {
@@ -130,93 +90,18 @@ impl ZfsRaidLevel {
Ok(())
}
-}
-serde_plain::derive_display_from_serialize!(ZfsRaidLevel);
-
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-pub enum FsType {
- Ext4,
- Xfs,
- Zfs(ZfsRaidLevel),
- Btrfs(BtrfsRaidLevel),
-}
-
-impl FsType {
- pub fn is_btrfs(&self) -> bool {
- matches!(self, FsType::Btrfs(_))
- }
-
- /// Returns true if the filesystem is used on top of LVM, e.g. ext4 or XFS.
- pub fn is_lvm(&self) -> bool {
- matches!(self, FsType::Ext4 | FsType::Xfs)
- }
-
- pub fn get_min_disks(&self) -> usize {
- match self {
- FsType::Ext4 => 1,
- FsType::Xfs => 1,
- FsType::Zfs(level) => level.get_min_disks(),
- FsType::Btrfs(level) => level.get_min_disks(),
+ fn check_mirror_size(&self, disk1: &Disk, disk2: &Disk) -> Result<(), String> {
+ if (disk1.size - disk2.size).abs() > disk1.size / 10. {
+ Err(format!(
+ "Mirrored disks must have same size:\n\n * {disk1}\n * {disk2}"
+ ))
+ } else {
+ Ok(())
}
}
}
-impl fmt::Display for FsType {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- // Values displayed to the user in the installer UI
- match self {
- FsType::Ext4 => write!(f, "ext4"),
- FsType::Xfs => write!(f, "XFS"),
- FsType::Zfs(level) => write!(f, "ZFS ({level})"),
- FsType::Btrfs(level) => write!(f, "BTRFS ({level})"),
- }
- }
-}
-
-impl Serialize for FsType {
- 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
- FsType::Ext4 => "ext4",
- FsType::Xfs => "xfs",
- // proxinstall::get_zfs_raid_setup()
- FsType::Zfs(level) => &format!("zfs ({level})"),
- // proxinstall::get_btrfs_raid_setup()
- FsType::Btrfs(level) => &format!("btrfs ({level})"),
- };
-
- serializer.collect_str(value)
- }
-}
-
-impl FromStr for FsType {
- type Err = String;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- match s {
- "ext4" => Ok(FsType::Ext4),
- "xfs" => Ok(FsType::Xfs),
- "zfs (RAID0)" => Ok(FsType::Zfs(ZfsRaidLevel::Raid0)),
- "zfs (RAID1)" => Ok(FsType::Zfs(ZfsRaidLevel::Raid1)),
- "zfs (RAID10)" => Ok(FsType::Zfs(ZfsRaidLevel::Raid10)),
- "zfs (RAIDZ-1)" => Ok(FsType::Zfs(ZfsRaidLevel::RaidZ)),
- "zfs (RAIDZ-2)" => Ok(FsType::Zfs(ZfsRaidLevel::RaidZ2)),
- "zfs (RAIDZ-3)" => Ok(FsType::Zfs(ZfsRaidLevel::RaidZ3)),
- "btrfs (RAID0)" => Ok(FsType::Btrfs(BtrfsRaidLevel::Raid0)),
- "btrfs (RAID1)" => Ok(FsType::Btrfs(BtrfsRaidLevel::Raid1)),
- "btrfs (RAID10)" => Ok(FsType::Btrfs(BtrfsRaidLevel::Raid10)),
- _ => Err(format!("Could not find file system: {s}")),
- }
- }
-}
-
-serde_plain::derive_deserialize_from_fromstr!(FsType, "valid filesystem");
-
#[derive(Clone, Debug)]
pub struct LvmBootdiskOptions {
pub total_size: f64,
@@ -426,7 +311,7 @@ impl cmp::Ord for Disk {
#[derive(Clone, Debug)]
pub struct BootdiskOptions {
pub disks: Vec<Disk>,
- pub fstype: FsType,
+ pub fstype: FilesystemType,
pub advanced: AdvancedBootdiskOptions,
}
@@ -434,7 +319,7 @@ impl BootdiskOptions {
pub fn defaults_from(disk: &Disk) -> Self {
Self {
disks: vec![disk.clone()],
- fstype: FsType::Ext4,
+ fstype: FilesystemType::Ext4,
advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(disk)),
}
}
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index 35a5436..91f1250 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -14,9 +14,10 @@ use proxmox_network_types::Cidr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use crate::options::{
- BtrfsBootdiskOptions, BtrfsCompressOption, Disk, FsType, NetworkInterfacePinningOptions,
+ BtrfsBootdiskOptions, BtrfsCompressOption, Disk, NetworkInterfacePinningOptions,
ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption,
};
+use proxmox_installer_types::answer::FilesystemType;
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Serialize)]
@@ -565,7 +566,7 @@ pub fn spawn_low_level_installer(test_mode: bool) -> io::Result<process::Child>
pub struct InstallConfig {
pub autoreboot: usize,
- pub filesys: FsType,
+ pub filesys: FilesystemType,
pub hdsize: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub swapsize: Option<f64>,
diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml
index beaaa26..f0c344e 100644
--- a/proxmox-post-hook/Cargo.toml
+++ b/proxmox-post-hook/Cargo.toml
@@ -15,5 +15,6 @@ anyhow.workspace = true
proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
proxmox-network-types.workspace = true
+proxmox-installer-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 71fdde2..a05b30f 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -10,6 +10,24 @@
//! previously installed system.
use anyhow::{Context, Result, anyhow, bail};
+use proxmox_auto_installer::{
+ answer::{
+ Answer, FqdnConfig, FqdnExtendedConfig, FqdnSourceMode, PostNotificationHookInfo,
+ RebootMode,
+ },
+ udevinfo::{UdevInfo, UdevProperties},
+};
+use proxmox_installer_common::http::{self, header::HeaderMap};
+use proxmox_installer_common::{
+ options::{Disk, NetworkOptions},
+ setup::{
+ BootType, InstallConfig, IsoInfo, ProxmoxProduct, RuntimeInfo, SetupInfo,
+ load_installer_setup_files,
+ },
+ sysinfo::SystemDMI,
+};
+use proxmox_installer_types::answer::FilesystemType;
+use proxmox_network_types::ip_address::Cidr;
use serde::Serialize;
use std::{
collections::HashSet,
@@ -20,23 +38,6 @@ use std::{
path::PathBuf,
process::{Command, ExitCode},
};
-use proxmox_auto_installer::{
- answer::{
- Answer, FqdnConfig, FqdnExtendedConfig, FqdnSourceMode, PostNotificationHookInfo,
- RebootMode,
- },
- udevinfo::{UdevInfo, UdevProperties},
-};
-use proxmox_installer_common::http::{self, header::HeaderMap};
-use proxmox_installer_common::{
- options::{Disk, FsType, NetworkOptions},
- setup::{
- BootType, InstallConfig, IsoInfo, ProxmoxProduct, RuntimeInfo, SetupInfo,
- load_installer_setup_files,
- },
- sysinfo::SystemDMI,
-};
-use proxmox_network_types::ip_address::Cidr;
/// Information about the system boot status.
#[derive(Serialize)]
@@ -195,7 +196,7 @@ struct PostHookInfo {
/// DMI information about the system
dmi: SystemDMI,
/// Filesystem used for boot disk(s)
- filesystem: FsType,
+ filesystem: FilesystemType,
/// Fully qualified domain name of the installed system
fqdn: String,
/// Unique systemd-id128 identifier of the installed system (128-bit, 16 bytes)
diff --git a/proxmox-tui-installer/Cargo.toml b/proxmox-tui-installer/Cargo.toml
index 1ca91cb..56395a4 100644
--- a/proxmox-tui-installer/Cargo.toml
+++ b/proxmox-tui-installer/Cargo.toml
@@ -10,6 +10,7 @@ homepage = "https://www.proxmox.com"
[dependencies]
proxmox-installer-common.workspace = true
proxmox-network-types.workspace = true
+proxmox-installer-types.workspace = true
anyhow.workspace = true
serde_json.workspace = true
diff --git a/proxmox-tui-installer/src/options.rs b/proxmox-tui-installer/src/options.rs
index c80877f..ff15fa0 100644
--- a/proxmox-tui-installer/src/options.rs
+++ b/proxmox-tui-installer/src/options.rs
@@ -2,29 +2,10 @@ use crate::SummaryOption;
use proxmox_installer_common::{
EMAIL_DEFAULT_PLACEHOLDER,
- options::{
- BootdiskOptions, BtrfsRaidLevel, FsType, NetworkOptions, TimezoneOptions, ZfsRaidLevel,
- },
+ options::{BootdiskOptions, NetworkOptions, TimezoneOptions},
setup::LocaleInfo,
};
-pub const FS_TYPES: &[FsType] = {
- use FsType::*;
- &[
- 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),
- ]
-};
-
#[derive(Clone)]
pub struct PasswordOptions {
pub email: String,
diff --git a/proxmox-tui-installer/src/views/bootdisk.rs b/proxmox-tui-installer/src/views/bootdisk.rs
index 5ec3e83..ed3936f 100644
--- a/proxmox-tui-installer/src/views/bootdisk.rs
+++ b/proxmox-tui-installer/src/views/bootdisk.rs
@@ -16,7 +16,6 @@ use cursive::{
use super::{DiskSizeEditView, FormView, IntegerEditView, TabbedView};
use crate::InstallerState;
-use crate::options::FS_TYPES;
use proxmox_installer_common::{
disk_checks::{
@@ -24,11 +23,12 @@ use proxmox_installer_common::{
},
options::{
AdvancedBootdiskOptions, BTRFS_COMPRESS_OPTIONS, BootdiskOptions, BtrfsBootdiskOptions,
- Disk, FsType, LvmBootdiskOptions, ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
+ Disk, LvmBootdiskOptions, RaidLevel, ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
ZfsBootdiskOptions,
},
setup::{BootType, ProductConfig, ProxmoxProduct, RuntimeInfo},
};
+use proxmox_installer_types::answer::{FILESYSTEM_TYPE_OPTIONS, FilesystemType};
/// OpenZFS specifies 64 MiB as the absolute minimum:
/// <https://openzfs.github.io/openzfs-docs/Performance%20and%20Tuning/Module%20Parameters.html#zfs-arc-max>
@@ -125,19 +125,19 @@ impl AdvancedBootdiskOptionsView {
product_conf: ProductConfig,
) -> Self {
let filter_btrfs =
- |fstype: &&FsType| -> bool { product_conf.enable_btrfs || !fstype.is_btrfs() };
+ |fstype: &&FilesystemType| -> bool { product_conf.enable_btrfs || !fstype.is_btrfs() };
let options = options_ref.lock().unwrap();
let fstype_select = SelectView::new()
.popup()
.with_all(
- FS_TYPES
+ FILESYSTEM_TYPE_OPTIONS
.iter()
.filter(filter_btrfs)
.map(|t| (t.to_string(), *t)),
)
.selected(
- FS_TYPES
+ FILESYSTEM_TYPE_OPTIONS
.iter()
.filter(filter_btrfs)
.position(|t| *t == options.fstype)
@@ -185,7 +185,11 @@ impl AdvancedBootdiskOptionsView {
/// * `fstype` - The chosen filesystem type by the user, for which the UI should be
/// updated accordingly
/// * `options_ref` - [`BootdiskOptionsRef`] where advanced disk options should be saved to
- fn fstype_on_submit(siv: &mut Cursive, fstype: &FsType, options_ref: BootdiskOptionsRef) {
+ fn fstype_on_submit(
+ siv: &mut Cursive,
+ fstype: &FilesystemType,
+ options_ref: BootdiskOptionsRef,
+ ) {
let state = siv.user_data::<InstallerState>().unwrap();
let runinfo = state.runtime_info.clone();
let product_conf = state.setup_info.config.clone();
@@ -208,16 +212,16 @@ impl AdvancedBootdiskOptionsView {
{
view.remove_child(3);
match fstype {
- FsType::Ext4 | FsType::Xfs => {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
view.add_child(LvmBootdiskOptionsView::new_with_defaults(
&selected_lvm_disk,
&product_conf,
))
}
- FsType::Zfs(_) => {
+ FilesystemType::Zfs(_) => {
view.add_child(ZfsBootdiskOptionsView::new_with_defaults(&runinfo))
}
- FsType::Btrfs(_) => {
+ FilesystemType::Btrfs(_) => {
view.add_child(BtrfsBootdiskOptionsView::new_with_defaults(&runinfo))
}
}
@@ -236,7 +240,7 @@ impl AdvancedBootdiskOptionsView {
siv.call_on_name(
"bootdisk-options-target-disk",
move |view: &mut FormView| match fstype {
- FsType::Ext4 | FsType::Xfs => {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
view.replace_child(
0,
target_bootdisk_selectview(&runinfo.disks, options_ref, &selected_lvm_disk),
@@ -252,7 +256,7 @@ impl AdvancedBootdiskOptionsView {
.view
.get_child(1)
.and_then(|v| v.downcast_ref::<FormView>())
- .and_then(|v| v.get_value::<SelectView<FsType>, _>(0))
+ .and_then(|v| v.get_value::<SelectView<FilesystemType>, _>(0))
.ok_or("Failed to retrieve filesystem type".to_owned())?;
let advanced = self
@@ -279,7 +283,7 @@ impl AdvancedBootdiskOptionsView {
.get_values()
.ok_or("Failed to retrieve advanced bootdisk options")?;
- if let FsType::Zfs(level) = fstype {
+ if let FilesystemType::Zfs(level) = fstype {
level
.check_raid_disks_setup(&disks)
.map_err(|err| format!("{fstype}: {err}"))?;
@@ -295,7 +299,7 @@ impl AdvancedBootdiskOptionsView {
.get_values()
.ok_or("Failed to retrieve advanced bootdisk options")?;
- if let FsType::Btrfs(level) = fstype {
+ if let FilesystemType::Btrfs(level) = fstype {
level
.check_raid_disks_setup(&disks)
.map_err(|err| format!("{fstype}: {err}"))?;
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 32/38] post-hook: switch to types in proxmox-installer-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (30 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types Christoph Heiss
` (5 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
Cargo.toml | 2 +-
proxmox-installer-common/src/dmi.rs | 43 ++
proxmox-installer-common/src/lib.rs | 1 +
proxmox-post-hook/Cargo.toml | 2 +-
proxmox-post-hook/src/main.rs | 682 +++++++++++-----------------
5 files changed, 313 insertions(+), 417 deletions(-)
create mode 100644 proxmox-installer-common/src/dmi.rs
diff --git a/Cargo.toml b/Cargo.toml
index 9d95796..2466822 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,7 +28,7 @@ toml = "0.8"
proxmox-auto-installer.path = "./proxmox-auto-installer"
proxmox-installer-common.path = "./proxmox-installer-common"
proxmox-network-types = "1.0"
-proxmox-installer-types = "0.1"
+proxmox-installer-types = { version = "0.1", features = ["legacy"] }
# Local path overrides
# NOTE: You must run `cargo update` after changing this for it to take effect!
diff --git a/proxmox-installer-common/src/dmi.rs b/proxmox-installer-common/src/dmi.rs
new file mode 100644
index 0000000..76ae4a5
--- /dev/null
+++ b/proxmox-installer-common/src/dmi.rs
@@ -0,0 +1,43 @@
+use std::{collections::HashMap, fs};
+
+use anyhow::{Result, bail};
+use proxmox_installer_types::SystemDMI;
+
+const DMI_PATH: &str = "/sys/devices/virtual/dmi/id";
+
+pub fn get() -> Result<SystemDMI> {
+ let system_files = [
+ "product_serial",
+ "product_sku",
+ "product_uuid",
+ "product_name",
+ ];
+ let baseboard_files = ["board_asset_tag", "board_serial", "board_name"];
+ let chassis_files = ["chassis_serial", "chassis_sku", "chassis_asset_tag"];
+
+ Ok(SystemDMI {
+ system: get_dmi_infos_for(&system_files)?,
+ baseboard: get_dmi_infos_for(&baseboard_files)?,
+ chassis: get_dmi_infos_for(&chassis_files)?,
+ })
+}
+
+fn get_dmi_infos_for(files: &[&str]) -> Result<HashMap<String, String>> {
+ let mut res: HashMap<String, String> = HashMap::new();
+
+ for file in files {
+ let path = format!("{DMI_PATH}/{file}");
+ let content = match fs::read_to_string(&path) {
+ Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(ref err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
+ bail!("Could not read data. Are you running as root or with sudo?")
+ }
+ Err(err) => bail!("Error: '{err}' on '{path}'"),
+ Ok(content) => content.trim().into(),
+ };
+ let key = file.splitn(2, '_').last().unwrap();
+ res.insert(key.into(), content);
+ }
+
+ Ok(res)
+}
diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs
index 7cdb1de..05445d5 100644
--- a/proxmox-installer-common/src/lib.rs
+++ b/proxmox-installer-common/src/lib.rs
@@ -1,4 +1,5 @@
pub mod disk_checks;
+pub mod dmi;
pub mod options;
pub mod setup;
pub mod sysinfo;
diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml
index f0c344e..748b922 100644
--- a/proxmox-post-hook/Cargo.toml
+++ b/proxmox-post-hook/Cargo.toml
@@ -12,9 +12,9 @@ homepage = "https://www.proxmox.com"
[dependencies]
anyhow.workspace = true
-proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
proxmox-network-types.workspace = true
proxmox-installer-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
+toml.workspace = true
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index a05b30f..9025c01 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -9,212 +9,49 @@
//! Relies on `proxmox-chroot` as an external dependency to (bind-)mount the
//! previously installed system.
-use anyhow::{Context, Result, anyhow, bail};
-use proxmox_auto_installer::{
- answer::{
- Answer, FqdnConfig, FqdnExtendedConfig, FqdnSourceMode, PostNotificationHookInfo,
- RebootMode,
- },
- udevinfo::{UdevInfo, UdevProperties},
-};
-use proxmox_installer_common::http::{self, header::HeaderMap};
-use proxmox_installer_common::{
- options::{Disk, NetworkOptions},
- setup::{
- BootType, InstallConfig, IsoInfo, ProxmoxProduct, RuntimeInfo, SetupInfo,
- load_installer_setup_files,
- },
- sysinfo::SystemDMI,
-};
-use proxmox_installer_types::answer::FilesystemType;
-use proxmox_network_types::ip_address::Cidr;
-use serde::Serialize;
+use anyhow::{Context, Result, bail};
+use proxmox_installer_types::answer::{AutoInstallerConfig, PostNotificationHookInfo};
use std::{
- collections::HashSet,
- ffi::CStr,
- fs::{self, File},
- io::BufReader,
- os::unix::fs::FileExt,
- path::PathBuf,
+ fs,
+ io::Read,
process::{Command, ExitCode},
};
-/// Information about the system boot status.
-#[derive(Serialize)]
-struct BootInfo {
- /// Whether the system is booted using UEFI or legacy BIOS.
- mode: BootType,
- /// Whether SecureBoot is enabled for the installation.
- #[serde(skip_serializing_if = "bool_is_false")]
- secureboot: bool,
-}
+use proxmox_installer_common::http::{self, header::HeaderMap};
-/// Holds all the public keys for the different algorithms available.
-#[derive(Serialize)]
-struct SshPublicHostKeys {
- // ECDSA-based public host key
- ecdsa: String,
- // ED25519-based public host key
- ed25519: String,
- // RSA-based public host key
- rsa: String,
-}
+/// Current version of the schema sent by this implementation.
+const POST_HOOK_SCHEMA_VERSION: &str = "1.2";
-/// Holds information about a single disk in the system.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-struct DiskInfo {
- /// Size in bytes
- size: usize,
- /// Set to true if the disk is used for booting.
- #[serde(skip_serializing_if = "bool_is_false")]
- is_bootdisk: bool,
- /// Properties about the device as given by udev.
- udev_properties: UdevProperties,
-}
+mod detail {
+ use anyhow::{Context, Result, anyhow, bail};
+ use std::{
+ collections::HashSet,
+ ffi::CStr,
+ fs::{self, File},
+ io::BufReader,
+ os::unix::fs::FileExt,
+ path::PathBuf,
+ process::Command,
+ };
-/// Holds information about the management network interface.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-struct NetworkInterfaceInfo {
- /// Name of the interface
- name: String,
- /// MAC address of the interface
- mac: String,
- /// (Designated) IP address of the interface
- #[serde(skip_serializing_if = "Option::is_none")]
- address: Option<Cidr>,
- /// Set to true if the interface is the chosen management interface during
- /// installation.
- #[serde(skip_serializing_if = "bool_is_false")]
- is_management: bool,
- /// Set to true if the network interface name was pinned based on the MAC
- /// address during the installation.
- #[serde(skip_serializing_if = "bool_is_false")]
- is_pinned: bool,
- /// Properties about the device as given by udev.
- udev_properties: UdevProperties,
-}
+ use proxmox_installer_common::{
+ options::{Disk, NetworkOptions},
+ setup::{
+ InstallConfig, ProxmoxProduct, RuntimeInfo, SetupInfo, load_installer_setup_files,
+ },
+ };
+ use proxmox_installer_types::{
+ BootType, IsoInfo, UdevInfo,
+ answer::{AutoInstallerConfig, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode},
+ post_hook::{
+ BootInfo, CpuInfo, DiskInfo, KernelVersionInformation, NetworkInterfaceInfo,
+ PostHookInfo, PostHookInfoSchema, ProductInfo, SshPublicHostKeys,
+ },
+ };
-fn bool_is_false(value: &bool) -> bool {
- !value
-}
+ /// Defines the size of a gibibyte in bytes.
+ const SIZE_GIB: usize = 1024 * 1024 * 1024;
-/// Information about the installed product itself.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-struct ProductInfo {
- /// Full name of the product
- fullname: String,
- /// Product abbreviation
- short: ProxmoxProduct,
- /// Version of the installed product
- version: String,
-}
-
-/// The current kernel version.
-/// Aligns with the format as used by the `/nodes/<node>/status` API of each product.
-#[derive(Serialize)]
-struct KernelVersionInformation {
- /// The systemname/nodename
- pub sysname: String,
- /// The kernel release number
- pub release: String,
- /// The kernel version
- pub version: String,
- /// The machine architecture
- pub machine: String,
-}
-
-/// Information about the CPU(s) installed in the system
-#[derive(Serialize)]
-struct CpuInfo {
- /// Number of physical CPU cores.
- cores: usize,
- /// Number of logical CPU cores aka. threads.
- cpus: usize,
- /// CPU feature flag set as a space-delimited list.
- flags: String,
- /// Whether hardware-accelerated virtualization is supported.
- hvm: bool,
- /// Reported model of the CPU(s)
- model: String,
- /// Number of physical CPU sockets
- sockets: usize,
-}
-
-/// Metadata of the hook, such as schema version of the document.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-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.
- version: String,
-}
-
-impl PostHookInfoSchema {
- const SCHEMA_VERSION: &str = "1.2";
-}
-
-impl Default for PostHookInfoSchema {
- fn default() -> Self {
- Self {
- version: Self::SCHEMA_VERSION.to_owned(),
- }
- }
-}
-
-/// 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.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-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: PostHookInfoSchema,
- /// major.minor version of Debian as installed, retrieved from /etc/debian_version
- debian_version: String,
- /// PVE/PMG/PBS/PDM version as reported by `pveversion`, `pmgversion`,
- /// `proxmox-backup-manager version` or `proxmox-datacenter-manager version`, respectively.
- product: ProductInfo,
- /// Release information for the ISO used for the installation.
- iso: IsoInfo,
- /// Installed kernel version
- kernel_version: KernelVersionInformation,
- /// Describes the boot mode of the machine and the SecureBoot status.
- boot_info: BootInfo,
- /// Information about the installed CPU(s)
- cpu_info: CpuInfo,
- /// DMI information about the system
- dmi: SystemDMI,
- /// Filesystem used for boot disk(s)
- filesystem: FilesystemType,
- /// Fully qualified domain name of the installed system
- fqdn: String,
- /// Unique systemd-id128 identifier of the installed system (128-bit, 16 bytes)
- machine_id: String,
- /// All disks detected on the system.
- disks: Vec<DiskInfo>,
- /// All network interfaces detected on the system.
- network_interfaces: Vec<NetworkInterfaceInfo>,
- /// Public parts of SSH host keys of the installed system
- ssh_public_host_keys: SshPublicHostKeys,
- /// Action to will be performed, i.e. either reboot or power off the machine.
- reboot_mode: RebootMode,
-}
-
-/// Defines the size of a gibibyte in bytes.
-const SIZE_GIB: usize = 1024 * 1024 * 1024;
-
-impl PostHookInfo {
/// Gathers all needed information about the newly installed system for sending
/// it to a specified server.
///
@@ -222,7 +59,7 @@ impl PostHookInfo {
///
/// * `target_path` - Path to where the chroot environment root is mounted
/// * `answer` - Answer file as provided by the user
- fn gather(target_path: &str, answer: &Answer) -> Result<Self> {
+ pub fn gather(target_path: &str, answer: &AutoInstallerConfig) -> Result<PostHookInfo> {
println!("Gathering installed system data ...");
let config: InstallConfig =
@@ -265,34 +102,42 @@ impl PostHookInfo {
let fqdn = match &answer.global.fqdn {
FqdnConfig::Simple(name) => name.to_string(),
- FqdnConfig::Extended(FqdnExtendedConfig {
+ FqdnConfig::FromDhcp(FqdnFromDhcpConfig {
source: FqdnSourceMode::FromDhcp,
domain,
}) => NetworkOptions::construct_fqdn(
&run_env.network,
- setup_info.config.product.default_hostname(),
+ &setup_info.config.product.to_string(),
domain.as_deref(),
)
.to_string(),
};
- Ok(Self {
- schema: PostHookInfoSchema::default(),
+ Ok(PostHookInfo {
+ schema: PostHookInfoSchema {
+ version: super::POST_HOOK_SCHEMA_VERSION.to_owned(),
+ },
debian_version: read_file("/etc/debian_version")?,
- product: Self::gather_product_info(&setup_info, &run_cmd)?,
- iso: setup_info.iso_info.clone(),
- kernel_version: Self::gather_kernel_version(&run_cmd, &open_file)?,
+ product: gather_product_info(&setup_info, &run_cmd)?,
+ iso: IsoInfo {
+ release: setup_info.iso_info.release,
+ isorelease: setup_info.iso_info.isorelease,
+ },
+ kernel_version: gather_kernel_version(&run_cmd, &open_file)?,
boot_info: BootInfo {
- mode: run_env.boot_type,
+ mode: match run_env.boot_type {
+ proxmox_installer_common::setup::BootType::Bios => BootType::Bios,
+ proxmox_installer_common::setup::BootType::Efi => BootType::Efi,
+ },
secureboot: run_env.secure_boot,
},
- cpu_info: Self::gather_cpu_info(&run_env)?,
- dmi: SystemDMI::get()?,
- filesystem: answer.disks.fs_type,
+ cpu_info: gather_cpu_info(&run_env)?,
+ dmi: proxmox_installer_common::dmi::get()?,
+ filesystem: answer.disks.filesystem_details()?.to_type(),
fqdn,
machine_id: read_file("/etc/machine-id")?,
- disks: Self::gather_disks(&config, &run_env, &udev)?,
- network_interfaces: Self::gather_nic(&config, &run_env, &udev)?,
+ disks: gather_disks(&config, &run_env, &udev)?,
+ network_interfaces: gather_nic(&config, &run_env, &udev)?,
ssh_public_host_keys: SshPublicHostKeys {
ecdsa: read_file("/etc/ssh/ssh_host_ecdsa_key.pub")?,
ed25519: read_file("/etc/ssh/ssh_host_ed25519_key.pub")?,
@@ -335,10 +180,10 @@ impl PostHookInfo {
.target_hd
.as_ref()
.map(|hd| *hd == disk.path)
- .unwrap_or_default();
+ .unwrap_or(false);
anyhow::Ok(DiskInfo {
- size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+ size: (config.hdsize * (SIZE_GIB as f64)) as u64,
is_bootdisk,
udev_properties: get_udev_properties(disk)?,
})
@@ -346,7 +191,7 @@ impl PostHookInfo {
.collect()
} else {
// If the filesystem is not LVM-based (thus Btrfs or ZFS), `config.disk_selection`
- // contains a list of indices identifiying the boot disks, as given by udev.
+ // contains a list of indices identifying the boot disks, as given by udev.
let selected_disks_indices: Vec<&String> = config.disk_selection.values().collect();
run_env
@@ -356,7 +201,7 @@ impl PostHookInfo {
let is_bootdisk = selected_disks_indices.contains(&&disk.index);
anyhow::Ok(DiskInfo {
- size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+ size: (config.hdsize * (SIZE_GIB as f64)) as u64,
is_bootdisk,
udev_properties: get_udev_properties(disk)?,
})
@@ -443,7 +288,12 @@ impl PostHookInfo {
Ok(ProductInfo {
fullname: setup_info.config.fullname.clone(),
- short: setup_info.config.product,
+ short: match setup_info.config.product {
+ ProxmoxProduct::PVE => proxmox_installer_types::ProxmoxProduct::Pve,
+ ProxmoxProduct::PBS => proxmox_installer_types::ProxmoxProduct::Pbs,
+ ProxmoxProduct::PMG => proxmox_installer_types::ProxmoxProduct::Pmg,
+ ProxmoxProduct::PDM => proxmox_installer_types::ProxmoxProduct::Pdm,
+ },
version,
})
}
@@ -465,7 +315,7 @@ impl PostHookInfo {
run_cmd: &dyn Fn(&[&str]) -> Result<String>,
open_file: &dyn Fn(&str) -> Result<File>,
) -> Result<KernelVersionInformation> {
- let file = open_file(&Self::find_kernel_image_path(run_cmd)?)?;
+ let file = open_file(&find_kernel_image_path(run_cmd)?)?;
// Read the 2-byte `kernel_version` field at offset 0x20e [0] from the file ..
// https://www.kernel.org/doc/html/latest/arch/x86/boot.html#the-real-mode-kernel-header
@@ -525,7 +375,7 @@ impl PostHookInfo {
run_cmd: &dyn Fn(&[&str]) -> Result<String>,
_open_file: &dyn Fn(&str) -> Result<File>,
) -> Result<KernelVersionInformation> {
- let image_path = Self::find_kernel_image_path(run_cmd)?;
+ let image_path = find_kernel_image_path(run_cmd)?;
let release = image_path
.strip_prefix("/boot/vmlinuz-")
@@ -556,7 +406,7 @@ impl PostHookInfo {
///
/// * `run_cmd` - Callback to run a command inside the target chroot.
fn find_kernel_image_path(run_cmd: &dyn Fn(&[&str]) -> Result<String>) -> Result<String> {
- let pkg_name = Self::find_kernel_package_name(run_cmd)?;
+ let pkg_name = find_kernel_package_name(run_cmd)?;
let all_files = run_cmd(&["dpkg-query", "--listfiles", &pkg_name])?;
for file in all_files.lines() {
@@ -663,6 +513,200 @@ impl PostHookInfo {
Ok(result)
}
+
+ #[cfg(test)]
+ mod tests {
+ use super::{find_kernel_image_path, find_kernel_package_name};
+
+ #[test]
+ fn finds_correct_kernel_package_name() {
+ let mocked_run_cmd = |cmd: &[&str]| {
+ if cmd[0] == "dpkg" {
+ assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+ Ok("amd64\n".to_owned())
+ } else {
+ assert_eq!(
+ cmd,
+ &[
+ "dpkg-query",
+ "--showformat",
+ "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+ "--show",
+ "proxmox-kernel-[0-9]*",
+ ]
+ );
+ Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+ "#
+ .to_owned())
+ }
+ };
+
+ assert_eq!(
+ find_kernel_package_name(&mocked_run_cmd).unwrap(),
+ "proxmox-kernel-6.8.8-2-pve-signed"
+ );
+ }
+
+ #[test]
+ fn finds_correct_kernel_package_name_arm64() {
+ let mocked_run_cmd = |cmd: &[&str]| {
+ if cmd[0] == "dpkg" {
+ assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+ Ok("arm64\n".to_owned())
+ } else {
+ assert_eq!(
+ cmd,
+ &[
+ "dpkg-query",
+ "--showformat",
+ "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+ "--show",
+ "proxmox-kernel-[0-9]*",
+ ]
+ );
+ Ok(r#"ii |all|proxmox-kernel-6.17
+un ||proxmox-kernel-6.17.2-1-pve
+ii |arm64|proxmox-kernel-6.17.2-1-pve-signed
+ "#
+ .to_owned())
+ }
+ };
+
+ assert_eq!(
+ find_kernel_package_name(&mocked_run_cmd).unwrap(),
+ "proxmox-kernel-6.17.2-1-pve-signed"
+ );
+ }
+
+ #[test]
+ fn find_kernel_package_name_fails_on_wrong_architecture() {
+ let mocked_run_cmd = |cmd: &[&str]| {
+ if cmd[0] == "dpkg" {
+ assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+ Ok("arm64\n".to_owned())
+ } else {
+ assert_eq!(
+ cmd,
+ &[
+ "dpkg-query",
+ "--showformat",
+ "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+ "--show",
+ "proxmox-kernel-[0-9]*",
+ ]
+ );
+ Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+ "#
+ .to_owned())
+ }
+ };
+
+ assert_eq!(
+ find_kernel_package_name(&mocked_run_cmd)
+ .unwrap_err()
+ .to_string(),
+ "failed to find installed kernel package"
+ );
+ }
+
+ #[test]
+ fn find_kernel_package_name_fails_on_missing_package() {
+ let mocked_run_cmd = |cmd: &[&str]| {
+ if cmd[0] == "dpkg" {
+ assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+ Ok("amd64\n".to_owned())
+ } else {
+ assert_eq!(
+ cmd,
+ &[
+ "dpkg-query",
+ "--showformat",
+ "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+ "--show",
+ "proxmox-kernel-[0-9]*",
+ ]
+ );
+ Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ "#
+ .to_owned())
+ }
+ };
+
+ assert_eq!(
+ find_kernel_package_name(&mocked_run_cmd)
+ .unwrap_err()
+ .to_string(),
+ "failed to find installed kernel package"
+ );
+ }
+
+ #[test]
+ fn finds_correct_absolute_kernel_image_path() {
+ let mocked_run_cmd = |cmd: &[&str]| {
+ if cmd[0] == "dpkg" {
+ assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+ Ok("amd64\n".to_owned())
+ } else if cmd[0..=1] == ["dpkg-query", "--showformat"] {
+ assert_eq!(
+ cmd,
+ &[
+ "dpkg-query",
+ "--showformat",
+ "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+ "--show",
+ "proxmox-kernel-[0-9]*",
+ ]
+ );
+ Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+ "#
+ .to_owned())
+ } else {
+ assert_eq!(
+ cmd,
+ [
+ "dpkg-query",
+ "--listfiles",
+ "proxmox-kernel-6.8.8-2-pve-signed"
+ ]
+ );
+ Ok(r#"
+/.
+/boot
+/boot/System.map-6.8.8-2-pve
+/boot/config-6.8.8-2-pve
+/boot/vmlinuz-6.8.8-2-pve
+/lib
+/lib/modules
+/lib/modules/6.8.8-2-pve
+/lib/modules/6.8.8-2-pve/kernel
+/lib/modules/6.8.8-2-pve/kernel/arch
+/lib/modules/6.8.8-2-pve/kernel/arch/x86
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aegis128-aesni.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aesni-intel.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx2-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-gfni-avx512-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/blowfish-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/camellia-aesni-avx-x86_64.ko
+ "#
+ .to_owned())
+ }
+ };
+
+ assert_eq!(
+ find_kernel_image_path(&mocked_run_cmd).unwrap(),
+ "/boot/vmlinuz-6.8.8-2-pve"
+ );
+ }
+ }
}
/// Runs the specified callback with the mounted chroot, passing along the
@@ -700,7 +744,9 @@ fn with_chroot<R, F: FnOnce(&str) -> Result<R>>(callback: F) -> Result<R> {
/// optional certificate fingerprint for HTTPS). If configured, retrieves all relevant information
/// about the installed system and sends them to the given endpoint.
fn do_main() -> Result<()> {
- let answer = Answer::try_from_reader(std::io::stdin().lock())?;
+ let mut raw_toml = String::new();
+ std::io::stdin().read_to_string(&mut raw_toml)?;
+ let answer: AutoInstallerConfig = toml::from_str(&raw_toml)?;
if let Some(PostNotificationHookInfo {
url,
@@ -709,7 +755,7 @@ fn do_main() -> Result<()> {
{
println!("Found post-installation-webhook; sending POST request to '{url}'.");
- let info = with_chroot(|target_path| PostHookInfo::gather(target_path, &answer))?;
+ let info = with_chroot(|target_path| detail::gather(target_path, &answer))?;
if let Err(err) = fs::write(
"/run/proxmox-installer/post-hook-data.json",
@@ -743,197 +789,3 @@ fn main() -> ExitCode {
}
}
}
-
-#[cfg(test)]
-mod tests {
- use crate::PostHookInfo;
-
- #[test]
- fn finds_correct_kernel_package_name() {
- let mocked_run_cmd = |cmd: &[&str]| {
- if cmd[0] == "dpkg" {
- assert_eq!(cmd, &["dpkg", "--print-architecture"]);
- Ok("amd64\n".to_owned())
- } else {
- assert_eq!(
- cmd,
- &[
- "dpkg-query",
- "--showformat",
- "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
- "--show",
- "proxmox-kernel-[0-9]*",
- ]
- );
- Ok(r#"ii |all|proxmox-kernel-6.8
-un ||proxmox-kernel-6.8.8-2-pve
-ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
- "#
- .to_owned())
- }
- };
-
- assert_eq!(
- PostHookInfo::find_kernel_package_name(&mocked_run_cmd).unwrap(),
- "proxmox-kernel-6.8.8-2-pve-signed"
- );
- }
-
- #[test]
- fn finds_correct_kernel_package_name_arm64() {
- let mocked_run_cmd = |cmd: &[&str]| {
- if cmd[0] == "dpkg" {
- assert_eq!(cmd, &["dpkg", "--print-architecture"]);
- Ok("arm64\n".to_owned())
- } else {
- assert_eq!(
- cmd,
- &[
- "dpkg-query",
- "--showformat",
- "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
- "--show",
- "proxmox-kernel-[0-9]*",
- ]
- );
- Ok(r#"ii |all|proxmox-kernel-6.17
-un ||proxmox-kernel-6.17.2-1-pve
-ii |arm64|proxmox-kernel-6.17.2-1-pve-signed
- "#
- .to_owned())
- }
- };
-
- assert_eq!(
- PostHookInfo::find_kernel_package_name(&mocked_run_cmd).unwrap(),
- "proxmox-kernel-6.17.2-1-pve-signed"
- );
- }
-
- #[test]
- fn find_kernel_package_name_fails_on_wrong_architecture() {
- let mocked_run_cmd = |cmd: &[&str]| {
- if cmd[0] == "dpkg" {
- assert_eq!(cmd, &["dpkg", "--print-architecture"]);
- Ok("arm64\n".to_owned())
- } else {
- assert_eq!(
- cmd,
- &[
- "dpkg-query",
- "--showformat",
- "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
- "--show",
- "proxmox-kernel-[0-9]*",
- ]
- );
- Ok(r#"ii |all|proxmox-kernel-6.8
-un ||proxmox-kernel-6.8.8-2-pve
-ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
- "#
- .to_owned())
- }
- };
-
- assert_eq!(
- PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
- .unwrap_err()
- .to_string(),
- "failed to find installed kernel package"
- );
- }
-
- #[test]
- fn find_kernel_package_name_fails_on_missing_package() {
- let mocked_run_cmd = |cmd: &[&str]| {
- if cmd[0] == "dpkg" {
- assert_eq!(cmd, &["dpkg", "--print-architecture"]);
- Ok("amd64\n".to_owned())
- } else {
- assert_eq!(
- cmd,
- &[
- "dpkg-query",
- "--showformat",
- "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
- "--show",
- "proxmox-kernel-[0-9]*",
- ]
- );
- Ok(r#"ii |all|proxmox-kernel-6.8
-un ||proxmox-kernel-6.8.8-2-pve
- "#
- .to_owned())
- }
- };
-
- assert_eq!(
- PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
- .unwrap_err()
- .to_string(),
- "failed to find installed kernel package"
- );
- }
-
- #[test]
- fn finds_correct_absolute_kernel_image_path() {
- let mocked_run_cmd = |cmd: &[&str]| {
- if cmd[0] == "dpkg" {
- assert_eq!(cmd, &["dpkg", "--print-architecture"]);
- Ok("amd64\n".to_owned())
- } else if cmd[0..=1] == ["dpkg-query", "--showformat"] {
- assert_eq!(
- cmd,
- &[
- "dpkg-query",
- "--showformat",
- "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
- "--show",
- "proxmox-kernel-[0-9]*",
- ]
- );
- Ok(r#"ii |all|proxmox-kernel-6.8
-un ||proxmox-kernel-6.8.8-2-pve
-ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
- "#
- .to_owned())
- } else {
- assert_eq!(
- cmd,
- [
- "dpkg-query",
- "--listfiles",
- "proxmox-kernel-6.8.8-2-pve-signed"
- ]
- );
- Ok(r#"
-/.
-/boot
-/boot/System.map-6.8.8-2-pve
-/boot/config-6.8.8-2-pve
-/boot/vmlinuz-6.8.8-2-pve
-/lib
-/lib/modules
-/lib/modules/6.8.8-2-pve
-/lib/modules/6.8.8-2-pve/kernel
-/lib/modules/6.8.8-2-pve/kernel/arch
-/lib/modules/6.8.8-2-pve/kernel/arch/x86
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aegis128-aesni.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aesni-intel.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx-x86_64.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx2-x86_64.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-gfni-avx512-x86_64.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/blowfish-x86_64.ko
-/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/camellia-aesni-avx-x86_64.ko
- "#
- .to_owned())
- }
- };
-
- assert_eq!(
- PostHookInfo::find_kernel_image_path(&mocked_run_cmd).unwrap(),
- "/boot/vmlinuz-6.8.8-2-pve"
- );
- }
-}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (31 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 32/38] post-hook: switch to types in proxmox-installer-types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 34/38] fetch-answer: " Christoph Heiss
` (4 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-auto-install-assistant/src/main.rs | 9 +-
proxmox-auto-installer/src/sysinfo.rs | 106 ++++++++----------
proxmox-fetch-answer/Cargo.toml | 1 +
.../src/fetch_plugins/http.rs | 7 +-
proxmox-installer-common/src/lib.rs | 1 -
proxmox-installer-common/src/sysinfo.rs | 52 ---------
6 files changed, 56 insertions(+), 120 deletions(-)
delete mode 100644 proxmox-installer-common/src/sysinfo.rs
diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
index 901ab81..a92ac75 100644
--- a/proxmox-auto-install-assistant/src/main.rs
+++ b/proxmox-auto-install-assistant/src/main.rs
@@ -19,7 +19,7 @@ use std::{
use proxmox_auto_installer::{
answer::{Answer, FilterMatch},
- sysinfo::SysInfo,
+ sysinfo,
utils::{
AutoInstSettings, FetchAnswerFrom, HttpOptions, default_partition_label,
get_matched_udev_indexes, get_nic_list, get_single_udev_index, verify_disks_settings,
@@ -674,10 +674,9 @@ fn validate_answer(args: &CommandValidateAnswerArgs) -> Result<()> {
}
fn show_system_info(_args: &CommandSystemInfoArgs) -> Result<()> {
- match SysInfo::as_json_pretty() {
- Ok(res) => println!("{res}"),
- Err(err) => eprintln!("Error fetching system info: {err}"),
- }
+ let info = sysinfo::get().context("fetching system info")?;
+ println!("{}", serde_json::to_string_pretty(&info)?);
+
Ok(())
}
diff --git a/proxmox-auto-installer/src/sysinfo.rs b/proxmox-auto-installer/src/sysinfo.rs
index fe3a10d..5129829 100644
--- a/proxmox-auto-installer/src/sysinfo.rs
+++ b/proxmox-auto-installer/src/sysinfo.rs
@@ -1,66 +1,54 @@
use anyhow::{Result, bail};
-use proxmox_installer_common::{
- RUNTIME_DIR,
- setup::{IsoInfo, ProductConfig, SetupInfo},
- sysinfo::SystemDMI,
-};
-use serde::Serialize;
use std::{fs, io, path::PathBuf};
use crate::utils::get_nic_list;
+use proxmox_installer_common::{
+ RUNTIME_DIR,
+ setup::{ProxmoxProduct, SetupInfo},
+};
+use proxmox_installer_types::{NetworkInterface, SystemInfo};
-#[derive(Debug, Serialize)]
-pub struct SysInfo {
- product: ProductConfig,
- iso: IsoInfo,
- dmi: SystemDMI,
- network_interfaces: Vec<NetdevWithMac>,
-}
-
-impl SysInfo {
- pub fn get() -> Result<Self> {
- let path = PathBuf::from(RUNTIME_DIR).join("iso-info.json").to_owned();
- let setup_info: SetupInfo = match fs::File::open(path) {
- Ok(iso_info_file) => {
- let reader = io::BufReader::new(iso_info_file);
- serde_json::from_reader(reader)?
- }
- Err(err) if err.kind() == io::ErrorKind::NotFound => SetupInfo::mocked(),
- Err(err) => bail!("failed to open iso-info.json - {err}"),
- };
-
- Ok(Self {
- product: setup_info.config,
- iso: setup_info.iso_info,
- network_interfaces: NetdevWithMac::get_all()?,
- dmi: SystemDMI::get()?,
- })
- }
-
- pub fn as_json_pretty() -> Result<String> {
- let info = Self::get()?;
- Ok(serde_json::to_string_pretty(&info)?)
- }
-}
-
-#[derive(Debug, Serialize)]
-struct NetdevWithMac {
- /// The network link name
- pub link: String,
- /// The MAC address of the network device
- pub mac: String,
-}
-
-impl NetdevWithMac {
- fn get_all() -> Result<Vec<Self>> {
- let mut result: Vec<Self> = Vec::new();
-
- let links = get_nic_list()?;
- for link in links {
- let mac = fs::read_to_string(format!("/sys/class/net/{link}/address"))?;
- let mac = String::from(mac.trim());
- result.push(Self { link, mac });
+pub fn get() -> Result<SystemInfo> {
+ let path = PathBuf::from(RUNTIME_DIR).join("iso-info.json").to_owned();
+ let setup_info: SetupInfo = match fs::File::open(path) {
+ Ok(iso_info_file) => {
+ let reader = io::BufReader::new(iso_info_file);
+ serde_json::from_reader(reader)?
}
- Ok(result)
- }
+ Err(err) if err.kind() == io::ErrorKind::NotFound => SetupInfo::mocked(),
+ Err(err) => bail!("failed to open iso-info.json - {err}"),
+ };
+
+ Ok(SystemInfo {
+ product: proxmox_installer_types::ProductConfig {
+ fullname: setup_info.config.fullname,
+ product: match setup_info.config.product {
+ ProxmoxProduct::PVE => proxmox_installer_types::ProxmoxProduct::Pve,
+ ProxmoxProduct::PBS => proxmox_installer_types::ProxmoxProduct::Pbs,
+ ProxmoxProduct::PMG => proxmox_installer_types::ProxmoxProduct::Pmg,
+ ProxmoxProduct::PDM => proxmox_installer_types::ProxmoxProduct::Pdm,
+ },
+ enable_btrfs: setup_info.config.enable_btrfs,
+ },
+ iso: proxmox_installer_types::IsoInfo {
+ release: setup_info.iso_info.release,
+ isorelease: setup_info.iso_info.isorelease,
+ },
+ network_interfaces: get_all_network_interfaces()?,
+ dmi: proxmox_installer_common::dmi::get()?,
+ })
+}
+
+fn get_all_network_interfaces() -> Result<Vec<NetworkInterface>> {
+ let mut result: Vec<NetworkInterface> = Vec::new();
+
+ let links = get_nic_list()?;
+ for link in links {
+ let mac = fs::read_to_string(format!("/sys/class/net/{link}/address"))?;
+ result.push(NetworkInterface {
+ link,
+ mac: mac.trim().parse()?,
+ });
+ }
+ Ok(result)
}
diff --git a/proxmox-fetch-answer/Cargo.toml b/proxmox-fetch-answer/Cargo.toml
index d779ad4..2d3b149 100644
--- a/proxmox-fetch-answer/Cargo.toml
+++ b/proxmox-fetch-answer/Cargo.toml
@@ -15,6 +15,7 @@ anyhow.workspace = true
log.workspace = true
proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
+proxmox-installer-types.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
toml.workspace = true
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index b958a35..fceabc2 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -6,8 +6,9 @@ use std::{
process::Command,
};
-use proxmox_auto_installer::{sysinfo::SysInfo, utils::HttpOptions};
+use proxmox_auto_installer::{sysinfo, utils::HttpOptions};
use proxmox_installer_common::http::{self, header::HeaderMap};
+use proxmox_installer_types::SystemInfo;
static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
@@ -70,7 +71,7 @@ struct HttpFetchPayload {
schema: HttpFetchInfoSchema,
/// Information about the running system, flattened into this structure directly.
#[serde(flatten)]
- sysinfo: SysInfo,
+ sysinfo: SystemInfo,
}
impl HttpFetchPayload {
@@ -79,7 +80,7 @@ impl HttpFetchPayload {
fn get() -> Result<Self> {
Ok(Self {
schema: HttpFetchInfoSchema::default(),
- sysinfo: SysInfo::get()?,
+ sysinfo: sysinfo::get()?,
})
}
diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs
index 05445d5..fde17b7 100644
--- a/proxmox-installer-common/src/lib.rs
+++ b/proxmox-installer-common/src/lib.rs
@@ -2,7 +2,6 @@ pub mod disk_checks;
pub mod dmi;
pub mod options;
pub mod setup;
-pub mod sysinfo;
#[cfg(feature = "http")]
pub mod http;
diff --git a/proxmox-installer-common/src/sysinfo.rs b/proxmox-installer-common/src/sysinfo.rs
deleted file mode 100644
index 05e6de6..0000000
--- a/proxmox-installer-common/src/sysinfo.rs
+++ /dev/null
@@ -1,52 +0,0 @@
-use std::{collections::HashMap, fs};
-
-use anyhow::{Result, bail};
-use serde::Serialize;
-
-const DMI_PATH: &str = "/sys/devices/virtual/dmi/id";
-
-#[derive(Debug, Serialize)]
-pub struct SystemDMI {
- system: HashMap<String, String>,
- baseboard: HashMap<String, String>,
- chassis: HashMap<String, String>,
-}
-
-impl SystemDMI {
- pub fn get() -> Result<Self> {
- let system_files = [
- "product_serial",
- "product_sku",
- "product_uuid",
- "product_name",
- ];
- let baseboard_files = ["board_asset_tag", "board_serial", "board_name"];
- let chassis_files = ["chassis_serial", "chassis_sku", "chassis_asset_tag"];
-
- Ok(Self {
- system: Self::get_dmi_infos(&system_files)?,
- baseboard: Self::get_dmi_infos(&baseboard_files)?,
- chassis: Self::get_dmi_infos(&chassis_files)?,
- })
- }
-
- fn get_dmi_infos(files: &[&str]) -> Result<HashMap<String, String>> {
- let mut res: HashMap<String, String> = HashMap::new();
-
- for file in files {
- let path = format!("{DMI_PATH}/{file}");
- let content = match fs::read_to_string(&path) {
- Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => continue,
- Err(ref err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
- bail!("Could not read data. Are you running as root or with sudo?")
- }
- Err(err) => bail!("Error: '{err}' on '{path}'"),
- Ok(content) => content.trim().into(),
- };
- let key = file.splitn(2, '_').last().unwrap();
- res.insert(key.into(), content);
- }
-
- Ok(res)
- }
-}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 34/38] fetch-answer: switch to types from proxmox-installer-types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (32 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 35/38] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
` (3 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-fetch-answer/Cargo.toml | 1 -
.../src/fetch_plugins/http.rs | 72 ++-----------------
2 files changed, 7 insertions(+), 66 deletions(-)
diff --git a/proxmox-fetch-answer/Cargo.toml b/proxmox-fetch-answer/Cargo.toml
index 2d3b149..93c11bb 100644
--- a/proxmox-fetch-answer/Cargo.toml
+++ b/proxmox-fetch-answer/Cargo.toml
@@ -16,6 +16,5 @@ log.workspace = true
proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = ["http"] }
proxmox-installer-types.workspace = true
-serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
toml.workspace = true
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index fceabc2..9e5a87a 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -1,14 +1,14 @@
use anyhow::{Result, bail};
use log::info;
-use serde::Serialize;
use std::{
fs::{self, read_to_string},
process::Command,
};
use proxmox_auto_installer::{sysinfo, utils::HttpOptions};
-use proxmox_installer_common::http::{self, header::HeaderMap};
-use proxmox_installer_types::SystemInfo;
+use proxmox_installer_common::http::{self, header::HeaderMap,
+};
+use proxmox_installer_types::answer::fetch::{AnswerFetchData, AnswerFetchDataSchema};
static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
@@ -31,67 +31,6 @@ static DHCP_URL_OPTION: &str = "proxmox-auto-installer-manifest-url";
static DHCP_CERT_FP_OPTION: &str = "proxmox-auto-installer-cert-fingerprint";
static DHCP_LEASE_FILE: &str = "/var/lib/dhcp/dhclient.leases";
-/// Metadata of the HTTP POST payload, such as schema version of the document.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-struct HttpFetchInfoSchema {
- /// 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.
- version: String,
-}
-
-impl HttpFetchInfoSchema {
- const SCHEMA_VERSION: &str = "1.0";
-}
-
-impl Default for HttpFetchInfoSchema {
- fn default() -> Self {
- Self {
- version: Self::SCHEMA_VERSION.to_owned(),
- }
- }
-}
-
-/// All data sent as request payload with the answerfile fetch 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.
-#[derive(Serialize)]
-#[serde(rename_all = "kebab-case")]
-struct HttpFetchPayload {
- /// Metadata for the answerfile 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")]
- schema: HttpFetchInfoSchema,
- /// Information about the running system, flattened into this structure directly.
- #[serde(flatten)]
- sysinfo: SystemInfo,
-}
-
-impl HttpFetchPayload {
- /// Retrieves the required information from the system and constructs the
- /// full payload including meta data.
- fn get() -> Result<Self> {
- Ok(Self {
- schema: HttpFetchInfoSchema::default(),
- sysinfo: sysinfo::get()?,
- })
- }
-
- /// Retrieves the required information from the system and constructs the
- /// full payload including meta data, serialized as JSON.
- pub fn as_json() -> Result<String> {
- let info = Self::get()?;
- Ok(serde_json::to_string(&info)?)
- }
-}
-
pub struct FetchFromHTTP;
impl FetchFromHTTP {
@@ -129,7 +68,10 @@ impl FetchFromHTTP {
}
info!("Gathering system information.");
- let payload = HttpFetchPayload::as_json()?;
+ let payload = serde_json::to_string(&AnswerFetchData {
+ schema: AnswerFetchDataSchema::default(),
+ sysinfo: sysinfo::get()?,
+ })?;
info!("Sending POST request to '{answer_url}'.");
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 35/38] fetch-answer: http: prefer json over toml for answer format
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (33 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 34/38] fetch-answer: " Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
` (2 subsequent siblings)
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
.../src/fetch_plugins/http.rs | 43 +++++++++++++++----
1 file changed, 34 insertions(+), 9 deletions(-)
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index 9e5a87a..6508721 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -6,9 +6,14 @@ use std::{
};
use proxmox_auto_installer::{sysinfo, utils::HttpOptions};
-use proxmox_installer_common::http::{self, header::HeaderMap,
+use proxmox_installer_common::http::{
+ self,
+ header::{HeaderMap, HeaderValue},
+};
+use proxmox_installer_types::answer::{
+ AutoInstallerConfig,
+ fetch::{AnswerFetchData, AnswerFetchDataSchema},
};
-use proxmox_installer_types::answer::fetch::{AnswerFetchData, AnswerFetchDataSchema};
static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
@@ -40,6 +45,14 @@ impl FetchFromHTTP {
/// needs to be either trusted by the root certs or a SHA256 fingerprint needs to be provided.
/// The SHA256 SSL fingerprint can either be defined in the ISO, as DHCP option, or as DNS TXT
/// record. If provided, the fingerprint provided in the ISO has preference.
+ ///
+ /// # Parameters
+ ///
+ /// * `settings` - HTTP fetch options from the baked-in auto-installer configuration.
+ ///
+ /// # Returns
+ ///
+ /// The TOML-formatted answer retrieved from the given server.
pub fn get_answer(settings: &HttpOptions) -> Result<String> {
let mut fingerprint: Option<String> = match settings.cert_fingerprint.clone() {
Some(fp) => {
@@ -74,14 +87,26 @@ impl FetchFromHTTP {
})?;
info!("Sending POST request to '{answer_url}'.");
+ let mut headers = HeaderMap::new();
- Ok(http::post(
- &answer_url,
- fingerprint.as_deref(),
- HeaderMap::new(),
- payload,
- )?
- .0)
+ // Prefer JSON answers over TOML. The TOML path might be deprecated in the future.
+ headers.insert(
+ http::header::ACCEPT,
+ HeaderValue::from_str("application/json, application/toml;q=0.5")?,
+ );
+
+ let (body, content_type) =
+ http::post(&answer_url, fingerprint.as_deref(), headers, payload)?;
+
+ if let Some(ct) = content_type
+ && ct.starts_with("application/json")
+ {
+ // do a round-trip with serde into TOML, if we received JSON from the server
+ let answer: AutoInstallerConfig = serde_json::from_str(&body)?;
+ Ok(toml::to_string(&answer)?)
+ } else {
+ Ok(body)
+ }
}
/// Fetches search domain from resolv.conf file
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (34 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 35/38] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 37/38] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 38/38] auto: drop now-dead answer file definitions Christoph Heiss
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
If an authorization token is present in the internal auto-installer
HTTP configuration, add it as
Authorization: ProxmoxInstallerToken <token>
header to the POST HTTP request when retrieving the answer.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
.../src/fetch_plugins/http.rs | 8 +++++++
proxmox-fetch-answer/src/main.rs | 22 ++++++++++++-------
2 files changed, 22 insertions(+), 8 deletions(-)
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index 6508721..121b620 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -95,6 +95,14 @@ impl FetchFromHTTP {
HeaderValue::from_str("application/json, application/toml;q=0.5")?,
);
+ if let Some(token) = &settings.token {
+ info!("Authentication token provided through ISO.");
+ headers.insert(
+ http::header::AUTHORIZATION,
+ HeaderValue::from_str(&format!("ProxmoxInstallerToken {token}"))?,
+ );
+ }
+
let (body, content_type) =
http::post(&answer_url, fingerprint.as_deref(), headers, payload)?;
diff --git a/proxmox-fetch-answer/src/main.rs b/proxmox-fetch-answer/src/main.rs
index 18b27e7..2e399d1 100644
--- a/proxmox-fetch-answer/src/main.rs
+++ b/proxmox-fetch-answer/src/main.rs
@@ -23,8 +23,13 @@ const CLI_USAGE_HELPTEXT: &str = concat!(
Commands:
iso Fetch the builtin answer file from the ISO
+
http Fetch the answer file via HTTP(S)
- Additional parameters: [<http-url>] [<tls-cert-fingerprint>]
+ Additional parameters: [<http-url>] [<tls-cert-fingerprint>] [<auth-token>]
+
+ To provide an authentication token without a certificate fingerprint, pass an
+ empty string to <tls-cert-fingerprint>.
+
partition Fetch the answer file from a mountable partition
Additional parameters: [<partition-label>]
@@ -47,18 +52,18 @@ fn fetch_answer(install_settings: &AutoInstSettings) -> Result<String> {
let answer_path = PathBuf::from("/cdrom/answer.toml");
match fs::read_to_string(answer_path) {
Ok(answer) => return Ok(answer),
- Err(err) => info!("Fetching answer file from ISO failed: {err}"),
+ Err(err) => info!("Fetching answer file from ISO failed: {err:#}"),
}
}
FetchAnswerFrom::Partition => {
match FetchFromPartition::get_answer(&install_settings.partition_label) {
Ok(answer) => return Ok(answer),
- Err(err) => info!("Fetching answer file from partition failed: {err}"),
+ Err(err) => info!("Fetching answer file from partition failed: {err:#}"),
}
}
FetchAnswerFrom::Http => match FetchFromHTTP::get_answer(&install_settings.http) {
Ok(answer) => return Ok(answer),
- Err(err) => info!("Fetching answer file via HTTP failed: {err}"),
+ Err(err) => info!("Fetching answer file via HTTP failed: {err:#}"),
},
}
bail!("Could not find any answer file!");
@@ -80,8 +85,8 @@ fn settings_from_cli_args(args: &[String]) -> Result<AutoInstSettings> {
FetchAnswerFrom::Iso if args.len() > 2 => {
bail!("'iso' mode does not take any additional arguments")
}
- FetchAnswerFrom::Http if args.len() > 4 => {
- bail!("'http' mode takes at most 2 additional arguments")
+ FetchAnswerFrom::Http if args.len() > 5 => {
+ bail!("'http' mode takes at most 3 additional arguments")
}
FetchAnswerFrom::Partition if args.len() > 3 => {
bail!("'partition' mode takes at most 1 additional argument")
@@ -97,8 +102,9 @@ fn settings_from_cli_args(args: &[String]) -> Result<AutoInstSettings> {
.cloned()?,
http: HttpOptions {
url: args.get(2).cloned(),
- cert_fingerprint: args.get(3).cloned(),
- token: None,
+ // treat empty value as not existing
+ cert_fingerprint: args.get(3).cloned().filter(|s| !s.is_empty()),
+ token: args.get(4).cloned(),
},
})
}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 37/38] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (35 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 38/38] auto: drop now-dead answer file definitions Christoph Heiss
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
The new `AutoInstallerConfig` type comes from proxmox-installer-types
and wholly replaces `Answer`.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-auto-install-assistant/Cargo.toml | 1 +
proxmox-auto-install-assistant/src/main.rs | 8 +-
proxmox-auto-installer/src/answer.rs | 7 +-
.../src/bin/proxmox-auto-installer.rs | 20 +--
proxmox-auto-installer/src/sysinfo.rs | 21 +--
proxmox-auto-installer/src/utils.rs | 142 ++++++++++--------
proxmox-auto-installer/tests/parse-answer.rs | 6 +-
.../tests/resources/iso-info.json | 4 +-
...rface_pinning_overlong_interface_name.json | 2 +-
proxmox-installer-common/src/disk_checks.rs | 5 +-
proxmox-installer-common/src/lib.rs | 3 -
proxmox-installer-common/src/options.rs | 119 +++++----------
proxmox-installer-common/src/setup.rs | 87 +----------
proxmox-post-hook/src/main.rs | 31 ++--
proxmox-tui-installer/src/main.rs | 12 +-
proxmox-tui-installer/src/options.rs | 2 +-
proxmox-tui-installer/src/views/bootdisk.rs | 18 ++-
17 files changed, 183 insertions(+), 305 deletions(-)
diff --git a/proxmox-auto-install-assistant/Cargo.toml b/proxmox-auto-install-assistant/Cargo.toml
index 9a61fb5..61253be 100644
--- a/proxmox-auto-install-assistant/Cargo.toml
+++ b/proxmox-auto-install-assistant/Cargo.toml
@@ -14,6 +14,7 @@ homepage = "https://www.proxmox.com"
anyhow.workspace = true
proxmox-auto-installer.workspace = true
proxmox-installer-common = { workspace = true, features = [ "cli" ] }
+proxmox-installer-types.workspace = true
serde_json.workspace = true
toml.workspace = true
diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
index a92ac75..ee12c1e 100644
--- a/proxmox-auto-install-assistant/src/main.rs
+++ b/proxmox-auto-install-assistant/src/main.rs
@@ -18,7 +18,6 @@ use std::{
};
use proxmox_auto_installer::{
- answer::{Answer, FilterMatch},
sysinfo,
utils::{
AutoInstSettings, FetchAnswerFrom, HttpOptions, default_partition_label,
@@ -28,6 +27,7 @@ use proxmox_auto_installer::{
},
};
use proxmox_installer_common::{FIRST_BOOT_EXEC_MAX_SIZE, FIRST_BOOT_EXEC_NAME, cli};
+use proxmox_installer_types::answer::{AutoInstallerConfig, FilterMatch};
static PROXMOX_ISO_FLAG: &str = "/auto-installer-capable";
@@ -95,7 +95,7 @@ impl cli::Subcommand for CommandDeviceMatchArgs {
fn parse(args: &mut cli::Arguments) -> Result<Self> {
let filter_match = args
.opt_value_from_str("--filter-match")?
- .unwrap_or(FilterMatch::Any);
+ .unwrap_or_default();
let device_type = args.free_from_str().context("parsing device type")?;
let mut filter = vec![];
@@ -630,7 +630,7 @@ fn validate_answer_file_keys(path: impl AsRef<Path> + fmt::Debug) -> Result<bool
}
}
-fn verify_hashed_password_interactive(answer: &Answer) -> Result<()> {
+fn verify_hashed_password_interactive(answer: &AutoInstallerConfig) -> Result<()> {
if let Some(hashed) = &answer.global.root_password_hashed {
println!("Verifying hashed root password.");
@@ -1313,7 +1313,7 @@ fn get_udev_properties(path: impl AsRef<Path> + fmt::Debug) -> Result<String> {
Ok(String::from_utf8(udev_output.stdout)?)
}
-fn parse_answer(path: impl AsRef<Path> + fmt::Debug) -> Result<Answer> {
+fn parse_answer(path: impl AsRef<Path> + fmt::Debug) -> Result<AutoInstallerConfig> {
let mut file = match fs::File::open(&path) {
Ok(file) => file,
Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index eec5b58..c7e7298 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -6,10 +6,11 @@ use std::{
net::IpAddr,
};
-use proxmox_installer_common::options::{
- BtrfsCompressOption, NetworkInterfacePinningOptions, ZfsChecksumOption, ZfsCompressOption,
+use proxmox_installer_common::options::NetworkInterfacePinningOptions;
+use proxmox_installer_types::answer::{
+ BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, ZfsChecksumOption, ZfsCompressOption,
+ ZfsRaidLevel,
};
-use proxmox_installer_types::answer::{BtrfsRaidLevel, FilesystemType, ZfsRaidLevel};
use proxmox_network_types::{Cidr, fqdn::Fqdn};
// NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards
diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
index 7614fbb..0ced7d4 100644
--- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
+++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
@@ -3,11 +3,12 @@ use log::{LevelFilter, error, info};
use std::{
env,
fs::{self, File},
- io::{BufRead, BufReader, Write},
+ io::{BufRead, BufReader, Read, Write},
path::PathBuf,
process::ExitCode,
};
+use proxmox_auto_installer::{log::AutoInstLogger, utils::parse_answer};
use proxmox_installer_common::{
FIRST_BOOT_EXEC_MAX_SIZE, FIRST_BOOT_EXEC_NAME, RUNTIME_DIR, http,
setup::{
@@ -15,12 +16,9 @@ use proxmox_installer_common::{
spawn_low_level_installer,
},
};
-
-use proxmox_auto_installer::{
- answer::{Answer, FirstBootHookInfo, FirstBootHookSourceMode, RebootMode},
- log::AutoInstLogger,
- udevinfo::UdevInfo,
- utils::parse_answer,
+use proxmox_installer_types::{
+ UdevInfo,
+ answer::{AutoInstallerConfig, FirstBootHookInfo, FirstBootHookSourceMode, RebootMode},
};
static LOGGER: AutoInstLogger = AutoInstLogger;
@@ -70,7 +68,7 @@ fn setup_first_boot_executable(first_boot: &FirstBootHookInfo) -> Result<()> {
}
}
-fn auto_installer_setup(in_test_mode: bool) -> Result<(Answer, UdevInfo)> {
+fn auto_installer_setup(in_test_mode: bool) -> Result<(AutoInstallerConfig, UdevInfo)> {
let base_path = if in_test_mode { "./testdir" } else { "/" };
let mut path = PathBuf::from(base_path);
@@ -85,7 +83,9 @@ fn auto_installer_setup(in_test_mode: bool) -> Result<(Answer, UdevInfo)> {
.map_err(|err| format_err!("Failed to retrieve udev info details: {err}"))?
};
- let answer = Answer::try_from_reader(std::io::stdin().lock())?;
+ let mut raw_toml = String::new();
+ std::io::stdin().read_to_string(&mut raw_toml)?;
+ let answer: AutoInstallerConfig = toml::from_str(&raw_toml)?;
if let Some(first_boot) = &answer.first_boot {
setup_first_boot_executable(first_boot)?;
@@ -151,7 +151,7 @@ fn main() -> ExitCode {
}
fn run_installation(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
locales: &LocaleInfo,
runtime_info: &RuntimeInfo,
udevadm_info: &UdevInfo,
diff --git a/proxmox-auto-installer/src/sysinfo.rs b/proxmox-auto-installer/src/sysinfo.rs
index 5129829..38f419f 100644
--- a/proxmox-auto-installer/src/sysinfo.rs
+++ b/proxmox-auto-installer/src/sysinfo.rs
@@ -2,10 +2,7 @@ use anyhow::{Result, bail};
use std::{fs, io, path::PathBuf};
use crate::utils::get_nic_list;
-use proxmox_installer_common::{
- RUNTIME_DIR,
- setup::{ProxmoxProduct, SetupInfo},
-};
+use proxmox_installer_common::{RUNTIME_DIR, setup::SetupInfo};
use proxmox_installer_types::{NetworkInterface, SystemInfo};
pub fn get() -> Result<SystemInfo> {
@@ -20,20 +17,8 @@ pub fn get() -> Result<SystemInfo> {
};
Ok(SystemInfo {
- product: proxmox_installer_types::ProductConfig {
- fullname: setup_info.config.fullname,
- product: match setup_info.config.product {
- ProxmoxProduct::PVE => proxmox_installer_types::ProxmoxProduct::Pve,
- ProxmoxProduct::PBS => proxmox_installer_types::ProxmoxProduct::Pbs,
- ProxmoxProduct::PMG => proxmox_installer_types::ProxmoxProduct::Pmg,
- ProxmoxProduct::PDM => proxmox_installer_types::ProxmoxProduct::Pdm,
- },
- enable_btrfs: setup_info.config.enable_btrfs,
- },
- iso: proxmox_installer_types::IsoInfo {
- release: setup_info.iso_info.release,
- isorelease: setup_info.iso_info.isorelease,
- },
+ product: setup_info.config,
+ iso: setup_info.iso_info,
network_interfaces: get_all_network_interfaces()?,
dmi: proxmox_installer_common::dmi::get()?,
})
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 83be913..8173ee8 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -6,33 +6,40 @@ use std::{
process::Command,
};
-use crate::{
+use proxmox_installer_types::{
+ UdevInfo,
answer::{
- self, Answer, DiskSelection, FirstBootHookSourceMode, FqdnConfig, FqdnExtendedConfig,
- FqdnSourceMode, Network,
+ AutoInstallerConfig, DiskSelection, Filesystem, FilesystemOptions, FilesystemType,
+ FilterMatch, FirstBootHookSourceMode, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode,
+ NetworkConfig,
},
- udevinfo::UdevInfo,
};
+
use proxmox_installer_common::{
ROOT_PASSWORD_MIN_LENGTH,
disk_checks::check_swapsize,
- options::{NetworkOptions, RaidLevel, ZfsChecksumOption, ZfsCompressOption, email_validate},
+ options::{FilesystemDiskInfo, NetworkInterfacePinningOptions, NetworkOptions, email_validate},
setup::{
InstallBtrfsOption, InstallConfig, InstallFirstBootSetup, InstallRootPassword,
InstallZfsOption, LocaleInfo, RuntimeInfo, SetupInfo,
},
};
-use proxmox_installer_types::answer::FilesystemType;
+
use serde::{Deserialize, Serialize};
fn get_network_settings(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
udev_info: &UdevInfo,
runtime_info: &RuntimeInfo,
setup_info: &SetupInfo,
) -> Result<NetworkOptions> {
info!("Setting up network configuration");
+ let pinning_opts = answer
+ .network
+ .interface_name_pinning()
+ .map(|answer| answer.into());
+
let mut network_options = match &answer.global.fqdn {
// If the user set a static FQDN in the answer file, override it
FqdnConfig::Simple(name) => {
@@ -40,12 +47,12 @@ fn get_network_settings(
setup_info,
&runtime_info.network,
None,
- answer.network.interface_name_pinning.as_ref(),
+ pinning_opts.as_ref(),
);
opts.fqdn = name.to_owned();
opts
}
- FqdnConfig::Extended(FqdnExtendedConfig {
+ FqdnConfig::FromDhcp(FqdnFromDhcpConfig {
source: FqdnSourceMode::FromDhcp,
domain,
}) => {
@@ -68,12 +75,12 @@ fn get_network_settings(
setup_info,
&runtime_info.network,
domain.as_deref(),
- answer.network.interface_name_pinning.as_ref(),
+ pinning_opts.as_ref(),
)
}
};
- if let answer::NetworkSettings::Manual(settings) = &answer.network.network_settings {
+ if let NetworkConfig::FromAnswer(settings) = &answer.network {
network_options.address = settings.cidr;
network_options.dns_server = settings.dns;
network_options.gateway = settings.gateway;
@@ -206,7 +213,7 @@ pub fn get_matched_udev_indexes(
}
fn set_disks(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
udev_info: &UdevInfo,
runtime_info: &RuntimeInfo,
config: &mut InstallConfig,
@@ -222,13 +229,13 @@ fn set_disks(
}
fn set_single_disk(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
udev_info: &UdevInfo,
runtime_info: &RuntimeInfo,
config: &mut InstallConfig,
) -> Result<()> {
- match &answer.disks.disk_selection {
- answer::DiskSelection::Selection(disk_list) => {
+ match answer.disks.disk_selection()? {
+ DiskSelection::Selection(disk_list) => {
let disk_name = disk_list[0].clone();
let disk = runtime_info
.disks
@@ -239,8 +246,8 @@ fn set_single_disk(
None => bail!("disk in 'disk-selection' not found"),
}
}
- answer::DiskSelection::Filter(filter) => {
- let disk_index = get_single_udev_index(filter, &udev_info.disks)?;
+ DiskSelection::Filter(filter) => {
+ let disk_index = get_single_udev_index(&filter, &udev_info.disks)?;
let disk = runtime_info
.disks
.iter()
@@ -253,13 +260,13 @@ fn set_single_disk(
}
fn set_selected_disks(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
udev_info: &UdevInfo,
runtime_info: &RuntimeInfo,
config: &mut InstallConfig,
) -> Result<()> {
- match &answer.disks.disk_selection {
- answer::DiskSelection::Selection(disk_list) => {
+ match answer.disks.disk_selection()? {
+ DiskSelection::Selection(disk_list) => {
info!("Disk selection found");
for disk_name in disk_list.clone() {
let disk = runtime_info
@@ -273,17 +280,13 @@ fn set_selected_disks(
}
}
}
- answer::DiskSelection::Filter(filter) => {
+ DiskSelection::Filter(filter) => {
info!("No disk list found, looking for disk filters");
- let filter_match = answer
- .disks
- .filter_match
- .clone()
- .unwrap_or(answer::FilterMatch::Any);
+ let filter_match = answer.disks.filter_match.unwrap_or_default();
let selected_disk_indexes = get_matched_udev_indexes(
- filter,
+ &filter,
&udev_info.disks,
- filter_match == answer::FilterMatch::All,
+ filter_match == FilterMatch::All,
)?;
for i in selected_disk_indexes.into_iter() {
@@ -336,19 +339,23 @@ fn get_first_selected_disk(config: &InstallConfig) -> usize {
.expect("could not parse key to usize")
}
-fn verify_filesystem_settings(answer: &Answer, setup_info: &SetupInfo) -> Result<()> {
+fn verify_filesystem_settings(
+ answer: &AutoInstallerConfig,
+ setup_info: &SetupInfo,
+) -> Result<FilesystemOptions> {
info!("Verifying filesystem settings");
- if answer.disks.fs_type.is_btrfs() && !setup_info.config.enable_btrfs {
+ let fs_options = answer.disks.filesystem_details()?;
+ if answer.disks.filesystem == Filesystem::Btrfs && !setup_info.config.enable_btrfs {
bail!(
"BTRFS is not supported as a root filesystem for the product or the release of this ISO."
);
}
- Ok(())
+ Ok(fs_options)
}
-pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Result<()> {
+pub fn verify_locale_settings(answer: &AutoInstallerConfig, locales: &LocaleInfo) -> Result<()> {
info!("Verifying locale settings");
if !locales
.countries
@@ -385,7 +392,7 @@ pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Result<(
///
/// Ensures that the provided email-address is of valid format and that one
/// of the two root password options is set appropriately.
-pub fn verify_email_and_root_password_settings(answer: &Answer) -> Result<()> {
+pub fn verify_email_and_root_password_settings(answer: &AutoInstallerConfig) -> Result<()> {
info!("Verifying email and root password settings");
email_validate(&answer.global.mailto).with_context(|| answer.global.mailto.clone())?;
@@ -411,40 +418,41 @@ pub fn verify_email_and_root_password_settings(answer: &Answer) -> Result<()> {
}
}
-pub fn verify_disks_settings(answer: &Answer) -> Result<()> {
- if let DiskSelection::Selection(selection) = &answer.disks.disk_selection {
- let min_disks = match answer.disks.fs_type {
- FilesystemType::Ext4 | FilesystemType::Xfs => 1,
- FilesystemType::Zfs(level) => level.get_min_disks(),
- FilesystemType::Btrfs(level) => level.get_min_disks(),
- };
+pub fn verify_disks_settings(answer: &AutoInstallerConfig) -> Result<()> {
+ let fs_options = answer.disks.filesystem_details()?;
+
+ if let DiskSelection::Selection(selection) = answer.disks.disk_selection()? {
+ let min_disks = fs_options.to_type().get_min_disks();
if selection.len() < min_disks {
bail!(
"{}: need at least {} disks",
- answer.disks.fs_type,
+ fs_options.to_type(),
min_disks
);
}
let mut disk_set = HashSet::new();
- for disk in selection {
+ for disk in &selection {
if !disk_set.insert(disk) {
bail!("List of disks contains duplicate device {disk}");
}
}
}
- if let answer::FsOptions::LVM(lvm) = &answer.disks.fs_options
- && let Some((swapsize, hdsize)) = lvm.swapsize.zip(lvm.hdsize)
- {
- check_swapsize(swapsize, hdsize)?;
+ match fs_options {
+ FilesystemOptions::Ext4(lvm) | FilesystemOptions::Xfs(lvm) => {
+ if let Some((swapsize, hdsize)) = lvm.swapsize.zip(lvm.hdsize) {
+ check_swapsize(swapsize, hdsize)?;
+ }
+ }
+ _ => {}
}
Ok(())
}
-pub fn verify_first_boot_settings(answer: &Answer) -> Result<()> {
+pub fn verify_first_boot_settings(answer: &AutoInstallerConfig) -> Result<()> {
info!("Verifying first boot settings");
if let Some(first_boot) = &answer.first_boot
@@ -457,10 +465,16 @@ pub fn verify_first_boot_settings(answer: &Answer) -> Result<()> {
Ok(())
}
-pub fn verify_network_settings(network: &Network, run_env: Option<&RuntimeInfo>) -> Result<()> {
+pub fn verify_network_settings(
+ network: &NetworkConfig,
+ run_env: Option<&RuntimeInfo>,
+) -> Result<()> {
info!("Verifying network settings");
- if let Some(pin_opts) = &network.interface_name_pinning {
+ let pin_opts: Option<NetworkInterfacePinningOptions> =
+ network.interface_name_pinning().map(|v| v.into());
+
+ if let Some(pin_opts) = pin_opts {
pin_opts.verify()?;
if let Some(run_env) = run_env {
@@ -483,7 +497,7 @@ pub fn verify_network_settings(network: &Network, run_env: Option<&RuntimeInfo>)
}
pub fn parse_answer(
- answer: &Answer,
+ answer: &AutoInstallerConfig,
udev_info: &UdevInfo,
runtime_info: &RuntimeInfo,
locales: &LocaleInfo,
@@ -491,11 +505,10 @@ pub fn parse_answer(
) -> Result<InstallConfig> {
info!("Parsing answer file");
- verify_filesystem_settings(answer, setup_info)?;
+ let fs_options = verify_filesystem_settings(answer, setup_info)?;
info!("Setting File system");
- let filesystem = answer.disks.fs_type;
- info!("File system selected: {}", filesystem);
+ info!("File system selected: {}", fs_options.to_type());
let network_settings = get_network_settings(answer, udev_info, runtime_info, setup_info)?;
@@ -517,7 +530,7 @@ pub fn parse_answer(
let mut config = InstallConfig {
autoreboot: 1_usize,
- filesys: filesystem,
+ filesys: fs_options.to_type(),
hdsize: 0.,
swapsize: None,
maxroot: None,
@@ -553,8 +566,8 @@ pub fn parse_answer(
};
set_disks(answer, udev_info, runtime_info, &mut config)?;
- match &answer.disks.fs_options {
- answer::FsOptions::LVM(lvm) => {
+ match fs_options {
+ FilesystemOptions::Ext4(lvm) | FilesystemOptions::Xfs(lvm) => {
let disk = runtime_info
.disks
.iter()
@@ -568,21 +581,24 @@ pub fn parse_answer(
config.maxvz = lvm.maxvz;
config.minfree = lvm.minfree;
}
- answer::FsOptions::ZFS(zfs) => {
+ FilesystemOptions::Zfs(zfs) => {
let first_selected_disk = get_first_selected_disk(&config);
config.hdsize = zfs
.hdsize
.unwrap_or(runtime_info.disks[first_selected_disk].size);
config.zfs_opts = Some(InstallZfsOption {
- ashift: zfs.ashift.unwrap_or(12),
- arc_max: zfs.arc_max.unwrap_or(runtime_info.default_zfs_arc_max),
- compress: zfs.compress.unwrap_or(ZfsCompressOption::On),
- checksum: zfs.checksum.unwrap_or(ZfsChecksumOption::On),
- copies: zfs.copies.unwrap_or(1),
+ ashift: zfs.ashift.unwrap_or(12) as usize,
+ arc_max: zfs
+ .arc_max
+ .map(|v| v as usize)
+ .unwrap_or(runtime_info.default_zfs_arc_max),
+ compress: zfs.compress.unwrap_or_default(),
+ checksum: zfs.checksum.unwrap_or_default(),
+ copies: zfs.copies.unwrap_or(1) as usize,
});
}
- answer::FsOptions::BTRFS(btrfs) => {
+ FilesystemOptions::Btrfs(btrfs) => {
let first_selected_disk = get_first_selected_disk(&config);
config.hdsize = btrfs
diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs
index 7dd4a9d..675678a 100644
--- a/proxmox-auto-installer/tests/parse-answer.rs
+++ b/proxmox-auto-installer/tests/parse-answer.rs
@@ -2,13 +2,11 @@ use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
-use proxmox_auto_installer::answer::Answer;
-use proxmox_auto_installer::udevinfo::UdevInfo;
use proxmox_auto_installer::utils::parse_answer;
-
use proxmox_installer_common::setup::{
LocaleInfo, RuntimeInfo, SetupInfo, load_installer_setup_files, read_json,
};
+use proxmox_installer_types::{UdevInfo, answer::AutoInstallerConfig};
fn get_test_resource_path() -> Result<PathBuf, String> {
Ok(std::env::current_dir()
@@ -16,7 +14,7 @@ fn get_test_resource_path() -> Result<PathBuf, String> {
.join("tests/resources"))
}
-fn get_answer(path: impl AsRef<Path>) -> Result<Answer, String> {
+fn get_answer(path: impl AsRef<Path>) -> Result<AutoInstallerConfig, String> {
let answer_raw = fs::read_to_string(path).unwrap();
toml::from_str(&answer_raw)
.map_err(|err| format!("error parsing answer.toml: {}", err.message()))
diff --git a/proxmox-auto-installer/tests/resources/iso-info.json b/proxmox-auto-installer/tests/resources/iso-info.json
index 881dafd..2cfbd6d 100644
--- a/proxmox-auto-installer/tests/resources/iso-info.json
+++ b/proxmox-auto-installer/tests/resources/iso-info.json
@@ -14,8 +14,8 @@
},
"product": "pve",
"product-cfg": {
- "bridged_network": 1,
- "enable_btrfs": 1,
+ "bridged_network": true,
+ "enable_btrfs": true,
"fullname": "Proxmox VE",
"port": "8006",
"product": "pve"
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
index af4ed79..f3c9169 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json
@@ -1,3 +1,3 @@
{
- "parse-error": "error parsing answer.toml: interface name 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters"
+ "error": "interface name 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters"
}
diff --git a/proxmox-installer-common/src/disk_checks.rs b/proxmox-installer-common/src/disk_checks.rs
index f17a7a6..fbed578 100644
--- a/proxmox-installer-common/src/disk_checks.rs
+++ b/proxmox-installer-common/src/disk_checks.rs
@@ -1,9 +1,8 @@
+use anyhow::ensure;
use std::collections::HashSet;
-use anyhow::ensure;
-
use crate::options::{Disk, LvmBootdiskOptions};
-use crate::setup::BootType;
+use proxmox_installer_types::BootType;
/// Checks a list of disks for duplicate entries, using their index as key.
///
diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs
index fde17b7..ee34096 100644
--- a/proxmox-installer-common/src/lib.rs
+++ b/proxmox-installer-common/src/lib.rs
@@ -19,9 +19,6 @@ pub mod net {
pub const RUNTIME_DIR: &str = "/run/proxmox-installer";
-/// Default placeholder value for the administrator email address.
-pub const EMAIL_DEFAULT_PLACEHOLDER: &str = "mail@example.invalid";
-
/// Name of the executable for the first-boot hook.
pub const FIRST_BOOT_EXEC_NAME: &str = "proxmox-first-boot";
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index 8e19663..ed00b4b 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -1,6 +1,6 @@
use anyhow::{Result, bail};
use regex::{Regex, RegexBuilder};
-use serde::{Deserialize, Serialize};
+use serde::Deserialize;
use std::{
cmp,
collections::HashMap,
@@ -12,7 +12,13 @@ use std::{
use crate::disk_checks::check_raid_min_disks;
use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN};
use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
-use proxmox_installer_types::answer::{BtrfsRaidLevel, FilesystemType, ZfsRaidLevel};
+use proxmox_installer_types::{
+ EMAIL_DEFAULT_PLACEHOLDER,
+ answer::{
+ BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, NetworkInterfacePinningOptionsAnswer,
+ ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
+ },
+};
use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
pub trait RaidLevel {
@@ -123,35 +129,22 @@ impl LvmBootdiskOptions {
}
}
-/// See the accompanying mount option in btrfs(5).
-#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)]
-#[serde(rename_all(deserialize = "lowercase"))]
-pub enum BtrfsCompressOption {
- On,
- #[default]
- Off,
- Zlib,
- Lzo,
- Zstd,
+pub trait FilesystemDiskInfo {
+ /// Returns the minimum number of disks needed for this filesystem.
+ fn get_min_disks(&self) -> usize;
}
-impl fmt::Display for BtrfsCompressOption {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- write!(f, "{}", format!("{self:?}").to_lowercase())
+impl FilesystemDiskInfo for FilesystemType {
+ fn get_min_disks(&self) -> usize {
+ match self {
+ FilesystemType::Ext4 => 1,
+ FilesystemType::Xfs => 1,
+ FilesystemType::Zfs(level) => level.get_min_disks(),
+ FilesystemType::Btrfs(level) => level.get_min_disks(),
+ }
}
}
-impl From<&BtrfsCompressOption> for String {
- fn from(value: &BtrfsCompressOption) -> Self {
- value.to_string()
- }
-}
-
-pub const BTRFS_COMPRESS_OPTIONS: &[BtrfsCompressOption] = {
- use BtrfsCompressOption::*;
- &[On, Off, Zlib, Lzo, Zstd]
-};
-
#[derive(Clone, Debug)]
pub struct BtrfsBootdiskOptions {
pub disk_size: f64,
@@ -171,54 +164,6 @@ impl BtrfsBootdiskOptions {
}
}
-#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum ZfsCompressOption {
- #[default]
- On,
- Off,
- Lzjb,
- Lz4,
- Zle,
- Gzip,
- Zstd,
-}
-
-serde_plain::derive_display_from_serialize!(ZfsCompressOption);
-
-impl From<&ZfsCompressOption> for String {
- fn from(value: &ZfsCompressOption) -> Self {
- value.to_string()
- }
-}
-
-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")]
-pub enum ZfsChecksumOption {
- #[default]
- On,
- Fletcher4,
- Sha256,
-}
-
-serde_plain::derive_display_from_serialize!(ZfsChecksumOption);
-
-impl From<&ZfsChecksumOption> for String {
- fn from(value: &ZfsChecksumOption) -> Self {
- value.to_string()
- }
-}
-
-pub const ZFS_CHECKSUM_OPTIONS: &[ZfsChecksumOption] = {
- use ZfsChecksumOption::*;
- &[On, Fletcher4, Sha256]
-};
-
#[derive(Clone, Debug)]
pub struct ZfsBootdiskOptions {
pub ashift: usize,
@@ -430,6 +375,24 @@ impl NetworkInterfacePinningOptions {
}
}
+impl From<&NetworkInterfacePinningOptionsAnswer> for NetworkInterfacePinningOptions {
+ fn from(answer: &NetworkInterfacePinningOptionsAnswer) -> Self {
+ if answer.enabled {
+ Self {
+ // convert all MAC addresses to lowercase before further usage,
+ // to enable easy comparison
+ mapping: answer
+ .mapping
+ .iter()
+ .map(|(k, v)| (k.to_lowercase(), v.clone()))
+ .collect(),
+ }
+ } else {
+ Self::default()
+ }
+ }
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct NetworkOptions {
pub ifname: String,
@@ -453,11 +416,7 @@ impl NetworkOptions {
// worse case nothing breaks down *completely*.
let mut this = Self {
ifname: String::new(),
- fqdn: Self::construct_fqdn(
- network,
- setup.config.product.default_hostname(),
- default_domain,
- ),
+ fqdn: Self::construct_fqdn(network, &setup.config.product.to_string(), default_domain),
// Safety: The provided IP address/mask is always valid.
// These are the same as used in the GTK-based installer.
address: Cidr::new_v4([192, 168, 100, 2], 24).unwrap(),
@@ -576,7 +535,7 @@ pub fn email_validate(email: &str) -> Result<()> {
if !re.is_match(email) {
bail!("Email does not look like a valid address (user@domain.tld)")
- } else if email == crate::EMAIL_DEFAULT_PLACEHOLDER {
+ } else if email == EMAIL_DEFAULT_PLACEHOLDER {
bail!("Invalid (default) email address")
}
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index 91f1250..57f9cf3 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -1,3 +1,4 @@
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::{
cmp,
collections::{BTreeMap, HashMap},
@@ -10,81 +11,14 @@ use std::{
process::{self, Command, Stdio},
};
-use proxmox_network_types::Cidr;
-use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
-
use crate::options::{
- BtrfsBootdiskOptions, BtrfsCompressOption, Disk, NetworkInterfacePinningOptions,
- ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption,
+ BtrfsBootdiskOptions, Disk, NetworkInterfacePinningOptions, ZfsBootdiskOptions,
};
-use proxmox_installer_types::answer::FilesystemType;
-
-#[allow(clippy::upper_case_acronyms)]
-#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum ProxmoxProduct {
- PVE,
- PBS,
- PMG,
- PDM,
-}
-
-impl ProxmoxProduct {
- pub fn default_hostname(self) -> &'static str {
- match self {
- Self::PVE => "pve",
- Self::PMG => "pmg",
- Self::PBS => "pbs",
- Self::PDM => "pdm",
- }
- }
-}
-
-impl fmt::Display for ProxmoxProduct {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(match self {
- Self::PVE => "pve",
- Self::PMG => "pmg",
- Self::PBS => "pbs",
- Self::PDM => "pdm",
- })
- }
-}
-
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct ProductConfig {
- pub fullname: String,
- pub product: ProxmoxProduct,
- #[serde(deserialize_with = "deserialize_bool_from_int")]
- 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(Debug, Clone, Deserialize, Serialize)]
-pub struct IsoInfo {
- pub release: String,
- 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"),
- }
- }
-}
+use proxmox_installer_types::{
+ BootType, IsoInfo, ProductConfig,
+ answer::{BtrfsCompressOption, FilesystemType, ZfsChecksumOption, ZfsCompressOption},
+};
+use proxmox_network_types::Cidr;
/// Paths in the ISO environment containing installer data.
#[derive(Clone, Deserialize)]
@@ -387,13 +321,6 @@ pub struct RuntimeInfo {
pub default_zfs_arc_max: usize,
}
-#[derive(Copy, Clone, Eq, Deserialize, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum BootType {
- Bios,
- Efi,
-}
-
#[derive(Clone, Deserialize)]
pub struct NetworkInfo {
pub dns: Dns,
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 9025c01..9d7932a 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -36,12 +36,10 @@ mod detail {
use proxmox_installer_common::{
options::{Disk, NetworkOptions},
- setup::{
- InstallConfig, ProxmoxProduct, RuntimeInfo, SetupInfo, load_installer_setup_files,
- },
+ setup::{InstallConfig, RuntimeInfo, SetupInfo, load_installer_setup_files},
};
use proxmox_installer_types::{
- BootType, IsoInfo, UdevInfo,
+ ProxmoxProduct, UdevInfo,
answer::{AutoInstallerConfig, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode},
post_hook::{
BootInfo, CpuInfo, DiskInfo, KernelVersionInformation, NetworkInterfaceInfo,
@@ -119,16 +117,10 @@ mod detail {
},
debian_version: read_file("/etc/debian_version")?,
product: gather_product_info(&setup_info, &run_cmd)?,
- iso: IsoInfo {
- release: setup_info.iso_info.release,
- isorelease: setup_info.iso_info.isorelease,
- },
+ iso: setup_info.iso_info,
kernel_version: gather_kernel_version(&run_cmd, &open_file)?,
boot_info: BootInfo {
- mode: match run_env.boot_type {
- proxmox_installer_common::setup::BootType::Bios => BootType::Bios,
- proxmox_installer_common::setup::BootType::Efi => BootType::Efi,
- },
+ mode: run_env.boot_type,
secureboot: run_env.secure_boot,
},
cpu_info: gather_cpu_info(&run_env)?,
@@ -271,10 +263,10 @@ mod detail {
run_cmd: &dyn Fn(&[&str]) -> Result<String>,
) -> Result<ProductInfo> {
let package = match setup_info.config.product {
- ProxmoxProduct::PVE => "pve-manager",
- ProxmoxProduct::PMG => "pmg-api",
- ProxmoxProduct::PBS => "proxmox-backup-server",
- ProxmoxProduct::PDM => "proxmox-datacenter-manager",
+ ProxmoxProduct::Pve => "pve-manager",
+ ProxmoxProduct::Pmg => "pmg-api",
+ ProxmoxProduct::Pbs => "proxmox-backup-server",
+ ProxmoxProduct::Pdm => "proxmox-datacenter-manager",
};
let version = run_cmd(&[
@@ -288,12 +280,7 @@ mod detail {
Ok(ProductInfo {
fullname: setup_info.config.fullname.clone(),
- short: match setup_info.config.product {
- ProxmoxProduct::PVE => proxmox_installer_types::ProxmoxProduct::Pve,
- ProxmoxProduct::PBS => proxmox_installer_types::ProxmoxProduct::Pbs,
- ProxmoxProduct::PMG => proxmox_installer_types::ProxmoxProduct::Pmg,
- ProxmoxProduct::PDM => proxmox_installer_types::ProxmoxProduct::Pdm,
- },
+ short: setup_info.config.product,
version,
})
}
diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs
index d2fd3d8..6c457aa 100644
--- a/proxmox-tui-installer/src/main.rs
+++ b/proxmox-tui-installer/src/main.rs
@@ -13,17 +13,19 @@ use cursive::{
},
};
-mod options;
-use options::{InstallerOptions, PasswordOptions};
-
use proxmox_installer_common::{
ROOT_PASSWORD_MIN_LENGTH,
options::{
BootdiskOptions, NetworkInterfacePinningOptions, NetworkOptions, TimezoneOptions,
email_validate,
},
- setup::{LocaleInfo, ProxmoxProduct, RuntimeInfo, SetupInfo, installer_setup},
+ setup::{LocaleInfo, RuntimeInfo, SetupInfo, installer_setup},
};
+use proxmox_installer_types::ProxmoxProduct;
+
+mod options;
+use options::{InstallerOptions, PasswordOptions};
+
mod setup;
mod system;
@@ -213,7 +215,7 @@ fn installer_setup_late(siv: &mut Cursive) {
);
}
- if state.setup_info.config.product == ProxmoxProduct::PVE && !state.runtime_info.hvm_supported {
+ if state.setup_info.config.product == ProxmoxProduct::Pve && !state.runtime_info.hvm_supported {
display_setup_warning(
siv,
concat!(
diff --git a/proxmox-tui-installer/src/options.rs b/proxmox-tui-installer/src/options.rs
index ff15fa0..2c156e8 100644
--- a/proxmox-tui-installer/src/options.rs
+++ b/proxmox-tui-installer/src/options.rs
@@ -1,10 +1,10 @@
use crate::SummaryOption;
use proxmox_installer_common::{
- EMAIL_DEFAULT_PLACEHOLDER,
options::{BootdiskOptions, NetworkOptions, TimezoneOptions},
setup::LocaleInfo,
};
+use proxmox_installer_types::EMAIL_DEFAULT_PLACEHOLDER;
#[derive(Clone)]
pub struct PasswordOptions {
diff --git a/proxmox-tui-installer/src/views/bootdisk.rs b/proxmox-tui-installer/src/views/bootdisk.rs
index ed3936f..a0267f1 100644
--- a/proxmox-tui-installer/src/views/bootdisk.rs
+++ b/proxmox-tui-installer/src/views/bootdisk.rs
@@ -22,13 +22,19 @@ use proxmox_installer_common::{
check_disks_4kn_legacy_boot, check_for_duplicate_disks, check_lvm_bootdisk_opts,
},
options::{
- AdvancedBootdiskOptions, BTRFS_COMPRESS_OPTIONS, BootdiskOptions, BtrfsBootdiskOptions,
- Disk, LvmBootdiskOptions, RaidLevel, ZFS_CHECKSUM_OPTIONS, ZFS_COMPRESS_OPTIONS,
- ZfsBootdiskOptions,
+ AdvancedBootdiskOptions, BootdiskOptions, BtrfsBootdiskOptions, Disk, LvmBootdiskOptions,
+ RaidLevel, ZfsBootdiskOptions,
+ },
+ setup::RuntimeInfo,
+};
+
+use proxmox_installer_types::{
+ BootType, ProductConfig, ProxmoxProduct,
+ answer::{
+ BTRFS_COMPRESS_OPTIONS, FILESYSTEM_TYPE_OPTIONS, FilesystemType, ZFS_CHECKSUM_OPTIONS,
+ ZFS_COMPRESS_OPTIONS,
},
- setup::{BootType, ProductConfig, ProxmoxProduct, RuntimeInfo},
};
-use proxmox_installer_types::answer::{FILESYSTEM_TYPE_OPTIONS, FilesystemType};
/// OpenZFS specifies 64 MiB as the absolute minimum:
/// <https://openzfs.github.io/openzfs-docs/Performance%20and%20Tuning/Module%20Parameters.html#zfs-arc-max>
@@ -328,7 +334,7 @@ struct LvmBootdiskOptionsView {
impl LvmBootdiskOptionsView {
fn new(disk: &Disk, options: &LvmBootdiskOptions, product_conf: &ProductConfig) -> Self {
- let show_extra_fields = product_conf.product == ProxmoxProduct::PVE;
+ let show_extra_fields = product_conf.product == ProxmoxProduct::Pve;
let view = FormView::new()
.child(
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread* [PATCH installer v3 38/38] auto: drop now-dead answer file definitions
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
` (36 preceding siblings ...)
2026-04-03 16:54 ` [PATCH installer v3 37/38] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
@ 2026-04-03 16:54 ` Christoph Heiss
37 siblings, 0 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:54 UTC (permalink / raw)
To: pdm-devel
These types are now wholly unused, so drop them.
No functional changes.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
proxmox-auto-installer/src/answer.rs | 511 -------------------------
proxmox-auto-installer/src/lib.rs | 2 -
proxmox-auto-installer/src/udevinfo.rs | 11 -
3 files changed, 524 deletions(-)
delete mode 100644 proxmox-auto-installer/src/answer.rs
delete mode 100644 proxmox-auto-installer/src/udevinfo.rs
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
deleted file mode 100644
index c7e7298..0000000
--- a/proxmox-auto-installer/src/answer.rs
+++ /dev/null
@@ -1,511 +0,0 @@
-use anyhow::{Result, bail, format_err};
-use serde::{Deserialize, Serialize};
-use std::{
- collections::{BTreeMap, HashMap},
- io::BufRead,
- net::IpAddr,
-};
-
-use proxmox_installer_common::options::NetworkInterfacePinningOptions;
-use proxmox_installer_types::answer::{
- BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, ZfsChecksumOption, ZfsCompressOption,
- ZfsRaidLevel,
-};
-use proxmox_network_types::{Cidr, fqdn::Fqdn};
-
-// NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards
-// compatibility. TODO Remove the snake_cased variants in a future major version (e.g. PVE 10).
-
-// BTreeMap is used to store filters as the order of the filters will be stable, compared to
-// storing them in a HashMap
-
-#[derive(Clone, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct Answer {
- pub global: Global,
- pub network: Network,
- #[serde(rename = "disk-setup")]
- pub disks: Disks,
- pub post_installation_webhook: Option<PostNotificationHookInfo>,
- pub first_boot: Option<FirstBootHookInfo>,
-}
-
-impl Answer {
- pub fn try_from_reader(reader: impl BufRead) -> Result<Self> {
- let mut buffer = String::new();
- let lines = reader.lines();
- for line in lines {
- buffer.push_str(&line.unwrap());
- buffer.push('\n');
- }
-
- toml::from_str(&buffer).map_err(|err| format_err!("Failed parsing answer file: {err}"))
- }
-}
-
-#[derive(Clone, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct Global {
- pub country: String,
- /// FQDN to set for the installed system.
- pub fqdn: FqdnConfig,
- pub keyboard: KeyboardLayout,
- pub mailto: String,
- pub timezone: String,
- #[serde(alias = "root_password")]
- pub root_password: Option<String>,
- #[serde(alias = "root_password_hashed")]
- pub root_password_hashed: Option<String>,
- #[serde(alias = "reboot_on_error", default)]
- pub reboot_on_error: bool,
- #[serde(alias = "reboot_mode", default)]
- pub reboot_mode: RebootMode,
- #[serde(alias = "root_ssh_keys", default)]
- pub root_ssh_keys: Vec<String>,
-}
-
-#[derive(Copy, Clone, Deserialize, Serialize, Debug, Default, PartialEq, Eq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub enum RebootMode {
- #[default]
- Reboot,
- PowerOff,
-}
-
-/// Allow the user to either set the FQDN of the installation to either some
-/// fixed value or retrieve it dynamically via e.g.DHCP.
-#[derive(Clone, Deserialize, Debug)]
-#[serde(
- untagged,
- expecting = "either a fully-qualified domain name or extendend configuration for usage with DHCP must be specified"
-)]
-pub enum FqdnConfig {
- /// Sets the FQDN to the exact value.
- Simple(Fqdn),
- /// Extended configuration, e.g. to use hostname and domain from DHCP.
- Extended(FqdnExtendedConfig),
-}
-
-/// Extended configuration for retrieving the FQDN from external sources.
-#[derive(Clone, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct FqdnExtendedConfig {
- /// 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>,
-}
-
-/// Describes the source to retrieve the FQDN of the installation.
-#[derive(Clone, Deserialize, Debug, Default, PartialEq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub enum FqdnSourceMode {
- #[default]
- FromDhcp,
-}
-
-#[derive(Clone, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct PostNotificationHookInfo {
- /// URL to send a POST request to
- pub url: String,
- /// SHA256 cert fingerprint if certificate pinning should be used.
- #[serde(alias = "cert_fingerprint")]
- pub cert_fingerprint: Option<String>,
-}
-
-/// Possible sources for the optional first-boot hook script/executable file.
-#[derive(Clone, Deserialize, Debug, PartialEq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-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,
-}
-
-/// 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.
-#[derive(Clone, Default, Deserialize, Debug, PartialEq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-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",
- }
- }
-}
-
-/// Describes from where to fetch the first-boot hook script, either being baked into the ISO or
-/// from a URL.
-#[derive(Clone, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-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,
- /// 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".
- #[serde(alias = "cert_fingerprint")]
- pub cert_fingerprint: Option<String>,
-}
-
-#[derive(Clone, Deserialize, Debug, Default, PartialEq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-enum NetworkConfigMode {
- #[default]
- FromDhcp,
- FromAnswer,
-}
-
-/// Options controlling the behaviour of the network interface pinning (by
-/// creating appropriate systemd.link files) during the installation.
-#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
-#[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)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-struct NetworkInAnswer {
- #[serde(default)]
- pub source: NetworkConfigMode,
- pub cidr: Option<Cidr>,
- pub dns: Option<IpAddr>,
- pub gateway: Option<IpAddr>,
- #[serde(default)]
- 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)]
-#[serde(try_from = "NetworkInAnswer", deny_unknown_fields)]
-pub struct Network {
- pub network_settings: NetworkSettings,
- /// Controls network interface pinning behaviour during installation.
- pub interface_name_pinning: Option<NetworkInterfacePinningOptions>,
-}
-
-impl TryFrom<NetworkInAnswer> for Network {
- type Error = anyhow::Error;
-
- fn try_from(network: NetworkInAnswer) -> Result<Self> {
- let interface_name_pinning = match network.interface_name_pinning {
- Some(opts) if opts.enabled => {
- let opts = NetworkInterfacePinningOptions {
- mapping: opts
- .mapping
- .iter()
- .map(|(k, v)| (k.to_lowercase(), v.clone()))
- .collect(),
- };
-
- opts.verify()?;
- Some(opts)
- }
- _ => None,
- };
-
- if network.source == NetworkConfigMode::FromAnswer {
- if network.cidr.is_none() {
- bail!("Field 'cidr' must be set.");
- }
- if network.dns.is_none() {
- bail!("Field 'dns' must be set.");
- }
- if network.gateway.is_none() {
- bail!("Field 'gateway' must be set.");
- }
- if network.filter.is_empty() {
- bail!("Field 'filter' must be set.");
- }
-
- Ok(Network {
- network_settings: NetworkSettings::Manual(NetworkManual {
- cidr: network.cidr.unwrap(),
- dns: network.dns.unwrap(),
- gateway: network.gateway.unwrap(),
- filter: network.filter,
- }),
- interface_name_pinning,
- })
- } else {
- if network.cidr.is_some() {
- bail!("Field 'cidr' not supported for 'from-dhcp' config.");
- }
- if network.dns.is_some() {
- bail!("Field 'dns' not supported for 'from-dhcp' config.");
- }
- if network.gateway.is_some() {
- bail!("Field 'gateway' not supported for 'from-dhcp' config.");
- }
- if !network.filter.is_empty() {
- bail!("Field 'filter' not supported for 'from-dhcp' config.");
- }
-
- Ok(Network {
- network_settings: NetworkSettings::FromDhcp,
- interface_name_pinning,
- })
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub enum NetworkSettings {
- FromDhcp,
- Manual(NetworkManual),
-}
-
-#[derive(Clone, Debug)]
-pub struct NetworkManual {
- pub cidr: Cidr,
- pub dns: IpAddr,
- pub gateway: IpAddr,
- pub filter: BTreeMap<String, String>,
-}
-
-#[derive(Clone, Debug, Deserialize)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct DiskSetup {
- pub filesystem: Filesystem,
- #[serde(alias = "disk_list", default)]
- pub disk_list: Vec<String>,
- #[serde(default)]
- pub filter: BTreeMap<String, String>,
- #[serde(alias = "filter_match")]
- pub filter_match: Option<FilterMatch>,
- pub zfs: Option<ZfsOptions>,
- pub lvm: Option<LvmOptions>,
- pub btrfs: Option<BtrfsOptions>,
-}
-
-#[derive(Clone, Debug, Deserialize)]
-#[serde(try_from = "DiskSetup", deny_unknown_fields)]
-pub struct Disks {
- pub fs_type: FilesystemType,
- pub disk_selection: DiskSelection,
- pub filter_match: Option<FilterMatch>,
- pub fs_options: FsOptions,
-}
-
-impl TryFrom<DiskSetup> for Disks {
- type Error = &'static str;
-
- fn try_from(source: DiskSetup) -> Result<Self, Self::Error> {
- if source.disk_list.is_empty() && source.filter.is_empty() {
- return Err("Need either 'disk-list' or 'filter' set");
- }
- if !source.disk_list.is_empty() && !source.filter.is_empty() {
- return Err("Cannot use both, 'disk-list' and 'filter'");
- }
-
- let disk_selection = if !source.disk_list.is_empty() {
- DiskSelection::Selection(source.disk_list.clone())
- } else {
- DiskSelection::Filter(source.filter.clone())
- };
-
- let lvm_checks = |source: &DiskSetup| -> Result<(), Self::Error> {
- if source.zfs.is_some() || source.btrfs.is_some() {
- return Err("make sure only 'lvm' options are set");
- }
- if source.disk_list.len() > 1 {
- return Err("make sure to define only one disk for ext4 and xfs");
- }
- Ok(())
- };
- // TODO: improve checks for foreign FS options. E.g. less verbose and handling new FS types
- // automatically
- let (fs, fs_options) = match source.filesystem {
- Filesystem::Xfs => {
- lvm_checks(&source)?;
- (
- FilesystemType::Xfs,
- FsOptions::LVM(source.lvm.unwrap_or_default()),
- )
- }
- Filesystem::Ext4 => {
- lvm_checks(&source)?;
- (
- FilesystemType::Ext4,
- FsOptions::LVM(source.lvm.unwrap_or_default()),
- )
- }
- Filesystem::Zfs => {
- if source.lvm.is_some() || source.btrfs.is_some() {
- return Err("make sure only 'zfs' options are set");
- }
- match source.zfs {
- None | Some(ZfsOptions { raid: None, .. }) => {
- return Err("ZFS raid level 'zfs.raid' must be set");
- }
- Some(opts) => (
- FilesystemType::Zfs(opts.raid.unwrap()),
- FsOptions::ZFS(opts),
- ),
- }
- }
- Filesystem::Btrfs => {
- if source.zfs.is_some() || source.lvm.is_some() {
- return Err("make sure only 'btrfs' options are set");
- }
- match source.btrfs {
- None | Some(BtrfsOptions { raid: None, .. }) => {
- return Err("BTRFS raid level 'btrfs.raid' must be set");
- }
- Some(opts) => (
- FilesystemType::Btrfs(opts.raid.unwrap()),
- FsOptions::BTRFS(opts),
- ),
- }
- }
- };
-
- let res = Disks {
- fs_type: fs,
- disk_selection,
- filter_match: source.filter_match,
- fs_options,
- };
- Ok(res)
- }
-}
-
-#[derive(Clone, Debug)]
-pub enum FsOptions {
- LVM(LvmOptions),
- ZFS(ZfsOptions),
- BTRFS(BtrfsOptions),
-}
-
-#[derive(Clone, Debug)]
-pub enum DiskSelection {
- Selection(Vec<String>),
- Filter(BTreeMap<String, String>),
-}
-
-#[derive(Clone, Deserialize, Debug, PartialEq)]
-#[serde(rename_all = "lowercase", deny_unknown_fields)]
-pub enum FilterMatch {
- Any,
- All,
-}
-
-serde_plain::derive_fromstr_from_deserialize!(FilterMatch);
-
-#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
-#[serde(rename_all = "lowercase", deny_unknown_fields)]
-pub enum Filesystem {
- Ext4,
- Xfs,
- Zfs,
- Btrfs,
-}
-
-#[derive(Clone, Copy, Default, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct ZfsOptions {
- pub raid: Option<ZfsRaidLevel>,
- pub ashift: Option<usize>,
- #[serde(alias = "arc_max")]
- pub arc_max: Option<usize>,
- pub checksum: Option<ZfsChecksumOption>,
- pub compress: Option<ZfsCompressOption>,
- pub copies: Option<usize>,
- pub hdsize: Option<f64>,
-}
-
-#[derive(Clone, Copy, Default, Deserialize, Serialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct LvmOptions {
- pub hdsize: Option<f64>,
- pub swapsize: Option<f64>,
- pub maxroot: Option<f64>,
- pub maxvz: Option<f64>,
- pub minfree: Option<f64>,
-}
-
-#[derive(Clone, Copy, Default, Deserialize, Debug)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub struct BtrfsOptions {
- pub hdsize: Option<f64>,
- pub raid: Option<BtrfsRaidLevel>,
- pub compress: Option<BtrfsCompressOption>,
-}
-
-#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
-#[serde(rename_all = "kebab-case", deny_unknown_fields)]
-pub enum 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,
-}
-
-serde_plain::derive_display_from_serialize!(KeyboardLayout);
-
-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-auto-installer/src/lib.rs b/proxmox-auto-installer/src/lib.rs
index 3bdf0b5..8c51a07 100644
--- a/proxmox-auto-installer/src/lib.rs
+++ b/proxmox-auto-installer/src/lib.rs
@@ -1,5 +1,3 @@
-pub mod answer;
pub mod log;
pub mod sysinfo;
-pub mod udevinfo;
pub mod utils;
diff --git a/proxmox-auto-installer/src/udevinfo.rs b/proxmox-auto-installer/src/udevinfo.rs
deleted file mode 100644
index 677f3f6..0000000
--- a/proxmox-auto-installer/src/udevinfo.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-use serde::Deserialize;
-use std::collections::BTreeMap;
-
-/// Uses a BTreeMap to have the keys sorted
-pub type UdevProperties = BTreeMap<String, String>;
-
-#[derive(Clone, Deserialize, Debug)]
-pub struct UdevInfo {
- pub disks: BTreeMap<String, UdevProperties>,
- pub nics: BTreeMap<String, UdevProperties>,
-}
--
2.53.0
^ permalink raw reply [flat|nested] 39+ messages in thread