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 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
118fn 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
148fn 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
206fn 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
250fn 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
258fn 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}