a knot runs each configured [[hooks]] entry after an activation, with its own lock, pid, started record and log
migration: replace `[site_hook]` with `[[hooks]]` (`name = "svfe-pages"`, `command`, `args`), and remove old `runtime/site-hook.{lock,pid,started.json}` and `site-hook.log` files on trimounts.
65 unmodified lines6667686970707172737477 unmodified lines65 unmodified lines# a firehose keeps the write from polling its own listRepos, as on a deployed knotsed '/^\[firehose\]/,/^\[/d' "$base_config" >"$fx/quiet.toml"printf '\n[firehose]\nsocket = "%s"\n' "$fx/firehose.sock" >>"$fx/quiet.toml"cp "$fx/quiet.toml" "$fx/render.toml"printf '\n[site_hook]\ncommand = "%s"\n' "$fx/stub.sh" >>"$fx/render.toml"printf '\n[[hooks]]\nname = "render"\ncommand = "%s"\n' "$fx/stub.sh" >>"$fx/render.toml"
ks() { "$bin" --config "$1" "${@:2}"; }# one issue to comment on in each of up to five repos the owner can write astouch "$fx/issues"77 unmodified lines103 unmodified linesExpand all104105106107108108109110111112317 unmodified linesExpand all103 unmodified linesExpand all /// skip updating owner declarations on their pds #[arg(long)] no_declarations: bool, }, /// start the site hook and collect garbage after a write's activation /// start the hooks and collect garbage after a write's activation #[command(hide = true)] AfterActivation { generation: String, #[arg(long)]317 unmodified linesExpand all216 unmodified linesExpand all21721821922022122122222322422422522622722822923023023123223323497 unmodified lines216 unmodified linesExpand all let before = publish::current_generation(&config.output_root); if let Err(error) = writes::publish_scoped(&config, None).await { tracing::error!("rebuild with the new binary: {error:#}"); } // an activation starts the renderer itself, a new one alone needs a run of its own // an activation starts the hooks itself, a new or changed one alone needs a run of its own let current = publish::current_generation(&config.output_root); if let Some(current) = current.filter(|current| before.as_ref() == Some(current)) && generate::site_hook::changed(&config) && generate::hooks::changed(&config) { let at = publish::unix_now().to_string(); if let Err(error) = git_ssh::spawn_detached(&config, ["after-activation", ¤t, "--at", &at]) { tracing::error!("start the new site hook: {error:#}"); tracing::error!("start the changed hooks: {error:#}"); } } Ok(()) }97 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443mod 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();}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340mod 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"
[site_hook]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_the_running_renderer() { 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 = format!( "n=$(ls {records} | grep -c pid); echo $$ > {records}/$n.pid; exec sleep 60", records = records.display() ); let text = std::fs::read_to_string(&config).unwrap(); let args = text .lines() .find(|line| line.starts_with("args = ")) .unwrap(); std::fs::write( &config, text.replace(args, &format!("args = [\"-c\", {slow:?}, \"hook\"]")), ) .unwrap(); knot_static(&config, &["init"]); let id = generate(&config).expect("an empty knot still builds a generation"); let pid = |n: usize| -> i32 { let path = records.join(format!("{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, "renderer {n} never started"); std::thread::sleep(Duration::from_millis(50)); } std::fs::read_to_string(&path) .unwrap() .trim() .parse() .unwrap() }; knot_static(&config, &["activate", &id]); let first = pid(0); let started = Instant::now(); knot_static(&config, &["activate", &id]); let second = pid(1); 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 renderer was stopped"); assert!(alive(second), "the newer one runs"); std::process::Command::new("kill") .arg(second.to_string()) .status() .unwrap();}
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_only_the_renderer_runs_it_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 id = generate(&config).expect("a first generation"); knot_static(&config, &["activate", &id]); wait_for(&records.join("0-hook.json"));
knot_static(&config, &["prepare"]); std::thread::sleep(Duration::from_secs(2)); assert_eq!(calls(&records), 1, "the same renderer isn't run again");
let text = std::fs::read_to_string(&config).unwrap(); std::fs::write(&config, text.replace("\"hook\"]", "\"renderer-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" );
knot_static(&config, &["prepare"]); std::thread::sleep(Duration::from_secs(2)); assert_eq!(calls(&records), 2, "once");}249 unmodified linesExpand all250251252253254254255256257258120 unmodified linesExpand all249 unmodified linesExpand all // a failed reload leaves old workers on the previous generation. keep at least one anyhow::ensure!(keep >= 1, "activate.keep_generations must be at least 1"); keep }, site_hook: raw.site_hook, hooks: Hook::validate(raw.hooks)?, }; if activate.real_ip_from.is_empty() && listens_on_loopback(&activate.listen) { tracing::warn!( listen = activate.listen,120 unmodified linesExpand all36 unmodified lines37383940414142434344454546474849505151525354555657585960616263606465666768697071727374757677787980818283848586878889909192297 unmodified linesExpand all36 unmodified lines}
#[test]fn a_misplaced_or_misspelled_key_is_refused_by_name() { let error = load_result("[activate.site_hook]\ncommand = \"/bin/true\"") let error = load_result("[[activate.hooks]]\nname = \"render\"\ncommand = \"/bin/true\"") .err() .expect("site_hook is a top-level table"); .expect("hooks is a top-level table"); assert!( format!("{error:#}").contains("unknown field `site_hook`"), format!("{error:#}").contains("unknown field `hooks`"), "{error:#}" ); for (extra, key) in [ ("[index]\nlink = \"https://example.test\"", "link"), ("[serve.limits]\npack_rates = \"2m\"", "pack_rates"), ("[site_hook]\ncommand = \"/bin/true\"\nargv = []", "argv"), ("[site_hook]\ncommand = \"/bin/true\"", "site_hook"), ( "[[hooks]]\nname = \"render\"\ncommand = \"/bin/true\"\nargv = []", "argv", ), ("[knots]\nhostname = \"knot.test\"", "knots"), ] { let error = load_result(extra).err().expect(extra); assert!( format!("{error:#}").contains(&format!("unknown field `{key}`")), "{error:#}" ); } assert!(load_result("[site_hook]\ncommand = \"/bin/true\"").is_ok()); let hooks = load_with( "[[hooks]]\nname = \"svfe-pages\"\ncommand = \"/bin/true\"\n[[hooks]]\nname = \"notify_1\"\ncommand = \"/bin/true\"", ); assert_eq!(hooks.activate.hooks.len(), 2); for (bad, says) in [ ( "[[hooks]]\nname = \"\"\ncommand = \"/bin/true\"", "plain name", ), ( "[[hooks]]\nname = \"../escape\"\ncommand = \"/bin/true\"", "plain name", ), ( "[[hooks]]\nname = \"a/b\"\ncommand = \"/bin/true\"", "plain name", ), ( "[[hooks]]\nname = \"dup\"\ncommand = \"/bin/true\"\n[[hooks]]\nname = \"dup\"\ncommand = \"/bin/true\"", "more than once", ), ] { let error = load_result(bad).err().expect(bad); assert!(format!("{error:#}").contains(says), "{bad}: {error:#}"); }}
/// what nix/module.nix writes before any `settings` of its own#[test]297 unmodified linesExpand all9 unmodified lines1011121314141516171819192021222324252627282930313233343536373839404142434445464748495051525354555657335859606162533 unmodified linesExpand all59659759859957560060160260360454 unmodified lines9 unmodified lines pub reload_command: Option<String>, pub listen: String, pub real_ip_from: Vec<String>, pub keep_generations: usize, pub site_hook: Option<SiteHook>, pub hooks: Vec<Hook>,}
/// hooks run after each activation, and a new activation supersedes the hook's own running one#[derive(Debug, Clone, Deserialize)]#[serde(deny_unknown_fields)]pub struct SiteHook {pub struct Hook { pub name: String, pub command: PathBuf, #[serde(default)] pub args: Vec<String>,}
impl Hook { pub(crate) fn validate(hooks: Vec<Self>) -> anyhow::Result<Vec<Self>> { let mut names = std::collections::BTreeSet::new(); for hook in &hooks { anyhow::ensure!( !hook.name.is_empty() && hook .name .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "hooks name {:?} is not a plain name", hook.name ); anyhow::ensure!( names.insert(hook.name.as_str()), "hooks names {:?} more than once", hook.name ); } Ok(hooks) }}
impl Default for ActivateConfig { fn default() -> Self { Self { nginx_binary: "nginx".to_string(), reload_command: None, listen: "8080".to_string(), real_ip_from: Vec::new(), keep_generations: DEFAULT_KEEP_GENERATIONS, site_hook: None, hooks: Vec::new(), } }}
533 unmodified linesExpand all pub(crate) firehose: RawFirehose, #[serde(default)] pub(crate) index: RawIndex, #[serde(default)] pub(crate) site_hook: Option<SiteHook>, pub(crate) hooks: Vec<Hook>,}
#[derive(Deserialize)]#[serde(deny_unknown_fields)]54 unmodified lines