Skip to main content

ryra_core/
upgrade.rs

1//! Diff and upgrade flows for already-installed services.
2//!
3//! "Upgrade" means: re-render an installed service's quadlet + configs
4//! against the current registry, replace any files whose content changed,
5//! and restart the unit. The render path is shared with `add_service`
6//! (driven via [`PlanMode::Upgrade`]); the side-effect steps differ.
7//!
8//! Drift detection is grounded in `service.manifest` — the per-install render
9//! manifest written by `ryra add`. Each tracked file is in one of these
10//! states:
11//!
12//! - **Unchanged**: on-disk content matches what the registry would render.
13//! - **Modified**: registry rendered output differs, but on-disk hash still
14//!   matches the manifest, so we know the file is ours and can be safely
15//!   overwritten.
16//! - **Drift**: on-disk hash matches *neither* the manifest nor the planned
17//!   content — i.e. the user hand-edited it. Refused without `--force`.
18//! - **Added**: file is in the planned set but not in the manifest (registry
19//!   added it).
20//! - **Removed**: file is in the manifest but not in the planned set (registry
21//!   stopped shipping it).
22//!
23//! `.env` is excluded throughout: it carries generated secrets that legitimately
24//! drift across restarts, and re-rendering it on upgrade would clobber rotated
25//! credentials. Its absence from the manifest is the source of truth for that.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::PathBuf;
29
30use crate::error::{Error, Result};
31use crate::exposure::Exposure;
32use crate::generate::GeneratedFile;
33use crate::manifest;
34use crate::metadata::load_metadata;
35use crate::registry::resolve::ServiceRef;
36use crate::{
37    AddResult, PlanMode, REGISTRY_DEFAULT, Step, add_service, is_service_installed,
38    resolve_registry_dir, service_home,
39};
40
41/// Per-file diff classification.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum DiffKind {
44    /// On-disk content matches the planned render. Nothing to do.
45    Unchanged,
46    /// Registry now renders different content. On-disk hash still matches
47    /// the manifest, so the file is ryra-owned and safe to overwrite.
48    Modified,
49    /// On-disk hash differs from both the manifest and the planned render —
50    /// the user hand-edited this file. Upgrade refuses without `--force`.
51    /// Includes the case where there is no manifest entry to compare against
52    /// (service installed before the manifest feature; treated conservatively
53    /// as drift until the user confirms with `--force`).
54    Drift,
55    /// File is in the planned render but absent from the manifest — registry
56    /// added it.
57    Added,
58    /// File is in the manifest but no longer rendered by the registry —
59    /// registry stopped shipping it. Upgrade deletes it.
60    Removed,
61}
62
63#[derive(Debug, Clone)]
64pub struct DiffEntry {
65    pub path: PathBuf,
66    pub kind: DiffKind,
67}
68
69/// One env var the registry expects in `.env` that the user's `.env`
70/// doesn't have. By design env tracking is *append-only* — we never flag
71/// a present-but-different value as drift, and we never propose
72/// removing a key. Users may have manually edited values or added their
73/// own keys; clobbering those would be the larger harm.
74///
75/// `kind` and `prompt` come straight from the registry's `EnvVar`
76/// definition, so the CLI can route Prompted / Required additions
77/// through the same interactive prompt that `ryra add` uses, while
78/// silently appending Default ones.
79#[derive(Debug, Clone)]
80pub struct EnvAddition {
81    pub key: String,
82    pub value: String,
83    pub kind: crate::registry::service_def::EnvKind,
84    pub prompt: Option<String>,
85}
86
87/// Result of comparing the registry's render to what's on disk.
88#[derive(Debug, Clone)]
89pub struct DiffResult {
90    pub service: String,
91    pub entries: Vec<DiffEntry>,
92    /// Static env vars the registry expects but the user's `.env` is
93    /// missing. Empty when the `.env` already covers everything tracked.
94    pub env_additions: Vec<EnvAddition>,
95}
96
97impl DiffResult {
98    /// True when nothing about the install would change — neither files
99    /// nor env vars.
100    pub fn is_clean(&self) -> bool {
101        self.entries
102            .iter()
103            .all(|e| matches!(e.kind, DiffKind::Unchanged))
104            && self.env_additions.is_empty()
105    }
106
107    /// Files the user hand-edited. Upgrade must refuse to overwrite these
108    /// without `--force`.
109    pub fn drifted(&self) -> Vec<&DiffEntry> {
110        self.entries
111            .iter()
112            .filter(|e| matches!(e.kind, DiffKind::Drift))
113            .collect()
114    }
115}
116
117/// Reconstruct the planning inputs we stashed at install time and feed them
118/// back through `add_service` in upgrade mode. Returns the planned step
119/// list and the planned-file content map (path → content). The richer
120/// per-env metadata lives on `AddResult.tracked_envs`.
121async fn replan(service_name: &str) -> Result<(AddResult, BTreeMap<PathBuf, String>)> {
122    if !is_service_installed(service_name) {
123        return Err(Error::ServiceNotInstalled(service_name.to_string()));
124    }
125    let metadata = load_metadata(service_name)?
126        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
127
128    let exposure = match metadata.url.as_deref() {
129        Some(url) => Exposure::from_url(url),
130        None => Exposure::Loopback,
131    };
132
133    let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
134        ServiceRef::Default(service_name.to_string())
135    } else if crate::registry::resolve::is_path_like(&metadata.registry) {
136        // Local-path install: re-read ./service.toml from the recorded project dir.
137        ServiceRef::Path {
138            dir: PathBuf::from(&metadata.registry),
139            name: service_name.to_string(),
140        }
141    } else {
142        ServiceRef::Custom {
143            registry: metadata.registry.clone(),
144            service: service_name.to_string(),
145        }
146    };
147    let repo_dir = resolve_registry_dir(&service_ref).await?;
148
149    // Recover existing host ports from the install's `.env` so the
150    // re-render lands on the same numbers. Without this every dynamically
151    // allocated port shifts because `port_in_use` reports them taken.
152    let port_overrides = read_existing_ports(service_name)?;
153
154    // Trivial port-in-use closure: the upgrade caller pins every port via
155    // `port_overrides`, so the closure is never consulted. Returning false
156    // unconditionally is safe — no allocation runs.
157    let port_in_use = |_p: u16| false;
158
159    let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
160    let no_env_overrides = BTreeMap::new();
161    let result = add_service(crate::AddServiceParams {
162        service_name,
163        exposure: &exposure,
164        auth: match metadata.auth.clone() {
165            Some(kind) => crate::AuthChoice::Native(kind),
166            None => crate::AuthChoice::None,
167        },
168        // SMTP and backup enablement are per-install state — persisted by
169        // `ryra add` and `ryra configure`. Upgrade preserves whatever the
170        // user picked.
171        enable_smtp: metadata.smtp_enabled,
172        enable_backup: metadata.backup_enabled,
173        env_overrides: &no_env_overrides,
174        enabled_groups: &enabled_groups,
175        registry_name: &metadata.registry,
176        repo_dir: &repo_dir,
177        pre_built_ctx: None,
178        port_in_use: &port_in_use,
179        // ACME mode is only consumed when adding the reverse proxy itself;
180        // upgrade never needs to seed the TLS snippet.
181        acme_mode: None,
182        mode: PlanMode::Upgrade,
183        port_overrides: &port_overrides,
184    })?;
185
186    let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
187    for step in &result.steps {
188        if let Step::WriteFile(file) = step {
189            planned.insert(file.path.clone(), file.content.clone());
190        }
191    }
192    Ok((result, planned))
193}
194
195/// Parse the on-disk `.env` for a service into a key→value map. Lines
196/// without `=`, comments, and blanks are skipped. Returns an empty map if
197/// the file is absent — caller decides whether that's a soft error.
198fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
199    let env_path = service_home(service_name)?.join(".env");
200    let mut out: BTreeMap<String, String> = BTreeMap::new();
201    let content = match std::fs::read_to_string(&env_path) {
202        Ok(c) => c,
203        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
204        Err(source) => {
205            return Err(Error::FileRead {
206                path: env_path,
207                source,
208            });
209        }
210    };
211    for line in content.lines() {
212        let line = line.trim();
213        if line.is_empty() || line.starts_with('#') {
214            continue;
215        }
216        if let Some((k, v)) = line.split_once('=') {
217            out.insert(k.trim().to_string(), v.to_string());
218        }
219    }
220    Ok(out)
221}
222
223/// Parse `SERVICE_PORT_<NAME>=<port>` lines out of an installed service's
224/// `.env`. Returns a name → port map (lowercased name, matching the
225/// `[[ports]]` definition in service.toml). Also used by the metrics
226/// bridge to resolve host-network scrape targets retroactively.
227pub(crate) fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
228    let env_path = service_home(service_name)?.join(".env");
229    let mut overrides = BTreeMap::new();
230    let content = match std::fs::read_to_string(&env_path) {
231        Ok(c) => c,
232        // No .env yet means a half-installed service; let the planner
233        // re-allocate. (`add_service` will then surface a richer error if
234        // the install is genuinely broken.)
235        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
236        Err(source) => {
237            return Err(Error::FileRead {
238                path: env_path,
239                source,
240            });
241        }
242    };
243    for line in content.lines() {
244        let line = line.trim();
245        if line.is_empty() || line.starts_with('#') {
246            continue;
247        }
248        let Some((key, value)) = line.split_once('=') else {
249            continue;
250        };
251        let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
252            continue;
253        };
254        if let Ok(port) = value.trim().parse::<u16>() {
255            overrides.insert(name.to_ascii_lowercase(), port);
256        }
257    }
258    Ok(overrides)
259}
260
261/// Lockfile-tracked files we never want to flag as drift. The `.env` carries
262/// generated secrets that rotate at runtime; `service.manifest` itself is the
263/// manifest, not a tracked file. Both are excluded from the planned set
264/// during diffing so they don't appear as Removed/Added.
265fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
266    if path == manifest_file {
267        return true;
268    }
269    matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
270}
271
272/// Compute the diff between the registry's render and what's on disk for an
273/// installed service.
274pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
275    let (result, planned) = replan(service_name).await?;
276    let manifest_file = manifest::manifest_path(service_name)?;
277    let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
278    let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
279        .into_iter()
280        .map(|e| (e.path, e.sha256))
281        .collect();
282
283    // Env additions: registry-expected static keys missing from the user's
284    // `.env`. Append-only — we ignore present-but-different values
285    // (could be a manual override) and never propose removals (could be
286    // a key the user added themselves that the registry happens not to
287    // ship). The registry-side list comes from the freshly-rendered
288    // `tracked_envs` (which carries kind + prompt for the CLI), not the
289    // on-disk manifest — that's the source of truth.
290    let existing_env = read_existing_env_keys(service_name)?;
291    let env_additions: Vec<EnvAddition> = result
292        .tracked_envs
293        .iter()
294        .filter(|p| !existing_env.contains_key(&p.key))
295        .map(|p| EnvAddition {
296            key: p.key.clone(),
297            value: p.value.clone(),
298            kind: p.kind.clone(),
299            prompt: p.prompt.clone(),
300        })
301        .collect();
302
303    let mut entries: Vec<DiffEntry> = Vec::new();
304    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
305
306    // Walk planned files first — Added / Modified / Drift / Unchanged.
307    for (path, content) in &planned {
308        if should_skip_path(path, &manifest_file) {
309            continue;
310        }
311        seen.insert(path.clone());
312        let planned_hash = manifest::hash_bytes(content.as_bytes());
313        let on_disk_hash = if path.exists() {
314            Some(manifest::hash_file(path)?)
315        } else {
316            None
317        };
318        let manifest_hash = manifest_by_path.get(path);
319
320        let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
321            // File doesn't exist on disk.
322            (None, Some(_)) | (None, None) => match manifest_hash {
323                Some(_) => DiffKind::Modified, // we wrote it, user deleted it; restore
324                None => DiffKind::Added,       // registry adds it, fresh write
325            },
326            // On-disk content already matches what the registry would render.
327            (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
328            // No manifest entry → can't tell if the user touched it.
329            // Conservative: treat as drift so --force is required once.
330            (Some(_), None) => DiffKind::Drift,
331            // On-disk matches the manifest but not the planned render →
332            // ryra-owned, safe to overwrite.
333            (Some(d), Some(l)) if d == l => DiffKind::Modified,
334            // On-disk matches neither lock nor plan → user hand-edited.
335            (Some(_), Some(_)) => DiffKind::Drift,
336        };
337        entries.push(DiffEntry {
338            path: path.clone(),
339            kind,
340        });
341    }
342
343    // Walk manifest entries that the planner no longer emits — Removed.
344    for path in manifest_by_path.keys() {
345        if seen.contains(path) {
346            continue;
347        }
348        if should_skip_path(path, &manifest_file) {
349            continue;
350        }
351        entries.push(DiffEntry {
352            path: path.clone(),
353            kind: DiffKind::Removed,
354        });
355    }
356
357    entries.sort_by(|a, b| a.path.cmp(&b.path));
358    Ok(DiffResult {
359        service: service_name.to_string(),
360        entries,
361        env_additions,
362    })
363}
364
365/// Plan an upgrade for an installed service.
366///
367/// Returns the steps to execute and the backup directory where displaced
368/// files will be copied. The backup dir is *also* baked into the steps
369/// (as `Step::CopyFile` entries placed before each `Step::WriteFile`).
370pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
371    let diff = diff_service(service_name).await?;
372
373    if !force {
374        let drifted = diff.drifted();
375        if !drifted.is_empty() {
376            return Err(Error::HandEditedFiles {
377                service: service_name.to_string(),
378                paths: drifted.iter().map(|e| e.path.clone()).collect(),
379            });
380        }
381    }
382
383    let (result, planned) = replan(service_name).await?;
384    let manifest_file = manifest::manifest_path(service_name)?;
385    let env_file = service_home(service_name)?.join(".env");
386
387    // Hard-fail if `.env` is missing. Append-only env handling can't
388    // reconstruct generated secrets (mysql_root_password, jwt_key, etc.)
389    // and would silently produce a half-written file that fails on
390    // restart. Surface the real problem instead.
391    if !env_file.exists() {
392        return Err(Error::Template(format!(
393            "{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
394             Restore the file from a backup or reinstall the service.",
395            env_file.display()
396        )));
397    }
398
399    // Decide the backup directory once per upgrade run. Used whenever any
400    // file would be overwritten *or* the existing service.manifest exists (the
401    // lock is always backed up so `ryra revert` can reconstruct the
402    // pre-upgrade state). Empty when neither holds — keeps
403    // `~/.local/state/ryra/` from accumulating no-op dirs.
404    let backup_dir = backup_directory(service_name)?;
405    let needs_backup: BTreeSet<PathBuf> = diff
406        .entries
407        .iter()
408        .filter(|e| {
409            matches!(
410                e.kind,
411                DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
412            )
413        })
414        .map(|e| e.path.clone())
415        .collect();
416    let manifest_will_be_backed_up = manifest_file.exists();
417    let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
418
419    // Filter the planned step list down to what an upgrade should actually do.
420    // - WriteFile for `.env` is dropped (preserve secrets).
421    // - PullImage stays (idempotent if cached, fetches new tag if registry bumped).
422    // - StartService is replaced with RestartService at the very end.
423    // - CreateDir / Symlink stay (idempotent and may be needed for new files).
424    // - DaemonReload stays.
425    // - CopyFile stays (vendored binaries; rare to upgrade but handled the same).
426    // - TailscaleSetup / TailscaleEnable were already gated out by PlanMode::Upgrade.
427    let mut steps: Vec<Step> = Vec::new();
428    if backup_used {
429        steps.push(Step::CreateDir(backup_dir.clone()));
430    }
431    let unchanged: BTreeSet<PathBuf> = diff
432        .entries
433        .iter()
434        .filter(|e| matches!(e.kind, DiffKind::Unchanged))
435        .map(|e| e.path.clone())
436        .collect();
437
438    let env_filename = std::ffi::OsStr::new(".env");
439    for step in result.steps {
440        match step {
441            // .env stays untouched on upgrade — generated secrets in the
442            // running service must not be regenerated.
443            Step::WriteFile(GeneratedFile { ref path, .. })
444                if path.file_name() == Some(env_filename) =>
445            {
446                continue;
447            }
448            // Identical content already on disk — skip the write entirely
449            // so the file's mtime stays put and `sha256sum -c` stays clean
450            // for unchanged entries.
451            Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
452                // The manifest is special: even if "unchanged" by content, we
453                // re-emit it because path-level adds/removes mean its content
454                // has changed and we need the new hashes recorded.
455                if path == &manifest_file {
456                    steps.push(step);
457                }
458                continue;
459            }
460            Step::WriteFile(ref file) => {
461                // Always back up the existing service.manifest too, even though
462                // it's filtered out of the diff. `ryra revert` reads the
463                // backed-up lock to know which files were Added during the
464                // upgrade (current lock − pre-upgrade lock) so it can delete
465                // them on revert. Without this, revert would leave
466                // upgrade-added files orphaned.
467                let should_backup = (needs_backup.contains(&file.path)
468                    || file.path == manifest_file)
469                    && file.path.exists();
470                if should_backup {
471                    let rel = backup_relpath(&file.path);
472                    let dst = backup_dir.join(rel);
473                    if let Some(parent) = dst.parent() {
474                        steps.push(Step::CreateDir(parent.to_path_buf()));
475                    }
476                    steps.push(Step::CopyFile {
477                        src: file.path.clone(),
478                        dst,
479                    });
480                }
481                steps.push(step);
482            }
483            // The replanned step list always ends with StartService; we
484            // strip it and append a RestartService at the very end so the
485            // unit picks up the new quadlet.
486            Step::StartService { .. } => continue,
487            other => steps.push(other),
488        }
489    }
490
491    // Removed files: back them up then delete.
492    for entry in &diff.entries {
493        if !matches!(entry.kind, DiffKind::Removed) {
494            continue;
495        }
496        if entry.path.exists() {
497            let rel = backup_relpath(&entry.path);
498            let dst = backup_dir.join(rel);
499            if let Some(parent) = dst.parent() {
500                steps.push(Step::CreateDir(parent.to_path_buf()));
501            }
502            steps.push(Step::CopyFile {
503                src: entry.path.clone(),
504                dst,
505            });
506        }
507        steps.push(Step::RemoveFile(entry.path.clone()));
508    }
509
510    // Env additions: append registry-required static env vars that the
511    // user's .env doesn't have. Append-only — we never rewrite the
512    // existing .env (that would clobber rotated secrets and any manual
513    // edits) and we never remove keys (the user might have added their
514    // own that the registry happens not to ship). The .env is
515    // intentionally NOT backed up: it only ever gains lines and the
516    // pre-existing content survives unchanged.
517    if !diff.env_additions.is_empty() {
518        let mut content = match std::fs::read_to_string(&env_file) {
519            Ok(c) => c,
520            // Service installed but .env missing? Treat the add as a
521            // fresh write — odd state, but the right one to recover to.
522            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
523            Err(source) => {
524                return Err(Error::FileRead {
525                    path: env_file.clone(),
526                    source,
527                });
528            }
529        };
530        if !content.is_empty() && !content.ends_with('\n') {
531            content.push('\n');
532        }
533        for add in &diff.env_additions {
534            content.push_str(&format!("{}={}\n", add.key, add.value));
535        }
536        steps.push(Step::WriteFile(GeneratedFile {
537            path: env_file,
538            content,
539        }));
540    }
541
542    // Pick up the new quadlet by restarting. RestartService is enough to
543    // re-read the env file, re-run ExecStartPre/Post, and pull in any new
544    // ExecStartPost script (the seafile case).
545    steps.push(Step::RestartService {
546        unit: service_name.to_string(),
547    });
548
549    // Native services rebuild from source on upgrade (the `Build` step) and
550    // restart. A source change leaves the rendered config clean, so force the
551    // apply; otherwise the CLI would short-circuit on the clean diff and never
552    // rebuild. The plan already ends in RestartService.
553    let force_apply = matches!(
554        crate::metadata::load_metadata(service_name),
555        Ok(Some(m)) if m.runtime == crate::registry::service_def::Runtime::Native
556    );
557
558    Ok(UpgradeResult {
559        service: service_name.to_string(),
560        diff,
561        steps,
562        backup_dir: if backup_used { Some(backup_dir) } else { None },
563        // The replanned env content is irrelevant for upgrade (we don't
564        // write it), but expose the template-render context bag in case
565        // future callers need it. Keep it empty for now to avoid
566        // confusing consumers.
567        planned_files: planned,
568        force_apply,
569    })
570}
571
572pub struct UpgradeResult {
573    pub service: String,
574    pub diff: DiffResult,
575    pub steps: Vec<Step>,
576    /// `None` when no files would be overwritten or removed.
577    pub backup_dir: Option<PathBuf>,
578    pub planned_files: BTreeMap<PathBuf, String>,
579    /// Apply even when the config diff is clean. True for native services: a
580    /// source rebuild isn't visible in the rendered config, so the plan must
581    /// still run (the `SyncBinary` step then no-ops if the binary is unchanged).
582    pub force_apply: bool,
583}
584
585/// One available backup snapshot for a service.
586#[derive(Debug, Clone)]
587pub struct BackupSnapshot {
588    /// Filesystem path: `~/.local/state/ryra/backups/<timestamp>/<service>/`.
589    pub path: PathBuf,
590    /// `YYYY-MM-DDTHH-MM-SSZ` timestamp from the parent dir name.
591    pub timestamp: String,
592}
593
594pub struct RevertResult {
595    pub service: String,
596    pub snapshot: BackupSnapshot,
597    pub steps: Vec<Step>,
598    /// Files to be copied from backup back to their original locations.
599    pub files_to_restore: Vec<PathBuf>,
600    /// Files added by the upgrade that didn't exist before — will be
601    /// removed by revert. Empty when the snapshot pre-dates the manifest
602    /// feature (we can't reconstruct what was added without it).
603    pub files_to_delete: Vec<PathBuf>,
604}
605
606/// List every backup snapshot for a service, newest first. Empty result
607/// means there's nothing to revert from.
608/// How many backup snapshots `ryra upgrade` retains per service before
609/// auto-pruning. Each snapshot is small (~tens of KB — config files +
610/// the manifest) so the cap is more about mental clutter than disk; 5
611/// is enough to revert a few iterations back without filling the
612/// `~/.local/state/ryra/backups/` tree with dead snapshots from years
613/// of upgrades.
614pub const DEFAULT_BACKUP_KEEP: usize = 5;
615
616/// Drop snapshots older than the most recent `keep` for this service.
617/// Returns the paths that were removed (newest-first within the
618/// removed set; the kept set keeps the same order). The shared
619/// timestamp dir is also removed when this was the last service-
620/// scoped subdir under it (multi-service upgrade runs share a
621/// timestamp dir; we don't want to nuke other services' state).
622pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
623    let backups_root = state_dir()?.join("backups");
624    prune_backups_in(&backups_root, service_name, keep)
625}
626
627/// Pure inner that operates on an explicit `<state>/backups/` root.
628/// Split out so tests can drive it against a tmp tree without touching
629/// the real XDG state dir.
630fn prune_backups_in(
631    backups_root: &std::path::Path,
632    service_name: &str,
633    keep: usize,
634) -> Result<Vec<PathBuf>> {
635    let snapshots = list_backups_in(backups_root, service_name)?;
636    if snapshots.len() <= keep {
637        return Ok(Vec::new());
638    }
639    let mut removed: Vec<PathBuf> = Vec::new();
640    for snap in snapshots.into_iter().skip(keep) {
641        if let Err(e) = std::fs::remove_dir_all(&snap.path) {
642            eprintln!(
643                "warning: failed to prune backup {}: {e}",
644                snap.path.display()
645            );
646            continue;
647        }
648        removed.push(snap.path.clone());
649        if let Some(parent) = snap.path.parent()
650            && let Ok(mut entries) = std::fs::read_dir(parent)
651            && entries.next().is_none()
652        {
653            let _ = std::fs::remove_dir(parent);
654        }
655    }
656    Ok(removed)
657}
658
659pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
660    let backups_root = state_dir()?.join("backups");
661    list_backups_in(&backups_root, service_name)
662}
663
664fn list_backups_in(
665    backups_root: &std::path::Path,
666    service_name: &str,
667) -> Result<Vec<BackupSnapshot>> {
668    if !backups_root.is_dir() {
669        return Ok(Vec::new());
670    }
671    let mut snapshots: Vec<BackupSnapshot> = Vec::new();
672    let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
673        path: backups_root.to_path_buf(),
674        source,
675    })?;
676    for entry in entries.flatten() {
677        let stamp_dir = entry.path();
678        if !stamp_dir.is_dir() {
679            continue;
680        }
681        let svc_dir = stamp_dir.join(service_name);
682        if !svc_dir.is_dir() {
683            continue;
684        }
685        let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
686            continue;
687        };
688        snapshots.push(BackupSnapshot {
689            path: svc_dir,
690            timestamp: stamp.to_string(),
691        });
692    }
693    // Newest first: timestamp is `YYYY-MM-DDTHH-MM-SSZ`, lexical-descending == reverse-chronological.
694    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
695    Ok(snapshots)
696}
697
698/// Plan a revert for an installed service.
699///
700/// `at` selects a specific backup timestamp; `None` picks the most recent.
701/// The returned plan: restore every file from the backup tree to its
702/// original location, delete files added by the upgrade, daemon-reload,
703/// restart the unit.
704pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
705    if !is_service_installed(service_name) {
706        return Err(Error::ServiceNotInstalled(service_name.to_string()));
707    }
708    let snapshot = pick_snapshot(service_name, at)?;
709
710    // Files to restore: walk the backup tree and reconstruct the original
711    // absolute path for each one. The backup mirrors absolute paths under
712    // `<snapshot>/<original-path-without-leading-slash>`, so the inverse is
713    // simply prefixing `/` to each path-relative-to-snapshot.
714    let mut files_to_restore: Vec<PathBuf> = Vec::new();
715    walk_backup_files(&snapshot.path, &mut files_to_restore)?;
716
717    // Files to delete: anything in the *current* lock that isn't in the
718    // *backed-up* lock was added by the upgrade and should disappear on
719    // revert. If either lock is absent, leave the delete set empty —
720    // safest no-op for snapshots that pre-date this feature.
721    let backup_manifest_file =
722        absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
723    let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
724    let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
725
726    let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
727        .iter()
728        .map(|e| e.path.clone())
729        .collect();
730    let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
731        // Pre-feature snapshot: no way to know what was added.
732        Vec::new()
733    } else {
734        current_manifest_entries
735            .iter()
736            .map(|e| e.path.clone())
737            .filter(|p| !backup_manifest_set.contains(p))
738            .collect()
739    };
740    files_to_delete.sort();
741
742    // Build the step list.
743    let mut steps: Vec<Step> = Vec::new();
744    // Restore: backup → original. CopyFile creates parents itself, so no
745    // CreateDir needed.
746    for backup_path in &files_to_restore {
747        let original = backup_to_absolute_path(&snapshot.path, backup_path);
748        steps.push(Step::CopyFile {
749            src: backup_path.clone(),
750            dst: original,
751        });
752    }
753    // Delete: each Added file, plus any orphan symlink in the quadlet dir
754    // that pointed at it (only the actual file is in the lock; the
755    // companion symlink in `~/.config/containers/systemd/` is not).
756    let qd = crate::quadlet_dir()?;
757    for path in &files_to_delete {
758        if path.exists() {
759            steps.push(Step::RemoveFile(path.clone()));
760        }
761        if let Some(name) = path.file_name() {
762            let symlink = qd.join(name);
763            if std::fs::symlink_metadata(&symlink).is_ok() {
764                steps.push(Step::RemoveFile(symlink));
765            }
766        }
767    }
768    steps.push(Step::DaemonReload);
769    steps.push(Step::RestartService {
770        unit: service_name.to_string(),
771    });
772
773    let files_to_restore_orig: Vec<PathBuf> = files_to_restore
774        .iter()
775        .map(|p| backup_to_absolute_path(&snapshot.path, p))
776        .collect();
777    Ok(RevertResult {
778        service: service_name.to_string(),
779        snapshot,
780        steps,
781        files_to_restore: files_to_restore_orig,
782        files_to_delete,
783    })
784}
785
786/// Resolve the snapshot to revert to. `at` is a timestamp string (e.g.
787/// `2026-05-05T13-33-50Z`); when absent, the most recent snapshot wins.
788fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
789    let snapshots = list_backups(service_name)?;
790    if snapshots.is_empty() {
791        return Err(Error::NoBackup(service_name.to_string()));
792    }
793    match at {
794        None => Ok(snapshots
795            .into_iter()
796            .next()
797            .expect("non-empty checked above")),
798        Some(stamp) => snapshots
799            .into_iter()
800            .find(|s| s.timestamp == stamp)
801            .ok_or_else(|| Error::BackupNotFound {
802                service: service_name.to_string(),
803                stamp: stamp.to_string(),
804            }),
805    }
806}
807
808/// Recursively collect every regular file under `root` into `out`. Symlinks
809/// are followed; we don't expect any in a backup tree (we always copied
810/// targets, never link entries).
811fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
812    let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
813        path: root.to_path_buf(),
814        source,
815    })?;
816    for entry in entries.flatten() {
817        let path = entry.path();
818        let meta = match entry.metadata() {
819            Ok(m) => m,
820            Err(_) => continue,
821        };
822        if meta.is_dir() {
823            walk_backup_files(&path, out)?;
824        } else if meta.is_file() {
825            out.push(path);
826        }
827    }
828    Ok(())
829}
830
831/// Inverse of `backup_relpath`: a backup path `<root>/home/user/foo`
832/// maps back to `/home/user/foo`.
833fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
834    let rel = backup.strip_prefix(root).unwrap_or(backup);
835    PathBuf::from("/").join(rel)
836}
837
838/// Forward variant: `<root>` + `/home/user/foo` → `<root>/home/user/foo`.
839fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
840    let rel = abs.to_string_lossy();
841    let stripped = rel.trim_start_matches('/');
842    root.join(stripped)
843}
844
845/// Read a manifest at the given path. Missing-file is treated as an empty
846/// list — pre-feature backups simply have no lock to reference.
847fn read_manifest_at(
848    path: &std::path::Path,
849) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
850    if !path.exists() {
851        return Ok((Vec::new(), Vec::new()));
852    }
853    let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
854        path: path.to_path_buf(),
855        source,
856    })?;
857    manifest::parse(&content)
858}
859
860/// `~/.local/state/ryra/backups/<timestamp>/<service>/`. Timestamp uses an
861/// ISO-8601-ish form that sorts lexically (no colons — Windows-friendly,
862/// not that it matters today, but the cost is zero).
863fn backup_directory(service_name: &str) -> Result<PathBuf> {
864    let state = state_dir()?;
865    let now = std::time::SystemTime::now()
866        .duration_since(std::time::UNIX_EPOCH)
867        .map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
868        .as_secs();
869    let stamp = format_timestamp(now);
870    Ok(state.join("backups").join(stamp).join(service_name))
871}
872
873/// XDG state dir under `ryra/`. Created on demand by the CreateDir step.
874fn state_dir() -> Result<PathBuf> {
875    let base = dirs::state_dir()
876        .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
877        .ok_or(Error::HomeDirNotFound)?;
878    Ok(base.join("ryra"))
879}
880
881/// Format a UNIX epoch into `YYYY-MM-DDTHH-MM-SSZ`. Avoids the chrono
882/// dependency — we just need stable lexical sort.
883fn format_timestamp(secs: u64) -> String {
884    // Days from 1970-01-01.
885    const SECS_PER_DAY: u64 = 86_400;
886    let days = secs / SECS_PER_DAY;
887    let time_of_day = secs % SECS_PER_DAY;
888    let h = time_of_day / 3600;
889    let m = (time_of_day % 3600) / 60;
890    let s = time_of_day % 60;
891    let (y, mo, d) = ymd_from_days(days);
892    format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
893}
894
895/// Convert "days since 1970-01-01" into `(year, month, day)` using the
896/// civil-from-days algorithm (Howard Hinnant's date library, MIT). Self-
897/// contained so we don't add a chrono/time dep just for backup naming.
898fn ymd_from_days(days: u64) -> (i64, u32, u32) {
899    let z = days as i64 + 719_468;
900    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
901    let doe = (z - era * 146_097) as u64;
902    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
903    let y = yoe as i64 + era * 400;
904    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
905    let mp = (5 * doy + 2) / 153;
906    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
907    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
908    let y = if m <= 2 { y + 1 } else { y };
909    (y, m, d)
910}
911
912/// Map an absolute path into the backup tree. We strip the leading `/` so the
913/// joined path doesn't escape the backup dir; everything else is preserved
914/// verbatim so the user can `diff -r` across the original location.
915fn backup_relpath(path: &std::path::Path) -> PathBuf {
916    PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    #[test]
924    fn timestamp_round_numbers() {
925        // 2026-01-01T00-00-00Z — sanity check on the calendar conversion.
926        // 1767225600 = days from epoch * 86400 for 2026-01-01.
927        // (epoch 0 = 1970-01-01; 56 years incl. leap days = 20454 days.)
928        // Easier: just verify a known value end-to-end.
929        let s = format_timestamp(0);
930        assert_eq!(s, "1970-01-01T00-00-00Z");
931        let s = format_timestamp(86_400);
932        assert_eq!(s, "1970-01-02T00-00-00Z");
933        let s = format_timestamp(31_536_000); // not a leap year (1970)
934        assert_eq!(s, "1971-01-01T00-00-00Z");
935    }
936
937    #[test]
938    fn backup_relpath_strips_leading_slash() {
939        let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
940        assert_eq!(p, PathBuf::from("home/user/foo/bar"));
941    }
942
943    /// Stand up a tmp backups tree with the given timestamps and a
944    /// service subdir under each, then run `prune_backups_in` against it.
945    /// Returns (kept timestamps newest-first, removed paths). Hermetic:
946    /// no env vars touched, no shared global state.
947    fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
948        let tmp = std::env::temp_dir().join(format!(
949            "ryra-prune-test-{}-{}",
950            std::process::id(),
951            std::time::SystemTime::now()
952                .duration_since(std::time::UNIX_EPOCH)
953                .unwrap()
954                .as_nanos()
955        ));
956        let backups_root = tmp.join("backups");
957        for s in stamps {
958            std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
959        }
960        let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
961        let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
962            .unwrap()
963            .filter_map(|e| e.ok())
964            .filter_map(|e| e.file_name().into_string().ok())
965            .collect();
966        kept.sort();
967        kept.reverse();
968        let _ = std::fs::remove_dir_all(&tmp);
969        (kept, removed)
970    }
971
972    #[test]
973    fn prune_keeps_newest_n() {
974        // Five timestamps, keep=3 — the two oldest (lex-smallest) should go.
975        let (kept, removed) = setup_and_prune(
976            &[
977                "2026-01-01T00-00-00Z",
978                "2026-02-01T00-00-00Z",
979                "2026-03-01T00-00-00Z",
980                "2026-04-01T00-00-00Z",
981                "2026-05-01T00-00-00Z",
982            ],
983            3,
984        );
985        assert_eq!(kept.len(), 3);
986        assert_eq!(kept[0], "2026-05-01T00-00-00Z");
987        assert_eq!(kept[2], "2026-03-01T00-00-00Z");
988        assert_eq!(removed.len(), 2);
989    }
990
991    #[test]
992    fn prune_no_op_when_under_keep() {
993        let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
994        assert_eq!(kept.len(), 2);
995        assert!(removed.is_empty());
996    }
997
998    #[test]
999    fn should_skip_path_excludes_env_and_manifest() {
1000        let lock = PathBuf::from("/svc/service.manifest");
1001        assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
1002        assert!(should_skip_path(&lock, &lock));
1003        assert!(!should_skip_path(
1004            &PathBuf::from("/svc/configs/x.sh"),
1005            &lock
1006        ));
1007    }
1008}