Compare commits

...
7 changed files with 438 additions and 53 deletions

View file

@ -3,7 +3,7 @@
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use hive_sh4re::priv_proto::BindMount; use hive_sh4re::priv_proto::{BindMount, CredentialMount};
use tokio::process::Command; use tokio::process::Command;
use crate::coordinator::{AgentPaths, HiveEnv}; use crate::coordinator::{AgentPaths, HiveEnv};
@ -1188,6 +1188,42 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
}); });
} }
/// Hive-wide secrets forwarded into every agent container via nspawn
/// `--load-credential=<name>:<host_path>`. 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<CredentialMount> {
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( #[allow(
clippy::too_many_lines, clippy::too_many_lines,
reason = "one contiguous nspawn-flag assembly block; the length is the flag \ 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. // is needed here — the bind alone is enough.
let claude_mount = container_claude_mount(agent_name); 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<BindMount> = vec![ let mut binds: Vec<BindMount> = vec![
BindMount { BindMount {
host_path: runtime_dir.to_string_lossy().into_owned(), 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 \ (bad CIDR? prefix too narrow?); skipping PRIVATE_NETWORK write to \
avoid misconfigured isolation" 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 { let Some(gateway_ip) = bridge_gateway_ip(&subnet) else {
tracing::warn!( tracing::warn!(
@ -1376,7 +1422,13 @@ async fn set_nspawn_flags(
skipping PRIVATE_NETWORK write to avoid an isolated container with no \ skipping PRIVATE_NETWORK write to avoid an isolated container with no \
default route or resolver" 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!( tracing::info!(
%agent_name, %agent_ip, %gateway_ip, %bridge, %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). // 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` / /// Build the per-line callback for `create_container_streaming` /

View file

@ -656,6 +656,48 @@ fn peer_ca_sources() -> Vec<String> {
.collect() .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<String>,
headers_credential: Option<String>,
}
/// 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<OtelConfig> {
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 /// The ordered set of CA certs embedded next to the meta flake, as
/// `(filename, host_source_path)`. The self-signed hive CA (when active) /// `(filename, host_source_path)`. The self-signed hive CA (when active)
/// is `hive-ca.pem`; each peer CA is `peer-ca-<N>.pem` in declaration /// is `hive-ca.pem`; each peer CA is `peer-ca-<N>.pem` in declaration
@ -907,6 +949,40 @@ where
ca_refs.join(" ") 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( out.push_str(
r#" # The harness service inside the container runs as a r#" # The harness service inside the container runs as a
# non-root unix user named after the agent (`damocles`, # 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}" "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}"
);
}
} }

View file

@ -8,8 +8,8 @@
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{ use hive_sh4re::priv_proto::{
BindMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery, NetworkIsolation,
PrivRequest, PrivResponse, PrivStream, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
}; };
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream; use tokio::net::UnixStream;
@ -179,11 +179,13 @@ pub async fn write_nspawn_flags(
container: &str, container: &str,
binds: &[BindMount], binds: &[BindMount],
isolation: Option<NetworkIsolation>, isolation: Option<NetworkIsolation>,
load_credentials: &[CredentialMount],
) -> Result<()> { ) -> Result<()> {
ok(call(&PrivRequest::WriteNspawnFlags { ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(), container: container.to_owned(),
binds: binds.to_vec(), binds: binds.to_vec(),
isolation, isolation,
load_credentials: load_credentials.to_vec(),
}) })
.await?) .await?)
} }

View file

@ -21,9 +21,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{ use hive_sh4re::priv_proto::{
AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, InfraContainer, JournalQuery, META_DIR, AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction, InfraContainer,
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, JournalQuery, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
SIBLING_CONTAINERS, PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
}; };
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf; use tokio::net::unix::OwnedWriteHalf;
@ -202,7 +202,8 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
ref container, ref container,
ref binds, ref binds,
ref isolation, 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 { PrivRequest::WriteResourceLimits {
ref container, ref container,
@ -323,22 +324,44 @@ async fn container_flake_action(
} }
} }
/// `WriteNspawnFlags` — validate the container + every bind path, then /// `WriteNspawnFlags` — validate the container + every bind path + every
/// write the container's nspawn flag overrides. /// credential entry, then write the container's nspawn flag overrides.
fn handle_write_nspawn_flags( fn handle_write_nspawn_flags(
container: &str, container: &str,
binds: &[BindMount], binds: &[BindMount],
isolation: Option<&NetworkIsolation>, isolation: Option<&NetworkIsolation>,
load_credentials: &[CredentialMount],
) -> Result<(String, String)> { ) -> Result<(String, String)> {
validate_container_system_name(container)?; validate_container_system_name(container)?;
for bind in binds { for bind in binds {
validate_bind_path(&bind.host_path)?; validate_bind_path(&bind.host_path)?;
validate_bind_path(&bind.container_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())) 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 /// `RemoveServiceDropin` — remove the container service's drop-in dir
/// if present (idempotent). /// if present (idempotent).
fn remove_service_dropin(container: &str) -> Result<(String, String)> { fn remove_service_dropin(container: &str) -> Result<(String, String)> {
@ -1306,6 +1329,7 @@ fn write_nspawn_flags(
container: &str, container: &str,
binds: &[BindMount], binds: &[BindMount],
isolation: Option<&NetworkIsolation>, isolation: Option<&NetworkIsolation>,
load_credentials: &[CredentialMount],
) -> Result<()> { ) -> Result<()> {
use std::fmt::Write as _; use std::fmt::Write as _;
let path = format!("/etc/nixos-containers/{container}.conf"); 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("LOCAL_ADDRESS6=\n");
out.push_str("HOST_BRIDGE=\n"); out.push_str("HOST_BRIDGE=\n");
} }
let flags: Vec<String> = binds let mut flags: Vec<String> = binds
.iter() .iter()
.map(|b| { .map(|b| {
let flag = if b.read_only { "--bind-ro" } else { "--bind" }; let flag = if b.read_only { "--bind-ro" } else { "--bind" };
format!("{flag}={}:{}", b.host_path, b.container_path) format!("{flag}={}:{}", b.host_path, b.container_path)
}) })
.collect(); .collect();
// Credential forwarding: nspawn loads each host secret into the
// container's credential store under `<name>`; inner units inherit it
// via `LoadCredential=<name>`. 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 flags_joined = flags.join(" ");
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\""); let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
std::fs::write(&path, out).with_context(|| format!("write {path}"))?; std::fs::write(&path, out).with_context(|| format!("write {path}"))?;

View file

@ -177,6 +177,23 @@ pub struct BindMount {
pub read_only: bool, pub read_only: bool,
} }
/// One credential-forwarding entry for `WriteNspawnFlags`. hive-priv
/// constructs `--load-credential=<name>:<host_path>` so systemd-nspawn
/// loads the host secret file into the container's credential store; an
/// inner unit then reads it via `LoadCredential=<name>` (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=<name>:<host_path>`.
pub host_path: String,
}
/// Network isolation parameters for `WriteNspawnFlags`. When `Some`, /// Network isolation parameters for `WriteNspawnFlags`. When `Some`,
/// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead /// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead
/// of the default `PRIVATE_NETWORK=0`. /// of the default `PRIVATE_NETWORK=0`.
@ -273,6 +290,12 @@ pub enum PrivRequest {
/// veth on the specified bridge (`PRIVATE_NETWORK=1`). /// veth on the specified bridge (`PRIVATE_NETWORK=1`).
#[serde(default)] #[serde(default)]
isolation: Option<NetworkIsolation>, isolation: Option<NetworkIsolation>,
/// Host secrets forwarded into the container's credential store via
/// nspawn `--load-credential=<name>:<host_path>`. 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<CredentialMount>,
}, },
/// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf` /// Write `/run/systemd/system/container@<container>.service.d/hyperhive-limits.conf`

View file

@ -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 <token>`). hive-c0re forwards this host
file into each agent container's credential store via
systemd-nspawn `--load-credential=otel-headers:<path>`; 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 # Peer hives in the same swarm. Each entry declares a remote hive
# reachable from this host. Serialised to JSON and injected as # reachable from this host. Serialised to JSON and injected as
# `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent # `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent
@ -720,24 +792,31 @@ in
config.services.hyperhive.swarm.wireguard.listenPort config.services.hyperhive.swarm.wireguard.listenPort
]; ];
assertions = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ assertions =
{ lib.optionals config.services.hyperhive.swarm.wireguard.enable [
assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; {
message = '' assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null;
services.hyperhive.swarm.wireguard.enable requires message = ''
services.hyperhive.swarm.wireguard.privateKeyFile to be set. services.hyperhive.swarm.wireguard.enable requires
Generate a key: wg genkey > /etc/wireguard/hive.key 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 = '' assertion = config.services.hyperhive.swarm.wireguard.address != "";
services.hyperhive.swarm.wireguard.enable requires message = ''
services.hyperhive.swarm.wireguard.address to be set services.hyperhive.swarm.wireguard.enable requires
(e.g. "10.100.0.1/24"). 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 = { systemd.services.hive-c0re = {
description = "hyperhive coordinator daemon"; description = "hyperhive coordinator daemon";
@ -790,6 +869,26 @@ in
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) { // lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName; 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.<domain>`), which # In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
# nginx proxies to forgejo. The forge is mandatory, so this is # nginx proxies to forgejo. The forge is mandatory, so this is

View file

@ -174,23 +174,34 @@ in
visible = false; 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 = { options.hyperhive.otel = {
enable = lib.mkEnableOption '' enable = lib.mkOption {
exporting this agent's Claude Code stats (token usage, cost, tool type = lib.types.bool;
calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry. default = false;
Each agent's harness exports its own stats directly to the collector, internal = true;
so it keeps working even when hive-c0re is down. Meant to be enabled description = ''
hive-wide (one switch for every agent) - there is no per-agent Export this agent's Claude Code stats (token usage, cost, tool
opt-in flag beyond this option 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 { endpoint = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = ""; default = "";
example = "https://collector.example.com/otel"; internal = true;
description = '' description = ''
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. 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" "grpc"
]; ];
default = "http/protobuf"; default = "http/protobuf";
internal = true;
description = '' description = ''
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. 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. # is only ever read from disk by systemd at start, never nix-stored.
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
example = "/run/secrets/otel-headers"; internal = true;
description = '' description = ''
Absolute path to an operator-provided secret file whose contents Absolute path to an operator-provided secret file whose contents
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
`Authorization=Bearer <token>`). Loaded via systemd `Authorization=Bearer <token>`). Loaded via systemd
`LoadCredential` into the unit-private credential store at `LoadCredential` into the unit-private credential store at
runtime, so the token is never copied into the nix store or runtime, so the token is never copied into the nix store or
exposed in the process argv. Must be an absolute path (systemd exposed in the process argv. Host-driven via
`LoadCredential` requires one). Leave null if the endpoint needs `services.hyperhive.otel.headersCredential`.
no auth header.
''; '';
}; };
extraResourceAttributes = lib.mkOption { extraResourceAttributes = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = ""; default = "";
example = "deployment.environment=prod"; internal = true;
description = '' description = ''
Extra comma-separated entries appended to Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in `OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `hive` / `swarm` labels. `service.name` / `agent` / `hive` / `swarm` labels.
Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
''; '';
}; };
}; };
@ -787,11 +800,6 @@ in
''; '';
assertions = [ 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 # Guard the inputs-routed-as-output pattern: the agent flake.nix is
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
# If `self` leaks into flakeInputs the agent gets a spurious attrset # If `self` leaks into flakeInputs the agent gets a spurious attrset
@ -1778,7 +1786,13 @@ in
Group = userName; Group = userName;
} }
// lib.optionalAttrs (otel.enable && otel.headersCredential != null) { // 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:<host path>` (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" ];
}; };
}; };