#![cfg_attr(windows, allow(dead_code))]
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Output, Stdio};
use std::time::{Duration, Instant};
use assert_cmd::Command;
use assert_cmd::cargo::CommandCargoExt as _;
use tempfile::TempDir;
const CMD_TIMEOUT: Duration =
Duration::from_secs(shep_client::spawn::SPAWN_DEADLINE.as_secs() + 15);
const RACER_DEADLINE: Duration = Duration::from_secs(CMD_TIMEOUT.as_secs() + 15);
const BLEATS_DEADLINE: Duration = Duration::from_secs(10);
const BLEATS_POLL_INTERVAL: Duration = Duration::from_millis(50);
const SCRIPT_SLEEP_SECS: u32 = 60;
const SLOW_SCRIPT_SLEEP_SECS: u32 = 300;
const FIXTURE_PIDS: &str = "fixture.pids";
const GUARD_PID_DEADLINE: Duration = Duration::from_secs(3);
const GUARD_PID_POLL_INTERVAL: Duration = Duration::from_millis(50);
const GUARD_SWEEP_WINDOW: Duration = Duration::from_secs(2);
const FLOCK_DEADLINE: Duration = Duration::from_secs(10);
const FLOCK_POLL_INTERVAL: Duration = Duration::from_millis(100);
const METRICS_SCRAPE_DEADLINE: Duration = FLOCK_DEADLINE;
const METRICS_SCRAPE_POLL_INTERVAL: Duration = Duration::from_millis(50);
const METRICS_SCRAPE_READ_TIMEOUT: Duration = Duration::from_secs(2);
const SERVE_HTTP_DEADLINE: Duration = FLOCK_DEADLINE;
const SERVE_HTTP_POLL_INTERVAL: Duration = Duration::from_millis(50);
const SERVE_HTTP_READ_TIMEOUT: Duration = Duration::from_secs(2);
const SERVE_STOP_DEADLINE: Duration = Duration::from_millis(1000);
const CRON_DEADLINE: Duration = Duration::from_secs(150);
const BREACH_DEADLINE: Duration = Duration::from_secs(60);
const BALLOON_BYTES: u64 = 16 * 1024 * 1024;
const BREACH_LIMIT: &str = "8M";
const NEVER_READY_TIMEOUT: &str = "1s";
const ROTATE_BEFORE: &str = "before-the-rotation";
const ROTATE_AFTER: &str = "after-the-rotation";
const READINESS_RECORD: &str = "readiness deadline elapsed";
fn fixture_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(format!("{name}.json"))
}
fn load_fixture(name: &str) -> serde_json::Value {
let path = fixture_path(name);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn write_test_script(dir: &TempDir) -> PathBuf {
write_script(
dir,
"sheep.sh",
&format!(
"{}{}{}",
script_header(),
record_pid_line(dir),
sleep_line(SCRIPT_SLEEP_SECS)
),
)
}
fn write_forking_script(dir: &TempDir) -> PathBuf {
write_script(
dir,
"forker.sh",
&format!("#!/bin/sh\n{}sleep 300 &\nwait\n", record_pid_line(dir)),
)
}
fn record_pid_line(dir: &TempDir) -> String {
#[cfg(unix)]
{
format!(
"echo $$ >> \"{}\"\n",
dir.path().join(FIXTURE_PIDS).display()
)
}
#[cfg(windows)]
{
let _ = dir;
String::new()
}
}
fn write_slow_script(dir: &TempDir) -> PathBuf {
write_script(
dir,
"slow.sh",
&format!(
"{}{}{}",
script_header(),
record_pid_line(dir),
sleep_line(SLOW_SCRIPT_SLEEP_SECS)
),
)
}
fn write_ballooning_script(dir: &TempDir) -> PathBuf {
write_script(dir, "balloon.sh", &balloon_body(dir))
}
fn write_logging_script(dir: &TempDir, out_marker: &str, err_marker: Option<&str>) -> PathBuf {
let mut script = format!(
"{}{}{}",
script_header(),
record_pid_line(dir),
echo_line(out_marker)
);
if let Some(err_marker) = err_marker {
script.push_str(&echo_err_line(err_marker));
}
script.push_str(&sleep_line(SCRIPT_SLEEP_SECS));
write_script(dir, "logging.sh", &script)
}
fn write_rotating_script(dir: &TempDir, gate: &Path) -> PathBuf {
write_script(
dir,
"rotating.sh",
&format!(
"{}{}{}{}{}{}",
script_header(),
record_pid_line(dir),
echo_line(ROTATE_BEFORE),
wait_for_path_lines(gate),
echo_line(ROTATE_AFTER),
sleep_line(SCRIPT_SLEEP_SECS)
),
)
}
fn write_ready_script(dir: &TempDir, sentinel: &Path) -> PathBuf {
write_script(
dir,
"ready.sh",
&format!(
"{}{}{}{}{}",
script_header(),
record_pid_line(dir),
wait_for_path_lines(sentinel),
ready_message_line(),
sleep_line(SCRIPT_SLEEP_SECS)
),
)
}
fn write_script(dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = dir.path().join(script_name(name));
std::fs::write(&path, contents).unwrap();
#[cfg(unix)]
{
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
path
}
fn script_name(name: &str) -> String {
#[cfg(unix)]
{
name.to_string()
}
#[cfg(windows)]
{
format!("{}.cmd", name.trim_end_matches(".sh"))
}
}
fn script_header() -> String {
#[cfg(unix)]
{
"#!/bin/sh\n".to_string()
}
#[cfg(windows)]
{
"@echo off\r\n".to_string()
}
}
fn sleep_line(secs: u32) -> String {
#[cfg(unix)]
{
format!("sleep {secs}\n")
}
#[cfg(windows)]
{
format!("ping -n {} 127.0.0.1 >nul\r\n", secs + 1)
}
}
fn echo_line(text: &str) -> String {
#[cfg(unix)]
{
format!("echo '{text}'\n")
}
#[cfg(windows)]
{
format!("echo {text}\r\n")
}
}
fn wait_for_path_lines(path: &Path) -> String {
#[cfg(unix)]
{
format!("until [ -e \"{}\" ]; do sleep 0.1; done\n", path.display())
}
#[cfg(windows)]
{
format!(
":wait\r\nif exist \"{}\" goto ready\r\nping -n 2 127.0.0.1 >nul\r\ngoto wait\r\n:ready\r\n",
path.display()
)
}
}
fn ready_message_line() -> String {
#[cfg(unix)]
{
"printf '{\"kind\":\"ready\"}\\n' >&3\n".to_string()
}
#[cfg(windows)]
{
"echo {\"kind\":\"ready\"}>\"%SHEP_CHANNEL_PIPE%\"\r\n".to_string()
}
}
fn echo_err_line(text: &str) -> String {
#[cfg(unix)]
{
format!("echo '{text}' 1>&2\n")
}
#[cfg(windows)]
{
format!("echo {text} 1>&2\r\n")
}
}
fn balloon_body(dir: &TempDir) -> String {
#[cfg(windows)]
let _ = dir;
#[cfg(unix)]
{
format!(
"{}{}s=x\nwhile [ ${{#s}} -lt {BALLOON_BYTES} ]; do s=\"$s$s\"; done\nsleep {SLOW_SCRIPT_SLEEP_SECS}\n",
script_header(),
record_pid_line(dir),
)
}
#[cfg(windows)]
{
format!(
"{}powershell -NoProfile -Command \"$s = 'x' * {BALLOON_BYTES}; Start-Sleep -Seconds {SLOW_SCRIPT_SLEEP_SECS}; $s.Length > $null\"\r\n",
script_header(),
)
}
}
fn write_never_ready_flockfile(dir: &TempDir) -> PathBuf {
let script = write_test_script(dir);
write_flockfile(
dir,
&format!(
"[[app]]\nname = \"gated\"\nscript = '{}'\n\
wait_ready = true\nlisten_timeout = \"{NEVER_READY_TIMEOUT}\"\n",
script.display(),
),
)
}
fn write_flockfile(dir: &TempDir, body: &str) -> PathBuf {
let path = dir.path().join("Flockfile.toml");
std::fs::write(&path, body).unwrap();
path
}
fn shep(home: &Path) -> Command {
let mut cmd = Command::cargo_bin("shep").unwrap();
cmd.arg("--home").arg(home).timeout(CMD_TIMEOUT);
cmd
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"expected success, got {:?}; stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
fn graceful_kill(home: &Path) {
let _ = shep(home).arg("kill").output();
}
#[cfg(unix)]
fn daemon_log_after_a_missed_handshake(dir: &TempDir, env: &[(&str, &str)]) -> String {
let home = dir.path();
let flockfile = write_never_ready_flockfile(dir);
let mut guard = DaemonGuard::default();
let mut start = shep(home);
for (key, value) in env {
start.env(key, value);
}
let boot = start.arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
let online = poll_flock(home, |info| info["status"] == "online");
assert_eq!(
online["status"], "online",
"a wait_ready sheep that never signals must still be taken online once \
its listen_timeout elapses, which is the record's own trigger: {online}"
);
let log = std::fs::read_to_string(home.join("logs").join("shepd.err.log")).unwrap();
graceful_kill(home);
log
}
#[derive(Debug, Default)]
struct DaemonGuard {
homes: Vec<PathBuf>,
#[cfg(unix)]
dog_pids: Vec<nix::unistd::Pid>,
}
impl DaemonGuard {
fn adopt_home(&mut self, home: &Path) {
self.homes.push(home.to_path_buf());
}
#[cfg(unix)]
fn adopt_dog_pid(&mut self, pid: nix::unistd::Pid) {
self.dog_pids.push(pid);
}
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
#[cfg(windows)]
{
for home in &self.homes {
let _ = std::process::Command::new(assert_cmd::cargo::cargo_bin("shep"))
.arg("--home")
.arg(home)
.arg("kill")
.output();
}
}
#[cfg(unix)]
{
let panicking = std::thread::panicking();
for home in &self.homes {
match daemon_pid(home) {
Some(pid) => kill_group_of(pid),
None if !panicking => {}
None => match wait_for_daemon_pid(home) {
Some(pid) => kill_group_of(pid),
None => eprintln!(
"DaemonGuard: no parseable daemon pid at {} after {GUARD_PID_DEADLINE:?}; \
if a daemon is still up it was NOT reaped",
home.display()
),
},
}
if !panicking {
continue;
}
sweep_flock(home);
}
for pid in &self.dog_pids {
kill_group_of(*pid);
}
}
}
}
#[cfg(unix)]
fn sweep_flock(home: &Path) {
let start = Instant::now();
loop {
for pid in recorded_fixture_pids(home) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(-pid.as_raw()),
nix::sys::signal::Signal::SIGKILL,
);
}
if start.elapsed() >= GUARD_SWEEP_WINDOW {
return;
}
std::thread::sleep(GUARD_PID_POLL_INTERVAL);
}
}
#[cfg(unix)]
fn daemon_pid(home: &Path) -> Option<nix::unistd::Pid> {
let text = std::fs::read_to_string(home.join("pids").join("shepd.pid")).ok()?;
let raw: i32 = text.trim().parse().ok()?;
Some(nix::unistd::Pid::from_raw(raw))
}
#[cfg(unix)]
fn wait_for_daemon_pid(home: &Path) -> Option<nix::unistd::Pid> {
let start = Instant::now();
loop {
if let Some(pid) = daemon_pid(home) {
return Some(pid);
}
if start.elapsed() >= GUARD_PID_DEADLINE {
return None;
}
std::thread::sleep(GUARD_PID_POLL_INTERVAL);
}
}
#[cfg(unix)]
fn kill_group_of(pid: nix::unistd::Pid) {
let target = match nix::unistd::getpgid(Some(pid)) {
Ok(pgid) if pgid == pid => nix::unistd::Pid::from_raw(-pid.as_raw()),
_ => pid,
};
let _ = nix::sys::signal::kill(target, nix::sys::signal::Signal::SIGKILL);
}
#[cfg(unix)]
fn recorded_fixture_pids(home: &Path) -> Vec<nix::unistd::Pid> {
let Ok(text) = std::fs::read_to_string(home.join(FIXTURE_PIDS)) else {
return Vec::new();
};
text.lines()
.filter_map(|line| line.trim().parse::<i32>().ok())
.map(nix::unistd::Pid::from_raw)
.collect()
}
#[cfg(unix)]
fn read_daemon_pid(home: &Path) -> nix::unistd::Pid {
let path = home.join("pids").join("shepd.pid");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("no pidfile at {}: {e}", path.display()));
let raw: i32 = text
.trim()
.parse()
.unwrap_or_else(|e| panic!("bad pidfile contents {text:?}: {e}"));
nix::unistd::Pid::from_raw(raw)
}
#[cfg(unix)]
fn assert_group_leader(pid: nix::unistd::Pid) {
assert_eq!(
nix::unistd::getpgid(Some(pid)).unwrap(),
pid,
"the daemon must be its own process-group leader"
);
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("the OS must have a free loopback port")
.local_addr()
.expect("a bound listener has an address")
.port()
}
fn scrape_metrics(addr: std::net::SocketAddr) -> std::io::Result<String> {
use std::io::{Read as _, Write as _};
let mut stream = std::net::TcpStream::connect(addr)?;
stream.set_read_timeout(Some(METRICS_SCRAPE_READ_TIMEOUT))?;
stream.write_all(b"GET /metrics HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")?;
let mut body = String::new();
stream.read_to_string(&mut body)?;
Ok(body)
}
fn poll_metrics(addr: std::net::SocketAddr) -> String {
let start = Instant::now();
loop {
if let Ok(body) = scrape_metrics(addr) {
return body;
}
if start.elapsed() >= METRICS_SCRAPE_DEADLINE {
return String::new();
}
std::thread::sleep(METRICS_SCRAPE_POLL_INTERVAL);
}
}
fn http_get(
addr: std::net::SocketAddr,
path: &str,
headers: &[(&str, &str)],
) -> std::io::Result<(u16, String)> {
use std::io::{Read as _, Write as _};
let mut stream = std::net::TcpStream::connect(addr)?;
stream.set_read_timeout(Some(SERVE_HTTP_READ_TIMEOUT))?;
let mut request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n");
for (name, value) in headers {
request.push_str(&format!("{name}: {value}\r\n"));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes())?;
let mut raw = String::new();
stream.read_to_string(&mut raw)?;
let status = raw
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or(0);
let body = raw
.split_once("\r\n\r\n")
.map_or("", |(_, body)| body)
.to_string();
Ok((status, body))
}
fn poll_http_get(
addr: std::net::SocketAddr,
path: &str,
headers: &[(&str, &str)],
) -> (u16, String) {
let start = Instant::now();
loop {
if let Ok(answer) = http_get(addr, path, headers) {
return answer;
}
if start.elapsed() >= SERVE_HTTP_DEADLINE {
return (0, String::new());
}
std::thread::sleep(SERVE_HTTP_POLL_INTERVAL);
}
}
#[cfg(unix)]
fn wait_for_dog_pid(home: &Path, name: &str) -> nix::unistd::Pid {
let flock = poll_flock_data(home, FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(|entries| {
entries
.iter()
.any(|e| e["name"] == name && !e["pid"].is_null())
})
});
let dog = flock
.as_array()
.and_then(|entries| entries.iter().find(|e| e["name"] == name))
.unwrap_or_else(|| panic!("no entry named {name} in `shep flock`: {flock}"));
let pid = dog["pid"]
.as_i64()
.unwrap_or_else(|| panic!("dog {name} has no pid after {FLOCK_DEADLINE:?}: {dog}"));
nix::unistd::Pid::from_raw(i32::try_from(pid).expect("a real OS pid fits i32"))
}
fn bleats_no_follow_until_written(home: &Path, args: &[&str]) -> Output {
let start = Instant::now();
loop {
let output = shep(home)
.arg("bleats")
.arg("--no-follow")
.args(args)
.output()
.unwrap();
if !output.stdout.is_empty() || start.elapsed() >= BLEATS_DEADLINE {
return output;
}
std::thread::sleep(BLEATS_POLL_INTERVAL);
}
}
fn poll_flock_data(
home: &Path,
deadline: Duration,
done: impl Fn(&serde_json::Value) -> bool,
) -> serde_json::Value {
let start = Instant::now();
loop {
let output = shep(home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&output);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout)
.unwrap_or_else(|e| panic!("flock stdout was not JSON: {e}"));
let data = envelope["data"].clone();
if done(&data) || start.elapsed() >= deadline {
return data;
}
std::thread::sleep(FLOCK_POLL_INTERVAL);
}
}
fn poll_flock(home: &Path, done: impl Fn(&serde_json::Value) -> bool) -> serde_json::Value {
poll_flock_data(home, FLOCK_DEADLINE, |data| done(&data[0]))[0].clone()
}
fn poll_describe_lambs(home: &Path, name: &str, deadline: Duration) -> Output {
let start = Instant::now();
loop {
let output = shep(home)
.arg("--format")
.arg("json")
.arg("describe")
.arg(name)
.output()
.unwrap();
assert_success(&output);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout)
.unwrap_or_else(|e| panic!("describe stdout was not JSON: {e}"));
let has_lamb = envelope["data"][0]["lambs"]
.as_array()
.is_some_and(|lambs| !lambs.is_empty());
if has_lamb || start.elapsed() >= deadline {
return output;
}
std::thread::sleep(FLOCK_POLL_INTERVAL);
}
}
fn sheep_named<'a>(data: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
data.as_array()
.unwrap_or_else(|| panic!("flock data must be an array: {data}"))
.iter()
.find(|info| info["name"] == name)
.unwrap_or_else(|| panic!("no sheep named {name} in the flock: {data}"))
}
fn serve_raw_response(response: String) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
if let Ok((mut stream, _peer)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let _ = stream.write_all(response.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Both);
}
});
format!("http://127.0.0.1:{}/dogs.json", addr.port())
}
fn serve_dog_index(body: &str) -> String {
serve_raw_response(format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
))
}
fn two_entry_index_json() -> String {
serde_json::json!({
"$schema": "https://shep.turtlesocks.dev/dogs.schema.json",
"version": 1,
"dogs": [
{
"name": "Spot",
"package": "shep-log-rotate",
"adopt_as": "log-rotate",
"description": "Rotates grown log files and asks the shepherd to reopen them.",
"repo": "https://github.com/TurtIeSocks/shep-log-rotate",
"license": "MIT OR Apache-2.0",
"category": "logs",
"source": {
"kind": "cargo-git",
"url": "https://github.com/TurtIeSocks/shep-log-rotate"
}
},
{
"name": "Rex",
"package": "shep-watchdog",
"adopt_as": "watchdog",
"description": "Barks when a sheep stops answering.\u{1b}[2J",
"repo": "https://github.com/example/shep-watchdog",
"license": "Apache-2.0",
"category": "health",
"source": {
"kind": "go-install",
"module": "github.com/example/shep-watchdog"
}
}
]
})
.to_string()
}
fn normalize_process_info(info: &mut serde_json::Value, home: &Path, name: &str, samples: Samples) {
let pid = info["pid"]
.as_i64()
.unwrap_or_else(|| panic!("pid must be a real positive OS pid: {info}"));
assert!(pid > 0, "pid must be a real positive OS pid: {info}");
info["uptime_ms"]
.as_u64()
.unwrap_or_else(|| panic!("uptime_ms must be present: {info}"));
let home_str = home.to_str().unwrap();
for (key, stream) in [("out_file", "out"), ("err_file", "err")] {
let path = info[key]
.as_str()
.unwrap_or_else(|| panic!("{key} must be a string: {info}"));
assert!(
path.starts_with(home_str),
"{key} must be rooted under $SHEP_HOME: {path}"
);
assert!(
path.ends_with(&format!("{name}-0-{stream}.log")),
"{key} must name this sheep's own log file: {path}"
);
}
match samples {
Samples::Live => {
let bytes = info["memory_bytes"].as_u64().unwrap_or_else(|| {
panic!("memory_bytes must be a live reading off the host: {info}")
});
assert!(
bytes > 0,
"a running sheep's tree cannot be 0 bytes: {info}"
);
}
Samples::None => assert!(
info["memory_bytes"].is_null(),
"a verb that takes no live sample must report no memory: {info}"
),
}
info["pid"] = serde_json::Value::Null;
info["uptime_ms"] = serde_json::Value::Null;
info["out_file"] = serde_json::Value::Null;
info["err_file"] = serde_json::Value::Null;
info["cpu_percent"] = serde_json::Value::Null;
info["memory_bytes"] = serde_json::Value::Null;
if let Some(lambs) = info["lambs"].as_array_mut() {
for lamb in lambs {
lamb["pid"] = serde_json::Value::Null;
}
}
}
#[derive(Debug, Clone, Copy)]
enum Samples {
Live,
None,
}
fn assert_envelope_matches_fixture(
output: &Output,
home: &Path,
command: &str,
sheep_name: &str,
samples: Samples,
) {
let mut envelope: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"{command}: stdout was not JSON: {e}: {}",
String::from_utf8_lossy(&output.stdout)
)
});
{
let data = envelope["data"]
.as_array()
.unwrap_or_else(|| panic!("{command}: data must be an array"));
assert_eq!(data.len(), 1, "{command}: exactly one sheep is expected");
}
normalize_process_info(&mut envelope["data"][0], home, sheep_name, samples);
assert_eq!(
envelope,
load_fixture(command),
"{command} envelope drifted from its committed fixture"
);
}
fn assert_json_error(output: &Output, expected_status: i32, expected_error_code: &str) {
assert_eq!(
output.status.code(),
Some(expected_status),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"stdout must stay empty on failure: {}",
String::from_utf8_lossy(&output.stdout)
);
let err: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap_or_else(|e| {
panic!(
"stderr was not JSON: {e}: {}",
String::from_utf8_lossy(&output.stderr)
)
});
assert_eq!(err["error"]["code"], expected_error_code, "{err}");
}
#[cfg(unix)]
#[test]
fn starting_with_no_daemon_running_autostarts_one_and_the_sheep_reaches_online() {
let dir = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let output = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&output);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(envelope["data"][0]["status"], "online", "{envelope}");
let pid = read_daemon_pid(dir.path());
assert_group_leader(pid);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn a_second_command_reuses_the_daemon_rather_than_spawning_a_second() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let first = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("alpha")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&first);
let first_pid = read_daemon_pid(home);
let second = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("beta")
.output()
.unwrap();
assert_success(&second);
let second_pid = read_daemon_pid(home);
assert_eq!(
first_pid, second_pid,
"the second command must reuse the first daemon, not spawn a new one"
);
let flock = shep(home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&flock);
let envelope: serde_json::Value = serde_json::from_slice(&flock.stdout).unwrap();
let names: Vec<&str> = envelope["data"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
["alpha", "beta"],
"both sheep must be registered against the one daemon: {envelope}"
);
graceful_kill(home);
}
#[test]
fn a_lifecycle_verb_prints_the_whole_flock_and_json_still_prints_what_it_touched() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
for name in ["alpha", "gamma", "beta"] {
let started = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg(name)
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&started);
}
let stopped = shep(home).arg("stop").arg("alpha").output().unwrap();
assert_success(&stopped);
let printed = String::from_utf8(stopped.stdout).unwrap();
let named: Vec<&str> = printed
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.filter(|word| *word != "NAME")
.collect();
assert_eq!(
named,
["alpha", "beta", "gamma"],
"stopping one sheep prints the whole flock, in name order: {printed}"
);
let json = shep(home)
.arg("--format")
.arg("json")
.arg("stop")
.arg("beta")
.output()
.unwrap();
assert_success(&json);
let envelope: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
let rows = envelope["data"].as_array().unwrap();
let names: Vec<&str> = rows.iter().map(|r| r["name"].as_str().unwrap()).collect();
assert_eq!(
names,
["beta"],
"the machine surface still answers what it touched: {envelope}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn concurrent_cold_starts_produce_exactly_one_daemon() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_path_buf();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
guard.adopt_home(&home);
let names = ["racer-a", "racer-b"];
let barrier = std::sync::Arc::new(std::sync::Barrier::new(names.len()));
let (finished, racers) = std::sync::mpsc::channel();
for name in names {
let home = home.clone();
let script = script.clone();
let barrier = std::sync::Arc::clone(&barrier);
let finished = finished.clone();
std::thread::spawn(move || {
barrier.wait(); let output = shep(&home)
.arg("start")
.arg(&script)
.arg("--name")
.arg(name)
.output()
.unwrap();
let _ = finished.send((name, output));
});
}
drop(finished);
let outputs: Vec<(&str, Output)> = (0..names.len())
.map(|_| {
racers
.recv_timeout(RACER_DEADLINE)
.expect("a racer never came back; see RACER_DEADLINE")
})
.collect();
for (name, output) in &outputs {
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let pid = read_daemon_pid(&home);
assert_group_leader(pid);
let flock = shep(&home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&flock);
let envelope: serde_json::Value = serde_json::from_slice(&flock.stdout).unwrap();
let mut got: Vec<&str> = envelope["data"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
got.sort_unstable();
assert_eq!(
got,
["racer-a", "racer-b"],
"both racers must have registered against the SAME daemon: {envelope}"
);
graceful_kill(&home);
}
#[cfg(unix)]
#[test]
fn json_format_matches_the_committed_fixtures() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_logging_script(&dir, "fixture-line-1", None);
let mut guard = DaemonGuard::default();
let start_out = shep(home)
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.arg("--name")
.arg("fixture")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&start_out);
assert_envelope_matches_fixture(&start_out, home, "start", "fixture", Samples::None);
let flock_out = shep(home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&flock_out);
assert_envelope_matches_fixture(&flock_out, home, "flock", "fixture", Samples::Live);
let describe_out = poll_describe_lambs(home, "fixture", FLOCK_DEADLINE);
assert_envelope_matches_fixture(&describe_out, home, "describe", "fixture", Samples::Live);
let ping_out = shep(home)
.arg("--format")
.arg("json")
.arg("ping")
.output()
.unwrap();
assert_success(&ping_out);
let mut ping_envelope: serde_json::Value = serde_json::from_slice(&ping_out.stdout).unwrap();
let ping_pid = ping_envelope["data"]["pid"]
.as_i64()
.unwrap_or_else(|| panic!("ping must report a real pid: {ping_envelope}"));
assert!(ping_pid > 0);
assert_eq!(
nix::unistd::Pid::from_raw(i32::try_from(ping_pid).unwrap()),
read_daemon_pid(home),
"ping's pid must be the daemon's own pid"
);
ping_envelope["data"]["pid"] = serde_json::Value::Null;
assert_eq!(
ping_envelope["data"]["home"].as_str().unwrap(),
home.to_str().unwrap(),
"ping must name the home it probed"
);
assert!(
ping_envelope["data"]["socket"]
.as_str()
.unwrap()
.starts_with(home.to_str().unwrap()),
"ping's socket must sit under that home"
);
ping_envelope["data"]["home"] = serde_json::Value::Null;
ping_envelope["data"]["socket"] = serde_json::Value::Null;
assert_eq!(
ping_envelope["data"]["daemon_version"].as_str().unwrap(),
env!("CARGO_PKG_VERSION"),
"ping must report this build's own version"
);
ping_envelope["data"]["daemon_version"] = serde_json::Value::Null;
assert_eq!(
ping_envelope,
load_fixture("ping"),
"ping envelope drifted from its committed fixture"
);
let bleats_out = bleats_no_follow_until_written(home, &["all", "--format", "json"]);
assert_eq!(
bleats_out.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&bleats_out.stderr)
);
let expected = std::fs::read(fixture_path("bleats_no_follow")).unwrap();
assert_eq!(
bleats_out.stdout,
expected,
"bleats --no-follow --format json must match its fixture byte-for-byte: got {}",
String::from_utf8_lossy(&bleats_out.stdout)
);
graceful_kill(home);
}
#[test]
fn exit_codes_and_stream_discipline() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("only")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let not_found = shep(home)
.arg("--format")
.arg("json")
.arg("describe")
.arg("ghost")
.output()
.unwrap();
assert_json_error(¬_found, 3, "not_found");
let usage = shep(home)
.arg("--format")
.arg("json")
.arg("describe")
.arg("/[/")
.output()
.unwrap();
assert_json_error(&usage, 2, "usage");
let cold = tempfile::tempdir().unwrap();
let quiet_home = cold.path().join("no-daemon-here");
std::fs::create_dir_all(&quiet_home).unwrap();
let unreachable = shep(&quiet_home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_json_error(&unreachable, 5, "daemon_unreachable");
let missing_home = cold.path().join("gone");
let absent = shep(&missing_home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_json_error(&absent, 2, "usage");
assert!(
!missing_home.exists(),
"a refused --home must be left on disk exactly as it was found"
);
graceful_kill(home);
}
#[test]
fn kill_stops_the_daemon_and_removes_the_socket() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&script).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
#[cfg(unix)]
let socket = home.join("run").join("shep.sock");
#[cfg(unix)]
assert!(socket.exists(), "precondition: the daemon is up");
#[cfg(windows)]
assert_success(&shep(home).arg("flock").output().unwrap());
let kill = shep(home).arg("kill").output().unwrap();
assert_success(&kill);
#[cfg(unix)]
assert!(!socket.exists(), "kill must remove the socket file");
#[cfg(windows)]
{
let after = shep(home).arg("flock").output().unwrap();
assert!(
!after.status.success(),
"kill must leave nothing answering on the control pipe; stderr={}",
String::from_utf8_lossy(&after.stderr)
);
}
}
#[test]
fn bleats_no_follow_prints_what_a_sheep_actually_wrote() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_logging_script(&dir, "bleater-out-marker", Some("bleater-err-marker"));
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("bleater")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let both = bleats_no_follow_until_written(home, &["all"]);
assert_eq!(
both.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&both.stderr)
);
let stdout = String::from_utf8_lossy(&both.stdout);
let stderr = String::from_utf8_lossy(&both.stderr);
assert!(stdout.contains("bleater-out-marker"), "stdout={stdout}");
assert!(stdout.contains("bleater-err-marker"), "stdout={stdout}");
assert!(
!stderr.contains("bleater-out-marker") && !stderr.contains("bleater-err-marker"),
"a sheep's own lines must never reach shep's diagnostic stream: stderr={stderr}"
);
let out_only = bleats_no_follow_until_written(home, &["all", "--out"]);
assert_eq!(
out_only.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&out_only.stderr)
);
let stdout_only = String::from_utf8_lossy(&out_only.stdout);
assert!(
stdout_only.contains("bleater-out-marker"),
"stdout={stdout_only}"
);
assert!(
!stdout_only.contains("bleater-err-marker"),
"--out must select the out file only: stdout={stdout_only}"
);
graceful_kill(home);
}
#[test]
fn reopen_puts_a_rotated_log_back_where_bleats_can_read_it() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let gate = home.join("rotated");
let script = write_rotating_script(&dir, &gate);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("rotator")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = bleats_no_follow_until_written(home, &["all"]);
let printed = String::from_utf8_lossy(&before.stdout);
assert!(
printed.contains(ROTATE_BEFORE),
"precondition: the sheep's first line must be readable before the \
rotation: stdout={printed}"
);
let online = poll_flock(home, |info| info["status"] == "online");
let out_file = PathBuf::from(
online["out_file"]
.as_str()
.unwrap_or_else(|| panic!("the daemon reports its own log paths: {online}")),
);
let archive = out_file.with_extension("log.1");
std::fs::rename(&out_file, &archive).unwrap();
assert!(!out_file.exists(), "sanity: the rename really moved it");
let reopened = shep(home)
.arg("reopen")
.arg("--format")
.arg("json")
.output()
.unwrap();
assert_success(&reopened);
let envelope: serde_json::Value = serde_json::from_slice(&reopened.stdout).unwrap();
assert_eq!(
envelope["command"], "reopen",
"a reopen's envelope must say so: {envelope}"
);
std::fs::write(&gate, "").unwrap();
let after = bleats_no_follow_until_written(home, &["all"]);
assert_eq!(
after.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&after.stderr)
);
let stdout = String::from_utf8_lossy(&after.stdout);
assert!(
stdout.contains(ROTATE_AFTER),
"a rotated sheep's next line must reach the recreated path: stdout={stdout}"
);
assert!(
!stdout.contains(ROTATE_BEFORE),
"the recreated log starts empty — the first line belongs to the \
archive now: stdout={stdout}"
);
assert_eq!(
std::fs::read_to_string(&archive).unwrap(),
format!("{ROTATE_BEFORE}\n"),
"the renamed file must stop growing the moment the handle is swapped"
);
graceful_kill(home);
}
#[test]
fn a_reopen_that_cannot_open_a_path_again_exits_internal() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_logging_script(&dir, "blocked-out-marker", None);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("blocked")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let online = poll_flock(home, |info| info["status"] == "online");
let out_file = PathBuf::from(
online["out_file"]
.as_str()
.unwrap_or_else(|| panic!("the daemon reports its own log paths: {online}")),
);
std::fs::rename(&out_file, out_file.with_extension("log.1")).unwrap();
std::fs::create_dir(&out_file).unwrap();
let refused = shep(home)
.arg("reopen")
.arg("blocked")
.arg("--format")
.arg("json")
.output()
.unwrap();
assert_json_error(&refused, 9, "internal");
let err: serde_json::Value = serde_json::from_slice(&refused.stderr).unwrap();
let message = err["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains(out_file.to_str().unwrap()),
"the operator's one message must name the path that failed: {err}"
);
assert!(
message.contains(&format!("blocked (id {})", online["id"])),
"and the sheep it belongs to: {err}"
);
std::fs::remove_dir(&out_file).unwrap();
graceful_kill(home);
}
#[test]
fn an_external_copytruncate_leaves_the_next_line_at_offset_zero() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let gate = home.join("copied");
let script = write_rotating_script(&dir, &gate);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("truncated")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = bleats_no_follow_until_written(home, &["all"]);
let printed = String::from_utf8_lossy(&before.stdout);
assert!(
printed.contains(ROTATE_BEFORE),
"precondition: the sheep's first line must be readable before the \
rotation: stdout={printed}"
);
let online = poll_flock(home, |info| info["status"] == "online");
let out_file = PathBuf::from(
online["out_file"]
.as_str()
.unwrap_or_else(|| panic!("the daemon reports its own log paths: {online}")),
);
let archive = out_file.with_extension("log.1");
std::fs::copy(&out_file, &archive).unwrap();
std::fs::File::create(&out_file).unwrap();
assert_eq!(
std::fs::read_to_string(&archive).unwrap(),
format!("{ROTATE_BEFORE}\n"),
"sanity: the copy really took the line the truncate is about to drop"
);
assert_eq!(
std::fs::metadata(&out_file).unwrap().len(),
0,
"sanity: the truncate really emptied it"
);
std::fs::write(&gate, "").unwrap();
let after = bleats_no_follow_until_written(home, &["all"]);
assert_eq!(
after.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&after.stderr)
);
let stdout = String::from_utf8_lossy(&after.stdout);
assert!(
stdout.contains(ROTATE_AFTER),
"a truncated sheep must go on logging into the same file: stdout={stdout}"
);
assert_eq!(
std::fs::metadata(&out_file).unwrap().len(),
(ROTATE_AFTER.len() + 1) as u64,
"the sheep's next line must land at offset 0 of the emptied file: a \
handle that kept its offset across an external truncation would \
leave a hole the size of what was emptied in front of it, and \
`bleats` would print the line just the same"
);
graceful_kill(home);
}
#[test]
fn flush_empties_a_log_the_sheep_goes_on_appending_to() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let gate = home.join("flushed");
let script = write_rotating_script(&dir, &gate);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("flusher")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = bleats_no_follow_until_written(home, &["all"]);
let printed = String::from_utf8_lossy(&before.stdout);
assert!(
printed.contains(ROTATE_BEFORE),
"precondition: the sheep's first line must be readable before the \
flush: stdout={printed}"
);
let online = poll_flock(home, |info| info["status"] == "online");
let out_file = PathBuf::from(
online["out_file"]
.as_str()
.unwrap_or_else(|| panic!("the daemon reports its own log paths: {online}")),
);
let flushed = shep(home)
.arg("flush")
.arg("all")
.arg("--format")
.arg("json")
.output()
.unwrap();
assert_success(&flushed);
let envelope: serde_json::Value = serde_json::from_slice(&flushed.stdout).unwrap();
assert_eq!(
envelope["command"], "flush",
"a flush's envelope must say so: {envelope}"
);
std::fs::write(&gate, "").unwrap();
let after = bleats_no_follow_until_written(home, &["all"]);
assert_eq!(
after.status.code(),
Some(0),
"stderr={}",
String::from_utf8_lossy(&after.stderr)
);
let stdout = String::from_utf8_lossy(&after.stdout);
assert!(
stdout.contains(ROTATE_AFTER),
"a flushed sheep must go on logging into the same file: stdout={stdout}"
);
assert!(
!stdout.contains(ROTATE_BEFORE),
"everything written before the flush is gone: stdout={stdout}"
);
assert_eq!(
std::fs::metadata(&out_file).unwrap().len(),
(ROTATE_AFTER.len() + 1) as u64,
"the sheep's next line must land at offset 0 of the emptied file: a \
handle that kept its offset across the truncate would leave a hole \
the size of what was emptied in front of it, and `bleats` would \
print the line just the same"
);
graceful_kill(home);
}
#[test]
fn a_daemon_flush_and_a_flock_flush_never_reach_each_others_files() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let gate = home.join("flushed");
let script = write_rotating_script(&dir, &gate);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("flusher")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let online = poll_flock(home, |info| info["status"] == "online");
let out_file = PathBuf::from(
online["out_file"]
.as_str()
.unwrap_or_else(|| panic!("the daemon reports its own log paths: {online}")),
);
let before = bleats_no_follow_until_written(home, &["all"]);
assert!(
String::from_utf8_lossy(&before.stdout).contains(ROTATE_BEFORE),
"precondition: the sheep must have logged something to lose"
);
const MARKER: &[u8] = b"a line only the shepherd's own log holds\n";
let shepd_out = home.join("logs").join("shepd.out.log");
let shepd_err = home.join("logs").join("shepd.err.log");
std::fs::write(&shepd_out, MARKER).unwrap();
std::fs::write(&shepd_err, MARKER).unwrap();
let flock_half = shep(home).arg("flush").arg("all").output().unwrap();
assert_success(&flock_half);
assert_eq!(
std::fs::metadata(&out_file).unwrap().len(),
0,
"the flock half must still empty the sheep it named"
);
let printed = String::from_utf8_lossy(&flock_half.stdout);
assert!(
printed.contains(&out_file.display().to_string()),
"a flush table must name the files it emptied: {printed}"
);
assert_eq!(
std::fs::read(&shepd_out).unwrap(),
MARKER,
"a flock flush must not reach the shepherd's own stdout log"
);
assert_eq!(
std::fs::read(&shepd_err).unwrap(),
MARKER,
"a flock flush must not reach the shepherd's own stderr log"
);
std::fs::write(&gate, "").unwrap();
let after = bleats_no_follow_until_written(home, &["all"]);
assert!(
String::from_utf8_lossy(&after.stdout).contains(ROTATE_AFTER),
"the sheep must have written again before the --daemon flush"
);
let sheep_len = std::fs::metadata(&out_file).unwrap().len();
assert!(sheep_len > 0, "precondition: the sheep's log is not empty");
let daemon_half = shep(home)
.arg("flush")
.arg("--daemon")
.arg("--format")
.arg("json")
.output()
.unwrap();
assert_success(&daemon_half);
let envelope: serde_json::Value = serde_json::from_slice(&daemon_half.stdout).unwrap();
assert_eq!(envelope["command"], "flush", "{envelope}");
let files: Vec<&str> = envelope["data"]
.as_array()
.unwrap()
.iter()
.map(|row| row["file"].as_str().unwrap())
.collect();
assert!(
files.contains(&shepd_out.display().to_string().as_str())
&& files.contains(&shepd_err.display().to_string().as_str()),
"the answer must name both files it emptied: {envelope}"
);
assert_eq!(std::fs::metadata(&shepd_out).unwrap().len(), 0);
assert_eq!(std::fs::metadata(&shepd_err).unwrap().len(), 0);
assert_eq!(
std::fs::metadata(&out_file).unwrap().len(),
sheep_len,
"a --daemon flush must not reach any sheep's log"
);
graceful_kill(home);
}
#[test]
fn a_daemon_flush_needs_no_daemon() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let logs = home.join("logs");
std::fs::create_dir_all(&logs).unwrap();
std::fs::write(
logs.join("shepd.out.log"),
b"left behind by a dead shepherd",
)
.unwrap();
let flushed = shep(home).arg("flush").arg("--daemon").output().unwrap();
assert_success(&flushed);
assert_eq!(
std::fs::metadata(logs.join("shepd.out.log")).unwrap().len(),
0
);
assert!(
!home.join("run").join("shep.sock").exists(),
"this verb must not autostart a daemon to empty files the CLI owns"
);
}
#[test]
fn flush_without_a_selector_is_a_usage_error() {
let dir = tempfile::tempdir().unwrap();
let bare = shep(dir.path()).arg("flush").output().unwrap();
assert_eq!(
bare.status.code(),
Some(2),
"clap's usage exit code; stdout={}",
String::from_utf8_lossy(&bare.stdout)
);
assert!(
!dir.path().join("run").join("shep.sock").exists(),
"a usage error must not have autostarted a daemon"
);
}
#[cfg(unix)]
#[test]
fn home_reaches_the_spawned_daemon() {
let dir = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let output = Command::cargo_bin("shep")
.unwrap()
.args([
"--home",
dir.path().to_str().unwrap(),
"start",
script.to_str().unwrap(),
])
.env_remove("SHEP_HOME") .timeout(CMD_TIMEOUT) .output()
.unwrap();
guard.adopt_home(dir.path());
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let socket = dir.path().join("run").join("shep.sock");
assert!(
socket.exists(),
"the daemon bound somewhere other than --home"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn a_write_under_a_watched_tree_restarts_the_sheep() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let watched = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"watcher\"\nscript = '{}'\ncwd = '{}'\nwatch = true\n",
script.display(),
watched.path().display(),
),
);
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = poll_flock(home, |info| info["status"] == "online");
assert_eq!(before["restarts"], 0, "precondition: {before}");
std::fs::write(watched.path().join("app.txt"), "changed").unwrap();
let after = poll_flock(home, |info| info["restarts"] == 1);
assert_eq!(
after["restarts"], 1,
"a write under the watched tree must restart the sheep exactly once: {after}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn a_write_to_a_dot_file_under_a_watched_tree_restarts_nothing() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let watched = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"watcher\"\nscript = '{}'\ncwd = '{}'\nwatch = true\n",
script.display(),
watched.path().display(),
),
);
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = poll_flock(home, |info| info["status"] == "online");
assert_eq!(before["restarts"], 0, "precondition: {before}");
std::fs::write(watched.path().join(".hidden.swp"), "editor churn").unwrap();
let quiet = poll_flock(home, |_| false);
assert_eq!(
quiet["restarts"], 0,
"a dot-file is ignored by default and must not restart anything: {quiet}"
);
std::fs::write(watched.path().join("app.txt"), "changed").unwrap();
let after = poll_flock(home, |info| info["restarts"] == 1);
assert_eq!(
after["restarts"], 1,
"the watcher must have been armed and delivering all along: {after}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn a_wait_ready_sheep_goes_online_only_once_it_signals_ready() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let sentinel = dir.path().join("go");
let script = write_ready_script(&dir, &sentinel);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"gated\"\nscript = '{}'\nwait_ready = true\nlisten_timeout = \"120s\"\n",
script.display(),
),
);
let mut guard = DaemonGuard::default();
let boot = shep(home)
.arg("--format")
.arg("json")
.arg("start")
.arg(&flockfile)
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&boot);
let envelope: serde_json::Value = serde_json::from_slice(&boot.stdout).unwrap();
assert_eq!(
envelope["data"][0]["status"], "starting",
"a wait_ready sheep must not be online before it signals: {envelope}"
);
std::fs::write(&sentinel, "").unwrap();
let ready = poll_flock(home, |info| info["status"] == "online");
assert_eq!(
ready["status"], "online",
"the sheep must reach online once it writes ready to fd 3: {ready}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn a_bad_cron_pattern_is_a_config_error() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"crony\"\nscript = '{}'\ncron_restart = \"not a cron\"\n",
script.display(),
),
);
let mut guard = DaemonGuard::default();
let output = shep(home)
.arg("--format")
.arg("json")
.arg("start")
.arg(&flockfile)
.output()
.unwrap();
guard.adopt_home(home);
assert_json_error(&output, 4, "invalid_config");
let err: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
let message = err["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("not a cron"),
"the rejection must name the offending pattern: {err}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn an_https_probe_target_is_a_config_error() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"probed\"\nscript = '{}'\n\
readiness_probe = {{ kind = \"http\", target = \"https://localhost:8443/health\" }}\n",
script.display(),
),
);
let mut guard = DaemonGuard::default();
let output = shep(home)
.arg("--format")
.arg("json")
.arg("start")
.arg(&flockfile)
.output()
.unwrap();
guard.adopt_home(home);
assert_json_error(&output, 4, "invalid_config");
let err: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
let message = err["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("https://localhost:8443/health"),
"the rejection must name the offending target: {err}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn a_cron_occurrence_restarts_a_sheep_on_the_real_clock() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_slow_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"minutely\"\nscript = '{script}'\ncron_restart = \"* * * * *\"\n\n\
[[app]]\nname = \"unscheduled\"\nscript = '{script}'\n",
script = script.display(),
),
);
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = poll_flock_data(home, FLOCK_DEADLINE, |data| {
sheep_named(data, "minutely")["status"] == "online"
&& sheep_named(data, "unscheduled")["status"] == "online"
});
assert_eq!(
sheep_named(&before, "minutely")["restarts"],
0,
"precondition: {before}"
);
assert_eq!(
sheep_named(&before, "unscheduled")["restarts"],
0,
"precondition: {before}"
);
let after = poll_flock_data(home, CRON_DEADLINE, |data| {
sheep_named(data, "minutely")["restarts"] == 1
});
assert_eq!(
sheep_named(&after, "minutely")["restarts"],
1,
"a `* * * * *` occurrence must restart the sheep within one real minute: {after}"
);
assert_eq!(
sheep_named(&after, "unscheduled")["restarts"],
0,
"the same script with no cron_restart must not have moved: a restart both sheep \
share is the script exiting, not an occurrence firing: {after}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn a_real_memory_breach_restarts_a_sheep() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_ballooning_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"greedy\"\nscript = '{script}'\nmax_memory = \"{BREACH_LIMIT}\"\n\n\
[[app]]\nname = \"unlimited\"\nscript = '{script}'\n",
script = script.display(),
),
);
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
let before = poll_flock_data(home, FLOCK_DEADLINE, |data| {
sheep_named(data, "greedy")["status"] == "online"
&& sheep_named(data, "unlimited")["status"] == "online"
});
assert_eq!(
sheep_named(&before, "greedy")["restarts"],
0,
"precondition: {before}"
);
assert_eq!(
sheep_named(&before, "unlimited")["restarts"],
0,
"precondition: {before}"
);
let after = poll_flock_data(home, BREACH_DEADLINE, |data| {
sheep_named(data, "greedy")["restarts"] == 1
});
assert_eq!(
sheep_named(&after, "greedy")["restarts"],
1,
"a process tree over its max_memory must be restarted by the real enforcer: {after}"
);
assert_eq!(
sheep_named(&after, "unlimited")["restarts"],
0,
"the same script with no max_memory must not have moved: a restart both sheep \
share is the script dying, not its ceiling being enforced: {after}"
);
let daemon_log = std::fs::read_to_string(home.join("logs").join("shepd.err.log")).unwrap();
assert!(
daemon_log.contains("exceeded its max_memory"),
"the daemon's own log must say why the sheep was restarted: {daemon_log:?}"
);
assert!(
daemon_log.contains("limit="),
"the record must carry the ceiling that was crossed: {daemon_log:?}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn shep_log_json_makes_the_daemons_own_records_json() {
let dir = tempfile::tempdir().unwrap();
let log = daemon_log_after_a_missed_handshake(&dir, &[("SHEP_LOG_JSON", "1")]);
let lines: Vec<&str> = log.lines().filter(|line| !line.trim().is_empty()).collect();
assert!(
!lines.is_empty(),
"the daemon must have written something to read: {log:?}"
);
let records: Vec<serde_json::Value> = lines
.iter()
.map(|line| {
serde_json::from_str(line).unwrap_or_else(|err| {
panic!("every line of shepd.err.log must be JSON under log_json: {line:?} ({err})")
})
})
.collect();
assert!(
records.iter().any(|record| {
record["level"] == "WARN"
&& record["fields"]["message"]
.as_str()
.is_some_and(|message| message.contains(READINESS_RECORD))
}),
"the readiness record must survive as a JSON object with its level and \
message intact: {records:?}"
);
}
#[cfg(unix)]
#[test]
fn the_daemons_own_log_carries_no_ansi_escapes() {
let dir = tempfile::tempdir().unwrap();
let log = daemon_log_after_a_missed_handshake(&dir, &[]);
assert!(
log.contains(READINESS_RECORD),
"precondition: the daemon must have written a record to colour: {log:?}"
);
assert!(
!log.contains('\x1b'),
"a log file is not a terminal: {log:?}"
);
}
#[cfg(unix)]
#[test]
fn shep_log_level_decides_which_of_the_daemons_records_survive() {
let at_default = tempfile::tempdir().unwrap();
let default_log = daemon_log_after_a_missed_handshake(&at_default, &[]);
assert!(
default_log.contains(READINESS_RECORD),
"a warn-level record must reach the log at the default level: {default_log:?}"
);
let at_error = tempfile::tempdir().unwrap();
let error_log = daemon_log_after_a_missed_handshake(&at_error, &[("SHEP_LOG_LEVEL", "error")]);
assert!(
!error_log.contains(READINESS_RECORD),
"SHEP_LOG_LEVEL=error must filter out the same warn-level record the \
default level lets through: {error_log:?}"
);
}
#[cfg(unix)]
#[test]
fn starting_an_errored_sheep_by_name_reports_the_same_failure_as_by_path() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("broken.sh");
std::fs::write(&script, "#!/bin/sh\necho hi\n").unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&script, perms).unwrap();
let mut guard = DaemonGuard::default();
let by_path = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_json_error(&by_path, 7, "spawn_failed");
let flock = poll_flock(dir.path(), |info| info["status"] == "errored");
assert_eq!(
flock["status"], "errored",
"the by-path failure must leave the sheep registered as errored: {flock}"
);
let name = script.file_stem().unwrap().to_str().unwrap();
let by_name = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(name)
.output()
.unwrap();
assert_json_error(&by_name, 7, "spawn_failed");
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn restarting_a_sheep_that_cannot_spawn_reports_it_rather_than_exiting_zero() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("noexec");
std::fs::write(&script, "#!/bin/sh\nsleep 30\n").unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&script, perms).unwrap();
let mut guard = DaemonGuard::default();
let by_path = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_json_error(&by_path, 7, "spawn_failed");
let flock = poll_flock(dir.path(), |info| info["status"] == "errored");
assert_eq!(
flock["status"], "errored",
"the by-path failure must leave the sheep registered as errored: {flock}"
);
let name = script.file_stem().unwrap().to_str().unwrap();
let restarted = shep(dir.path())
.arg("--format")
.arg("json")
.arg("restart")
.arg(name)
.output()
.unwrap();
assert_json_error(&restarted, 7, "spawn_failed");
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn a_js_flockfile_without_node_says_so_and_says_what_to_do() {
let dir = tempfile::tempdir().unwrap();
let flockfile = dir.path().join("Flockfile.js");
std::fs::write(
&flockfile,
"module.exports = { app: [{ name: 'web', script: './server.js' }] };\n",
)
.unwrap();
let mut guard = DaemonGuard::default();
let output = shep(dir.path())
.env("PATH", "")
.arg("start")
.arg("--flockfile")
.arg(&flockfile)
.output()
.unwrap();
guard.adopt_home(dir.path());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"a Flockfile that cannot be read must not succeed: {stderr}"
);
assert!(
stderr.contains("node was not found on PATH"),
"the message names the cause: {stderr}"
);
assert!(
stderr.contains("install node, or convert"),
"and what to do about it: {stderr}"
);
assert!(
!stderr.contains('\u{2014}') && !stderr.contains('\u{2013}'),
"no em or en dash in copy a user reads: {stderr}"
);
graceful_kill(dir.path());
}
#[test]
fn shep_init_writes_a_flockfile_where_there_is_none() {
let dir = tempfile::tempdir().unwrap();
let output = shep(dir.path())
.current_dir(dir.path())
.arg("init")
.output()
.unwrap();
assert_success(&output);
let written = dir.path().join("Flockfile.toml");
assert!(written.exists(), "shep init must write Flockfile.toml");
let body = std::fs::read_to_string(&written).unwrap();
assert!(
body.contains("[[app]]"),
"the scaffold shows an app entry: {body}"
);
assert!(
body.lines().any(|l| l.trim_start().starts_with('#')),
"and it arrives commented out: {body}"
);
}
#[cfg(unix)]
#[test]
fn what_shep_init_writes_is_a_flockfile_shep_can_read() {
let dir = tempfile::tempdir().unwrap();
shep(dir.path())
.current_dir(dir.path())
.arg("init")
.output()
.unwrap();
let body = std::fs::read_to_string(dir.path().join("Flockfile.toml")).unwrap();
let live: String = body
.lines()
.map(|line| {
let trimmed = line.trim_start();
match trimmed.strip_prefix('#') {
Some(rest) if !rest.starts_with(' ') => rest.to_string(),
_ => line.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.path().join("Flockfile.toml"), &live).unwrap();
let mut guard = DaemonGuard::default();
let output = shep(dir.path())
.current_dir(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg("--flockfile")
.arg("Flockfile.toml")
.output()
.unwrap();
guard.adopt_home(dir.path());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("invalid_config"),
"the uncommented scaffold must be valid config: {stderr}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn shep_init_refuses_an_existing_flockfile_without_touching_it() {
let dir = tempfile::tempdir().unwrap();
let existing = dir.path().join("Flockfile.toml");
std::fs::write(
&existing,
"# mine\n[[app]]\nname = \"web\"\nscript = \"./s\"\n",
)
.unwrap();
let before = std::fs::metadata(&existing).unwrap();
let output = shep(dir.path())
.current_dir(dir.path())
.arg("init")
.output()
.unwrap();
assert!(
!output.status.success(),
"an existing Flockfile must not be overwritten silently"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Flockfile.toml"),
"the refusal names the file: {stderr}"
);
let after = std::fs::metadata(&existing).unwrap();
assert_eq!(
before.ino(),
after.ino(),
"a refused write must not replace the file"
);
assert_eq!(
before.permissions().mode(),
after.permissions().mode(),
"nor change its mode"
);
assert_eq!(
std::fs::read_to_string(&existing).unwrap(),
"# mine\n[[app]]\nname = \"web\"\nscript = \"./s\"\n",
"nor its contents"
);
}
#[test]
fn shep_init_force_replaces_an_existing_flockfile() {
let dir = tempfile::tempdir().unwrap();
let existing = dir.path().join("Flockfile.toml");
std::fs::write(&existing, "# mine\n").unwrap();
let output = shep(dir.path())
.current_dir(dir.path())
.arg("init")
.arg("--force")
.output()
.unwrap();
assert_success(&output);
let body = std::fs::read_to_string(&existing).unwrap();
assert!(
body.contains("[[app]]"),
"--force writes the scaffold over what was there: {body}"
);
}
#[test]
fn shep_init_all_writes_the_full_scaffold() {
let dir = tempfile::tempdir().unwrap();
let output = shep(dir.path())
.current_dir(dir.path())
.arg("init")
.arg("--all")
.output()
.unwrap();
assert_success(&output);
let body = std::fs::read_to_string(dir.path().join("Flockfile.toml")).unwrap();
for field in ["max_restarts", "kill_timeout", "watch_delay"] {
assert!(
body.contains(field),
"--all names every option, and is missing `{field}`"
);
}
}
#[cfg(unix)]
#[test]
fn reload_swaps_a_sheep_for_a_fresh_instance_under_a_new_id() {
let dir = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&started);
let envelope: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap();
let original_id = envelope["data"][0]["id"]
.as_u64()
.unwrap_or_else(|| panic!("a started sheep must carry an id: {envelope}"));
let reloaded = shep(dir.path())
.arg("--format")
.arg("json")
.arg("reload")
.arg("sheep")
.output()
.unwrap();
assert_success(&reloaded);
let envelope: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap();
assert_eq!(
envelope["command"], "reload",
"`shep reload` must reach the reload verb and no other: {envelope}"
);
assert_eq!(
envelope["data"][0]["id"], original_id,
"the answer is the flock as it stood when the reload was accepted: {envelope}"
);
let after = poll_flock(dir.path(), |info| info["id"] != original_id);
assert_ne!(
after["id"], original_id,
"the swap must finish, leaving one entry under a new id: {after}"
);
assert_eq!(after["status"], "online", "{after}");
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn trigger_reaches_the_trigger_verb_and_names_the_missing_channel() {
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 triggered = shep(dir.path())
.arg("--format")
.arg("json")
.arg("trigger")
.arg("sheep")
.arg("reload-config")
.output()
.unwrap();
assert_success(&triggered);
let envelope: serde_json::Value = serde_json::from_slice(&triggered.stdout).unwrap();
assert_eq!(
envelope["command"], "trigger",
"`shep trigger` must reach the trigger verb and no other: {envelope}"
);
assert_eq!(envelope["data"][0]["name"], "sheep", "{envelope}");
assert_eq!(
envelope["data"][0]["outcome"]["kind"], "no_channel",
"a sheep with no channel/wait_ready/shutdown_with_message must answer \
no_channel, never a reply it never opened a pipe to receive: {envelope}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn signal_reaches_the_signal_verb_and_delivers() {
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 signalled = shep(dir.path())
.arg("--format")
.arg("json")
.arg("signal")
.arg("sheep")
.arg("SIGWINCH")
.output()
.unwrap();
assert_success(&signalled);
let envelope: serde_json::Value = serde_json::from_slice(&signalled.stdout).unwrap();
assert_eq!(
envelope["command"], "signal",
"`shep signal` must reach the signal verb and no other: {envelope}"
);
assert_eq!(envelope["data"][0]["name"], "sheep", "{envelope}");
assert_eq!(
envelope["data"][0]["outcome"]["kind"], "delivered",
"a running sheep must answer delivered for a signal the kernel accepted: {envelope}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn stock_reaches_the_stock_verb_and_settles_the_flock() {
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 stocked_up = shep(dir.path())
.arg("--format")
.arg("json")
.arg("stock")
.arg("sheep")
.arg("3")
.output()
.unwrap();
assert_success(&stocked_up);
let envelope: serde_json::Value = serde_json::from_slice(&stocked_up.stdout).unwrap();
assert_eq!(
envelope["command"], "stock",
"`shep stock` must reach the stock verb and no other: {envelope}"
);
let grown = poll_flock_data(dir.path(), FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(|rows| rows.len() == 3)
});
assert_eq!(
grown.as_array().unwrap().len(),
3,
"stocking up must settle at three instances: {grown}"
);
assert!(
grown
.as_array()
.unwrap()
.iter()
.all(|row| row["name"] == "sheep"),
"every instance must still belong to `sheep`: {grown}"
);
let stocked_down = shep(dir.path())
.arg("stock")
.arg("sheep")
.arg("1")
.output()
.unwrap();
assert_success(&stocked_down);
let settled = poll_flock_data(dir.path(), FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(|rows| rows.len() == 1)
});
assert_eq!(
settled.as_array().unwrap().len(),
1,
"stocking down must settle back to one instance: {settled}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn scale_alias_reaches_stock_against_a_real_daemon() {
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 scaled = shep(dir.path())
.arg("--format")
.arg("json")
.arg("scale")
.arg("sheep")
.arg("2")
.output()
.unwrap();
assert_success(&scaled);
let envelope: serde_json::Value = serde_json::from_slice(&scaled.stdout).unwrap();
assert_eq!(
envelope["command"], "stock",
"`shep scale` is an alias for `stock`, and must reach it: {envelope}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn describe_renders_a_real_sheeps_lamb_tree() {
let dir = tempfile::tempdir().unwrap();
let script = write_forking_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 start = Instant::now();
let described = loop {
let output = shep(dir.path())
.arg("describe")
.arg("sheep")
.output()
.unwrap();
assert_success(&output);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if text.contains("sleep") || start.elapsed() >= FLOCK_DEADLINE {
break text;
}
std::thread::sleep(FLOCK_POLL_INTERVAL);
};
assert!(described.contains("Lambs of"), "{described}");
assert!(described.contains("sleep"), "{described}");
assert!(
described.contains("not exactly the set a stop kills"),
"{described}"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn saving_the_roll_then_mustering_reports_the_same_flock() {
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("--format")
.arg("json")
.arg("start")
.arg(&script)
.arg("--name")
.arg("roundtrip")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&started);
let start_envelope: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap();
assert_eq!(
start_envelope["data"][0]["status"], "online",
"{start_envelope}"
);
let original_pid = start_envelope["data"][0]["pid"]
.as_i64()
.unwrap_or_else(|| panic!("pid must be a real positive OS pid: {start_envelope}"));
let saved = shep(home)
.arg("--format")
.arg("json")
.arg("save")
.output()
.unwrap();
assert_success(&saved);
let save_envelope: serde_json::Value = serde_json::from_slice(&saved.stdout).unwrap();
assert_eq!(
save_envelope["command"], "save",
"`shep save` must reach the save verb and no other: {save_envelope}"
);
assert_eq!(
save_envelope["data"]["apps"], 1,
"the roll must record the one app started above: {save_envelope}"
);
let mustered = shep(home)
.arg("--format")
.arg("json")
.arg("muster")
.output()
.unwrap();
assert_success(&mustered);
let muster_envelope: serde_json::Value = serde_json::from_slice(&mustered.stdout).unwrap();
assert_eq!(
muster_envelope["command"], "muster",
"`shep muster` must reach the muster verb and no other: {muster_envelope}"
);
let flock = muster_envelope["data"]
.as_array()
.unwrap_or_else(|| panic!("muster data must be an array: {muster_envelope}"));
assert_eq!(
flock.len(),
1,
"muster against a daemon already running the flock the roll \
describes must not spawn a duplicate: {muster_envelope}"
);
assert_eq!(flock[0]["name"], "roundtrip", "{muster_envelope}");
assert_eq!(
flock[0]["pid"].as_i64().unwrap(),
original_pid,
"muster must leave an already-running sheep alone and report the \
SAME process, never restart it: {muster_envelope}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn import_writes_a_flockfile_shep_can_read_back_and_starts_no_daemon() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let out = home.join("Flockfile.toml");
let dump = concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/commands/import/testdata/dump.pm2.json"
);
let mut guard = DaemonGuard::default();
let output = shep(home)
.arg("--format")
.arg("json")
.arg("import")
.arg("--from")
.arg(dump)
.arg("--out")
.arg(&out)
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&output);
assert!(
!home.join("run").join("shep.sock").exists(),
"`shep import` reads a file and writes a file; it must never \
autostart a daemon"
);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
envelope["command"], "import",
"`shep import` must reach the import verb and no other: {envelope}"
);
let rows = envelope["data"]
.as_array()
.unwrap_or_else(|| panic!("import data must be an array: {envelope}"));
assert_eq!(rows.len(), 3, "{envelope}");
let written = std::fs::read_to_string(&out).unwrap();
let parsed =
shep_core::config::Flockfile::parse(&written, shep_core::config::FlockFormat::Toml)
.unwrap_or_else(|e| {
panic!("shep import wrote a Flockfile shep cannot read back: {e}\n{written}")
});
assert_eq!(parsed.apps.len(), 3, "{written}");
}
fn write_shep_toml(dir: &TempDir, body: &str) -> PathBuf {
let path = dir.path().join("shep.toml");
std::fs::write(&path, body).unwrap();
path
}
#[cfg(unix)]
#[test]
fn a_real_shepherd_runs_a_real_metrics_dog_that_answers_a_scrape() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let port = free_port();
write_shep_toml(
&dir,
&format!("[dog.metrics]\nbind = \"127.0.0.1:{port}\"\n"),
);
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("web")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&started);
let online = poll_flock(home, |info| info["status"] == "online");
assert_eq!(
online["status"], "online",
"the sheep must reach online before the dog's own exposition has \
anything real to name: {online}"
);
let enabled = shep(home).arg("enable").arg("metrics").output().unwrap();
assert_success(&enabled);
let dog_pid = wait_for_dog_pid(home, "metrics");
guard.adopt_dog_pid(dog_pid);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let body = poll_metrics(addr);
assert!(
body.contains("HTTP/1.1 200"),
"the metrics dog must answer 200 at /metrics: {body}"
);
assert!(
body.contains(r#"shep_sheep_status{sheep="web",id="0",fold="",status="online"} 1"#),
"the exposition must name the sheep, online: {body}"
);
assert!(
body.contains(r#"shep_dog_up{dog="metrics",source="built-in"} 1"#),
"the dog must report itself up while it is the one serving the \
scrape that says so: {body}"
);
graceful_kill(home);
}
#[cfg(unix)]
#[test]
fn dogs_and_flock_render_the_two_populations_the_right_way_round() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let port = free_port();
write_shep_toml(
&dir,
&format!("[dog.metrics]\nbind = \"127.0.0.1:{port}\"\n"),
);
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(home)
.arg("start")
.arg(&script)
.arg("--name")
.arg("web")
.output()
.unwrap();
guard.adopt_home(home);
assert_success(&started);
poll_flock(home, |info| info["status"] == "online");
let enabled = shep(home).arg("enable").arg("metrics").output().unwrap();
assert_success(&enabled);
guard.adopt_dog_pid(wait_for_dog_pid(home, "metrics"));
let flock_table = String::from_utf8(shep(home).arg("flock").output().unwrap().stdout).unwrap();
assert!(
flock_table.contains("web"),
"shep flock must still render the sheep: {flock_table}"
);
assert!(
flock_table.contains("Dogs") && flock_table.contains("metrics"),
"shep flock must render the dogs section beneath the sheep table: {flock_table}"
);
let dogs_table = String::from_utf8(shep(home).arg("dogs").output().unwrap().stdout).unwrap();
assert!(
dogs_table.contains("metrics"),
"shep dogs must render the dog: {dogs_table}"
);
assert!(
!dogs_table.contains("web"),
"shep dogs must render nothing but dogs — not the sheep: {dogs_table}"
);
assert!(
!dogs_table.contains("Dogs\n"),
"shep dogs must not carry flock's own section header — it IS the \
dogs table, not a listing with one embedded: {dogs_table}"
);
graceful_kill(home);
}
#[test]
fn barks_reads_the_history_with_no_shepherd_running() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let bark = shep_core::barks::Bark {
at_ms: 1_700_000_000_000,
rule: "watchdog".to_string(),
subject: "web".to_string(),
message: "restart budget exhausted".to_string(),
sinks: vec![shep_core::barks::SinkOutcome {
sink: "ops".to_string(),
error: None,
}],
};
shep_core::barks::append(
&home.join("barks.jsonl"),
&bark,
shep_core::barks::DEFAULT_MAX_BYTES,
)
.unwrap();
assert!(
!home.join("run").join("shep.sock").exists(),
"this case never starts a daemon at all"
);
let output = shep(home)
.arg("--format")
.arg("json")
.arg("barks")
.output()
.unwrap();
assert_success(&output);
assert!(
!home.join("run").join("shep.sock").exists(),
"`shep barks` must never autostart a shepherd either"
);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
envelope["command"], "barks",
"`shep barks` must reach the barks verb and no other: {envelope}"
);
let rows = envelope["data"]
.as_array()
.unwrap_or_else(|| panic!("barks data must be an array: {envelope}"));
assert_eq!(rows.len(), 1, "{envelope}");
assert_eq!(rows[0]["subject"], "web", "{envelope}");
assert_eq!(rows[0]["rule"], "watchdog", "{envelope}");
}
#[cfg(unix)]
#[test]
fn the_kv_store_works_with_no_shepherd_running() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let set1 = shep(home)
.arg("set")
.arg("bark.cooldown")
.arg("30s")
.output()
.unwrap();
assert_success(&set1);
assert!(
!home.join("run").join("shep.sock").exists(),
"shep set must never autostart a shepherd"
);
let mode = std::fs::metadata(home.join("kv.json"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "{mode:o}");
let get1 = shep(home).arg("get").arg("bark.cooldown").output().unwrap();
assert_success(&get1);
assert!(
String::from_utf8_lossy(&get1.stdout).contains("30s"),
"{}",
String::from_utf8_lossy(&get1.stdout)
);
let missing = shep(home).arg("get").arg("missing").output().unwrap();
assert_eq!(missing.status.code(), Some(3), "NotFound; {missing:?}");
let set2 = shep(home)
.arg("set")
.arg("metrics_port")
.arg("9615")
.output()
.unwrap();
assert_success(&set2);
let both = shep(home).arg("get").output().unwrap();
assert_success(&both);
let both_text = String::from_utf8_lossy(&both.stdout);
assert!(both_text.contains("bark.cooldown"), "{both_text}");
assert!(both_text.contains("metrics_port"), "{both_text}");
let unset1 = shep(home)
.arg("unset")
.arg("bark.cooldown")
.output()
.unwrap();
assert_success(&unset1);
let gone = shep(home).arg("get").arg("bark.cooldown").output().unwrap();
assert_eq!(gone.status.code(), Some(3), "NotFound; {gone:?}");
let unset_all = shep(home).arg("unset").arg("--all").output().unwrap();
assert_success(&unset_all);
let empty = shep(home).arg("get").output().unwrap();
assert_success(&empty);
let empty_text = String::from_utf8_lossy(&empty.stdout);
assert!(
!empty_text.contains("metrics_port"),
"store must be empty after unset --all: {empty_text}"
);
let bad_key = shep(home)
.arg("set")
.arg("bad key")
.arg("x")
.output()
.unwrap();
assert_eq!(bad_key.status.code(), Some(2), "usage; {bad_key:?}");
}
#[test]
fn kv_json_envelope_is_an_array_with_the_schema_version() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
shep(home).arg("set").arg("a").arg("1").output().unwrap();
shep(home).arg("set").arg("b").arg("2").output().unwrap();
let get_all = shep(home)
.arg("--format")
.arg("json")
.arg("get")
.output()
.unwrap();
assert_success(&get_all);
let envelope: serde_json::Value = serde_json::from_slice(&get_all.stdout).unwrap();
assert!(envelope["data"].is_array(), "{envelope}");
assert_eq!(envelope["data"].as_array().unwrap().len(), 2, "{envelope}");
assert_eq!(envelope["schema_version"], 1, "{envelope}");
let missing = shep(home)
.arg("--format")
.arg("json")
.arg("get")
.arg("ghost")
.output()
.unwrap();
assert_json_error(&missing, 3, "not_found");
let bad_key = shep(home)
.arg("--format")
.arg("json")
.arg("set")
.arg("bad key")
.arg("x")
.output()
.unwrap();
assert_json_error(&bad_key, 2, "usage");
}
#[test]
fn two_real_shep_processes_writing_concurrently_lose_no_keys() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_path_buf();
const PER_WRITER: usize = 15;
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let (finished, racers) = std::sync::mpsc::channel();
for writer in 0..2 {
let home = home.clone();
let barrier = std::sync::Arc::clone(&barrier);
let finished = finished.clone();
std::thread::spawn(move || {
barrier.wait(); for n in 0..PER_WRITER {
let key = format!("writer{writer}.k{n}");
let output = shep(&home).arg("set").arg(&key).arg("v").output().unwrap();
let _ = finished.send((writer, key, output));
}
});
}
drop(finished);
for _ in 0..(PER_WRITER * 2) {
let (writer, key, output) = racers
.recv_timeout(RACER_DEADLINE)
.expect("a writer never came back; see RACER_DEADLINE");
assert!(
output.status.success(),
"writer {writer}, key {key}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let list = shep(&home)
.arg("--format")
.arg("json")
.arg("get")
.output()
.unwrap();
assert_success(&list);
let envelope: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap();
let data = envelope["data"]
.as_array()
.unwrap_or_else(|| panic!("get data must be an array: {envelope}"));
assert_eq!(
data.len(),
PER_WRITER * 2,
"two concurrent shep set processes must not lose each other's keys: {envelope}"
);
}
#[test]
fn shep_lookout_refuses_when_stdout_is_not_a_terminal() {
let home = TempDir::new().unwrap();
let output = shep(home.path())
.arg("lookout")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("needs a terminal"));
}
#[test]
fn shep_dash_is_the_same_verb() {
let home = TempDir::new().unwrap();
let output = shep(home.path())
.arg("dash")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("needs a terminal")
);
}
#[test]
fn shep_lookout_help_names_the_gate() {
let home = TempDir::new().unwrap();
let output = shep(home.path())
.args(["lookout", "--help"])
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0));
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("--allow-control"));
assert!(text.contains("security boundary"));
}
fn push_mcp_line(buf: &mut Vec<u8>, value: &serde_json::Value) {
buf.extend_from_slice(value.to_string().as_bytes());
buf.push(b'\n');
}
fn mcp_session(requests: &[serde_json::Value]) -> Vec<u8> {
let mut buf = Vec::new();
push_mcp_line(
&mut buf,
&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "cli_e2e", "version": "0.0.0"},
},
}),
);
push_mcp_line(
&mut buf,
&serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
);
for request in requests {
push_mcp_line(&mut buf, request);
}
buf
}
fn tools_list_request(id: i64) -> serde_json::Value {
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"})
}
fn call_tool_request(
id: i64,
name: &str,
arguments: Option<serde_json::Value>,
) -> serde_json::Value {
let mut params = serde_json::json!({"name": name});
if let Some(args) = arguments {
params
.as_object_mut()
.expect("params is always an object")
.insert("arguments".to_string(), args);
}
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": params})
}
fn assert_every_stdout_line_is_jsonrpc(stdout: &[u8]) -> Vec<serde_json::Value> {
let text = String::from_utf8(stdout.to_vec()).expect("whistle's stdout is valid UTF-8");
text.lines()
.map(|line| {
let value: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|err| panic!("stdout line is not JSON: {err}\nline: {line}"));
assert_eq!(
value.get("jsonrpc").and_then(serde_json::Value::as_str),
Some("2.0"),
"stdout line is not JSON-RPC: {line}"
);
value
})
.collect()
}
fn find_reply(lines: &[serde_json::Value], id: i64) -> &serde_json::Value {
lines
.iter()
.find(|line| {
line.get("id") == Some(&serde_json::Value::from(id))
&& (line.get("result").is_some() || line.get("error").is_some())
})
.unwrap_or_else(|| panic!("no reply with id {id} in {lines:#?}"))
}
fn shep_via_env(home: &Path) -> Command {
let mut cmd = Command::cargo_bin("shep").unwrap();
cmd.env("SHEP_HOME", home).timeout(CMD_TIMEOUT);
cmd
}
fn whistle_tool_names(mut cmd: Command) -> Vec<String> {
let stdin = mcp_session(&[tools_list_request(2)]);
let output = cmd.arg("whistle").write_stdin(stdin).output().unwrap();
assert_success(&output);
let lines = assert_every_stdout_line_is_jsonrpc(&output.stdout);
find_reply(&lines, 2)["result"]["tools"]
.as_array()
.expect("tools/list result carries a tools array")
.iter()
.map(|tool| {
tool["name"]
.as_str()
.expect("every tool has a name")
.to_string()
})
.collect()
}
#[test]
fn whistle_speaks_mcp_and_writes_nothing_else_to_stdout() {
let home = TempDir::new().unwrap();
let stdin = mcp_session(&[tools_list_request(2)]);
let output = shep(home.path())
.arg("whistle")
.write_stdin(stdin)
.output()
.unwrap();
assert_success(&output);
let lines = assert_every_stdout_line_is_jsonrpc(&output.stdout);
let init_reply = find_reply(&lines, 1);
assert_eq!(init_reply["result"]["serverInfo"]["name"], "shep");
assert!(init_reply["result"]["capabilities"]["tools"].is_object());
let list_reply = find_reply(&lines, 2);
assert!(list_reply["result"]["tools"].is_array());
}
#[test]
fn the_shep_toml_gate_decides_the_tool_list_in_a_real_process() {
let control_tools = ["start_sheep", "stop_sheep", "restart_sheep", "reload_sheep"];
let closed_home = TempDir::new().unwrap();
let names = whistle_tool_names(shep_via_env(closed_home.path()));
assert_eq!(names.len(), 5, "read-only: {names:?}");
for tool in control_tools {
assert!(
!names.contains(&tool.to_string()),
"{tool} must be absent: {names:?}"
);
}
let open_home = TempDir::new().unwrap();
write_shep_toml(&open_home, "[whistle]\nallow_control = true\n");
let names = whistle_tool_names(shep_via_env(open_home.path()));
assert_eq!(names.len(), 9, "gate open via env: {names:?}");
for tool in control_tools {
assert!(
names.contains(&tool.to_string()),
"{tool} must be present: {names:?}"
);
}
let names = whistle_tool_names(shep(open_home.path()));
assert_eq!(names.len(), 9, "gate open via --home: {names:?}");
for tool in control_tools {
assert!(
names.contains(&tool.to_string()),
"{tool} must be present: {names:?}"
);
}
}
#[test]
fn a_malformed_shep_toml_stays_off_stdout_and_keeps_the_gate_shut() {
let home = TempDir::new().unwrap();
write_shep_toml(&home, "[whistle\n");
let stdin = mcp_session(&[tools_list_request(2)]);
let output = shep(home.path())
.arg("whistle")
.write_stdin(stdin)
.output()
.unwrap();
assert_success(&output);
let lines = assert_every_stdout_line_is_jsonrpc(&output.stdout);
let list_reply = find_reply(&lines, 2);
let names: Vec<String> = list_reply["result"]["tools"]
.as_array()
.expect("tools/list result carries a tools array")
.iter()
.map(|tool| {
tool["name"]
.as_str()
.expect("every tool has a name")
.to_string()
})
.collect();
assert_eq!(
names.len(),
5,
"a broken config must read as the gate SHUT, not open: {names:?}"
);
for tool in ["start_sheep", "stop_sheep", "restart_sheep", "reload_sheep"] {
assert!(
!names.contains(&tool.to_string()),
"{tool} must be absent when shep.toml fails to parse: {names:?}"
);
}
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid_config"),
"the malformed-config notice must reach stderr: {stderr}"
);
assert!(
stderr.contains("shep.toml"),
"the notice must name the file: {stderr}"
);
}
#[test]
fn a_gated_off_control_tool_is_not_merely_refused_it_is_absent() {
let home = TempDir::new().unwrap();
let stdin = mcp_session(&[call_tool_request(
2,
"stop_sheep",
Some(serde_json::json!({"name": "api"})),
)]);
let output = shep(home.path())
.arg("whistle")
.write_stdin(stdin)
.output()
.unwrap();
assert_success(&output);
let lines = assert_every_stdout_line_is_jsonrpc(&output.stdout);
let reply = find_reply(&lines, 2);
assert!(
reply.get("result").is_none(),
"a gated-off tool must be a protocol error, not a result: {reply:#?}"
);
let error = reply
.get("error")
.expect("a gated-off tool call must answer a JSON-RPC error");
assert_eq!(error["code"], -32602);
assert_eq!(error["message"], "tool not found");
}
#[test]
fn whistle_starts_with_no_shepherd_and_reports_it_per_call() {
let home = TempDir::new().unwrap();
let stdin = mcp_session(&[call_tool_request(2, "list_flock", None)]);
let output = shep(home.path())
.arg("whistle")
.write_stdin(stdin)
.output()
.unwrap();
assert_success(&output);
let lines = assert_every_stdout_line_is_jsonrpc(&output.stdout);
let init_reply = find_reply(&lines, 1);
assert_eq!(init_reply["result"]["serverInfo"]["name"], "shep");
assert!(init_reply["result"]["capabilities"]["tools"].is_object());
let call_reply = find_reply(&lines, 2);
assert_eq!(call_reply["result"]["isError"], true);
let message = call_reply["result"]["structuredContent"]["message"]
.as_str()
.expect("a no-shepherd refusal carries a message");
assert!(
message.contains("no shepherd is running"),
"message: {message}"
);
}
#[test]
fn available_dogs_lists_the_index_and_never_leaks_a_raw_escape() {
let home = TempDir::new().unwrap();
let url = serve_dog_index(&two_entry_index_json());
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.output()
.unwrap();
assert_success(&output);
assert!(
!output.stdout.contains(&0x1b),
"a raw escape reached stdout: {:?}",
String::from_utf8_lossy(&output.stdout)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"NAME",
"PACKAGE",
"CATEGORY",
"DESCRIPTION",
"Spot",
"shep-log-rotate",
"logs",
"Rex",
"shep-watchdog",
"health",
] {
assert!(
stdout.contains(expected),
"table is missing {expected:?}: {stdout}"
);
}
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("1 entry contained control characters"),
"stderr must note the sanitised entry: {stderr}"
);
}
#[test]
fn available_dogs_detail_view_uses_adopt_as_never_name() {
let home = TempDir::new().unwrap();
let url = serve_dog_index(&two_entry_index_json());
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.arg("spot")
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Spot . shep-log-rotate . logs"),
"detail header line: {stdout}"
);
assert!(
stdout.contains("$ cargo install --git https://github.com/TurtIeSocks/shep-log-rotate"),
"install command: {stdout}"
);
assert!(
stdout.contains("$ shep adopt ~/.cargo/bin/shep-log-rotate --name log-rotate"),
"adopt command must use adopt_as (log-rotate), not name (Spot): {stdout}"
);
assert!(
!stdout.contains("--name Spot"),
"adopt command must never use the display name: {stdout}"
);
}
#[test]
fn available_dogs_zero_matches_exits_zero_and_says_so() {
let home = TempDir::new().unwrap();
let url = serve_dog_index(&two_entry_index_json());
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.arg("wombat")
.output()
.unwrap();
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("no dog matches \"wombat\""),
"stdout: {stdout}"
);
}
#[test]
fn available_dogs_needs_no_shepherd() {
let home = TempDir::new().unwrap();
let url = serve_dog_index(&two_entry_index_json());
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.output()
.unwrap();
assert_success(&output);
assert!(
!home.path().join("run").join("shep.sock").exists(),
"--available must never bring up a shepherd"
);
assert!(
!home.path().join("pids").join("shepd.pid").exists(),
"--available must never bring up a shepherd"
);
}
#[test]
fn available_dogs_reports_a_server_error_naming_the_url() {
let home = TempDir::new().unwrap();
let url = serve_raw_response(
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n".to_string(),
);
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.output()
.unwrap();
assert!(
!output.status.success(),
"a 500 must not exit success: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(&format!("reading the dog index from {url}")),
"stderr must name the failing url: {stderr}"
);
assert!(stderr.contains("500"), "stderr: {stderr}");
}
#[test]
fn available_dogs_reports_a_truncated_body_naming_the_url() {
let home = TempDir::new().unwrap();
let url = serve_raw_response("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n[]".to_string());
let output = shep(home.path())
.env("SHEP_DOG_INDEX", &url)
.arg("dogs")
.arg("--available")
.output()
.unwrap();
assert!(
!output.status.success(),
"a truncated body must not exit success: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(&format!("reading the dog index from {url}")),
"stderr must name the failing url: {stderr}"
);
assert!(stderr.contains("truncated"), "stderr: {stderr}");
}
#[cfg(unix)]
#[test]
fn serve_registers_a_sheep_that_answers_on_its_port() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("site");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("index.html"), "hello from shep serve").unwrap();
let mut guard = DaemonGuard::default();
let port = free_port();
let output = shep(dir.path())
.arg("--format")
.arg("json")
.arg("serve")
.arg(&root)
.arg("--port")
.arg(port.to_string())
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&output);
let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let (status, body) = poll_http_get(addr, "/", &[]);
assert_eq!(status, 200, "body={body}");
assert!(body.contains("hello from shep serve"), "{body}");
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn serve_refuses_a_docroot_that_is_not_a_directory() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope");
let mut guard = DaemonGuard::default();
let output = shep(dir.path())
.arg("--format")
.arg("json")
.arg("serve")
.arg(&missing)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_json_error(&output, 2, "usage");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains(&missing.display().to_string()), "{stderr}");
assert!(
daemon_pid(dir.path()).is_none(),
"a refused root must not even bring a shepherd up"
);
}
#[cfg(unix)]
#[test]
fn a_served_sheep_stops_on_sigterm_rather_than_riding_the_ladder_to_sigkill() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("site");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("index.html"), "ok").unwrap();
let mut guard = DaemonGuard::default();
let port = free_port();
let name = "sigterm-check";
let output = shep(dir.path())
.arg("serve")
.arg(&root)
.arg("--port")
.arg(port.to_string())
.arg("--name")
.arg(name)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&output);
let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let (status, body) = poll_http_get(addr, "/", &[]);
assert_eq!(status, 200, "body={body}");
let started = Instant::now();
let stop_output = shep(dir.path()).arg("stop").arg(name).output().unwrap();
let elapsed = started.elapsed();
assert_success(&stop_output);
assert!(
elapsed < SERVE_STOP_DEADLINE,
"shep stop took {elapsed:?}, at or past SERVE_STOP_DEADLINE ({SERVE_STOP_DEADLINE:?}); \
a worker riding the ladder to SIGKILL takes at least the 1600ms kill_timeout default"
);
graceful_kill(dir.path());
}
#[cfg(unix)]
fn write_deploy_layout(root: &Path) {
let release = root.join("releases").join("2026-08-15");
std::fs::create_dir_all(&release).unwrap();
std::fs::write(release.join("index.html"), "the deploy layout").unwrap();
std::os::unix::fs::symlink(&release, root.join("current")).unwrap();
}
#[cfg(unix)]
#[test]
fn a_refused_symlink_writes_the_path_and_the_flag_to_the_sheeps_bleats() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("site");
std::fs::create_dir(&root).unwrap();
write_deploy_layout(&root);
let canonical_root = root.canonicalize().unwrap();
let mut guard = DaemonGuard::default();
let port = free_port();
let name = "symlink-refused";
let output = shep(dir.path())
.arg("serve")
.arg(&root)
.arg("--port")
.arg(port.to_string())
.arg("--name")
.arg(name)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&output);
let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let (status, body) = poll_http_get(addr, "/current/index.html", &[]);
assert_eq!(status, 404, "body={body}");
let bleats_output = bleats_no_follow_until_written(dir.path(), &[name, "--err"]);
let bleats = String::from_utf8_lossy(&bleats_output.stdout);
assert!(
bleats.contains(&canonical_root.join("current").display().to_string()),
"{bleats}"
);
assert!(bleats.contains("--follow-symlinks"), "{bleats}");
graceful_kill(dir.path());
}
#[cfg(unix)]
#[test]
fn a_served_sheep_with_follow_symlinks_serves_the_deploy_layout_and_says_so() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("site");
std::fs::create_dir(&root).unwrap();
write_deploy_layout(&root);
let mut guard = DaemonGuard::default();
let port = free_port();
let name = "symlink-followed";
let output = shep(dir.path())
.arg("serve")
.arg(&root)
.arg("--port")
.arg(port.to_string())
.arg("--name")
.arg(name)
.arg("--follow-symlinks")
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&output);
let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let (status, body) = poll_http_get(addr, "/current/index.html", &[]);
assert_eq!(status, 200, "body={body}");
assert!(body.contains("the deploy layout"), "{body}");
let bleats_output = bleats_no_follow_until_written(dir.path(), &[name, "--err"]);
let bleats = String::from_utf8_lossy(&bleats_output.stdout);
assert!(bleats.contains("--follow-symlinks"), "{bleats}");
assert!(
bleats.contains("race") || bleats.contains("TOCTOU"),
"{bleats}"
);
graceful_kill(dir.path());
}
#[test]
fn runtime_exits_when_the_flock_empties_with_a_code_that_says_why() {
let clean_dir = tempfile::tempdir().unwrap();
let clean_script = write_script(&clean_dir, "clean.sh", "#!/bin/sh\nexit 0\n");
let clean_flockfile = write_flockfile(
&clean_dir,
&format!(
"[[app]]\nname = \"batch\"\nscript = '{}'\nautorestart = false\n",
clean_script.display(),
),
);
let clean = shep(clean_dir.path())
.arg("runtime")
.arg(&clean_flockfile)
.output()
.unwrap();
assert_eq!(
clean.status.code(),
Some(0),
"a clean emptying is not a failure; stderr={}",
String::from_utf8_lossy(&clean.stderr)
);
let failed_dir = tempfile::tempdir().unwrap();
let failed_script = write_script(&failed_dir, "fail.sh", "#!/bin/sh\nexit 1\n");
let failed_flockfile = write_flockfile(
&failed_dir,
&format!(
"[[app]]\nname = \"batch\"\nscript = '{}'\nmax_restarts = 1\n",
failed_script.display(),
),
);
let failed = shep(failed_dir.path())
.arg("runtime")
.arg(&failed_flockfile)
.output()
.unwrap();
assert_eq!(
failed.status.code(),
Some(11),
"an errored sheep must fail the container; stderr={}",
String::from_utf8_lossy(&failed.stderr)
);
}
fn shep_dev(dev_home: &Path) -> Command {
let mut cmd = Command::cargo_bin("shep").unwrap();
cmd.env("SHEP_DEV_HOME", dev_home)
.arg("dev")
.timeout(CMD_TIMEOUT);
cmd
}
fn discard_in_background<R: Read + Send + 'static>(mut source: R) {
std::thread::spawn(move || {
let _ = std::io::copy(&mut source, &mut std::io::sink());
});
}
fn spawn_shep_dev(dev_home: &Path, flockfile: &Path) -> Child {
let mut child = std::process::Command::cargo_bin("shep")
.expect("locate the built shep binary")
.env("SHEP_DEV_HOME", dev_home)
.arg("dev")
.arg(flockfile)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shep dev");
discard_in_background(child.stdout.take().unwrap());
discard_in_background(child.stderr.take().unwrap());
child
}
fn wait_for_dev_online(dev_home: &Path, deadline: Duration) -> serde_json::Value {
let start = Instant::now();
loop {
let output = shep(dev_home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
if output.status.success()
&& let Ok(envelope) = serde_json::from_slice::<serde_json::Value>(&output.stdout)
&& envelope["data"][0]["status"] == "online"
{
return envelope["data"][0].clone();
}
if start.elapsed() >= deadline {
panic!(
"shep dev's flock never reached online within {deadline:?}; last stdout={}",
String::from_utf8_lossy(&output.stdout)
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn wait_bounded(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait().expect("poll shep dev") {
return status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("shep dev did not exit within {timeout:?}");
}
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn dev_tidies_up_after_itself() {
let dir = tempfile::tempdir().unwrap();
let dev_home = tempfile::tempdir().unwrap();
let script = write_script(&dir, "batch.sh", "#!/bin/sh\nexit 0\n");
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"batch\"\nscript = '{}'\nautorestart = false\n",
script.display(),
),
);
let output = shep_dev(dev_home.path()).arg(&flockfile).output().unwrap();
assert_success(&output);
let socket = dev_home.path().join("run").join("shep.sock");
assert!(!socket.exists(), "dev must not leave a live socket behind");
let flock_output = shep(dev_home.path()).arg("flock").output().unwrap();
assert!(
!flock_output.status.success(),
"no shepherd should remain at the dev home to answer `flock`: {flock_output:?}"
);
}
#[cfg(unix)]
#[test]
fn dev_tidies_up_when_it_is_signalled_rather_than_when_the_flock_empties() {
let dir = tempfile::tempdir().unwrap();
let dev_home = tempfile::tempdir().unwrap();
let script = write_script(&dir, "held.sh", "#!/bin/sh\nsleep 60\n");
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"held\"\nscript = '{}'\n",
script.display()
),
);
let mut child = spawn_shep_dev(dev_home.path(), &flockfile);
let dev_pid = child.id() as i32;
let online = wait_for_dev_online(dev_home.path(), FLOCK_DEADLINE);
let sheep_pid = online["pid"]
.as_i64()
.unwrap_or_else(|| panic!("a real pid: {online}")) as i32;
nix::sys::signal::kill(
nix::unistd::Pid::from_raw(dev_pid),
nix::sys::signal::Signal::SIGTERM,
)
.expect("send SIGTERM to shep dev");
let status = wait_bounded(&mut child, FLOCK_DEADLINE);
assert!(
status.success(),
"a signalled dev session must still tidy up and exit cleanly: {status:?}"
);
let socket = dev_home.path().join("run").join("shep.sock");
assert!(!socket.exists(), "dev must not leave a live socket behind");
let flock_output = shep(dev_home.path()).arg("flock").output().unwrap();
assert!(
!flock_output.status.success(),
"no shepherd should remain at the dev home to answer `flock`: {flock_output:?}"
);
assert!(
nix::sys::signal::kill(nix::unistd::Pid::from_raw(sheep_pid), None).is_err(),
"the held sheep (pid {sheep_pid}) must not outlive the dev session"
);
let roll_text = std::fs::read_to_string(dev_home.path().join("flock.json"))
.expect("teardown must still write a final flock.json, even an empty one");
let roll: serde_json::Value =
serde_json::from_str(&roll_text).expect("flock.json must still be valid JSON");
assert_eq!(
roll["apps"].as_array().map(Vec::len),
Some(0),
"a signalled dev session must not leave `held` in the roll for `shep muster` to \
resurrect: {roll}"
);
}
#[test]
fn the_alias_binaries_exist_and_reach_their_own_verbs() {
for (bin, verb) in [("shep-dev", "dev"), ("shep-runtime", "runtime")] {
let output = Command::cargo_bin(bin)
.unwrap_or_else(|err| panic!("{bin} must be a [[bin]] target: {err}"))
.arg("--help")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
let text = String::from_utf8_lossy(&output.stdout);
assert!(
text.contains(&format!("Usage: shep {verb}")),
"{bin} --help must be {verb}'s own help, not the root's:\n{text}"
);
assert!(
!text.contains("lookout"),
"{bin} printed the root verb list, so the alias supplied no verb:\n{text}"
);
}
}
fn assert_no_box_or_escape_reached_the_pipe(stdout: &str, verb: &str) {
assert!(
!stdout.contains('\u{1b}'),
"shep {verb} piped: an escape byte reached a pipe: {stdout:?}"
);
for glyph in ['┌', '┬', '┐', '├', '┼', '┤', '└', '┴', '┘', '│', '─'] {
assert!(
!stdout.contains(glyph),
"shep {verb} piped: a box-drawing glyph ({glyph:?}) reached a pipe:\n{stdout}"
);
}
}
#[cfg(unix)]
#[test]
fn piped_table_output_at_the_default_style_carries_no_box_or_escape() {
let dir = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let mut guard = DaemonGuard::default();
let started = shep(dir.path())
.arg("--format")
.arg("json")
.arg("start")
.arg(&script)
.output()
.unwrap();
guard.adopt_home(dir.path());
assert_success(&started);
let envelope: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap();
assert_eq!(envelope["data"][0]["status"], "online", "{envelope}");
let flock = shep(dir.path()).arg("flock").output().unwrap();
assert_success(&flock);
let flock_stdout = String::from_utf8_lossy(&flock.stdout).into_owned();
assert_no_box_or_escape_reached_the_pipe(&flock_stdout, "flock");
assert!(
flock_stdout.contains("online"),
"precondition: the piped table must still say something: {flock_stdout}"
);
let describe = shep(dir.path())
.arg("describe")
.arg("all")
.output()
.unwrap();
assert_success(&describe);
let describe_stdout = String::from_utf8_lossy(&describe.stdout).into_owned();
assert_no_box_or_escape_reached_the_pipe(&describe_stdout, "describe");
assert!(
describe_stdout.contains("online"),
"precondition: the piped table must still say something: {describe_stdout}"
);
graceful_kill(dir.path());
}
fn as_shep_spells_it(path: &Path) -> String {
let canonical = std::fs::canonicalize(path).expect("canonicalize the recorded binary");
shep_core::paths::strip_verbatim_prefix(&canonical)
.display()
.to_string()
}
#[test]
fn shep_adopt_finds_a_binary_on_path_by_bare_name() {
let home = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let binary = write_script(&bin_dir, "shep-log-rotate", "#!/bin/sh\nexit 0\n");
let output = Command::cargo_bin("shep")
.unwrap()
.env("PATH", bin_dir.path())
.arg("--home")
.arg(home.path())
.arg("adopt")
.arg("shep-log-rotate")
.arg("--name")
.arg("lr")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_success(&output);
let written = std::fs::read_to_string(home.path().join("shep.toml")).unwrap();
assert!(
written.contains(&as_shep_spells_it(&binary)),
"the $PATH hit must be the recorded binary: {written}"
);
}
#[cfg(unix)]
#[test]
fn shep_adopt_expands_a_leading_tilde_path() {
let shep_home = TempDir::new().unwrap();
let fake_user_home = TempDir::new().unwrap();
let bin_dir = fake_user_home.path().join(".cargo").join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let binary = bin_dir.join("shep-log-rotate");
std::fs::write(&binary, "#!/bin/sh\nexit 0\n").unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
mode.set_mode(0o755);
std::fs::set_permissions(&binary, mode).unwrap();
let output = Command::cargo_bin("shep")
.unwrap()
.env("HOME", fake_user_home.path())
.arg("--home")
.arg(shep_home.path())
.arg("adopt")
.arg("~/.cargo/bin/shep-log-rotate")
.arg("--name")
.arg("lr")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_success(&output);
let written = std::fs::read_to_string(shep_home.path().join("shep.toml")).unwrap();
assert!(
written.contains(&as_shep_spells_it(&binary)),
"the ~/-expanded binary must be the one recorded: {written}"
);
}
fn write_marker_script(dir: &TempDir, marker: &Path, code: u8) -> PathBuf {
#[cfg(unix)]
let body = format!(
"#!/bin/sh\necho \"argv:$*\" > \"{marker}\"\necho \"home:$SHEP_HOME\" >> \"{marker}\"\necho from-the-dog\nexit {code}\n",
marker = marker.display(),
);
#[cfg(windows)]
let body = format!(
"@echo off\r\necho argv:%*>\"{marker}\"\r\necho home:%SHEP_HOME%>>\"{marker}\"\r\necho from-the-dog\r\nexit /b {code}\r\n",
marker = marker.display(),
);
write_script(dir, "dog.sh", &body)
}
#[test]
fn an_adopted_dog_runs_directly_with_its_own_argv_and_shep_home() {
let home = TempDir::new().unwrap();
let marker = home.path().join("marker.txt");
let script = write_marker_script(&home, &marker, 7);
let adopted = shep(home.path())
.arg("adopt")
.arg(&script)
.arg("--name")
.arg("deploy")
.output()
.unwrap();
assert_success(&adopted);
let ran = Command::cargo_bin("shep")
.unwrap()
.env("SHEP_HOME", home.path())
.arg("deploy")
.arg("koji")
.arg("--flag")
.timeout(CMD_TIMEOUT)
.output()
.unwrap();
assert_eq!(
ran.status.code(),
Some(7),
"the dog's own exit code must pass through: {ran:?}"
);
assert!(
String::from_utf8_lossy(&ran.stdout).contains("from-the-dog"),
"stdio must be inherited, not captured away: {ran:?}"
);
let recorded = std::fs::read_to_string(&marker).unwrap();
assert!(
recorded.contains("argv:koji --flag"),
"argv must reach the dog exactly as typed: {recorded}"
);
assert!(
recorded.contains(&format!("home:{}", home.path().display())),
"SHEP_HOME must reach the dog's own environment: {recorded}"
);
}
#[test]
fn a_built_in_verb_always_wins_over_a_same_named_adopted_dog() {
let home = TempDir::new().unwrap();
let marker = home.path().join("marker.txt");
let script = write_marker_script(&home, &marker, 0);
std::fs::write(
home.path().join("shep.toml"),
format!(
"[daemon]\nadopted_dogs = {{ stop = \"{}\" }}\nenabled_dogs = [\"stop\"]\n",
script.display()
),
)
.unwrap();
let output = shep(home.path()).arg("stop").arg("all").output().unwrap();
assert_eq!(
output.status.code(),
Some(5),
"must be the built-in `stop`'s own DaemonUnreachable, not the dog's exit 0: {output:?}"
);
assert!(
!marker.exists(),
"the adopted dog's script must never have run"
);
}
#[test]
fn an_unknown_verb_with_no_matching_dog_keeps_claps_own_suggestion() {
let home = TempDir::new().unwrap();
let output = shep(home.path()).arg("flcok").output().unwrap();
assert_eq!(output.status.code(), Some(2), "clap's own usage exit code");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unrecognized subcommand"),
"clap's own wording must survive untouched: {stderr}"
);
assert!(
stderr.contains("flock"),
"clap's own did-you-mean must still suggest the real verb: {stderr}"
);
}
#[test]
fn a_flockfile_edit_to_a_registered_sheep_is_reported_rather_than_swallowed() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let elsewhere = tempfile::tempdir().unwrap();
let script = write_test_script(&dir);
let body = |cwd: &Path, token: &str| {
format!(
"[[app]]\nname = \"edited\"\nscript = '{}'\ncwd = '{}'\n\
env = {{ API_TOKEN = \"{token}\" }}\n",
script.display(),
cwd.display(),
)
};
let flockfile = write_flockfile(&dir, &body(home, "hunter2-before"));
let mut guard = DaemonGuard::default();
let boot = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_success(&boot);
poll_flock(home, |info| info["status"] == "online");
write_flockfile(&dir, &body(elsewhere.path(), "hunter2-after"));
let again = shep(home).arg("start").arg(&flockfile).output().unwrap();
assert_success(&again);
let stderr = String::from_utf8_lossy(&again.stderr);
assert!(
stderr.contains("edited"),
"the report must name the sheep: {stderr}"
);
assert!(
stderr.contains("cwd") && stderr.contains("env"),
"the report must name every field that changed: {stderr}"
);
assert!(
!stderr.contains("hunter2"),
"a field's VALUE must never reach an operator's terminal (IR-41): {stderr}"
);
graceful_kill(home);
}
#[test]
fn one_absent_script_refuses_the_whole_flockfile_and_registers_nothing() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"good\"\nscript = '{}'\n\n\
[[app]]\nname = \"unbuilt\"\nscript = '{}/never-built'\n",
script.display(),
dir.path().display(),
),
);
let mut guard = DaemonGuard::default();
let output = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_eq!(
output.status.code(),
Some(7),
"the spawn-failed exit code: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unbuilt"),
"the refusal must name the app: {stderr}"
);
assert!(
stderr.contains("never-built"),
"the refusal must name the path it looked at: {stderr}"
);
let flock = shep(home)
.arg("--format")
.arg("json")
.arg("flock")
.output()
.unwrap();
assert_success(&flock);
let envelope: serde_json::Value = serde_json::from_slice(&flock.stdout).unwrap();
assert_eq!(
envelope["data"].as_array().map(Vec::len),
Some(0),
"a Flockfile refused as a whole must leave NOTHING registered: {}",
envelope
);
graceful_kill(home);
}
#[test]
fn a_spawn_that_no_check_could_have_caught_still_names_the_sheep_and_the_path() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let unrunnable = dir.path().join("unrunnable.sh");
std::fs::write(&unrunnable, "#!/bin/sh\nsleep 60\n").unwrap();
#[cfg(unix)]
{
std::fs::set_permissions(&unrunnable, std::fs::Permissions::from_mode(0o644)).unwrap();
}
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"locked-out\"\nscript = '{}'\n",
unrunnable.display(),
),
);
let mut guard = DaemonGuard::default();
let output = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_eq!(
output.status.code(),
Some(7),
"the spawn-failed exit code: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("locked-out"),
"the error must name the sheep: {stderr}"
);
assert!(
stderr.contains("unrunnable.sh"),
"the error must name the script it tried: {stderr}"
);
let flockfile_dir = as_shep_spells_it(dir.path());
assert!(
stderr.contains(&format!("in {flockfile_dir}")),
"the error must name the cwd it tried it in: {stderr}"
);
graceful_kill(home);
}
#[test]
fn a_bare_command_off_the_path_takes_only_its_own_app_down() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let script = write_test_script(&dir);
let flockfile = write_flockfile(
&dir,
&format!(
"[[app]]\nname = \"resolvable\"\nscript = '{}'\n\n\
[[app]]\nname = \"no-interpreter\"\nscript = \"shep-no-such-interpreter-xyz\"\n",
script.display(),
),
);
let mut guard = DaemonGuard::default();
let output = shep(home).arg("start").arg(&flockfile).output().unwrap();
guard.adopt_home(home);
assert_eq!(
output.status.code(),
Some(7),
"the one app that cannot run still fails the command: {output:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("is not on the shepherd's PATH"),
"the reply must explain WHY the program was not found, not only that \
it was not: {stderr}"
);
assert!(
stderr.contains("shep-no-such-interpreter-xyz") && stderr.contains("no-interpreter"),
"naming the program and the sheep: {stderr}"
);
let data = poll_flock_data(home, FLOCK_DEADLINE, |data| {
data.as_array().is_some_and(|rows| {
rows.iter()
.any(|row| row["name"] == "resolvable" && row["status"] == "online")
})
});
let survivor = data
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "resolvable"));
assert_eq!(
survivor.map(|row| &row["status"]).map(ToString::to_string),
Some("\"online\"".to_string()),
"an app whose own script resolves must come up regardless of a \
sibling's unresolvable interpreter, and must not be refused \
registration over it: {data}"
);
let log = std::fs::read_to_string(home.join("logs").join("shepd.err.log")).unwrap();
assert!(
log.contains("shep-no-such-interpreter-xyz") && log.contains("PATH"),
"the shepherd must still say which program it could not find: {log}"
);
assert!(
log.contains("no-interpreter"),
"and which sheep wanted it: {log}"
);
graceful_kill(home);
}