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        chrono::Utc::now(),
92    )
93    .map_err(anyhow::Error::from)?
94    {
95        None => {
96            out.print_success("Identity already exists and matches — no changes needed.");
97        }
98        Some(result) => {
99            out.newline();
100            out.print_success("Identity provisioned successfully.");
101            out.println(&format!(
102                "  {}",
103                out.key_value("Identity", &result.controller_did)
104            ));
105            out.println(&format!(
106                "  {}",
107                out.key_value("Key name", &result.key_alias)
108            ));
109        }
110    }
111
112    install_system_hooks(&config.identity, &out);
113    print_provision_summary(&config, &out);
114
115    Ok(())
116}
117
118/// Load and merge TOML config file with environment variable overrides.
119///
120/// Environment variables use prefix `AUTHS_` with double-underscore separator
121/// for nested keys. For example:
122/// - `AUTHS_IDENTITY__KEY_ALIAS` overrides `identity.key_alias`
123/// - `AUTHS_WITNESS__THRESHOLD` overrides `witness.threshold`
124///
125/// Args:
126/// * `path`: Path to the TOML configuration file.
127///
128/// Usage:
129/// ```ignore
130/// let config = load_node_config(Path::new("node.toml"))?;
131/// ```
132fn load_node_config(path: &Path) -> Result<NodeConfig> {
133    let path_str = path
134        .to_str()
135        .ok_or_else(|| anyhow!("Config path is not valid UTF-8"))?;
136
137    let settings = Config::builder()
138        .add_source(File::with_name(path_str))
139        .add_source(Environment::with_prefix("AUTHS").separator("__"))
140        .build()
141        .with_context(|| format!("Failed to load config from {:?}", path))?;
142
143    settings
144        .try_deserialize::<NodeConfig>()
145        .with_context(|| "Failed to deserialize node config")
146}
147
148/// Print the resolved configuration for `--dry-run` inspection.
149fn display_resolved_state(config: &NodeConfig, out: &Output) -> Result<()> {
150    out.print_heading("Resolved Configuration (dry-run)");
151    out.println("=================================");
152    out.newline();
153
154    out.println(&format!(
155        "  {}",
156        out.key_value("key_alias", &config.identity.key_alias)
157    ));
158    out.println(&format!(
159        "  {}",
160        out.key_value("repo_path", &config.identity.repo_path)
161    ));
162    out.println(&format!(
163        "  {}",
164        out.key_value("preset", &config.identity.preset)
165    ));
166
167    if !config.identity.metadata.is_empty() {
168        out.newline();
169        out.println("  Metadata:");
170        for (k, v) in &config.identity.metadata {
171            out.println(&format!("    {} = {}", k, v));
172        }
173    }
174
175    if let Some(ref witness) = config.witness {
176        out.newline();
177        out.println("  Witness:");
178        out.println(&format!(
179            "    {}",
180            out.key_value(
181                "witnesses",
182                &witness
183                    .witnesses
184                    .iter()
185                    .map(|w| format!("{} ({})", w.url, w.aid))
186                    .collect::<Vec<_>>()
187                    .join(", "),
188            )
189        ));
190        out.println(&format!(
191            "    {}",
192            out.key_value("threshold", &witness.threshold.to_string())
193        ));
194        out.println(&format!(
195            "    {}",
196            out.key_value("timeout_ms", &witness.timeout_ms.to_string())
197        ));
198        out.println(&format!("    {}", out.key_value("policy", &witness.policy)));
199    }
200
201    out.newline();
202    out.print_success("Config is valid. No changes applied (dry-run).");
203    Ok(())
204}
205
206/// Ensure the repo directory exists and contains a Git repository.
207fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result<()> {
208    use crate::factories::storage::{ensure_git_repo, open_git_repo};
209
210    let repo_path = Path::new(&identity.repo_path);
211
212    if repo_path.exists() {
213        match open_git_repo(repo_path) {
214            Ok(_) => {
215                out.println(&format!(
216                    "  Repository: {} ({})",
217                    out.info(&identity.repo_path),
218                    out.success("found")
219                ));
220            }
221            Err(_) => {
222                out.print_info("Initializing Git repository...");
223                ensure_git_repo(repo_path)
224                    .with_context(|| format!("Failed to init Git repository at {:?}", repo_path))?;
225                out.println(&format!(
226                    "  Repository: {} ({})",
227                    out.info(&identity.repo_path),
228                    out.success("initialized")
229                ));
230            }
231        }
232    } else {
233        out.print_info("Creating directory and Git repository...");
234        ensure_git_repo(repo_path).with_context(|| {
235            format!(
236                "Failed to create and init Git repository at {:?}",
237                repo_path
238            )
239        })?;
240        out.println(&format!(
241            "  Repository: {} ({})",
242            out.info(&identity.repo_path),
243            out.success("created")
244        ));
245    }
246
247    Ok(())
248}
249
250/// Install linearity enforcement hook (best-effort).
251fn install_system_hooks(identity: &IdentityConfig, out: &Output) {
252    let repo_path = Path::new(&identity.repo_path);
253    if let Err(e) = install_linearity_hook(repo_path) {
254        out.print_warn(&format!("Could not install linearity hook: {}", e));
255    }
256}
257
258/// Print a summary of what was provisioned.
259fn print_provision_summary(config: &NodeConfig, out: &Output) {
260    out.newline();
261    out.print_heading("Provision Summary");
262    out.println(&format!(
263        "  {}",
264        out.key_value("Repository", &config.identity.repo_path)
265    ));
266    out.println(&format!(
267        "  {}",
268        out.key_value("Key alias", &config.identity.key_alias)
269    ));
270    out.println(&format!(
271        "  {}",
272        out.key_value("Preset", &config.identity.preset)
273    ));
274
275    if let Some(ref w) = config.witness {
276        out.println(&format!(
277            "  {}",
278            out.key_value(
279                "Witnesses",
280                &w.witnesses
281                    .iter()
282                    .map(|e| e.url.clone())
283                    .collect::<Vec<_>>()
284                    .join(", "),
285            )
286        ));
287        out.println(&format!("  {}", out.key_value("Witness policy", &w.policy)));
288    }
289}
290
291impl crate::commands::executable::ExecutableCommand for ProvisionCommand {
292    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
293        handle_provision(self.clone(), ctx.passphrase_provider.clone())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use std::io::Write;
301    use tempfile::NamedTempFile;
302
303    fn write_test_toml(content: &str) -> NamedTempFile {
304        let mut f = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
305        f.write_all(content.as_bytes()).unwrap();
306        f
307    }
308
309    #[test]
310    fn test_load_minimal_config() {
311        let toml = r#"
312[identity]
313key_alias = "test-key"
314repo_path = "/tmp/test-auths"
315"#;
316        let f = write_test_toml(toml);
317        let config = load_node_config(f.path()).unwrap();
318        assert_eq!(config.identity.key_alias, "test-key");
319        assert_eq!(config.identity.repo_path, "/tmp/test-auths");
320        assert_eq!(config.identity.preset, "default");
321        assert!(config.witness.is_none());
322    }
323
324    #[test]
325    fn test_load_full_config() {
326        let toml = r#"
327[identity]
328key_alias = "prod-key"
329repo_path = "/data/auths"
330preset = "radicle"
331
332[identity.metadata]
333name = "prod-node-01"
334environment = "production"
335
336[witness]
337threshold = 2
338timeout_ms = 10000
339policy = "enforce"
340
341[[witness.witnesses]]
342url = "https://witness1.example.com"
343aid = "BWitnessOne00000000000000000000000000000000"
344
345[[witness.witnesses]]
346url = "https://witness2.example.com"
347aid = "BWitnessTwo00000000000000000000000000000000"
348"#;
349        let f = write_test_toml(toml);
350        let config = load_node_config(f.path()).unwrap();
351        assert_eq!(config.identity.key_alias, "prod-key");
352        assert_eq!(config.identity.preset, "radicle");
353        assert_eq!(
354            config.identity.metadata.get("name").unwrap(),
355            "prod-node-01"
356        );
357        let w = config.witness.unwrap();
358        assert_eq!(w.witnesses.len(), 2);
359        assert_eq!(w.threshold, 2);
360        assert_eq!(w.timeout_ms, 10000);
361        assert_eq!(w.policy, "enforce");
362    }
363
364    #[test]
365    fn test_load_config_with_defaults() {
366        let toml = r#"
367[identity]
368"#;
369        let f = write_test_toml(toml);
370        let config = load_node_config(f.path()).unwrap();
371        assert_eq!(config.identity.key_alias, "main");
372        assert_eq!(config.identity.preset, "default");
373    }
374
375    #[test]
376    fn test_load_config_missing_file() {
377        let result = load_node_config(Path::new("/nonexistent/config.toml"));
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn test_provision_command_defaults() {
383        let cmd = ProvisionCommand {
384            config: PathBuf::from("test.toml"),
385            dry_run: false,
386            force: false,
387        };
388        assert!(!cmd.dry_run);
389        assert!(!cmd.force);
390    }
391
392    #[test]
393    fn test_witness_policy_parsing() {
394        let toml = r#"
395[identity]
396key_alias = "test"
397repo_path = "/tmp/test"
398
399[witness]
400urls = ["https://w1.example.com"]
401threshold = 1
402policy = "warn"
403"#;
404        let f = write_test_toml(toml);
405        let config = load_node_config(f.path()).unwrap();
406        let w = config.witness.unwrap();
407        assert_eq!(w.policy, "warn");
408    }
409}