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