add a per-agent nix builds mode, host or none, carried like privateUsers
This commit is contained in:
parent
b9484e62a0
commit
5485151ba6
27 changed files with 618 additions and 58 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -2003,6 +2003,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"hive-priv-sock",
|
||||
"hive-types",
|
||||
"libc",
|
||||
"mockall",
|
||||
"rustix",
|
||||
|
|
@ -2016,6 +2017,7 @@ dependencies = [
|
|||
name = "hive-priv-sock"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hive-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -4929,6 +4931,7 @@ dependencies = [
|
|||
"async-nats",
|
||||
"futures-util",
|
||||
"hive-log",
|
||||
"hive-types",
|
||||
"mockall",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
|
|
|
|||
|
|
@ -306,7 +306,10 @@ nix build "git+<forge>/internal/meta.git?rev=<rev>#nixosConfigurations.argus.con
|
|||
|
||||
### Containerized nix-daemon needs `sandbox-fallback = true`
|
||||
|
||||
Agent containers bind-mount the host's nix-daemon socket. nspawn
|
||||
nixos-containers' start script bind-mounts the host's nix-daemon socket
|
||||
directory into every container (hyperhive adds no bind for it; an agent
|
||||
with `services.hyperhive.agent.nixBuilds = "none"` gets it shadowed, see
|
||||
`docs/trust-boundary/security.md`). nspawn
|
||||
containers don't get user-namespaces by default, so `nix build`
|
||||
invocations _inside_ the container can't set up the build sandbox
|
||||
and fail outright if the host daemon's
|
||||
|
|
|
|||
|
|
@ -174,15 +174,53 @@ before building `agent_notes_dir(name)`, so a `..` component can't traverse.
|
|||
|
||||
### Background
|
||||
|
||||
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
|
||||
nixos-containers' start script bind-mounts the host's `nix-daemon` socket
|
||||
directory (`/nix/var/nix/daemon-socket`) into every container, hyperhive's
|
||||
agents included; hyperhive adds no bind for it. The host daemon may
|
||||
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
||||
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
||||
can't set up its build sandbox. the agent modules set `sandbox-fallback = true`
|
||||
so that builds fall back to unsandboxed execution rather than failing outright.
|
||||
|
||||
### Per-agent nix builds mode
|
||||
|
||||
`services.hyperhive.agent.nixBuilds` picks where an agent's nix builds run. It
|
||||
has two values, and defaults to `host`:
|
||||
|
||||
- **`host`**: builds go to the host daemon through the socket above. The daemon
|
||||
forks them in its own cgroup, so they run **outside the agent container's
|
||||
CPU and memory caps**, which sit on `container-<name>.service`.
|
||||
- **`none`**: the container has no route to the host daemon. hive-priv adds
|
||||
`--tmpfs=/nix/var/nix/daemon-socket` to the container's nspawn flags. nspawn
|
||||
mounts its custom mounts sorted by destination, then by kind (a bind before
|
||||
a tmpfs), so the empty tmpfs lands on top of the start script's bind
|
||||
whatever order the flags are in, and the container finds no `socket` in the
|
||||
directory. `NIX_REMOTE=daemon` stays, so a build fails with `cannot connect
|
||||
to socket at '/nix/var/nix/daemon-socket/socket'`, which names the cause. The
|
||||
agent's `/nix/store` and its read-only nix database stay bound, so store
|
||||
paths already there stay readable; only a realisation of a new path, or any
|
||||
`nix` command that needs the daemon, fails.
|
||||
|
||||
The mode reaches the container as `privateUsers` does: the swarm builder reads
|
||||
the option from the evaluated config into the agent's closure record
|
||||
(`nix_builds`), hive-c0re reads the record when it writes the container's
|
||||
flags and sends the mode to hive-priv, and the flags apply at the container's
|
||||
next start.
|
||||
|
||||
- A record or builder from before the field reads `host`.
|
||||
- A mode this build doesn't know (a record written by a newer swarm) reads as
|
||||
`none`, never `host`: an agent that asked for a mode its hive can't give gets
|
||||
no builds, not builds outside its limits.
|
||||
- A record hive-c0re can't read, or none, sends no mode, and hive-priv keeps
|
||||
what the container's last write chose.
|
||||
- A hive-c0re or hive-priv from before the option ignores it, so the agent keeps
|
||||
host builds until you upgrade both.
|
||||
|
||||
`none` cuts the daemon only. It leaves the agent's egress alone.
|
||||
|
||||
### Threat model
|
||||
|
||||
Unsandboxed nix builds run as `nixbld` users (non-root, typically UIDs 30001-30010).
|
||||
Unsandboxed nix builds run as `nixbld` users (non-root; `nrBuildUsers` is at least 32, so UIDs 30001 and up).
|
||||
Without sandbox isolation, a build derivation's builder script has read access to
|
||||
any file in the container that the nixbld user can read.
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ mod tests {
|
|||
built_unix: 1,
|
||||
cached,
|
||||
private_users: false,
|
||||
nix_builds: hive_types::NixBuilds::Host,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ mod tests {
|
|||
built_unix: 1,
|
||||
cached: true,
|
||||
private_users: false,
|
||||
nix_builds: hive_types::NixBuilds::Host,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ fn closure(cached: bool) -> AgentClosure {
|
|||
built_unix: 1,
|
||||
cached,
|
||||
private_users: false,
|
||||
nix_builds: hive_types::NixBuilds::Host,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_priv_sock::BindMount;
|
||||
use hive_types::Permission;
|
||||
use hive_types::{NixBuilds, Permission};
|
||||
|
||||
use crate::coordinator::{AgentPaths, HiveEnv};
|
||||
|
||||
|
|
@ -451,9 +451,11 @@ async fn set_nspawn_flags(
|
|||
});
|
||||
}
|
||||
|
||||
// Whether the agent's config sets `privateUsers`, as the swarm builder
|
||||
// recorded it. Read from the record, never from the agent's nix.
|
||||
let private_users = private_users_of(&crate::attic::closure::record(agent_name).await);
|
||||
// What the agent's config sets for `privateUsers` and `nixBuilds`, as the
|
||||
// swarm builder recorded it. Read from the record, never from the agent's nix.
|
||||
let record = crate::attic::closure::record(agent_name).await;
|
||||
let private_users = private_users_of(&record);
|
||||
let nix_builds = nix_builds_of(&record);
|
||||
|
||||
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
|
||||
// container so the harness can bind `web.sock` there and the host-side
|
||||
|
|
@ -487,6 +489,7 @@ async fn set_nspawn_flags(
|
|||
&load_creds,
|
||||
super::hive_facts(hive),
|
||||
private_users,
|
||||
nix_builds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -529,6 +532,21 @@ fn private_users_of(
|
|||
}
|
||||
}
|
||||
|
||||
/// What hive-priv is told about the agent's nix builds: the record's mode, or
|
||||
/// `None` (leave the agent as the last write set it) when there is no record or
|
||||
/// it cannot be read, so a swarm outage never turns an agent's host builds on.
|
||||
fn nix_builds_of(
|
||||
record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>,
|
||||
) -> Option<NixBuilds> {
|
||||
match record {
|
||||
Ok(record) => record.as_ref().map(|r| r.nix_builds),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's nix builds are left as they are");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The binds every agent container gets, before its own state, config and
|
||||
/// role-specific dirs.
|
||||
fn base_binds(runtime_dir: &Path, claude_dir: &Path, claude_mount: String) -> Vec<BindMount> {
|
||||
|
|
@ -559,8 +577,16 @@ mod tests {
|
|||
stage_limits,
|
||||
};
|
||||
use crate::coordinator::HiveEnv;
|
||||
use hive_types::NixBuilds;
|
||||
|
||||
fn record_with(private_users: bool) -> swarm_wire::agent_closures::AgentClosure {
|
||||
record_with_builds(private_users, NixBuilds::Host)
|
||||
}
|
||||
|
||||
fn record_with_builds(
|
||||
private_users: bool,
|
||||
nix_builds: NixBuilds,
|
||||
) -> swarm_wire::agent_closures::AgentClosure {
|
||||
swarm_wire::agent_closures::AgentClosure {
|
||||
meta_rev: "a".repeat(40),
|
||||
store_path: "/nix/store/x".to_owned(),
|
||||
|
|
@ -568,6 +594,7 @@ mod tests {
|
|||
built_unix: 1,
|
||||
cached: true,
|
||||
private_users,
|
||||
nix_builds,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -609,6 +636,38 @@ mod tests {
|
|||
assert_eq!(super::private_users_of(&Ok(Some(old))), Some(false));
|
||||
}
|
||||
|
||||
/// The record's mode is what hive-priv is told, both ways. No record and an
|
||||
/// unreadable one say nothing, so a swarm outage changes no agent; a record
|
||||
/// from before the field reads as host builds.
|
||||
#[test]
|
||||
fn the_records_nix_builds_is_what_the_start_path_passes() {
|
||||
for mode in [NixBuilds::Host, NixBuilds::Disabled] {
|
||||
assert_eq!(
|
||||
super::nix_builds_of(&Ok(Some(record_with_builds(false, mode)))),
|
||||
Some(mode)
|
||||
);
|
||||
}
|
||||
assert_eq!(super::nix_builds_of(&Ok(None)), None);
|
||||
assert_eq!(super::nix_builds_of(&Err(anyhow::anyhow!("down"))), None);
|
||||
let old = serde_json::json!({
|
||||
"meta_rev": "a", "store_path": "/nix/store/x",
|
||||
"system": "s", "built_unix": 1,
|
||||
});
|
||||
let old = serde_json::from_value(old).expect("decodes");
|
||||
assert_eq!(super::nix_builds_of(&Ok(Some(old))), Some(NixBuilds::Host));
|
||||
// A mode a newer swarm wrote, which this build does not know, decodes
|
||||
// and builds nothing.
|
||||
let newer = serde_json::json!({
|
||||
"meta_rev": "a", "store_path": "/nix/store/x",
|
||||
"system": "s", "built_unix": 1, "nix_builds": "container",
|
||||
});
|
||||
let newer = serde_json::from_value(newer).expect("decodes");
|
||||
assert_eq!(
|
||||
super::nix_builds_of(&Ok(Some(newer))),
|
||||
Some(NixBuilds::Disabled)
|
||||
);
|
||||
}
|
||||
|
||||
/// No agent container gets a cross-agent scratch dir; `/knowledge` is the
|
||||
/// control that the lookup finds a bind that is there.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use hive_priv_sock::{
|
|||
BindMount, CredentialMount, HiveFacts, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
|
||||
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||
};
|
||||
use hive_types::NixBuilds;
|
||||
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Interest};
|
||||
|
|
@ -320,6 +321,7 @@ pub async fn write_nspawn_flags(
|
|||
load_credentials: &[CredentialMount],
|
||||
hive_facts: HiveFacts,
|
||||
private_users: Option<bool>,
|
||||
nix_builds: Option<NixBuilds>,
|
||||
) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteNspawnFlags {
|
||||
container: container.to_owned(),
|
||||
|
|
@ -328,6 +330,7 @@ pub async fn write_nspawn_flags(
|
|||
load_credentials: load_credentials.to_vec(),
|
||||
hive_facts: Box::new(hive_facts),
|
||||
private_users,
|
||||
nix_builds,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ readme = "README.md"
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# `NixBuilds`, shared with the closure record.
|
||||
hive-types.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
//! audit. No server/client implementation lives here, only the wire
|
||||
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
|
||||
|
||||
use hive_types::NixBuilds;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default socket path for the privileged helper.
|
||||
|
|
@ -350,6 +351,16 @@ pub enum PrivRequest {
|
|||
/// agent as it is: namespaced if hive-priv moved its data, else not.
|
||||
#[serde(default)]
|
||||
private_users: Option<bool>,
|
||||
/// Whether the container may reach the host's nix-daemon, as the
|
||||
/// swarm builder recorded the agent's `services.hyperhive.agent.nixBuilds`.
|
||||
/// `Some(NixBuilds::Disabled)` has hive-priv shadow the daemon socket
|
||||
/// directory with an empty tmpfs. `None`, which is what a hive-c0re
|
||||
/// without the field or without a readable record sends, keeps the
|
||||
/// agent as the last write left it, so a swarm outage never turns an
|
||||
/// agent's host builds on. An agent never written before gets the
|
||||
/// host daemon.
|
||||
#[serde(default)]
|
||||
nix_builds: Option<NixBuilds>,
|
||||
},
|
||||
|
||||
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751
|
||||
|
|
@ -718,6 +729,8 @@ pub enum PrivEvent {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hive_types::NixBuilds;
|
||||
|
||||
use super::{InfraContainer, PrivRequest, valid_closure_path, valid_meta_rev};
|
||||
|
||||
/// A request from a hive-c0re that predates the field decodes with the
|
||||
|
|
@ -746,6 +759,42 @@ mod tests {
|
|||
assert_eq!(private_users, want);
|
||||
}
|
||||
}
|
||||
/// The nix-builds mode is optional on the wire: a request from a hive-c0re
|
||||
/// that predates the field reads as `None`, the field round-trips, and a
|
||||
/// mode this build does not know reads as the one that builds nothing.
|
||||
#[test]
|
||||
fn nix_builds_is_optional_on_the_wire() {
|
||||
let request = |extra: Option<(&str, serde_json::Value)>| {
|
||||
let mut wire = serde_json::json!({
|
||||
"op": "write_nspawn_flags",
|
||||
"container": "h-iris",
|
||||
"binds": [],
|
||||
"isolation": {"bridge": "hive0", "gateway_ip": "10.42.0.1"},
|
||||
});
|
||||
if let Some((key, value)) = extra {
|
||||
wire[key] = value;
|
||||
}
|
||||
let PrivRequest::WriteNspawnFlags { nix_builds, .. } =
|
||||
serde_json::from_value(wire).expect("decodes")
|
||||
else {
|
||||
panic!("variant");
|
||||
};
|
||||
nix_builds
|
||||
};
|
||||
assert_eq!(request(None), None);
|
||||
for (spelling, want) in [
|
||||
("host", NixBuilds::Host),
|
||||
("none", NixBuilds::Disabled),
|
||||
("container", NixBuilds::Disabled),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(Some(("nix_builds", spelling.into()))),
|
||||
Some(want),
|
||||
"{spelling}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infra_control_names_no_c0re_or_swarm_service() {
|
||||
// Neither the daemon nor the swarm's forge and matrix have a
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
hive-priv-sock.workspace = true
|
||||
hive-types.workspace = true
|
||||
libc.workspace = true
|
||||
rustix.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use anyhow::{Context as _, Result, bail};
|
|||
use hive_priv_sock::{
|
||||
AGENT_PREFIX, AGENT_RUNTIME_ROOT, BindMount, CredentialMount, HiveFacts, NetworkIsolation,
|
||||
};
|
||||
use hive_types::NixBuilds;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::shared::{StagedFile, open_dir, validate_credential_name, validate_name_chars};
|
||||
|
|
@ -27,6 +28,7 @@ pub(crate) fn handle_write_nspawn_flags(
|
|||
load_credentials: &[CredentialMount],
|
||||
hive_facts: &HiveFacts,
|
||||
private_users: Option<bool>,
|
||||
nix_builds: Option<NixBuilds>,
|
||||
) -> Result<(String, String)> {
|
||||
let agent = validate_container_system_name(container)?;
|
||||
for bind in binds {
|
||||
|
|
@ -60,8 +62,11 @@ pub(crate) fn handle_write_nspawn_flags(
|
|||
isolation,
|
||||
load_credentials,
|
||||
&hive_facts,
|
||||
ephemeral,
|
||||
userns,
|
||||
StartMode {
|
||||
ephemeral,
|
||||
userns,
|
||||
nix_builds,
|
||||
},
|
||||
)?;
|
||||
Ok((String::new(), String::new()))
|
||||
}
|
||||
|
|
@ -79,6 +84,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
|
|||
load_credentials: Vec<CredentialMount>,
|
||||
hive_facts: HiveFacts,
|
||||
private_users: Option<bool>,
|
||||
nix_builds: Option<NixBuilds>,
|
||||
) -> Result<(String, String)> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
handle_write_nspawn_flags(
|
||||
|
|
@ -88,6 +94,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
|
|||
&load_credentials,
|
||||
&hive_facts,
|
||||
private_users,
|
||||
nix_builds,
|
||||
)
|
||||
})
|
||||
.await
|
||||
|
|
@ -358,6 +365,18 @@ fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// What a start of the container asks of nspawn besides its binds, as
|
||||
/// [`write_nspawn_flags`] writes it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct StartMode {
|
||||
/// `--ephemeral`, once [`crate::rootfs`] has made the root fit for it.
|
||||
ephemeral: bool,
|
||||
/// The container runs in its own user namespace.
|
||||
userns: bool,
|
||||
/// `None` keeps the choice the conf's last write made.
|
||||
nix_builds: Option<NixBuilds>,
|
||||
}
|
||||
|
||||
/// Update `/etc/nixos-containers/<container>.conf`: strip old network vars
|
||||
/// (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`),
|
||||
/// write the current network-isolation settings, then append
|
||||
|
|
@ -370,11 +389,15 @@ fn write_nspawn_flags(
|
|||
isolation: &NetworkIsolation,
|
||||
load_credentials: &[CredentialMount],
|
||||
hive_facts: &str,
|
||||
ephemeral: bool,
|
||||
userns: bool,
|
||||
mode: StartMode,
|
||||
) -> Result<()> {
|
||||
use std::fmt::Write as _;
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let StartMode {
|
||||
ephemeral,
|
||||
userns,
|
||||
nix_builds,
|
||||
} = mode;
|
||||
let path = format!("/etc/nixos-containers/{container}.conf");
|
||||
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
||||
let lines: Vec<&str> = original
|
||||
|
|
@ -406,7 +429,9 @@ fn write_nspawn_flags(
|
|||
out.push_str("LOCAL_ADDRESS6=\n");
|
||||
let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge);
|
||||
}
|
||||
let flags_joined = nspawn_flags(binds, load_credentials, ephemeral, userns).join(" ");
|
||||
let nix_builds = chosen_nix_builds(nix_builds, &original);
|
||||
let flags_joined =
|
||||
nspawn_flags(binds, load_credentials, ephemeral, userns, nix_builds).join(" ");
|
||||
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
|
||||
// Keeps the owner and mode `nixos-container` created the file with; this
|
||||
// helper only rewrites its lines.
|
||||
|
|
@ -457,6 +482,38 @@ fn private_users_line(userns: bool) -> &'static str {
|
|||
if userns { "PRIVATE_USERS=pick\n" } else { "" }
|
||||
}
|
||||
|
||||
/// An empty tmpfs over `/nix/var/nix/daemon-socket`, the directory nixos-containers
|
||||
/// always binds the host's nix-daemon socket through, so the container finds no
|
||||
/// socket to connect to. nspawn mounts its custom mounts sorted by destination
|
||||
/// and then by kind, and a tmpfs sorts after a bind, so this lands on top of the
|
||||
/// start script's bind whatever order the flags are in.
|
||||
const NIX_DAEMON_SHADOW_FLAG: &str = "--tmpfs=/nix/var/nix/daemon-socket";
|
||||
|
||||
/// What the previous write of the conf chose: [`NixBuilds::Disabled`] when its
|
||||
/// `EXTRA_NSPAWN_FLAGS` line carries [`NIX_DAEMON_SHADOW_FLAG`], else
|
||||
/// [`NixBuilds::Host`], which is also what a conf never written gets.
|
||||
fn previous_nix_builds(conf: &str) -> NixBuilds {
|
||||
let shadowed = conf
|
||||
.lines()
|
||||
.filter_map(|line| line.trim_start().strip_prefix("EXTRA_NSPAWN_FLAGS="))
|
||||
.any(|flags| {
|
||||
flags
|
||||
.split(|c: char| c.is_whitespace() || c == '"')
|
||||
.any(|flag| flag == NIX_DAEMON_SHADOW_FLAG)
|
||||
});
|
||||
if shadowed {
|
||||
NixBuilds::Disabled
|
||||
} else {
|
||||
NixBuilds::Host
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode a write uses: the one asked for, else what the conf's last write
|
||||
/// chose.
|
||||
fn chosen_nix_builds(requested: Option<NixBuilds>, conf: &str) -> NixBuilds {
|
||||
requested.unwrap_or_else(|| previous_nix_builds(conf))
|
||||
}
|
||||
|
||||
/// The flags `EXTRA_NSPAWN_FLAGS` carries, in the order nspawn needs them.
|
||||
///
|
||||
/// `--ephemeral` runs the container on a snapshot of its root and drops the
|
||||
|
|
@ -464,6 +521,8 @@ fn private_users_line(userns: bool) -> &'static str {
|
|||
/// the flag is asked for. The git overlays and credentials follow the binds
|
||||
/// they refer to.
|
||||
///
|
||||
/// `nix_builds` set to [`NixBuilds::Disabled`] adds [`NIX_DAEMON_SHADOW_FLAG`].
|
||||
///
|
||||
/// `userns` mounts every bind `owneridmap` and idmaps the root instead of
|
||||
/// chowning it, so a different `pick` range never walks the tree.
|
||||
fn nspawn_flags(
|
||||
|
|
@ -471,6 +530,7 @@ fn nspawn_flags(
|
|||
load_credentials: &[CredentialMount],
|
||||
ephemeral: bool,
|
||||
userns: bool,
|
||||
nix_builds: NixBuilds,
|
||||
) -> Vec<String> {
|
||||
let mut flags: Vec<String> = ephemeral
|
||||
.then(|| "--ephemeral".to_owned())
|
||||
|
|
@ -485,6 +545,9 @@ fn nspawn_flags(
|
|||
format!("{flag}={}:{}{idmap}", b.host_path, b.container_path)
|
||||
}));
|
||||
flags.extend(git_overlay_flags(binds));
|
||||
if nix_builds == NixBuilds::Disabled {
|
||||
flags.push(NIX_DAEMON_SHADOW_FLAG.to_owned());
|
||||
}
|
||||
// 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
|
||||
|
|
@ -653,8 +716,9 @@ pub(crate) fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BindMount, HiveFacts, NetworkIsolation, git_overlay_flags, handle_write_nspawn_flags,
|
||||
hive_priv_exe, is_owned_var, limits_dropin_body, nspawn_flags, private_users_line,
|
||||
BindMount, HiveFacts, NIX_DAEMON_SHADOW_FLAG, NetworkIsolation, NixBuilds,
|
||||
chosen_nix_builds, git_overlay_flags, handle_write_nspawn_flags, hive_priv_exe,
|
||||
is_owned_var, limits_dropin_body, nspawn_flags, previous_nix_builds, private_users_line,
|
||||
publish_file, remove_service_dropin, render_hive_facts, rootfs_check_command,
|
||||
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
|
||||
};
|
||||
|
|
@ -1105,10 +1169,10 @@ mod tests {
|
|||
#[test]
|
||||
fn ephemeral_is_passed_only_when_asked() {
|
||||
let binds = [bind("/agents/atlas/work")];
|
||||
let with = nspawn_flags(&binds, &[], true, false);
|
||||
let with = nspawn_flags(&binds, &[], true, false, NixBuilds::Host);
|
||||
assert_eq!(with[0], "--ephemeral", "{with:?}");
|
||||
assert!(with.iter().any(|f| f.starts_with("--bind-ro=")), "{with:?}");
|
||||
let without = nspawn_flags(&binds, &[], false, false);
|
||||
let without = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
|
||||
assert!(!without.iter().any(|f| f == "--ephemeral"), "{without:?}");
|
||||
assert_eq!(without.len() + 1, with.len());
|
||||
}
|
||||
|
|
@ -1119,7 +1183,7 @@ mod tests {
|
|||
#[test]
|
||||
fn binds_are_owneridmap_only_for_a_namespaced_agent() {
|
||||
let binds = [bind("/knowledge"), bind("/agents/a/config")];
|
||||
let plain = nspawn_flags(&binds, &[], false, false);
|
||||
let plain = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
|
||||
assert!(
|
||||
plain.iter().any(|f| f.starts_with("--bind-ro=")),
|
||||
"{plain:?}"
|
||||
|
|
@ -1130,7 +1194,7 @@ mod tests {
|
|||
.all(|f| !f.contains("idmap") && !f.contains("ownership")),
|
||||
"{plain:?}"
|
||||
);
|
||||
let userns = nspawn_flags(&binds, &[], false, true);
|
||||
let userns = nspawn_flags(&binds, &[], false, true, NixBuilds::Host);
|
||||
assert!(
|
||||
userns.contains(&"--private-users-ownership=map".to_owned()),
|
||||
"{userns:?}"
|
||||
|
|
@ -1143,6 +1207,63 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// `Disabled` puts the tmpfs over the daemon socket directory after the
|
||||
/// binds; `Host` leaves it out. The binds are the control that the lookup
|
||||
/// finds what is there, and the flag is the whole of the difference.
|
||||
#[test]
|
||||
fn nix_builds_none_shadows_the_daemon_socket_dir() {
|
||||
let binds = [bind("/agents/atlas/work")];
|
||||
let host = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
|
||||
let none = nspawn_flags(&binds, &[], false, false, NixBuilds::Disabled);
|
||||
assert!(host.iter().any(|f| f.starts_with("--bind-ro=")), "{host:?}");
|
||||
assert!(
|
||||
!host.iter().any(|f| f == NIX_DAEMON_SHADOW_FLAG),
|
||||
"{host:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
none.last().map(String::as_str),
|
||||
Some(NIX_DAEMON_SHADOW_FLAG)
|
||||
);
|
||||
assert_eq!(none.len(), host.len() + 1);
|
||||
assert_eq!(NIX_DAEMON_SHADOW_FLAG, "--tmpfs=/nix/var/nix/daemon-socket");
|
||||
}
|
||||
|
||||
/// An unspecified mode keeps what the conf's last write chose, whatever
|
||||
/// else the line carries; a conf with no such line is a host-builds agent.
|
||||
#[test]
|
||||
fn an_unspecified_nix_builds_mode_keeps_the_last_writes_choice() {
|
||||
let binds = [bind("/knowledge")];
|
||||
for want in [NixBuilds::Host, NixBuilds::Disabled] {
|
||||
let flags = nspawn_flags(&binds, &[], true, true, want).join(" ");
|
||||
let conf = format!("AUTO_START=1\nEXTRA_NSPAWN_FLAGS=\"{flags}\"\n");
|
||||
assert_eq!(previous_nix_builds(&conf), want, "{conf}");
|
||||
}
|
||||
assert_eq!(previous_nix_builds(""), NixBuilds::Host);
|
||||
// A mode asked for wins over the conf; none asked for keeps it.
|
||||
let shadowed = format!("EXTRA_NSPAWN_FLAGS=\"{NIX_DAEMON_SHADOW_FLAG}\"\n");
|
||||
assert_eq!(chosen_nix_builds(None, &shadowed), NixBuilds::Disabled);
|
||||
assert_eq!(chosen_nix_builds(None, ""), NixBuilds::Host);
|
||||
assert_eq!(
|
||||
chosen_nix_builds(Some(NixBuilds::Host), &shadowed),
|
||||
NixBuilds::Host
|
||||
);
|
||||
assert_eq!(
|
||||
chosen_nix_builds(Some(NixBuilds::Disabled), ""),
|
||||
NixBuilds::Disabled
|
||||
);
|
||||
// The flag has to be a flag of its own, on the flags line.
|
||||
assert_eq!(
|
||||
previous_nix_builds(&format!(
|
||||
"EXTRA_NSPAWN_FLAGS=\"{NIX_DAEMON_SHADOW_FLAG}/x\"\n"
|
||||
)),
|
||||
NixBuilds::Host
|
||||
);
|
||||
assert_eq!(
|
||||
previous_nix_builds(&format!("OTHER=\"{NIX_DAEMON_SHADOW_FLAG}\"\n")),
|
||||
NixBuilds::Host
|
||||
);
|
||||
}
|
||||
|
||||
/// `PRIVATE_USERS=` is dropped from the conf whatever the agent now is, so
|
||||
/// leaving the list cannot leave the line behind. `AUTO_START=` is the
|
||||
/// control: a line that is not ours stays.
|
||||
|
|
@ -1166,7 +1287,8 @@ mod tests {
|
|||
let facts = HiveFacts::default();
|
||||
for name in ["hive-forge", "hive-matrix"] {
|
||||
let errs = [
|
||||
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts, None).unwrap_err(),
|
||||
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts, None, None)
|
||||
.unwrap_err(),
|
||||
write_resource_limits(name, "4G", "200%", None, None).unwrap_err(),
|
||||
remove_service_dropin(name).unwrap_err(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ async fn exec(
|
|||
load_credentials,
|
||||
hive_facts,
|
||||
private_users,
|
||||
nix_builds,
|
||||
} => {
|
||||
container_config::handle_write_nspawn_flags_blocking(
|
||||
container,
|
||||
|
|
@ -422,6 +423,7 @@ async fn exec(
|
|||
load_credentials,
|
||||
*hive_facts,
|
||||
private_users,
|
||||
nix_builds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency
|
|||
|
||||
- **`Permission`** — the set of named permissions an agent may hold, with its
|
||||
stored `snake_case` names and the MCP and built-in tools each grants.
|
||||
- **`NixBuilds`** — where an agent's nix builds run (`host` or `none`), as
|
||||
`services.hyperhive.agent.nixBuilds` sets it. It rides the swarm closure record,
|
||||
the builder's reply and the `hive-priv` wire; a spelling it doesn't know reads
|
||||
as `none`.
|
||||
- **`Ident`** — a validated agent-name newtype. Typing agent-name fields as
|
||||
`Ident` (rather than bare `String`) gets serde-validated parsing **at the
|
||||
socket boundary for free**: a malformed name is rejected at deserialize time
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`],
|
||||
//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`], [`NixBuilds`],
|
||||
//! the [`term_msg`] row markers and the [`review`] rule.
|
||||
//!
|
||||
//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate
|
||||
|
|
@ -7,8 +7,10 @@
|
|||
//! and get serde-validated parsing at the socket boundary for free — with
|
||||
//! no cross-crate coupling and without growing `hive-sh4re`.
|
||||
|
||||
mod nix_builds;
|
||||
mod permission;
|
||||
|
||||
pub use nix_builds::NixBuilds;
|
||||
pub use permission::{Permission, deserialize_known};
|
||||
|
||||
pub mod review;
|
||||
|
|
|
|||
58
hive-types/src/nix_builds.rs
Normal file
58
hive-types/src/nix_builds.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! How an agent's nix builds run: [`NixBuilds`].
|
||||
//!
|
||||
//! Lives in this leaf crate because the same value is read by the swarm
|
||||
//! builder's reply, the closure record, `hive-c0re` and the `hive-priv` wire.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where an agent's nix builds run, as `services.hyperhive.agent.nixBuilds`
|
||||
/// sets it. Stored by its option spelling.
|
||||
///
|
||||
/// A spelling this build does not know reads as [`NixBuilds::Disabled`], so a
|
||||
/// record written by a newer swarm never fails to decode. An agent that asked
|
||||
/// for a mode this build lacks therefore gets no builds, never builds on the
|
||||
/// host daemon outside its limits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum NixBuilds {
|
||||
/// Builds go to the host's nix-daemon, which runs them outside the agent
|
||||
/// container's CPU and memory caps. The default.
|
||||
#[default]
|
||||
#[serde(rename = "host")]
|
||||
Host,
|
||||
/// The container cannot reach the host's nix-daemon, so it builds nothing.
|
||||
#[serde(rename = "none", other)]
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NixBuilds;
|
||||
|
||||
#[test]
|
||||
fn the_spellings_are_pinned() {
|
||||
for (value, wire) in [(NixBuilds::Host, "host"), (NixBuilds::Disabled, "none")] {
|
||||
assert_eq!(serde_json::to_value(value).expect("encodes"), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<NixBuilds>(serde_json::json!(wire)).expect("decodes"),
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_is_the_host_daemon() {
|
||||
assert_eq!(NixBuilds::default(), NixBuilds::Host);
|
||||
}
|
||||
|
||||
/// A mode a newer swarm writes decodes, and as the one that builds nothing.
|
||||
#[test]
|
||||
fn an_unknown_spelling_reads_as_disabled() {
|
||||
for unknown in ["container", "", "HOST"] {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<NixBuilds>(serde_json::json!(unknown)).expect("decodes"),
|
||||
NixBuilds::Disabled,
|
||||
"{unknown:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,32 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.agent.nixBuilds = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"host"
|
||||
"none"
|
||||
];
|
||||
default = "host";
|
||||
example = "none";
|
||||
description = ''
|
||||
Where this agent's nix builds run.
|
||||
|
||||
- `host` (default): builds go through the host's nix-daemon socket,
|
||||
which every agent container gets bound in by default. The daemon runs
|
||||
them outside this container, so the container's CPU and memory caps do
|
||||
not apply to a build.
|
||||
- `none`: the container cannot reach the host's nix-daemon, so
|
||||
`nix build`, `nix run`, `nix shell` and the like fail for anything not
|
||||
already in the store, with a "cannot connect to socket" error. The
|
||||
agent's own system closure stays readable.
|
||||
|
||||
The swarm builder reads the option from the evaluated config into the
|
||||
agent's closure record, and the hive applies it when it next starts the
|
||||
container, so a running agent keeps its mode until restarted. A host
|
||||
that does not know the option leaves the agent on `host`.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
|
||||
|
|
@ -235,6 +261,10 @@ in
|
|||
# Non-root nix clients already default to the daemon socket, so this
|
||||
# is a no-op for them; it only matters for root services that would
|
||||
# otherwise silently use the local store.
|
||||
#
|
||||
# Stays set under `nixBuilds = "none"`: the missing socket then fails a
|
||||
# build with an error that names it, where the local store would fail on a
|
||||
# read-only filesystem.
|
||||
systemd.globalEnvironment.NIX_REMOTE = "daemon";
|
||||
|
||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||
|
|
|
|||
|
|
@ -22,11 +22,20 @@ let
|
|||
;
|
||||
|
||||
# A named agent, so the rendered script carries a name no default supplies.
|
||||
migrate =
|
||||
(agentWith { services.hyperhive.agent.user.name = "iris"; })
|
||||
.system.activationScripts.hive-agent-user-migrate.text;
|
||||
iris = agentWith { services.hyperhive.agent.user.name = "iris"; };
|
||||
migrate = iris.system.activationScripts.hive-agent-user-migrate.text;
|
||||
# The tmpfiles rules of the same named agent.
|
||||
rules = (agentWith { services.hyperhive.agent.user.name = "iris"; }).systemd.tmpfiles.rules;
|
||||
rules = iris.systemd.tmpfiles.rules;
|
||||
# The same agent with its nix builds switched off, and one asking for a mode
|
||||
# the option does not have.
|
||||
noBuilds = agentWith {
|
||||
services.hyperhive.agent.user.name = "iris";
|
||||
services.hyperhive.agent.nixBuilds = "none";
|
||||
};
|
||||
unknownBuilds = agentWith {
|
||||
services.hyperhive.agent.user.name = "iris";
|
||||
services.hyperhive.agent.nixBuilds = "container";
|
||||
};
|
||||
# `privateUsers` with the given pins; the agent name is the one the cases
|
||||
# above use, so only the pins differ.
|
||||
withUsers =
|
||||
|
|
@ -113,6 +122,18 @@ let
|
|||
&& lib.elem "L /home/iris/.local/state/nix - - - - /nix/var/nix/profiles/per-user/iris" rules
|
||||
&& lib.elem "L /home/iris/.nix-profile - - - - /home/iris/.local/state/nix/profiles/profile" rules;
|
||||
}
|
||||
{
|
||||
# The option defaults to host builds, accepts `none`, and refuses a mode
|
||||
# it does not declare. The default and `none` arms are each other's
|
||||
# control; `none` leaves the in-container routing alone, because the
|
||||
# error an agent gets then comes from the missing socket.
|
||||
name = "nixBuilds defaults to host, takes none and refuses a mode it does not declare";
|
||||
ok =
|
||||
iris.services.hyperhive.agent.nixBuilds == "host"
|
||||
&& noBuilds.services.hyperhive.agent.nixBuilds == "none"
|
||||
&& noBuilds.systemd.globalEnvironment.NIX_REMOTE == "daemon"
|
||||
&& !(builtins.tryEval unknownBuilds.services.hyperhive.agent.nixBuilds).success;
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "agent-user" cases
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@
|
|||
# - a link planted at `root` in the template refuses the next start through
|
||||
# the limits drop-in's `ExecStartPre=` check (whose build has a GC root),
|
||||
# leaves the link's target untouched, and the start goes through once the
|
||||
# link is gone.
|
||||
#
|
||||
# link is gone;
|
||||
# - an agent whose config sets `services.hyperhive.agent.user.privateUsers`
|
||||
# (hive-c0re sends the flag from the closure record; the test sends it)
|
||||
# starts in its own
|
||||
# user namespace with state it already had, owned by uid 1000 on disk: the
|
||||
# data moves once to `dataOwner`, the agent user reads and writes it as
|
||||
# itself, the other agent's conf (no flag sent) is untouched, and a second
|
||||
# `WriteNspawnFlags` moves nothing.
|
||||
# starts in its own user namespace with state it already had, owned by uid
|
||||
# 1000 on disk: the data moves once to `dataOwner`, the agent user reads and
|
||||
# writes it as itself, the other agent's conf (no flag sent) is untouched,
|
||||
# and a second `WriteNspawnFlags` moves nothing;
|
||||
# - an agent set to `services.hyperhive.agent.nixBuilds = "none"` has no
|
||||
# nix-daemon socket and cannot build; a request naming no mode keeps that.
|
||||
#
|
||||
# The agent's journal forwarder itself (an OTLP push to the hive's collector) is
|
||||
# not booted here; the module-eval suite covers its wiring.
|
||||
|
|
@ -277,6 +277,9 @@ pkgs.testers.runNixOSTest {
|
|||
assert host.succeed(f"cat {WORK}/kept.txt").strip() == "kept"
|
||||
as_agent("hello")
|
||||
as_agent("tree --version")
|
||||
# The default mode: the host daemon's socket is there and answers.
|
||||
assert "socket" in in_ct("ls /nix/var/nix/daemon-socket").split()
|
||||
as_agent(f"nix-store --store daemon -q --hash {HELLO}")
|
||||
|
||||
with subtest("the switch ran once"):
|
||||
salvages = [d for d in salvage_dirs() if d.startswith("salvaged-rootfs-")]
|
||||
|
|
@ -335,10 +338,14 @@ pkgs.testers.runNixOSTest {
|
|||
"binds": NS_BINDS,
|
||||
"isolation": {"bridge": "${bridge}", "gateway_ip": "${gateway}"},
|
||||
"private_users": True,
|
||||
"nix_builds": "none",
|
||||
}
|
||||
priv_request(host, write)
|
||||
ns_conf = host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
|
||||
assert "PRIVATE_USERS=pick" in ns_conf, ns_conf
|
||||
assert "--tmpfs=/nix/var/nix/daemon-socket" in ns_conf, ns_conf
|
||||
# The other agent's conf has no such flag: the control for the line above.
|
||||
assert "daemon-socket" not in conf(), conf()
|
||||
assert "--private-users-ownership=map" in ns_conf, ns_conf
|
||||
# Every bind, the read-only config included, is mounted owneridmap.
|
||||
assert ns_conf.count(":owneridmap") == len(NS_BINDS), ns_conf
|
||||
|
|
@ -379,12 +386,27 @@ pkgs.testers.runNixOSTest {
|
|||
host.succeed("stat -c %u:%g ${nsSocketDir}/web.sock | grep -x ${dataOwner}:${dataOwner}")
|
||||
host.succeed(f"nixos-container run {NS} -- cat /proc/self/uid_map | grep -v ' 4294967295$'")
|
||||
|
||||
def no_daemon():
|
||||
# The start script's bind is under the tmpfs: no socket in the
|
||||
# directory, and a build cannot connect. The store path is still
|
||||
# readable, and the first agent's directory (checked above) has the
|
||||
# socket where this one has none.
|
||||
host.fail(f"nixos-container run {NS} -- test -S /nix/var/nix/daemon-socket/socket")
|
||||
assert as_ns("ls -A /nix/var/nix/daemon-socket") == ""
|
||||
host.fail(f"nixos-container run {NS} -- runuser -l {NS_AGENT} -c 'nix-store --store daemon -q --hash {HELLO}'")
|
||||
as_ns(f"test -r {HELLO}/bin/hello")
|
||||
no_daemon()
|
||||
|
||||
# The move runs once: a file a host process leaves owned by 1000 after it
|
||||
# is not chowned by the next request, and the agent restarts.
|
||||
host.succeed(f"nixos-container stop {NS}")
|
||||
host.succeed(f"echo late > {NS_DIR}/state/late.txt && chown 1000:1000 {NS_DIR}/state/late.txt")
|
||||
priv_request(host, write)
|
||||
# A request that names no nix-builds mode, as one with no readable
|
||||
# record is, keeps the choice the last write made.
|
||||
priv_request(host, {k: v for k, v in write.items() if k != "nix_builds"})
|
||||
host.succeed(f"stat -c %u:%g {NS_DIR}/state/late.txt | grep -x 1000:1000")
|
||||
assert "--tmpfs=/nix/var/nix/daemon-socket" in host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
|
||||
boot_ns()
|
||||
no_daemon()
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ async-nats.workspace = true
|
|||
# StreamExt::next on the subscription.
|
||||
futures-util.workspace = true
|
||||
hive-log.workspace = true
|
||||
# `NixBuilds`, the type of the reply's `nix_builds`.
|
||||
hive-types.workspace = true
|
||||
# The build metrics, exported over OTLP like the review classifier's.
|
||||
opentelemetry.workspace = true
|
||||
opentelemetry-otlp.workspace = true
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue