From 348fb3792a7d0495e33773720be722011f52c385 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 22:48:14 +0200 Subject: [PATCH] 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 in StateSnapshot, derived as {name:label, url:"http://domain/"}. identity.rs: PeerHive struct + peers() accessor for agent-side use. --- hive-ag3nt/src/identity.rs | 19 +++++++++++++ hive-c0re/src/dashboard.rs | 45 +++++++++++++++++++++++++++++++ hive-c0re/src/meta.rs | 2 ++ nix/modules/hive-c0re.nix | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/hive-ag3nt/src/identity.rs b/hive-ag3nt/src/identity.rs index 1145ff8b..97f511df 100644 --- a/hive-ag3nt/src/identity.rs +++ b/hive-ag3nt/src/identity.rs @@ -48,6 +48,25 @@ pub fn swarm_name() -> Option { .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 { + 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 diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 690a2373..d3ffdd0b 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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, + /// 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:///`; v0 HTTP-only - HTTPS peers + /// require gateway TLS (#593/#594)). + peer_hives: Vec, +} + +/// 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) -> 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 { + #[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, _> = 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 diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 15660350..a9d013b7 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -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", diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index c8a98dd3..dde7a1b6 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -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 `: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)}";