Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions tests/cache_portability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
//! Cache keys are identical across checkout paths and machines.
//!
//! Two copies of one fixture live at different absolute paths, with different
//! `HOME` values and `CI` set in only one. Every cacheable target must hash
//! to the same key. Absolute paths, `HOME`, `CI`, and gitignored directories
//! must not leak into a key.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::process::Command;

use serde_json::{json, Value};
use tempfile::TempDir;

const COMPONENT_FIELDS: &[&str] = &[
"salt", "platform", "files", "command", "deps", "env", "path_env", "runtime",
];

/// Build the acceptance fixture under `root`.
///
/// `extra_ignored` adds `node_modules/` and `target/` content that `.gitignore`
/// excludes. Those directories must not change a cache key.
fn make_fixture(root: &Path, extra_ignored: bool) {
fs::create_dir_all(root.join(".git")).unwrap();
write(root, ".gitignore", "node_modules/\ntarget/\n");

write(
root,
"apps/web/package.json",
"{\n \"name\": \"web\",\n \"scripts\": {\n \"build\": \"echo build\",\n \"test\": \"echo test\"\n }\n}\n",
);
write(
root,
"apps/web/src/index.js",
"export const ready = true;\n",
);
write(
root,
"apps/web/aster.toml",
&cache_runtime_toml(&[
("deps", "npm install"),
("build", "npm run build"),
("test", "npm test"),
]),
);

write(
root,
"Cargo.toml",
"[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\nresolver = \"2\"\n",
);
write(
root,
"Cargo.lock",
"# This file is automatically @generated by Cargo.\nversion = 3\n",
);
for crate_name in ["a", "b"] {
write(
root,
&format!("crates/{crate_name}/Cargo.toml"),
&format!(
"[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
),
);
write(
root,
&format!("crates/{crate_name}/src/lib.rs"),
"pub fn ready() -> bool { true }\n",
);
write(
root,
&format!("crates/{crate_name}/aster.toml"),
&cache_runtime_toml(&[
("deps", "cargo fetch"),
("build", "cargo build"),
("test", "cargo test"),
("lint", "cargo clippy"),
("format", "cargo fmt"),
("check", "cargo check"),
]),
);
}

write(
root,
"services/api/go.mod",
"module example.com/api\n\ngo 1.22\n",
);
write(
root,
"services/api/main.go",
"package main\n\nfunc main() {}\n",
);
write(
root,
"services/api/aster.toml",
&cache_runtime_toml(&[
("deps", "go mod download"),
("build", "go build ./..."),
("test", "go test ./..."),
("format", "go fmt ./..."),
]),
);

if extra_ignored {
write(root, "node_modules/x/index.js", "module.exports = 1;\n");
write(
root,
"apps/web/node_modules/x/index.js",
"module.exports = 2;\n",
);
write(root, "target/debug/junk", "not a real artifact\n");
write(root, "crates/a/target/debug/junk", "member junk\n");
}
}

fn cache_runtime_toml(targets: &[(&str, &str)]) -> String {
let mut toml = String::new();
for (target, command) in targets {
toml.push_str(&format!(
"[targets.{target}]\ncommand = \"{command}\"\ncache = {{ runtime = [\"echo fixed\"] }}\n\n"
));
}
toml
}

fn write(root: &Path, path: &str, content: &str) {
let full = root.join(path);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(full, content).unwrap();
}

fn aster(root: &Path, home: &Path, ci: bool) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_aster"));
cmd.current_dir(root)
.env_clear()
.env("HOME", home)
.env("PATH", std::env::var_os("PATH").unwrap_or_default());
if ci {
cmd.env("CI", "true");
}
cmd
}

fn list_addresses(root: &Path, home: &Path, ci: bool) -> Vec<String> {
let output = aster(root, home, ci)
.args(["list", "--json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"aster list failed: {} {stdout}",
String::from_utf8_lossy(&output.stderr)
);
let projects: Vec<Value> = serde_json::from_str(&stdout).expect("list json");
let mut addresses = Vec::new();
for project in projects {
let project_addr = project["address"].as_str().expect("address");
let targets = project["targets"].as_object().expect("targets");
for name in targets.keys() {
addresses.push(format!("{project_addr}:{name}"));
}
}
addresses.sort();
addresses
}

fn cache_inputs(root: &Path, home: &Path, ci: bool, address: &str) -> Result<Value, String> {
let output = aster(root, home, ci)
.args(["cache", "status", address, "--inputs", "--json"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(format!("{address}: {stderr} {stdout}"));
}
serde_json::from_str(&stdout).map_err(|err| format!("{address}: {err}: {stdout}"))
}

fn component_diff(left: &Value, right: &Value) -> Value {
let mut diff = serde_json::Map::new();
for field in COMPONENT_FIELDS {
if left.get(*field) != right.get(*field) {
diff.insert(
(*field).to_string(),
json!({ "a": left.get(*field), "b": right.get(*field) }),
);
}
}
Value::Object(diff)
}

/// Two checkouts of one fixture, at different absolute paths and with
/// different `HOME`, produce the same cache key for every cacheable target.
/// One copy has `CI=true` and extra gitignored directories; neither may leak.
#[test]
fn identical_keys_across_checkout_paths_and_machines() {
let parent = TempDir::new().unwrap();
let copy_a = parent.path().join("machine-a").join("checkout");
let copy_b = parent.path().join("other").join("machine-b").join("src");
let home_a = parent.path().join("homes").join("alice");
let home_b = parent.path().join("homes").join("bob");
fs::create_dir_all(&home_a).unwrap();
fs::create_dir_all(&home_b).unwrap();

make_fixture(&copy_a, false);
make_fixture(&copy_b, true);
assert_ne!(copy_a, copy_b);
assert!(copy_b.join("node_modules/x/index.js").is_file());
assert!(copy_b.join("target/debug/junk").is_file());
assert!(!copy_a.join("node_modules").exists());

let addresses_a = list_addresses(&copy_a, &home_a, false);
let addresses_b = list_addresses(&copy_b, &home_b, true);
assert_eq!(
addresses_a, addresses_b,
"fixture copies discovered different targets"
);
assert!(
addresses_a.iter().any(|addr| addr == "//apps/web:build"),
"node project missing: {addresses_a:?}"
);
assert!(
addresses_a.iter().any(|addr| addr == "//crates/a:build"),
"cargo member missing: {addresses_a:?}"
);
assert!(
addresses_a
.iter()
.any(|addr| addr == "//services/api:build"),
"go module missing: {addresses_a:?}"
);

let mut keys_a = BTreeMap::new();
let mut keys_b = BTreeMap::new();
let mut skipped = Vec::new();
for address in &addresses_a {
let inputs_a = match cache_inputs(&copy_a, &home_a, false, address) {
Ok(value) => value,
Err(error) => {
skipped.push(error);
continue;
}
};
let inputs_b = cache_inputs(&copy_b, &home_b, true, address)
.unwrap_or_else(|error| panic!("copy b failed after copy a succeeded: {error}"));

let key_a = inputs_a["key"].as_str().unwrap_or("").to_string();
let key_b = inputs_b["key"].as_str().unwrap_or("").to_string();
assert!(
!key_a.is_empty() && !key_b.is_empty(),
"{address} has an empty key:\n{inputs_a}\n{inputs_b}"
);
if key_a != key_b {
panic!(
"{address} keys differ across checkouts\n a: {key_a}\n b: {key_b}\n diff: {}",
serde_json::to_string_pretty(&component_diff(&inputs_a, &inputs_b)).unwrap()
);
}
assert!(
!format!("{inputs_a}").contains(copy_a.to_str().unwrap()),
"{address} leaked checkout path into inputs: {inputs_a}"
);
assert!(
!format!("{inputs_b}").contains(home_b.to_str().unwrap()),
"{address} leaked HOME into inputs: {inputs_b}"
);
keys_a.insert(address.clone(), key_a);
keys_b.insert(address.clone(), key_b);
}

assert!(
!keys_a.is_empty(),
"no cacheable targets; skipped: {skipped:?}"
);
assert_eq!(keys_a, keys_b);
assert!(
keys_a.contains_key("//apps/web:build"),
"web build was not cacheable: skipped {skipped:?}"
);
assert!(
keys_a.contains_key("//crates/a:test"),
"crate test was not cacheable: skipped {skipped:?}"
);
assert!(
keys_a.contains_key("//services/api:build"),
"go build was not cacheable: skipped {skipped:?}"
);
}
Loading