From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 3C0671FF1A6 for ; Fri, 5 Dec 2025 12:26:15 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 3760B156CA; Fri, 5 Dec 2025 12:26:43 +0100 (CET) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Date: Fri, 5 Dec 2025 12:25:13 +0100 Message-ID: <20251205112528.373387-12-c.heiss@proxmox.com> X-Mailer: git-send-email 2.51.2 In-Reply-To: <20251205112528.373387-1-c.heiss@proxmox.com> References: <20251205112528.373387-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1764933939249 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.948 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager v2 11/14] server: api: add auto-installer integration module X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" Adds the required API surface for managing prepared answer files and viewing past installations via the UI, as well as serving answer files to proxmox-auto-installer. 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 --- 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. Changes v1 -> v2: * fixed compilation error due to leftover, unresolved type Cargo.toml | 2 + debian/control | 2 + server/Cargo.toml | 4 + server/src/api/auto_installer/mod.rs | 653 +++++++++++++++++++++++++++ server/src/api/mod.rs | 2 + 5 files changed, 663 insertions(+) create mode 100644 server/src/api/auto_installer/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 5021531..5893db5 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..ec5752a --- /dev/null +++ b/server/src/api/auto_installer/mod.rs @@ -0,0 +1,653 @@ +//! 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, 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 + "#, + &[&::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, +) -> ApiResponseFuture { + Box::pin(async move { + let response = serde_json::from_value::(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 { + 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(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> { + 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> { + let (prepared, digest) = pdm_config::auto_install::read_prepared_answers()?; + + rpcenv["digest"] = hex::encode(digest).into(); + + Ok(prepared + .convert_to_typed_array::("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, +) -> Result<()> { + let _lock = pdm_config::auto_install::prepared_answers_write_lock(); + let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + if prepared + .lookup::("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::("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 { + let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + if let Ok(mut p) = prepared.lookup::("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, + delete: Option>, + digest: Option, +) -> 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::("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::("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> { + 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::("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