Files
dotfiles/rust/snapshot_manager/tests/manager.rs
T
Richie 1ccba91d2a
build_systems / prebuild-common-x86-64-v3 (pull_request) Successful in 20s
build_systems / build-portal-1 (pull_request) Successful in 16s
test ebook search / test-ebook-search (pull_request) Successful in 1m8s
build_systems / build-bob (pull_request) Successful in 1m42s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m41s
build_systems / build-jeeves (pull_request) Successful in 1m44s
build_systems / build-brain (pull_request) Successful in 1m57s
treefmt / nix fmt (pull_request) Successful in 6s
treefmt / nix fmt (push) Successful in 3s
build_systems / prebuild-common-x86-64-v3 (push) Successful in 8s
build_systems / build-portal-1 (push) Successful in 21s
build_systems / build-bob (push) Successful in 30s
build_systems / build-brain (push) Successful in 38s
build_systems / build-rhapsody-in-green (push) Successful in 39s
test ebook search / test-ebook-search (push) Successful in 1m14s
build_systems / build-jeeves (push) Successful in 1m8s
feat(snapshot-manager): migrate snapshot management to Rust
2026-10-11 20:11:21 -04:00

291 lines
9.0 KiB
Rust

#![cfg(test)]
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
process::{Command, Output},
};
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
config: PathBuf,
}
impl Fixture {
fn new(script: &str) -> Self {
let dir = tempdir().unwrap();
// Only the child process sees this PATH. The real zfs cannot be found.
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
// The executable is a checked-in fixture; scenario files are read by
// its shell, so parallel tests never execute a file being written.
fs::write(dir.path().join("scenario"), script).unwrap();
let config = dir.path().join("config.toml");
fs::write(
&config,
"[default]\n15_min = 1\nhourly = 0\ndaily = 0\nmonthly = 0\n",
)
.unwrap();
Self { dir, config }
}
fn calls(&self) -> Vec<String> {
fs::read_to_string(self.dir.path().join("calls"))
.unwrap_or_default()
.lines()
.map(String::from)
.collect()
}
fn normalized_calls(&self) -> Vec<String> {
self.calls()
.into_iter()
.map(|call| {
if call.starts_with("snapshot ") {
let (dataset, name) = call.split_once('@').unwrap();
assert!(name.starts_with("auto_"), "{call}");
format!("{dataset}@<timestamp>")
} else {
call
}
})
.collect()
}
fn run(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_snapshot-manager"))
.current_dir(self.dir.path())
.env("PATH", self.dir.path())
.env("SNAPSHOT_TEST_SCRIPT", self.dir.path().join("scenario"))
.env_remove("SIGNAL_ALERTS")
.env_remove("SIGNAL_ALERT_FROM_PHONE")
.env_remove("SIGNAL_ALERT_TO_PHONE")
.env_remove("RUST_LOG")
.arg(&self.config)
.output()
.unwrap()
}
}
fn stderr(output: &Output) -> &str {
std::str::from_utf8(&output.stderr).unwrap()
}
#[test]
fn creates_then_prunes_only_this_datasets_managed_snapshots() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name')
printf '%s\n' pool pool/home
printf '%s\n' 'warning on stderr' >&2 ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020145 pool/home@auto_200002020115 pool/home@manual pool/home@manual_auto_200001010015 pool/home/child@auto_200001010015 pool/other@auto_200001010015 ;;
'destroy pool/home@auto_200002020115') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/home",
"destroy pool/home@auto_200002020115",
]
);
}
#[test]
fn failed_or_duplicate_creation_skips_pruning_but_continues() {
for error in ["snapshot already exists", "permission denied"] {
let fixture = Fixture::new(&format!(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home pool/other ;;
'snapshot pool/home@auto_'*) printf '%s\n' '{error}' >&2; exit 1 ;;
'snapshot pool/other@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/other') ;;
*) exit 99 ;;
esac
"#
));
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("pool/home failed to create snapshot auto_"));
assert!(stderr(&output).contains(error));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"snapshot pool/other@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/other",
]
);
let calls = fixture.calls();
assert_eq!(
calls[1].split_once('@').unwrap().1,
calls[2].split_once('@').unwrap().1
);
}
}
#[test]
fn dependent_clone_failure_is_reported_and_remaining_deletions_continue() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 ;;
'destroy pool/home@auto_200002020115')
printf '%s\n' "cannot destroy 'pool/home@auto_200002020115': snapshot has dependent clones" >&2; exit 1 ;;
'destroy pool/home@auto_200002020130') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stderr(&output).contains(
"pool/home@auto_200002020115 failed to delete: snapshot has dependent clones"
)
);
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020130"
);
}
#[test]
fn unexpected_destroy_failure_aborts_and_reports_the_error() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
if [ "$4" = filesystem ]; then
printf '%s\n' pool/home
else
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145
fi ;;
snapshot) ;;
destroy) printf '%s\n' 'permission denied' >&2; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("permission denied"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020115"
);
}
#[test]
fn failed_list_never_uses_partial_output() {
for stream in ["", " >&2"] {
let fixture = Fixture::new(&format!(
"printf '%s\\n' pool/home\nprintf '%s\\n' 'cannot list datasets'{stream}\nexit 1"
));
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("cannot list datasets"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls(), ["list -Hp -t filesystem -o name"]);
}
}
#[test]
fn failed_snapshot_listing_aborts_without_destroying_anything() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
*) printf '%s\n' pool/home@auto_200002020115; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls().len(), 3);
}
#[test]
fn invalid_config_stops_before_any_zfs_commands() {
let fixture = Fixture::new("exit 99");
fs::write(
&fixture.config,
"[default]\n15_min = -1\nhourly = 1\ndaily = 0\nmonthly = 0",
)
.unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Invalid configuration"));
assert!(fixture.calls().is_empty());
}
#[test]
fn missing_zfs_executable_fails_and_reports_the_error() {
let fixture = Fixture::new("exit 99");
fs::remove_file(fixture.dir.path().join("zfs")).unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Failed to execute zfs"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert!(fixture.calls().is_empty());
}
#[test]
fn dataset_names_are_passed_as_one_argument_without_a_shell() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
[ "$*" = 'list -Hp -t filesystem -o name' ] || exit 98
printf '%s\n' 'pool/two words;touch injected' ;;
snapshot)
[ "$#" -eq 2 ] || exit 97
case "$2" in
'pool/two words;touch injected@auto_'*) printf '%s\n' 'argument preserved' >&2; exit 1 ;;
*) exit 96 ;;
esac ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("argument preserved"));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/two words;touch injected@<timestamp>"
]
);
assert!(fs::read_dir(fixture.dir.path()).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("injected")
}));
}