1use crate::deployer::{self, DeployResult};
2use crate::hash;
3use crate::loader::ConfigLoader;
4use crate::metadata;
5use crate::resolver;
6use crate::scanner;
7use crate::state::{DeployEntry, DeployState};
8use crate::template;
9use crate::vars;
10use anyhow::{Context, Result, bail};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13
14pub struct Orchestrator {
15 loader: ConfigLoader,
16 target_dir: PathBuf,
17 state_dir: Option<PathBuf>,
18 system_mode: bool,
19 package_filter: Option<String>,
20 no_hooks: bool,
21}
22
23#[derive(Debug, Default)]
24pub struct DeployReport {
25 pub created: Vec<PathBuf>,
26 pub updated: Vec<PathBuf>,
27 pub unchanged: Vec<PathBuf>,
28 pub conflicts: Vec<(PathBuf, String)>,
29 pub dry_run_actions: Vec<PathBuf>,
30 pub orphaned: Vec<PathBuf>,
31 pub pruned: Vec<PathBuf>,
32}
33
34struct PendingAction {
35 pkg_name: String,
36 action: scanner::FileAction,
37 pkg_target: PathBuf,
38 rendered: Option<String>,
39 is_system: bool,
40}
41
42impl Orchestrator {
43 pub fn new(dotfiles_dir: &Path, target_dir: &Path) -> Result<Self> {
44 let loader = ConfigLoader::new(dotfiles_dir)?;
45 Ok(Self {
46 loader,
47 target_dir: target_dir.to_path_buf(),
48 state_dir: None,
49 system_mode: false,
50 package_filter: None,
51 no_hooks: false,
52 })
53 }
54
55 pub fn with_state_dir(mut self, state_dir: &Path) -> Self {
56 self.state_dir = Some(state_dir.to_path_buf());
57 self
58 }
59
60 pub fn with_system_mode(mut self, system: bool) -> Self {
61 self.system_mode = system;
62 self
63 }
64
65 pub fn with_package_filter(mut self, filter: Option<String>) -> Self {
66 self.package_filter = filter;
67 self
68 }
69
70 pub fn with_no_hooks(mut self, no_hooks: bool) -> Self {
71 self.no_hooks = no_hooks;
72 self
73 }
74
75 pub fn loader(&self) -> &ConfigLoader {
76 &self.loader
77 }
78
79 pub fn deploy(&mut self, hostname: &str, dry_run: bool, force: bool) -> Result<DeployReport> {
80 let mut report = DeployReport::default();
81 let mut state = self
82 .state_dir
83 .as_ref()
84 .map(|d| DeployState::new(d))
85 .unwrap_or_default();
86
87 if self.state_dir.is_some() {
88 state.lock()?;
89 }
90
91 let host = self
93 .loader
94 .load_host(hostname)
95 .with_context(|| format!("failed to load host config for '{hostname}'"))?;
96
97 let merged_vars = vars::resolve_vars(&self.loader, hostname)?;
98
99 let mut all_requested_packages: Vec<String> = Vec::new();
101 let mut seen_packages: HashSet<String> = HashSet::new();
102
103 for role_name in &host.roles {
104 let role = self
105 .loader
106 .load_role(role_name)
107 .with_context(|| format!("failed to load role '{role_name}'"))?;
108
109 for pkg in &role.packages {
110 if seen_packages.insert(pkg.clone()) {
111 all_requested_packages.push(pkg.clone());
112 }
113 }
114 }
115
116 let requested_refs: Vec<&str> = all_requested_packages.iter().map(|s| s.as_str()).collect();
118 let mut resolved = resolver::resolve_packages(self.loader.root(), &requested_refs)?;
119
120 if let Some(ref filter) = self.package_filter {
122 let filter_refs: Vec<&str> = vec![filter.as_str()];
123 let filtered = resolver::resolve_packages(self.loader.root(), &filter_refs)?;
124 resolved.retain(|pkg| filtered.contains(pkg));
125 }
126
127 let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
129
130 let packages_dir = self.loader.packages_dir();
132 let mut pending: Vec<PendingAction> = Vec::new();
133
134 for pkg_name in &resolved {
135 let is_system = self
137 .loader
138 .root()
139 .packages
140 .get(pkg_name)
141 .map(|c| c.system)
142 .unwrap_or(false);
143 if self.system_mode != is_system {
144 continue;
145 }
146
147 let pkg_dir = packages_dir.join(pkg_name);
148 if !pkg_dir.is_dir() {
149 eprintln!(
150 "warning: package directory not found: {}",
151 pkg_dir.display()
152 );
153 continue;
154 }
155
156 let actions = scanner::scan_package(&pkg_dir, hostname, &role_names)?;
157
158 let pkg_target = if let Some(pkg_config) = self.loader.root().packages.get(pkg_name) {
159 if let Some(ref target) = pkg_config.target {
160 PathBuf::from(expand_path(target, Some(&format!("package '{pkg_name}'")))?)
161 } else {
162 self.target_dir.clone()
163 }
164 } else {
165 self.target_dir.clone()
166 };
167
168 for action in actions {
169 let rendered = if action.kind == scanner::EntryKind::Template {
170 let tmpl_content =
171 std::fs::read_to_string(&action.source).with_context(|| {
172 format!("failed to read template: {}", action.source.display())
173 })?;
174 Some(template::render_template(&tmpl_content, &merged_vars)?)
175 } else {
176 None
177 };
178
179 pending.push(PendingAction {
180 pkg_name: pkg_name.clone(),
181 action,
182 pkg_target: pkg_target.clone(),
183 rendered,
184 is_system,
185 });
186 }
187 }
188
189 let mut target_owners: HashMap<PathBuf, String> = HashMap::new();
191 for p in &pending {
192 let target_path = p.pkg_target.join(&p.action.target_rel_path);
193 if let Some(existing) = target_owners.get(&target_path) {
194 bail!(
195 "target collision -- packages '{}' and '{}' both deploy {}",
196 existing,
197 p.pkg_name,
198 target_path.display()
199 );
200 }
201 target_owners.insert(target_path, p.pkg_name.clone());
202 }
203
204 let existing_state = self
206 .state_dir
207 .as_ref()
208 .map(|d| DeployState::load(d))
209 .transpose()?
210 .unwrap_or_default();
211
212 let existing_hashes: HashMap<PathBuf, &str> = existing_state
213 .entries()
214 .iter()
215 .map(|e| (e.target.clone(), e.content_hash.as_str()))
216 .collect();
217
218 let existing_targets: HashSet<PathBuf> = existing_state
219 .entries()
220 .iter()
221 .map(|e| e.target.clone())
222 .collect();
223
224 let mut current_pkg: Option<String> = None;
226 let mut skip_pkg: Option<String> = None;
227 let mut deploy_error: Option<anyhow::Error> = None;
228
229 for p in &pending {
230 let file_result: Result<()> = (|| {
231 if current_pkg.as_deref() != Some(&p.pkg_name) {
233 if let Some(ref prev_pkg) = current_pkg {
235 if !dry_run && !self.no_hooks {
236 if let Some(pkg_config) = self.loader.root().packages.get(prev_pkg) {
237 if let Some(ref cmd) = pkg_config.post_deploy {
238 let pkg_target = pending
239 .iter()
240 .find(|pp| pp.pkg_name == *prev_pkg)
241 .map(|pp| &pp.pkg_target)
242 .unwrap();
243 if let Err(e) =
244 crate::hooks::run_hook(cmd, pkg_target, prev_pkg, "deploy")
245 {
246 eprintln!("warning: {e}");
247 }
248 }
249 }
250 }
251 }
252
253 if !dry_run && !self.no_hooks {
255 if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
256 if let Some(ref cmd) = pkg_config.pre_deploy {
257 if let Err(e) = crate::hooks::run_hook(
258 cmd,
259 &p.pkg_target,
260 &p.pkg_name,
261 "deploy",
262 ) {
263 eprintln!(
264 "warning: pre_deploy hook failed, skipping package '{}': {e}",
265 p.pkg_name
266 );
267 skip_pkg = Some(p.pkg_name.clone());
268 current_pkg = Some(p.pkg_name.clone());
269 return Ok(());
270 }
271 }
272 }
273 }
274 skip_pkg = None;
275 current_pkg = Some(p.pkg_name.clone());
276 }
277
278 if skip_pkg.as_deref() == Some(&p.pkg_name) {
280 report.conflicts.push((
281 p.pkg_target.join(&p.action.target_rel_path),
282 "skipped: pre_deploy hook failed".to_string(),
283 ));
284 return Ok(());
285 }
286
287 let target_path = p.pkg_target.join(&p.action.target_rel_path);
288
289 let use_symlink = !p.is_system
291 && (p.action.kind == scanner::EntryKind::Base
292 || p.action.kind == scanner::EntryKind::Override);
293
294 if !use_symlink && target_path.exists() {
296 if let Some(&expected_hash) = existing_hashes.get(&target_path) {
297 let current_hash = hash::hash_file(&target_path)?;
298 if current_hash != expected_hash && !force {
299 eprintln!(
300 "warning: {} has been modified since last deploy, skipping (use --force to overwrite)",
301 p.action.target_rel_path.display()
302 );
303 report
304 .conflicts
305 .push((target_path, "modified since last deploy".to_string()));
306 return Ok(());
307 }
308 }
309 }
310
311 let effective_force = if existing_targets.contains(&target_path) {
313 true
315 } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir()
316 {
317 force
319 } else {
320 force
322 };
323
324 let is_managed = existing_targets.contains(&target_path);
325
326 let (original_hash, original_owner, original_group, original_mode) =
329 if !dry_run && !is_managed && target_path.exists() && !target_path.is_symlink()
330 {
331 let content = std::fs::read(&target_path)?;
332 let hash = hash::hash_content(&content);
333 state.store_original(&hash, &content)?;
334
335 let (owner, group, mode) = metadata::read_file_metadata(&target_path)?;
336 (Some(hash), Some(owner), Some(group), Some(mode))
337 } else {
338 (None, None, None, None)
339 };
340
341 let result = if use_symlink {
343 deployer::deploy_symlink(&p.action, &p.pkg_target, dry_run, effective_force)?
344 } else {
345 deployer::deploy_copy(
346 &p.action,
347 &p.pkg_target,
348 dry_run,
349 effective_force,
350 p.rendered.as_deref(),
351 )?
352 };
353
354 match result {
355 DeployResult::Created | DeployResult::Updated => {
356 let content_hash = if !dry_run {
358 if use_symlink {
359 hash::hash_file(&p.action.source)?
360 } else {
361 hash::hash_file(&target_path)?
362 }
363 } else {
364 String::new()
365 };
366
367 let resolved = if !dry_run && p.is_system {
369 if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
370 let rel_path_str = p.action.target_rel_path.to_str().unwrap_or("");
371 let resolved = metadata::resolve_metadata(pkg_config, rel_path_str);
372
373 if resolved.owner.is_some() || resolved.group.is_some() {
374 if let Err(e) = metadata::apply_ownership(
375 &target_path,
376 resolved.owner.as_deref(),
377 resolved.group.as_deref(),
378 ) {
379 eprintln!(
380 "warning: failed to set ownership on {}: {e}",
381 target_path.display()
382 );
383 }
384 }
385
386 if let Some(ref mode) = resolved.mode {
387 deployer::apply_permission_override(&target_path, mode)?;
388 }
389
390 resolved
391 } else {
392 metadata::resolve_metadata(
393 &crate::config::PackageConfig::default(),
394 "",
395 )
396 }
397 } else {
398 metadata::resolve_metadata(&crate::config::PackageConfig::default(), "")
399 };
400
401 let abs_source = std::fs::canonicalize(&p.action.source)
402 .unwrap_or_else(|_| p.action.source.clone());
403
404 state.record(DeployEntry {
405 target: target_path.clone(),
406 staged: None,
407 source: abs_source,
408 content_hash,
409 original_hash,
410 kind: p.action.kind,
411 package: p.pkg_name.clone(),
412 owner: resolved.owner,
413 group: resolved.group,
414 mode: resolved.mode,
415 original_owner,
416 original_group,
417 original_mode,
418 });
419
420 if matches!(result, DeployResult::Updated) {
421 report.updated.push(target_path.clone());
422 } else {
423 report.created.push(target_path.clone());
424 }
425 }
426 DeployResult::Conflict(msg) => {
427 report.conflicts.push((target_path, msg));
428 }
429 DeployResult::DryRun => {
430 report.dry_run_actions.push(target_path);
431 }
432 _ => {}
433 }
434
435 Ok(())
436 })();
437
438 if let Err(e) = file_result {
439 deploy_error = Some(e);
440 break;
441 }
442 }
443
444 if let Some(ref last_pkg) = current_pkg {
446 if !dry_run
447 && !self.no_hooks
448 && skip_pkg.as_deref() != Some(last_pkg)
449 && deploy_error.is_none()
450 {
451 if let Some(pkg_config) = self.loader.root().packages.get(last_pkg) {
452 if let Some(ref cmd) = pkg_config.post_deploy {
453 let pkg_target = pending
454 .iter()
455 .find(|pp| pp.pkg_name == *last_pkg)
456 .map(|pp| &pp.pkg_target)
457 .unwrap();
458 if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, last_pkg, "deploy")
459 {
460 eprintln!("warning: {e}");
461 }
462 }
463 }
464 }
465 }
466
467 if self.state_dir.is_some() {
469 let new_targets: std::collections::HashSet<PathBuf> = pending
470 .iter()
471 .map(|p| p.pkg_target.join(&p.action.target_rel_path))
472 .collect();
473
474 let resolved_set: HashSet<&str> = resolved.iter().map(|s| s.as_str()).collect();
475
476 for old_entry in existing_state.entries() {
477 if self.package_filter.is_some()
484 && !resolved_set.contains(old_entry.package.as_str())
485 {
486 continue;
487 }
488 if !new_targets.contains(&old_entry.target) {
489 report.orphaned.push(old_entry.target.clone());
490
491 if !dry_run && self.loader.root().dotm.auto_prune {
492 if old_entry.target.is_symlink() || old_entry.target.exists() {
493 let _ = std::fs::remove_file(&old_entry.target);
494 crate::state::cleanup_empty_parents(&old_entry.target);
495 }
496 report.pruned.push(old_entry.target.clone());
497 }
498 }
499 }
500 }
501
502 if !dry_run && self.state_dir.is_some() {
504 let deployed_targets: HashSet<PathBuf> =
508 state.entries().iter().map(|e| e.target.clone()).collect();
509 let orphaned_targets: HashSet<&PathBuf> = report.orphaned.iter().collect();
510 for old in existing_state.entries() {
511 if !deployed_targets.contains(&old.target)
512 && !orphaned_targets.contains(&old.target)
513 {
514 state.record(old.clone());
515 }
516 }
517
518 if deploy_error.is_some() {
519 if let Err(e) = state.save() {
520 eprintln!("warning: failed to save partial state: {e}");
521 }
522 } else {
523 state.save()?;
524 }
525 }
526
527 if let Some(e) = deploy_error {
528 return Err(e);
529 }
530
531 Ok(report)
532 }
533}
534
535pub fn expand_path(path: &str, context: Option<&str>) -> Result<String> {
538 shellexpand::full(path)
539 .map(|s| s.into_owned())
540 .map_err(|e| {
541 if let Some(ctx) = context {
542 anyhow::anyhow!("{ctx}: {e}")
543 } else {
544 anyhow::anyhow!("path expansion failed: {e}")
545 }
546 })
547}