Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89b4e8afb6 | ||
|
|
877e8bcf81 |
2 changed files with 110 additions and 11 deletions
|
|
@ -56,9 +56,9 @@ pub(super) struct StateSnapshot {
|
||||||
/// survive after a destroy-without-purge. The operator can re-spawn
|
/// survive after a destroy-without-purge. The operator can re-spawn
|
||||||
/// with the same name to resume, or PURG3 to wipe them.
|
/// with the same name to resume, or PURG3 to wipe them.
|
||||||
tombstones: Vec<TombstoneView>,
|
tombstones: Vec<TombstoneView>,
|
||||||
/// Sub-agents whose FNV-1a hashed web UI port collides with at
|
/// Agents whose FNV-1a hashed web UI port collides with at least
|
||||||
/// least one other agent. Operator resolves by renaming. The
|
/// one other. Operator resolves by renaming. The dashboard renders
|
||||||
/// dashboard renders a banner at the top listing each cluster.
|
/// a banner at the top listing each cluster.
|
||||||
port_conflicts: Vec<PortConflict>,
|
port_conflicts: Vec<PortConflict>,
|
||||||
/// Inputs in `meta/flake.lock` the operator can selectively
|
/// Inputs in `meta/flake.lock` the operator can selectively
|
||||||
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
||||||
|
|
@ -342,11 +342,13 @@ pub(super) async fn api_state(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group live containers by their assigned web UI port; clusters with
|
/// Group containers by their assigned web UI port; clusters with more
|
||||||
/// more than one member are port-hash collisions the operator needs
|
/// than one member are port-hash collisions the operator resolves by
|
||||||
/// to resolve by renaming. Manager (fixed at 8000) and sub-agents
|
/// renaming an agent.
|
||||||
/// (8100..8999) can't collide with each other — collisions are
|
///
|
||||||
/// strictly between sub-agents.
|
/// Every container's port comes from [`crate::lifecycle::agent_web_port`],
|
||||||
|
/// which hashes the name into one range for all of them — so the manager
|
||||||
|
/// is a candidate for a collision exactly like any sub-agent.
|
||||||
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
|
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
|
||||||
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
|
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
|
||||||
std::collections::BTreeMap::new();
|
std::collections::BTreeMap::new();
|
||||||
|
|
@ -712,3 +714,100 @@ pub(super) async fn dashboard_stream(
|
||||||
});
|
});
|
||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ContainerView, build_port_conflicts};
|
||||||
|
|
||||||
|
fn cv(name: &str, port: u16, running: bool) -> ContainerView {
|
||||||
|
ContainerView {
|
||||||
|
name: name.to_owned(),
|
||||||
|
container: format!("h-{name}"),
|
||||||
|
port,
|
||||||
|
running,
|
||||||
|
failed: false,
|
||||||
|
needs_update: false,
|
||||||
|
needs_login: false,
|
||||||
|
deployed_sha: None,
|
||||||
|
parent: None,
|
||||||
|
active_model: None,
|
||||||
|
paused: false,
|
||||||
|
cpu_quota: "200%".to_owned(),
|
||||||
|
memory_max: "4G".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_ports_produce_no_conflicts() {
|
||||||
|
let cs = [cv("alice", 8101, true), cv("bob", 8102, true)];
|
||||||
|
assert!(build_port_conflicts(&cs).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lone_container_does_not_conflict_with_itself() {
|
||||||
|
assert!(build_port_conflicts(&[cv("alice", 8101, true)]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_agents_on_one_port_are_reported_together() {
|
||||||
|
let cs = [cv("alice", 8101, true), cv("bob", 8101, true)];
|
||||||
|
let got = build_port_conflicts(&cs);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].port, 8101);
|
||||||
|
assert_eq!(got[0].agents, ["alice", "bob"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manager takes its port from the same hash as everyone else, so
|
||||||
|
/// it collides like any other name. A doc comment here used to claim it
|
||||||
|
/// sat at a fixed 8000 and could not — no such special case exists.
|
||||||
|
#[test]
|
||||||
|
fn the_manager_can_collide_with_a_sub_agent() {
|
||||||
|
let cs = [cv("manager", 8101, true), cv("alice", 8101, true)];
|
||||||
|
let got = build_port_conflicts(&cs);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].agents, ["alice", "manager"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stopped agent keeps its hashed port, so the clash is real the
|
||||||
|
/// moment it starts. Reporting it while it is down is the point.
|
||||||
|
#[test]
|
||||||
|
fn a_stopped_container_still_claims_its_port() {
|
||||||
|
let cs = [cv("alice", 8101, true), cv("bob", 8101, false)];
|
||||||
|
assert_eq!(build_port_conflicts(&cs).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_sorted_within_a_conflict() {
|
||||||
|
let cs = [cv("zoe", 8101, true), cv("adam", 8101, true)];
|
||||||
|
assert_eq!(build_port_conflicts(&cs)[0].agents, ["adam", "zoe"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn more_than_two_agents_land_in_one_cluster() {
|
||||||
|
let cs = [
|
||||||
|
cv("c", 8101, true),
|
||||||
|
cv("a", 8101, true),
|
||||||
|
cv("b", 8101, true),
|
||||||
|
];
|
||||||
|
let got = build_port_conflicts(&cs);
|
||||||
|
assert_eq!(got.len(), 1, "one cluster, not one row per pair");
|
||||||
|
assert_eq!(got[0].agents, ["a", "b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separate_clusters_are_ordered_by_port() {
|
||||||
|
let cs = [
|
||||||
|
cv("d", 8300, true),
|
||||||
|
cv("c", 8300, true),
|
||||||
|
cv("b", 8200, true),
|
||||||
|
cv("a", 8200, true),
|
||||||
|
];
|
||||||
|
let ports: Vec<u16> = build_port_conflicts(&cs).iter().map(|p| p.port).collect();
|
||||||
|
assert_eq!(ports, [8200, 8300]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_containers_is_not_a_conflict() {
|
||||||
|
assert!(build_port_conflicts(&[]).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@ pub fn container_claude_mount(name: &str) -> String {
|
||||||
/// willing to lose (other agents may delete them).
|
/// willing to lose (other agents may delete them).
|
||||||
pub const CONTAINER_SHARED_MOUNT: &str = "/shared";
|
pub const CONTAINER_SHARED_MOUNT: &str = "/shared";
|
||||||
|
|
||||||
/// Sub-agent web UI port range. Deterministic from the agent's name (FNV-1a
|
/// Web UI port range, shared by every agent including the manager.
|
||||||
/// hash mod range size), so the dashboard can compute the same port without
|
/// Deterministic from the agent's name (FNV-1a hash mod range size), so the
|
||||||
/// asking hive-c0re.
|
/// dashboard can compute the same port without asking hive-c0re.
|
||||||
const WEB_PORT_BASE: u16 = 8100;
|
const WEB_PORT_BASE: u16 = 8100;
|
||||||
const WEB_PORT_RANGE: u16 = 900;
|
const WEB_PORT_RANGE: u16 = 900;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue