1use 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#[derive(Parser, Debug, Clone)]
31#[command(
32 name = "provision",
33 about = "Declarative headless provisioning from a TOML config file"
34)]
35pub struct ProvisionCommand {
36 #[arg(long, value_parser, help = "Path to the TOML config file")]
38 pub config: PathBuf,
39
40 #[arg(long, help = "Validate and print resolved config without applying")]
42 pub dry_run: bool,
43
44 #[arg(long, help = "Overwrite existing identity")]
46 pub force: bool,
47}
48
49pub 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
117fn 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
147fn 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
205fn 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
249fn 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
257fn 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}