mod common;
use common::{commit, git};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const OWNER: &str = "did:web:owner.hook.test";
const MASTER_KEY_ENV: &str = "KNOT_STATIC_HOOK_KEY";
const MASTER_KEY: [u8; 32] = [7; 32];
fn knot_static(config: &Path, args: &[&str]) -> String {
let output = common::knot_static(config, MASTER_KEY_ENV, &MASTER_KEY, args);
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn write_config(root: &Path, records: &Path) -> PathBuf {
let script = format!(
"n=$(ls {records} | grep -c hook); cp \"$1\" {records}/$n-hook.json; \
d=$(dirname \"$1\"); if [ -f \"$d/xrpc-diff.jsonl\" ]; then cp \"$d/xrpc-diff.jsonl\" {records}/$n-diff.jsonl; fi",
records = records.display()
);
let config = root.join("config.toml");
std::fs::write(
&config,
format!(
r#"[knot]
hostname = "knot.hook.test"
owner = "{OWNER}"
[repo]
scan_path = "{root}/state"
default_branch = "main"
object_format = "sha1"
[secrets]
sealed_key_file = "{root}/keys.sealed"
master_key_env = "{MASTER_KEY_ENV}"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{root}/output"
[activate]
nginx_binary = "true"
reload_command = "true"
[[hooks]]
name = "render"
command = "/bin/sh"
args = ["-c", {script:?}, "hook"]
"#,
root = root.display(),
),
)
.unwrap();
config
}
fn wait_for(path: &Path) -> serde_json::Value {
let deadline = Instant::now() + Duration::from_secs(20);
while !path.is_file() {
assert!(
Instant::now() < deadline,
"the hook never recorded {}",
path.display()
);
std::thread::sleep(Duration::from_millis(50));
}
// cp creates the file before its last bytes land.
std::thread::sleep(Duration::from_millis(200));
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
fn generate(config: &Path) -> Option<String> {
let stderr = knot_static(config, &["generate"]);
let line = stderr.lines().find(|line| line.contains("/generations/"))?;
let id = line.rsplit('/').next()?.trim().to_string();
Some(id)
}
fn calls(records: &Path) -> usize {
std::fs::read_dir(records)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().ends_with("-hook.json"))
.count()
}
#[test]
fn generate_twice_hands_the_hook_a_diff_of_the_push() {
let root = tempfile::tempdir().unwrap();
let records = root.path().join("records");
std::fs::create_dir_all(&records).unwrap();
std::fs::create_dir_all(root.path().join("state")).unwrap();
let config = write_config(root.path(), &records);
knot_static(&config, &["init"]);
let source = root.path().join("source");
std::fs::create_dir_all(&source).unwrap();
git(&source, ["init", "-q", "-b", "main"]);
commit(&source, "README.md", "# hook\n", "initial");
commit(&source, "src/lib.rs", "fn a() {}\n", "code");
// plc submit fails offline, after the repo is already registered
common::knot_static_raw(
&config,
MASTER_KEY_ENV,
&MASTER_KEY,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"hooked",
"--name",
"hooked",
"--default-branch",
"main",
source.to_str().unwrap(),
],
&[],
);
if let Some(id) = generate(&config) {
knot_static(&config, &["activate", &id]);
}
let first = calls(&records);
assert!(first >= 1, "the first activation ran the hook");
let initial = wait_for(&records.join(format!("{}-hook.json", first - 1)));
assert_eq!(initial["format"], 1);
let repo = initial["repos"][0].clone();
assert_eq!(repo["name"], "hooked");
let did = repo["did"].as_str().unwrap().to_string();
let hosted = find_repo(&root.path().join("state"), &did);
git(&hosted, ["config", "receive.denyCurrentBranch", "ignore"]);
let work = root.path().join("work");
git(
root.path(),
[
"clone",
"-q",
hosted.to_str().unwrap(),
work.to_str().unwrap(),
],
);
commit(&work, "src/lib.rs", "fn a() {}\nfn b() {}\n", "second");
git(&work, ["push", "-q", hosted.to_str().unwrap(), "HEAD:main"]);
let pushed = git(&work, ["rev-parse", "HEAD"]);
let id = generate(&config).expect("the push builds a new generation");
knot_static(&config, &["activate", &id]);
let hook = wait_for(&records.join(format!("{first}-hook.json")));
assert_eq!(hook["generation"], id.as_str());
assert_eq!(hook["previous"], initial["generation"]);
assert_eq!(hook["trigger"]["kind"], "push");
let summary = &hook["repos"][0];
assert_eq!(summary["change"], "updated");
assert_eq!(summary["refs"][0]["ref"], "refs/heads/main");
assert_eq!(summary["refs"][0]["new"], pushed.as_str());
let diff = std::fs::read_to_string(records.join(format!("{first}-diff.jsonl"))).unwrap();
let lines: Vec<serde_json::Value> = diff
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(hook["diff"]["lines"], lines.len());
let blob = lines.iter().find(|line| {
line["nsid"] == "org.tangled.git.getBlob"
&& line["params"]["ref"] == "main"
&& line["params"]["path"] == "src/lib.rs"
});
assert_eq!(blob.expect("the pushed file's blob")["op"], "changed");
assert!(
lines.iter().all(|line| line["params"]
.get("repo")
.is_none_or(|repo| repo == did.as_str())),
"every git line names the repo by its did"
);
assert!(
!lines
.iter()
.any(|line| line["nsid"] == "org.tangled.git.getBlob"
&& line["params"]["path"] == "README.md"
&& line["params"]["ref"] == "main"),
"an unchanged file isn't listed"
);
assert_eq!(generate(&config), None);
let started = Instant::now();
knot_static(&config, &["activate", &id]);
let again = wait_for(&records.join(format!("{}-hook.json", first + 1)));
assert_eq!(again["previous"], id.as_str());
assert_eq!(again["diff"]["lines"], 0);
assert_eq!(
std::fs::read(records.join(format!("{}-diff.jsonl", first + 1))).unwrap(),
b""
);
assert!(started.elapsed() < Duration::from_secs(20));
}
#[test]
fn a_newer_activation_stops_only_its_own_hook_group() {
let root = tempfile::tempdir().unwrap();
let records = root.path().join("records");
std::fs::create_dir_all(&records).unwrap();
std::fs::create_dir_all(root.path().join("state")).unwrap();
let config = write_config(root.path(), &records);
let slow = |name: &str| {
format!(
"n=$(ls {records} | grep -c '^{name}-' || true); echo $$ > {records}/{name}-$n.pid; exec sleep 60",
records = records.display()
)
};
let fast = format!(
"n=$(ls {records} | grep -c '^fast-' || true); cp \"$1\" {records}/fast-$n-hook.json",
records = records.display()
);
let text = std::fs::read_to_string(&config).unwrap();
let head = text.split("[[hooks]]").next().unwrap();
std::fs::write(
&config,
format!(
"{head}[[hooks]]\nname = \"slow\"\ncommand = \"/bin/sh\"\nargs = [\"-c\", {:?}, \"hook\"]\n\n[[hooks]]\nname = \"fast\"\ncommand = \"/bin/sh\"\nargs = [\"-c\", {fast:?}, \"hook\"]\n",
slow("slow")
),
)
.unwrap();
knot_static(&config, &["init"]);
let id = generate(&config).expect("an empty knot still builds a generation");
let pid = |name: &str, n: usize| -> i32 {
let path = records.join(format!("{name}-{n}.pid"));
let deadline = Instant::now() + Duration::from_secs(20);
while std::fs::read_to_string(&path).map_or(true, |pid| pid.trim().is_empty()) {
assert!(Instant::now() < deadline, "hook {name} {n} never started");
std::thread::sleep(Duration::from_millis(50));
}
std::fs::read_to_string(&path)
.unwrap()
.trim()
.parse()
.unwrap()
};
let mut bystander = std::process::Command::new("sleep")
.arg("60")
.spawn()
.unwrap();
let bystander_pid = bystander.id() as i32;
let hooks_dir = root.path().join("output/runtime/hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("other.pid"), bystander_pid.to_string()).unwrap();
knot_static(&config, &["activate", &id]);
let first = pid("slow", 0);
let fast_first = wait_for(&records.join("fast-0-hook.json"));
assert_eq!(fast_first["generation"], id.as_str());
for file in [
"slow.lock",
"slow.pid",
"slow.started.json",
"fast.lock",
"fast.pid",
"fast.started.json",
] {
assert!(hooks_dir.join(file).is_file(), "{file}");
}
let started = Instant::now();
knot_static(&config, &["activate", &id]);
let second = pid("slow", 1);
let fast_second = wait_for(&records.join("fast-1-hook.json"));
assert_eq!(fast_second["generation"], id.as_str());
assert_ne!(first, second);
assert!(
started.elapsed() < Duration::from_secs(10),
"superseding waits at most a few seconds"
);
let alive = |pid: i32| {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.unwrap()
.success()
};
assert!(!alive(first), "the older slow hook was stopped");
assert!(alive(second), "the newer one runs");
assert!(
alive(bystander_pid),
"superseding slow leaves another hook's process alone"
);
std::process::Command::new("kill")
.arg(second.to_string())
.status()
.unwrap();
let _ = bystander.kill();
let _ = bystander.wait();
}
fn find_repo(scan: &Path, did: &str) -> PathBuf {
let identity = did.replace([':', '.'], "");
let mut stack = vec![scan.to_path_buf()];
while let Some(dir) = stack.pop() {
if dir.join("HEAD").is_file() && dir.join("config").is_file() {
let joined: String = dir
.strip_prefix(scan)
.unwrap()
.to_string_lossy()
.chars()
.filter(|c| *c != '/')
.collect();
if !joined.is_empty() && (identity.contains(&joined) || joined.contains(&identity)) {
return dir;
}
}
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
if entry.path().is_dir() {
stack.push(entry.path());
}
}
}
panic!("no hosted copy of {did}")
}
#[test]
fn a_deploy_that_changes_or_adds_a_hook_runs_only_that_hook_once() {
let root = tempfile::tempdir().unwrap();
let records = root.path().join("records");
std::fs::create_dir_all(&records).unwrap();
std::fs::create_dir_all(root.path().join("state")).unwrap();
let config = write_config(root.path(), &records);
knot_static(&config, &["init"]);
let source = root.path().join("source");
std::fs::create_dir_all(&source).unwrap();
git(&source, ["init", "-q", "-b", "main"]);
commit(&source, "README.md", "# hook\n", "initial");
common::knot_static_raw(
&config,
MASTER_KEY_ENV,
&MASTER_KEY,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"hooked",
"--name",
"hooked",
"--default-branch",
"main",
source.to_str().unwrap(),
],
&[],
);
let steady = format!(
"n=$(ls {records} | grep -c '^steady-' || true); echo $$ > {records}/steady-$n.pid; exec sleep 60",
records = records.display()
);
let text = std::fs::read_to_string(&config).unwrap();
std::fs::write(
&config,
format!(
"{text}\n[[hooks]]\nname = \"steady\"\ncommand = \"/bin/sh\"\nargs = [\"-c\", {steady:?}, \"steady\"]\n"
),
)
.unwrap();
let pid = |n: usize| -> i32 {
let path = records.join(format!("steady-{n}.pid"));
let deadline = Instant::now() + Duration::from_secs(20);
while std::fs::read_to_string(&path).map_or(true, |pid| pid.trim().is_empty()) {
assert!(Instant::now() < deadline, "steady {n} never started");
std::thread::sleep(Duration::from_millis(50));
}
std::fs::read_to_string(&path)
.unwrap()
.trim()
.parse()
.unwrap()
};
let alive = |pid: i32| {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.unwrap()
.success()
};
let id = generate(&config).expect("a first generation");
knot_static(&config, &["activate", &id]);
wait_for(&records.join("0-hook.json"));
let steady_pid = pid(0);
assert!(alive(steady_pid));
knot_static(&config, &["prepare"]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(calls(&records), 1, "the same hooks aren't run again");
assert!(alive(steady_pid));
assert!(!records.join("steady-1.pid").exists());
let text = std::fs::read_to_string(&config).unwrap();
std::fs::write(&config, text.replace("\"hook\"]", "\"hook-v2\"]")).unwrap();
knot_static(&config, &["prepare"]);
let again = wait_for(&records.join("1-hook.json"));
assert_eq!(
again["generation"],
id.as_str(),
"the active generation, not a new one"
);
assert!(
alive(steady_pid) && !records.join("steady-1.pid").exists(),
"changing render leaves steady's running process untouched"
);
knot_static(&config, &["prepare"]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(calls(&records), 2, "once");
let second = format!("cp \"$1\" {}/second-hook.json", records.display());
let text = std::fs::read_to_string(&config).unwrap();
std::fs::write(
&config,
format!(
"{text}\n[[hooks]]\nname = \"second\"\ncommand = \"/bin/sh\"\nargs = [\"-c\", {second:?}, \"second\"]\n"
),
)
.unwrap();
knot_static(&config, &["prepare"]);
let added = wait_for(&records.join("second-hook.json"));
assert_eq!(added["generation"], id.as_str());
knot_static(&config, &["prepare"]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
calls(&records),
3,
"adding second runs only second and leaves render and steady alone"
);
assert!(alive(steady_pid) && !records.join("steady-1.pid").exists());
let _ = std::process::Command::new("kill")
.arg(steady_pid.to_string())
.status();
}