From f449fdd05604b7ea03b4e29492ae9ac46c7a332a Mon Sep 17 00:00:00 2001 From: alcroito Date: Mon, 22 May 2023 12:21:51 +0200 Subject: [PATCH] refactor(config)!: Use clap derive API and figment for config parsing Use figment for parsing configuration from env vars, cli, toml file. Switch from clap builder api to clap derive api. This creates a struct that then be fed to figment. Remove old homegrown config builder. Remove direct toml dependency, it's handled by figment now. Update documentation concerning booleanss and numbers. BREAKING CHANGE: toml booleans and numbers in the config files can't be quoted anymore. Previously they had to be quoted strings due to implementation issues. --- Cargo.lock | 57 +-- config/do_ddns.sample.toml | 14 +- crates/dyndns/Cargo.toml | 1 - crates/dyndns/src/cli.rs | 342 ++++++------- crates/dyndns/src/config/app_config.rs | 121 ++++- .../dyndns/src/config/app_config_builder.rs | 297 +++-------- crates/dyndns/src/config/config_builder.rs | 477 ------------------ crates/dyndns/src/config/consts.rs | 18 +- crates/dyndns/src/config/mod.rs | 1 - crates/dyndns/src/db/setup.rs | 13 + .../src/domain_record_api/digital_ocean.rs | 74 +-- crates/dyndns/src/token.rs | 8 +- 12 files changed, 408 insertions(+), 1015 deletions(-) delete mode 100644 crates/dyndns/src/config/config_builder.rs diff --git a/Cargo.lock b/Cargo.lock index 13075c2..279742c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,6 @@ dependencies = [ "tailsome", "tempfile", "tokio", - "toml 0.7.3", "tower", "tower-http", "tracing", @@ -793,7 +792,7 @@ dependencies = [ "pear", "serde", "tempfile", - "toml 0.5.11", + "toml", "uncased", "version_check", ] @@ -1438,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c493c09323068c01e54c685f7da41a9ccf9219735c3766fbfd6099806ea08fbc" dependencies = [ "serde", - "toml 0.5.11", + "toml", ] [[package]] @@ -2238,15 +2237,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "serde_spanned" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2554,40 +2544,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "tower" version = "0.4.13" @@ -3168,15 +3124,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" -[[package]] -name = "winnow" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" -dependencies = [ - "memchr", -] - [[package]] name = "winreg" version = "0.10.1" diff --git a/config/do_ddns.sample.toml b/config/do_ddns.sample.toml index c03d8b9..dceec40 100644 --- a/config/do_ddns.sample.toml +++ b/config/do_ddns.sample.toml @@ -7,16 +7,16 @@ update_interval = "30mins" digital_ocean_token = "aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz" # Setting this option to true will cause the final IP updates to be skipped. -dry_run = "false" +dry_run = false # Setting this option to true will enable resolving of ipv6 addresses and # storing them in AAAA records. -# ipv6 = "true" +# ipv6 = true # Enable collection of statistics (how often does the public IP change) in # a local sqlite database. # Disabled by default. -# collect_stats = "true" +# collect_stats = true # File path where the sqlite database with statistics will be stored. # By default stored in one of the following locations: @@ -26,16 +26,16 @@ dry_run = "false" # Enable web server to visualize collected statistics. # Disabled by default. -# enable_web = "true" +# enable_web = true # An IPv4 / IPv6 address or host name where to serve HTTP pages on. # In case of host that has a dual IP stack, both will be used. # Default is localhost. -# listen_hostname = "true" +# listen_hostname = true # Port number where to serve HTTP pages on. # Default is 8095. -# listen_port = "8095" +# listen_port = 8095 ## Simple config mode sample @@ -48,7 +48,7 @@ subdomain_to_update = "home" # Updates the IP of the 'mysite.com' A record. # domain_root = "mysite.com" -# update_domain_root = "true" +# update_domain_root = true ## Advanced config mode sample diff --git a/crates/dyndns/Cargo.toml b/crates/dyndns/Cargo.toml index 50a093f..7435ac3 100644 --- a/crates/dyndns/Cargo.toml +++ b/crates/dyndns/Cargo.toml @@ -56,7 +56,6 @@ serde_json = "1" serde_with = "3" signal-hook = { version = "0.3", features = ["extended-siginfo"] } tailsome = "1" -toml = "0.7" tracing = "0.1" tracing-log = "0.1" tracing-subscriber = "0.3" diff --git a/crates/dyndns/src/cli.rs b/crates/dyndns/src/cli.rs index 69792f5..271cddf 100644 --- a/crates/dyndns/src/cli.rs +++ b/crates/dyndns/src/cli.rs @@ -1,11 +1,162 @@ -use clap::{crate_version, Arg, ArgMatches, Command}; +use clap::{crate_version, ArgMatches, Args, Command}; +use serde::Serialize; +use serde_with::skip_serializing_none; -use crate::config::consts::*; +use crate::{config::app_config::UpdateInterval, token::SecretDigitalOceanToken}; pub fn get_cli_args() -> ArgMatches { get_cli_command_definition().get_matches() } +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct CommonArgs { + /// Path to TOML config file. + /// + /// Default config path when none specified: '$PWD/config/do_ddns.toml' + /// Env var: DO_DYNDNS_CONFIG=/config/do_ddns.toml", + #[arg(short = 'c', long = "config", id = "config")] + pub config_file_path: Option, + + /// Increases the level of verbosity. Repeat for more verbosity. + /// + /// Env var: DO_DYNDNS_LOG_LEVEL=info [error|warn|info|debug|trace] + #[arg(short = 'v', action = clap::ArgAction::Count, id = "v")] + pub log_level: Option, + + /// The domain root for which the domain record will be changed. + /// + /// Example: 'foo.net' + /// Env var: DO_DYNDNS_DOMAIN_ROOT=foo.net" + #[arg(short = 'd', long)] + pub domain_root: Option, + + /// The subdomain for which the public IP will be updated. + /// + /// Example: 'home' + /// Env var: DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home + #[arg(short = 's', long)] + pub subdomain_to_update: Option, + + /// If true, the provided domain root 'A' record will be updated (instead of a subdomain). + /// + /// Env var: DO_DYNDNS_UPDATE_DOMAIN_ROOT=true + #[arg(short = 'r', long, conflicts_with = "subdomain_to_update", default_missing_value = "true", num_args = 0..=1)] + pub update_domain_root: Option, + + /// The digital ocean access token. + /// + /// Example: 'abcdefghijklmnopqrstuvwxyz' + /// Env var: DO_DYNDNS_DIGITAL_OCEAN_TOKEN=abcdefghijklmnopqrstuvwxyz" + #[arg(short = 't', long, value_parser = crate::token::parse_secret_token)] + pub digital_ocean_token: Option, + + /// Path to file containing the digital ocean token on its first line. + /// + /// Example: '/config/secret_token.txt' + #[arg( + short = 'p', + long = "token-file-path", + conflicts_with = "digital_ocean_token", + id = "token_file_path" + )] + pub digital_ocean_token_path: Option, + + /// How often should the domain be updated. + /// + /// Default is every 10 minutes. + /// Uses rust's humantime format. + /// Example: '15 mins 30 secs' + /// Env var: DO_DYNDNS_UPDATE_INTERVAL=2hours 30mins + #[arg(short = 'i', long)] + pub update_interval: Option, + + /// Show what would have been updated. + /// + /// Env var: DO_DYNDNS_DRY_RUN=true + #[arg(short = 'n', long, default_missing_value = "true", num_args = 0..=1)] + pub dry_run: Option, + + /// Enable ipv6 support (disabled by default). + /// + /// Env var: DO_DYNDNS_IPV6_SUPPORT=true" + // num_args + default_missing_value emulates a flag action::SetTrue, which + // preserves None when nothing is passed + #[arg(long = "enable-ipv6", id = "ipv6", default_missing_value = "true", num_args = 0..=1)] + #[serde(rename = "ipv6")] + pub ipv6_support: Option, + + /// Output build info like git commit sha, rustc version, etc + #[arg(long = "build-info")] + pub build_info: bool, +} + +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct ConditionalArgs { + /// Enable collection of statistics (how often does the public IP change). + /// + /// Env var: DO_DYNDNS_COLLECT_STATS=true" + #[arg(long, default_missing_value = "true", num_args = 0..=1)] + #[cfg_attr(not(feature = "stats"), arg(hide = true))] + pub collect_stats: Option, + + /// File path where a sqlite database with statistics will be stored. + /// + /// Env var: DO_DYNDNS_DATABASE_PATH=/tmp/dyndns_stats_db.sqlite + #[arg(long = "database-path", id = "database_path")] + #[cfg_attr(not(feature = "stats"), arg(hide = true))] + #[serde(rename = "database_path")] + pub db_path: Option, + + /// Enable web server to visualize collected statistics. + /// + /// Env var: DO_DYNDNS_ENABLE_WEB=true + #[arg(long, default_missing_value = "true", num_args = 0..=1)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub enable_web: Option, + + /// An IP address or host name where to serve HTTP pages on. + /// + /// Env var: DO_DYNDNS_LISTEN_HOSTNAME=192.168.0.1 + #[arg(long)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub listen_hostname: Option, + + /// Port numbere where to serve HTTP pages on. + /// + /// Env var: DO_DYNDNS_LISTEN_PORT=8080 + #[arg(long)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub listen_port: Option, +} + +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct ClapAllArgs { + #[command(flatten)] + #[serde(flatten)] + common_args: CommonArgs, + + #[command(flatten)] + #[serde(flatten)] + conditional_args: ConditionalArgs, +} + +impl ClapAllArgs { + pub fn parse_and_process(clap_matches: &ArgMatches) -> Result { + use clap::FromArgMatches; + let mut args = Self::from_arg_matches(clap_matches)?; + // clap doesn't support generic mapping of argument values when using ArgAction::Count + // So we manually reset the log level to None if count was 0 (aka none was specified). + // This ensures the value is not serialized and used the by the configuration merging. + if let Some(0) = args.common_args.log_level { + args.common_args.log_level = None; + } + Ok(args) + } +} + fn get_cli_command_definition_base() -> Command { Command::new("DigitalOcean dynamic dns updater") .version(crate_version!()) @@ -65,196 +216,13 @@ type = \"A\" name = \"crib\" ", ) - .arg( - Arg::new(CONFIG_KEY) - .short('c') - .long(CONFIG_KEY) - .value_name("FILE") - .help( - "\ -Path to TOML config file. -Default config path when none specified: '$PWD/config/do_ddns.toml' -Env var: DO_DYNDNS_CONFIG=/config/do_ddns.toml", - ), - ) - .arg( - Arg::new(LOG_LEVEL_VERBOSITY_SHORT) - .short(LOG_LEVEL_VERBOSITY_SHORT_CHAR) - .action(clap::ArgAction::Count) - .help( - "\ -Increases the level of verbosity. Repeat for more verbosity. -Env var: DO_DYNDNS_LOG_LEVEL=info [error|warn|info|debug|trace] -", - ), - ) - .arg( - Arg::new(DOMAIN_ROOT) - .short('d') - .long("domain-root") - .value_name("DOMAIN") - .help( - "\ -The domain root for which the domain record will be changed. -Example: 'foo.net' -Env var: DO_DYNDNS_DOMAIN_ROOT=foo.net", - ), - ) - .arg( - Arg::new(SUBDOMAIN_TO_UPDATE) - .short('s') - .long("subdomain-to-update") - .value_name("SUBDOMAIN") - .help( - "\ -The subdomain for which the public IP will be updated. -Example: 'home' -Env var: DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home", - ), - ) - .arg( - Arg::new(UPDATE_DOMAIN_ROOT) - .short('r') - .long("update-domain-root") - .help( - "\ -If true, the provided domain root 'A' record will be updated (instead of a subdomain). -Env var: DO_DYNDNS_UPDATE_DOMAIN_ROOT=true", - ) - .action(clap::ArgAction::SetTrue) - .conflicts_with(SUBDOMAIN_TO_UPDATE), - ) - .arg( - Arg::new(DIGITAL_OCEAN_TOKEN) - .short('t') - .long("token") - .value_name("TOKEN") - .help( - "\ -The digital ocean access token. -Example: 'abcdefghijklmnopqrstuvwxyz' -Env var: DO_DYNDNS_DIGITAL_OCEAN_TOKEN=abcdefghijklmnopqrstuvwxyz", - ), - ) - .arg( - Arg::new(DIGITAL_OCEAN_TOKEN_PATH) - .short('p') - .long("token-file-path") - .value_name("FILE_PATH") - .help( - "\ -Path to file containing the digital ocean token on its first line. -Example: '/config/secret_token.txt'", - ) - .conflicts_with(DIGITAL_OCEAN_TOKEN), - ) - .arg( - Arg::new(UPDATE_INTERVAL) - .short('i') - .long("update-interval") - .value_name("INTERVAL") - .help( - "\ -How often should the domain be updated. -Default is every 10 minutes. -Uses rust's humantime format. -Example: '15 mins 30 secs' -Env var: DO_DYNDNS_UPDATE_INTERVAL=2hours 30mins", - ), - ) - .arg( - Arg::new(DRY_RUN) - .short('n') - .long("dry-run") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Show what would have been updated. -Env var: DO_DYNDNS_DRY_RUN=true", - ), - ) - .arg( - Arg::new(IPV6_SUPPORT) - .long("enable-ipv6") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable ipv6 support (disabled by default). -Env var: DO_DYNDNS_IPV6_SUPPORT=true", - ), - ) - .arg( - Arg::new(BUILD_INFO) - .long("build-info") - .help( - "\ -Output build info like git commit sha, rustc version, etc", - ) - .action(clap::ArgAction::SetTrue), - ) } pub fn get_cli_command_definition() -> Command { let mut command = get_cli_command_definition_base(); - // Don't show stats related options when building with the feature disabled. - let mut arg = Arg::new(COLLECT_STATS) - .long("collect-stats") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable collection of statistics (how often does the public IP change). -Env var: DO_DYNDNS_COLLECT_STATS=true", - ); - if cfg!(not(feature = "stats")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(DB_PATH).long("database-path").help( - "\ -File path where a sqlite database with statistics will be stored. -Env var: DO_DYNDNS_DATABASE_PATH=/tmp/dyndns_stats_db.sqlite", - ); - - if cfg!(not(feature = "stats")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - // Don't show web related options when building with the feature disabled. - let mut arg = Arg::new(ENABLE_WEB) - .long("enable-web") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable web server to visualize collected statistics. -Env var: DO_DYNDNS_ENABLE_WEB=true", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(LISTEN_HOSTNAME).long("listen-hostname").help( - "\ -An IP address or host name where to serve HTTP pages on. -Env var: DO_DYNDNS_LISTEN_HOSTNAME=192.168.0.1", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(LISTEN_PORT).long("listen-port").help( - "\ -Port numbere where to serve HTTP pages on. -Env var: DO_DYNDNS_LISTEN_PORT=8080", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); + command = CommonArgs::augment_args(command); + command = ConditionalArgs::augment_args(command); command } diff --git a/crates/dyndns/src/config/app_config.rs b/crates/dyndns/src/config/app_config.rs index c4d6481..1a02d87 100644 --- a/crates/dyndns/src/config/app_config.rs +++ b/crates/dyndns/src/config/app_config.rs @@ -1,7 +1,7 @@ use crate::token::SecretDigitalOceanToken; use color_eyre::eyre::Result; use humantime::parse_duration; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ops::Deref, sync::Arc, time::Duration}; #[derive(Debug, Clone)] @@ -33,21 +33,66 @@ pub struct AppConfigInner { } #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Deserialize)] pub struct GeneralOptions { pub update_interval: UpdateInterval, pub digital_ocean_token: SecretDigitalOceanToken, + #[serde(deserialize_with = "deserialize_log_level_from_u8_or_string")] + pub log_level: tracing::Level, + pub dry_run: bool, + pub ipv4: bool, + pub ipv6: bool, + pub collect_stats: bool, + #[serde(rename = "database_path")] + pub db_path: Option, + pub enable_web: bool, + pub listen_hostname: String, + pub listen_port: u16, +} + +#[non_exhaustive] +#[derive(Debug, Serialize)] +pub struct GeneralOptionsDefaults { + pub update_interval: UpdateInterval, + pub digital_ocean_token: Option, + #[serde(serialize_with = "serialize_to_u8_from_log_level")] pub log_level: tracing::Level, pub dry_run: bool, pub ipv4: bool, pub ipv6: bool, pub collect_stats: bool, + #[serde(rename = "database_path")] pub db_path: Option, pub enable_web: bool, pub listen_hostname: String, pub listen_port: u16, } +impl Default for GeneralOptionsDefaults { + fn default() -> Self { + Self { + update_interval: Default::default(), + digital_ocean_token: None, + log_level: tracing::Level::INFO, + dry_run: Default::default(), + ipv4: true, + ipv6: Default::default(), + collect_stats: Default::default(), + db_path: Default::default(), + enable_web: Default::default(), + listen_hostname: "localhost".to_owned(), + listen_port: 8095, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct SimpleModeDomainConfig { + pub domain_root: String, + pub subdomain_to_update: Option, + pub update_domain_root: Option, +} + #[non_exhaustive] #[derive(Debug, Deserialize)] pub struct DomainRecord { @@ -69,8 +114,8 @@ pub struct Domains { pub domains: Vec, } -#[derive(Clone, Debug)] -pub struct UpdateInterval(pub Duration); +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UpdateInterval(#[serde(with = "humantime_serde")] pub Duration); impl Default for UpdateInterval { fn default() -> Self { @@ -85,3 +130,71 @@ impl std::str::FromStr for UpdateInterval { parse_duration(s).map(UpdateInterval) } } + +impl std::fmt::Display for UpdateInterval { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", humantime::format_duration(self.0)) + } +} + +pub fn deserialize_log_level_from_u8_or_string<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct LogLevelVisitor; + impl<'de> Visitor<'de> for LogLevelVisitor { + type Value = tracing::Level; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a number between 0 and 3 or one of the following strings: error, warn, info, debug, trace") + } + + fn visit_u8(self, value: u8) -> Result + where + E: de::Error, + { + let level = match value { + 0 => tracing::Level::INFO, + 1 => tracing::Level::DEBUG, + 2 => tracing::Level::TRACE, + _ => tracing::Level::TRACE, + }; + Ok(level) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value.parse::().map_err(|_| { + let msg = "error parsing log level: expected one of \"error\", \"warn\", \ + \"info\", \"debug\", \"trace\""; + E::custom(msg) + }) + } + } + + // deserialize_u8 is just a hint, the deserializer can handle strings too. + deserializer.deserialize_u8(LogLevelVisitor) +} + +pub fn serialize_to_u8_from_log_level( + level: &tracing::Level, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let level_u8 = match *level { + tracing::Level::INFO => 0, + tracing::Level::DEBUG => 1, + tracing::Level::TRACE => 2, + tracing::Level::ERROR => 0, + tracing::Level::WARN => 0, + }; + serializer.serialize_u8(level_u8) +} diff --git a/crates/dyndns/src/config/app_config_builder.rs b/crates/dyndns/src/config/app_config_builder.rs index 6245dba..32e03bd 100644 --- a/crates/dyndns/src/config/app_config_builder.rs +++ b/crates/dyndns/src/config/app_config_builder.rs @@ -1,31 +1,29 @@ +use crate::cli::ClapAllArgs; + use super::app_config::{ - AppConfig, AppConfigInner, Domain, DomainRecord, Domains, GeneralOptions, UpdateInterval, + AppConfig, AppConfigInner, Domain, DomainRecord, Domains, GeneralOptions, + GeneralOptionsDefaults, SimpleModeDomainConfig, }; -use super::config_builder::{make_env_var_from_key, ValueBuilder}; use super::consts::*; use super::early::EarlyConfig; -use crate::token::SecretDigitalOceanToken; -use color_eyre::eyre::{bail, eyre, Result, WrapErr}; use clap::ArgMatches; +use color_eyre::eyre::{bail, eyre, Result, WrapErr}; +use figment::Figment; use tracing::trace; fn get_default_config_path() -> &'static str { "./config/do_ddns.toml" } -fn read_config_map(config_path: &str) -> Result { - let config = std::fs::read_to_string(config_path) - .wrap_err(format!("Failed to read config file: {config_path}"))?; - let config = - toml::from_str(&config).wrap_err(format!("Failed to parse config file: {config_path}"))?; - Ok(config) -} - fn file_is_readable(path: &str) -> bool { std::fs::File::open(path).is_ok() } +fn make_env_var_from_key(key: &str) -> String { + format!("{}{}", ENV_VAR_PREFIX, key.to_ascii_uppercase()) +} + fn get_config_path_candidates(clap_matches: &ArgMatches) -> Vec { let mut candidates = vec![]; @@ -88,6 +86,12 @@ pub fn config_with_args(early_config: &EarlyConfig) -> Result { let config_file_path = get_config_path(clap_matches); let config_builder = AppConfigBuilder::new(Some(clap_matches), config_file_path); let config = config_builder + .map_err(|e| { + tracing::error!( + "Failed to initialize configuration system. Will exit shortly with error details." + ); + e + })? .build() .map_err(|e| { tracing::error!( @@ -99,144 +103,77 @@ pub fn config_with_args(early_config: &EarlyConfig) -> Result { Ok(config) } -fn get_advanced_mode_domains(table: Option<&toml::value::Table>) -> Result { - let domains = table - .ok_or_else(|| { - eyre!("No config contents found while retrieving 'advanced mode' domains section") - })? - .get(DOMAINS_CONFIG_KEY) - .ok_or_else(|| eyre!("No 'advanced mode' domains section found in config"))? - .clone() - .try_into::() +fn get_advanced_mode_domains(builder: &AppConfigBuilder) -> Result { + let domains: Domains = builder + .figment + .extract_inner(DOMAINS_CONFIG_KEY) .map_err(|e| eyre!(e).wrap_err("Failed to parse 'advanced mode' domain section"))?; Ok(domains) } -pub struct AppConfigBuilder<'clap> { - clap_matches: Option<&'clap ArgMatches>, - toml_table: Option, - domain_root: Option, - subdomain_to_update: Option, - update_domain_root: Option, - update_interval: Option, - digital_ocean_token: Option, - log_level: Option, - dry_run: Option, - ipv6: Option, - db_path: Option, +pub struct AppConfigBuilder { + figment: Figment, } -impl<'clap> AppConfigBuilder<'clap> { - pub fn new(clap_matches: Option<&'clap ArgMatches>, config_file_path: Option) -> Self { - fn get_config(config_file_path: &str) -> Result { - let toml_value = read_config_map(config_file_path)?; - let toml_table = match toml_value { - toml::value::Value::Table(table) => table, - _ => bail!("Failed to deserialize config file"), - }; - Ok(toml_table) - } - - let mut toml_table = None; - if let Some(config_file_path) = config_file_path { - toml_table = get_config(&config_file_path) - .map_err(|e| { - tracing::error!("{:#}", e); - e - }) - .ok(); - } +impl AppConfigBuilder { + pub fn new( + clap_matches: Option<&ArgMatches>, + config_file_path: Option, + ) -> Result { + let figment = Self::prepare_figmment(clap_matches, config_file_path.as_deref())?; - AppConfigBuilder { - clap_matches, - toml_table, - domain_root: None, - update_domain_root: None, - subdomain_to_update: None, - update_interval: None, - digital_ocean_token: None, - log_level: None, - dry_run: None, - ipv6: None, - db_path: None, - } - } + let builder = AppConfigBuilder { figment }; - pub fn set_domain_root(&mut self, value: String) -> &mut Self { - self.domain_root = Some(value); - self + Ok(builder) } - pub fn set_subdomain_to_update(&mut self, value: String) -> &mut Self { - self.subdomain_to_update = Some(value); - self - } + fn prepare_figmment( + clap_matches: Option<&ArgMatches>, + config_file_path: Option<&str>, + ) -> Result { + use figment::providers::{Env, Format, Serialized, Toml}; + use figment_file_provider_adapter::FileAdapter; - pub fn set_update_domain_root(&mut self, value: bool) -> &mut Self { - self.update_domain_root = Some(value); - self - } + let mut figment = + Figment::new().merge(Serialized::defaults(GeneralOptionsDefaults::default())); - pub fn set_update_interval(&mut self, value: UpdateInterval) -> &mut Self { - self.update_interval = Some(value); - self - } + if let Some(config_file_path) = config_file_path { + figment = figment.merge(Toml::file(config_file_path)); + } - pub fn set_digital_ocean_token(&mut self, value: SecretDigitalOceanToken) -> &mut Self { - self.digital_ocean_token = Some(value); - self - } + if let Some(clap_matches) = clap_matches { + let clap_args = ClapAllArgs::parse_and_process(clap_matches)?; + let wrapped_clap_figment = FileAdapter::wrap(Serialized::defaults(clap_args)) + .with_suffix("_path") + .only(&[DIGITAL_OCEAN_TOKEN_PATH]); + figment = figment.merge(wrapped_clap_figment); + } - pub fn set_log_level(&mut self, value: tracing::Level) -> &mut Self { - self.log_level = Some(value); - self - } + figment = figment.merge(Env::prefixed(ENV_VAR_PREFIX)); - pub fn set_dry_run(&mut self, value: bool) -> &mut Self { - self.dry_run = Some(value); - self + Ok(figment) } fn build_simple_mode_domain_config_values(&self) -> Result<(String, String)> { - let domain_root = ValueBuilder::new(DOMAIN_ROOT) - .with_value(self.domain_root.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build()?; - - let subdomain_to_update = ValueBuilder::new(SUBDOMAIN_TO_UPDATE) - .with_value(self.subdomain_to_update.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build(); - - let update_domain_root = ValueBuilder::new(UPDATE_DOMAIN_ROOT) - .with_value(self.update_domain_root) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build(); - - let hostname_part = match (subdomain_to_update, update_domain_root) { - (Ok(subdomain_to_update), Err(_)) => subdomain_to_update, - (Err(_), Ok(update_domain_root)) => { + let config: SimpleModeDomainConfig = self.figment.extract()?; + + let hostname_part = match (config.subdomain_to_update, config.update_domain_root) { + (Some(subdomain_to_update), None) => subdomain_to_update, + (None, Some(update_domain_root)) => { if update_domain_root { "@".to_owned() } else { bail!("Please provide a subdomain to update") } } - (Err(e1), Err(e2)) => { - let e = format!("{e1:#}\n{e2:#}"); - return Err(eyre!(e).wrap_err("No valid domain to update found")); + (None, None) => { + bail!("Neither 'subdomain to update' nor 'update domain root' options were set. Please provide one.") } - (Ok(_), Ok(_)) => { + (Some(_), Some(_)) => { bail!("Both 'subdomain to update' and 'update domain root' options were set. Please provide only one option") } }; - Ok((domain_root, hostname_part)) + Ok((config.domain_root, hostname_part)) } fn simple_mode_domains_as_records(config: Result<(String, String)>) -> Result { @@ -257,7 +194,7 @@ impl<'clap> AppConfigBuilder<'clap> { let simple_mode_domains = AppConfigBuilder::simple_mode_domains_as_records( self.build_simple_mode_domain_config_values(), ); - let advanced_mode_domains = get_advanced_mode_domains(self.toml_table.as_ref()); + let advanced_mode_domains = get_advanced_mode_domains(self); let domains = match (simple_mode_domains, advanced_mode_domains) { (Ok(simple_mode_domains), Err(_)) => simple_mode_domains, (Err(_), Ok(advanced_mode_domains)) => advanced_mode_domains, @@ -276,122 +213,12 @@ impl<'clap> AppConfigBuilder<'clap> { } fn build_general_options(&self) -> Result { - let update_interval = ValueBuilder::new(UPDATE_INTERVAL) - .with_value(self.update_interval.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(UpdateInterval::default()) - .build()?; - - let mut builder = ValueBuilder::new(DIGITAL_OCEAN_TOKEN); - builder - .with_value(self.digital_ocean_token.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()); - if let Some(clap_matches) = self.clap_matches { - let from_file = clap_matches - .get_one::(DIGITAL_OCEAN_TOKEN_PATH) - .map(|s| s.as_str()); - if let Some(from_file) = from_file { - builder.with_single_line_from_file(from_file); - } - } + let general_options: GeneralOptions = self.figment.extract()?; - let digital_ocean_token: SecretDigitalOceanToken = builder.build()?; - - let log_level = ValueBuilder::new(SERVICE_LOG_LEVEL) - .with_value(self.log_level) - .with_env_var_name() - .with_clap_occurences( - self.clap_matches, - LOG_LEVEL_VERBOSITY_SHORT, - Box::new(|count| match count { - 0 => None, - 1 => Some(tracing::Level::DEBUG), - 2 => Some(tracing::Level::TRACE), - _ => Some(tracing::Level::TRACE), - }), - ) - .with_config_value(self.toml_table.as_ref()) - .with_default(tracing::Level::INFO) - .build()?; - - let dry_run = ValueBuilder::new(DRY_RUN) - .with_value(self.dry_run) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - // TODO: Figure out the bool clap cli issue where it is always true even if it's - // specified as false. - let ipv4 = true; - - let ipv6 = ValueBuilder::new(IPV6_SUPPORT) - .with_value(self.ipv6) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - if !ipv4 && !ipv6 { + if !general_options.ipv4 && !general_options.ipv6 { bail!("At least one kind of ip family support needs to be enabled, both are disabled."); } - let collect_stats = ValueBuilder::new(COLLECT_STATS) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - let db_path = ValueBuilder::new(DB_PATH) - .with_value(self.db_path.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build() - .ok() - .map(|db_path| std::path::PathBuf::from(&db_path)); - - let enable_web = ValueBuilder::new(ENABLE_WEB) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - let listen_hostname: String = ValueBuilder::new(LISTEN_HOSTNAME) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default("localhost".to_owned()) - .build()?; - - let listen_port = ValueBuilder::new(LISTEN_PORT) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(8095_u16) - .build()?; - - let general_options = GeneralOptions { - update_interval, - digital_ocean_token, - log_level, - dry_run, - ipv4, - ipv6, - collect_stats, - db_path, - enable_web, - listen_hostname, - listen_port, - }; Ok(general_options) } diff --git a/crates/dyndns/src/config/config_builder.rs b/crates/dyndns/src/config/config_builder.rs deleted file mode 100644 index ef8665f..0000000 --- a/crates/dyndns/src/config/config_builder.rs +++ /dev/null @@ -1,477 +0,0 @@ -use super::consts::*; -use crate::types::{ValueFromBool, ValueFromStr}; -use clap::ArgMatches; -use color_eyre::eyre::{eyre, Result}; - -pub fn make_env_var_from_key(key: &str) -> String { - format!("{}{}", ENV_VAR_PREFIX, key.to_ascii_uppercase()) -} - -type OccurencesFn = Box Option>; -pub struct ValueBuilder<'clap, 'toml, T> { - key: String, - value: Option, - env_var_name: Option, - clap_option: Option<(&'clap ArgMatches, String)>, - clap_option_bool: Option<(&'clap ArgMatches, String)>, - clap_occurrences_option: Option<(&'clap ArgMatches, String, OccurencesFn)>, - file_path: Option, - config_value: Option<(&'toml toml::value::Table, String)>, - default_value: Option, -} - -impl<'clap, 'toml, T: ValueFromStr + ValueFromBool> ValueBuilder<'clap, 'toml, T> { - pub fn new(key: &str) -> Self { - ValueBuilder { - key: key.to_owned(), - value: None, - env_var_name: None, - clap_option: None, - clap_option_bool: None, - clap_occurrences_option: None, - file_path: None, - config_value: None, - default_value: None, - } - } - - pub fn with_env_var_name(&mut self) -> &mut Self { - let env_var_name = make_env_var_from_key(&self.key.to_ascii_uppercase()); - self.env_var_name = Some(env_var_name); - self - } - - pub fn with_clap(&mut self, arg_matches: Option<&'clap ArgMatches>) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_option = Some((arg_matches, self.key.to_owned())); - } - self - } - - pub fn with_clap_bool(&mut self, arg_matches: Option<&'clap ArgMatches>) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_option_bool = Some((arg_matches, self.key.to_owned())); - } - self - } - - pub fn with_clap_occurences( - &mut self, - arg_matches: Option<&'clap ArgMatches>, - key: &str, - clap_fn: OccurencesFn, - ) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_occurrences_option = Some((arg_matches, key.to_owned(), clap_fn)); - } - self - } - - pub fn with_single_line_from_file(&mut self, file_path: &str) -> &mut Self { - self.file_path = Some(file_path.to_owned()); - self - } - - pub fn with_config_value(&mut self, toml_map: Option<&'toml toml::value::Table>) -> &mut Self { - if let Some(toml_map) = toml_map { - self.config_value = Some((toml_map, self.key.to_owned())); - } - self - } - - pub fn with_default(&mut self, default_value: T) -> &mut Self { - self.default_value = Some(default_value); - self - } - - pub fn with_value(&mut self, value: Option) -> &mut Self { - self.value = value; - self - } - - fn try_from_env(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - if let Some(ref env_var_name) = self.env_var_name { - let env_res = std::env::var(env_var_name); - if let Ok(value) = env_res { - let parsed_res = ValueFromStr::from_str(value.as_ref()); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - - self - } - - fn try_from_clap(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name)) = self.clap_option { - let clap_value = arg_matches - .get_one::(option_name) - .map(|s| s.as_str()); - if let Some(value) = clap_value { - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - - self - } - - fn try_from_clap_bool(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name)) = self.clap_option_bool { - // We only care about values that come from the command line, not default ones set by - // clap. It's not possible to configure a clap argument to return None by default when - // .action(clap::ArgAction::SetTrue/SetFalse) is used and no argument is specified - // on the command line. So we only retrieve the value if it's a non-default one. - if arg_matches.contains_id(option_name) - && arg_matches - .value_source(option_name) - .expect("checked contains_id") - == clap::parser::ValueSource::CommandLine - { - let value = arg_matches.get_flag(option_name); - let parsed_res = ValueFromBool::from_bool(value); - self.value = parsed_res.ok(); - } - } - - self - } - - fn try_from_clap_occurences(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name, ref mut clap_fn)) = self.clap_occurrences_option - { - let occurences_value = arg_matches.get_count(option_name); - self.value = clap_fn(occurences_value as u64); - } - - self - } - - fn try_from_file_line(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some(ref file_path) = self.file_path { - let line = std::fs::read_to_string(file_path); - if let Ok(line) = line { - let value = line.trim_end(); - if !value.is_empty() { - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - } - - self - } - - fn try_from_config_value(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((toml_table, ref key)) = self.config_value { - let toml_value = toml_table.get(key); - if let Some(toml_value) = toml_value { - if let Some(toml_str) = toml_value.as_str() { - let value = toml_str; - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - } - - self - } - - fn try_from_default_value(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if self.default_value.is_some() { - self.value = self.default_value.take(); - } - - self - } - - pub fn build(&mut self) -> Result { - self.try_from_env(); - self.try_from_clap(); - self.try_from_clap_bool(); - self.try_from_clap_occurences(); - self.try_from_file_line(); - self.try_from_config_value(); - self.try_from_default_value(); - self.value - .take() - .ok_or_else(|| eyre!(format!("Missing value for config option: '{}'", self.key))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::get_cli_command_definition; - use tempfile::NamedTempFile; - - #[test] - fn test_env_var() { - // Happy path - let key = "valid_env_var"; - std::env::set_var(make_env_var_from_key(key), "some_val"); - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty value - let key = "empty_env_var"; - std::env::set_var(make_env_var_from_key(key), ""); - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Env does not exist - let key = "non_existent_env_var"; - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_string_value() { - let command = clap::Command::new("test").arg(clap::Arg::new("foo").short('f').long("foo")); - - // Happy path - let arg_vec = vec!["my_prog", "--foo", "some_val"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty value - let arg_vec = vec!["my_prog", "--foo", ""]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Key not given - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - - // Value not given - let command = clap::Command::new("test") - .arg(clap::Arg::new("foo").short('f').long("foo").num_args(0..=1)); - let arg_vec = vec!["my_prog", "--foo"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_bool_value() { - let command = clap::Command::new("test").arg( - clap::Arg::new("foo") - .short('f') - .action(clap::ArgAction::SetTrue), - ); - - // clap option is false by default when unset, option set, thus result is Some(true) - let arg_vec = vec!["my_prog", "-f"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build().unwrap(); - assert!(value); - - // clap option is false by default when unset, option unset, thus result is None - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - - let command = clap::Command::new("test").arg( - clap::Arg::new("foo") - .short('f') - .action(clap::ArgAction::SetFalse), - ); - - // clap option is true by default when unset, option set, thus result is Some(false) - let arg_vec = vec!["my_prog", "-f"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build().unwrap(); - assert!(!value); - - // clap option is true by default when unset, option unset, thus result is None - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_occurrences_value() { - let command = clap::Command::new("test").arg( - clap::Arg::new("v") - .short('v') - .action(clap::ArgAction::Count), - ); - - // Happy path 2 values - let arg_vec = vec!["my_prog", "-vv"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "2"); - - // Happy path 1 value - let arg_vec = vec!["my_prog", "-v"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "1"); - - // Happy path 0 values - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "0"); - } - - #[test] - fn test_file_line() { - use std::io::Write; - // Happy path - { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "some_val").unwrap(); - let temp_file_path = file.path(); - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path.to_str().unwrap()); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - } - - // Empty file - { - let file = NamedTempFile::new().unwrap(); - let temp_file_path = file.path(); - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path.to_str().unwrap()); - let value = builder.build(); - assert!(value.is_err()); - } - - // Missing file - { - let temp_file_path = "/definitely_should_not_exist"; - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path); - let value = builder.build(); - assert!(value.is_err()); - } - } - - #[test] - fn test_toml_value() { - let toml_value: toml::Value = toml::from_str( - r#" - some_field = 'some_val' - empty_field = '' - "#, - ) - .unwrap(); - - // Happy path - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("some_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty field - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("empty_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Missing key - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("missing_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_default_value() { - // Happy path - let mut builder = ValueBuilder::::new("foo"); - builder.with_default("some_val".to_owned()); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty string - let mut builder = ValueBuilder::::new("foo"); - builder.with_default("".to_owned()); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Missing key - let mut builder = ValueBuilder::::new("foo"); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn verify_cli() { - get_cli_command_definition().debug_assert() - } -} diff --git a/crates/dyndns/src/config/consts.rs b/crates/dyndns/src/config/consts.rs index 9c37fe9..4ef23a4 100644 --- a/crates/dyndns/src/config/consts.rs +++ b/crates/dyndns/src/config/consts.rs @@ -1,21 +1,5 @@ pub static CONFIG_KEY: &str = "config"; -pub static DOMAIN_ROOT: &str = "domain_root"; -pub static SUBDOMAIN_TO_UPDATE: &str = "subdomain_to_update"; -pub static UPDATE_DOMAIN_ROOT: &str = "update_domain_root"; -pub static UPDATE_INTERVAL: &str = "update_interval"; -pub static DIGITAL_OCEAN_TOKEN: &str = "digital_ocean_token"; -pub static DIGITAL_OCEAN_TOKEN_PATH: &str = "token_file_path"; -pub static SERVICE_LOG_LEVEL: &str = "log_level"; -pub static DRY_RUN: &str = "dry_run"; -pub static IPV4_SUPPORT: &str = "ipv4"; -pub static IPV6_SUPPORT: &str = "ipv6"; -pub static COLLECT_STATS: &str = "collect_stats"; -pub static DB_PATH: &str = "database_path"; -pub static ENABLE_WEB: &str = "enable_web"; -pub static LISTEN_HOSTNAME: &str = "listen_hostname"; -pub static LISTEN_PORT: &str = "listen_port"; -pub static LOG_LEVEL_VERBOSITY_SHORT: &str = "v"; -pub static LOG_LEVEL_VERBOSITY_SHORT_CHAR: char = 'v'; +pub static DIGITAL_OCEAN_TOKEN_PATH: &str = "digital_ocean_token_path"; pub static ENV_VAR_PREFIX: &str = "DO_DYNDNS_"; pub static BUILD_INFO: &str = "build_info"; diff --git a/crates/dyndns/src/config/mod.rs b/crates/dyndns/src/config/mod.rs index 670ab3a..a72d531 100644 --- a/crates/dyndns/src/config/mod.rs +++ b/crates/dyndns/src/config/mod.rs @@ -1,5 +1,4 @@ pub mod app_config; pub mod app_config_builder; -pub mod config_builder; pub mod consts; pub mod early; diff --git a/crates/dyndns/src/db/setup.rs b/crates/dyndns/src/db/setup.rs index 766d9f8..3f547e3 100644 --- a/crates/dyndns/src/db/setup.rs +++ b/crates/dyndns/src/db/setup.rs @@ -20,7 +20,20 @@ pub fn setup_db(maybe_db_path: Option) -> Result) -> Result { if cfg!(debug_assertions) { + // Make the path absolute relative to the current directory to avoid weird + // flaky failures in test_do_ops_with_db because the current directory is + // modified by figment::Jail in other tests. let db_path = "./config/do_ddns_test.sqlite"; + let cur_dir = std::env::current_dir()?; + let db_path = [cur_dir, db_path.into()] + .iter() + .collect::(); + let db_path = db_path.to_str().ok_or_else(|| { + eyre!( + "Failed to convert db_path: {:#?} PathBuf to a String", + db_path + ) + })?; trace!("Using debug database path: {}", db_path); return Ok(db_path.to_owned()); } diff --git a/crates/dyndns/src/domain_record_api/digital_ocean.rs b/crates/dyndns/src/domain_record_api/digital_ocean.rs index 3fd7a38..d5c0a16 100644 --- a/crates/dyndns/src/domain_record_api/digital_ocean.rs +++ b/crates/dyndns/src/domain_record_api/digital_ocean.rs @@ -116,7 +116,13 @@ mod tests { } fn get_mock_domain_records_response() -> String { - let path = format!("tests/data/{}", "sample_list_domain_records_response.json"); + let path = [ + env!("CARGO_MANIFEST_DIR"), + "tests/data/", + "sample_list_domain_records_response.json", + ] + .iter() + .collect::(); std::fs::read_to_string(path).expect("Mock domain records not found") } @@ -150,36 +156,44 @@ mod tests { #[test] fn test_basic() { - use crate::types::ValueFromStr; use crate::updater::{get_record_to_update, should_update_domain_ip}; - let mut config_builder = - crate::config::app_config_builder::AppConfigBuilder::new(None, None); - config_builder - .set_subdomain_to_update("home".to_owned()) - .set_domain_root("site.com".to_owned()) - .set_digital_ocean_token(ValueFromStr::from_str("123").unwrap()) - .set_log_level(tracing::Level::INFO) - .set_update_interval(crate::config::app_config::UpdateInterval( - std::time::Duration::from_secs(5), - )); - let config = config_builder.build().unwrap(); - let ip_fetcher = MockIpFetcher::default(); - let public_ips = ip_fetcher.fetch_public_ips(true, true).unwrap(); - let updater = MockApi::new(); - let domain_name = &config.domains.domains[0].name; - let hostname_part = &config.domains.domains[0].records[0].name; - let record_type = "A"; - let record_to_update = DomainRecordToUpdate::new(domain_name, hostname_part, record_type); - - let records = updater.get_domain_records(domain_name).unwrap(); - let domain_record = get_record_to_update(&records, &record_to_update).unwrap(); - let (ip_addr, _ip_kind) = public_ips.to_ip_addr_from_any(); - let should_update = should_update_domain_ip(&ip_addr, domain_record); - - assert!(should_update); - - let result = updater.update_domain_ip(domain_record.id, &record_to_update, &ip_addr); - assert!(result.is_err()); + figment::Jail::expect_with(|jail| { + jail.create_file( + "config.toml", + r#" +domain_root = "site.com" +subdomain_to_update = "home" +digital_ocean_token = "123" + "#, + )?; + + let config_builder = crate::config::app_config_builder::AppConfigBuilder::new( + None, + Some("config.toml".to_owned()), + ) + .expect("Failed to create config builder"); + let config = config_builder.build().expect("failed to parse config"); + let ip_fetcher = MockIpFetcher::default(); + let public_ips = ip_fetcher.fetch_public_ips(true, true).unwrap(); + let updater = MockApi::new(); + let domain_name = &config.domains.domains[0].name; + let hostname_part = &config.domains.domains[0].records[0].name; + let record_type = "A"; + let record_to_update = + DomainRecordToUpdate::new(domain_name, hostname_part, record_type); + + let records = updater.get_domain_records(domain_name).unwrap(); + let domain_record = get_record_to_update(&records, &record_to_update).unwrap(); + let (ip_addr, _ip_kind) = public_ips.to_ip_addr_from_any(); + let should_update = should_update_domain_ip(&ip_addr, domain_record); + + assert!(should_update); + + let result = updater.update_domain_ip(domain_record.id, &record_to_update, &ip_addr); + assert!(result.is_err()); + + Ok(()) + }); } } diff --git a/crates/dyndns/src/token.rs b/crates/dyndns/src/token.rs index 331bc79..ff538a4 100644 --- a/crates/dyndns/src/token.rs +++ b/crates/dyndns/src/token.rs @@ -1,8 +1,9 @@ use crate::types::ValueFromStr; use color_eyre::eyre::Error; use secrecy::Secret; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct DigitalOceanToken(String); pub type SecretDigitalOceanToken = Secret; @@ -21,6 +22,7 @@ impl secrecy::Zeroize for DigitalOceanToken { impl secrecy::CloneableSecret for DigitalOceanToken {} impl secrecy::DebugSecret for DigitalOceanToken {} +impl secrecy::SerializableSecret for DigitalOceanToken {} impl std::str::FromStr for DigitalOceanToken { type Err = Error; @@ -37,3 +39,7 @@ impl ValueFromStr for Secret { Ok(Secret::new(s.parse::()?)) } } + +pub fn parse_secret_token(s: &str) -> Result, Error> { + Ok(Secret::new(s.parse::()?)) +}