public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module
Date: Fri,  3 Apr 2026 18:53:50 +0200	[thread overview]
Message-ID: <20260403165437.2166551-19-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260403165437.2166551-1-c.heiss@proxmox.com>

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





  parent reply	other threads:[~2026-04-03 16:56 UTC|newest]

Thread overview: 39+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 ` Christoph Heiss [this message]
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

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=20260403165437.2166551-19-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal