use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
const DEPLOY_BIN: &str = env!("CARGO_BIN_EXE_shep-deploy");
const PATIENCE: Duration = Duration::from_secs(60);
const POLL_WINDOW: Duration = Duration::from_secs(20);
const DEFAULT_INTERVAL_SECS: u64 = 30;
const _: () = assert!(
POLL_WINDOW.as_secs() + 5 <= DEFAULT_INTERVAL_SECS,
"POLL_WINDOW must stay under config::DEFAULT_INTERVAL with room to spare, \
or a defaulted dog's next tick can land inside the window and the test \
stops telling the two apart"
);
const ROLLED_BACK_EXIT: u8 = 12;
fn shep_bin() -> PathBuf {
let raw = std::env::var("SHEP_BIN").expect(
"the integration tier needs $SHEP_BIN pointing at a built shep binary, for example \
SHEP_BIN=\"$(command -v shep)\"",
);
let path = PathBuf::from(raw);
assert!(
path.is_file(),
"$SHEP_BIN does not name a file: {}",
path.display()
);
path
}
struct Shepherd {
home: tempfile::TempDir,
shep: PathBuf,
}
impl Shepherd {
fn new() -> Self {
let home = tempfile::tempdir().expect("a temporary $SHEP_HOME");
let socket = home.path().join("run/shep.sock");
assert!(
socket.as_os_str().len() < 100,
"$TMPDIR is too deep for a unix socket here: {} is {} bytes and the kernel allows \
about 104. Run with a shorter TMPDIR.",
socket.display(),
socket.as_os_str().len()
);
Self {
home,
shep: shep_bin(),
}
}
fn home(&self) -> &Path {
self.home.path()
}
fn run(&self, args: &[&str]) -> Output {
Command::new(&self.shep)
.args(args)
.arg("--home")
.arg(self.home())
.env("SHEP_HOME", self.home())
.output()
.expect("shep ran")
}
fn ok(&self, args: &[&str]) -> String {
let output = self.run(args);
assert!(
output.status.success(),
"shep {args:?} failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn deploy(&self, sheep: &str) -> Output {
self.deploy_args(&["deploy", sheep])
}
fn deploy_args(&self, args: &[&str]) -> Output {
Command::new(DEPLOY_BIN)
.args(args)
.env("SHEP_HOME", self.home())
.output()
.expect("shep-deploy ran")
}
}
impl Drop for Shepherd {
fn drop(&mut self) {
let _ = self.run(&["kill", "--style", "bare"]);
}
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn head_of(dir: &Path) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse");
String::from_utf8(out.stdout)
.expect("utf-8 sha")
.trim()
.to_owned()
}
fn app_toml(home: &Path, with_cwd: bool, readiness: Readiness, extra: &str) -> String {
let current = home.join("deploy/web/current");
let cwd = if with_cwd {
format!("cwd = {:?}\n", current.to_str().expect("utf-8 path"))
} else {
String::new()
};
let gate = match readiness {
Readiness::Probe => format!(
"\n[app.readiness_probe]\nkind = \"exec\"\ntarget = \"test -f \
{marker}\"\ninterval = \"1s\"\ntimeout = \"2s\"\nfailure_threshold = 1\n",
marker = current.join("ready-marker").display(),
),
Readiness::Heuristic(listen) => format!("listen_timeout = \"{listen}s\"\n"),
};
format!("[[app]]\nname = \"web\"\nscript = \"./run.sh\"\n{cwd}{extra}{gate}")
}
#[derive(Clone, Copy)]
enum Readiness {
Probe,
Heuristic(u64),
}
const SLOW_LISTEN: u64 = 12;
fn origin_with_app(
home: &Path,
version: &str,
readiness: Readiness,
extra: &str,
) -> tempfile::TempDir {
let origin = tempfile::tempdir().expect("tempdir");
git(origin.path(), &["init", "-q", "-b", "main"]);
git(origin.path(), &["config", "user.email", "test@example.com"]);
git(origin.path(), &["config", "user.name", "test"]);
fs::write(
origin.path().join("Flockfile.toml"),
app_toml(home, false, readiness, extra),
)
.expect("write Flockfile");
fs::write(origin.path().join("ready-marker"), "").expect("write ready-marker");
write_run_script(origin.path(), version);
git(origin.path(), &["add", "."]);
git(origin.path(), &["commit", "-q", "-m", version]);
origin
}
const DRAINING_APP: &str = "instances = 2\ngraceful_timeout = \"8s\"\n";
const DRAINING_LISTEN: u64 = 1;
fn write_run_script(dir: &Path, version: &str) {
write_script(dir, &format!("#!/bin/sh\necho {version}\nsleep 300\n"));
}
fn write_stubborn_run_script(dir: &Path, version: &str) {
write_script(
dir,
&format!("#!/bin/sh\ntrap '' TERM\necho {version}\nwhile :; do sleep 1; done\n"),
);
}
fn write_script(dir: &Path, body: &str) {
let path = dir.join("run.sh");
fs::write(&path, body).expect("write run.sh");
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
}
fn build_tree(home: &Path, sheep: &str, origin: &Path) -> String {
let root = home.join("deploy").join(sheep);
let git_dir = root.join("git");
fs::create_dir_all(&git_dir).expect("create git dir");
git(&git_dir, &["init", "-q", "--bare"]);
let remote = origin.to_str().expect("utf-8 origin path").to_owned();
git(
&git_dir,
&["fetch", "--prune", &remote, "+refs/heads/*:refs/heads/*"],
);
let sha = head_of(origin);
let release = root.join("releases").join(&sha);
git(
&git_dir,
&["worktree", "add", release.to_str().unwrap(), &sha],
);
symlink(&release, root.join("current")).expect("symlink current");
sha
}
fn write_state(home: &Path, sheep: &str, origin: &Path, sha: &str, verify: &str) {
let path = home.join("deploy").join(sheep).join("deploy.toml");
fs::write(
&path,
format!(
"remote = {remote:?}\nbranch = \"main\"\ndeployed = {sha:?}\nverify = \
{verify:?}\ncheckout = {remote:?}\n",
remote = origin.to_str().expect("utf-8 origin path"),
),
)
.expect("write deploy.toml");
}
fn register_web(shepherd: &Shepherd, readiness: Readiness, extra: &str) {
let path = shepherd.home().join("register.toml");
fs::write(&path, app_toml(shepherd.home(), true, readiness, extra))
.expect("write register.toml");
shepherd.ok(&[
"start",
path.to_str().expect("utf-8 path"),
"--style",
"bare",
]);
}
const CHECKOUT_LISTEN: u64 = 1;
fn register_from_checkout(shepherd: &Shepherd, origin: &Path) -> tempfile::TempDir {
let checkout = tempfile::tempdir().expect("tempdir");
git(
checkout.path(),
&[
"clone",
"-q",
origin.to_str().expect("utf-8 origin path"),
".",
],
);
let path = checkout.path().join("Flockfile.toml");
fs::write(
&path,
app_toml(
shepherd.home(),
false,
Readiness::Heuristic(CHECKOUT_LISTEN),
"",
),
)
.expect("write the checkout Flockfile");
shepherd.ok(&[
"start",
path.to_str().expect("utf-8 path"),
"--style",
"bare",
]);
checkout
}
fn last_line(path: &Path) -> Option<String> {
let text = fs::read_to_string(path).ok()?;
text.lines()
.rfind(|line| !line.trim().is_empty())
.map(|line| shep_client::shep_core::logstamp::strip(line).to_owned())
}
fn wait_until(what: &str, ready: impl Fn() -> bool) {
wait_within(what, PATIENCE, ready);
}
fn wait_within(what: &str, budget: Duration, ready: impl Fn() -> bool) {
let deadline = Instant::now() + budget;
while Instant::now() < deadline {
if ready() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("timed out waiting for {what}");
}
fn described_online(shepherd: &Shepherd, sheep: &str) -> bool {
shepherd
.ok(&["describe", sheep, "--format", "json"])
.contains("\"status\":\"online\"")
}
fn described_pid(shepherd: &Shepherd, sheep: &str) -> Option<u32> {
let listing = shepherd.ok(&["describe", sheep, "--format", "json"]);
listing
.split("\"pid\":")
.nth(1)?
.split(|c: char| !c.is_ascii_digit())
.next()?
.parse()
.ok()
}
fn described_instances(shepherd: &Shepherd, sheep: &str) -> usize {
shepherd
.ok(&["describe", sheep, "--format", "json"])
.matches("\"id\":")
.count()
}
fn out_file(shepherd: &Shepherd, sheep: &str) -> PathBuf {
let listing = shepherd.ok(&["describe", sheep, "--format", "json"]);
let named = listing
.split("\"out_file\":\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("describe names an out_file");
PathBuf::from(named)
}
#[test]
fn a_real_deploy_swaps_reloads_and_verifies() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Probe, "");
let first = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &first, "probed");
register_web(&shepherd, Readiness::Probe, "");
wait_until("the first release to come online", || {
described_pid(&shepherd, "web").is_some()
});
let out_log = shepherd.home().join("logs/web-0-out.log");
wait_until("v1 to have run at least once", || {
fs::read_to_string(&out_log)
.unwrap_or_default()
.contains("v1")
});
write_run_script(origin.path(), "v2");
git(origin.path(), &["add", "."]);
git(origin.path(), &["commit", "-q", "-m", "v2"]);
let second = head_of(origin.path());
let output = shepherd.deploy("web");
assert!(
output.status.success(),
"deploy failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&second),
"the deploy should report the new sha: {stdout}"
);
let current = fs::read_link(shepherd.home().join("deploy/web/current")).expect("current");
assert_eq!(
current,
shepherd.home().join("deploy/web/releases").join(&second)
);
let state = fs::read_to_string(shepherd.home().join("deploy/web/deploy.toml")).expect("state");
assert!(state.contains(&second), "{state}");
wait_until("v2 to have run", || {
fs::read_to_string(&out_log)
.unwrap_or_default()
.contains("v2")
});
}
#[test]
fn a_failing_build_leaves_the_previous_release_serving() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Probe, "");
let first = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &first, "probed");
register_web(&shepherd, Readiness::Probe, "");
wait_until("the first release to come online", || {
described_pid(&shepherd, "web").is_some()
});
let live_pid = described_pid(&shepherd, "web").expect("a pid");
fs::write(
origin.path().join("Flockfile.toml"),
format!(
"{}\n[dog.deploy.build]\ncommand = 'exit 3'\n",
app_toml(shepherd.home(), false, Readiness::Probe, "")
),
)
.expect("write a failing build");
git(origin.path(), &["add", "."]);
git(origin.path(), &["commit", "-q", "-m", "broken"]);
let output = shepherd.deploy("web");
assert!(
!output.status.success(),
"a failing build must not be reported as a successful deploy"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("build exited with status 3"),
"the failure should name the build's own exit status: {stderr}"
);
let current = fs::read_link(shepherd.home().join("deploy/web/current")).expect("current");
assert_eq!(
current,
shepherd.home().join("deploy/web/releases").join(&first)
);
let state = fs::read_to_string(shepherd.home().join("deploy/web/deploy.toml")).expect("state");
assert!(state.contains(&first), "{state}");
let pid_after = described_pid(&shepherd, "web").expect("still running");
assert_eq!(
live_pid, pid_after,
"a failed build must never trigger a reload of the running sheep"
);
}
#[test]
fn a_release_that_cannot_come_up_is_rolled_back_and_the_old_release_serves() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Probe, "");
let first = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &first, "alive");
register_web(&shepherd, Readiness::Probe, "");
wait_until("the first release to come online", || {
described_pid(&shepherd, "web").is_some()
});
let out_log = shepherd.home().join("logs/web-0-out.log");
wait_until("v1 to have run", || {
last_line(&out_log).as_deref() == Some("v1")
});
write_run_script(origin.path(), "v2");
git(origin.path(), &["rm", "-q", "ready-marker"]);
git(origin.path(), &["add", "-A"]);
git(origin.path(), &["commit", "-q", "-m", "v2, never ready"]);
let second = head_of(origin.path());
let output = shepherd.deploy("web");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("rolled back"),
"a release that never came up must be reported as rolled back: {stdout}{stderr}"
);
assert!(
!stdout.contains(&format!("deployed {second}")),
"the broken release must never be reported deployed: {stdout}"
);
assert_eq!(
output.status.code(),
Some(i32::from(ROLLED_BACK_EXIT)),
"a rolled-back deploy must not report success: {stdout}{stderr}"
);
let current = fs::read_link(shepherd.home().join("deploy/web/current")).expect("current");
assert_eq!(
current,
shepherd.home().join("deploy/web/releases").join(&first),
"current must be back on the release that works"
);
let state = fs::read_to_string(shepherd.home().join("deploy/web/deploy.toml")).expect("state");
assert!(
state.contains(&format!("deployed = \"{first}\"")),
"{state}"
);
assert!(
!state.contains(&format!("deployed = \"{second}\"")),
"{state}"
);
assert!(state.contains(&format!("failed = \"{second}\"")), "{state}");
wait_until("the old release to be serving again", || {
last_line(&out_log).as_deref() == Some("v1")
});
assert!(
described_pid(&shepherd, "web").is_some(),
"the sheep must still be running after a rollback"
);
}
#[test]
fn a_reload_slower_than_the_old_window_still_deploys() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Heuristic(SLOW_LISTEN), "");
let first = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &first, "alive");
register_web(&shepherd, Readiness::Heuristic(SLOW_LISTEN), "");
wait_until("the first release to come online", || {
described_online(&shepherd, "web")
});
let out_log = shepherd.home().join("logs/web-0-out.log");
wait_until("v1 to have run", || {
last_line(&out_log).as_deref() == Some("v1")
});
write_run_script(origin.path(), "v2");
git(origin.path(), &["add", "-A"]);
git(origin.path(), &["commit", "-q", "-m", "v2"]);
let second = head_of(origin.path());
let output = shepherd.deploy("web");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"a good release must not fail its own deploy: {stdout}{stderr}"
);
assert!(
stdout.contains(&format!("deployed {second}")),
"the new release must be reported deployed: {stdout}{stderr}"
);
assert!(
!stdout.contains("rolled back") && !stderr.contains("split"),
"nothing here should roll back: {stdout}{stderr}"
);
let current = fs::read_link(shepherd.home().join("deploy/web/current")).expect("current");
assert_eq!(
current,
shepherd.home().join("deploy/web/releases").join(&second)
);
let state = fs::read_to_string(shepherd.home().join("deploy/web/deploy.toml")).expect("state");
assert!(state.contains(&second), "{state}");
assert_eq!(
last_line(&out_log).as_deref(),
Some("v2"),
"the running process must be executing the new release"
);
}
#[test]
fn a_reload_that_uses_its_whole_drain_window_still_deploys() {
let shepherd = Shepherd::new();
let readiness = Readiness::Heuristic(DRAINING_LISTEN);
let origin = origin_with_app(shepherd.home(), "v1", readiness, DRAINING_APP);
write_stubborn_run_script(origin.path(), "v1");
git(origin.path(), &["add", "-A"]);
git(origin.path(), &["commit", "-q", "-m", "stubborn v1"]);
let first = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &first, "alive");
register_web(&shepherd, readiness, DRAINING_APP);
wait_until("both instances to come online", || {
described_online(&shepherd, "web")
});
let out_log = shepherd.home().join("logs/web-0-out.log");
wait_until("v1 to have run", || {
last_line(&out_log).as_deref() == Some("v1")
});
write_stubborn_run_script(origin.path(), "v2");
git(origin.path(), &["add", "-A"]);
git(origin.path(), &["commit", "-q", "-m", "stubborn v2"]);
let second = head_of(origin.path());
let started = Instant::now();
let output = shepherd.deploy("web");
let elapsed = started.elapsed();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"a healthy reload that used its drain window must not fail its own \
deploy: {stdout}{stderr}"
);
assert!(
stdout.contains(&format!("deployed {second}")),
"the new release must be reported deployed: {stdout}{stderr}"
);
assert!(
!stdout.contains("rolled back") && !stderr.contains("split"),
"nothing here should roll back: {stdout}{stderr}"
);
assert!(
elapsed >= Duration::from_secs(14),
"the app cannot have used its drain window in {elapsed:?} - this test \
is no longer testing the term it exists for"
);
let current = fs::read_link(shepherd.home().join("deploy/web/current")).expect("current");
assert_eq!(
current,
shepherd.home().join("deploy/web/releases").join(&second)
);
let state = fs::read_to_string(shepherd.home().join("deploy/web/deploy.toml")).expect("state");
assert!(state.contains(&second), "{state}");
assert_eq!(
last_line(&out_log).as_deref(),
Some("v2"),
"the running process must be executing the new release"
);
}
#[test]
fn a_sheep_taken_over_by_setup_follows_a_later_swap() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Probe, "");
let _checkout = register_from_checkout(&shepherd, origin.path());
wait_until("web to come up from the checkout", || {
described_online(&shepherd, "web")
});
let before = described_pid(&shepherd, "web").expect("a pid");
let setup = shepherd.deploy_args(&["setup", "web"]);
assert!(
setup.status.success(),
"setup failed: {}{}",
String::from_utf8_lossy(&setup.stdout),
String::from_utf8_lossy(&setup.stderr)
);
wait_until("the cutover to settle", || {
described_instances(&shepherd, "web") == 1
});
assert_ne!(
described_pid(&shepherd, "web"),
Some(before),
"the surviving instance must be the newcomer, not the one it replaced"
);
let out_log = out_file(&shepherd, "web");
write_run_script(origin.path(), "v2");
git(origin.path(), &["commit", "-qam", "v2"]);
let deployed = shepherd.deploy("web");
assert!(
deployed.status.success(),
"the second deploy failed: {}{}",
String::from_utf8_lossy(&deployed.stdout),
String::from_utf8_lossy(&deployed.stderr)
);
wait_until("the second release to be serving", || {
last_line(&out_log).as_deref() == Some("v2")
});
}
#[test]
fn the_supervised_dog_deploys_a_moved_branch_without_being_asked() {
let shepherd = Shepherd::new();
let origin = origin_with_app(shepherd.home(), "v1", Readiness::Probe, "");
let sha = build_tree(shepherd.home(), "web", origin.path());
write_state(shepherd.home(), "web", origin.path(), &sha, "probed");
register_web(&shepherd, Readiness::Probe, "");
wait_until("web to come up", || described_online(&shepherd, "web"));
let out_log = out_file(&shepherd, "web");
fs::write(
shepherd.home().join("shep.toml"),
"[dog.deploy]\ninterval = \"1s\"\n",
)
.expect("write shep.toml");
shepherd.ok(&["adopt", DEPLOY_BIN, "--style", "bare"]);
wait_until("the dog to be supervised", || {
described_instances(&shepherd, "deploy") == 1
});
let dog_log = out_file(&shepherd, "deploy");
let fetch_head = shepherd.home().join("deploy/web/git/FETCH_HEAD");
fs::remove_file(&fetch_head).expect("remove FETCH_HEAD");
wait_until("a tick that looked at the remote and found nothing", || {
fetch_head.is_file()
});
write_run_script(origin.path(), "v2");
git(origin.path(), &["commit", "-qam", "v2"]);
let second = head_of(origin.path());
wait_within(
"the poll loop to deploy v2 on its own, within one configured interval",
POLL_WINDOW,
|| last_line(&out_log).as_deref() == Some("v2"),
);
wait_within(
"the supervised dog to report the deploy as its own",
POLL_WINDOW,
|| {
fs::read_to_string(&dog_log)
.unwrap_or_default()
.contains(&format!("web deployed {second}"))
},
);
}