Skip to main content

rustic_rs/
config.rs

1//! Rustic Config
2//!
3//! See instructions in `commands.rs` to specify the path to your
4//! application's configuration file and/or command-line options
5//! for specifying it.
6
7pub(crate) mod hooks;
8pub(crate) mod logging;
9pub(crate) mod progress_options;
10
11use std::{
12    collections::BTreeMap,
13    fmt::{self, Display, Formatter},
14    path::PathBuf,
15};
16
17use abscissa_core::{FrameworkError, FrameworkErrorKind, config::Config, path::AbsPathBuf};
18use anyhow::{Result, anyhow};
19use clap::{Parser, ValueHint};
20use conflate::Merge;
21use directories::ProjectDirs;
22use itertools::Itertools;
23use jiff::{Timestamp, Zoned, tz::TimeZone};
24use log::Level;
25use reqwest::Url;
26use rustic_core::SnapshotGroupCriterion;
27use serde::{Deserialize, Serialize};
28use serde_with::{DisplayFromStr, serde_as};
29#[cfg(not(all(feature = "mount", feature = "webdav")))]
30use toml::Value;
31
32#[cfg(feature = "mount")]
33use crate::commands::mount::MountCmd;
34#[cfg(feature = "webdav")]
35use crate::commands::webdav::WebDavCmd;
36
37use crate::{
38    commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
39    config::{hooks::Hooks, logging::LoggingOptions, progress_options::ProgressOptions},
40    filtering::SnapshotFilter,
41    repository::AllRepositoryOptions,
42};
43
44/// Rustic Configuration
45///
46/// Further documentation can be found [here](https://github.com/rustic-rs/rustic/blob/main/config/README.md).
47///
48/// # Example
49// TODO: add example
50#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
51#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
52pub struct RusticConfig {
53    /// Global options
54    #[clap(flatten, next_help_heading = "Global options")]
55    pub global: GlobalOptions,
56
57    /// Repository options
58    #[clap(flatten, next_help_heading = "Repository options")]
59    pub repository: AllRepositoryOptions,
60
61    /// Snapshot filter options
62    #[clap(flatten, next_help_heading = "Snapshot filter options")]
63    pub snapshot_filter: SnapshotFilter,
64
65    /// Backup options
66    #[clap(skip)]
67    pub backup: BackupCmd,
68
69    /// Copy options
70    #[clap(skip)]
71    pub copy: CopyCmd,
72
73    /// Forget options
74    #[clap(skip)]
75    pub forget: ForgetOptions,
76
77    /// mount options
78    #[cfg(feature = "mount")]
79    #[clap(skip)]
80    pub mount: MountCmd,
81    #[cfg(not(feature = "mount"))]
82    #[clap(skip)]
83    #[merge(skip)]
84    pub mount: Option<Value>,
85
86    /// webdav options
87    #[cfg(feature = "webdav")]
88    #[clap(skip)]
89    pub webdav: WebDavCmd,
90    #[cfg(not(feature = "webdav"))]
91    #[clap(skip)]
92    #[merge(skip)]
93    pub webdav: Option<Value>,
94}
95
96impl Display for RusticConfig {
97    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98        let config = toml::to_string_pretty(self)
99            .unwrap_or_else(|_| "<Error serializing config>".to_string());
100
101        write!(f, "{config}",)
102    }
103}
104
105impl RusticConfig {
106    /// Merge a profile into the current config by reading the corresponding config file.
107    /// Also recursively merge all profiles given within this config file.
108    ///
109    /// # Arguments
110    ///
111    /// * `profile` - name of the profile to merge
112    /// * `merge_logs` - Vector to collect logs during merging
113    /// * `level_missing` - The log level to use if this profile is missing. Recursive calls will produce a Warning.
114    pub fn merge_profile(
115        &mut self,
116        profile: &str,
117        merge_logs: &mut Vec<(Level, String)>,
118        level_missing: Level,
119    ) -> Result<(), FrameworkError> {
120        let profile_filename = if profile.ends_with(".toml") {
121            profile.to_string()
122        } else {
123            profile.to_string() + ".toml"
124        };
125        let paths = get_config_paths(&profile_filename);
126
127        if let Some(path) = paths.iter().find(|path| path.exists()) {
128            merge_logs.push((Level::Info, format!("using config {}", path.display())));
129            let config_content = std::fs::read_to_string(AbsPathBuf::canonicalize(path)?)?;
130            let config_content = if self.global.profile_substitute_env {
131                subst::substitute(&config_content, &subst::Env).map_err(|e| {
132                    abscissa_core::error::context::Context::new(
133                        FrameworkErrorKind::ParseError,
134                        Some(Box::new(e)),
135                    )
136                })?
137            } else {
138                config_content
139            };
140            let mut config = Self::load_toml(config_content)?;
141            // sanity check
142            if config.global.profile_substitute_env && config.global.use_profiles.is_empty() {
143                merge_logs.push((Level::Warn, "Option `profile-substitute-env` is given without any profiles to load! Note that this option does NOT apply to the file where it is specified!".to_string()));
144            }
145            // if "use_profile" is defined in config file, merge the referenced profiles first
146            for profile in &config.global.use_profiles.clone() {
147                config.merge_profile(profile, merge_logs, Level::Warn)?;
148            }
149            self.merge(config);
150        } else {
151            let paths_string = paths.iter().map(|path| path.display()).join(", ");
152            merge_logs.push((
153                level_missing,
154                format!(
155                    "using no config file, none of these exist: {}",
156                    &paths_string
157                ),
158            ));
159        };
160        Ok(())
161    }
162}
163
164/// Global options
165///
166/// These options are available for all commands.
167#[serde_as]
168#[derive(Default, Debug, Parser, Clone, Deserialize, Serialize, Merge)]
169#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
170pub struct GlobalOptions {
171    /// Substitute environment variables in profiles
172    #[clap(long, global = true, env = "RUSTIC_PROFILE_SUBSTITUTE_ENV")]
173    #[merge(strategy=conflate::bool::overwrite_false)]
174    pub profile_substitute_env: bool,
175
176    /// Config profile to use. This parses the file `<PROFILE>.toml` in the config directory.
177    /// [default: "rustic"]
178    #[clap(
179        short = 'P',
180        long = "use-profile",
181        global = true,
182        value_name = "PROFILE",
183        env = "RUSTIC_USE_PROFILE"
184    )]
185    #[merge(strategy=conflate::vec::append)]
186    pub use_profiles: Vec<String>,
187
188    /// Group snapshots by any combination of host,label,paths,tags, e.g. to find the latest snapshot [default: "host,label,paths"]
189    #[clap(
190        long,
191        short = 'g',
192        global = true,
193        value_name = "CRITERION",
194        env = "RUSTIC_GROUP_BY"
195    )]
196    #[serde_as(as = "Option<DisplayFromStr>")]
197    #[merge(strategy=conflate::option::overwrite_none)]
198    pub group_by: Option<SnapshotGroupCriterion>,
199
200    /// Only show what would be done without modifying anything. Does not affect read-only commands.
201    #[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
202    #[merge(strategy=conflate::bool::overwrite_false)]
203    pub dry_run: bool,
204
205    /// Additional to dry run, but still issue warm-up command if configured
206    #[clap(long, global = true, env = "RUSTIC_DRY_RUN_WARMUP")]
207    #[merge(strategy=conflate::bool::overwrite_false)]
208    pub dry_run_warmup: bool,
209
210    /// Check if index matches pack files and read pack headers if necessary
211    #[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
212    #[merge(strategy=conflate::bool::overwrite_false)]
213    pub check_index: bool,
214
215    /// Settings to customize logging
216    #[clap(flatten)]
217    #[serde(flatten)]
218    pub logging_options: LoggingOptions,
219
220    /// Settings to customize progress bars
221    #[clap(flatten)]
222    #[serde(flatten)]
223    pub progress_options: ProgressOptions,
224
225    /// Hooks
226    #[clap(skip)]
227    pub hooks: Hooks,
228
229    /// List of environment variables to set (only in config file)
230    #[clap(skip)]
231    #[merge(strategy = conflate::btreemap::append_or_ignore)]
232    pub env: BTreeMap<String, String>,
233
234    /// Push metrics to a Prometheus Pushgateway
235    #[serde_as(as = "Option<DisplayFromStr>")]
236    #[clap(long, global = true, env = "RUSTIC_PROMETHEUS", value_name = "PUSHGATEWAY_URL", value_hint = ValueHint::Url)]
237    #[merge(strategy=conflate::option::overwrite_none)]
238    pub prometheus: Option<Url>,
239
240    /// Authenticate to Prometheus Pushgateway using this user
241    #[clap(long, value_name = "USER", env = "RUSTIC_PROMETHEUS_USER")]
242    #[merge(strategy=conflate::option::overwrite_none)]
243    pub prometheus_user: Option<String>,
244
245    /// Authenticate to Prometheus Pushgateway using this password
246    #[clap(long, value_name = "PASSWORD", env = "RUSTIC_PROMETHEUS_PASS")]
247    #[merge(strategy=conflate::option::overwrite_none)]
248    pub prometheus_pass: Option<String>,
249
250    /// Additional labels to set to generated metrics
251    #[clap(skip)]
252    #[merge(strategy=conflate::btreemap::append_or_ignore)]
253    pub metrics_labels: BTreeMap<String, String>,
254
255    /// OpenTelemetry metrics endpoint (HTTP Protobuf)
256    #[serde_as(as = "Option<DisplayFromStr>")]
257    #[clap(long, global = true, env = "RUSTIC_OTEL", value_name = "ENDPOINT_URL", value_hint = ValueHint::Url)]
258    #[merge(strategy=conflate::option::overwrite_none)]
259    pub opentelemetry: Option<Url>,
260
261    /// Show time offsets instead of converting to system time zone
262    #[clap(long, global = true, env = "RUSTIC_SHOW_TIME_OFFSET")]
263    #[merge(strategy=conflate::bool::overwrite_false)]
264    pub show_time_offset: bool,
265}
266
267pub fn parse_labels(s: &str) -> Result<BTreeMap<String, String>> {
268    s.split(',')
269        .filter_map(|s| {
270            let s = s.trim();
271            (!s.is_empty()).then_some(s)
272        })
273        .map(|s| -> Result<_> {
274            let pos = s.find('=').ok_or_else(|| {
275                anyhow!("invalid prometheus label definition: no `=` found in `{s}`")
276            })?;
277            Ok((s[..pos].to_owned(), s[pos + 1..].to_owned()))
278        })
279        .try_collect()
280}
281
282impl GlobalOptions {
283    pub fn is_metrics_configured(&self) -> bool {
284        self.prometheus.is_some() || self.opentelemetry.is_some()
285    }
286
287    pub fn format_timestamp(&self, timestamp: Timestamp) -> String {
288        self.format_time(&timestamp.to_zoned(TimeZone::UTC))
289            .to_string()
290    }
291
292    pub fn format_time(&self, time: &Zoned) -> impl Display {
293        if self.show_time_offset {
294            time.strftime("%Y-%m-%d %H:%M:%S%z")
295        } else {
296            let tz = TimeZone::system();
297            if time.offset() == tz.to_offset(time.timestamp()) {
298                time.strftime("%Y-%m-%d %H:%M:%S")
299            } else {
300                time.with_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S*")
301            }
302        }
303    }
304}
305
306/// Get the paths to the config file
307///
308/// # Arguments
309///
310/// * `filename` - name of the config file
311///
312/// # Returns
313///
314/// A vector of [`PathBuf`]s to the config files
315fn get_config_paths(filename: &str) -> Vec<PathBuf> {
316    [
317        ProjectDirs::from("", "", "rustic")
318            .map(|project_dirs| project_dirs.config_dir().to_path_buf()),
319        get_global_config_path(),
320        Some(PathBuf::from(".")),
321    ]
322    .into_iter()
323    .filter_map(|path| {
324        path.map(|mut p| {
325            p.push(filename);
326            p
327        })
328    })
329    .collect()
330}
331
332/// Get the path to the global config directory on Windows.
333///
334/// # Returns
335///
336/// The path to the global config directory on Windows.
337/// If the environment variable `PROGRAMDATA` is not set, `None` is returned.
338#[cfg(target_os = "windows")]
339fn get_global_config_path() -> Option<PathBuf> {
340    std::env::var_os("PROGRAMDATA").map(|program_data| {
341        let mut path = PathBuf::from(program_data);
342        path.push(r"rustic\config");
343        path
344    })
345}
346
347/// Get the path to the global config directory on ios and wasm targets.
348///
349/// # Returns
350///
351/// `None` is returned.
352#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
353fn get_global_config_path() -> Option<PathBuf> {
354    None
355}
356
357/// Get the path to the global config directory on non-Windows,
358/// non-iOS, non-wasm targets.
359///
360/// # Returns
361///
362/// "/etc/rustic" is returned.
363#[cfg(not(any(target_os = "windows", target_os = "ios", target_arch = "wasm32")))]
364fn get_global_config_path() -> Option<PathBuf> {
365    Some(PathBuf::from("/etc/rustic"))
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use insta::{assert_debug_snapshot, assert_snapshot};
372
373    #[test]
374    fn test_default_config_passes() {
375        let config = RusticConfig::default();
376
377        assert_debug_snapshot!(config);
378    }
379
380    #[test]
381    fn test_default_config_display_passes() {
382        let config = RusticConfig::default();
383
384        assert_snapshot!(config);
385    }
386
387    #[test]
388    fn test_global_env_roundtrip_passes() {
389        let mut config = RusticConfig::default();
390
391        for i in 0..10 {
392            let _ = config
393                .global
394                .env
395                .insert(format!("KEY{i}"), format!("VALUE{i}"));
396        }
397
398        let serialized = toml::to_string(&config).unwrap();
399
400        // Check Serialization
401        assert_snapshot!(serialized);
402
403        let deserialized: RusticConfig = toml::from_str(&serialized).unwrap();
404        // Check Deserialization and Display
405        assert_snapshot!(deserialized);
406
407        // Check Debug
408        assert_debug_snapshot!(deserialized);
409    }
410}