diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 1ea2a5f5..f46c50fd 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::{Context, Result, bail}; -use hive_sh4re::priv_proto::{BindMount, CredentialMount}; +use hive_sh4re::priv_proto::BindMount; use tokio::process::Command; use crate::coordinator::{AgentPaths, HiveEnv}; @@ -1188,42 +1188,6 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { }); } -/// Hive-wide secrets forwarded into every agent container via nspawn -/// `--load-credential=:`. Currently just the OTEL -/// auth-header secret, when `services.hyperhive.otel.headersCredential` -/// is set (surfaced as `HYPERHIVE_OTEL_HEADERS_CREDENTIAL` on hive-c0re's -/// unit env — the same host option meta.rs reads to inject -/// `hyperhive.otel.headersCredential`). The inner harness unit reads it -/// via `LoadCredential=otel-headers` (inherit). The secret never lands in -/// a bind mount, the nix store, or the generated config. -/// -/// A configured-but-missing file is skipped with a warning rather than -/// forwarded (nspawn would refuse to start the container otherwise): a -/// host-level secret typo shouldn't take down every agent's start; OTEL -/// just exports without the auth header until the file appears. -fn hive_load_credentials() -> Vec { - let mut out = Vec::new(); - let Ok(path) = std::env::var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL") else { - return out; - }; - if path.is_empty() { - return out; - } - if std::path::Path::new(&path).is_file() { - out.push(CredentialMount { - name: "otel-headers".to_owned(), - host_path: path, - }); - } else { - tracing::warn!( - %path, - "HYPERHIVE_OTEL_HEADERS_CREDENTIAL is set but the file is missing; \ - skipping --load-credential (OTEL will export without the auth header)" - ); - } - out -} - #[allow( clippy::too_many_lines, reason = "one contiguous nspawn-flag assembly block; the length is the flag \ @@ -1270,10 +1234,6 @@ async fn set_nspawn_flags( // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); - // Hive-wide secrets forwarded into the container's credential store - // (currently just the OTEL auth-header). Same for every agent. - let load_creds = hive_load_credentials(); - let mut binds: Vec = vec![ BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), @@ -1407,13 +1367,7 @@ async fn set_nspawn_flags( (bad CIDR? prefix too narrow?); skipping PRIVATE_NETWORK write to \ avoid misconfigured isolation" ); - return crate::priv_client::write_nspawn_flags( - container, - &binds, - None, - &load_creds, - ) - .await; + return crate::priv_client::write_nspawn_flags(container, &binds, None).await; }; let Some(gateway_ip) = bridge_gateway_ip(&subnet) else { tracing::warn!( @@ -1422,13 +1376,7 @@ async fn set_nspawn_flags( skipping PRIVATE_NETWORK write to avoid an isolated container with no \ default route or resolver" ); - return crate::priv_client::write_nspawn_flags( - container, - &binds, - None, - &load_creds, - ) - .await; + return crate::priv_client::write_nspawn_flags(container, &binds, None).await; }; tracing::info!( %agent_name, %agent_ip, %gateway_ip, %bridge, @@ -1445,7 +1393,7 @@ async fn set_nspawn_flags( }; // Delegate the actual conf-file rewrite to hive-priv (runs as root). - crate::priv_client::write_nspawn_flags(container, &binds, isolation, &load_creds).await + crate::priv_client::write_nspawn_flags(container, &binds, isolation).await } /// Build the per-line callback for `create_container_streaming` / diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index c5da4c40..29a3a3c2 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -656,48 +656,6 @@ fn peer_ca_sources() -> Vec { .collect() } -/// Hive-wide OTEL config injected into every agent's build, read off -/// hive-c0re's own unit env (set from `services.hyperhive.otel.*` in -/// `nix/modules/hive-c0re.nix`). A present, non-empty -/// `HYPERHIVE_OTEL_ENDPOINT` is the enable signal — the host module -/// asserts the endpoint is set whenever `otel.enable` is true, so -/// "endpoint present" == "OTEL on". The optional fields map to the -/// matching host options and are only carried when set. -struct OtelConfig { - endpoint: String, - protocol: String, - extra_resource_attributes: Option, - headers_credential: Option, -} - -/// Read the hive-wide OTEL config from env, or `None` when OTEL is off. -/// Mirrors `hive_ca_source` — host state surfaced to the meta renderer -/// so it can bake build-time `hyperhive.otel.*` config into each agent -/// (the per-agent options harness-base.nix consumes). Returns `None` -/// when the endpoint signal is absent so the renderer emits no -/// `hyperhive.otel.*` lines and agents keep the disabled default. -fn otel_config() -> Option { - let endpoint = std::env::var("HYPERHIVE_OTEL_ENDPOINT") - .ok() - .filter(|v| !v.is_empty())?; - let protocol = std::env::var("HYPERHIVE_OTEL_PROTOCOL") - .ok() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "http/protobuf".to_owned()); - let extra_resource_attributes = std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES") - .ok() - .filter(|v| !v.is_empty()); - let headers_credential = std::env::var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL") - .ok() - .filter(|v| !v.is_empty()); - Some(OtelConfig { - endpoint, - protocol, - extra_resource_attributes, - headers_credential, - }) -} - /// The ordered set of CA certs embedded next to the meta flake, as /// `(filename, host_source_path)`. The self-signed hive CA (when active) /// is `hive-ca.pem`; each peer CA is `peer-ca-.pem` in declaration @@ -949,40 +907,6 @@ where ca_refs.join(" ") ); } - // Hive-wide OTEL stats export (`services.hyperhive.otel.*`): inject the - // build-time `hyperhive.otel.*` config harness-base.nix consumes (its - // otelEnv + otelExecStart wrapper + LoadCredential). Host-driven, so - // the same config lands on every agent; emitted only when enabled. - // Mirrors the CA-cert injection above — host state -> build-time agent - // module config. - if let Some(otel) = otel_config() { - let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\""); - out.push_str(" hyperhive.otel.enable = true;\n"); - let _ = writeln!( - out, - " hyperhive.otel.endpoint = \"{}\";", - esc(&otel.endpoint) - ); - let _ = writeln!( - out, - " hyperhive.otel.protocol = \"{}\";", - esc(&otel.protocol) - ); - if let Some(attrs) = &otel.extra_resource_attributes { - let _ = writeln!( - out, - " hyperhive.otel.extraResourceAttributes = \"{}\";", - esc(attrs) - ); - } - if let Some(cred) = &otel.headers_credential { - let _ = writeln!( - out, - " hyperhive.otel.headersCredential = \"{}\";", - esc(cred) - ); - } - } out.push_str( r#" # The harness service inside the container runs as a # non-root unix user named after the agent (`damocles`, @@ -1521,89 +1445,4 @@ mod tests { "no certificateFiles reference without any CA signal:\n{without_ca}" ); } - - #[test] - fn render_flake_injects_otel_when_signalled() { - // services.hyperhive.otel.* -> HYPERHIVE_OTEL_* on hive-c0re's unit - // -> injected as build-time hyperhive.otel.* into every agent. With - // no endpoint signal, no hyperhive.otel lines are emitted (agents - // keep the harness-base disabled default). - // - // SAFETY: single-threaded mutation of process env vars no other - // test asserts on; restored before returning. - let render = || { - render_flake( - "github:example/hyperhive", - "path:/nix/store/aaaa-nixpkgs-source", - "path:/nix/store/bbbb-nixpkgs-unstable-source", - 8000, - "she/her", - &std::collections::HashMap::new(), - &[sample_spec("alice", false, 9001)], - ) - }; - unsafe { - std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); - std::env::remove_var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL"); - std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "https://c.example/otel"); - std::env::set_var("HYPERHIVE_OTEL_PROTOCOL", "grpc"); - } - let on_minimal = render(); - unsafe { - std::env::set_var( - "HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES", - "deployment.environment=prod", - ); - std::env::set_var( - "HYPERHIVE_OTEL_HEADERS_CREDENTIAL", - "/run/secrets/otel-headers", - ); - } - let on_full = render(); - unsafe { - std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT"); - std::env::remove_var("HYPERHIVE_OTEL_PROTOCOL"); - std::env::remove_var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES"); - std::env::remove_var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL"); - } - let off = render(); - - assert!( - on_minimal.contains("hyperhive.otel.enable = true;"), - "otel enable must be injected:\n{on_minimal}" - ); - assert!( - on_minimal.contains("hyperhive.otel.endpoint = \"https://c.example/otel\";"), - "otel endpoint must be injected:\n{on_minimal}" - ); - assert!( - on_minimal.contains("hyperhive.otel.protocol = \"grpc\";"), - "otel protocol must be injected:\n{on_minimal}" - ); - // Optional fields absent when unset. - assert!( - !on_minimal.contains("hyperhive.otel.extraResourceAttributes"), - "extraResourceAttributes must not appear when unset:\n{on_minimal}" - ); - assert!( - !on_minimal.contains("hyperhive.otel.headersCredential"), - "headersCredential must not appear when unset:\n{on_minimal}" - ); - - assert!( - on_full.contains( - "hyperhive.otel.extraResourceAttributes = \"deployment.environment=prod\";" - ), - "extraResourceAttributes must be injected when set:\n{on_full}" - ); - assert!( - on_full.contains("hyperhive.otel.headersCredential = \"/run/secrets/otel-headers\";"), - "headersCredential must be injected when set:\n{on_full}" - ); - - assert!( - !off.contains("hyperhive.otel"), - "no otel lines when disabled:\n{off}" - ); - } } diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index c8ac97af..87c92472 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, - PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, + BindMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, + PrivRequest, PrivResponse, PrivStream, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -179,13 +179,11 @@ pub async fn write_nspawn_flags( container: &str, binds: &[BindMount], isolation: Option, - load_credentials: &[CredentialMount], ) -> Result<()> { ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), binds: binds.to_vec(), isolation, - load_credentials: load_credentials.to_vec(), }) .await?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index a03c747d..f3980dc2 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction, InfraContainer, - JournalQuery, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, - PrivStream, PrivStreamLine, SIBLING_CONTAINERS, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, InfraContainer, JournalQuery, META_DIR, + NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, + SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -202,8 +202,7 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, ref container, ref binds, ref isolation, - ref load_credentials, - } => handle_write_nspawn_flags(container, binds, isolation.as_ref(), load_credentials), + } => handle_write_nspawn_flags(container, binds, isolation.as_ref()), PrivRequest::WriteResourceLimits { ref container, @@ -324,44 +323,22 @@ async fn container_flake_action( } } -/// `WriteNspawnFlags` — validate the container + every bind path + every -/// credential entry, then write the container's nspawn flag overrides. +/// `WriteNspawnFlags` — validate the container + every bind path, then +/// write the container's nspawn flag overrides. fn handle_write_nspawn_flags( container: &str, binds: &[BindMount], isolation: Option<&NetworkIsolation>, - load_credentials: &[CredentialMount], ) -> Result<(String, String)> { validate_container_system_name(container)?; for bind in binds { validate_bind_path(&bind.host_path)?; validate_bind_path(&bind.container_path)?; } - for cred in load_credentials { - validate_credential_name(&cred.name)?; - // Same path rules as binds (absolute, no colon/newline/quote/null): - // the colon ban is essential since `--load-credential=name:path` - // uses `:` as the name/path separator. - validate_bind_path(&cred.host_path)?; - } - write_nspawn_flags(container, binds, isolation, load_credentials)?; + write_nspawn_flags(container, binds, isolation)?; Ok((String::new(), String::new())) } -/// A systemd credential id must be a short token — restrict to -/// `[A-Za-z0-9_.-]` so it can't inject extra `--load-credential` argv or -/// break the `name:path` shape. -fn validate_credential_name(name: &str) -> Result<()> { - if name.is_empty() - || !name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) - { - bail!("invalid credential name {name:?}: must be non-empty [A-Za-z0-9_.-]"); - } - Ok(()) -} - /// `RemoveServiceDropin` — remove the container service's drop-in dir /// if present (idempotent). fn remove_service_dropin(container: &str) -> Result<(String, String)> { @@ -1329,7 +1306,6 @@ fn write_nspawn_flags( container: &str, binds: &[BindMount], isolation: Option<&NetworkIsolation>, - load_credentials: &[CredentialMount], ) -> Result<()> { use std::fmt::Write as _; let path = format!("/etc/nixos-containers/{container}.conf"); @@ -1374,23 +1350,13 @@ fn write_nspawn_flags( out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); } - let mut flags: Vec = binds + let flags: Vec = binds .iter() .map(|b| { let flag = if b.read_only { "--bind-ro" } else { "--bind" }; format!("{flag}={}:{}", b.host_path, b.container_path) }) .collect(); - // Credential forwarding: nspawn loads each host secret into the - // container's credential store under ``; inner units inherit it - // via `LoadCredential=`. Validated (name charset + bind-path - // rules) in handle_write_nspawn_flags above. - for cred in load_credentials { - flags.push(format!( - "--load-credential={}:{}", - cred.name, cred.host_path - )); - } let flags_joined = flags.join(" "); let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\""); std::fs::write(&path, out).with_context(|| format!("write {path}"))?; diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 06556b7e..8dba0c27 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -177,23 +177,6 @@ pub struct BindMount { pub read_only: bool, } -/// One credential-forwarding entry for `WriteNspawnFlags`. hive-priv -/// constructs `--load-credential=:` so systemd-nspawn -/// loads the host secret file into the container's credential store; an -/// inner unit then reads it via `LoadCredential=` (inherit form). -/// The secret never lands in a bind mount, the nix store, or the -/// generated config — only its host path (validated like a bind path) -/// crosses the wire. Used for the hive-wide OTEL auth-header credential. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CredentialMount { - /// systemd credential id (e.g. `otel-headers`); inner units inherit - /// it by this name. Restricted to `[A-Za-z0-9_.-]` by hive-priv. - pub name: String, - /// Host path to the secret file, forwarded via nspawn - /// `--load-credential=:`. - pub host_path: String, -} - /// Network isolation parameters for `WriteNspawnFlags`. When `Some`, /// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead /// of the default `PRIVATE_NETWORK=0`. @@ -290,12 +273,6 @@ pub enum PrivRequest { /// veth on the specified bridge (`PRIVATE_NETWORK=1`). #[serde(default)] isolation: Option, - /// Host secrets forwarded into the container's credential store via - /// nspawn `--load-credential=:`. Empty for agents - /// with no credentials configured (the common case). `#[serde(default)]` - /// so a hive-priv built before this field deserialises new requests. - #[serde(default)] - load_credentials: Vec, }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 52fc2cd8..aae957bb 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -187,78 +187,6 @@ in ''; }; - # Hive-wide OTEL stats export. Set ONCE here at host level; the - # meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the - # HYPERHIVE_OTEL_* env exported below off hive-c0re's unit and injects - # the matching `hyperhive.otel.*` build-time config into EVERY agent - # (mirroring the CA-cert injection), so each agent's harness exports - # its own Claude Code stats directly to the collector. There is no - # per-agent opt-in — this is the single switch for the whole hive. - options.services.hyperhive.otel = { - enable = lib.mkEnableOption '' - hive-wide export of every agent's Claude Code stats (token usage, - cost, tool calls) to an OTLP endpoint via Claude Code's built-in - OpenTelemetry. One switch for all agents; each harness exports - directly to the collector, so it keeps working even when hive-c0re - is down - ''; - - endpoint = lib.mkOption { - type = lib.types.str; - default = ""; - example = "https://collector.example.com/otel"; - description = '' - OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT` - for every agent. Required when `enable` is true. - ''; - }; - - protocol = lib.mkOption { - type = lib.types.enum [ - "http/protobuf" - "http/json" - "grpc" - ]; - default = "http/protobuf"; - description = '' - OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. - ''; - }; - - headersCredential = lib.mkOption { - # `str`, not `path`: a `path`-typed relative literal is hash-copied - # into the world-readable nix store at eval time, defeating the - # point. Keep it a string + require an absolute runtime path so the - # secret is only ever read from disk by systemd at start. - type = lib.types.nullOr lib.types.str; - default = null; - example = "/run/secrets/otel-headers"; - description = '' - Absolute path to an operator-provided secret file whose contents - become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. - `Authorization=Bearer `). hive-c0re forwards this host - file into each agent container's credential store via - systemd-nspawn `--load-credential=otel-headers:`; the inner - harness unit inherits it by name (`LoadCredential`), so the token - is never copied into the nix store, the generated config, a bind - mount, or argv. Must be absolute. Leave null if the endpoint - needs no auth header. A configured-but-missing file is skipped - with a log warning (OTEL still exports, without the auth header). - ''; - }; - - extraResourceAttributes = lib.mkOption { - type = lib.types.str; - default = ""; - example = "deployment.environment=prod"; - description = '' - Extra comma-separated entries appended to - `OTEL_RESOURCE_ATTRIBUTES` after the built-in - `service.name` / `agent` / `hive` / `swarm` labels. - ''; - }; - }; - # 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 @@ -792,31 +720,24 @@ in config.services.hyperhive.swarm.wireguard.listenPort ]; - assertions = - lib.optionals config.services.hyperhive.swarm.wireguard.enable [ - { - assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; - message = '' - services.hyperhive.swarm.wireguard.enable requires - services.hyperhive.swarm.wireguard.privateKeyFile to be set. - Generate a key: wg genkey > /etc/wireguard/hive.key - ''; - } - { - assertion = config.services.hyperhive.swarm.wireguard.address != ""; - message = '' - services.hyperhive.swarm.wireguard.enable requires - services.hyperhive.swarm.wireguard.address to be set - (e.g. "10.100.0.1/24"). - ''; - } - ] - ++ lib.optionals config.services.hyperhive.otel.enable [ - { - assertion = config.services.hyperhive.otel.endpoint != ""; - message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty."; - } - ]; + assertions = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ + { + assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; + message = '' + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.privateKeyFile to be set. + Generate a key: wg genkey > /etc/wireguard/hive.key + ''; + } + { + assertion = config.services.hyperhive.swarm.wireguard.address != ""; + message = '' + services.hyperhive.swarm.wireguard.enable requires + services.hyperhive.swarm.wireguard.address to be set + (e.g. "10.100.0.1/24"). + ''; + } + ]; systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; @@ -869,26 +790,6 @@ in // lib.optionalAttrs (config.services.hyperhive.swarmName != null) { HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName; } - // lib.optionalAttrs config.services.hyperhive.otel.enable ( - # Hive-wide OTEL config -> read by meta.rs::otel_config and - # injected as build-time `hyperhive.otel.*` into every agent. - # Endpoint presence is the enable signal on the meta side; the - # optional fields are only emitted when set so absent values - # don't render no-op env lines. - let - otel = config.services.hyperhive.otel; - in - { - HYPERHIVE_OTEL_ENDPOINT = otel.endpoint; - HYPERHIVE_OTEL_PROTOCOL = otel.protocol; - } - // lib.optionalAttrs (otel.extraResourceAttributes != "") { - HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes; - } - // lib.optionalAttrs (otel.headersCredential != null) { - HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential; - } - ) // { # In-cluster forge URL — the gateway vhost (`forge.`), which # nginx proxies to forgejo. The forge is mandatory, so this is diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index c3367bf7..dae0e750 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -174,34 +174,23 @@ in visible = false; }; - # OTEL stats export is configured ONCE at host level via - # `services.hyperhive.otel.*` (see nix/modules/hive-c0re.nix) and - # injected into every agent's build by the meta-flake renderer - # (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are - # the build-time implementation surface that injection writes into; - # they are not meant to be set directly in an agent.nix. Marked - # `internal` so the host option is the only documented operator knob. options.hyperhive.otel = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - internal = true; - description = '' - Export this agent's Claude Code stats (token usage, cost, tool - calls) to an OTLP endpoint via Claude Code's built-in - OpenTelemetry. Each agent's harness exports directly to the - collector, so it keeps working even when hive-c0re is down. - Host-driven: set `services.hyperhive.otel.enable` instead. - ''; - }; + enable = lib.mkEnableOption '' + exporting this agent's Claude Code stats (token usage, cost, tool + calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry. + Each agent's harness exports its own stats directly to the collector, + so it keeps working even when hive-c0re is down. Meant to be enabled + hive-wide (one switch for every agent) - there is no per-agent + opt-in flag beyond this option + ''; endpoint = lib.mkOption { type = lib.types.str; default = ""; - internal = true; + example = "https://collector.example.com/otel"; description = '' OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. - Host-driven via `services.hyperhive.otel.endpoint`. + Required when `enable` is true. ''; }; @@ -212,10 +201,8 @@ in "grpc" ]; default = "http/protobuf"; - internal = true; description = '' OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. - Host-driven via `services.hyperhive.otel.protocol`. ''; }; @@ -227,27 +214,27 @@ in # is only ever read from disk by systemd at start, never nix-stored. type = lib.types.nullOr lib.types.str; default = null; - internal = true; + example = "/run/secrets/otel-headers"; description = '' Absolute path to an operator-provided secret file whose contents become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `Authorization=Bearer `). Loaded via systemd `LoadCredential` into the unit-private credential store at runtime, so the token is never copied into the nix store or - exposed in the process argv. Host-driven via - `services.hyperhive.otel.headersCredential`. + exposed in the process argv. Must be an absolute path (systemd + `LoadCredential` requires one). Leave null if the endpoint needs + no auth header. ''; }; extraResourceAttributes = lib.mkOption { type = lib.types.str; default = ""; - internal = true; + example = "deployment.environment=prod"; description = '' Extra comma-separated entries appended to `OTEL_RESOURCE_ATTRIBUTES` after the built-in `service.name` / `agent` / `hive` / `swarm` labels. - Host-driven via `services.hyperhive.otel.extraResourceAttributes`. ''; }; }; @@ -800,6 +787,11 @@ in ''; assertions = [ + # OTEL export needs an endpoint to point at. + { + assertion = !config.hyperhive.otel.enable || config.hyperhive.otel.endpoint != ""; + message = "hyperhive.otel.enable is true but hyperhive.otel.endpoint is empty."; + } # Guard the inputs-routed-as-output pattern: the agent flake.nix is # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. # If `self` leaks into flakeInputs the agent gets a spurious attrset @@ -1786,13 +1778,7 @@ in Group = userName; } // lib.optionalAttrs (otel.enable && otel.headersCredential != null) { - # Inherit form (no `:path`): hive-c0re forwards the host file at - # `headersCredential` into this container's credential store via - # nspawn `--load-credential=otel-headers:` (see - # lifecycle.rs::hive_load_credentials). The path isn't reachable - # from inside the container, so we inherit the already-loaded - # credential by name rather than re-reading the host path here. - LoadCredential = [ "otel-headers" ]; + LoadCredential = [ "otel-headers:${otel.headersCredential}" ]; }; };