diff --git a/crates/system-manager-engine/src/activate/etc_files.rs b/crates/system-manager-engine/src/activate/etc_files.rs index ece05a8..2e3259d 100644 --- a/crates/system-manager-engine/src/activate/etc_files.rs +++ b/crates/system-manager-engine/src/activate/etc_files.rs @@ -30,6 +30,8 @@ fn get_uid_gid_regex() -> &'static regex::Regex { UID_GID_REGEX.get_or_init(|| regex::Regex::new(r"^\+[0-9]+$").expect("could not compile regex")) } +const BACKUP_SUFFIX: &str = ".system-manager-backup"; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct EtcFile { @@ -40,6 +42,8 @@ struct EtcFile { group: String, user: String, mode: String, + #[serde(default)] + replace_existing: bool, } type EtcFiles = HashMap; @@ -118,29 +122,68 @@ pub fn deactivate(old_state: FileTree) -> EtcActivationResult { Ok(final_state) } +fn backup_path_for(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push(BACKUP_SUFFIX); + PathBuf::from(s) +} + +fn backup_existing_file(path: &Path) -> anyhow::Result<()> { + let backup_path = backup_path_for(path); + log::info!( + "Backing up existing file {} to {}", + path.display(), + backup_path.display() + ); + fs::rename(path, &backup_path)?; + Ok(()) +} + +fn restore_backup(path: &Path) -> anyhow::Result<()> { + let backup_path = backup_path_for(path); + if backup_path.exists() || backup_path.is_symlink() { + log::info!( + "Restoring backup {} to {}", + backup_path.display(), + path.display() + ); + fs::rename(&backup_path, path)?; + } else { + log::warn!( + "Backup file {} not found, cannot restore", + backup_path.display() + ); + } + Ok(()) +} + fn try_delete_path(path: &Path, status: &FileStatus) -> bool { fn do_try_delete(path: &Path, status: &FileStatus) -> anyhow::Result<()> { // exists() returns false for broken symlinks if path.exists() || path.is_symlink() { if path.is_symlink() { - remove_link(path) + remove_link(path)?; } else if path.is_file() { - remove_file(path) + remove_file(path)?; } else if path.is_dir() { if path.read_dir()?.next().is_none() { - remove_dir(path) + remove_dir(path)?; } else { - if let FileStatus::Managed = status { + if matches!(status, FileStatus::Managed | FileStatus::ManagedWithBackup) { log::warn!("Managed directory not empty, ignoring: {}", path.display()); } - Ok(()) + return Ok(()); } } else { anyhow::bail!("Unsupported file type! {}", path.display()) } - } else { - Ok(()) } + + if *status == FileStatus::ManagedWithBackup { + restore_backup(path)?; + } + + Ok(()) } log::debug!("Deactivating: {}", path.display()); @@ -198,6 +241,7 @@ fn create_etc_link

( etc_dir: &Path, state: FileTree, old_state: &FileTree, + replace_existing: bool, ) -> EtcActivationResult where P: AsRef, @@ -209,6 +253,7 @@ where state: FileTree, old_state: &FileTree, upwards_path: &Path, + replace_existing: bool, ) -> EtcActivationResult { let link_path = etc_dir.join(link_target); // Create the dir if it doesn't exist yet @@ -232,6 +277,7 @@ where state, old_state, &upwards_path.join(".."), + replace_existing, ); match new_state { Ok(new_state) => new_state, @@ -260,12 +306,42 @@ where .is_some() } + /// Check whether link_path is inside a systemd .wants or .requires directory. + fn is_inside_systemd_dependency_dir(link_path: &Path) -> bool { + link_path + .parent() + .map(|parent| { + parent + .extension() + .filter(|ext| ["wants", "requires"].iter().any(|other| other == ext)) + .is_some() + && parent + .parent() + .map(|pp| pp.ends_with("systemd/system")) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + fn backup_and_link( + target: &Path, + link_path: &Path, + dir_state: FileTree, + ) -> EtcActivationResult { + backup_existing_file(link_path) + .map_err(|e| ActivationError::with_partial_result(dir_state.clone(), e))?; + create_link(target, link_path) + .map_err(|e| ActivationError::with_partial_result(dir_state.clone(), e))?; + Ok(dir_state.register_backed_up_entry(link_path)) + } + fn go( link_target: &Path, etc_dir: &Path, state: FileTree, old_state: &FileTree, upwards_path: &Path, + replace_existing: bool, ) -> EtcActivationResult { let link_path = etc_dir.join(link_target); let dir_state = create_dir_recursively(link_path.parent().unwrap(), state)?; @@ -278,6 +354,9 @@ where || is_systemd_dependency_dir(&absolute_target) { if absolute_target.is_dir() { + // Auto-replace inside .wants/.requires directories + let effective_replace = + replace_existing || is_systemd_dependency_dir(&absolute_target); link_dir_contents( link_target, &absolute_target, @@ -285,7 +364,10 @@ where dir_state, old_state, upwards_path, + effective_replace, ) + } else if replace_existing || is_inside_systemd_dependency_dir(&link_path) { + backup_and_link(&target, &link_path, dir_state) } else { Err(ActivationError::with_partial_result( dir_state, @@ -300,14 +382,20 @@ where { log::debug!("Link {} up to date.", link_path.display()); Ok(dir_state.register_managed_entry(&link_path)) - } else if link_path.exists() && !old_state.is_managed(&link_path) { - Err(ActivationError::with_partial_result( - dir_state, - anyhow::anyhow!("Unmanaged path already exists in filesystem, please remove it and run system-manager again: {}", - link_path.display()), - )) + } else if (link_path.exists() || link_path.is_symlink()) + && !old_state.is_managed(&link_path) + { + if replace_existing || is_inside_systemd_dependency_dir(&link_path) { + backup_and_link(&target, &link_path, dir_state) + } else { + Err(ActivationError::with_partial_result( + dir_state, + anyhow::anyhow!("Unmanaged path already exists in filesystem, please remove it and run system-manager again: {}", + link_path.display()), + )) + } } else { - let result = if link_path.exists() { + let result = if link_path.exists() || link_path.is_symlink() { fs::remove_file(&link_path) .map_err(|e| ActivationError::with_partial_result(dir_state.clone(), e)) } else { @@ -330,6 +418,7 @@ where state, old_state, Path::new("."), + replace_existing, ) } @@ -341,7 +430,13 @@ fn create_etc_entry( ) -> EtcActivationResult { if entry.mode == "symlink" { if let Some(path::Component::Normal(link_target)) = entry.target.components().next() { - create_etc_link(&link_target, etc_dir, state, old_state) + create_etc_link( + &link_target, + etc_dir, + state, + old_state, + entry.replace_existing, + ) } else { Err(ActivationError::with_partial_result( state, @@ -357,7 +452,14 @@ fn create_etc_entry( entry, old_state, ) { - Ok(_) => Ok(new_state.register_managed_entry(&target_path)), + Ok(backed_up) => { + let register = if backed_up { + FileTree::register_backed_up_entry + } else { + FileTree::register_managed_entry + }; + Ok(register(new_state, &target_path)) + } Err(e) => Err(ActivationError::with_partial_result(new_state, e)), } } @@ -456,25 +558,33 @@ fn find_gid(entry: &EtcFile) -> anyhow::Result { } } +/// Copy a file from source to target. Returns `Ok(true)` if a pre-existing +/// file was backed up, `Ok(false)` if no backup was needed. fn copy_file( source: &Path, target: &Path, entry: &EtcFile, old_state: &FileTree, -) -> anyhow::Result<()> { +) -> anyhow::Result { let exists = target.try_exists()?; - if !exists || old_state.is_managed(target) { - log::debug!( - "Copying file {} to {}...", - source.display(), - target.display() - ); - fs::copy(source, target)?; - let mode_int = u32::from_str_radix(&entry.mode, 8)?; - fs::set_permissions(target, Permissions::from_mode(mode_int))?; - unixfs::chown(target, Some(find_uid(entry)?), Some(find_gid(entry)?))?; - Ok(()) + let backed_up = if exists && !old_state.is_managed(target) { + if entry.replace_existing { + backup_existing_file(target)?; + true + } else { + anyhow::bail!("File {} already exists, ignoring.", target.display()); + } } else { - anyhow::bail!("File {} already exists, ignoring.", target.display()); - } + false + }; + log::debug!( + "Copying file {} to {}...", + source.display(), + target.display() + ); + fs::copy(source, target)?; + let mode_int = u32::from_str_radix(&entry.mode, 8)?; + fs::set_permissions(target, Permissions::from_mode(mode_int))?; + unixfs::chown(target, Some(find_uid(entry)?), Some(find_gid(entry)?))?; + Ok(backed_up) } diff --git a/crates/system-manager-engine/src/activate/etc_files/etc_tree.rs b/crates/system-manager-engine/src/activate/etc_files/etc_tree.rs index 661ee52..6728246 100644 --- a/crates/system-manager-engine/src/activate/etc_files/etc_tree.rs +++ b/crates/system-manager-engine/src/activate/etc_files/etc_tree.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; #[serde(rename_all = "camelCase")] pub enum FileStatus { Managed, + ManagedWithBackup, Unmanaged, } @@ -18,6 +19,7 @@ impl FileStatus { match (self, other) { (Unmanaged, Unmanaged) => Unmanaged, + (ManagedWithBackup, _) | (_, ManagedWithBackup) => ManagedWithBackup, _ => Managed, } } @@ -97,13 +99,29 @@ impl FileTree { } pub fn is_managed(&self, path: &Path) -> bool { - *self.get_status(path) == FileStatus::Managed + matches!( + self.get_status(path), + FileStatus::Managed | FileStatus::ManagedWithBackup + ) } // TODO is recursion OK here? // Should we convert to CPS and use a crate like tramp to TCO this? pub fn register_managed_entry(self, path: &Path) -> Self { - fn go<'a, C>(mut tree: FileTree, mut components: Peekable, path: PathBuf) -> FileTree + self.register_entry(path, FileStatus::Managed) + } + + pub fn register_backed_up_entry(self, path: &Path) -> Self { + self.register_entry(path, FileStatus::ManagedWithBackup) + } + + fn register_entry(self, path: &Path, leaf_status: FileStatus) -> Self { + fn go<'a, C>( + mut tree: FileTree, + mut components: Peekable, + path: PathBuf, + leaf_status: &FileStatus, + ) -> FileTree where C: Iterator>, { @@ -117,25 +135,30 @@ impl FileTree { maybe_subtree.unwrap_or_else(|| { FileTree::with_status( new_path.clone(), - // We only label as managed the final path entry, - // to label intermediate nodes as managed, we should - // call this function for every one of them separately. - components.peek().map_or(FileStatus::Managed, |_| { + // We only label with the leaf status the final path + // entry, to label intermediate nodes as managed, we + // should call this function for every one of them + // separately. + components.peek().map_or(leaf_status.clone(), |_| { FileStatus::Unmanaged }), ) }), components, new_path, + leaf_status, )) }, name.to_string_lossy().to_string(), ); tree } - path::Component::RootDir => { - go(tree, components, path.join(path::MAIN_SEPARATOR_STR)) - } + path::Component::RootDir => go( + tree, + components, + path.join(path::MAIN_SEPARATOR_STR), + leaf_status, + ), _ => panic!( "Unsupported path provided! At path component: {:?}", component @@ -146,7 +169,12 @@ impl FileTree { } } - go(self, path.components().peekable(), PathBuf::new()) + go( + self, + path.components().peekable(), + PathBuf::new(), + &leaf_status, + ) } pub fn deactivate(self, delete_action: &F) -> Option @@ -166,7 +194,10 @@ impl FileTree { // are not responsible for cleaning them up (we don't run the delete_action // closure on their paths). if new_tree.nested.is_empty() { - if let FileStatus::Managed = new_tree.status { + if matches!( + new_tree.status, + FileStatus::Managed | FileStatus::ManagedWithBackup + ) { if delete_action(&new_tree.path, &new_tree.status) { None } else { @@ -223,7 +254,13 @@ impl FileTree { // If our invariants are properly maintained, then we should never end up // here with dangling unmanaged nodes. - debug_assert!(!merged.nested.is_empty() || merged.status == FileStatus::Managed); + debug_assert!( + !merged.nested.is_empty() + || matches!( + merged.status, + FileStatus::Managed | FileStatus::ManagedWithBackup + ) + ); Some(merged) } @@ -403,6 +440,108 @@ mod tests { ); } + #[test] + fn managed_with_backup_is_managed() { + let tree = FileTree::root_node() + .register_backed_up_entry(&PathBuf::from("/").join("foo").join("bar")); + + assert!(tree.is_managed(&PathBuf::from("/").join("foo").join("bar"))); + assert!(!tree.is_managed(&PathBuf::from("/").join("foo"))); + } + + #[test] + fn register_backed_up_entry_sets_status() { + let tree = FileTree::root_node() + .register_backed_up_entry(&PathBuf::from("/").join("etc").join("nix.conf")); + + assert_eq!( + *tree.get_status(&PathBuf::from("/").join("etc").join("nix.conf")), + FileStatus::ManagedWithBackup, + ); + assert_eq!( + *tree.get_status(&PathBuf::from("/").join("etc")), + FileStatus::Unmanaged, + ); + } + + #[test] + fn merge_preserves_managed_with_backup() { + assert_eq!( + FileStatus::ManagedWithBackup.merge(&FileStatus::Unmanaged), + FileStatus::ManagedWithBackup, + ); + assert_eq!( + FileStatus::ManagedWithBackup.merge(&FileStatus::Managed), + FileStatus::ManagedWithBackup, + ); + assert_eq!( + FileStatus::Managed.merge(&FileStatus::ManagedWithBackup), + FileStatus::ManagedWithBackup, + ); + assert_eq!( + FileStatus::Unmanaged.merge(&FileStatus::ManagedWithBackup), + FileStatus::ManagedWithBackup, + ); + assert_eq!( + FileStatus::ManagedWithBackup.merge(&FileStatus::ManagedWithBackup), + FileStatus::ManagedWithBackup, + ); + } + + #[test] + fn deactivate_passes_backup_status_to_action() { + let tree = FileTree::root_node() + .register_backed_up_entry(&PathBuf::from("/").join("etc").join("nix.conf")) + .register_managed_entry(&PathBuf::from("/").join("etc").join("other")); + + let statuses = std::cell::RefCell::new(Vec::<(PathBuf, FileStatus)>::new()); + tree.deactivate(&|path: &Path, status: &FileStatus| { + statuses + .borrow_mut() + .push((path.to_owned(), status.clone())); + true + }); + + let statuses = statuses.into_inner(); + let backup_entries: Vec<_> = statuses + .iter() + .filter(|(_, s)| *s == FileStatus::ManagedWithBackup) + .collect(); + assert_eq!(backup_entries.len(), 1); + assert_eq!( + backup_entries[0].0, + PathBuf::from("/").join("etc").join("nix.conf") + ); + + let managed_entries: Vec<_> = statuses + .iter() + .filter(|(_, s)| *s == FileStatus::Managed) + .collect(); + assert_eq!(managed_entries.len(), 1); + assert_eq!( + managed_entries[0].0, + PathBuf::from("/").join("etc").join("other") + ); + } + + #[test] + fn mixed_managed_and_backed_up() { + let tree = FileTree::root_node() + .register_managed_entry(&PathBuf::from("/").join("foo").join("bar")) + .register_backed_up_entry(&PathBuf::from("/").join("foo").join("baz")); + + assert!(tree.is_managed(&PathBuf::from("/").join("foo").join("bar"))); + assert!(tree.is_managed(&PathBuf::from("/").join("foo").join("baz"))); + assert_eq!( + *tree.get_status(&PathBuf::from("/").join("foo").join("bar")), + FileStatus::Managed, + ); + assert_eq!( + *tree.get_status(&PathBuf::from("/").join("foo").join("baz")), + FileStatus::ManagedWithBackup, + ); + } + #[test] fn update_state() { let tree1 = FileTree::root_node() diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ef20539..f6d8868 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -137,6 +137,7 @@ nav: - Use Remote Flakes: how-to/use-remote-flakes.md - Use Blueprint: how-to/use-blueprint.md - Test Configuration: how-to/test-configuration.md + - Manage Existing Files: how-to/manage-existing-files.md - Rollback Changes: how-to/rollback.md - Reference: - Overview: reference/index.md diff --git a/docs/site/how-to/manage-existing-files.md b/docs/site/how-to/manage-existing-files.md new file mode 100644 index 0000000..aa324fc --- /dev/null +++ b/docs/site/how-to/manage-existing-files.md @@ -0,0 +1,103 @@ +# Managing pre-existing files + +System-manager is designed for non-NixOS Linux distributions that already have populated `/etc` directories. +By default, when it encounters a file at a target path that it didn't create, it refuses to overwrite it and logs an error. +This page explains how to handle these conflicts. + +## The problem + +On a fresh Ubuntu or Debian system, files like `/etc/nix/nix.conf` (created by the Nix installer) or systemd timer symlinks in `.wants` directories already exist. +When system-manager tries to manage these paths, activation skips them: + +``` +Unmanaged path already exists in filesystem, please remove it and run system-manager again: /etc/nix/nix.conf +``` + +The activation continues but the conflicting entries are not applied. + +## Replacing individual etc files + +Use `replaceExisting = true` on any `environment.etc` entry to have system-manager back up the existing file before replacing it. +On deactivation, the original is restored. + +```nix +{ ... }: +{ + environment.etc."my-app/config.toml" = { + text = '' + [server] + port = 8080 + ''; + mode = "0644"; + replaceExisting = true; + }; +} +``` + +During activation, the pre-existing file is renamed to `/etc/my-app/config.toml.system-manager-backup`. +When system-manager is deactivated or the entry is removed from the configuration, the backup is restored to its original path. + +## Nix configuration + +The `nix` module is enabled by default and generates `/etc/nix/nix.conf` from `nix.settings`. +Since the Nix installer already creates this file, you need `replaceExisting`: + +```nix +{ ... }: +{ + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + trusted-users = [ "myuser" ]; + }; + + environment.etc."nix/nix.conf".replaceExisting = true; +} +``` + +This backs up the installer-created `nix.conf` and replaces it with the one generated from `nix.settings`. +On deactivation, the original is restored so `nix` keeps working. + +## Systemd timer and service conflicts + +Systemd `.wants` and `.requires` directories are handled automatically. +When system-manager declares a timer with `wantedBy` and the target `.wants` directory already contains a symlink for that unit (common on Ubuntu/Debian), the existing symlink is backed up and replaced without requiring any configuration. + +```nix +{ pkgs, ... }: +{ + systemd.timers.logrotate = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*:0/5"; + Persistent = true; + }; + }; + + systemd.services.logrotate = { + serviceConfig.Type = "oneshot"; + script = "${pkgs.logrotate}/bin/logrotate /etc/logrotate.conf"; + }; +} +``` + +If `/etc/systemd/system/timers.target.wants/logrotate.timer` already exists (Ubuntu pre-installs it), system-manager backs it up and creates its own symlink. +Other entries in the `.wants` directory that system-manager does not manage are left untouched. +On deactivation, the original symlink is restored. + +## How backups work + +Backups are stored next to the original file with a `.system-manager-backup` suffix. +The file tree state tracks which paths have backups via a `ManagedWithBackup` status, so deactivation knows to restore them rather than simply deleting the managed file. + +| Event | Action | +|-------|--------| +| Activation with `replaceExisting` | Rename existing file to `.system-manager-backup`, create managed entry | +| Activation of `.wants`/`.requires` entry | Same, automatically | +| Re-activation (same config) | No change, symlink already up to date | +| Deactivation | Remove managed entry, rename backup back to original path | + +## See also + +- [Getting Started](../tutorials/getting-started.md) for initial setup +- [Timer example](../examples/timer.md) for systemd timer configuration +- [Rollback Changes](rollback.md) for reverting to previous configurations diff --git a/nix/modules/etc.nix b/nix/modules/etc.nix index 7e96c5b..7348f65 100644 --- a/nix/modules/etc.nix +++ b/nix/modules/etc.nix @@ -120,6 +120,18 @@ Changing this option takes precedence over `gid`. ''; }; + + replaceExisting = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Whether to replace a pre-existing file at the target path. + When enabled, the existing file is backed up to + `{file}`.system-manager-backup` before being replaced. + The backup is restored when system-manager is deactivated or + when the entry is removed from the configuration. + ''; + }; }; config = { diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index 02cdabb..d6c4bc9 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -795,6 +795,121 @@ forEachUbuntuImage "example" { ''; } +// + + # Test that pre-existing files are backed up and restored when replaceExisting + # is enabled, and that systemd .wants/.requires symlinks are auto-replaced + # with backup on systems where those entries already exist. + forEachUbuntuImage "existing-files" { + modules = [ + ( + { lib, pkgs, ... }: + { + config = { + environment.etc = { + "force-copy-test" = { + text = "managed copy content\n"; + mode = "0644"; + replaceExisting = true; + }; + "force-symlink-test" = { + text = "managed symlink content\n"; + replaceExisting = true; + }; + "no-replace-test" = { + text = "should not appear\n"; + mode = "0644"; + }; + }; + + systemd.timers.existing = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + }; + systemd.services.existing = { + serviceConfig.Type = "oneshot"; + wantedBy = [ "system-manager.target" ]; + script = "true"; + }; + }; + } + ) + ]; + extraPathsToRegister = [ ]; + testScriptFunction = + { toplevel, hostPkgs, ... }: + '' + start_all() + vm.wait_for_unit("default.target") + + # Create pre-existing files that system-manager will replace + vm.succeed("echo -n 'original copy content' > /etc/force-copy-test") + vm.succeed("echo -n 'original symlink content' > /etc/force-symlink-test") + vm.succeed("echo -n 'do not touch' > /etc/no-replace-test") + + # Create pre-existing .wants symlink (simulating Ubuntu's pre-installed timers) + vm.succeed("mkdir -p /etc/systemd/system/timers.target.wants") + vm.succeed("ln -sf /lib/systemd/system/fake-existing.timer /etc/systemd/system/timers.target.wants/existing.timer") + + # Activate directly (not via snippet) because the no-replace-test entry + # will produce an expected ERROR that the snippet would reject. + vm.succeed("${toplevel}/bin/activate 2>&1 | tee /tmp/output.log") + + # Verify that the no-replace entry produced the expected error + vm.succeed("grep -F 'File /etc/no-replace-test already exists' /tmp/output.log") + no_replace = vm.succeed("cat /etc/no-replace-test").strip() + assert no_replace == "do not touch", f"Expected untouched file, got: {no_replace}" + vm.fail("test -e /etc/no-replace-test.system-manager-backup") + + managed_copy = vm.succeed("cat /etc/force-copy-test").strip() + assert "managed copy content" in managed_copy, f"Expected managed copy content, got: {managed_copy}" + backup_copy = vm.succeed("cat /etc/force-copy-test.system-manager-backup").strip() + assert backup_copy == "original copy content", f"Expected original backup, got: {backup_copy}" + + vm.succeed("test -L /etc/force-symlink-test") + managed_symlink = vm.succeed("cat /etc/force-symlink-test").strip() + assert "managed symlink content" in managed_symlink, f"Expected managed symlink content, got: {managed_symlink}" + backup_symlink = vm.succeed("cat /etc/force-symlink-test.system-manager-backup").strip() + assert backup_symlink == "original symlink content", f"Expected original symlink backup, got: {backup_symlink}" + + # Verify .wants symlink was replaced (auto-backup for systemd dependency dirs) + vm.succeed("test -L /etc/systemd/system/timers.target.wants/existing.timer") + backup_wants = vm.succeed("readlink /etc/systemd/system/timers.target.wants/existing.timer.system-manager-backup").strip() + assert "fake-existing.timer" in backup_wants, f"Expected backup of original .wants symlink, got: {backup_wants}" + + # Verify the timer unit content matches the declared config + timer_content = vm.succeed("cat /etc/systemd/system/existing.timer") + assert "OnCalendar=daily" in timer_content, f"Expected OnCalendar=daily in timer unit, got: {timer_content}" + assert "Persistent=true" in timer_content, f"Expected Persistent=true in timer unit, got: {timer_content}" + + # Deactivate and verify backups are restored + ${system-manager.lib.deactivateProfileSnippet { + node = "vm"; + profile = toplevel; + }} + + # Verify originals restored from backups + restored_copy = vm.succeed("cat /etc/force-copy-test").strip() + assert restored_copy == "original copy content", f"Expected restored original, got: {restored_copy}" + vm.fail("test -e /etc/force-copy-test.system-manager-backup") + + restored_symlink = vm.succeed("cat /etc/force-symlink-test").strip() + assert restored_symlink == "original symlink content", f"Expected restored original, got: {restored_symlink}" + vm.fail("test -e /etc/force-symlink-test.system-manager-backup") + + restored_wants = vm.succeed("readlink /etc/systemd/system/timers.target.wants/existing.timer").strip() + assert "fake-existing.timer" in restored_wants, f"Expected restored .wants symlink, got: {restored_wants}" + vm.fail("test -e /etc/systemd/system/timers.target.wants/existing.timer.system-manager-backup") + + # Verify no-replace-test was never touched + no_replace_after = vm.succeed("cat /etc/no-replace-test").strip() + assert no_replace_after == "do not touch", f"Expected untouched file after deactivation, got: {no_replace_after}" + ''; + } + // # Test sops secrets decryption using SSH host key (age.sshKeyPaths)