Skip to main content

auths_cli/commands/
provision.rs

1//! Declarative, headless provisioning for enterprise deployments.
2//!
3//! Reads a TOML configuration file and reconciles the node's identity state
4//! to match. Secrets are handled via environment variable overrides layered
5//! automatically by the `config` crate, never passed as CLI arguments.
6
7use crate::factories::storage::build_auths_context;
8use crate::ux::format::Output;
9use anyhow::{Context, Result, anyhow};
10use auths_sdk::signing::PassphraseProvider;
11use auths_sdk::storage_layout::install_linearity_hook;
12use auths_sdk::workflows::provision::{IdentityConfig, NodeConfig, enforce_identity_state};
13use clap::Parser;
14use config::{Config, Environment, File};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18/// Declarative headless provisioning for enterprise deployments.
19///
20/// Reads a TOML configuration file and reconciles the node's identity
21/// state to match. Environment variables with prefix `AUTHS_` and
22/// separator `__` override any TOML values.
23///
24/// Usage:
25/// ```ignore
26/// auths provision --config node.toml
27/// auths provision --config node.toml --dry-run
28/// AUTHS_IDENTITY__KEY_ALIAS=override auths provision --config node.toml
29/// ```
30#[derive(Parser, Debug, Clone)]
31#[command(
32    name = "provision",
33    about = "Declarative headless provisioning from a TOML config file"
34)]
35pub struct ProvisionCommand {
36    /// Path to the TOML configuration file.
37    #[arg(long, value_parser, help = "Path to the TOML config file")]
38    pub config: PathBuf,
39
40    /// Validate config and print resolved state without applying changes.
41    #[arg(long, help = "Validate and print resolved config without applying")]
42    pub dry_run: bool,
43
44    /// Overwrite existing identity if present.
45    #[arg(long, help = "Overwrite existing identity")]
46    pub force: bool,
47}
48
49/// Handle the provision command.
50///
51/// Args:
52/// * `cmd`: The parsed provision command with config path, dry-run, and force flags.
53/// * `passphrase_provider`: Provider for key encryption passphrases.
54///
55/// Usage:
56/// ```ignore
57/// handle_provision(cmd, Arc::clone(&passphrase_provider))?;
58/// ```
59pub fn handle_provision(
60    cmd: ProvisionCommand,
61    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
62) -> Result<()> {
63    let out = Output::new();
64    let config = load_node_config(&cmd.config)?;
65
66    if cmd.dry_run {
67        return display_resolved_state(&config, &out);
68    }
69
70    out.print_heading("Auths Provision");
71    out.println("================");
72    out.newline();
73
74    validate_storage_perimeter(&config.identity, &out)?;
75    out.print_info("Initializing identity...");
76
77    let repo_path = Path::new(&config.identity.repo_path);
78    let auths_ctx = build_auths_context(
79        repo_path,
80        &Default::default(),
81        Some(passphrase_provider.clone()),
82    )?;
83
84    match enforce_identity_state(
85        &config,
86        cmd.force,
87        passphrase_provider.as_ref(),
88        auths_ctx.key_storage.as_ref(),
89        Arc::clone(&auths_ctx.registry),
90        Arc::clone(&auths_ctx.identity_storage),
91    )
92    .map_err(anyhow::Error::from)?
93    {
94        None => {
95            out.print_success("Identity already exists and matches — no changes needed.");
96        }
97        Some(result) => {
98            out.newline();
99            out.print_success("Identity provisioned successfully.");
100            out.println(&format!(
101                "  {}",
102                out.key_value("Identity", &result.controller_did)
103            ));
104            out.println(&format!(
105                "  {}",
106                out.key_value("Key name", &result.key_alias)
107            ));
108        }
109    }
110
111    install_system_hooks(&config.identity, &out);
112    print_provision_summary(&config, &out);
113
114    Ok(())
115}
116
117/// Load and merge TOML config file with environment variable overrides.
118///
119/// Environment variables use prefix `AUTHS_` with double-underscore separator
120/// for nested keys. For example:
121/// - `AUTHS_IDENTITY__KEY_ALIAS` overrides `identity.key_alias`
122/// - `AUTHS_WITNESS__THRESHOLD` overrides `witness.threshold`
123///
124/// Args:
125/// * `path`: Path to the TOML configuration file.
126///
127/// Usage:
128/// ```ignore
129/// let config = load_node_config(Path::new("node.toml"))?;
130/// ```
131fn load_node_config(path: &Path) -> Result<NodeConfig> {
132    let path_str = path
133        .to_str()
134        .ok_or_else(|| anyhow!("Config path is not valid UTF-8"))?;
135
136    let settings = Config::builder()
137        .add_source(File::with_name(path_str))
138        .add_source(Environment::with_prefix("AUTHS").separator("__"))
139        .build()
140        .with_context(|| format!("Failed to load config from {:?}", path))?;
141
142    settings
143        .try_deserialize::<NodeConfig>()
144        .with_context(|| "Failed to deserialize node config")
145}
146
147/// Print the resolved configuration for `--dry-run` inspection.
148fn display_resolved_state(config: &NodeConfig, out: &Output) -> Result<()> {
149    out.print_heading("Resolved Configuration (dry-run)");
150    out.println("=================================");
151    out.newline();
152
153    out.println(&format!(
154        "  {}",
155        out.key_value("key_alias", &config.identity.key_alias)
156    ));
157    out.println(&format!(
158        "  {}",
159        out.key_value("repo_path", &config.identity.repo_path)
160    ));
161    out.println(&format!(
162        "  {}",
163        out.key_value("preset", &config.identity.preset)
164    ));
165
166    if !config.identity.metadata.is_empty() {
167        out.newline();
168        out.println("  Metadata:");
169        for (k, v) in &config.identity.metadata {
170            out.println(&format!("    {} = {}", k, v));
171        }
172    }
173
174    if let Some(ref witness) = config.witness {
175        out.newline();
176        out.println("  Witness:");
177        out.println(&format!(
178            "    {}",
179            out.key_value(
180                "witnesses",
181                &witness
182                    .witnesses
183                    .iter()
184                    .map(|w| format!("{} ({})", w.url, w.aid))
185                    .collect::<Vec<_>>()
186                    .join(", "),
187            )
188        ));
189        out.println(&format!(
190            "    {}",
191            out.key_value("threshold", &witness.threshold.to_string())
192        ));
193        out.println(&format!(
194            "    {}",
195            out.key_value("timeout_ms", &witness.timeout_ms.to_string())
196        ));
197        out.println(&format!("    {}", out.key_value("policy", &witness.policy)));
198    }
199
200    out.newline();
201    out.print_success("Config is valid. No changes applied (dry-run).");
202    Ok(())
203}
204
205/// Ensure the repo directory exists and contains a Git repository.
206fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result<()> {
207    use crate::factories::storage::{ensure_git_repo, open_git_repo};
208
209    let repo_path = Path::new(&identity.repo_path);
210
211    if repo_path.exists() {
212        match open_git_repo(repo_path) {
213            Ok(_) => {
214                out.println(&format!(
215                    "  Repository: {} ({})",
216                    out.info(&identity.repo_path),
217                    out.success("found")
218                ));
219            }
220            Err(_) => {
221                out.print_info("Initializing Git repository...");
222                ensure_git_repo(repo_path)
223                    .with_context(|| format!("Failed to init Git repository at {:?}", repo_path))?;
224                out.println(&format!(
225                    "  Repository: {} ({})",
226                    out.info(&identity.repo_path),
227                    out.success("initialized")
228                ));
229            }
230        }
231    } else {
232        out.print_info("Creating directory and Git repository...");
233        ensure_git_repo(repo_path).with_context(|| {
234            format!(
235                "Failed to create and init Git repository at {:?}",
236                repo_path
237            )
238        })?;
239        out.println(&format!(
240            "  Repository: {} ({})",
241            out.info(&identity.repo_path),
242            out.success("created")
243        ));
244    }
245
246    Ok(())
247}
248
249/// Install linearity enforcement hook (best-effort).
250fn install_system_hooks(identity: &IdentityConfig, out: &Output) {
251    let repo_path = Path::new(&identity.repo_path);
252    if let Err(e) = install_linearity_hook(repo_path) {
253        out.print_warn(&format!("Could not install linearity hook: {}", e));
254    }
255}
256
257/// Print a summary of what was provisioned.
258fn print_provision_summary(config: &NodeConfig, out: &Output) {
259    out.newline();
260    out.print_heading("Provision Summary");
261    out.println(&format!(
262        "  {}",
263        out.key_value("Repository", &config.identity.repo_path)
264    ));
265    out.println(&format!(
266        "  {}",
267        out.key_value("Key alias", &config.identity.key_alias)
268    ));
269    out.println(&format!(
270        "  {}",
271        out.key_value("Preset", &config.identity.preset)
272    ));
273
274    if let Some(ref w) = config.witness {
275        out.println(&format!(
276            "  {}",
277            out.key_value(
278                "Witnesses",
279                &w.witnesses
280                    .iter()
281                    .map(|e| e.url.clone())
282                    .collect::<Vec<_>>()
283                    .join(", "),
284            )
285        ));
286        out.println(&format!("  {}", out.key_value("Witness policy", &w.policy)));
287    }
288}
289
290impl crate::commands::executable::ExecutableCommand for ProvisionCommand {
291    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
292        handle_provision(self.clone(), ctx.passphrase_provider.clone())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::io::Write;
300    use tempfile::NamedTempFile;
301
302    fn write_test_toml(content: &str) -> NamedTempFile {
303        let mut f = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
304        f.write_all(content.as_bytes()).unwrap();
305        f
306    }
307
308    #[test]
309    fn test_load_minimal_config() {
310        let toml = r#"
311[identity]
312key_alias = "test-key"
313repo_path = "/tmp/test-auths"
314"#;
315        let f = write_test_toml(toml);
316        let config = load_node_config(f.path()).unwrap();
317        assert_eq!(config.identity.key_alias, "test-key");
318        assert_eq!(config.identity.repo_path, "/tmp/test-auths");
319        assert_eq!(config.identity.preset, "default");
320        assert!(config.witness.is_none());
321    }
322
323    #[test]
324    fn test_load_full_config() {
325        let toml = r#"
326[identity]
327key_alias = "prod-key"
328repo_path = "/data/auths"
329preset = "radicle"
330
331[identity.metadata]
332name = "prod-node-01"
333environment = "production"
334
335[witness]
336threshold = 2
337timeout_ms = 10000
338policy = "enforce"
339
340[[witness.witnesses]]
341url = "https://witness1.example.com"
342aid = "BWitnessOne00000000000000000000000000000000"
343
344[[witness.witnesses]]
345url = "https://witness2.example.com"
346aid = "BWitnessTwo00000000000000000000000000000000"
347"#;
348        let f = write_test_toml(toml);
349        let config = load_node_config(f.path()).unwrap();
350        assert_eq!(config.identity.key_alias, "prod-key");
351        assert_eq!(config.identity.preset, "radicle");
352        assert_eq!(
353            config.identity.metadata.get("name").unwrap(),
354            "prod-node-01"
355        );
356        let w = config.witness.unwrap();
357        assert_eq!(w.witnesses.len(), 2);
358        assert_eq!(w.threshold, 2);
359        assert_eq!(w.timeout_ms, 10000);
360        assert_eq!(w.policy, "enforce");
361    }
362
363    #[test]
364    fn test_load_config_with_defaults() {
365        let toml = r#"
366[identity]
367"#;
368        let f = write_test_toml(toml);
369        let config = load_node_config(f.path()).unwrap();
370        assert_eq!(config.identity.key_alias, "main");
371        assert_eq!(config.identity.preset, "default");
372    }
373
374    #[test]
375    fn test_load_config_missing_file() {
376        let result = load_node_config(Path::new("/nonexistent/config.toml"));
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_provision_command_defaults() {
382        let cmd = ProvisionCommand {
383            config: PathBuf::from("test.toml"),
384            dry_run: false,
385            force: false,
386        };
387        assert!(!cmd.dry_run);
388        assert!(!cmd.force);
389    }
390
391    #[test]
392    fn test_witness_policy_parsing() {
393        let toml = r#"
394[identity]
395key_alias = "test"
396repo_path = "/tmp/test"
397
398[witness]
399urls = ["https://w1.example.com"]
400threshold = 1
401policy = "warn"
402"#;
403        let f = write_test_toml(toml);
404        let config = load_node_config(f.path()).unwrap();
405        let w = config.witness.unwrap();
406        assert_eq!(w.policy, "warn");
407    }
408}