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: [PATCH datacenter-manager v4 18/40] server: api: add auto-installer integration module
Date: Thu, 30 Apr 2026 14:46:47 +0200	[thread overview]
Message-ID: <20260430124712.1614305-19-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260430124712.1614305-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 v3 -> v4:
  * switch templating engine from handlebars to minijinja
  * add validation for udev filters and template counters
  * add `proxmox-uuid` dependency on `server`
  * check whether tokens are actually enabled before verifying them
  * strip additional slashes from constructed post-hook url
  * use new create/update api result types
  * only allow post-hook to continue on in-progress installations
  * use `Bearer` instead of `ProxmoxInstallerToken` in HTTP
    Authorization header

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                           |   2 +
 debian/control                       |   2 +
 server/Cargo.toml                    |   5 +
 server/src/api/auto_installer/mod.rs | 966 +++++++++++++++++++++++++++
 server/src/api/mod.rs                |   2 +
 5 files changed, 977 insertions(+)
 create mode 100644 server/src/api/auto_installer/mod.rs

diff --git a/Cargo.toml b/Cargo.toml
index 166e227..fc1df08 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -104,6 +104,7 @@ async-stream = "0.3"
 async-trait = "0.1"
 const_format = "0.2"
 futures = "0.3"
+glob = "0.3"
 hex = "0.4.3"
 http = "1"
 http-body-util = "0.1.2"
@@ -111,6 +112,7 @@ hyper = { version = "1", features = [ "full" ] }
 hyper-util = "0.1"
 libc = "0.2"
 log = "0.4.17"
+minijinja = "2.19"
 nix = "0.29"
 once_cell = "1.3.1"
 openssl = "0.10.40"
diff --git a/debian/control b/debian/control
index 3bdd611..0a38111 100644
--- a/debian/control
+++ b/debian/control
@@ -17,6 +17,7 @@ Build-Depends: debhelper-compat (= 13),
                librust-async-trait-0.1+default-dev,
                librust-const-format-0.2+default-dev,
                librust-futures-0.3+default-dev,
+               librust-glob-0.3-dev,
                librust-hex-0.4+default-dev (>= 0.4.3-~~),
                librust-http-1+default-dev,
                librust-http-body-util-0.1+default-dev (>= 0.1.2-~~),
@@ -25,6 +26,7 @@ Build-Depends: debhelper-compat (= 13),
                librust-hyper-util-0.1+default-dev,
                librust-libc-0.2+default-dev,
                librust-log-0.4+default-dev (>= 0.4.17-~~),
+               librust-minijinja-2.19-dev,
                librust-nix-0.29+default-dev,
                librust-once-cell-1+default-dev (>= 1.3.1-~~),
                librust-openssl-0.10+default-dev (>= 0.10.40-~~),
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 0d6371d..e46184c 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -14,6 +14,7 @@ async-stream.workspace = true
 async-trait.workspace = true
 const_format.workspace = true
 futures.workspace = true
+glob.workspace = true
 hex.workspace = true
 http.workspace = true
 http-body-util.workspace = true
@@ -21,6 +22,7 @@ hyper.workspace = true
 hyper-util.workspace = true
 libc.workspace = true
 log.workspace = true
+minijinja.workspace = true
 nix.workspace = true
 once_cell.workspace = true
 openssl.workspace = true
@@ -39,10 +41,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"] }
@@ -57,6 +61,7 @@ proxmox-sys = { workspace = true, features = [ "timer" ] }
 proxmox-systemd.workspace = true
 proxmox-tfa = { workspace = true, features = [ "api" ] }
 proxmox-time.workspace = true
+proxmox-uuid.workspace = true
 
 proxmox-apt = { workspace = true, features = [ "cache" ] }
 proxmox-apt-api-types.workspace = true
diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs
new file mode 100644
index 0000000..852a233
--- /dev/null
+++ b/server/src/api/auto_installer/mod.rs
@@ -0,0 +1,966 @@
+//! Implements all the methods under `/api2/json/auto-install/`.
+
+use anyhow::{anyhow, Result};
+use http::StatusCode;
+use std::collections::{BTreeMap, HashMap};
+
+use pdm_api_types::{
+    auto_installer::{
+        AnswerToken, DeletablePreparedInstallationConfigProperty, Installation, InstallationStatus,
+        PreparedInstallationConfig, PreparedInstallationConfigCreateResult,
+        PreparedInstallationConfigUpdateResult, PreparedInstallationConfigUpdater,
+        INSTALLATION_UUID_SCHEMA, PREPARED_INSTALL_CONFIG_ID_SCHEMA, TEMPLATE_COUNTER_NAME_REGEX,
+        UDEV_FILTER_KEY_REGEX,
+    },
+    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};
+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,
+    &<AutoInstallerConfig as ApiType>::API_SCHEMA,
+))
+.access(
+    Some("Implemented through specialized bearer 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. Bearer <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() != "bearer" {
+        return None;
+    }
+
+    let _lock = pdm_config::auto_install::tokens_read_lock();
+    let (tokens, _) = pdm_config::auto_install::read_tokens().ok()?;
+
+    let (id, secret) = token.split_once(':').unwrap_or_default();
+
+    let token: AnswerToken = tokens.get(id)?.clone().into();
+    if !token.enabled.unwrap_or(true) {
+        return None;
+    }
+
+    pdm_config::auto_install::verify_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 = 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 the user defined a base url
+        if let Some(base_url) = config.post_hook_base_url {
+            answer.post_installation_webhook = Some(PostNotificationHookInfo {
+                url: format!(
+                    "{}/api2/json/auto-install/installations/{uuid}/post-hook",
+                    base_url.trim_end_matches('/')
+                ),
+                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),
+    },
+    returns: {
+        type: PreparedInstallationConfigCreateResult,
+    },
+)]
+/// POST /auto-install/prepared
+///
+/// Creates a new prepared answer file.
+async fn create_prepared_answer(
+    mut config: PreparedInstallationConfig,
+    root_password: Option<String>,
+) -> Result<PreparedInstallationConfigCreateResult> {
+    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"
+        );
+    }
+
+    validate_udev_filter_map(&config.netdev_filter)?;
+    validate_udev_filter_map(&config.disk_filter)?;
+    validate_template_map(&config.template_counters)?;
+
+    prepared.insert(config.id.clone(), config.clone().try_into()?);
+    pdm_config::auto_install::save_prepared_answers(&prepared)?;
+
+    Ok(PreparedInstallationConfigCreateResult {
+        config,
+        token: None,
+    })
+}
+
+#[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),
+    },
+    returns: {
+        type: PreparedInstallationConfigCreateResult,
+    },
+)]
+/// 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<PreparedInstallationConfigUpdateResult> {
+    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(filter) = target_filter {
+        **p.target_filter = 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 {
+        validate_udev_filter_map(&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 {
+        validate_udev_filter_map(&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 {
+        validate_template_map(&counters)?;
+        **p.template_counters = counters;
+    }
+
+    let config = p.clone().try_into()?;
+    pdm_config::auto_install::save_prepared_answers(&prepared)?;
+
+    Ok(PreparedInstallationConfigCreateResult {
+        config,
+        token: None,
+    })
+}
+
+#[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)
+}
+
+fn validate_udev_filter_map(map: &BTreeMap<String, String>) -> Result<()> {
+    for k in map.keys() {
+        if !UDEV_FILTER_KEY_REGEX.is_match(k) {
+            http_bail!(BAD_REQUEST, "invalid udev filter key: '{k}'");
+        }
+    }
+    Ok(())
+}
+
+fn validate_template_map<T>(map: &BTreeMap<String, T>) -> Result<()> {
+    for k in map.keys() {
+        if !TEMPLATE_COUNTER_NAME_REGEX.is_match(k) {
+            http_bail!(BAD_REQUEST, "invalid template counter name: '{k}'");
+        }
+    }
+    Ok(())
+}
+
+#[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(|inst| {
+        inst.uuid == uuid
+            && inst.status == InstallationStatus::InProgress
+            && inst.post_hook_data.is_none()
+    }) {
+        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 jinja = minijinja::Environment::new();
+    let mut context = serde_json::to_value(sysinfo)?;
+    if let Some(obj) = context.as_object_mut() {
+        for (k, v) in conf.template_counters.iter() {
+            obj.insert(k.clone(), (*v).into());
+        }
+    }
+
+    let fqdn = if conf.use_dhcp_fqdn {
+        answer::FqdnConfig::from_dhcp(None)
+    } else {
+        let fqdn = jinja.render_named_str("fqdn", &conf.fqdn.to_string(), &context)?;
+        answer::FqdnConfig::Simple(Fqdn::from(&fqdn)?)
+    };
+
+    let mailto = jinja.render_named_str("mailto", &conf.mailto, &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(jinja.render_named_str("cidr", &cidr.to_string(), &context)?))
+                .and_then(|s| Ok(s.parse()?))?;
+
+            let dns = conf
+                .dns
+                .ok_or_else(|| anyhow!("no DNS server address"))
+                .and_then(|cidr| Ok(jinja.render_named_str("dns", &cidr.to_string(), &context)?))
+                .and_then(|s| Ok(s.parse()?))?;
+
+            let gateway = conf
+                .gateway
+                .ok_or_else(|| anyhow!("no gateway address"))
+                .and_then(|cidr| {
+                    Ok(jinja.render_named_str("gateway", &cidr.to_string(), &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(())
+}
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 9c1890a..110191b 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 nodes;
 pub mod pbs;
@@ -21,6 +22,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-30 12:49 UTC|newest]

Thread overview: 41+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-30 12:46 [PATCH datacenter-manager/installer/proxmox/yew-comp v4 00/40] add auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 01/40] api-macro: allow $ in identifier name Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 02/40] schema: oneOf: allow single string variant Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 03/40] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 04/40] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 05/40] network-types: implement api type for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 06/40] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 07/40] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 08/40] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 09/40] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 10/40] installer-types: add common types used by the installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 11/40] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 12/40] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-30 12:46 ` [PATCH yew-comp v4 13/40] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 14/40] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 15/40] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 16/40] config: add auto-installer configuration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 17/40] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-30 12:46 ` Christoph Heiss [this message]
2026-04-30 12:46 ` [PATCH datacenter-manager v4 19/40] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 20/40] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 21/40] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 22/40] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 23/40] ui: auto-installer: add access token " Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 24/40] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 25/40] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 26/40] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 27/40] common: http: retrieve error message from body on post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 28/40] common: options: move regex construction out of loop Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 29/40] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 30/40] post-hook: run cargo fmt Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 31/40] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 32/40] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 33/40] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 34/40] auto: sysinfo: switch to " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 35/40] fetch-answer: " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 36/40] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 37/40] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 38/40] fetch-answer: print full error messages when fetching failed Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 39/40] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 40/40] 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=20260430124712.1614305-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 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