diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 879c4021..6bf57444 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -92,7 +92,8 @@ pub fn agent_web_port(name: &str) -> u16 { /// Number of IP addresses at the top of each subnet reserved for the DHCP /// pool (bridge-attached service containers such as hive-ci). Agents are -/// excluded from this range by the remap in `agent_network_ip`. +/// excluded from this range by subtracting it from the usable count before +/// hashing, so agents only ever land in [2, usable - DHCP_POOL_SIZE + 1]. /// /// Single source of truth: `nix/dhcp-pool-size` (one integer, shared with /// `nix/modules/hive-gateway.nix` which reads the same file via @@ -117,21 +118,23 @@ const DHCP_POOL_SIZE: u32 = { /// `"10.42.0.0/24"`), then computes: /// /// ```text -/// host_count = 2^(32 - prefix_len) -/// usable = host_count - 3 // skip .0 (network), .1 (gateway), last (broadcast) -/// offset = FNV-1a(name) % usable + 2 // .2 is the first agent slot -/// agent_ip = network_base_u32 + offset +/// host_count = 2^(32 - prefix_len) +/// usable = host_count - 3 // skip .0 (network), .1 (gateway), last (broadcast) +/// agent_slots = usable - DHCP_POOL_SIZE // top of range reserved for bridge DHCP pool +/// offset = FNV-1a(name) % agent_slots + 2 // .2 is the first agent slot +/// agent_ip = network_base_u32 + offset /// ``` /// /// The last `DHCP_POOL_SIZE` usable host addresses are reserved for the -/// bridge DHCP pool (service containers). When the primary hash falls in -/// that range the offset is remapped into the agent-only window `[2, -/// dhcp_start - 1]` so no agent is ever assigned a DHCP-pool address. -/// Only the rare agent whose name hashes into the pool is affected; all -/// other agents keep their original IPs. +/// bridge DHCP pool (service containers such as hive-ci) and are never +/// assigned to agents. Agent IPs are stable as long as the agent name and +/// subnet don't change; a change to either (or to `DHCP_POOL_SIZE`) will +/// re-address agents, which is fine because nothing outside the container +/// depends on a specific agent IP — they reconnect on next rebuild. /// /// Returns `None` when `subnet_cidr` can't be parsed (invalid format, -/// prefix out of range, etc.) so callers can fall back gracefully. +/// prefix out of range, subnet too small for both agents and DHCP pool, +/// etc.) so callers can fall back gracefully. /// Collisions are possible (birthday paradox) and the operator resolves /// them by renaming an agent, same as for port collisions. #[must_use] @@ -163,24 +166,13 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { let host_count: u32 = 1u32.checked_shl(32 - prefix_len).unwrap_or(0); // `.0` = network, `.1` = bridge gateway, last = broadcast → 3 reserved. let usable = host_count.saturating_sub(3); - if usable == 0 { + // Reserve the last DHCP_POOL_SIZE usable addresses for the bridge DHCP + // pool. Agents only hash into the remaining agent-only window. + let agent_slots = usable.saturating_sub(DHCP_POOL_SIZE); + if agent_slots == 0 { return None; } - let mut offset = fnv1a(name) % usable + 2; // +2: skip .0 and .1 - - // Remap agents whose primary hash falls in the DHCP pool (the last - // DHCP_POOL_SIZE usable addresses). Only has an effect when the - // subnet is large enough to hold both agent slots AND a pool. - if usable > DHCP_POOL_SIZE { - // First offset that belongs to the DHCP pool. - let dhcp_start = usable + 2 - DHCP_POOL_SIZE; - if offset >= dhcp_start { - // Secondary hash into the agent-only window [2, dhcp_start - 1]. - // Result is guaranteed < dhcp_start (no overlap with pool). - let agent_only = dhcp_start - 2; - offset = fnv1a(name) % agent_only + 2; - } - } + let offset = fnv1a(name) % agent_slots + 2; // +2: skip .0 and .1 let ip_u32 = network_base + offset; let [a, b, c, d] = ip_u32.to_be_bytes(); diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 1da16580..b9b380f0 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -40,10 +40,10 @@ async fn setup_proposed_seeds_flake_nix() { #[test] fn agent_network_ip_is_in_subnet() { // Default subnet 10.42.0.0/24 — agents get .2 to .240 (last 14 are DHCP pool). + // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP"); let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix"); - // usable=253, dhcp_start=241 → agent range is .2-.240 assert!( octets[3] >= 2 && octets[3] <= 240, "host byte {} — expected in agent-only range [2,240]", @@ -55,6 +55,7 @@ fn agent_network_ip_is_in_subnet() { fn agent_network_ip_never_in_dhcp_pool() { // No agent should be assigned an address in the DHCP pool // (.241-.254 on a /24 with DHCP_POOL_SIZE=14). + // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. let subnet = "10.42.0.0/24"; let names = [ "alice", @@ -79,7 +80,6 @@ fn agent_network_ip_never_in_dhcp_pool() { for name in names { let ip = agent_network_ip(name, subnet).unwrap_or_else(|| panic!("{name} returned None")); let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - // dhcp_start_offset = usable(253) + 2 - DHCP_POOL_SIZE(14) = 241 assert!( last < 241, "{name} got .{last} — inside the DHCP pool [.241-.254]"