Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21ec7dc23d | ||
|
|
838cc9af9a |
7 changed files with 438 additions and 53 deletions
|
|
@ -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` /
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"))?;
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue