use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use forjar::core::state;
fn forjar() -> Command {
Command::new(env!("CARGO_BIN_EXE_forjar"))
}
pub fn write_stack(root: &Path, dir: &str, name: &str, machine: &str, content: &str) -> PathBuf {
let d = root.join(dir);
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("forjar.yaml");
std::fs::write(
&cfg,
format!(
r#"version: "1.0"
name: {name}
policy:
snapshot_generations: 10
machines:
{machine}:
hostname: localhost
addr: 127.0.0.1
transport: local
resources:
{name}_file:
type: file
machine: {machine}
path: {}
content: "{content}\n"
"#,
d.join("marker.txt").display()
),
)
.unwrap();
cfg
}
pub fn write_stack_plus_extra(
root: &Path,
dir: &str,
name: &str,
machine: &str,
content: &str,
) -> PathBuf {
let cfg = write_stack(root, dir, name, machine, content);
let extra = extra_marker(root, dir);
let mut body = std::fs::read_to_string(&cfg).unwrap();
body.push_str(&format!(
" {name}_extra:\n type: file\n machine: {machine}\n path: {}\n content: \"extra\\n\"\n",
extra.display()
));
std::fs::write(&cfg, body).unwrap();
cfg
}
pub fn extra_marker(root: &Path, dir: &str) -> PathBuf {
root.join(dir).join("extra.txt")
}
pub fn write_two_machine_stack(
root: &Path,
dir: &str,
name: &str,
machines: (&str, &str),
content: &str,
) -> PathBuf {
let (first, second) = machines;
let d = root.join(dir);
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("forjar.yaml");
let mut body = format!(
r#"version: "1.0"
name: {name}
policy:
snapshot_generations: 10
machines:
"#
);
for machine in [first, second] {
body.push_str(&format!(
" {machine}:\n hostname: localhost\n addr: 127.0.0.1\n transport: local\n"
));
}
body.push_str("resources:\n");
for machine in [first, second] {
body.push_str(&format!(
" {name}_{machine}_file:\n type: file\n machine: {machine}\n path: {}\n content: \"{content}\\n\"\n",
machine_marker(root, dir, machine).display(),
));
}
std::fs::write(&cfg, body).unwrap();
cfg
}
pub fn machine_marker(root: &Path, dir: &str, machine: &str) -> PathBuf {
root.join(dir).join(format!("marker-{machine}.txt"))
}
pub fn plant_snapshots(state: &Path, count: usize) -> Vec<String> {
let dir = state.join("snapshots");
std::fs::create_dir_all(&dir).unwrap();
let mut planted = Vec::new();
for i in 0..count {
let name = format!("planted-{i:02}");
std::fs::create_dir_all(dir.join(&name)).unwrap();
std::fs::write(dir.join(&name).join("marker"), "planted\n").unwrap();
planted.push(name);
}
planted
}
pub fn snapshot_names(state: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(state.join("snapshots")) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
names.sort();
names
}
pub fn run(args: &[&str]) -> (i32, String) {
let out = forjar().args(args).output().unwrap();
let mut merged = String::from_utf8_lossy(&out.stdout).into_owned();
merged.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.code().unwrap_or(-1), merged)
}
pub fn apply(cfg: &Path, state: &Path) -> (i32, String) {
apply_with(cfg, state, &[])
}
pub fn apply_with(cfg: &Path, state: &Path, extra: &[&str]) -> (i32, String) {
let cfg = cfg.display().to_string();
let state = state.display().to_string();
let mut args = vec!["apply", "-f", &cfg, "--state-dir", &state, "--yes"];
args.extend_from_slice(extra);
run(&args)
}
pub fn undo(cfg: &Path, state: &Path) -> (i32, String) {
run(&[
"undo",
"-f",
&cfg.display().to_string(),
"--state-dir",
&state.display().to_string(),
"--yes",
])
}
pub fn undo_generations(cfg: &Path, state: &Path, back: &str) -> (i32, String) {
run(&[
"undo",
"-f",
&cfg.display().to_string(),
"--state-dir",
&state.display().to_string(),
"--generations",
back,
"--yes",
])
}
pub fn undo_resume(cfg: &Path, state: &Path) -> (i32, String) {
run(&[
"undo",
"-f",
&cfg.display().to_string(),
"--state-dir",
&state.display().to_string(),
"--resume",
"--yes",
])
}
pub fn rollback(state: &Path, generation: &str) -> (i32, String) {
run(&[
"rollback",
"--state-dir",
&state.display().to_string(),
"--generation",
generation,
"--yes",
])
}
pub fn fingerprint(dir: &Path) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
fingerprint_into(dir, dir, &mut out);
out
}
fn fingerprint_into(root: &Path, at: &Path, out: &mut BTreeMap<String, String>) {
let Ok(entries) = std::fs::read_dir(at) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let key = path.strip_prefix(root).unwrap().display().to_string();
let meta = std::fs::symlink_metadata(&path).unwrap();
if meta.is_symlink() {
let target = std::fs::read_link(&path).unwrap();
out.insert(key, format!("-> {}", target.display()));
} else if meta.is_dir() {
out.insert(key, "dir".to_string());
fingerprint_into(root, &path, out);
} else {
out.insert(key, digest(&std::fs::read(&path).unwrap()));
}
}
}
fn digest(bytes: &[u8]) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
format!("{:016x}", h.finish())
}
pub fn warnings(out: &str) -> Vec<String> {
out.lines()
.filter(|l| l.trim_start().starts_with("warning:"))
.filter(|l| !l.contains("pre-apply snapshot failed"))
.map(str::to_string)
.collect()
}
pub fn lock_of(state: &Path) -> forjar::core::types::GlobalLock {
state::load_global_lock(state).unwrap().unwrap()
}
pub fn stack_names(state: &Path) -> Vec<String> {
lock_of(state).stacks.keys().cloned().collect()
}
pub fn marker(root: &Path, dir: &str) -> String {
std::fs::read_to_string(root.join(dir).join("marker.txt")).unwrap()
}
pub struct Fleet {
pub _dir: tempfile::TempDir,
pub root: PathBuf,
pub state: PathBuf,
pub alpha: PathBuf,
pub bravo: PathBuf,
}
pub fn fleet() -> (Fleet, Vec<String>) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let state = root.join("state");
let alpha = write_stack(&root, "alpha", "alpha", "mini", "one");
let bravo = write_stack(&root, "bravo", "bravo", "lambda-labs", "one");
let charlie = write_stack(&root, "charlie", "charlie", "clean-room", "one");
let mut warned = Vec::new();
for cfg in [&alpha, &bravo, &charlie] {
let (rc, out) = apply(cfg, &state);
assert_eq!(rc, 0, "apply of {} failed:\n{out}", cfg.display());
warned.extend(warnings(&out));
}
(
Fleet {
_dir: dir,
root,
state,
alpha,
bravo,
},
warned,
)
}
pub fn rename_stack(cfg: &Path, old: &str, new: &str, content: &str) {
let body = std::fs::read_to_string(cfg).unwrap();
let mut out = String::new();
for line in body.lines() {
if line == format!("name: {old}") {
out.push_str(&format!("name: {new}\n"));
} else if line.trim_start().starts_with("content:") {
out.push_str(&format!(" content: \"{content}\\n\"\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
std::fs::write(cfg, out).unwrap();
}
pub fn generation_numbers(state: &Path) -> Vec<u32> {
let Ok(entries) = std::fs::read_dir(state.join("generations")) else {
return Vec::new();
};
let mut nums: Vec<u32> = entries
.flatten()
.filter_map(|e| e.file_name().to_string_lossy().parse::<u32>().ok())
.collect();
nums.sort_unstable();
nums
}