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::{bail, Context, Result};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use toml::map::Map;
14use toml::Value;
15
16pub struct Orchestrator {
17    loader: ConfigLoader,
18    target_dir: PathBuf,
19    state_dir: Option<PathBuf>,
20    system_mode: bool,
21    package_filter: Option<String>,
22}
23
24#[derive(Debug, Default)]
25pub struct DeployReport {
26    pub created: Vec<PathBuf>,
27    pub updated: Vec<PathBuf>,
28    pub unchanged: Vec<PathBuf>,
29    pub conflicts: Vec<(PathBuf, String)>,
30    pub dry_run_actions: Vec<PathBuf>,
31    pub orphaned: Vec<PathBuf>,
32    pub pruned: Vec<PathBuf>,
33}
34
35struct PendingAction {
36    pkg_name: String,
37    action: scanner::FileAction,
38    pkg_target: PathBuf,
39    rendered: Option<String>,
40    is_system: bool,
41}
42
43impl Orchestrator {
44    pub fn new(dotfiles_dir: &Path, target_dir: &Path) -> Result<Self> {
45        let loader = ConfigLoader::new(dotfiles_dir)?;
46        Ok(Self {
47            loader,
48            target_dir: target_dir.to_path_buf(),
49            state_dir: None,
50            system_mode: false,
51            package_filter: None,
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 loader(&self) -> &ConfigLoader {
71        &self.loader
72    }
73
74    pub fn deploy(&mut self, hostname: &str, dry_run: bool, force: bool) -> Result<DeployReport> {
75        let mut report = DeployReport::default();
76        let mut state = self
77            .state_dir
78            .as_ref()
79            .map(|d| DeployState::new(d))
80            .unwrap_or_default();
81
82        // 1. Load host config
83        let host = self
84            .loader
85            .load_host(hostname)
86            .with_context(|| format!("failed to load host config for '{hostname}'"))?;
87
88        // 2. Load roles and collect packages + merge vars
89        let mut all_requested_packages: Vec<String> = Vec::new();
90        let mut merged_vars: Map<String, Value> = Map::new();
91
92        for role_name in &host.roles {
93            let role = self
94                .loader
95                .load_role(role_name)
96                .with_context(|| format!("failed to load role '{role_name}'"))?;
97
98            for pkg in &role.packages {
99                if !all_requested_packages.contains(pkg) {
100                    all_requested_packages.push(pkg.clone());
101                }
102            }
103
104            merged_vars = vars::merge_vars(&merged_vars, &role.vars);
105        }
106
107        // Host vars override role vars
108        merged_vars = vars::merge_vars(&merged_vars, &host.vars);
109
110        // 3. Resolve dependencies
111        let requested_refs: Vec<&str> = all_requested_packages.iter().map(|s| s.as_str()).collect();
112        let mut resolved = resolver::resolve_packages(self.loader.root(), &requested_refs)?;
113
114        // 3.5. Apply package filter if set
115        if let Some(ref filter) = self.package_filter {
116            let filter_refs: Vec<&str> = vec![filter.as_str()];
117            let filtered = resolver::resolve_packages(self.loader.root(), &filter_refs)?;
118            resolved.retain(|pkg| filtered.contains(pkg));
119        }
120
121        // 4. Collect role names for override resolution
122        let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
123
124        // Phase 1: Scan all packages and collect pending actions
125        let packages_dir = self.loader.packages_dir();
126        let mut pending: Vec<PendingAction> = Vec::new();
127
128        for pkg_name in &resolved {
129            // Filter packages based on system mode
130            let is_system = self
131                .loader
132                .root()
133                .packages
134                .get(pkg_name)
135                .map(|c| c.system)
136                .unwrap_or(false);
137            if self.system_mode != is_system {
138                continue;
139            }
140
141            let pkg_dir = packages_dir.join(pkg_name);
142            if !pkg_dir.is_dir() {
143                eprintln!("warning: package directory not found: {}", pkg_dir.display());
144                continue;
145            }
146
147            let actions = scanner::scan_package(&pkg_dir, hostname, &role_names)?;
148
149            let pkg_target = if let Some(pkg_config) = self.loader.root().packages.get(pkg_name) {
150                if let Some(ref target) = pkg_config.target {
151                    PathBuf::from(expand_path(target, Some(&format!("package '{pkg_name}'")))?)
152                } else {
153                    self.target_dir.clone()
154                }
155            } else {
156                self.target_dir.clone()
157            };
158
159            for action in actions {
160                let rendered = if action.kind == scanner::EntryKind::Template {
161                    let tmpl_content = std::fs::read_to_string(&action.source)
162                        .with_context(|| format!("failed to read template: {}", action.source.display()))?;
163                    Some(template::render_template(&tmpl_content, &merged_vars)?)
164                } else {
165                    None
166                };
167
168                pending.push(PendingAction {
169                    pkg_name: pkg_name.clone(),
170                    action,
171                    pkg_target: pkg_target.clone(),
172                    rendered,
173                    is_system,
174                });
175            }
176        }
177
178        // Phase 2: Target-path collision detection
179        let mut target_owners: HashMap<PathBuf, String> = HashMap::new();
180        for p in &pending {
181            let target_path = p.pkg_target.join(&p.action.target_rel_path);
182            if let Some(existing) = target_owners.get(&target_path) {
183                bail!(
184                    "target collision -- packages '{}' and '{}' both deploy {}",
185                    existing,
186                    p.pkg_name,
187                    target_path.display()
188                );
189            }
190            target_owners.insert(target_path, p.pkg_name.clone());
191        }
192
193        // Phase 3: Load existing state for drift detection
194        let existing_state = self
195            .state_dir
196            .as_ref()
197            .map(|d| DeployState::load(d))
198            .transpose()?
199            .unwrap_or_default();
200
201        let existing_hashes: HashMap<PathBuf, &str> = existing_state
202            .entries()
203            .iter()
204            .map(|e| (e.target.clone(), e.content_hash.as_str()))
205            .collect();
206
207        let existing_targets: HashSet<PathBuf> = existing_state
208            .entries()
209            .iter()
210            .map(|e| e.target.clone())
211            .collect();
212
213        // Phase 4: Deploy each action (with per-package hooks)
214        let mut current_pkg: Option<String> = None;
215        let mut skip_pkg: Option<String> = None;
216
217        for p in &pending {
218            // Run pre_deploy hook when entering a new package
219            if current_pkg.as_deref() != Some(&p.pkg_name) {
220                // Run post_deploy for the previous package
221                if let Some(ref prev_pkg) = current_pkg {
222                    if !dry_run {
223                        if let Some(pkg_config) = self.loader.root().packages.get(prev_pkg) {
224                            if let Some(ref cmd) = pkg_config.post_deploy {
225                                let pkg_target = pending.iter()
226                                    .find(|pp| pp.pkg_name == *prev_pkg)
227                                    .map(|pp| &pp.pkg_target)
228                                    .unwrap();
229                                if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, prev_pkg, "deploy") {
230                                    eprintln!("warning: {e}");
231                                }
232                            }
233                        }
234                    }
235                }
236
237                // Run pre_deploy for the new package
238                if !dry_run {
239                    if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
240                        if let Some(ref cmd) = pkg_config.pre_deploy {
241                            if let Err(e) = crate::hooks::run_hook(cmd, &p.pkg_target, &p.pkg_name, "deploy") {
242                                eprintln!("warning: pre_deploy hook failed, skipping package '{}': {e}", p.pkg_name);
243                                skip_pkg = Some(p.pkg_name.clone());
244                                current_pkg = Some(p.pkg_name.clone());
245                                continue;
246                            }
247                        }
248                    }
249                }
250                skip_pkg = None;
251                current_pkg = Some(p.pkg_name.clone());
252            }
253
254            // Skip all files for a package whose pre_deploy hook failed
255            if skip_pkg.as_deref() == Some(&p.pkg_name) {
256                report.conflicts.push((
257                    p.pkg_target.join(&p.action.target_rel_path),
258                    "skipped: pre_deploy hook failed".to_string(),
259                ));
260                continue;
261            }
262
263            let target_path = p.pkg_target.join(&p.action.target_rel_path);
264
265            // Determine if this is a user-mode symlink deployment
266            let use_symlink = !p.is_system
267                && (p.action.kind == scanner::EntryKind::Base
268                    || p.action.kind == scanner::EntryKind::Override);
269
270            // Drift detection: only for copies (templates + system-mode files)
271            if !use_symlink && target_path.exists() {
272                if let Some(&expected_hash) = existing_hashes.get(&target_path) {
273                    let current_hash = hash::hash_file(&target_path)?;
274                    if current_hash != expected_hash && !force {
275                        eprintln!(
276                            "warning: {} has been modified since last deploy, skipping (use --force to overwrite)",
277                            p.action.target_rel_path.display()
278                        );
279                        report.conflicts.push((
280                            target_path,
281                            "modified since last deploy".to_string(),
282                        ));
283                        continue;
284                    }
285                }
286            }
287
288            // Determine the effective force flag based on the orchestrator decision tree
289            let effective_force = if existing_targets.contains(&target_path) {
290                // Target is in existing state -- managed re-deploy: skip backup
291                true
292            } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir() {
293                // Unmanaged regular file -- backup to originals, pass user's force value
294                force
295            } else {
296                // Symlink, directory, or nonexistent -- pass through
297                force
298            };
299
300            let is_managed = existing_targets.contains(&target_path);
301
302            // Backup pre-existing file content and metadata before deploying
303            // Skip backup for managed re-deploys (preserve the original pre-dotm content)
304            let (original_hash, original_owner, original_group, original_mode) =
305                if !dry_run && !is_managed && target_path.exists() && !target_path.is_symlink() {
306                    let content = std::fs::read(&target_path)?;
307                    let hash = hash::hash_content(&content);
308                    state.store_original(&hash, &content)?;
309
310                    let (owner, group, mode) = metadata::read_file_metadata(&target_path)?;
311                    (Some(hash), Some(owner), Some(group), Some(mode))
312                } else {
313                    (None, None, None, None)
314                };
315
316            // Deploy using the appropriate method
317            let result = if use_symlink {
318                deployer::deploy_symlink(
319                    &p.action,
320                    &p.pkg_target,
321                    dry_run,
322                    effective_force,
323                )?
324            } else {
325                deployer::deploy_copy(
326                    &p.action,
327                    &p.pkg_target,
328                    dry_run,
329                    effective_force,
330                    p.rendered.as_deref(),
331                )?
332            };
333
334            match result {
335                DeployResult::Created | DeployResult::Updated => {
336                    // For content_hash: hash the source file for symlinks, hash the target file for copies
337                    let content_hash = if !dry_run {
338                        if use_symlink {
339                            hash::hash_file(&p.action.source)?
340                        } else {
341                            hash::hash_file(&target_path)?
342                        }
343                    } else {
344                        String::new()
345                    };
346
347                    // Resolve and apply metadata (only for system-mode packages)
348                    let resolved = if !dry_run && p.is_system {
349                        if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
350                            let rel_path_str = p.action.target_rel_path.to_str().unwrap_or("");
351                            let resolved = metadata::resolve_metadata(pkg_config, rel_path_str);
352
353                            if resolved.owner.is_some() || resolved.group.is_some() {
354                                if let Err(e) = metadata::apply_ownership(
355                                    &target_path,
356                                    resolved.owner.as_deref(),
357                                    resolved.group.as_deref(),
358                                ) {
359                                    eprintln!("warning: failed to set ownership on {}: {e}", target_path.display());
360                                }
361                            }
362
363                            if let Some(ref mode) = resolved.mode {
364                                deployer::apply_permission_override(&target_path, mode)?;
365                            }
366
367                            resolved
368                        } else {
369                            metadata::resolve_metadata(
370                                &crate::config::PackageConfig::default(),
371                                "",
372                            )
373                        }
374                    } else {
375                        metadata::resolve_metadata(
376                            &crate::config::PackageConfig::default(),
377                            "",
378                        )
379                    };
380
381                    let abs_source = std::fs::canonicalize(&p.action.source)
382                        .unwrap_or_else(|_| p.action.source.clone());
383
384                    state.record(DeployEntry {
385                        target: target_path.clone(),
386                        staged: None,
387                        source: abs_source,
388                        content_hash,
389                        original_hash,
390                        kind: p.action.kind,
391                        package: p.pkg_name.clone(),
392                        owner: resolved.owner,
393                        group: resolved.group,
394                        mode: resolved.mode,
395                        original_owner,
396                        original_group,
397                        original_mode,
398                    });
399
400                    if matches!(result, DeployResult::Updated) {
401                        report.updated.push(target_path.clone());
402                    } else {
403                        report.created.push(target_path.clone());
404                    }
405                }
406                DeployResult::Conflict(msg) => {
407                    report.conflicts.push((target_path, msg));
408                }
409                DeployResult::DryRun => {
410                    report.dry_run_actions.push(target_path);
411                }
412                _ => {}
413            }
414        }
415
416        // Run post_deploy for the final package
417        if let Some(ref last_pkg) = current_pkg {
418            if !dry_run && skip_pkg.as_deref() != Some(last_pkg) {
419                if let Some(pkg_config) = self.loader.root().packages.get(last_pkg) {
420                    if let Some(ref cmd) = pkg_config.post_deploy {
421                        let pkg_target = pending.iter()
422                            .find(|pp| pp.pkg_name == *last_pkg)
423                            .map(|pp| &pp.pkg_target)
424                            .unwrap();
425                        if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, last_pkg, "deploy") {
426                            eprintln!("warning: {e}");
427                        }
428                    }
429                }
430            }
431        }
432
433        // Phase 4.5: Detect orphaned files
434        if self.state_dir.is_some() {
435            let new_targets: std::collections::HashSet<PathBuf> = pending
436                .iter()
437                .map(|p| p.pkg_target.join(&p.action.target_rel_path))
438                .collect();
439
440            for old_entry in existing_state.entries() {
441                if !new_targets.contains(&old_entry.target) {
442                    report.orphaned.push(old_entry.target.clone());
443
444                    if !dry_run && self.loader.root().dotm.auto_prune {
445                        if old_entry.target.is_symlink() || old_entry.target.exists() {
446                            let _ = std::fs::remove_file(&old_entry.target);
447                            crate::state::cleanup_empty_parents(&old_entry.target);
448                        }
449                        report.pruned.push(old_entry.target.clone());
450                    }
451                }
452            }
453        }
454
455        // Phase 5: Save state
456        if !dry_run && self.state_dir.is_some() {
457            state.save()?;
458        }
459
460        Ok(report)
461    }
462}
463
464/// Expand shell variables and tilde in a path string.
465/// Errors if a referenced environment variable is not defined.
466pub fn expand_path(path: &str, context: Option<&str>) -> Result<String> {
467    shellexpand::full(path)
468        .map(|s| s.into_owned())
469        .map_err(|e| {
470            if let Some(ctx) = context {
471                anyhow::anyhow!("{ctx}: {e}")
472            } else {
473                anyhow::anyhow!("path expansion failed: {e}")
474            }
475        })
476}