1use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use std::time::SystemTime;
30
31use crate::error::{Error, Result};
32use crate::exposure::Exposure;
33use crate::generate::GeneratedFile;
34use crate::manifest;
35use crate::metadata::{Metadata, load_metadata};
36use crate::registry::resolve::ServiceRef;
37use crate::registry::service_def::{Color, DeployStrategy, Runtime};
38use crate::{
39 AddResult, PlanMode, REGISTRY_DEFAULT, Step, add_service, caddy, deploy, is_service_installed,
40 paths::metadata_path, resolve_registry_dir, service_home,
41};
42
43const IGNORED_DIRS: &[&str] = &[
66 "target",
67 "node_modules",
68 "dist",
69 "build",
70 "out",
71 "vendor",
72 "__pycache__",
73 "venv",
74];
75
76fn any_file_newer_than(dir: &Path, since: SystemTime) -> bool {
80 let Ok(entries) = std::fs::read_dir(dir) else {
81 return false;
82 };
83 for entry in entries.flatten() {
84 let Ok(file_type) = entry.file_type() else {
85 continue;
86 };
87 let path = entry.path();
88 if file_type.is_dir() {
89 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
90 if name.starts_with('.') || IGNORED_DIRS.contains(&name) {
91 continue;
92 }
93 if any_file_newer_than(&path, since) {
94 return true;
95 }
96 } else if file_type.is_file()
97 && let Ok(mtime) = entry.metadata().and_then(|m| m.modified())
98 && mtime > since
99 {
100 return true;
101 }
102 }
103 false
104}
105
106fn service_ref_for(metadata: &Metadata, service_name: &str) -> ServiceRef {
109 if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
110 ServiceRef::Default(service_name.to_string())
111 } else if crate::registry::resolve::is_path_like(&metadata.registry) {
112 ServiceRef::Path {
113 dir: PathBuf::from(&metadata.registry),
114 name: service_name.to_string(),
115 }
116 } else {
117 ServiceRef::Custom {
118 registry: metadata.registry.clone(),
119 service: service_name.to_string(),
120 }
121 }
122}
123
124fn unit_main_pid(service_name: &str) -> Option<u32> {
127 let out = std::process::Command::new("systemctl")
128 .args([
129 "--user",
130 "show",
131 &format!("{service_name}.service"),
132 "-p",
133 "MainPID",
134 "--value",
135 ])
136 .output()
137 .ok()?;
138 if !out.status.success() {
139 return None;
140 }
141 let pid: u32 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
142 (pid != 0).then_some(pid)
143}
144
145fn process_start_time(pid: u32) -> Option<SystemTime> {
149 const USER_HZ: u64 = 100;
153
154 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
155 let after_comm = stat.rsplit_once(')')?.1;
159 let starttime_ticks: u64 = after_comm.split_whitespace().nth(19)?.parse().ok()?;
160
161 let proc_stat = std::fs::read_to_string("/proc/stat").ok()?;
162 let btime: u64 = proc_stat
163 .lines()
164 .find_map(|l| l.strip_prefix("btime ")?.trim().parse().ok())?;
165
166 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(btime + starttime_ticks / USER_HZ))
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum DiffKind {
172 Unchanged,
174 Modified,
177 Drift,
183 Added,
186 Removed,
189}
190
191#[derive(Debug, Clone)]
192pub struct DiffEntry {
193 pub path: PathBuf,
194 pub kind: DiffKind,
195}
196
197#[derive(Debug, Clone)]
208pub struct EnvAddition {
209 pub key: String,
210 pub value: String,
211 pub kind: crate::registry::service_def::EnvKind,
212 pub prompt: Option<String>,
213}
214
215#[derive(Debug, Clone)]
217pub struct DiffResult {
218 pub service: String,
219 pub entries: Vec<DiffEntry>,
220 pub env_additions: Vec<EnvAddition>,
223 pub source_stale: bool,
229}
230
231impl DiffResult {
232 pub fn is_clean(&self) -> bool {
235 self.entries
236 .iter()
237 .all(|e| matches!(e.kind, DiffKind::Unchanged))
238 && self.env_additions.is_empty()
239 }
240
241 pub fn drifted(&self) -> Vec<&DiffEntry> {
244 self.entries
245 .iter()
246 .filter(|e| matches!(e.kind, DiffKind::Drift))
247 .collect()
248 }
249}
250
251async fn replan(service_name: &str) -> Result<Replanned> {
256 if !is_service_installed(service_name) {
257 return Err(Error::ServiceNotInstalled(service_name.to_string()));
258 }
259 let metadata = load_metadata(service_name)?
260 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
261
262 let exposure = match metadata.url.as_deref() {
263 Some(url) => Exposure::from_url(url),
264 None => Exposure::Loopback,
265 };
266
267 let service_ref = service_ref_for(&metadata, service_name);
268 let repo_dir = resolve_registry_dir(&service_ref).await?;
269 let source_dir = crate::registry::find_service(&repo_dir, service_name)?.service_dir;
273 let native = matches!(metadata.runtime, Runtime::Native);
274
275 let port_overrides = read_existing_ports(service_name)?;
279
280 let port_in_use = |_p: u16| false;
284
285 let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
286 let selected_choices = metadata.selected_choices.clone();
287 let no_env_overrides = BTreeMap::new();
288 let result = add_service(crate::AddServiceParams {
289 service_name,
290 exposure: &exposure,
291 auth: match metadata.auth.clone() {
292 Some(kind) => crate::AuthChoice::Native(kind),
293 None => crate::AuthChoice::None,
294 },
295 enable_smtp: metadata.smtp_enabled,
299 enable_backup: metadata.backup_enabled,
300 env_overrides: &no_env_overrides,
301 enabled_groups: &enabled_groups,
302 selected_choices: &selected_choices,
303 registry_name: &metadata.registry,
304 repo_dir: &repo_dir,
305 pre_built_ctx: None,
306 port_in_use: &port_in_use,
307 acme_mode: None,
310 mode: PlanMode::Upgrade,
311 port_overrides: &port_overrides,
312 })?;
313
314 let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
315 for step in &result.steps {
316 if let Step::WriteFile(file) = step {
317 planned.insert(file.path.clone(), file.content.clone());
318 }
319 }
320 Ok(Replanned {
321 result,
322 planned,
323 source_dir,
324 native,
325 })
326}
327
328struct Replanned {
331 result: AddResult,
332 planned: BTreeMap<PathBuf, String>,
333 source_dir: PathBuf,
335 native: bool,
337}
338
339fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
343 let env_path = service_home(service_name)?.join(".env");
344 let mut out: BTreeMap<String, String> = BTreeMap::new();
345 let content = match std::fs::read_to_string(&env_path) {
346 Ok(c) => c,
347 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
348 Err(source) => {
349 return Err(Error::FileRead {
350 path: env_path,
351 source,
352 });
353 }
354 };
355 for line in content.lines() {
356 let line = line.trim();
357 if line.is_empty() || line.starts_with('#') {
358 continue;
359 }
360 if let Some((k, v)) = line.split_once('=') {
361 out.insert(k.trim().to_string(), v.to_string());
362 }
363 }
364 Ok(out)
365}
366
367pub(crate) fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
372 let env_path = service_home(service_name)?.join(".env");
373 let mut overrides = BTreeMap::new();
374 let content = match std::fs::read_to_string(&env_path) {
375 Ok(c) => c,
376 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
380 Err(source) => {
381 return Err(Error::FileRead {
382 path: env_path,
383 source,
384 });
385 }
386 };
387 for line in content.lines() {
388 let line = line.trim();
389 if line.is_empty() || line.starts_with('#') {
390 continue;
391 }
392 let Some((key, value)) = line.split_once('=') else {
393 continue;
394 };
395 let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
396 continue;
397 };
398 if let Ok(port) = value.trim().parse::<u16>() {
399 overrides.insert(name.to_ascii_lowercase(), port);
400 }
401 }
402 Ok(overrides)
403}
404
405fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
410 if path == manifest_file {
411 return true;
412 }
413 matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
414}
415
416pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
419 let Replanned {
420 result,
421 planned,
422 source_dir,
423 native,
424 } = replan(service_name).await?;
425
426 let source_stale = native
430 && unit_main_pid(service_name)
431 .and_then(process_start_time)
432 .is_some_and(|started| any_file_newer_than(&source_dir, started));
433
434 let manifest_file = manifest::manifest_path(service_name)?;
435 let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
436 let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
437 .into_iter()
438 .map(|e| (e.path, e.sha256))
439 .collect();
440
441 let existing_env = read_existing_env_keys(service_name)?;
449 let env_additions: Vec<EnvAddition> = result
450 .tracked_envs
451 .iter()
452 .filter(|p| !existing_env.contains_key(&p.key))
453 .map(|p| EnvAddition {
454 key: p.key.clone(),
455 value: p.value.clone(),
456 kind: p.kind.clone(),
457 prompt: p.prompt.clone(),
458 })
459 .collect();
460
461 let mut entries: Vec<DiffEntry> = Vec::new();
462 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
463
464 for (path, content) in &planned {
466 if should_skip_path(path, &manifest_file) {
467 continue;
468 }
469 seen.insert(path.clone());
470 let planned_hash = manifest::hash_bytes(content.as_bytes());
471 let on_disk_hash = if path.exists() {
472 Some(manifest::hash_file(path)?)
473 } else {
474 None
475 };
476 let manifest_hash = manifest_by_path.get(path);
477
478 let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
479 (None, Some(_)) | (None, None) => match manifest_hash {
481 Some(_) => DiffKind::Modified, None => DiffKind::Added, },
484 (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
486 (Some(_), None) => DiffKind::Drift,
489 (Some(d), Some(l)) if d == l => DiffKind::Modified,
492 (Some(_), Some(_)) => DiffKind::Drift,
494 };
495 entries.push(DiffEntry {
496 path: path.clone(),
497 kind,
498 });
499 }
500
501 for path in manifest_by_path.keys() {
503 if seen.contains(path) {
504 continue;
505 }
506 if should_skip_path(path, &manifest_file) {
507 continue;
508 }
509 entries.push(DiffEntry {
510 path: path.clone(),
511 kind: DiffKind::Removed,
512 });
513 }
514
515 entries.sort_by(|a, b| a.path.cmp(&b.path));
516 Ok(DiffResult {
517 service: service_name.to_string(),
518 entries,
519 env_additions,
520 source_stale,
521 })
522}
523
524pub async fn blue_green_swap(service_name: &str) -> Result<Option<UpgradeResult>> {
537 if !is_service_installed(service_name) {
538 return Err(Error::ServiceNotInstalled(service_name.to_string()));
539 }
540 let metadata = load_metadata(service_name)?
541 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
542
543 let service_ref = service_ref_for(&metadata, service_name);
545 let repo_dir = resolve_registry_dir(&service_ref).await?;
546 let reg = crate::registry::find_service(&repo_dir, service_name)?;
547 let def = ®.def;
548 if def.service.deploy != DeployStrategy::BlueGreen {
549 return Ok(None);
550 }
551 let health_check = def.service.health_check.clone().ok_or_else(|| {
552 Error::Template(format!(
553 "{service_name}: deploy = \"blue-green\" but no health_check — validation should have caught this"
554 ))
555 })?;
556
557 let live = metadata.active_color.unwrap_or(Color::Blue);
559 let target = live.other();
560
561 let primary_port_name = def
564 .ports
565 .iter()
566 .find(|p| p.name.eq_ignore_ascii_case("http"))
567 .or_else(|| def.ports.first())
568 .map(|p| p.name.clone())
569 .ok_or_else(|| {
570 Error::Template(format!("{service_name}: blue/green needs a routable port"))
571 })?;
572 let existing_ports = read_existing_ports(service_name)?;
573 let target_key = format!("{}_{}", primary_port_name.to_ascii_lowercase(), target);
574 let target_port = existing_ports.get(&target_key).copied().ok_or_else(|| {
575 Error::Template(format!(
576 "{service_name}: missing {} in .env — reinstall to allocate the blue/green port pair",
577 deploy::color_port_var(
578 &format!("SERVICE_PORT_{}", primary_port_name.to_uppercase()),
579 target
580 )
581 ))
582 })?;
583 let health_url = format!("http://127.0.0.1:{target_port}{health_check}");
584
585 let replanned = replan(service_name).await?;
591 let env_filename = std::ffi::OsStr::new(".env");
592 let metadata_file = metadata_path(service_name)?;
593 let live_slot = format!("colors/{live}");
599 let touches_live = |p: &std::path::Path| p.to_string_lossy().contains(&live_slot);
600 let mut steps: Vec<Step> = Vec::new();
601 for step in replanned.result.steps {
602 match step {
603 Step::StartService { .. } | Step::StopService { .. } => continue,
604 Step::WriteFile(GeneratedFile { ref path, .. })
605 if path.file_name() == Some(env_filename) || *path == metadata_file =>
606 {
607 continue;
608 }
609 Step::SyncDir { ref dst, .. } if touches_live(dst) => continue,
610 Step::Build { ref dir, .. } if touches_live(dir) => continue,
611 other => steps.push(other),
612 }
613 }
614
615 let caddy_rewrite =
618 blue_green_caddy_rewrite(service_name, def, &metadata, target, target_port)?;
619
620 steps.extend(deploy::color_swap_steps(deploy::ColorSwap {
623 service_name: service_name.to_string(),
624 live,
625 prepare: None,
626 health_url,
627 health_timeout_secs: def.service.health_timeout_secs(),
628 caddy_rewrite,
629 }));
630
631 let mut new_metadata = metadata.clone();
633 new_metadata.active_color = Some(target);
634 steps.push(Step::WriteFile(GeneratedFile {
635 path: metadata_file,
636 content: toml::to_string_pretty(&new_metadata)?,
637 }));
638
639 Ok(Some(UpgradeResult {
640 service: service_name.to_string(),
641 diff: diff_service(service_name).await?,
642 steps,
643 backup_dir: None,
644 planned_files: replanned.planned,
645 force_apply: true,
648 }))
649}
650
651fn blue_green_caddy_rewrite(
655 service_name: &str,
656 def: &crate::registry::service_def::ServiceDef,
657 metadata: &Metadata,
658 target: Color,
659 target_port: u16,
660) -> Result<Option<Step>> {
661 let Some(url) = metadata.url.as_deref() else {
662 return Ok(None);
663 };
664 let caddyfile_path = caddy::caddyfile_path()?;
665 let Ok(existing) = std::fs::read_to_string(&caddyfile_path) else {
666 return Ok(None);
667 };
668 let parsed = url::Url::parse(url)
669 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
670 let domain = parsed
671 .host_str()
672 .ok_or_else(|| Error::Template(format!("service URL '{url}' has no host")))?;
673 let paths = crate::config::ConfigPaths::resolve()?;
674 let config = crate::config::load_or_default(&paths.config_file)?;
675 let (target_host, port) = match metadata.runtime {
679 Runtime::Podman => (
680 deploy::color_unit(service_name, target),
681 def.ports.first().map(|p| p.container_port).unwrap_or(80),
682 ),
683 Runtime::Native => ("host.containers.internal".to_string(), target_port),
684 };
685 let block = caddy::render_site_block(&caddy::CaddySiteParams {
686 service_name: service_name.to_string(),
687 target_host,
688 domain: domain.to_string(),
689 container_port: port,
690 https_port: crate::caddy_https_port(&config),
691 force_internal_tls: false,
692 });
693 let updated = caddy::add_route(&existing, service_name, &block);
694 Ok(Some(Step::WriteFile(GeneratedFile {
695 path: caddyfile_path,
696 content: updated,
697 })))
698}
699
700pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
706 if let Some(plan) = blue_green_swap(service_name).await? {
710 return Ok(plan);
711 }
712
713 let diff = diff_service(service_name).await?;
714
715 if !force {
716 let drifted = diff.drifted();
717 if !drifted.is_empty() {
718 return Err(Error::HandEditedFiles {
719 service: service_name.to_string(),
720 paths: drifted.iter().map(|e| e.path.clone()).collect(),
721 });
722 }
723 }
724
725 let Replanned {
726 result, planned, ..
727 } = replan(service_name).await?;
728 let manifest_file = manifest::manifest_path(service_name)?;
729 let env_file = service_home(service_name)?.join(".env");
730
731 if !env_file.exists() {
736 return Err(Error::Template(format!(
737 "{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
738 Restore the file from a backup or reinstall the service.",
739 env_file.display()
740 )));
741 }
742
743 let backup_dir = backup_directory(service_name)?;
749 let needs_backup: BTreeSet<PathBuf> = diff
750 .entries
751 .iter()
752 .filter(|e| {
753 matches!(
754 e.kind,
755 DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
756 )
757 })
758 .map(|e| e.path.clone())
759 .collect();
760 let manifest_will_be_backed_up = manifest_file.exists();
761 let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
762
763 let mut steps: Vec<Step> = Vec::new();
772 if backup_used {
773 steps.push(Step::CreateDir(backup_dir.clone()));
774 }
775 let unchanged: BTreeSet<PathBuf> = diff
776 .entries
777 .iter()
778 .filter(|e| matches!(e.kind, DiffKind::Unchanged))
779 .map(|e| e.path.clone())
780 .collect();
781
782 let env_filename = std::ffi::OsStr::new(".env");
783 for step in result.steps {
784 match step {
785 Step::WriteFile(GeneratedFile { ref path, .. })
788 if path.file_name() == Some(env_filename) =>
789 {
790 continue;
791 }
792 Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
796 if path == &manifest_file {
800 steps.push(step);
801 }
802 continue;
803 }
804 Step::WriteFile(ref file) => {
805 let should_backup = (needs_backup.contains(&file.path)
812 || file.path == manifest_file)
813 && file.path.exists();
814 if should_backup {
815 let rel = backup_relpath(&file.path);
816 let dst = backup_dir.join(rel);
817 if let Some(parent) = dst.parent() {
818 steps.push(Step::CreateDir(parent.to_path_buf()));
819 }
820 steps.push(Step::CopyFile {
821 src: file.path.clone(),
822 dst,
823 });
824 }
825 steps.push(step);
826 }
827 Step::StartService { .. } => continue,
831 other => steps.push(other),
832 }
833 }
834
835 for entry in &diff.entries {
837 if !matches!(entry.kind, DiffKind::Removed) {
838 continue;
839 }
840 if entry.path.exists() {
841 let rel = backup_relpath(&entry.path);
842 let dst = backup_dir.join(rel);
843 if let Some(parent) = dst.parent() {
844 steps.push(Step::CreateDir(parent.to_path_buf()));
845 }
846 steps.push(Step::CopyFile {
847 src: entry.path.clone(),
848 dst,
849 });
850 }
851 steps.push(Step::RemoveFile(entry.path.clone()));
852 }
853
854 if !diff.env_additions.is_empty() {
862 let mut content = match std::fs::read_to_string(&env_file) {
863 Ok(c) => c,
864 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
867 Err(source) => {
868 return Err(Error::FileRead {
869 path: env_file.clone(),
870 source,
871 });
872 }
873 };
874 if !content.is_empty() && !content.ends_with('\n') {
875 content.push('\n');
876 }
877 for add in &diff.env_additions {
878 content.push_str(&format!("{}={}\n", add.key, add.value));
879 }
880 steps.push(Step::WriteFile(GeneratedFile {
881 path: env_file,
882 content,
883 }));
884 }
885
886 steps.push(Step::RestartService {
890 unit: service_name.to_string(),
891 });
892
893 let force_apply = matches!(
898 crate::metadata::load_metadata(service_name),
899 Ok(Some(m)) if m.runtime == crate::registry::service_def::Runtime::Native
900 );
901
902 Ok(UpgradeResult {
903 service: service_name.to_string(),
904 diff,
905 steps,
906 backup_dir: if backup_used { Some(backup_dir) } else { None },
907 planned_files: planned,
912 force_apply,
913 })
914}
915
916pub struct UpgradeResult {
917 pub service: String,
918 pub diff: DiffResult,
919 pub steps: Vec<Step>,
920 pub backup_dir: Option<PathBuf>,
922 pub planned_files: BTreeMap<PathBuf, String>,
923 pub force_apply: bool,
927}
928
929#[derive(Debug, Clone)]
931pub struct BackupSnapshot {
932 pub path: PathBuf,
934 pub timestamp: String,
936}
937
938pub struct RevertResult {
939 pub service: String,
940 pub snapshot: BackupSnapshot,
941 pub steps: Vec<Step>,
942 pub files_to_restore: Vec<PathBuf>,
944 pub files_to_delete: Vec<PathBuf>,
948}
949
950pub const DEFAULT_BACKUP_KEEP: usize = 5;
959
960pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
967 let backups_root = state_dir()?.join("backups");
968 prune_backups_in(&backups_root, service_name, keep)
969}
970
971fn prune_backups_in(
975 backups_root: &std::path::Path,
976 service_name: &str,
977 keep: usize,
978) -> Result<Vec<PathBuf>> {
979 let snapshots = list_backups_in(backups_root, service_name)?;
980 if snapshots.len() <= keep {
981 return Ok(Vec::new());
982 }
983 let mut removed: Vec<PathBuf> = Vec::new();
984 for snap in snapshots.into_iter().skip(keep) {
985 if let Err(e) = std::fs::remove_dir_all(&snap.path) {
986 eprintln!(
987 "warning: failed to prune backup {}: {e}",
988 snap.path.display()
989 );
990 continue;
991 }
992 removed.push(snap.path.clone());
993 if let Some(parent) = snap.path.parent()
994 && let Ok(mut entries) = std::fs::read_dir(parent)
995 && entries.next().is_none()
996 {
997 let _ = std::fs::remove_dir(parent);
998 }
999 }
1000 Ok(removed)
1001}
1002
1003pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
1004 let backups_root = state_dir()?.join("backups");
1005 list_backups_in(&backups_root, service_name)
1006}
1007
1008fn list_backups_in(
1009 backups_root: &std::path::Path,
1010 service_name: &str,
1011) -> Result<Vec<BackupSnapshot>> {
1012 if !backups_root.is_dir() {
1013 return Ok(Vec::new());
1014 }
1015 let mut snapshots: Vec<BackupSnapshot> = Vec::new();
1016 let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
1017 path: backups_root.to_path_buf(),
1018 source,
1019 })?;
1020 for entry in entries.flatten() {
1021 let stamp_dir = entry.path();
1022 if !stamp_dir.is_dir() {
1023 continue;
1024 }
1025 let svc_dir = stamp_dir.join(service_name);
1026 if !svc_dir.is_dir() {
1027 continue;
1028 }
1029 let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
1030 continue;
1031 };
1032 snapshots.push(BackupSnapshot {
1033 path: svc_dir,
1034 timestamp: stamp.to_string(),
1035 });
1036 }
1037 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
1039 Ok(snapshots)
1040}
1041
1042pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
1049 if !is_service_installed(service_name) {
1050 return Err(Error::ServiceNotInstalled(service_name.to_string()));
1051 }
1052 let snapshot = pick_snapshot(service_name, at)?;
1053
1054 let mut files_to_restore: Vec<PathBuf> = Vec::new();
1059 walk_backup_files(&snapshot.path, &mut files_to_restore)?;
1060
1061 let backup_manifest_file =
1066 absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
1067 let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
1068 let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
1069
1070 let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
1071 .iter()
1072 .map(|e| e.path.clone())
1073 .collect();
1074 let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
1075 Vec::new()
1077 } else {
1078 current_manifest_entries
1079 .iter()
1080 .map(|e| e.path.clone())
1081 .filter(|p| !backup_manifest_set.contains(p))
1082 .collect()
1083 };
1084 files_to_delete.sort();
1085
1086 let mut steps: Vec<Step> = Vec::new();
1088 for backup_path in &files_to_restore {
1091 let original = backup_to_absolute_path(&snapshot.path, backup_path);
1092 steps.push(Step::CopyFile {
1093 src: backup_path.clone(),
1094 dst: original,
1095 });
1096 }
1097 let qd = crate::quadlet_dir()?;
1101 for path in &files_to_delete {
1102 if path.exists() {
1103 steps.push(Step::RemoveFile(path.clone()));
1104 }
1105 if let Some(name) = path.file_name() {
1106 let symlink = qd.join(name);
1107 if std::fs::symlink_metadata(&symlink).is_ok() {
1108 steps.push(Step::RemoveFile(symlink));
1109 }
1110 }
1111 }
1112 steps.push(Step::DaemonReload);
1113 steps.push(Step::RestartService {
1114 unit: service_name.to_string(),
1115 });
1116
1117 let files_to_restore_orig: Vec<PathBuf> = files_to_restore
1118 .iter()
1119 .map(|p| backup_to_absolute_path(&snapshot.path, p))
1120 .collect();
1121 Ok(RevertResult {
1122 service: service_name.to_string(),
1123 snapshot,
1124 steps,
1125 files_to_restore: files_to_restore_orig,
1126 files_to_delete,
1127 })
1128}
1129
1130fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
1133 let snapshots = list_backups(service_name)?;
1134 if snapshots.is_empty() {
1135 return Err(Error::NoBackup(service_name.to_string()));
1136 }
1137 match at {
1138 None => Ok(snapshots
1139 .into_iter()
1140 .next()
1141 .expect("non-empty checked above")),
1142 Some(stamp) => snapshots
1143 .into_iter()
1144 .find(|s| s.timestamp == stamp)
1145 .ok_or_else(|| Error::BackupNotFound {
1146 service: service_name.to_string(),
1147 stamp: stamp.to_string(),
1148 }),
1149 }
1150}
1151
1152fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
1156 let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
1157 path: root.to_path_buf(),
1158 source,
1159 })?;
1160 for entry in entries.flatten() {
1161 let path = entry.path();
1162 let meta = match entry.metadata() {
1163 Ok(m) => m,
1164 Err(_) => continue,
1165 };
1166 if meta.is_dir() {
1167 walk_backup_files(&path, out)?;
1168 } else if meta.is_file() {
1169 out.push(path);
1170 }
1171 }
1172 Ok(())
1173}
1174
1175fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
1178 let rel = backup.strip_prefix(root).unwrap_or(backup);
1179 PathBuf::from("/").join(rel)
1180}
1181
1182fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
1184 let rel = abs.to_string_lossy();
1185 let stripped = rel.trim_start_matches('/');
1186 root.join(stripped)
1187}
1188
1189fn read_manifest_at(
1192 path: &std::path::Path,
1193) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
1194 if !path.exists() {
1195 return Ok((Vec::new(), Vec::new()));
1196 }
1197 let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
1198 path: path.to_path_buf(),
1199 source,
1200 })?;
1201 manifest::parse(&content)
1202}
1203
1204fn backup_directory(service_name: &str) -> Result<PathBuf> {
1208 let state = state_dir()?;
1209 let now = std::time::SystemTime::now()
1210 .duration_since(std::time::UNIX_EPOCH)
1211 .map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
1212 .as_secs();
1213 let stamp = format_timestamp(now);
1214 Ok(state.join("backups").join(stamp).join(service_name))
1215}
1216
1217fn state_dir() -> Result<PathBuf> {
1219 let base = dirs::state_dir()
1220 .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
1221 .ok_or(Error::HomeDirNotFound)?;
1222 Ok(base.join("ryra"))
1223}
1224
1225fn format_timestamp(secs: u64) -> String {
1228 const SECS_PER_DAY: u64 = 86_400;
1230 let days = secs / SECS_PER_DAY;
1231 let time_of_day = secs % SECS_PER_DAY;
1232 let h = time_of_day / 3600;
1233 let m = (time_of_day % 3600) / 60;
1234 let s = time_of_day % 60;
1235 let (y, mo, d) = ymd_from_days(days);
1236 format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
1237}
1238
1239fn ymd_from_days(days: u64) -> (i64, u32, u32) {
1243 let z = days as i64 + 719_468;
1244 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1245 let doe = (z - era * 146_097) as u64;
1246 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1247 let y = yoe as i64 + era * 400;
1248 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1249 let mp = (5 * doy + 2) / 153;
1250 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1251 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
1252 let y = if m <= 2 { y + 1 } else { y };
1253 (y, m, d)
1254}
1255
1256fn backup_relpath(path: &std::path::Path) -> PathBuf {
1260 PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::*;
1266
1267 #[test]
1268 fn timestamp_round_numbers() {
1269 let s = format_timestamp(0);
1274 assert_eq!(s, "1970-01-01T00-00-00Z");
1275 let s = format_timestamp(86_400);
1276 assert_eq!(s, "1970-01-02T00-00-00Z");
1277 let s = format_timestamp(31_536_000); assert_eq!(s, "1971-01-01T00-00-00Z");
1279 }
1280
1281 #[test]
1282 fn backup_relpath_strips_leading_slash() {
1283 let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
1284 assert_eq!(p, PathBuf::from("home/user/foo/bar"));
1285 }
1286
1287 fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
1292 let tmp = std::env::temp_dir().join(format!(
1293 "ryra-prune-test-{}-{}",
1294 std::process::id(),
1295 std::time::SystemTime::now()
1296 .duration_since(std::time::UNIX_EPOCH)
1297 .unwrap()
1298 .as_nanos()
1299 ));
1300 let backups_root = tmp.join("backups");
1301 for s in stamps {
1302 std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
1303 }
1304 let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
1305 let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
1306 .unwrap()
1307 .filter_map(|e| e.ok())
1308 .filter_map(|e| e.file_name().into_string().ok())
1309 .collect();
1310 kept.sort();
1311 kept.reverse();
1312 let _ = std::fs::remove_dir_all(&tmp);
1313 (kept, removed)
1314 }
1315
1316 #[test]
1317 fn prune_keeps_newest_n() {
1318 let (kept, removed) = setup_and_prune(
1320 &[
1321 "2026-01-01T00-00-00Z",
1322 "2026-02-01T00-00-00Z",
1323 "2026-03-01T00-00-00Z",
1324 "2026-04-01T00-00-00Z",
1325 "2026-05-01T00-00-00Z",
1326 ],
1327 3,
1328 );
1329 assert_eq!(kept.len(), 3);
1330 assert_eq!(kept[0], "2026-05-01T00-00-00Z");
1331 assert_eq!(kept[2], "2026-03-01T00-00-00Z");
1332 assert_eq!(removed.len(), 2);
1333 }
1334
1335 #[test]
1336 fn prune_no_op_when_under_keep() {
1337 let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
1338 assert_eq!(kept.len(), 2);
1339 assert!(removed.is_empty());
1340 }
1341
1342 fn unique_tmp(prefix: &str) -> PathBuf {
1343 std::env::temp_dir().join(format!(
1344 "{prefix}-{}-{}",
1345 std::process::id(),
1346 std::time::SystemTime::now()
1347 .duration_since(std::time::UNIX_EPOCH)
1348 .unwrap()
1349 .as_nanos()
1350 ))
1351 }
1352
1353 #[test]
1354 fn source_staleness_ignores_build_and_dotdirs() {
1355 use std::time::Duration;
1356
1357 let tmp = unique_tmp("ryra-stale");
1358 std::fs::create_dir_all(tmp.join("src")).unwrap();
1359 std::fs::create_dir_all(tmp.join("target")).unwrap();
1360 std::fs::create_dir_all(tmp.join(".git")).unwrap();
1361 std::fs::write(tmp.join("src/main.rs"), "fn main(){}").unwrap();
1362 std::fs::write(tmp.join("target/app"), "bin").unwrap();
1363 std::fs::write(tmp.join(".git/HEAD"), "ref").unwrap();
1364
1365 assert!(!any_file_newer_than(
1367 &tmp,
1368 SystemTime::now() + Duration::from_secs(3600)
1369 ));
1370 assert!(any_file_newer_than(
1372 &tmp,
1373 SystemTime::now() - Duration::from_secs(3600)
1374 ));
1375
1376 let ignored_only = unique_tmp("ryra-stale-ign");
1378 std::fs::create_dir_all(ignored_only.join("node_modules")).unwrap();
1379 std::fs::write(ignored_only.join("node_modules/x.js"), "x").unwrap();
1380 assert!(!any_file_newer_than(
1381 &ignored_only,
1382 SystemTime::now() - Duration::from_secs(3600)
1383 ));
1384
1385 let _ = std::fs::remove_dir_all(&tmp);
1386 let _ = std::fs::remove_dir_all(&ignored_only);
1387 }
1388
1389 #[test]
1390 fn should_skip_path_excludes_env_and_manifest() {
1391 let lock = PathBuf::from("/svc/service.manifest");
1392 assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
1393 assert!(should_skip_path(&lock, &lock));
1394 assert!(!should_skip_path(
1395 &PathBuf::from("/svc/configs/x.sh"),
1396 &lock
1397 ));
1398 }
1399}