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
291 lines
9.0 KiB
Rust
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")
|
|
}));
|
|
}
|