* [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration
@ 2026-04-03 16:53 Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name Christoph Heiss
` (37 more replies)
0 siblings, 38 replies; 39+ messages in thread
From: Christoph Heiss @ 2026-04-03 16:53 UTC (permalink / raw)
To: pdm-devel
This series adds integration with our automated installer [0] for all
our products. With this, Proxmox Datacenter Manager can be used for
serving answer files via HTTPS in an automated fashion.
It provides three panels under the "Automated Installations" tab in the
"Remotes" menu:
- Automated Installations: List all past and currently ongoing
installations.
- Prepared Answers: Enables users to do the whole CRUD cycle for
prepared answers, i.e. create new answers (from scratch or based on
existing ones), edit and delete.
- Authentication Tokens: Simple token for the /answer endpoint. What
tokens can be used to obtain which answer can be set when editing a
prepared answer under the "Authentication" tab.
Permission-wise, everything is currently scoped under
/system/auto-installation.
User interface
==============
Happy about feedback regarding the UI, especially the editing dialog! By
the nature of it, the auto-installer has a lot more options than the
standard GUI/TUI installer, and it's kind of hard to not fully cramp up
the UI while still providing all options.
The viewing of the system information and post-installation hook
notification data is currently maybe a bit bare-bones. For the first
one, displaying it as a tree could work, if we don't want to simply
display raw JSON documents to the user.
Answer endpoint
===============
The endpoint for serving answer files to the automated installer lives
under `/api2/json/auto-install/answer`. This makes it (unfortunately)
incompatible with older ISOs, as currently they only support answer
files formatted as TOML. See also the discussion in [1].
We could serve TOML (as done in previous revisision), but the
introduction of authentication tokens [2] for the /answer endpoint
breaks backwards-compatibility anyway, rendering the whole endeavour
moot - so I removed TOML support entirely.
Installer changes
=================
Most of the changes to pve-installer are churn from switching to the
moved types in proxmox-installer-types. They were heavily intertwined
between the different crates in pve-installer, so
a) untwisting that, b) doing some long-overdue quality-of-life
improvements and c) making them #[api] ready created a lot of churn,
although nothing in the actual TOML/JSON interface changed for any of
these types.
Other possible future work
==========================
- Target filter selection by dropdown/tree. Based on the system
information schema, a list of all possible JSON pointers, or even a
tree, could be generated and displayed to the user, in a form
consisting of the key, a text input for the value/glob to match and a
set of add/remove buttons for each filter.
- Display/saving the installation progress/log. The progress log
generated by the auto-installer can be sent to PDM, showing it there
similar to tasks.
- Implement a "wait for answer" system. Core idea here is that the
installer waits for PDM to provide in answer by implemented e.g. an
retry system. The use case is for to be able to effectively install
systems on demand, without the need for preparations.
- Providing the option for automatically adding the installed node to
PDM (for PVE/PBS)
- Maybe mTLS authentication for the answer endpoint, as suggested by
Lukas/Thomas?
[0] https://pve.proxmox.com/wiki/Automated_Installation
[1] https://lore.proxmox.com/pdm-devel/11b27b4a-7e1f-4af9-8384-12d54d72ef17@proxmox.com/
[2] https://lore.proxmox.com/pdm-devel/DETMUXY1Q877.32G593TWC52WW@proxmox.com/#:~:text=%20I%20think%20this%20is%20dangerous
History
=======
v2: https://lore.proxmox.com/pdm-devel/20251205112528.373387-1-c.heiss@proxmox.com/
v1: https://lore.proxmox.com/pdm-devel/20251204125122.945961-1-c.heiss@proxmox.com/
Notable Changes v2 -> v3:
* moved "Automated Installations" panels to a tab under "Remotes"
* added authentication to answer endpoint
* answers are now served as JSON unconditionally
* moved installations state file to
/var/lib/proxmox-datacenter-manager/
* added required pve-installer changes
* ui: use `DataTable`s instead of text areas for filter entries
* ui: use `PdmClient` for interacting with API instead of sending
requests directly
Notable Changes v1 -> v2:
* add documentation patch
* fixed compilation due to leftover type
Diffstat
========
proxmox:
Christoph Heiss (12):
api-macro: allow $ in identifier name
schema: oneOf: allow single string variant
schema: implement UpdaterType for HashMap and BTreeMap
network-types: move `Fqdn` type from proxmox-installer-common
network-types: implement api type for Fqdn
network-types: add api wrapper type for std::net::IpAddr
network-types: cidr: implement generic `IpAddr::new` constructor
network-types: fqdn: implement standard library Error for Fqdn
node-status: make KernelVersionInformation Clone + PartialEq
installer-types: add common types used by the installer
installer-types: add types used by the auto-installer
installer-types: implement api type for all externally-used types
Cargo.toml | 2 +
proxmox-api-macro/src/util.rs | 2 +-
proxmox-installer-types/Cargo.toml | 28 +
proxmox-installer-types/debian/changelog | 5 +
proxmox-installer-types/debian/control | 67 +
proxmox-installer-types/debian/debcargo.toml | 7 +
proxmox-installer-types/src/answer.rs | 1199 ++++++++++++++++++
proxmox-installer-types/src/lib.rs | 182 +++
proxmox-installer-types/src/post_hook.rs | 215 ++++
proxmox-network-types/Cargo.toml | 3 +-
proxmox-network-types/debian/control | 8 +-
proxmox-network-types/src/fqdn.rs | 257 ++++
proxmox-network-types/src/ip_address.rs | 73 +-
proxmox-network-types/src/lib.rs | 1 +
proxmox-node-status/src/types.rs | 2 +-
proxmox-schema/src/schema.rs | 78 +-
proxmox-schema/tests/schema.rs | 91 +-
17 files changed, 2207 insertions(+), 13 deletions(-)
create mode 100644 proxmox-installer-types/Cargo.toml
create mode 100644 proxmox-installer-types/debian/changelog
create mode 100644 proxmox-installer-types/debian/control
create mode 100644 proxmox-installer-types/debian/debcargo.toml
create mode 100644 proxmox-installer-types/src/answer.rs
create mode 100644 proxmox-installer-types/src/lib.rs
create mode 100644 proxmox-installer-types/src/post_hook.rs
create mode 100644 proxmox-network-types/src/fqdn.rs
proxmox-yew-widget-toolkit:
Christoph Heiss (1):
widget: kvlist: add widget for user-modifiable data tables
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
proxmox-datacenter-manager:
Christoph Heiss (11):
api-types, cli: use ReturnType::new() instead of constructing it
manually
api-types: add api types for auto-installer integration
config: add auto-installer configuration module
acl: wire up new /system/auto-installation acl path
server: api: add auto-installer integration module
server: api: auto-installer: add access token management endpoints
client: add bindings for auto-installer endpoints
ui: auto-installer: add installations overview panel
ui: auto-installer: add prepared answer configuration panel
ui: auto-installer: add access token configuration panel
docs: add documentation for auto-installer integration
Cargo.toml | 5 +
cli/client/src/pbs.rs | 10 +-
cli/client/src/pve.rs | 15 +-
cli/client/src/remotes.rs | 5 +-
debian/control | 5 +
docs/automated-installations.rst | 124 ++
docs/index.rst | 1 +
lib/pdm-api-types/Cargo.toml | 3 +
lib/pdm-api-types/src/acl.rs | 4 +-
lib/pdm-api-types/src/auto_installer.rs | 415 ++++++
lib/pdm-api-types/src/lib.rs | 10 +-
lib/pdm-buildcfg/src/lib.rs | 10 +
lib/pdm-client/src/lib.rs | 232 ++++
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 +
server/Cargo.toml | 4 +
server/src/api/auto_installer/mod.rs | 1218 +++++++++++++++++
server/src/api/mod.rs | 2 +
ui/Cargo.toml | 2 +
.../configuration/permission_path_selector.rs | 1 +
.../auto_installer/installations_panel.rs | 305 +++++
ui/src/remotes/auto_installer/mod.rs | 86 ++
.../prepared_answer_add_wizard.rs | 192 +++
.../prepared_answer_edit_window.rs | 187 +++
.../auto_installer/prepared_answer_form.rs | 875 ++++++++++++
.../auto_installer/prepared_answers_panel.rs | 248 ++++
ui/src/remotes/auto_installer/token_panel.rs | 476 +++++++
.../remotes/auto_installer/token_selector.rs | 137 ++
ui/src/remotes/mod.rs | 10 +
31 files changed, 5122 insertions(+), 30 deletions(-)
create mode 100644 docs/automated-installations.rst
create mode 100644 lib/pdm-api-types/src/auto_installer.rs
create mode 100644 lib/pdm-config/src/auto_install.rs
create mode 100644 server/src/api/auto_installer/mod.rs
create mode 100644 ui/src/remotes/auto_installer/installations_panel.rs
create mode 100644 ui/src/remotes/auto_installer/mod.rs
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
create mode 100644 ui/src/remotes/auto_installer/token_panel.rs
create mode 100644 ui/src/remotes/auto_installer/token_selector.rs
pve-installer:
Christoph Heiss (14):
install: iso env: use JSON boolean literals for product config
common: http: allow passing custom headers to post()
common: options: move regex construction out of loop
assistant: support adding an authorization token for HTTP-based
answers
tree-wide: used moved `Fqdn` type to proxmox-network-types
tree-wide: use `Cidr` type from proxmox-network-types
tree-wide: switch to filesystem types from proxmox-installer-types
post-hook: switch to types in proxmox-installer-types
auto: sysinfo: switch to types from proxmox-installer-types
fetch-answer: switch to types from proxmox-installer-types
fetch-answer: http: prefer json over toml for answer format
fetch-answer: send auto-installer HTTP authorization token if set
tree-wide: switch out `Answer` -> `AutoInstallerConfig` types
auto: drop now-dead answer file definitions
Cargo.toml | 8 +
Proxmox/Install/ISOEnv.pm | 16 +-
proxmox-auto-install-assistant/Cargo.toml | 1 +
proxmox-auto-install-assistant/src/main.rs | 35 +-
proxmox-auto-installer/Cargo.toml | 2 +
proxmox-auto-installer/src/answer.rs | 499 -------------
.../src/bin/proxmox-auto-installer.rs | 20 +-
proxmox-auto-installer/src/lib.rs | 2 -
proxmox-auto-installer/src/sysinfo.rs | 91 +--
proxmox-auto-installer/src/udevinfo.rs | 11 -
proxmox-auto-installer/src/utils.rs | 154 ++--
proxmox-auto-installer/tests/parse-answer.rs | 6 +-
.../tests/resources/iso-info.json | 4 +-
.../ipv4_and_subnet_mask_33.json | 2 +-
...rface_pinning_overlong_interface_name.json | 2 +-
proxmox-chroot/Cargo.toml | 1 +
proxmox-chroot/src/main.rs | 60 +-
proxmox-fetch-answer/Cargo.toml | 2 +-
.../src/fetch_plugins/http.rs | 116 ++-
proxmox-fetch-answer/src/main.rs | 21 +-
proxmox-installer-common/Cargo.toml | 2 +
proxmox-installer-common/src/disk_checks.rs | 5 +-
proxmox-installer-common/src/dmi.rs | 43 ++
proxmox-installer-common/src/http.rs | 40 +-
proxmox-installer-common/src/lib.rs | 6 +-
proxmox-installer-common/src/options.rs | 365 +++------
proxmox-installer-common/src/setup.rs | 103 +--
proxmox-installer-common/src/sysinfo.rs | 52 --
proxmox-installer-common/src/utils.rs | 382 ----------
proxmox-post-hook/Cargo.toml | 4 +-
proxmox-post-hook/src/main.rs | 691 +++++++-----------
proxmox-tui-installer/Cargo.toml | 2 +
proxmox-tui-installer/src/main.rs | 12 +-
proxmox-tui-installer/src/options.rs | 23 +-
proxmox-tui-installer/src/setup.rs | 5 +-
proxmox-tui-installer/src/views/bootdisk.rs | 44 +-
proxmox-tui-installer/src/views/mod.rs | 21 +-
proxmox-tui-installer/src/views/network.rs | 15 +-
38 files changed, 779 insertions(+), 2089 deletions(-)
delete mode 100644 proxmox-auto-installer/src/answer.rs
delete mode 100644 proxmox-auto-installer/src/udevinfo.rs
create mode 100644 proxmox-installer-common/src/dmi.rs
delete mode 100644 proxmox-installer-common/src/sysinfo.rs
delete mode 100644 proxmox-installer-common/src/utils.rs
Summary over all repositories:
88 files changed, 8540 insertions(+), 2132 deletions(-)
--
Generated by murpp 0.11.0+ch1
^ permalink raw reply [flat|nested] 39+ messages in thread
* [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
end of thread, other threads:[~2026-04-03 16:58 UTC | newest]
Thread overview: 39+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
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 ` [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
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 ` [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn Christoph Heiss
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 ` [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
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 ` [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
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 ` [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer Christoph Heiss
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 ` [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables 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
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 ` [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Christoph Heiss
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 ` [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Christoph Heiss
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 ` [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints Christoph Heiss
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 ` [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token " Christoph Heiss
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 ` [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config Christoph Heiss
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 ` [PATCH installer v3 27/38] common: options: move regex construction out of loop Christoph Heiss
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 ` [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
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 ` [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
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 ` [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 34/38] fetch-answer: " Christoph Heiss
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 ` [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set 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
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.