mod common;
use common::{commit, git, repo_key, repo_token};
use serde::Deserialize;
use sha2::Digest as _;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::process::Output;
const OWNER: &str = "did:web:owner.phase5.test";
const KNOT_HOST: &str = "knot.phase5.test";
const MASTER_KEY_ENV: &str = "KNOT_STATIC_PHASE5_KEY";
const MASTER_KEY: [u8; 32] = [5; 32];
#[derive(Deserialize, Debug)]
struct Manifest {
#[serde(default)]
repos: Vec<RepoEntry>,
}
#[derive(Deserialize, Debug, Clone)]
struct RepoEntry {
repo_did: String,
#[serde(default)]
release_id: Option<String>,
#[serde(default)]
public_refs: Vec<(String, String)>,
}
impl Manifest {
fn load(generation: &Path) -> Self {
let bytes = std::fs::read(generation.join("manifest.json")).expect("generation manifest");
serde_json::from_slice(&bytes).expect("parse generation manifest")
}
}
fn atproto_latest(generation: &Path, repo_did: &str) -> Vec<u8> {
std::fs::read(
generation
.join("www/_atproto")
.join(repo_token(repo_did))
.join("latest.json"),
)
.expect("latest.json")
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
struct BackendRecord {
version: u32,
revision: u64,
generation: String,
#[serde(default)]
rollback: bool,
telemetry: Option<Telemetry>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
struct Telemetry {
config_test_seconds: Option<f64>,
reload_seconds: Option<f64>,
#[serde(default)]
nginx_config_bytes: u64,
#[serde(default)]
fragments_total: usize,
#[serde(default)]
fragments_reused: usize,
}
fn backend_record(output: &Path) -> BackendRecord {
let bytes = std::fs::read(output.join("active.json")).expect("backend publication record");
serde_json::from_slice(&bytes).expect("parse backend record")
}
fn knot_static_raw(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
) -> Output {
common::knot_static_raw(config, MASTER_KEY_ENV, &MASTER_KEY, args, &[])
}
fn knot_static_env(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
env: &[(&str, &str)],
) -> Output {
let output = common::knot_static_raw(config, MASTER_KEY_ENV, &MASTER_KEY, args, env);
common::assert_success(&output, "knot-static");
output
}
fn knot_static(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
) -> Output {
common::knot_static(config, MASTER_KEY_ENV, &MASTER_KEY, args)
}
struct Env {
_root: tempfile::TempDir,
config: PathBuf,
bad_nginx_config: PathBuf,
output: PathBuf,
scan: PathBuf,
dids: BTreeMap<String, String>,
}
fn make_source(source: &Path, seed: &str) {
std::fs::create_dir_all(source).unwrap();
git(source, ["init", "-q", "-b", "main"]);
commit(
source,
"README.md",
&format!("# {seed}\nphase5\n"),
"initial commit",
);
commit(
source,
&format!("src/{seed}.rs"),
"fn main() {}\n",
"add code",
);
git(source, ["tag", "v1", "HEAD"]);
}
fn write_config(path: &Path, scan: &Path, output: &Path, root: &Path, nginx_binary: &str) {
std::fs::write(
path,
format!(
r#"[knot]
hostname = "{KNOT_HOST}"
owner = "{OWNER}"
[repo]
scan_path = "{}"
default_branch = "main"
object_format = "sha1"
[secrets]
sealed_key_file = "{}"
master_key_env = "KNOT_STATIC_PHASE5_KEY"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{}"
[activate]
nginx_binary = "{nginx_binary}"
reload_command = "true"
"#,
scan.display(),
root.join("keys.sealed").display(),
output.display(),
),
)
.unwrap();
}
fn setup_env() -> Env {
let root = tempfile::tempdir().expect("tempdir");
let scan = root.path().join("state");
let output = root.path().join("output");
let config = root.path().join("config.toml");
std::fs::create_dir_all(&scan).unwrap();
write_config(&config, &scan, &output, root.path(), "true");
let bad_nginx_config = root.path().join("config_bad_nginx.toml");
write_config(&bad_nginx_config, &scan, &output, root.path(), "false");
knot_static(&config, ["init"]);
let mut dids = BTreeMap::new();
for seed in ["alpha", "beta"] {
let source = root.path().join(format!("source-{seed}"));
make_source(&source, seed);
let _ = knot_static_raw(
&config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
seed,
"--name",
seed,
"--default-branch",
"main",
source.to_str().unwrap(),
],
);
let listed = knot_static(&config, ["repo", "list"]);
let did = String::from_utf8(listed.stdout)
.unwrap()
.lines()
.find_map(|line| {
let did = line.split_whitespace().next()?;
line.contains(seed).then(|| did.to_string())
})
.unwrap_or_else(|| panic!("repo {seed} must be listed"));
dids.insert(seed.to_string(), did);
}
Env {
_root: root,
config,
bad_nginx_config,
output,
scan,
dids,
}
}
fn push_commit(env: &Env, seed: &str, marker: &str) -> String {
let did = env.dids.get(seed).expect("seed did");
let layout_repo = find_layout_repo(&env.scan, did).expect("layout repo copy");
git(
&layout_repo,
["config", "receive.denyCurrentBranch", "ignore"],
);
let wt = env._root.path().join(format!("push-wt-{seed}"));
if !wt.exists() {
std::fs::create_dir_all(&wt).unwrap();
git(&wt, ["clone", "-q", layout_repo.to_str().unwrap(), "."]);
git(&wt, ["config", "user.email", "phase5@test"]);
git(&wt, ["config", "user.name", "Phase5"]);
}
std::fs::write(wt.join(marker), "new\n").unwrap();
git(&wt, ["add", "-A"]);
git(&wt, ["commit", "-q", "-m", marker]);
git(
&wt,
["push", "-q", layout_repo.to_str().unwrap(), "HEAD:main"],
);
git(&wt, ["rev-parse", "HEAD"])
}
fn find_layout_repo(scan: &Path, repo_did: &str) -> Option<PathBuf> {
let identity = repo_did.replace([':', '.'], "");
let mut found = None;
visit_dirs(scan, &mut |dir| {
if found.is_some() {
return;
}
if dir.join("HEAD").is_file() && dir.join("config").is_file() {
let concatenated: String = dir
.strip_prefix(scan)
.unwrap_or(dir)
.to_string_lossy()
.chars()
.filter(|c| *c != '/' && *c != '\\')
.collect();
if identity.contains(&concatenated) || concatenated.contains(&identity) {
found = Some(dir.to_path_buf());
}
}
});
found
}
fn visit_dirs(dir: &Path, callback: &mut impl FnMut(&Path)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
callback(&path);
visit_dirs(&path, callback);
}
}
}
fn current_target(output: &Path) -> Option<String> {
let link = std::fs::read_link(output.join("current")).ok()?;
link.file_name().and_then(|n| n.to_str()).map(String::from)
}
fn generations(output: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::fs::read_dir(output.join("generations"))
.expect("generations root")
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.file_name().unwrap().to_str().unwrap().starts_with('.'))
.collect();
dirs.sort_by_key(|p| std::fs::metadata(p).unwrap().modified().unwrap());
dirs
}
fn activate(config: &Path, generation: &str) -> Output {
knot_static_raw(config, ["activate", generation])
}
fn activate_ok(env: &Env, generation: &str) -> Output {
let out = activate(&env.config, generation);
assert!(
out.status.success(),
"activate {generation} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out
}
fn snapshot_tree(root: &Path) -> HashMap<PathBuf, (u64, String)> {
let mut files = HashMap::new();
fn visit(root: &Path, dir: &Path, files: &mut HashMap<PathBuf, (u64, String)>) {
for entry in std::fs::read_dir(dir).expect("walk dir").flatten() {
let path = entry.path();
// repo mounts are symlinks into repo storage, not the generation's payload
if entry.file_type().map(|t| t.is_symlink()).unwrap_or(false) {
continue;
}
if path.is_dir() {
visit(root, &path, files);
} else {
let bytes = std::fs::read(&path).expect("read file");
let digest = knot_types::lowercase_hex(&sha2::Sha256::digest(&bytes));
files.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
(bytes.len() as u64, digest),
);
}
}
}
visit(root, root, &mut files);
files
}
fn releases(output: &Path, repo_did: &str) -> Vec<PathBuf> {
let root = output
.join("repos")
.join(repo_key(repo_did))
.join("releases");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("releases root {}: {e}", root.display()))
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.file_name().unwrap().to_str().unwrap().starts_with('.'))
.collect();
dirs.sort();
dirs
}
fn map_fragments(generation: &Path) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let dir = generation.join("nginx/maps");
for entry in std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("maps dir {}: {e}", dir.display()))
.flatten()
{
let path = entry.path();
if path.is_file() {
let bytes = std::fs::read(&path).unwrap();
let hex = knot_types::lowercase_hex(&sha2::Sha256::digest(&bytes));
out.insert(entry.file_name().to_string_lossy().into_owned(), hex);
}
}
out
}
#[cfg(unix)]
fn inode(path: &Path) -> u64 {
use std::os::unix::fs::MetadataExt as _;
std::fs::metadata(path).unwrap().ino() as u64
}
#[test]
fn activation_publishes_revisioned_records_and_visible_telemetry() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
let record = backend_record(&env.output);
assert_eq!(record.version, 1);
assert_eq!(record.revision, 1);
assert_eq!(record.generation, gen0_id);
assert!(!record.rollback);
let telemetry = record.telemetry.clone().expect("telemetry recorded");
assert!(
telemetry.config_test_seconds.is_some(),
"nginx -t cost must be measured"
);
assert!(
telemetry.reload_seconds.is_some(),
"reload cost must be measured and recorded, not hidden"
);
assert!(telemetry.nginx_config_bytes > 0);
assert!(telemetry.fragments_total > 0);
assert_eq!(telemetry.fragments_reused, 0);
let manifest = Manifest::load(&gen0);
for did in env.dids.values() {
let entry = manifest
.repos
.iter()
.find(|e| &e.repo_did == did)
.expect("manifest entry");
let release_id = entry.release_id.clone().expect("release id recorded");
assert!(
env.output
.join("repos")
.join(repo_key(did))
.join("releases")
.join(&release_id)
.is_dir(),
"the manifest must name a published release directory"
);
}
activate_ok(&env, &gen0_id);
assert_eq!(backend_record(&env.output).revision, 2);
}
#[test]
fn failed_activation_leaves_active_generation_byte_identical_and_servable() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
let before_files = snapshot_tree(&gen0);
let before_conf = std::fs::read(env.output.join("active.conf")).unwrap();
let before_backend = std::fs::read(env.output.join("active.json")).unwrap();
push_commit(&env, "alpha", "failed-activation.txt");
knot_static(&env.config, ["generate"]);
let gen1 = generations(&env.output).pop().unwrap();
let gen1_id = gen1.file_name().unwrap().to_str().unwrap().to_string();
assert_ne!(gen0_id, gen1_id);
let failed = activate(&env.bad_nginx_config, &gen1_id);
assert!(
!failed.status.success(),
"activation must fail with the broken nginx binary"
);
let after_files = snapshot_tree(&gen0);
let moved: Vec<&PathBuf> = before_files
.iter()
.filter(|(path, before)| after_files.get(*path) != Some(*before))
.map(|(path, _)| path)
.collect();
assert!(
moved.is_empty(),
"generation bytes must not move; {} files changed, first: {:?} before {:?} after {:?}",
moved.len(),
moved.first(),
moved.first().and_then(|p| before_files.get(*p)),
moved.first().and_then(|p| after_files.get(*p)),
);
assert_eq!(
before_conf,
std::fs::read(env.output.join("active.conf")).unwrap(),
"active.conf must be restored byte-identically"
);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen0_id.as_str()),
"current must still name the active generation"
);
assert_eq!(
before_backend,
std::fs::read(env.output.join("active.json")).unwrap(),
"backend record must be untouched by the failed activation"
);
activate_ok(&env, &gen0_id);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen0_id.as_str())
);
activate_ok(&env, &gen1_id);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen1_id.as_str())
);
}
#[test]
fn stale_candidate_cannot_displace_newer_activation() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
push_commit(&env, "alpha", "alpha-first.txt");
knot_static_env(
&env.config,
["generate", "--repo", env.dids["alpha"].as_str()],
&[],
);
let gen1 = generations(&env.output).pop().unwrap();
let gen1_id = gen1.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen1_id);
push_commit(&env, "beta", "beta-first.txt");
knot_static_env(
&env.config,
["generate", "--repo", env.dids["beta"].as_str()],
&[],
);
let gen2 = generations(&env.output).pop().unwrap();
let gen2_id = gen2.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen2_id);
let stale = activate(&env.config, &gen1_id);
assert!(
!stale.status.success(),
"a stale candidate must not displace the newer active state"
);
assert!(
String::from_utf8_lossy(&stale.stderr).contains("stale publication candidate"),
"stderr was: {}",
String::from_utf8_lossy(&stale.stderr)
);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen2_id.as_str())
);
assert_eq!(backend_record(&env.output).generation, gen2_id);
let before_revision = backend_record(&env.output).revision;
let rollback = knot_static(&env.config, ["activate", "--rollback", &gen1_id]);
assert!(
rollback.status.success(),
"rollback failed: {}",
String::from_utf8_lossy(&rollback.stderr)
);
let record = backend_record(&env.output);
assert_eq!(record.generation, gen1_id);
assert!(record.rollback);
assert!(
record.revision > before_revision,
"rollback must publish a new, higher revision"
);
activate_ok(&env, &gen2_id);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen2_id.as_str())
);
}
#[test]
fn concurrent_repo_updates_never_lose_one_another() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
let alpha_tip = push_commit(&env, "alpha", "concurrent-alpha.txt");
let beta_tip = push_commit(&env, "beta", "concurrent-beta.txt");
let thread_config = env.config.clone();
let alpha_did = env.dids["alpha"].clone();
let beta_did = env.dids["beta"].clone();
let thread_alpha_did = alpha_did.clone();
let first = std::thread::spawn(move || {
knot_static(&thread_config, ["generate", "--repo", &thread_alpha_did])
});
let second = knot_static(&env.config, ["generate", "--repo", &beta_did]);
let first = first.join().unwrap();
assert!(first.status.success(), "concurrent generate A failed");
assert!(second.status.success(), "concurrent generate B failed");
let built: Vec<String> = generations(&env.output)
.iter()
.filter(|p| p.join("manifest.json").is_file())
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(built.len(), 3, "two candidates plus the active generation");
let candidate_a = &built
.iter()
.find(|id| **id != gen0_id)
.expect("candidate")
.clone();
activate_ok(&env, candidate_a);
let candidate_b = built
.iter()
.find(|id| **id != gen0_id && *id != candidate_a)
.expect("second candidate");
let refused = activate(&env.config, candidate_b);
assert!(
!refused.status.success()
&& String::from_utf8_lossy(&refused.stderr).contains("stale publication candidate"),
"second candidate must be refused, stderr: {}",
String::from_utf8_lossy(&refused.stderr)
);
knot_static(&env.config, ["generate"]);
let merged = generations(&env.output).pop().unwrap();
let merged_id = merged.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &merged_id);
let manifest = Manifest::load(&merged);
let entry_refs = |seed: &str| -> Vec<String> {
let did = env.dids[seed].clone();
manifest
.repos
.iter()
.find(|e| e.repo_did == did)
.unwrap_or_else(|| panic!("{seed} entry in merged manifest"))
.public_refs
.iter()
.map(|(_, oid)| oid.clone())
.collect()
};
assert!(
entry_refs("alpha").contains(&alpha_tip),
"alpha's pushed tip must survive in the merged active generation"
);
assert!(
entry_refs("beta").contains(&beta_tip),
"beta's pushed tip must survive in the merged active generation"
);
let _ = gen0_id;
}
#[test]
fn unchanged_payloads_and_fragments_are_reused_not_rematerialized() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
let alpha_did = env.dids["alpha"].clone();
let beta_did = env.dids["beta"].clone();
let beta_releases_before = releases(&env.output, &beta_did);
assert_eq!(beta_releases_before.len(), 1);
push_commit(&env, "alpha", "reuse-alpha.txt");
knot_static_env(&env.config, ["generate", "--repo", alpha_did.as_str()], &[]);
let gen1 = generations(&env.output).pop().unwrap();
let gen1_id = gen1.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen1_id);
let beta_releases_after = releases(&env.output, &beta_did);
assert_eq!(
beta_releases_after, beta_releases_before,
"unchanged repo must keep its release (no new release, no rewrite)"
);
let beta_token = repo_token(&beta_did);
#[cfg(unix)]
{
let release = &beta_releases_after[0];
for relative in ["HEAD", "export.fingerprint"] {
assert_eq!(
inode(&release.join("export").join(relative)),
inode(&gen1.join("www/_git").join(&beta_token).join(relative)),
"generation export must be the release bytes ({relative})"
);
}
}
assert_eq!(
atproto_latest(&gen0, &beta_did),
atproto_latest(&gen1, &beta_did),
"an unchanged repo mints no new commit"
);
assert_ne!(
atproto_latest(&gen0, &alpha_did),
atproto_latest(&gen1, &alpha_did),
"the pushed repo's head moves"
);
let fragments_before = map_fragments(&gen0);
let fragments_after = map_fragments(&gen1);
let shared: Vec<&String> = fragments_before
.iter()
.filter(|(name, digest)| fragments_after.get(*name) == Some(*digest))
.map(|(name, _)| name)
.collect();
assert!(
shared.contains(&&"60_atproto.conf".to_string()),
"unchanged nginx fragments must be reused across activations: {shared:?}"
);
}
#[test]
fn adding_a_repo_leaves_existing_releases_and_atproto_heads_alone() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
let release_ids_before: BTreeMap<String, String> = env
.dids
.values()
.map(|did| {
(
did.clone(),
releases(&env.output, did)[0]
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
)
})
.collect();
let listing = |generation: &Path| {
std::fs::read_to_string(generation.join("www/_atproto/list/50-0.json")).unwrap()
};
let source = env._root.path().join("source-gamma");
make_source(&source, "gamma");
let _ = knot_static_raw(
&env.config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"gamma",
"--name",
"gamma",
"--default-branch",
"main",
source.to_str().unwrap(),
],
);
knot_static(&env.config, ["generate"]);
let gen1 = generations(&env.output).pop().unwrap();
let gen1_id = gen1.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen1_id);
assert_ne!(
listing(&gen0),
listing(&gen1),
"adding a repo must change the published listing"
);
for (did, release_id) in &release_ids_before {
assert_eq!(
&releases(&env.output, did)[0]
.file_name()
.unwrap()
.to_string_lossy(),
release_id,
"unchanged repo keeps its release after a discovery change"
);
assert_eq!(
atproto_latest(&gen0, did),
atproto_latest(&gen1, did),
"unchanged repo keeps its atproto head"
);
}
}
#[test]
fn rollback_retains_old_release_bytes() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let gen0 = generations(&env.output).pop().unwrap();
let gen0_id = gen0.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen0_id);
push_commit(&env, "alpha", "rollback-alpha.txt");
knot_static_env(
&env.config,
["generate", "--repo", env.dids["alpha"].as_str()],
&[],
);
let gen1 = generations(&env.output).pop().unwrap();
let gen1_id = gen1.file_name().unwrap().to_str().unwrap().to_string();
activate_ok(&env, &gen1_id);
let gen1_files = snapshot_tree(&gen1);
for did in env.dids.values() {
let repo_key = did.replace(':', "%3A");
let _ = std::fs::remove_dir_all(env.output.join("repos").join(&repo_key).join("cache"));
}
let rollback = knot_static(&env.config, ["activate", "--rollback", &gen0_id]);
assert!(
rollback.status.success(),
"rollback failed: {}",
String::from_utf8_lossy(&rollback.stderr)
);
assert_eq!(
current_target(&env.output).as_deref(),
Some(gen0_id.as_str())
);
assert_eq!(
gen1_files,
snapshot_tree(&gen1),
"the superseded generation must remain byte-identical"
);
for did in env.dids.values() {
for release in releases(&env.output, did) {
let marker = std::fs::read(release.join("release.json")).expect("release marker");
assert!(serde_json::from_slice::<serde_json::Value>(&marker).is_ok());
}
}
}
#[test]
fn gc_keeps_what_rollback_and_draining_workers_need() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
let mut ids = vec![newest_generation(&env)];
activate_ok(&env, &ids[0]);
for round in 0..3 {
push_commit(&env, "alpha", &format!("gc-{round}.txt"));
knot_static_env(
&env.config,
["generate", "--repo", env.dids["alpha"].as_str()],
&[],
);
let id = newest_generation(&env);
activate_ok(&env, &id);
ids.push(id);
}
let alpha = env.dids["alpha"].as_str();
let release_of = |id: &str| {
Manifest::load(&env.output.join("generations").join(id))
.repos
.into_iter()
.find(|repo| repo.repo_did == alpha)
.and_then(|repo| repo.release_id)
.unwrap()
};
let alpha_releases: Vec<String> = ids.iter().map(|id| release_of(id)).collect();
knot_static(&env.config, ["gc", "--keep", "1"]);
assert_eq!(
generations(&env.output).len(),
4,
"all four may still be drained"
);
drain_window_passed(&env);
activate_ok(&env, &ids[3]);
let kept: Vec<String> = generations(&env.output)
.iter()
.map(|dir| file_name(dir))
.collect();
assert_eq!(
kept,
ids[1..],
"activation keeps the configured two rollback targets"
);
knot_static(&env.config, ["gc", "--keep", "1"]);
let kept: Vec<String> = generations(&env.output)
.iter()
.map(|dir| file_name(dir))
.collect();
assert_eq!(kept, ids[2..]);
let on_disk: Vec<String> = releases(&env.output, alpha)
.iter()
.map(|dir| file_name(dir))
.collect();
for (index, release) in alpha_releases.iter().enumerate() {
assert_eq!(
on_disk.contains(release),
index >= 2,
"alpha release of generation {index}"
);
}
for (seed, did) in &env.dids {
if seed != "alpha" {
assert_eq!(
releases(&env.output, did).len(),
1,
"{seed} keeps its one release"
);
}
}
let rollback = knot_static_raw(&env.config, ["activate", "--rollback", &ids[2]]);
assert!(
rollback.status.success(),
"rollback to a kept generation: {}",
String::from_utf8_lossy(&rollback.stderr)
);
assert_eq!(
current_target(&env.output).as_deref(),
Some(ids[2].as_str())
);
let gone = knot_static_raw(&env.config, ["activate", "--rollback", &ids[0]]);
assert!(
!gone.status.success(),
"a collected generation can't be activated"
);
}
const POOLED: [&str; 3] = ["records", "lists", "versions"];
#[test]
fn gc_keeps_the_pooled_files_kept_seals_reach() {
let env = setup_env();
knot_static(&env.config, ["generate"]);
activate_ok(&env, &newest_generation(&env));
let alpha = env.dids["alpha"].as_str();
for round in 0..3 {
push_commit(&env, "alpha", &format!("pool-{round}.txt"));
knot_static_env(&env.config, ["generate", "--repo", alpha], &[]);
activate_ok(&env, &newest_generation(&env));
}
let served = env
.output
.join("repos")
.join(repo_key(alpha))
.join("served");
let pooled = || {
let mut files = BTreeSet::new();
for shared in POOLED {
for entry in std::fs::read_dir(served.join("atproto").join(shared)).unwrap() {
let name = entry.unwrap().file_name().into_string().unwrap();
files.insert(format!("{shared}/{name}"));
}
}
files
};
let before = pooled();
drain_window_passed(&env);
knot_static(&env.config, ["gc", "--keep", "1"]);
let kept = generations(&env.output);
assert_eq!(kept.len(), 2);
let mut reached = BTreeSet::new();
for generation in &kept {
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(generation.join("manifest.json")).unwrap())
.unwrap();
let seal = manifest["profile"]["atproto_entries"][alpha]
.as_str()
.unwrap();
let seal: serde_json::Value = serde_json::from_slice(
&std::fs::read(served.join("atproto-seal").join(format!("{seal}.json"))).unwrap(),
)
.unwrap();
for (file, len) in seal["payload"]["files"].as_object().unwrap() {
let held = std::fs::metadata(served.join("atproto").join(file)).map(|m| m.len());
assert_eq!(
held.ok(),
len.as_u64(),
"{file} of {}",
generation.display()
);
if POOLED
.iter()
.any(|shared| file.starts_with(&format!("{shared}/")))
{
reached.insert(file.clone());
}
}
assert!(!atproto_latest(generation, alpha).is_empty());
}
assert_eq!(pooled(), reached);
assert!(
reached.len() < before.len(),
"{} of {}",
reached.len(),
before.len()
);
let rollback = knot_static_raw(
&env.config,
["activate", "--rollback", &file_name(&kept[0])],
);
assert!(
rollback.status.success(),
"{}",
String::from_utf8_lossy(&rollback.stderr)
);
}
fn drain_window_passed(env: &Env) {
let record_path = env.output.join("active.json");
let mut record: serde_json::Value =
serde_json::from_slice(&std::fs::read(&record_path).unwrap()).unwrap();
for entry in record["superseded"].as_array_mut().unwrap() {
entry["at_unix"] = 0.into();
}
std::fs::write(&record_path, serde_json::to_vec(&record).unwrap()).unwrap();
}
fn newest_generation(env: &Env) -> String {
file_name(&generations(&env.output).pop().unwrap())
}
fn file_name(path: &Path) -> String {
path.file_name().unwrap().to_str().unwrap().to_string()
}