1use 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#[derive(Parser, Debug, Clone)]
34#[command(
35 name = "provision",
36 about = "Declarative headless provisioning from a TOML config file"
37)]
38pub struct ProvisionCommand {
39 #[arg(long, value_parser, help = "Path to the TOML config file")]
41 pub config: PathBuf,
42
43 #[arg(long, help = "Validate and print resolved config without applying")]
45 pub dry_run: bool,
46
47 #[arg(long, help = "Overwrite existing identity")]
49 pub force: bool,
50}
51
52pub 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
122fn 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
152fn 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
202fn 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
246fn 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
254fn 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}