use super::*;
#[cfg(unix)]
const DIAL_INTERVAL: Duration = Duration::from_millis(5);
#[cfg(unix)]
const PING_OFFLINE: i32 = 5;
#[cfg(unix)]
#[test]
fn the_control_socket_accepts_throughout_a_handover() {
let dir = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(dir.path())
.arg("start")
.arg(&script)
.arg("--name")
.arg("sheep")
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&started);
let _ = poll_flock(dir.path(), |info| info["status"] == "online");
let deadline = Instant::now() + Duration::from_secs(8);
let socket = dir.path().join("run").join("shep.sock");
let inode_before = std::fs::metadata(&socket)
.expect("the control socket must exist before the handover")
.ino();
let dial_socket = socket.clone();
let dialer = std::thread::spawn(move || {
let mut refused = Vec::new();
let mut dials = 0_usize;
while Instant::now() < deadline {
dials += 1;
if let Err(err) = std::os::unix::net::UnixStream::connect(&dial_socket) {
refused.push(format!("dial {dials}: {:?}: {err}", err.kind()));
}
std::thread::sleep(DIAL_INTERVAL);
}
(refused, dials)
});
let home = dir.path().to_path_buf();
let (probing, started_probing) = std::sync::mpsc::channel();
let prober = std::thread::spawn(move || {
let mut before_reload = Vec::new();
let mut dropped = Vec::new();
let mut pings = 0_usize;
let mut announced = false;
while Instant::now() < deadline {
pings += 1;
let out = shep(&home).arg("ping").output().unwrap();
let served = out.status.success();
if !served {
assert_eq!(
out.status.code(),
Some(PING_OFFLINE),
"ping {pings} failed for a reason no handover produces: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
if !announced {
if served {
announced = true;
let _ = probing.send(());
} else {
before_reload.push(pings);
}
continue;
}
if !served {
dropped.push(pings);
}
}
(before_reload, dropped, pings)
});
started_probing
.recv_timeout(FLOCK_DEADLINE)
.expect("the prober must reach the shepherd before the reload starts");
let reloaded = shep(dir.path())
.arg("daemon")
.arg("reload")
.output()
.unwrap();
assert_success(&reloaded);
let reload_aside = String::from_utf8_lossy(&reloaded.stderr);
assert!(
!reload_aside.contains("starting one instead")
&& !reload_aside.contains("stopping and starting instead"),
"this case is about the handover arm and the reload took the other one: {reload_aside}"
);
let inode_after = std::fs::metadata(&socket)
.expect("the control socket must still exist after the handover")
.ino();
assert_eq!(
inode_after, inode_before,
"the successor bound a fresh listener instead of adopting the carried one: \
the socket file's inode changed across the handover"
);
let (refused, dials) = dialer.join().unwrap();
let (before_reload, dropped, pings) = prober.join().unwrap();
assert!(
before_reload.is_empty(),
"a ping failed before any reload was asked for, at {before_reload:?} of \
{pings}: the predecessor was not answering, which is not the handover's \
doing"
);
assert!(
refused.is_empty(),
"the control address must stay bound across the handover, \
{} of {dials} dials refused: {refused:?}",
refused.len()
);
assert!(
dropped.len() <= 1,
"at most the one request in flight at the exec may drop, got {} of {pings} \
pings, at {dropped:?}",
dropped.len()
);
graceful_kill(dir.path());
}
#[cfg(unix)]
fn roll_recording(roll: &Path, want: usize) -> Vec<u8> {
let start = Instant::now();
loop {
let bytes = std::fs::read(roll).unwrap_or_default();
let apps = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|value| value["apps"].as_array().map(Vec::len));
if apps == Some(want) || start.elapsed() >= FLOCK_DEADLINE {
return bytes;
}
std::thread::sleep(FLOCK_POLL_INTERVAL);
}
}
#[cfg(unix)]
#[test]
fn a_successor_inheriting_an_empty_flock_does_not_restore_the_roll() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("ghost")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&started);
let _ = poll_flock(home, |info| info["status"] == "online");
let shepherd = wait_for_daemon_pid(home).expect("the shepherd must record a pid");
assert_success(&shep(home).arg("save").output().unwrap());
let roll = home.join("flock.json");
let stale = roll_recording(&roll, 1);
assert!(
!stale.is_empty(),
"the roll must record `ghost` before it can go stale: {}",
String::from_utf8_lossy(&stale)
);
assert_success(&shep(home).arg("delete").arg("ghost").output().unwrap());
let emptied = poll_flock_data(home, FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(Vec::is_empty)
});
assert_eq!(
emptied.as_array().map(Vec::len),
Some(0),
"the delete must leave an idle shepherd: {emptied}"
);
let _ = roll_recording(&roll, 0);
std::fs::write(&roll, &stale).unwrap();
nix::sys::signal::kill(shepherd, nix::sys::signal::Signal::SIGHUP).unwrap();
let after = poll_flock_data_across_a_handover(home, FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(|rows| !rows.is_empty())
});
assert_eq!(
after.as_array().map(Vec::len),
Some(0),
"a successor must install the flock it was handed and nothing else; \
this one restored a stale roll: {after}"
);
assert_eq!(
wait_for_daemon_pid(home),
Some(shepherd),
"the shepherd must have been replaced in place; a moved pid means \
SIGHUP stopped it and the polling above started a fresh one, which \
is not the boot this test is about"
);
graceful_kill(home);
}
#[test]
fn add_registers_a_stopped_sheep_that_a_later_start_brings_up() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"pending\"\nscript = '{}'\n",
script.display(),
),
);
let mut guard = DaemonGuard::default();
let added = shep(home)
.arg("--format")
.arg("json")
.arg("add")
.arg(&flockfile)
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&added);
let envelope: serde_json::Value = serde_json::from_slice(&added.stdout)
.unwrap_or_else(|e| panic!("add stdout was not JSON: {e}"));
assert_eq!(
envelope["command"], "add",
"the envelope names the verb the operator typed: {envelope}"
);
assert_eq!(
envelope["data"][0]["status"], "stopped",
"registered, not started: {envelope}"
);
assert!(
envelope["data"][0]["pid"].is_null(),
"a sheep that was never spawned has no pid: {envelope}"
);
#[cfg(unix)]
assert!(
!dir.path().join(FIXTURE_PIDS).exists(),
"the script never ran, so it never recorded a pid"
);
let listed = shep(home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&listed);
let flock: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap();
assert_eq!(flock["data"][0]["name"], "pending", "it is in the flock");
assert_eq!(flock["data"][0]["status"], "stopped", "and still at rest");
let started = shep(home).arg("start").arg("pending").output().unwrap();
assert_success(&started);
let running = poll_flock(home, |info| info["status"] == "online");
assert_eq!(
running["status"], "online",
"the registered sheep came up: {running}"
);
graceful_kill(home);
}
#[test]
fn add_with_nothing_to_add_is_a_usage_error() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let output = shep(home).arg("add").current_dir(home).output().unwrap();
assert_eq!(
output.status.code(),
Some(2),
"clap's own code for bad arguments: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no target and no Flockfile"),
"the refusal says what was missing: {stderr}"
);
assert!(
!home.join("run").join("shep.sock").exists(),
"and no shepherd was started to answer a request nobody made"
);
}
#[cfg(unix)]
#[test]
fn a_sudo_startup_without_home_carries_the_target_users_home_not_this_processes() {
if nix::unistd::geteuid().is_root() {
eprintln!("skipping: as root this would really install a unit if the gate were broken");
return;
}
let Ok(Some(nobody)) = nix::unistd::User::from_name("nobody") else {
eprintln!("skipping: no `nobody` user to stand in for $SUDO_USER");
return;
};
let fake_root_home = TempDir::new().unwrap();
let output = Command::cargo_bin("shep")
.unwrap()
.env("HOME", fake_root_home.path())
.env("SUDO_USER", "nobody")
.env_remove("SHEP_HOME")
.arg("startup")
.arg("--init")
.arg("systemd")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert!(!output.status.success(), "{output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
let target_home = nobody.dir.join(".shep");
let refusal = format!(
"error[usage]: no directory at {}; create it first (any shep verb run as nobody \
creates that user's own ~/.shep), or pass --home with the $SHEP_HOME this unit \
should carry",
target_home.display()
);
assert!(
stderr.lines().any(|line| line == refusal),
"the refusal names nobody's own home and both ways out: {stderr}"
);
assert!(
!stderr.contains(fake_root_home.path().to_str().unwrap()),
"and never this process's $HOME: {stderr}"
);
assert!(
!fake_root_home.path().join(".shep").exists(),
"nothing is created under a $HOME that is not the target user's"
);
}
fn write_chained_flockfile(dir: &TempDir, script: &Path) -> PathBuf {
write_flockfile(
dir,
&format!(
"[[app]]\nname = \"db\"\nscript = '{script}'\n\
[[app]]\nname = \"api\"\nscript = '{script}'\ndepends_on = [\"db\"]\n\
[[app]]\nname = \"web\"\nscript = '{script}'\ndepends_on = [\"api\"]\n",
script = script.display(),
),
)
}
#[test]
fn starting_a_flockfile_with_a_cycle_refuses_and_names_it() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"a\"\nscript = '{script}'\ndepends_on = [\"b\"]\n\
[[app]]\nname = \"b\"\nscript = '{script}'\ndepends_on = [\"a\"]\n",
script = script.display(),
),
);
let mut guard = DaemonGuard::default();
let out = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_eq!(out.status.code(), Some(4), "invalid_config");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(" -> "),
"the cycle must be named as a path: {stderr}"
);
graceful_kill(home);
}
#[test]
fn a_three_stage_start_brings_every_stage_online() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_chained_flockfile(&dir, &script);
let mut guard = DaemonGuard::default();
let out = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&out);
let data = poll_flock_data(home, FLOCK_DEADLINE, |data| {
data.as_array()
.is_some_and(|rows| rows.len() == 3 && rows.iter().all(|row| row["status"] == "online"))
});
let names: Vec<&str> = data
.as_array()
.unwrap()
.iter()
.map(|row| row["name"].as_str().unwrap())
.collect();
assert_eq!(names.len(), 3, "every stage came up: {data}");
graceful_kill(home);
}