diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index f46c50fd..1ea2a5f5 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; +use hive_sh4re::priv_proto::{BindMount, CredentialMount}; use tokio::process::Command; use crate::coordinator::{AgentPaths, HiveEnv}; @@ -1188,6 +1188,42 @@ 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 \ @@ -1234,6 +1270,10 @@ 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(), @@ -1367,7 +1407,13 @@ 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).await; + return crate::priv_client::write_nspawn_flags( + container, + &binds, + None, + &load_creds, + ) + .await; }; let Some(gateway_ip) = bridge_gateway_ip(&subnet) else { tracing::warn!( @@ -1376,7 +1422,13 @@ 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).await; + return crate::priv_client::write_nspawn_flags( + container, + &binds, + None, + &load_creds, + ) + .await; }; tracing::info!( %agent_name, %agent_ip, %gateway_ip, %bridge, @@ -1393,7 +1445,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).await + crate::priv_client::write_nspawn_flags(container, &binds, isolation, &load_creds).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 29a3a3c2..c5da4c40 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -656,6 +656,48 @@ 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 @@ -907,6 +949,40 @@ 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`, @@ -1445,4 +1521,89 @@ 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 87c92472..c8ac97af 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, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, - PrivRequest, PrivResponse, PrivStream, + BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, + PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -179,11 +179,13 @@ 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 f3980dc2..a03c747d 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, InfraAction, InfraContainer, JournalQuery, META_DIR, - NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, - SIBLING_CONTAINERS, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, CredentialMount, 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,7 +202,8 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, ref container, ref binds, ref isolation, - } => handle_write_nspawn_flags(container, binds, isolation.as_ref()), + ref load_credentials, + } => handle_write_nspawn_flags(container, binds, isolation.as_ref(), load_credentials), PrivRequest::WriteResourceLimits { ref container, @@ -323,22 +324,44 @@ async fn container_flake_action( } } -/// `WriteNspawnFlags` — validate the container + every bind path, then -/// write the container's nspawn flag overrides. +/// `WriteNspawnFlags` — validate the container + every bind path + every +/// credential entry, 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)?; } - write_nspawn_flags(container, binds, isolation)?; + 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)?; 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)> { @@ -1306,6 +1329,7 @@ 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"); @@ -1350,13 +1374,23 @@ fn write_nspawn_flags( out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); } - let flags: Vec = binds + let mut 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 8dba0c27..06556b7e 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -177,6 +177,23 @@ 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`. @@ -273,6 +290,12 @@ 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 aae957bb..52fc2cd8 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -187,6 +187,78 @@ 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 @@ -720,24 +792,31 @@ in config.services.hyperhive.swarm.wireguard.listenPort ]; - 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"). - ''; - } - ]; + 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."; + } + ]; systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; @@ -790,6 +869,26 @@ 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 dae0e750..c3367bf7 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -174,23 +174,34 @@ 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.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 - ''; + 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. + ''; + }; endpoint = lib.mkOption { type = lib.types.str; default = ""; - example = "https://collector.example.com/otel"; + internal = true; description = '' OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. - Required when `enable` is true. + Host-driven via `services.hyperhive.otel.endpoint`. ''; }; @@ -201,8 +212,10 @@ in "grpc" ]; default = "http/protobuf"; + internal = true; description = '' OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. + Host-driven via `services.hyperhive.otel.protocol`. ''; }; @@ -214,27 +227,27 @@ in # is only ever read from disk by systemd at start, never nix-stored. type = lib.types.nullOr lib.types.str; default = null; - example = "/run/secrets/otel-headers"; + internal = true; 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. Must be an absolute path (systemd - `LoadCredential` requires one). Leave null if the endpoint needs - no auth header. + exposed in the process argv. Host-driven via + `services.hyperhive.otel.headersCredential`. ''; }; extraResourceAttributes = lib.mkOption { type = lib.types.str; default = ""; - example = "deployment.environment=prod"; + internal = true; 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`. ''; }; }; @@ -787,11 +800,6 @@ 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 @@ -1778,7 +1786,13 @@ in Group = userName; } // lib.optionalAttrs (otel.enable && otel.headersCredential != null) { - LoadCredential = [ "otel-headers:${otel.headersCredential}" ]; + # 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" ]; }; };