refactor(hive-c0re): split lifecycle into submodules
mod.rs keeps the container verbs + priv_run plumbing; git helpers, repo/dir setup, and host drop-in config move to their own files
This commit is contained in:
parent
9e7af3b6bf
commit
3ee87d394c
5 changed files with 998 additions and 947 deletions
155
hive-c0re/src/lifecycle/tests.rs
Normal file
155
hive-c0re/src/lifecycle/tests.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! Unit tests for the lifecycle module (moved verbatim from the old
|
||||
//! single-file `lifecycle.rs` `#[cfg(test)]` block).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix
|
||||
/// in the initial commit. Before commit 5b5a93e flake.nix was missing from
|
||||
/// the scaffold, requiring manual creation (seen with the damocles agent).
|
||||
#[tokio::test]
|
||||
async fn setup_proposed_seeds_flake_nix() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let proposed = dir.path().join("proposed");
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
.await
|
||||
.expect("setup_proposed");
|
||||
|
||||
// Both files must exist on disk.
|
||||
assert!(proposed.join("agent.nix").exists(), "agent.nix missing");
|
||||
assert!(proposed.join("flake.nix").exists(), "flake.nix missing");
|
||||
|
||||
// flake.nix must export nixosModules.default (the meta-flake contract).
|
||||
let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap();
|
||||
assert!(
|
||||
flake.contains("nixosModules.default"),
|
||||
"flake.nix does not export nixosModules.default"
|
||||
);
|
||||
|
||||
// Both files must be tracked in the initial git commit.
|
||||
let out = git_command()
|
||||
.current_dir(&proposed)
|
||||
.args(["show", "--name-only", "--format=", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.expect("git show");
|
||||
let tracked = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(tracked.contains("agent.nix"), "agent.nix not committed");
|
||||
assert!(tracked.contains("flake.nix"), "flake.nix not committed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_is_in_subnet() {
|
||||
// Default subnet 10.42.0.0/24 — agents get .2 to .254.
|
||||
let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP");
|
||||
let octets: Vec<u8> = ip.split('.').map(|o| o.parse().unwrap()).collect();
|
||||
assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix");
|
||||
assert!(
|
||||
octets[3] >= 2 && octets[3] <= 254,
|
||||
"host byte {}",
|
||||
octets[3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_stable() {
|
||||
// Same name + subnet must always produce the same IP.
|
||||
let a = agent_network_ip("damocles", "10.42.0.0/24");
|
||||
let b = agent_network_ip("damocles", "10.42.0.0/24");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_different_agents() {
|
||||
// Different agent names very likely produce different IPs (not guaranteed,
|
||||
// but for these two names the hashes don't collide).
|
||||
let alice = agent_network_ip("alice", "10.42.0.0/24").unwrap();
|
||||
let bob = agent_network_ip("bob", "10.42.0.0/24").unwrap();
|
||||
assert_ne!(alice, bob, "alice and bob collide — rename one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_different_subnet() {
|
||||
let ip = agent_network_ip("alice", "192.168.5.0/24").expect("should produce an IP");
|
||||
let octets: Vec<u8> = ip.split('.').map(|o| o.parse().unwrap()).collect();
|
||||
assert_eq!(&octets[..3], &[192, 168, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_gateway_ip_extracts_verbatim_address() {
|
||||
// HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the
|
||||
// canonical network — the gateway is the address before the `/`.
|
||||
assert_eq!(
|
||||
bridge_gateway_ip("10.42.0.1/24").as_deref(),
|
||||
Some("10.42.0.1")
|
||||
);
|
||||
// Non-`.1` operator override: the gateway is wherever the bridge is.
|
||||
assert_eq!(
|
||||
bridge_gateway_ip("10.42.0.254/24").as_deref(),
|
||||
Some("10.42.0.254")
|
||||
);
|
||||
assert_eq!(
|
||||
bridge_gateway_ip("172.30.0.1/16").as_deref(),
|
||||
Some("172.30.0.1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_gateway_ip_rejects_bad_input() {
|
||||
assert!(bridge_gateway_ip("notanip/24").is_none());
|
||||
assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix
|
||||
assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32
|
||||
assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255
|
||||
assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_rejects_bad_input() {
|
||||
assert!(agent_network_ip("alice", "notanip/24").is_none());
|
||||
assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32
|
||||
assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small
|
||||
assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_network_ip_normalizes_bridge_ip_subnet() {
|
||||
// HIVE_NETWORK_SUBNET carries the bridge IP (10.42.0.1/24), not
|
||||
// canonical network (10.42.0.0/24). Both must produce the same result
|
||||
// after host-bit masking.
|
||||
let from_bridge = agent_network_ip("alice", "10.42.0.1/24");
|
||||
let from_canonical = agent_network_ip("alice", "10.42.0.0/24");
|
||||
assert_eq!(
|
||||
from_bridge, from_canonical,
|
||||
"bridge-IP and canonical-network form should normalize to the same result"
|
||||
);
|
||||
// Result must still be in .2-.254.
|
||||
let ip = from_bridge.unwrap();
|
||||
let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap();
|
||||
assert!((2..=254).contains(&last), "host byte {last}");
|
||||
}
|
||||
|
||||
/// `setup_proposed` is idempotent: calling it on an existing repo is a
|
||||
/// no-op (the fresh guard skips all writes).
|
||||
#[tokio::test]
|
||||
async fn setup_proposed_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let proposed = dir.path().join("proposed");
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
.await
|
||||
.expect("first call");
|
||||
// Second call must not error even though .git already exists.
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
.await
|
||||
.expect("second call");
|
||||
// Still one commit.
|
||||
let out = git_command()
|
||||
.current_dir(&proposed)
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.expect("git rev-list");
|
||||
let count = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
assert_eq!(
|
||||
count, "1",
|
||||
"expected exactly one commit after idempotent call"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue