feat(#589): swarm peers option + HYPERHIVE_PEERS env wire v0

nix: services.hyperhive.peers attrset-of-submodules option; serialises
to HYPERHIVE_PEERS JSON ([{label,domain}]); forwarded to containers via
FORWARDED_VARS. dashboard.rs: peer_hives: Vec<PeerHiveView> in
StateSnapshot, derived as {name:label, url:"http://domain/"}.
identity.rs: PeerHive struct + peers() accessor for agent-side use.
This commit is contained in:
damocles 2026-05-31 22:48:14 +02:00 committed by mara
commit 348fb3792a
4 changed files with 121 additions and 0 deletions

View file

@ -48,6 +48,25 @@ pub fn swarm_name() -> Option<String> {
.filter(|s| !s.is_empty())
}
/// One peer hive in the same swarm. Parsed from `HYPERHIVE_PEERS`.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PeerHive {
pub label: String,
pub domain: String,
}
/// Peer hives in the same swarm, parsed from `HYPERHIVE_PEERS` env var
/// (JSON array of `{label,domain}` objects, emitted by the c0re NixOS
/// module from `services.hyperhive.peers`). Returns empty vec on
/// single-hive deploys (env var absent).
#[must_use]
pub fn peers() -> Vec<PeerHive> {
env::var("HYPERHIVE_PEERS")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Hive-qualified agent identity. When the hive domain is configured, returns
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
/// the short label so callers can render a single string regardless of

View file

@ -266,6 +266,24 @@ struct StateSnapshot {
/// var, set from `services.hyperhive.swarmName`. `None` when
/// unset — chrome omits the swarm segment of the breadcrumb.
swarm_name: Option<String>,
/// Peer hives in the same swarm. Parsed from `HYPERHIVE_PEERS`
/// (JSON array of `{label,domain}` objects, emitted by the c0re
/// NixOS module from `services.hyperhive.peers`). Empty on
/// single-hive deploys. Feeds iris's P33RS tab (#589). Each entry
/// exposes `name` (the operator-chosen label) and `url` (dashboard
/// link derived as `http://<domain>/`; v0 HTTP-only - HTTPS peers
/// require gateway TLS (#593/#594)).
peer_hives: Vec<PeerHiveView>,
}
/// One peer hive for the P33RS dashboard tab. Derived from
/// `HYPERHIVE_PEERS` env; `url` is the peer's dashboard root so the
/// tab can render a clickable card without knowing the remote port.
/// v0: always `http://` - HTTPS peers need gateway TLS (#593/#594).
#[derive(Serialize)]
struct PeerHiveView {
name: String,
url: String,
}
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
@ -475,9 +493,36 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()),
peer_hives: parse_peer_hives(),
})
}
/// Parse `HYPERHIVE_PEERS` env var into dashboard-ready `PeerHiveView`
/// entries. The env var is a JSON array of `{label, domain}` objects
/// emitted by the c0re NixOS module from `services.hyperhive.peers`.
/// Each entry becomes `{ name: label, url: "http://domain/" }` for the
/// P33RS tab. Returns empty vec when unset (single-hive deploy).
fn parse_peer_hives() -> Vec<PeerHiveView> {
#[derive(serde::Deserialize)]
struct Raw {
label: String,
domain: String,
}
let Ok(json) = std::env::var("HYPERHIVE_PEERS") else {
return Vec::new();
};
let Ok(raw): Result<Vec<Raw>, _> = serde_json::from_str(&json) else {
tracing::warn!("HYPERHIVE_PEERS is not valid JSON; ignoring");
return Vec::new();
};
raw.into_iter()
.map(|r| PeerHiveView {
name: r.label,
url: format!("http://{}/", r.domain),
})
.collect()
}
/// Group live containers by their assigned web UI port; clusters with
/// more than one member are port-hash collisions the operator needs
/// to resolve by renaming. Manager (fixed at 8000) and sub-agents

View file

@ -325,6 +325,8 @@ const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
/// touching process-wide env).
const FORWARDED_VARS: &[&str] = &[
"HIVE_FORGE_URL",
"HIVE_FORGE_PUBLIC_URL",
"HYPERHIVE_PEERS",
"HYPERHIVE_HIVE_DOMAIN",
"HYPERHIVE_HIVE_NAME",
"HYPERHIVE_SWARM_NAME",

View file

@ -92,6 +92,51 @@ in
'';
};
# Peer hives in the same swarm. Each entry declares a remote hive
# reachable from this host. Serialised to JSON and injected as
# `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent
# containers via `meta.rs::FORWARDED_VARS`. Consumed by
# `identity.rs::peers()` + the dashboard's `peer_hives` state field
# (feeds iris's P33RS tab). See #589.
options.services.hyperhive.peers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
domain = lib.mkOption {
type = lib.types.str;
example = "lab.example.com";
description = ''
DNS domain of the peer hive. Used to construct the peer's
dashboard URL (`http://''${domain}/`) and for Matrix
federation auto-discovery (`matrix.''${domain}`).
Must be reachable from this host.
'';
};
tlsCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Optional path to a PEM cert/bundle to trust for this peer's
TLS. Null = system CA bundle (for Let's Encrypt peers). Set
to the peer's self-signed cert for `selfSignedTls = true`
peers. See #594.
'';
};
};
});
default = { };
example = {
lab = { domain = "lab.example.com"; };
edge = { domain = "edge.corp"; };
};
description = ''
Peer hives in the same swarm. The attrset key is a short label
used in dashboard links and log messages -- it does not need to
match the remote hive's `hiveName`. Null `tlsCertFile` uses the
system CA bundle; set it for self-signed TLS peers (#594 slot,
unimplemented in v0).
'';
};
options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
@ -309,6 +354,16 @@ in
# wrong). Absent when `behindGateway = false` — dashboard
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs (config.services.hyperhive.peers != { }) {
# Peer hives serialised as a JSON array of {label, domain} objects.
# Consumed by hive-ag3nt::identity::peers() + the dashboard's
# peer_hives StateSnapshot field (P33RS tab). tlsCertFile is
# nix-side-only (host nginx/trust config); rust never needs the path.
HYPERHIVE_PEERS = builtins.toJSON (
lib.mapAttrsToList (label: p: { inherit label; inherit (p) domain; })
config.services.hyperhive.peers
);
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}";