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: [pdm-devel] [PATCH datacenter-manager 11/13] server: api: add auto-installer integration module
Date: Thu,  4 Dec 2025 13:51:20 +0100	[thread overview]
Message-ID: <20251204125122.945961-12-c.heiss@proxmox.com> (raw)
In-Reply-To: <20251204125122.945961-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.

Short 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

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

DELETE /auto-install/prepared
     delete an existing prepared answer file configuration

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
The auto-installer (currently) only supports TOML as input format for
the answer, so we need to "hack" around a bit to serve TOML in the API.
See also the cover letter for a bit more discussion.

 Cargo.toml                           |   2 +
 debian/control                       |   2 +
 server/Cargo.toml                    |   4 +
 server/src/api/auto_installer/mod.rs | 654 +++++++++++++++++++++++++++
 server/src/api/mod.rs                |   2 +
 5 files changed, 664 insertions(+)
 create mode 100644 server/src/api/auto_installer/mod.rs

diff --git a/Cargo.toml b/Cargo.toml
index 9c1f0c2..c897eda 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"
@@ -131,6 +132,7 @@ tokio = "1.6"
 tokio-openssl = "0.6.1"
 tokio-stream = "0.1.0"
 tokio-util = { version = "0.7", features = [ "io" ] }
+toml.version = "0.8"
 tower-service = "0.3.0"
 tracing = "0.1"
 url = "2.1"
diff --git a/debian/control b/debian/control
index b1ce92a..59d5f63 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-~~),
@@ -130,6 +131,7 @@ Build-Depends: debhelper-compat (= 13),
                librust-tokio-1+signal-dev (>= 1.6-~~),
                librust-tokio-1+time-dev (>= 1.6-~~),
                librust-tokio-stream-0.1+default-dev,
+               librust-toml-0.8+default-dev,
                librust-tracing-0.1+default-dev,
                librust-url-2+default-dev (>= 2.1-~~),
                librust-webauthn-rs-core-0.5+default-dev,
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 6969549..4501a15 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
@@ -31,6 +32,7 @@ serde_plain.workspace = true
 syslog.workspace = true
 tokio = { workspace = true, features = [ "fs", "io-util", "io-std", "macros", "net", "parking_lot", "process", "rt", "rt-multi-thread", "signal", "time" ] }
 tokio-stream.workspace = true
+toml.workspace = true
 tracing.workspace = true
 url.workspace = true
 zstd.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..1412e99
--- /dev/null
+++ b/server/src/api/auto_installer/mod.rs
@@ -0,0 +1,654 @@
+//! Implements all the methods under `/api2/*/access/auto-install/`.
+
+use anyhow::{anyhow, Result};
+use std::time::SystemTime;
+
+use pdm_api_types::{
+    auto_installer::{
+        Installation, InstallationStatus, PreparedInstallationConfig,
+        PreparedInstallationConfigDeletableProperty, PreparedInstallationConfigUpdater,
+        INSTALLATION_UUID_SCHEMA, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+    },
+    ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA,
+};
+use proxmox_installer_types::{
+    answer::{
+        fetch::{AnswerFetchData, AnswerFetchResponse},
+        AutoInstallerConfig, PostNotificationHookInfo, ROOT_PASSWORD_SCHEMA,
+    },
+    post_hook::PostHookInfo,
+};
+use proxmox_router::{
+    http_bail, http_err, 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)),
+    (
+        "prepared",
+        &Router::new()
+            .get(&API_METHOD_LIST_PREPARED)
+            .post(&API_METHOD_CREATE_PREPARED)
+            .match_all(
+                "id",
+                &Router::new()
+                    .get(&API_METHOD_GET_PREPARED)
+                    .put(&API_METHOD_UPDATE_PREPARED)
+                    .delete(&API_METHOD_DELETE_PREPARED)
+            )
+    ),
+    (
+        "installations",
+        &Router::new().get(&API_METHOD_LIST_INSTALLATIONS).match_all(
+            "uuid",
+            &Router::new()
+                .delete(&API_METHOD_DELETE_INSTALLATION)
+                .subdirs(SUBDIR_INSTALLATION_PER_ID)
+        )
+    ),
+]);
+
+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("proxmox-fetch-answer only sends unauthenticated requests."),
+    &Permission::World,
+)
+.protected(false);
+
+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 response = serde_json::from_value::<AnswerFetchData>(param)
+            .map_err(|err| anyhow!("failed to deserialize body: {err:?}"))
+            .and_then(new_installation)
+            .and_then(|x| {
+                toml::to_string(&x).map_err(|err| anyhow!("failed to deserialize body: {err:?}"))
+            });
+
+        match response {
+            Ok(value) => Ok(http::Response::builder()
+                .status(200)
+                .header(http::header::CONTENT_TYPE, "application/toml")
+                .body(value.into())?),
+            Err(err) => Ok(http::Response::builder()
+                .status(401)
+                .header(http::header::CONTENT_TYPE, "text/plain")
+                .body(format!("{err:?}").into())?),
+        }
+    })
+}
+
+/// POST /auto-install/answer
+///
+/// Either returns a auto-installer configuration if a matching one is found or errors out.
+/// The system information data is saved in any case to make them easily inspectable.
+fn new_installation(payload: AnswerFetchData) -> Result<AnswerFetchResponse> {
+    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 = SystemTime::now()
+        .duration_since(SystemTime::UNIX_EPOCH)?
+        .as_secs() as i64;
+
+    if let Some(config) = find_config(&payload.sysinfo)? {
+        let status = if config.post_hook_base_url.is_some() {
+            InstallationStatus::InProgress
+        } else {
+            InstallationStatus::Finished
+        };
+
+        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
+        let mut answer: AutoInstallerConfig = config.clone().try_into()?;
+        if let Some(base_url) = config.post_hook_base_url {
+            answer.post_installation_webhook = Some(PostNotificationHookInfo {
+                url: format!("{base_url}/auto-install/installations/{uuid}/post-hook"),
+                cert_fingerprint: config.post_hook_cert_fp.clone(),
+            });
+        }
+
+        pdm_config::auto_install::save_installations(&installations)?;
+        Ok(AnswerFetchResponse::Configuration(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(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PreparedInstallationConfig>> {
+    let (prepared, digest) = pdm_config::auto_install::read_prepared_answers()?;
+
+    rpcenv["digest"] = hex::encode(digest).into();
+
+    Ok(prepared
+        .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+        .into_iter()
+        .map(|mut p| {
+            p.root_password_hashed = None;
+            p
+        })
+        .collect())
+}
+
+#[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(
+    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
+        .lookup::<PreparedInstallationConfig>("prepared-answer", &config.id)
+        .is_ok()
+    {
+        http_bail!(
+            CONFLICT,
+            "configuration with ID {} already exists",
+            config.id
+        );
+    }
+
+    if config.is_default {
+        if let Some(p) = prepared
+            .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+            .iter()
+            .find(|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.set_data(&config.id.clone(), "prepared-answer", config)?;
+    pdm_config::auto_install::save_prepared_answers(&prepared)?;
+    Ok(())
+}
+
+#[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(id: String) -> Result<PreparedInstallationConfig> {
+    let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?;
+
+    if let Ok(mut p) = prepared.lookup::<PreparedInstallationConfig>("prepared-answer", &id) {
+        // Don't send the hashed password, the user cannot do anything with it anyway
+        p.root_password_hashed = None;
+        Ok(p)
+    } 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: PreparedInstallationConfigDeletableProperty,
+                }
+            },
+            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(
+    id: String,
+    update: PreparedInstallationConfigUpdater,
+    root_password: Option<String>,
+    delete: Option<Vec<PreparedInstallationConfigDeletableProperty>>,
+    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())?;
+
+    let mut p = prepared
+        .lookup::<PreparedInstallationConfig>("prepared-answer", &id)
+        .map_err(|_| http_err!(NOT_FOUND, "no such prepared answer configuration: {id}"))?;
+
+    if let Some(delete) = delete {
+        for prop in delete {
+            match prop {
+                PreparedInstallationConfigDeletableProperty::TargetFilter => {
+                    p.target_filter = Vec::new();
+                }
+                PreparedInstallationConfigDeletableProperty::NetdevFilter => {
+                    p.netdev_filter = Vec::new();
+                }
+                PreparedInstallationConfigDeletableProperty::DiskFilter => {
+                    p.disk_filter = Vec::new()
+                }
+                PreparedInstallationConfigDeletableProperty::RootSshKeys => {
+                    p.root_ssh_keys = Vec::new();
+                }
+                PreparedInstallationConfigDeletableProperty::PostHookBaseUrl => {
+                    p.post_hook_base_url = None;
+                }
+                PreparedInstallationConfigDeletableProperty::PostHookCertFp => {
+                    p.post_hook_cert_fp = None;
+                }
+            }
+        }
+    }
+
+    if let Some(target_filter) = update.target_filter {
+        p.target_filter = target_filter;
+    }
+
+    if let Some(is_default) = update.is_default {
+        if is_default {
+            if let Some(other) = prepared
+                .convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")?
+                .iter()
+                .find(|p| p.is_default)
+            {
+                http_bail!(
+                    CONFLICT,
+                    "configuration '{}' is already the default answer",
+                    other.id
+                );
+            }
+        }
+
+        p.is_default = is_default;
+    }
+
+    if let Some(country) = update.country {
+        p.country = country;
+    }
+
+    if let Some(fqdn) = update.fqdn {
+        p.fqdn = fqdn;
+    }
+
+    if let Some(use_dhcp) = update.use_dhcp_fqdn {
+        p.use_dhcp_fqdn = use_dhcp;
+    }
+
+    if let Some(keyboard) = update.keyboard {
+        p.keyboard = keyboard;
+    }
+
+    if let Some(mailto) = update.mailto {
+        p.mailto = mailto;
+    }
+
+    if let Some(timezone) = update.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) = update.root_password_hashed {
+        p.root_password_hashed = Some(password);
+    }
+
+    if let Some(reboot_on_error) = update.reboot_on_error {
+        p.reboot_on_error = reboot_on_error;
+    }
+
+    if let Some(reboot_mode) = update.reboot_mode {
+        p.reboot_mode = reboot_mode;
+    }
+
+    if let Some(ssh_keys) = update.root_ssh_keys {
+        p.root_ssh_keys = if ssh_keys.is_empty() {
+            Vec::new()
+        } else {
+            ssh_keys
+        };
+    }
+
+    if let Some(use_dhcp) = update.use_dhcp_network {
+        p.use_dhcp_network = use_dhcp;
+    }
+
+    if let Some(cidr) = update.cidr {
+        p.cidr = Some(cidr);
+    }
+
+    if let Some(gateway) = update.gateway {
+        p.gateway = Some(gateway);
+    }
+
+    if let Some(dns) = update.dns {
+        p.dns = Some(dns);
+    }
+
+    if let Some(filter) = update.netdev_filter {
+        p.netdev_filter = filter;
+    }
+
+    if let Some(enabled) = update.netif_name_pinning_enabled {
+        p.netif_name_pinning_enabled = enabled;
+    }
+
+    if let Some(typ) = update.filesystem_type {
+        p.filesystem_type = typ;
+    }
+
+    if let Some(options) = update.filesystem_options {
+        p.filesystem_options = options;
+    }
+
+    if let Some(mode) = update.disk_mode {
+        p.disk_mode = mode;
+    }
+
+    if let Some(list) = update.disk_list {
+        p.disk_list = if list.is_empty() { None } else { Some(list) };
+    }
+
+    if let Some(filter) = update.disk_filter {
+        p.disk_filter = filter;
+    }
+
+    if let Some(filter_match) = update.disk_filter_match {
+        p.disk_filter_match = Some(filter_match);
+    }
+
+    if let Some(url) = update.post_hook_base_url {
+        p.post_hook_base_url = Some(url);
+    }
+
+    if let Some(fp) = update.post_hook_cert_fp {
+        p.post_hook_cert_fp = Some(fp);
+    }
+
+    prepared.set_data(&id, "prepared-answer", p)?;
+    pdm_config::auto_install::save_prepared_answers(&prepared)?;
+
+    Ok(())
+}
+
+#[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(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.sections.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(())
+}
+
+fn find_config(
+    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 p in prepared.convert_to_typed_array::<PreparedInstallationConfig>("prepared-answer")? {
+        if p.is_default {
+            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| {
+            let pattern = match glob::Pattern::new(&filter.value) {
+                Ok(pattern) => pattern,
+                _ => return false,
+            };
+
+            if let Some(value) = info.pointer(&filter.key).and_then(|v| v.as_str()) {
+                pattern.matches(value)
+            } else {
+                false
+            }
+        });
+
+        if matched_all {
+            return Ok(Some(p.clone()));
+        }
+    }
+
+    // If no specific target filter(s) matched, return the default answer, if there is one
+    Ok(default_answer)
+}
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.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-04 12:52 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-12-04 12:51 [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 01/13] api-macro: allow $ in identifier name Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 02/13] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 03/13] network-types: implement api type for Fqdn Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 04/13] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 05/13] installer-types: add common types used by the installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 06/13] installer-types: add types used by the auto-installer Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH proxmox 07/13] installer-types: implement api type for all externally-used types Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 08/13] api-types: add api types for auto-installer integration Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 09/13] config: add auto-installer configuration module Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 10/13] acl: wire up new /system/auto-installation acl path Christoph Heiss
2025-12-04 12:51 ` Christoph Heiss [this message]
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 12/13] ui: auto-installer: add installations overview panel Christoph Heiss
2025-12-04 12:51 ` [pdm-devel] [PATCH datacenter-manager 13/13] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2025-12-04 14:17 ` [pdm-devel] [PATCH proxmox/datacenter-manager 00/13] initial auto-installer integration Lukas Wagner
2025-12-04 15:06   ` Christoph Heiss
2025-12-05 11:26 ` 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=20251204125122.945961-12-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