manta_shared/common/config/
mod.rs

1//! Config-file loaders for `cli.toml` and `server.toml`.
2//!
3//! This module owns the file-system paths, env-var overrides
4//! (`MANTA_CLI_CONFIG`, `MANTA_SERVER_CONFIG`), and the loader
5//! functions that parse a config and merge `MANTA_*`-prefixed
6//! environment variables. See [`get_cli_configuration`] and
7//! [`get_server_configuration`] for the canonical entry points.
8//! The typed deserialisation targets live with each binary:
9//! `CliConfiguration` in `manta-cli`, `ServerConfiguration` in
10//! `manta-server`.
11
12use std::{
13  fs::{self, File},
14  io::{Read, Write},
15  path::PathBuf,
16};
17
18use crate::common::error::MantaError as Error;
19use config::Config;
20use directories::ProjectDirs;
21use toml_edit::DocumentMut;
22
23/// Returns the XDG-compliant `ProjectDirs` for manta.
24///
25/// All path helpers in this module delegate to this function
26/// so the qualifier/organization/application triple is defined
27/// in exactly one place.
28fn get_project_dirs() -> Result<ProjectDirs, Error> {
29  ProjectDirs::from(
30    "local", /*qualifier*/
31    "cscs",  /*organization*/
32    "manta", /*application*/
33  )
34  .ok_or_else(|| {
35    Error::MissingField(
36      "Could not determine project directories \
37       (home directory may not be set)"
38        .to_string(),
39    )
40  })
41}
42
43/// Returns the default manta config directory path
44/// (e.g. `~/.config/manta/`).
45pub fn get_default_config_path() -> Result<PathBuf, Error> {
46  Ok(PathBuf::from(get_project_dirs()?.config_dir()))
47}
48
49/// Returns the path of the *legacy* unified config file
50/// (e.g. `~/.config/manta/config.toml`). Used only by
51/// `missing_config_message` to detect when a user is migrating from
52/// the pre-split layout — neither binary ever reads from this path
53/// at startup.
54pub fn get_default_manta_config_file_path() -> Result<PathBuf, Error> {
55  let mut path = get_default_config_path()?;
56  path.push("config.toml");
57  Ok(path)
58}
59
60/// Returns the default CLI config file path
61/// (e.g. `~/.config/manta/cli.toml`).
62pub fn get_default_manta_cli_config_file_path() -> Result<PathBuf, Error> {
63  let mut path = get_default_config_path()?;
64  path.push("cli.toml");
65  Ok(path)
66}
67
68/// Returns the default server config file path
69/// (e.g. `~/.config/manta/server.toml`).
70pub fn get_default_manta_server_config_file_path() -> Result<PathBuf, Error> {
71  let mut path = get_default_config_path()?;
72  path.push("server.toml");
73  Ok(path)
74}
75
76/// Returns the default manta cache directory path
77/// (e.g. `~/.cache/manta/`).
78pub fn get_default_cache_path() -> Result<PathBuf, Error> {
79  Ok(PathBuf::from(get_project_dirs()?.cache_dir()))
80}
81
82/// Reads the manta CLI configuration file (`cli.toml`) and parses it as
83/// TOML, honoring `MANTA_CLI_CONFIG`.
84///
85/// Returns both the file path (for later writing) and the
86/// parsed `DocumentMut`.
87pub fn read_config_toml() -> Result<(PathBuf, DocumentMut), Error> {
88  let path = get_cli_config_file_path()?;
89
90  tracing::debug!(
91    "Reading manta CLI configuration from {}",
92    path.to_string_lossy()
93  );
94
95  let content = fs::read_to_string(&path)?;
96
97  let doc = content.parse::<DocumentMut>()?;
98
99  Ok((path, doc))
100}
101
102/// Writes a `DocumentMut` back to the manta configuration file.
103pub fn write_config_toml(
104  path: &std::path::Path,
105  doc: &DocumentMut,
106) -> Result<(), Error> {
107  let mut file = std::fs::OpenOptions::new()
108    .write(true)
109    .truncate(true)
110    .open(path)?;
111
112  file.write_all(doc.to_string().as_bytes())?;
113  file.flush()?;
114
115  Ok(())
116}
117
118/// Read the root CA certificate from `file_path`, falling
119/// back to the default config directory if the path is
120/// relative.
121pub fn get_csm_root_cert_content(file_path: &str) -> Result<Vec<u8>, Error> {
122  let mut buf = Vec::new();
123  let root_cert_file_rslt = File::open(file_path);
124
125  let file_rslt = if root_cert_file_rslt.is_err() {
126    let mut config_path = get_default_config_path()?;
127    config_path.push(file_path);
128    File::open(config_path)
129  } else {
130    root_cert_file_rslt
131  };
132
133  match file_rslt {
134    Ok(mut file) => {
135      file.read_to_end(&mut buf)?;
136      Ok(buf)
137    }
138    Err(_) => Err(Error::NotFound(
139      "CA public root file could not be found".to_string(),
140    )),
141  }
142}
143
144/// Returns the CLI config file path, honoring `MANTA_CLI_CONFIG` if set.
145pub fn get_cli_config_file_path() -> Result<PathBuf, Error> {
146  if let Ok(env_path) = std::env::var("MANTA_CLI_CONFIG") {
147    Ok(PathBuf::from(env_path))
148  } else {
149    get_default_manta_cli_config_file_path()
150  }
151}
152
153/// Returns the server config file path, honoring `MANTA_SERVER_CONFIG` if set.
154pub fn get_server_config_file_path() -> Result<PathBuf, Error> {
155  if let Ok(env_path) = std::env::var("MANTA_SERVER_CONFIG") {
156    Ok(PathBuf::from(env_path))
157  } else {
158    get_default_manta_server_config_file_path()
159  }
160}
161
162/// Minimal CLI config sample shown in the NotFound error.
163const CLI_CONFIG_SAMPLE: &str = r#"log = "info"
164site = "<site_name>"
165manta_server_url = "https://manta-server.example.com:8443"
166
167# Timeout knobs. Values shown are the built-in defaults — delete a
168# line to fall back to the default, or change the value to override.
169#
170# Per-request HTTP timeout reaching `manta_server_url` (seconds).
171# Default 300 caps REST calls. Streams (SSE log tail, WS console)
172# are unlimited when this is absent; setting it caps streams too.
173request_timeout_secs             = 300
174#
175# `manta power on/off/reset`: poll interval (seconds) and max
176# attempts before giving up. 300 × 3 s = 15 min total wait.
177power_poll_interval_secs         = 3
178power_max_poll_attempts          = 300
179#
180# `manta apply sat-file`: poll interval, overall hard cap, and
181# cap on consecutive "session not yet visible" responses (seconds).
182sat_file_poll_interval_secs      = 10
183sat_file_poll_budget_secs        = 14400   # 4 hours
184sat_file_not_visible_budget_secs = 300     # 5 minutes
185
186[sites.<site_name>]
187backend = "csm"                 # or "ochami"
188shasta_base_url = "https://api.example.com"
189root_ca_cert_file = "alps_root_cert.pem"
190"#;
191
192/// Migration mapping shown when a legacy `config.toml` is detected.
193const CLI_CONFIG_MIGRATION: &str = "\
194Migration from ~/.config/manta/config.toml:
195  copy these fields verbatim:        log, site, auditor, sites
196  add CLI-only (now required):       manta_server_url = \"https://...\"
197                                     (CLI talks only to the manta server)
198  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file
199  do not copy (server-only fields):  the [server] section belongs in
200                                     server.toml, not cli.toml";
201
202/// Minimal server config sample shown in the NotFound error.
203const SERVER_CONFIG_SAMPLE: &str = r#"log = "info"
204
205[server]
206listen_address = "0.0.0.0"
207port = 8443
208cert = "/path/to/server.crt"
209key = "/path/to/server.key"
210console_inactivity_timeout_secs = 1800
211auth_rate_limit_per_minute      = 60     # per source IP for /auth/*; omit to disable
212# Values shown for the two timeout knobs are the built-in defaults —
213# delete a line to fall back to the default, or change to override.
214request_timeout_secs            = 300    # global per-route timeout; returns 408 on expiry
215shutdown_grace_period_secs      = 30     # drain window after SIGTERM / Ctrl+C; matches k8s terminationGracePeriodSeconds
216# allow_http = false                     # opt in to plain-HTTP listen when no cert/key is set
217                                         #   (e.g. TLS terminated upstream). Default fail-closed.
218# Filesystem root for POST /migrate/{backup,restore}. Required for those
219# endpoints to work — the server will reject migrate requests with 400
220# while this is unset. Must be an absolute path to an existing directory.
221# migrate_backup_root = "/var/lib/manta/migrate"
222
223# [auditor.kafka]                        # optional: enable Kafka audit emission
224# brokers            = ["kafka.example.com:9092"]
225# topic              = "manta-audit"
226# message_timeout_ms = 5000              # librdkafka per-message delivery deadline; default 5000
227# delivery_wait_secs = 0                 # how long produce_message blocks; 0 = fire-and-forget (default)
228
229[sites.<site_name>]
230backend = "csm"
231shasta_base_url = "https://api.example.com"
232root_ca_cert_file = "/path/to/alps_root_cert.pem"
233"#;
234
235/// Migration mapping shown when a legacy `config.toml` is detected.
236const SERVER_CONFIG_MIGRATION: &str = "\
237Migration from ~/.config/manta/config.toml:
238  copy these fields verbatim:        log, auditor, sites
239  add new [server] section:          listen_address, port, cert, key,
240                                     console_inactivity_timeout_secs
241  drop (CLI-only):                   site, hsm_group, manta_server_url
242  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file";
243
244fn missing_config_message(
245  binary: &str,
246  expected_path: &std::path::Path,
247  sample: &str,
248  migration: &str,
249) -> String {
250  let legacy_exists = get_default_manta_config_file_path()
251    .map(|p| p.exists())
252    .unwrap_or(false);
253  let mut msg = format!(
254    "{binary} configuration file '{}' not found.\n\nMinimal example:\n\n{sample}",
255    expected_path.to_string_lossy()
256  );
257  if legacy_exists {
258    msg.push('\n');
259    msg.push_str(migration);
260  }
261  msg
262}
263
264/// Load `cli.toml`. Fails loudly if the file is missing; the error
265/// message includes a minimal example and (when a legacy config.toml is
266/// detected) a field-by-field migration mapping.
267pub fn get_cli_configuration() -> Result<Config, Error> {
268  let path = get_cli_config_file_path()?;
269  if !path.exists() {
270    return Err(Error::NotFound(missing_config_message(
271      "CLI",
272      &path,
273      CLI_CONFIG_SAMPLE,
274      CLI_CONFIG_MIGRATION,
275    )));
276  }
277  let path_str = path.to_str().ok_or_else(|| {
278    Error::MissingField(
279      "CLI configuration file path contains invalid UTF-8".to_string(),
280    )
281  })?;
282  ::config::Config::builder()
283    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
284    .add_source(
285      ::config::Environment::with_prefix("MANTA")
286        .try_parsing(true)
287        .prefix_separator("_"),
288    )
289    .build()
290    .map_err(Error::ConfigError)
291}
292
293/// Load `server.toml`. Fails loudly if the file is missing; the error
294/// message includes a minimal example and (when a legacy config.toml is
295/// detected) a field-by-field migration mapping.
296pub fn get_server_configuration() -> Result<Config, Error> {
297  let path = get_server_config_file_path()?;
298  if !path.exists() {
299    return Err(Error::NotFound(missing_config_message(
300      "Server",
301      &path,
302      SERVER_CONFIG_SAMPLE,
303      SERVER_CONFIG_MIGRATION,
304    )));
305  }
306  let path_str = path.to_str().ok_or_else(|| {
307    Error::MissingField(
308      "Server configuration file path contains invalid UTF-8".to_string(),
309    )
310  })?;
311  ::config::Config::builder()
312    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
313    .add_source(
314      ::config::Environment::with_prefix("MANTA")
315        .try_parsing(true)
316        .prefix_separator("_"),
317    )
318    .build()
319    .map_err(Error::ConfigError)
320}
321
322#[cfg(test)]
323mod tests {
324  use super::*;
325  use std::io::Write;
326  use std::sync::Mutex;
327  use tempfile::NamedTempFile;
328
329  // The MANTA_* env vars are process-global; tests that mutate them
330  // must serialise on this lock or they'll race each other under
331  // cargo's default parallel test runner.
332  static ENV_LOCK: Mutex<()> = Mutex::new(());
333
334  /// Guard that sets the named env var on construction and clears it on
335  /// drop. Use inside a test holding `ENV_LOCK` so concurrent tests
336  /// don't see the half-installed value.
337  struct EnvGuard(&'static str);
338  impl EnvGuard {
339    fn set(key: &'static str, value: &str) -> Self {
340      // SAFETY: serialised by `ENV_LOCK` above.
341      unsafe { std::env::set_var(key, value) };
342      Self(key)
343    }
344  }
345  impl Drop for EnvGuard {
346    fn drop(&mut self) {
347      unsafe { std::env::remove_var(self.0) };
348    }
349  }
350
351  fn write_tmp_toml(content: &str) -> NamedTempFile {
352    let mut f = NamedTempFile::new().expect("tempfile");
353    f.write_all(content.as_bytes()).expect("write tempfile");
354    f
355  }
356
357  #[test]
358  fn default_cli_config_path_ends_with_cli_toml() {
359    let path = get_default_manta_cli_config_file_path().unwrap();
360    assert_eq!(path.file_name().unwrap(), "cli.toml");
361  }
362
363  #[test]
364  fn default_server_config_path_ends_with_server_toml() {
365    let path = get_default_manta_server_config_file_path().unwrap();
366    assert_eq!(path.file_name().unwrap(), "server.toml");
367  }
368
369  #[test]
370  fn default_legacy_config_path_ends_with_config_toml() {
371    let path = get_default_manta_config_file_path().unwrap();
372    assert_eq!(path.file_name().unwrap(), "config.toml");
373  }
374
375  #[test]
376  fn cli_and_server_default_paths_share_parent() {
377    let cli = get_default_manta_cli_config_file_path().unwrap();
378    let server = get_default_manta_server_config_file_path().unwrap();
379    assert_eq!(cli.parent(), server.parent());
380  }
381
382  #[test]
383  fn cli_config_file_path_honors_env_var() {
384    let _g = ENV_LOCK.lock().unwrap();
385    let _e = EnvGuard::set("MANTA_CLI_CONFIG", "/tmp/custom-cli.toml");
386    let path = get_cli_config_file_path().unwrap();
387    assert_eq!(path, PathBuf::from("/tmp/custom-cli.toml"));
388  }
389
390  #[test]
391  fn server_config_file_path_honors_env_var() {
392    let _g = ENV_LOCK.lock().unwrap();
393    let _e = EnvGuard::set("MANTA_SERVER_CONFIG", "/tmp/custom-server.toml");
394    let path = get_server_config_file_path().unwrap();
395    assert_eq!(path, PathBuf::from("/tmp/custom-server.toml"));
396  }
397
398  #[test]
399  fn cli_configuration_with_missing_file_returns_notfound() {
400    let _g = ENV_LOCK.lock().unwrap();
401    let _e = EnvGuard::set(
402      "MANTA_CLI_CONFIG",
403      "/nonexistent-dir/definitely-not-here.toml",
404    );
405    let err = get_cli_configuration().unwrap_err();
406    match err {
407      Error::NotFound(msg) => {
408        assert!(
409          msg.contains("CLI configuration file"),
410          "expected helpful NotFound message, got: {msg}"
411        );
412        assert!(
413          msg.contains("Minimal example"),
414          "expected sample TOML in message"
415        );
416      }
417      other => panic!("expected NotFound, got {other:?}"),
418    }
419  }
420
421  #[test]
422  fn cli_configuration_with_malformed_toml_returns_config_error() {
423    let _g = ENV_LOCK.lock().unwrap();
424    let bad = write_tmp_toml("this is = not [valid toml");
425    let _e = EnvGuard::set("MANTA_CLI_CONFIG", bad.path().to_str().unwrap());
426    let err = get_cli_configuration().unwrap_err();
427    assert!(
428      matches!(err, Error::ConfigError(_)),
429      "expected ConfigError variant, got {err:?}"
430    );
431  }
432
433  #[test]
434  fn cli_configuration_loads_valid_toml_and_env_var_overrides_file() {
435    let _g = ENV_LOCK.lock().unwrap();
436    let good = write_tmp_toml(
437      r#"log = "info"
438site = "alps"
439manta_server_url = "https://example:8443"
440"#,
441    );
442    let _path =
443      EnvGuard::set("MANTA_CLI_CONFIG", good.path().to_str().unwrap());
444
445    let cfg = get_cli_configuration().unwrap();
446    assert_eq!(cfg.get_string("log").unwrap(), "info");
447    assert_eq!(cfg.get_string("site").unwrap(), "alps");
448    drop(cfg);
449
450    // Set a MANTA_*-prefixed env var; the `Environment` source should
451    // merge over the file value.
452    let _override = EnvGuard::set("MANTA_LOG", "trace");
453    let cfg = get_cli_configuration().unwrap();
454    assert_eq!(
455      cfg.get_string("log").unwrap(),
456      "trace",
457      "env var should override file value"
458    );
459  }
460
461  #[test]
462  fn server_configuration_with_missing_file_returns_notfound() {
463    let _g = ENV_LOCK.lock().unwrap();
464    let _e = EnvGuard::set(
465      "MANTA_SERVER_CONFIG",
466      "/nonexistent-dir/missing-server.toml",
467    );
468    let err = get_server_configuration().unwrap_err();
469    match err {
470      Error::NotFound(msg) => {
471        assert!(
472          msg.contains("Server configuration file"),
473          "expected helpful NotFound message, got: {msg}"
474        );
475      }
476      other => panic!("expected NotFound, got {other:?}"),
477    }
478  }
479
480  #[test]
481  fn server_configuration_loads_valid_toml() {
482    let _g = ENV_LOCK.lock().unwrap();
483    let good = write_tmp_toml(
484      r#"log = "info"
485
486[server]
487listen_address = "0.0.0.0"
488port = 8443
489cert = "/etc/manta/cert.pem"
490key = "/etc/manta/key.pem"
491"#,
492    );
493    let _e =
494      EnvGuard::set("MANTA_SERVER_CONFIG", good.path().to_str().unwrap());
495    let cfg = get_server_configuration().unwrap();
496    assert_eq!(cfg.get_string("server.listen_address").unwrap(), "0.0.0.0");
497    assert_eq!(cfg.get_int("server.port").unwrap(), 8443);
498  }
499}