use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::{
SEED_MARKER, SlotPool, SlotPoolError, body_reads_seeded, hold_idle_build_locks,
marker_reads_seeded, seed_failure_path, subdirectories, unique_nanos,
};
const SERVED_PREFIX: &str = "served: ";
const INVALIDATED_DIR: &str = ".trusty-invalidated";
impl SlotPool {
#[must_use]
pub fn with_checkout(mut self, checkout: PathBuf) -> Self {
self.checkout = Some(checkout);
self
}
#[must_use]
pub fn is_seeded(&self, index: u32) -> bool {
marker_reads_seeded(&self.slot_path(index))
}
#[must_use]
pub fn existing_indexes(&self) -> Vec<u32> {
let slot = self.slot_path(0);
let Some(Ok(entries)) = slot.parent().map(std::fs::read_dir) else {
return Vec::new();
};
let mut indexes: Vec<u32> = entries
.flatten()
.filter_map(|e| e.file_name().to_str()?.strip_prefix("slot-")?.parse().ok())
.collect();
indexes.sort_unstable();
indexes
}
#[must_use]
pub fn last_seed_failure(&self, index: u32) -> Option<String> {
let body = std::fs::read_to_string(seed_failure_path(&self.slot_path(index))).ok()?;
body.lines()
.find_map(|line| line.strip_prefix("error: "))
.map(str::to_string)
}
pub fn hand_over(&self, index: u32, holder: &str) -> Result<PathBuf, SlotPoolError> {
let path = self.slot_path(index);
let fail = |detail: String| SlotPoolError::HandOver {
path: path.clone(),
detail,
};
let marker = path.join(SEED_MARKER);
let body = std::fs::read_to_string(&marker)
.map_err(|err| fail(format!("could not read {}: {err}", marker.display())))?;
if !body_reads_seeded(&body) {
return Err(fail("the slot is not seeded".to_string()));
}
let served = match &self.checkout {
Some(checkout) => format!("{SERVED_PREFIX}{holder} from {}", checkout.display()),
None => format!("{SERVED_PREFIX}{holder}"),
};
if body.lines().any(|line| line == served) {
return Ok(path);
}
let packages = path_packages(self.checkout.as_deref()).map_err(fail)?;
let _locks = hold_idle_build_locks(&path).map_err(|lock| SlotPoolError::ActiveBuild {
path: path.clone(),
lock,
})?;
let moved = invalidate_fingerprints(&path, &packages).map_err(fail)?;
tracing::info!(slot = %path.display(), %served, moved, "builder slot handed to a new holder");
rewrite_served(&path, &body, &served).map_err(fail)?;
Ok(path)
}
#[must_use]
pub fn invalidated_trees(&self, index: u32) -> Vec<PathBuf> {
let trash = self.slot_path(index).join(INVALIDATED_DIR);
if !is_real_dir(&trash) {
return Vec::new();
}
subdirectories(&trash)
}
pub fn purge_invalidated(trees: &[PathBuf]) -> usize {
trees
.iter()
.filter(|tree| match std::fs::remove_dir_all(tree) {
Ok(()) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => {
tracing::warn!(tree = %tree.display(), "could not purge invalidated builder fingerprints: {err}");
false
}
})
.count()
}
}
fn path_packages(checkout: Option<&Path>) -> Result<BTreeSet<String>, String> {
#[derive(serde::Deserialize)]
struct Lock {
#[serde(default)]
package: Vec<Package>,
}
#[derive(serde::Deserialize)]
struct Package {
name: String,
#[serde(default)]
source: Option<String>,
}
let Some(checkout) = checkout else {
return Err("no checkout is recorded, so the repo's own packages are unknown".to_string());
};
let Some((lock_path, body)) = checkout.ancestors().find_map(|dir| {
let lock = dir.join("Cargo.lock");
std::fs::read_to_string(&lock).ok().map(|body| (lock, body))
}) else {
return Err(format!("no Cargo.lock at or above {}", checkout.display()));
};
let lock = toml::from_str::<Lock>(&body)
.map_err(|err| format!("could not parse {}: {err}", lock_path.display()))?;
let packages: BTreeSet<String> = lock
.package
.into_iter()
.filter(|p| p.source.is_none())
.map(|p| p.name)
.collect();
if packages.is_empty() {
return Err(format!("{} names no path package", lock_path.display()));
}
Ok(packages)
}
fn invalidate_fingerprints(slot: &Path, packages: &BTreeSet<String>) -> Result<usize, String> {
let trash =
slot.join(INVALIDATED_DIR)
.join(format!("{}.{}", std::process::id(), unique_nanos()));
let mut moved = 0;
let mut profiles = Vec::new();
for child in subdirectories(slot) {
if child.file_name() == Some(std::ffi::OsStr::new(INVALIDATED_DIR)) {
continue;
}
profiles.extend(subdirectories(&child));
profiles.push(child);
}
for (ordinal, profile) in profiles.iter().enumerate() {
let fingerprints = profile.join(".fingerprint");
if !is_real_dir(&fingerprints) {
continue;
}
let entries = std::fs::read_dir(&fingerprints)
.map_err(|err| format!("could not read {}: {err}", fingerprints.display()))?;
for entry in entries {
let entry =
entry.map_err(|err| format!("could not read {}: {err}", fingerprints.display()))?;
let name = entry.file_name();
let Some(package) = name.to_str().and_then(unit_package) else {
continue;
};
if !packages.contains(package) {
continue;
}
if moved == 0 {
create_trash(&trash)?;
}
let aside = trash.join(format!("{ordinal}.{}", name.to_string_lossy()));
std::fs::rename(entry.path(), &aside)
.map_err(|err| format!("could not move {} aside: {err}", entry.path().display()))?;
moved += 1;
}
}
Ok(moved)
}
fn is_real_dir(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|meta| meta.is_dir())
}
fn create_trash(trash: &Path) -> Result<(), String> {
let parent = trash.parent().unwrap_or(trash);
match std::fs::create_dir(parent) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && is_real_dir(parent) => {}
Err(err) => return Err(format!("could not create {}: {err}", parent.display())),
}
std::fs::create_dir(trash).map_err(|err| format!("could not create {}: {err}", trash.display()))
}
fn unit_package(name: &str) -> Option<&str> {
let (package, hash) = name.rsplit_once('-')?;
(hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit())).then_some(package)
}
fn rewrite_served(slot: &Path, body: &str, served: &str) -> Result<(), String> {
let mut next: String = body
.lines()
.filter(|line| !line.starts_with(SERVED_PREFIX))
.map(|line| format!("{line}\n"))
.collect();
next.push_str(served);
next.push('\n');
let marker = slot.join(SEED_MARKER);
let draft = slot.join(format!(
"{SEED_MARKER}.draft.{}.{}",
std::process::id(),
unique_nanos()
));
std::fs::write(&draft, next)
.and_then(|()| std::fs::rename(&draft, &marker))
.map_err(|err| {
drop(std::fs::remove_file(&draft));
format!("could not record the holder in {}: {err}", marker.display())
})
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::github_path::GithubPath;
fn bare_pool(root: &Path) -> SlotPool {
let identity = GithubPath {
owner: "acme".to_string(),
repo: "widgets".to_string(),
};
SlotPool::new(root.join("pool"), identity, 4)
}
fn pool_with_checkout(root: &Path) -> SlotPool {
let checkout = super::super::test_support::write_checkout(&root.join("checkout"));
bare_pool(root).with_checkout(checkout)
}
fn slot_held_by_a(pool: &SlotPool) -> (PathBuf, PathBuf) {
pool.seed(0, None).expect("a seeded slot 0");
let slot = pool.hand_over(0, "toolu_A").expect("A is handed slot 0");
let (local, _) = plant_fingerprints(&slot);
(slot, local)
}
fn served_line(slot: &Path) -> String {
std::fs::read_to_string(slot.join(SEED_MARKER))
.expect("marker")
.lines()
.find(|line| line.starts_with(SERVED_PREFIX))
.unwrap_or_default()
.to_string()
}
fn plant_fingerprints(slot: &Path) -> (PathBuf, PathBuf) {
let fingerprints = slot.join("debug").join(".fingerprint");
let local = fingerprints.join("widgets-core-0123456789abcdef");
let registry = fingerprints.join("serde-fedcba9876543210");
for dir in [&local, ®istry] {
std::fs::create_dir_all(dir).expect("fingerprint dir");
std::fs::write(dir.join("lib.json"), b"{}").expect("fingerprint");
}
(local, registry)
}
#[test]
fn a_slot_handed_to_a_new_holder_invalidates_the_repo_packages() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
pool.seed(0, None).expect("a seeded slot 0");
assert_eq!(pool.existing_indexes(), vec![0]);
let slot = pool.hand_over(0, "toolu_A").expect("A is handed slot 0");
let (local, registry) = plant_fingerprints(&slot);
pool.hand_over(0, "toolu_A").expect("A again");
assert!(local.is_dir(), "the same holder keeps its own build");
pool.hand_over(0, "toolu_B").expect("B is handed slot 0");
assert!(
!local.exists(),
"A's build of a repo package must not serve B"
);
assert!(registry.is_dir(), "the dependency cache stays warm");
let marker = std::fs::read_to_string(slot.join(SEED_MARKER)).expect("marker");
assert!(marker.contains("served: toolu_B from"), "{marker}");
assert!(!marker.contains("toolu_A"), "{marker}");
assert!(pool.is_seeded(0), "the rewrite keeps the slot seeded");
assert_eq!(pool.invalidated_trees(0).len(), 1, "one trash tree");
}
#[test]
fn an_unresolvable_package_set_hands_over_nothing() {
let registry_only = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n\
source = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
for (arm, lock) in [
("no checkout", None),
("no Cargo.lock", Some(None)),
("unparsable Cargo.lock", Some(Some("[[package]\n"))),
("no path package", Some(Some(registry_only))),
] {
let root = tempfile::tempdir().expect("tempdir");
let (slot, local) = slot_held_by_a(&pool_with_checkout(root.path()));
let before = served_line(&slot);
let pool = match lock {
None => bare_pool(root.path()),
Some(body) => {
let checkout = root.path().join("other-checkout");
std::fs::create_dir_all(&checkout).expect("checkout");
if let Some(body) = body {
std::fs::write(checkout.join("Cargo.lock"), body).expect("lock");
}
bare_pool(root.path()).with_checkout(checkout)
}
};
let err = pool
.hand_over(0, "toolu_B")
.expect_err(&format!("{arm}: an unknown package set hands over nothing"));
assert!(
matches!(err, SlotPoolError::HandOver { .. }),
"{arm}: {err}"
);
assert!(local.is_dir(), "{arm}: A's build is untouched");
assert_eq!(served_line(&slot), before, "{arm}: the marker keeps A");
}
}
#[test]
fn a_fingerprint_that_cannot_be_moved_hands_over_nothing() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, local) = slot_held_by_a(&pool);
let before = served_line(&slot);
let fingerprints = local.parent().expect("parent").to_path_buf();
let perms = std::fs::metadata(&fingerprints)
.expect("meta")
.permissions();
std::fs::set_permissions(&fingerprints, std::fs::Permissions::from_mode(0o555))
.expect("read-only .fingerprint");
let result = pool.hand_over(0, "toolu_B");
std::fs::set_permissions(&fingerprints, perms).expect("restore perms");
let err = result.expect_err("an unmovable fingerprint hands over nothing");
assert!(matches!(err, SlotPoolError::HandOver { .. }), "{err}");
assert!(local.is_dir(), "A's build is still in place");
assert_eq!(served_line(&slot), before, "the marker keeps A");
}
#[test]
fn a_marker_that_cannot_be_rewritten_hands_over_nothing() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, _) = slot_held_by_a(&pool);
let before = served_line(&slot);
std::fs::create_dir_all(slot.join(INVALIDATED_DIR)).expect("trash parent");
let perms = std::fs::metadata(&slot).expect("meta").permissions();
std::fs::set_permissions(&slot, std::fs::Permissions::from_mode(0o555))
.expect("read-only slot");
let result = pool.hand_over(0, "toolu_B");
std::fs::set_permissions(&slot, perms).expect("restore perms");
let err = result.expect_err("an unwritable marker hands over nothing");
assert!(matches!(err, SlotPoolError::HandOver { .. }), "{err}");
assert!(err.to_string().contains("record the holder"), "{err}");
assert_eq!(served_line(&slot), before, "the marker keeps A");
}
#[test]
fn a_leftover_trash_tree_is_purged_at_the_next_handover() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, local) = slot_held_by_a(&pool);
let leftover = slot
.join(INVALIDATED_DIR)
.join("1.1")
.join("0.widgets-core-0123456789abcdef");
std::fs::create_dir_all(&leftover).expect("an interrupted purge's tree");
std::fs::write(leftover.join("lib.json"), b"{}").expect("a file in it");
pool.hand_over(0, "toolu_B").expect("B is handed slot 0");
assert!(!local.exists(), "A's build is out of .fingerprint");
let trees = pool.invalidated_trees(0);
assert_eq!(
trees.len(),
2,
"the leftover and this handover's: {trees:?}"
);
assert_eq!(SlotPool::purge_invalidated(&trees), 2);
assert!(pool.invalidated_trees(0).is_empty(), "nothing piles up");
assert_eq!(
SlotPool::purge_invalidated(&trees),
2,
"a tree another purge already removed counts as gone"
);
}
#[test]
fn a_symlinked_fingerprint_directory_is_not_followed() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, _) = pool.seed(0, None).expect("a seeded slot 0");
let outside = root.path().join("outside");
let foreign = outside.join("widgets-core-0123456789abcdef");
std::fs::create_dir_all(&foreign).expect("a directory outside the slot");
std::fs::create_dir_all(slot.join("debug")).expect("profile");
std::os::unix::fs::symlink(&outside, slot.join("debug").join(".fingerprint"))
.expect("symlink");
pool.hand_over(0, "toolu_B").expect("B is handed slot 0");
assert!(foreign.is_dir(), "nothing outside the slot is moved");
}
#[test]
fn a_symlinked_trash_directory_is_not_used() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, local) = slot_held_by_a(&pool);
let outside = root.path().join("outside");
let foreign = outside.join("1.1");
std::fs::create_dir_all(&foreign).expect("a directory outside the slot");
std::os::unix::fs::symlink(&outside, slot.join(INVALIDATED_DIR)).expect("symlink");
assert!(pool.invalidated_trees(0).is_empty(), "nothing to purge");
let err = pool
.hand_over(0, "toolu_B")
.expect_err("no trash tree outside the slot");
assert!(matches!(err, SlotPoolError::HandOver { .. }), "{err}");
assert!(local.is_dir(), "A's build stays in place");
assert!(foreign.is_dir(), "nothing outside the slot is touched");
}
#[test]
fn a_slot_holding_a_live_build_is_not_handed_over() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let (slot, _) = pool.seed(0, None).expect("a seeded slot 0");
let (local, _) = plant_fingerprints(&slot);
let lock = std::fs::File::create(slot.join("debug").join(".cargo-lock")).expect("lock");
lock.lock().expect("hold the build lock");
let err = pool
.hand_over(0, "toolu_B")
.expect_err("a live build keeps its slot");
assert!(matches!(err, SlotPoolError::ActiveBuild { .. }), "{err}");
assert!(local.is_dir(), "a refused handover touches nothing");
}
#[test]
fn a_refused_seed_is_recorded_for_the_next_admission() {
let root = tempfile::tempdir().expect("tempdir");
let pool = pool_with_checkout(root.path());
let debug = pool.slot_path(0).join("debug");
std::fs::create_dir_all(&debug).expect("a slot a build is using");
let lock = std::fs::File::create(debug.join(".cargo-lock")).expect("lock");
lock.lock().expect("hold the build lock");
pool.seed(0, None)
.expect_err("a live build refuses the seed");
let why = pool.last_seed_failure(0).expect("the refusal is recorded");
assert!(why.contains("cargo holds"), "{why}");
assert!(!pool.is_seeded(0));
}
#[test]
fn unit_package_reads_the_package_name() {
assert_eq!(
unit_package("trusty-mpm-0123456789abcdef"),
Some("trusty-mpm")
);
assert_eq!(unit_package("trusty-mpm"), None);
assert_eq!(unit_package("serde-12345"), None);
}
}