Skip to main content

dotm/
orchestrator.rs

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        // 1. Load host config and merge vars
92        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        // 2. Collect packages from roles
100        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        // 3. Resolve dependencies
117        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        // 3.5. Apply package filter if set
121        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        // 4. Collect role names for override resolution
128        let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
129
130        // Phase 1: Scan all packages and collect pending actions
131        let packages_dir = self.loader.packages_dir();
132        let mut pending: Vec<PendingAction> = Vec::new();
133
134        for pkg_name in &resolved {
135            // Filter packages based on system mode
136            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        // Phase 2: Target-path collision detection
190        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        // Phase 3: Load existing state for drift detection
205        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        // Phase 4: Deploy each action (with per-package hooks)
225        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                // Run pre_deploy hook when entering a new package
232                if current_pkg.as_deref() != Some(&p.pkg_name) {
233                    // Run post_deploy for the previous package
234                    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                    // Run pre_deploy for the new package
254                    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                // Skip all files for a package whose pre_deploy hook failed
279                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                // Determine if this is a user-mode symlink deployment
290                let use_symlink = !p.is_system
291                    && (p.action.kind == scanner::EntryKind::Base
292                        || p.action.kind == scanner::EntryKind::Override);
293
294                // Drift detection: only for copies (templates + system-mode files)
295                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                // Determine the effective force flag based on the orchestrator decision tree
312                let effective_force = if existing_targets.contains(&target_path) {
313                    // Target is in existing state -- managed re-deploy: skip backup
314                    true
315                } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir()
316                {
317                    // Unmanaged regular file -- backup to originals, pass user's force value
318                    force
319                } else {
320                    // Symlink, directory, or nonexistent -- pass through
321                    force
322                };
323
324                let is_managed = existing_targets.contains(&target_path);
325
326                // Backup pre-existing file content and metadata before deploying
327                // Skip backup for managed re-deploys (preserve the original pre-dotm content)
328                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                // Deploy using the appropriate method
342                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                        // For content_hash: hash the source file for symlinks, hash the target file for copies
357                        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                        // Resolve and apply metadata (only for system-mode packages)
368                        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        // Run post_deploy for the final package
445        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        // Phase 4.5: Detect orphaned files
468        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                // When a --package filter is active, packages outside the
478                // filtered set were never candidates for (re)deploy this run,
479                // so their old entries must not be treated as orphaned. When
480                // there is no filter, a package missing from `resolved` means
481                // it was actually removed from the role/host config, and its
482                // old entries should still go through normal orphan detection.
483                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        // Phase 5: Save state (including partial state on error, so deployed files are tracked)
503        if !dry_run && self.state_dir.is_some() {
504            // Merge: keep old entries for targets we didn't deploy this run,
505            // but drop ones Phase 4.5 already flagged as orphaned so pruned
506            // (or prune-eligible) entries don't get resurrected.
507            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
535/// Expand shell variables and tilde in a path string.
536/// Errors if a referenced environment variable is not defined.
537pub 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}