all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v2 08/14] api-types: add api types for auto-installer integration
Date: Fri,  5 Dec 2025 12:25:10 +0100	[thread overview]
Message-ID: <20251205112528.373387-9-c.heiss@proxmox.com> (raw)
In-Reply-To: <20251205112528.373387-1-c.heiss@proxmox.com>

The `Installation` type represents an individual installation done
through PDM acting as auto-install server, and
`PreparedInstallationConfig` a configuration provided by the user for
automatically responding to answer requests based on certain target
filters.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * no changes

 Cargo.toml                              |   4 +
 debian/control                          |   3 +
 lib/pdm-api-types/Cargo.toml            |   3 +
 lib/pdm-api-types/src/auto_installer.rs | 441 ++++++++++++++++++++++++
 lib/pdm-api-types/src/lib.rs            |   1 +
 5 files changed, 452 insertions(+)
 create mode 100644 lib/pdm-api-types/src/auto_installer.rs

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



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  parent reply	other threads:[~2025-12-05 11:25 UTC|newest]

Thread overview: 18+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-12-05 11:25 [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 01/14] api-macro: allow $ in identifier name Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 02/14] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 03/14] network-types: implement api type for Fqdn Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 04/14] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 05/14] installer-types: add common types used by the installer Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 06/14] installer-types: add types used by the auto-installer Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH proxmox v2 07/14] installer-types: implement api type for all externally-used types Christoph Heiss
2025-12-05 11:25 ` Christoph Heiss [this message]
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 09/14] config: add auto-installer configuration module Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 10/14] acl: wire up new /system/auto-installation acl path Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 12/14] ui: auto-installer: add installations overview panel Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 13/14] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2025-12-05 11:25 ` [pdm-devel] [PATCH datacenter-manager v2 14/14] docs: add documentation for auto-installer integration Christoph Heiss
2025-12-05 11:53 ` [pdm-devel] [PATCH proxmox/datacenter-manager v2 00/14] initial " Thomas Lamprecht
2025-12-05 15:50   ` Christoph Heiss
2025-12-05 15:57     ` Thomas Lamprecht

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20251205112528.373387-9-c.heiss@proxmox.com \
    --to=c.heiss@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal