simplify(#2363): drop remap, shrink modulus to exclude DHCP pool

No need to preserve agent IPs across this deploy — nothing outside a
container depends on a specific agent IP.  Simpler approach: subtract
DHCP_POOL_SIZE from the usable count before hashing so agents only ever
land in [2, usable - DHCP_POOL_SIZE + 1], never in the DHCP pool.

Removes the secondary-hash remap block (~10 lines).  Returns None for
subnets too small to hold both agent slots and the pool (edge case;
practical subnets are /24).
This commit is contained in:
atlas 2026-07-13 11:01:16 +02:00 committed by mara
commit 9396918ffb
2 changed files with 21 additions and 29 deletions

View file

@ -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<String> {
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();

View file

@ -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<u8> = 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]"