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 4755D1FF13E for ; Fri, 03 Apr 2026 18:56:23 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 0EBB48685; Fri, 3 Apr 2026 18:56:54 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Date: Fri, 3 Apr 2026 18:53:48 +0200 Message-ID: <20260403165437.2166551-17-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260403165437.2166551-1-c.heiss@proxmox.com> References: <20260403165437.2166551-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1775235308617 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.085 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_SHORT 0.001 Use of a URL Shortener for very short URL POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: T2NL3J3NZNED4O23RV3QCJFDTS5Q4MSQ X-Message-ID-Hash: T2NL3J3NZNED4O23RV3QCJFDTS5Q4MSQ X-MailFrom: c.heiss@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Provides some primitives for the auto-installer integration to save state about - individual installations (as plain JSON, as these aren't configurations files) and - prepared answer file configurations, as section config The new files (including lock files) are placed under `/etc/proxmox-datacenter-manager/autoinst`. Signed-off-by: Christoph Heiss --- Changes v2 -> v3: * added answer authentication token interface * added separate type from `PreparedInstallationConfig` api type for use with configuration, mapping maps to propertystrings * moved state files to /var/lib/proxmox-datacenter-manager Changes v1 -> v2: * no changes lib/pdm-buildcfg/src/lib.rs | 10 + lib/pdm-config/Cargo.toml | 3 + lib/pdm-config/src/auto_install.rs | 559 +++++++++++++++++++++++++++++ lib/pdm-config/src/lib.rs | 1 + lib/pdm-config/src/setup.rs | 7 + 5 files changed, 580 insertions(+) create mode 100644 lib/pdm-config/src/auto_install.rs diff --git a/lib/pdm-buildcfg/src/lib.rs b/lib/pdm-buildcfg/src/lib.rs index 9380972..734e95d 100644 --- a/lib/pdm-buildcfg/src/lib.rs +++ b/lib/pdm-buildcfg/src/lib.rs @@ -106,3 +106,13 @@ macro_rules! rundir { concat!($crate::PDM_RUN_DIR_M!(), $subdir) }; } + +/// Prepend the state directory to a file name. +/// +/// This is a simply way to get the full path for files in `/var/lib/`. +#[macro_export] +macro_rules! statedir { + ($subdir:expr) => { + concat!($crate::PDM_STATE_DIR_M!(), $subdir) + }; +} diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml index d39c2ad..17ca27e 100644 --- a/lib/pdm-config/Cargo.toml +++ b/lib/pdm-config/Cargo.toml @@ -12,6 +12,7 @@ nix.workspace = true once_cell.workspace = true openssl.workspace = true serde.workspace = true +serde_json.workspace = true proxmox-config-digest = { workspace = true, features = [ "openssl" ] } proxmox-http = { workspace = true, features = [ "http-helpers" ] } @@ -23,5 +24,7 @@ proxmox-shared-memory.workspace = true proxmox-simple-config.workspace = true proxmox-sys = { workspace = true, features = [ "acl", "crypt", "timer" ] } proxmox-acme-api.workspace = true +proxmox-serde.workspace = true +proxmox-network-types.workspace = true pdm-api-types.workspace = true pdm-buildcfg.workspace = true diff --git a/lib/pdm-config/src/auto_install.rs b/lib/pdm-config/src/auto_install.rs new file mode 100644 index 0000000..fe32d30 --- /dev/null +++ b/lib/pdm-config/src/auto_install.rs @@ -0,0 +1,559 @@ +//! Implements configuration for the auto-installer integration. + +use anyhow::{bail, Result}; +use std::collections::HashMap; + +use pdm_api_types::{ + auto_installer::{AnswerAuthToken, Installation}, + ConfigDigest, +}; +use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard}; +use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData}; + +use crate::auto_install::types::AnswerAuthTokenWrapper; + +pub const CONFIG_PATH: &str = pdm_buildcfg::configdir!("/autoinst"); + +const PREPARED_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/prepared.cfg"); +const PREPARED_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.prepared.lock"); + +const TOKENS_CONF_FILE: &str = pdm_buildcfg::configdir!("/autoinst/tokens.cfg"); +const TOKENS_SHADOW_FILE: &str = pdm_buildcfg::configdir!("/autoinst/tokens.shadow"); +const TOKENS_LOCK_FILE: &str = pdm_buildcfg::configdir!("/autoinst/.tokens.lock"); + +const INSTALLATIONS_STATE_FILE: &str = pdm_buildcfg::statedir!("/automated-installations.json"); +const INSTALLATIONS_LOCK_FILE: &str = pdm_buildcfg::statedir!("/.automated-installations.lock"); + +pub mod types { + use serde::{Deserialize, Serialize}; + use std::{collections::BTreeMap, fmt::Debug}; + + use pdm_api_types::{ + auto_installer::{ + answer, AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig, + PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + CERT_FINGERPRINT_SHA256_SCHEMA, DISK_ARRAY_SCHEMA, HTTP_URL_SCHEMA, + PROXMOX_TOKEN_NAME_SCHEMA, SINGLE_LINE_COMMENT_FORMAT, + }; + use proxmox_network_types::{api_types::IpAddr, Cidr}; + use proxmox_schema::{api, ApiStringFormat, ApiType, PropertyString}; + + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] + /// API wrapper for a [`BTreeMap`]. + pub struct BTreeMapWrapper(BTreeMap); + + impl std::ops::Deref for BTreeMapWrapper { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl std::ops::DerefMut for BTreeMapWrapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl Deserialize<'de> + Serialize> ApiType for BTreeMapWrapper { + const API_SCHEMA: proxmox_schema::Schema = + proxmox_schema::ObjectSchema::new("Map of key-value pairs", &[]) + .additional_properties(true) + .schema(); + } + + #[api( + "id-property": "id", + "id-schema": { + type: String, + description: "ID of prepared configuration for automated installations.", + min_length: 3, + max_length: 64 + } + )] + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] + #[serde(rename_all = "kebab-case", tag = "type")] + /// Wrapper type for using [`PreparedInstallationConfig`] with + /// [`proxmox_schema::typed::SectionConfigData`]. + pub enum PreparedInstallationSectionConfigWrapper { + PreparedConfig(PreparedInstallationSectionConfig), + } + + #[api( + properties: { + id: { + schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + "authorized-tokens": { + type: Array, + optional: true, + items: { + schema: PROXMOX_TOKEN_NAME_SCHEMA, + }, + }, + "is-default": { + optional: true, + }, + "target-filter": { + type: String, + optional: true, + }, + country: { + format: &ApiStringFormat::Pattern(&answer::COUNTRY_CODE_REGEX), + min_length: 2, + max_length: 2, + }, + mailto: { + min_length: 2, + max_length: 256, + format: &SINGLE_LINE_COMMENT_FORMAT, + }, + "root-ssh-keys": { + type: Array, + optional: true, + items: { + type: String, + description: "SSH public key.", + }, + }, + "netdev-filter": { + type: String, + optional: true, + }, + filesystem: { + type: String, + }, + "disk-mode": { + type: String, + }, + "disk-list": { + schema: DISK_ARRAY_SCHEMA, + optional: true, + }, + "disk-filter": { + type: String, + optional: true, + }, + "post-hook-base-url": { + schema: HTTP_URL_SCHEMA, + optional: true, + }, + "post-hook-cert-fp": { + schema: CERT_FINGERPRINT_SHA256_SCHEMA, + optional: true, + }, + "template-counters": { + type: String, + optional: true, + }, + }, + )] + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] + #[serde(rename_all = "kebab-case")] + /// Configuration describing an automated installation. + /// + /// Certain fields support simple templating via [Handlebars]. Currently, following fields will + /// resolve handlebars expression upon instantiation of an answer: + /// + /// * `fqdn` + /// * `mailto` + /// * `cidr` + /// * `gateway` + /// * `dns + /// + /// [Handlebars]: https://handlebarsjs.com/guide/ + pub struct PreparedInstallationSectionConfig { + pub id: String, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// List of token IDs that are authoried to retrieve this answer. + pub authorized_tokens: Vec, + + /// Whether this is the default answer. There can only ever be one default answer. + /// `target_filter` below is ignored if this is `true`. + pub is_default: bool, + + // Target filters + /// Map of filters for matching against a property in [`answer::fetch::AnswerFetchData`]. + /// The keys are JSON Pointers as per [RFC6901], the values globs as accepted + /// by the [glob] crate. + /// + /// Used to check this configuration against incoming automated installation requests. If this + /// is unset, it will match any installation not matched "narrower" by other prepared + /// configurations, thus being the default. + /// + /// [RFC6901] https://datatracker.ietf.org/doc/html/rfc6901 + /// [glob crate] https://docs.rs/glob/ + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub target_filter: PropertyString>, + + // Keys from [`answer::GlobalOptions`], adapted to better fit the API and model of the UI. + /// Country to use for apt mirrors. + pub country: String, + /// FQDN to set for the installed system. Only used if `use_dhcp_fqdn` is true. + /// + /// Supports templating via Handlebars. + /// The [`proxmox_network_types::fqdn::Fqdn`] type cannot be used here + /// because of that, as curly brackets are not valid in hostnames. + pub fqdn: String, + /// Whether to use the FQDN from the DHCP lease or the user-provided one. + pub use_dhcp_fqdn: bool, + /// Keyboard layout to set. + pub keyboard: answer::KeyboardLayout, + /// Mail address for `root@pam`. + /// + /// Supports templating via Handlebars. + pub mailto: String, + /// Timezone to set on the new system. + pub timezone: String, + /// Pre-hashed password to set for the `root` PAM account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_password_hashed: Option, + /// Whether to reboot the machine if an error occurred during the + /// installation. + pub reboot_on_error: bool, + /// Action to take after the installation completed successfully. + pub reboot_mode: answer::RebootMode, + /// Newline-separated list of public SSH keys to set up for the `root` PAM account. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub root_ssh_keys: Vec, + + // Keys from [`answer::NetworkConfig`], adapted to better fit the API and model of the UI. + /// Whether to use the network configuration from the DHCP lease or not. + pub use_dhcp_network: bool, + /// IP address and netmask if not using DHCP. + /// + /// Supports templating via Handlebars. + #[serde(skip_serializing_if = "Option::is_none")] + pub cidr: Option, + /// Gateway if not using DHCP. + /// + /// Supports templating via Handlebars. + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway: Option, + /// DNS server address if not using DHCP. + /// + /// Supports templating via Handlebars. + #[serde(skip_serializing_if = "Option::is_none")] + pub dns: Option, + /// Filter for network devices, to select a specific management interface. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub netdev_filter: PropertyString>, + /// Whether to enable network interface name pinning. + pub netif_name_pinning_enabled: bool, + + /// Root filesystem options. + pub filesystem: PropertyString, + + /// Whether to use the fixed disk list or select disks dynamically by udev filters. + pub disk_mode: DiskSelectionMode, + /// List of raw disk identifiers to use for the root filesystem. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disk_list: Vec, + /// Filter against udev properties to select the disks for the installation, + /// to allow dynamic selection of disks. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub disk_filter: PropertyString>, + /// Whether it is enough that any filter matches on a disk or all given + /// filters must match to select a disk. Only used if `disk_list` is unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk_filter_match: Option, + + /// Post installations hook base URL, i.e. host PDM is reachable as from + /// the target machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_hook_base_url: Option, + /// Post hook certificate fingerprint, if needed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_hook_cert_fp: Option, + + /// Key-value pairs of (auto-incrementing) counters. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub template_counters: PropertyString>, + } + + impl TryFrom for PreparedInstallationSectionConfig { + type Error = anyhow::Error; + + fn try_from(conf: PreparedInstallationConfig) -> Result { + Ok(Self { + id: conf.id, + authorized_tokens: conf.authorized_tokens, + // target filter + is_default: conf.is_default, + target_filter: PropertyString::new(BTreeMapWrapper(conf.target_filter)), + // global options + country: conf.country, + fqdn: conf.fqdn, + use_dhcp_fqdn: conf.use_dhcp_fqdn, + keyboard: conf.keyboard, + mailto: conf.mailto, + timezone: conf.timezone, + root_password_hashed: conf.root_password_hashed, + reboot_on_error: conf.reboot_on_error, + reboot_mode: conf.reboot_mode, + root_ssh_keys: conf.root_ssh_keys, + // network options + use_dhcp_network: conf.use_dhcp_network, + cidr: conf.cidr, + gateway: conf.gateway, + dns: conf.dns, + netdev_filter: PropertyString::new(BTreeMapWrapper(conf.netdev_filter)), + netif_name_pinning_enabled: conf.netif_name_pinning_enabled, + // disk options + filesystem: PropertyString::new(conf.filesystem), + disk_mode: conf.disk_mode, + disk_list: conf.disk_list, + disk_filter: PropertyString::new(BTreeMapWrapper(conf.disk_filter)), + disk_filter_match: conf.disk_filter_match, + // post hook + post_hook_base_url: conf.post_hook_base_url, + post_hook_cert_fp: conf.post_hook_cert_fp, + // templating + template_counters: PropertyString::new(BTreeMapWrapper(conf.template_counters)), + }) + } + } + + impl TryFrom for PreparedInstallationSectionConfigWrapper { + type Error = anyhow::Error; + + fn try_from(conf: PreparedInstallationConfig) -> Result { + Ok(Self::PreparedConfig(conf.try_into()?)) + } + } + + impl TryInto for PreparedInstallationSectionConfig { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + Ok(PreparedInstallationConfig { + id: self.id, + authorized_tokens: self.authorized_tokens, + // target filter + is_default: self.is_default, + target_filter: self.target_filter.into_inner().0, + // global options + country: self.country, + fqdn: self.fqdn, + use_dhcp_fqdn: self.use_dhcp_fqdn, + keyboard: self.keyboard, + mailto: self.mailto, + timezone: self.timezone, + root_password_hashed: self.root_password_hashed, + reboot_on_error: self.reboot_on_error, + reboot_mode: self.reboot_mode, + root_ssh_keys: self.root_ssh_keys, + // network options + use_dhcp_network: self.use_dhcp_network, + cidr: self.cidr, + gateway: self.gateway, + dns: self.dns, + netdev_filter: self.netdev_filter.into_inner().0, + netif_name_pinning_enabled: self.netif_name_pinning_enabled, + // disk options + filesystem: self.filesystem.into_inner(), + disk_mode: self.disk_mode, + disk_list: self.disk_list, + disk_filter: self.disk_filter.into_inner().0, + disk_filter_match: self.disk_filter_match, + // post hook + post_hook_base_url: self.post_hook_base_url, + post_hook_cert_fp: self.post_hook_cert_fp, + // templating + template_counters: self.template_counters.into_inner().0, + }) + } + } + + impl TryInto for PreparedInstallationSectionConfigWrapper { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let PreparedInstallationSectionConfigWrapper::PreparedConfig(conf) = self; + conf.try_into() + } + } + + #[api( + "id-property": "id", + "id-schema": { + type: String, + description: "Access token name.", + min_length: 3, + max_length: 64, + }, + )] + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] + #[serde(rename_all = "kebab-case", tag = "type")] + /// Access token for authenticating against the /answer endpoint. + pub enum AnswerAuthTokenWrapper { + /// API-token (like). + AccessToken(AnswerAuthToken), + } + + impl From for AnswerAuthToken { + fn from(value: AnswerAuthTokenWrapper) -> Self { + let AnswerAuthTokenWrapper::AccessToken(token) = value; + token + } + } + + impl From for AnswerAuthTokenWrapper { + fn from(value: AnswerAuthToken) -> Self { + AnswerAuthTokenWrapper::AccessToken(value) + } + } +} + +pub fn installations_read_lock() -> Result { + open_api_lockfile(INSTALLATIONS_LOCK_FILE, None, false) +} + +pub fn installations_write_lock() -> Result { + open_api_lockfile(INSTALLATIONS_LOCK_FILE, None, true) +} + +pub fn read_installations() -> Result<(Vec, ConfigDigest)> { + let content: serde_json::Value = serde_json::from_str( + &proxmox_sys::fs::file_read_optional_string(INSTALLATIONS_STATE_FILE)? + .unwrap_or_else(|| "[]".to_owned()), + )?; + + let digest = proxmox_serde::json::to_canonical_json(&content).map(ConfigDigest::from_slice)?; + let data = serde_json::from_value(content)?; + + Ok((data, digest)) +} + +/// Write lock must be already held. +pub fn save_installations(config: &[Installation]) -> Result<()> { + let raw = serde_json::to_string(&config)?; + replace_config(INSTALLATIONS_STATE_FILE, raw.as_bytes()) +} + +pub fn prepared_answers_read_lock() -> Result { + open_api_lockfile(PREPARED_LOCK_FILE, None, false) +} + +pub fn prepared_answers_write_lock() -> Result { + open_api_lockfile(PREPARED_LOCK_FILE, None, true) +} + +pub fn read_prepared_answers() -> Result<( + SectionConfigData, + ConfigDigest, +)> { + let content = + proxmox_sys::fs::file_read_optional_string(PREPARED_CONF_FILE)?.unwrap_or_default(); + + let digest = ConfigDigest::from_slice(content.as_bytes()); + let data = types::PreparedInstallationSectionConfigWrapper::parse_section_config( + PREPARED_CONF_FILE, + &content, + )?; + + Ok((data, digest)) +} + +/// Write lock must be already held. +pub fn save_prepared_answers( + config: &SectionConfigData, +) -> Result<()> { + let raw = types::PreparedInstallationSectionConfigWrapper::write_section_config( + PREPARED_CONF_FILE, + config, + )?; + replace_config(PREPARED_CONF_FILE, raw.as_bytes()) +} + +pub fn token_read_lock() -> Result { + open_api_lockfile(TOKENS_LOCK_FILE, None, false) +} + +pub fn token_write_lock() -> Result { + open_api_lockfile(TOKENS_LOCK_FILE, None, true) +} + +pub fn read_tokens() -> Result<( + SectionConfigData, + ConfigDigest, +)> { + let content = proxmox_sys::fs::file_read_optional_string(TOKENS_CONF_FILE)?.unwrap_or_default(); + + let digest = ConfigDigest::from_slice(content.as_bytes()); + let data = types::AnswerAuthTokenWrapper::parse_section_config(TOKENS_CONF_FILE, &content)?; + + Ok((data, digest)) +} + +/// Write lock must be already held. +pub fn add_token(token: &AnswerAuthToken, secret: &str) -> Result<()> { + let (mut auths, _) = read_tokens()?; + + if auths.contains_key(&token.id.to_string()) { + bail!("token already exists"); + } + + let auth: AnswerAuthTokenWrapper = token.clone().into(); + auths.insert(token.id.to_string(), auth); + + let mut shadow = read_tokens_shadow()?; + let hashed = proxmox_sys::crypt::encrypt_pw(secret)?; + shadow.insert(token.id.clone(), hashed); + write_tokens_shadow(shadow)?; + + write_tokens(auths) +} + +/// Write lock must be already held. +pub fn update_token(token: &AnswerAuthToken) -> Result<()> { + let (mut auths, _) = read_tokens()?; + + let auth: AnswerAuthTokenWrapper = token.clone().into(); + auths.insert(token.id.to_string(), auth); + + write_tokens(auths) +} + +/// Write lock must be already held. +pub fn delete_token(id: &str) -> Result<()> { + let (mut tokens, _) = read_tokens()?; + tokens.remove(&id.to_string()); + + let mut shadow = read_tokens_shadow()?; + shadow.remove(id); + write_tokens_shadow(shadow)?; + + write_tokens(tokens) +} + +/// Write lock must be already held. +fn write_tokens(data: SectionConfigData) -> Result<()> { + let raw = types::AnswerAuthTokenWrapper::write_section_config(TOKENS_CONF_FILE, &data)?; + replace_config(TOKENS_CONF_FILE, raw.as_bytes()) +} + +/// At least read lock must be held. +pub fn verify_token_secret(id: &str, secret: &str) -> Result<()> { + let data = read_tokens_shadow()?; + match data.get(id) { + Some(hashed) => proxmox_sys::crypt::verify_crypt_pw(secret, hashed), + None => bail!("invalid access token"), + } +} + +fn read_tokens_shadow() -> Result> { + Ok(serde_json::from_str( + &proxmox_sys::fs::file_read_optional_string(TOKENS_SHADOW_FILE)? + .unwrap_or_else(|| "{}".to_owned()), + )?) +} + +/// Write lock must be already held. +fn write_tokens_shadow(data: HashMap) -> Result<()> { + let raw = serde_json::to_string(&data)?; + replace_config(TOKENS_SHADOW_FILE, raw.as_bytes()) +} diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs index 4c49054..5b9bcca 100644 --- a/lib/pdm-config/src/lib.rs +++ b/lib/pdm-config/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::{format_err, Error}; use nix::unistd::{Gid, Group, Uid, User}; pub use pdm_buildcfg::{BACKUP_GROUP_NAME, BACKUP_USER_NAME}; +pub mod auto_install; pub mod certificate_config; pub mod domains; pub mod node; diff --git a/lib/pdm-config/src/setup.rs b/lib/pdm-config/src/setup.rs index 5f920c8..5adb05f 100644 --- a/lib/pdm-config/src/setup.rs +++ b/lib/pdm-config/src/setup.rs @@ -24,6 +24,13 @@ pub fn create_configdir() -> Result<(), Error> { 0o750, )?; + mkdir_perms( + crate::auto_install::CONFIG_PATH, + api_user.uid, + api_user.gid, + 0o750, + )?; + Ok(()) } -- 2.53.0