Compare commits

..
7 changed files with 53 additions and 438 deletions

View file

@ -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<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(
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<BindMount> = 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` /

View file

@ -656,48 +656,6 @@ fn peer_ca_sources() -> Vec<String> {
.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
/// `(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
@ -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}"
);
}
}

View file

@ -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<NetworkIsolation>,
load_credentials: &[CredentialMount],
) -> Result<()> {
ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(),
binds: binds.to_vec(),
isolation,
load_credentials: load_credentials.to_vec(),
})
.await?)
}

View file

@ -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<String> = binds
let flags: Vec<String> = 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 `<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 _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
std::fs::write(&path, out).with_context(|| format!("write {path}"))?;

View file

@ -177,23 +177,6 @@ pub struct BindMount {
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`,
/// 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<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`

View file

@ -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 <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
# 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.<domain>`), which
# nginx proxies to forgejo. The forge is mandatory, so this is

View file

@ -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 <token>`). 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:<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" ];
LoadCredential = [ "otel-headers:${otel.headersCredential}" ];
};
};