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 B80C41FF13C for ; Thu, 30 Apr 2026 14:49:32 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 99A9B74EC; Thu, 30 Apr 2026 14:49:32 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v4 16/40] config: add auto-installer configuration module Date: Thu, 30 Apr 2026 14:46:45 +0200 Message-ID: <20260430124712.1614305-17-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com> References: <20260430124712.1614305-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: 1777553223524 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.077 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 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: ZONBQXWBMXA6UPM7ROWOIVLILYLGL327 X-Message-ID-Hash: ZONBQXWBMXA6UPM7ROWOIVLILYLGL327 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 v3 -> v4: * fix schema for PreparedInstallationSectionConfig::disk_list * rename `AnswerAuthToken*` -> `AnswerToken*` 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 | 575 +++++++++++++++++++++++++++++ lib/pdm-config/src/lib.rs | 1 + lib/pdm-config/src/setup.rs | 7 + 5 files changed, 596 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..e1d1dc9 --- /dev/null +++ b/lib/pdm-config/src/auto_install.rs @@ -0,0 +1,575 @@ +//! Implements configuration for the auto-installer integration. + +use anyhow::{anyhow, bail, Result}; +use std::collections::HashMap; + +use pdm_api_types::{ + auto_installer::{AnswerToken, Installation}, + ConfigDigest, +}; +use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard}; +use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData}; + +use crate::auto_install::types::AnswerTokenWrapper; + +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, AnswerToken, DiskSelectionMode, PreparedInstallationConfig, + PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + BLOCKDEVICE_NAME_SCHEMA, CERT_FINGERPRINT_SHA256_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 { + /// Prepared Installation Configuration. + 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": { + type: Array, + optional: true, + items: { + schema: BLOCKDEVICE_NAME_SCHEMA, + }, + }, + "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 AnswerTokenWrapper { + /// Simple token with a secret, like an API token. + Token(AnswerToken), + } + + impl From for AnswerToken { + fn from(value: AnswerTokenWrapper) -> Self { + let AnswerTokenWrapper::Token(token) = value; + token + } + } + + impl From for AnswerTokenWrapper { + fn from(value: AnswerToken) -> Self { + AnswerTokenWrapper::Token(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 tokens_read_lock() -> Result { + open_api_lockfile(TOKENS_LOCK_FILE, None, false) +} + +pub fn tokens_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::AnswerTokenWrapper::parse_section_config(TOKENS_CONF_FILE, &content)?; + + Ok((data, digest)) +} + +fn write_tokens(data: SectionConfigData) -> Result<()> { + let raw = AnswerTokenWrapper::write_section_config(TOKENS_CONF_FILE, &data)?; + replace_config(TOKENS_CONF_FILE, raw.as_bytes()) +} + +/// Write lock must be already held. +pub fn add_token(token: &AnswerToken, value: &str) -> Result<()> { + let (mut tokens, _) = read_tokens()?; + + if tokens.contains_key(&token.id) { + bail!("token already exists"); + } + + tokens.insert(token.id.clone(), token.clone().into()); + write_tokens(tokens)?; + + let mut shadow = read_token_shadow()?; + let hashed = proxmox_sys::crypt::encrypt_pw(value)?; + shadow.insert(token.id.clone(), hashed); + write_token_shadow(shadow) +} + +/// Write lock must be already held. +pub fn update_token(token: &AnswerToken) -> Result<()> { + let (mut tokens, _) = read_tokens()?; + + if tokens.contains_key(&token.id) { + bail!("unknown token: {}", token.id); + } + + tokens.insert(token.id.clone(), token.clone().into()); + write_tokens(tokens)?; + Ok(()) +} + +/// Write lock must be already held. +pub fn update_token_shadow(id: &str, secret: &str) -> Result<()> { + let mut shadow = read_token_shadow()?; + if !shadow.contains_key(id) { + bail!("unknown token: {id}"); + } + + let hashed = proxmox_sys::crypt::encrypt_pw(secret)?; + shadow.insert(id.to_owned(), hashed); + write_token_shadow(shadow) +} + +/// Write lock must be already held. +pub fn delete_token(id: &str) -> Result<()> { + let (mut tokens, _) = read_tokens()?; + + if !tokens.contains_key(id) { + bail!("unknown token: {id}"); + } + + tokens.remove(id); + write_tokens(tokens)?; + + let mut shadow = read_token_shadow()?; + shadow.remove(id); + write_token_shadow(shadow) +} + +/// At least read lock must be held. +pub fn verify_secret(id: &str, secret: &str) -> Result<()> { + read_token_shadow()? + .get(id) + .and_then(|hashed| proxmox_sys::crypt::verify_crypt_pw(secret, hashed).ok()) + .ok_or_else(|| anyhow!("invalid access secret")) +} + +fn read_token_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_token_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