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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hive-priv-sock",
|
"hive-priv-sock",
|
||||||
|
"hive-types",
|
||||||
"libc",
|
"libc",
|
||||||
"mockall",
|
"mockall",
|
||||||
"rustix",
|
"rustix",
|
||||||
|
|
@ -2016,6 +2017,7 @@ dependencies = [
|
||||||
name = "hive-priv-sock"
|
name = "hive-priv-sock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"hive-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
@ -4929,6 +4931,7 @@ dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hive-log",
|
"hive-log",
|
||||||
|
"hive-types",
|
||||||
"mockall",
|
"mockall",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-otlp",
|
"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`
|
### 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`
|
containers don't get user-namespaces by default, so `nix build`
|
||||||
invocations _inside_ the container can't set up the build sandbox
|
invocations _inside_ the container can't set up the build sandbox
|
||||||
and fail outright if the host daemon's
|
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
|
### 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`
|
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
||||||
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
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`
|
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.
|
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
|
### 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
|
Without sandbox isolation, a build derivation's builder script has read access to
|
||||||
any file in the container that the nixbld user can read.
|
any file in the container that the nixbld user can read.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ mod tests {
|
||||||
built_unix: 1,
|
built_unix: 1,
|
||||||
cached,
|
cached,
|
||||||
private_users: false,
|
private_users: false,
|
||||||
|
nix_builds: hive_types::NixBuilds::Host,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ mod tests {
|
||||||
built_unix: 1,
|
built_unix: 1,
|
||||||
cached: true,
|
cached: true,
|
||||||
private_users: false,
|
private_users: false,
|
||||||
|
nix_builds: hive_types::NixBuilds::Host,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ fn closure(cached: bool) -> AgentClosure {
|
||||||
built_unix: 1,
|
built_unix: 1,
|
||||||
cached,
|
cached,
|
||||||
private_users: false,
|
private_users: false,
|
||||||
|
nix_builds: hive_types::NixBuilds::Host,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hive_priv_sock::BindMount;
|
use hive_priv_sock::BindMount;
|
||||||
use hive_types::Permission;
|
use hive_types::{NixBuilds, Permission};
|
||||||
|
|
||||||
use crate::coordinator::{AgentPaths, HiveEnv};
|
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
|
// What the agent's config sets for `privateUsers` and `nixBuilds`, as the
|
||||||
// recorded it. Read from the record, never from the agent's nix.
|
// 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);
|
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
|
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
|
||||||
// container so the harness can bind `web.sock` there and the host-side
|
// container so the harness can bind `web.sock` there and the host-side
|
||||||
|
|
@ -487,6 +489,7 @@ async fn set_nspawn_flags(
|
||||||
&load_creds,
|
&load_creds,
|
||||||
super::hive_facts(hive),
|
super::hive_facts(hive),
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
)
|
)
|
||||||
.await
|
.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
|
/// The binds every agent container gets, before its own state, config and
|
||||||
/// role-specific dirs.
|
/// role-specific dirs.
|
||||||
fn base_binds(runtime_dir: &Path, claude_dir: &Path, claude_mount: String) -> Vec<BindMount> {
|
fn base_binds(runtime_dir: &Path, claude_dir: &Path, claude_mount: String) -> Vec<BindMount> {
|
||||||
|
|
@ -559,8 +577,16 @@ mod tests {
|
||||||
stage_limits,
|
stage_limits,
|
||||||
};
|
};
|
||||||
use crate::coordinator::HiveEnv;
|
use crate::coordinator::HiveEnv;
|
||||||
|
use hive_types::NixBuilds;
|
||||||
|
|
||||||
fn record_with(private_users: bool) -> swarm_wire::agent_closures::AgentClosure {
|
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 {
|
swarm_wire::agent_closures::AgentClosure {
|
||||||
meta_rev: "a".repeat(40),
|
meta_rev: "a".repeat(40),
|
||||||
store_path: "/nix/store/x".to_owned(),
|
store_path: "/nix/store/x".to_owned(),
|
||||||
|
|
@ -568,6 +594,7 @@ mod tests {
|
||||||
built_unix: 1,
|
built_unix: 1,
|
||||||
cached: true,
|
cached: true,
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,6 +636,38 @@ mod tests {
|
||||||
assert_eq!(super::private_users_of(&Ok(Some(old))), Some(false));
|
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
|
/// No agent container gets a cross-agent scratch dir; `/knowledge` is the
|
||||||
/// control that the lookup finds a bind that is there.
|
/// control that the lookup finds a bind that is there.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use hive_priv_sock::{
|
||||||
BindMount, CredentialMount, HiveFacts, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
|
BindMount, CredentialMount, HiveFacts, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
|
||||||
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||||
};
|
};
|
||||||
|
use hive_types::NixBuilds;
|
||||||
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
|
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
|
||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Interest};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Interest};
|
||||||
|
|
@ -320,6 +321,7 @@ pub async fn write_nspawn_flags(
|
||||||
load_credentials: &[CredentialMount],
|
load_credentials: &[CredentialMount],
|
||||||
hive_facts: HiveFacts,
|
hive_facts: HiveFacts,
|
||||||
private_users: Option<bool>,
|
private_users: Option<bool>,
|
||||||
|
nix_builds: Option<NixBuilds>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ok(call(&PrivRequest::WriteNspawnFlags {
|
ok(call(&PrivRequest::WriteNspawnFlags {
|
||||||
container: container.to_owned(),
|
container: container.to_owned(),
|
||||||
|
|
@ -328,6 +330,7 @@ pub async fn write_nspawn_flags(
|
||||||
load_credentials: load_credentials.to_vec(),
|
load_credentials: load_credentials.to_vec(),
|
||||||
hive_facts: Box::new(hive_facts),
|
hive_facts: Box::new(hive_facts),
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ readme = "README.md"
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# `NixBuilds`, shared with the closure record.
|
||||||
|
hive-types.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
//! audit. No server/client implementation lives here, only the wire
|
//! audit. No server/client implementation lives here, only the wire
|
||||||
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
|
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
|
||||||
|
|
||||||
|
use hive_types::NixBuilds;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Default socket path for the privileged helper.
|
/// 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.
|
/// agent as it is: namespaced if hive-priv moved its data, else not.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
private_users: Option<bool>,
|
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
|
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751
|
||||||
|
|
@ -718,6 +729,8 @@ pub enum PrivEvent {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use hive_types::NixBuilds;
|
||||||
|
|
||||||
use super::{InfraContainer, PrivRequest, valid_closure_path, valid_meta_rev};
|
use super::{InfraContainer, PrivRequest, valid_closure_path, valid_meta_rev};
|
||||||
|
|
||||||
/// A request from a hive-c0re that predates the field decodes with the
|
/// A request from a hive-c0re that predates the field decodes with the
|
||||||
|
|
@ -746,6 +759,42 @@ mod tests {
|
||||||
assert_eq!(private_users, want);
|
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]
|
#[test]
|
||||||
fn infra_control_names_no_c0re_or_swarm_service() {
|
fn infra_control_names_no_c0re_or_swarm_service() {
|
||||||
// Neither the daemon nor the swarm's forge and matrix have a
|
// Neither the daemon nor the swarm's forge and matrix have a
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
hive-priv-sock.workspace = true
|
hive-priv-sock.workspace = true
|
||||||
|
hive-types.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
rustix.workspace = true
|
rustix.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use anyhow::{Context as _, Result, bail};
|
||||||
use hive_priv_sock::{
|
use hive_priv_sock::{
|
||||||
AGENT_PREFIX, AGENT_RUNTIME_ROOT, BindMount, CredentialMount, HiveFacts, NetworkIsolation,
|
AGENT_PREFIX, AGENT_RUNTIME_ROOT, BindMount, CredentialMount, HiveFacts, NetworkIsolation,
|
||||||
};
|
};
|
||||||
|
use hive_types::NixBuilds;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::shared::{StagedFile, open_dir, validate_credential_name, validate_name_chars};
|
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],
|
load_credentials: &[CredentialMount],
|
||||||
hive_facts: &HiveFacts,
|
hive_facts: &HiveFacts,
|
||||||
private_users: Option<bool>,
|
private_users: Option<bool>,
|
||||||
|
nix_builds: Option<NixBuilds>,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
let agent = validate_container_system_name(container)?;
|
let agent = validate_container_system_name(container)?;
|
||||||
for bind in binds {
|
for bind in binds {
|
||||||
|
|
@ -60,8 +62,11 @@ pub(crate) fn handle_write_nspawn_flags(
|
||||||
isolation,
|
isolation,
|
||||||
load_credentials,
|
load_credentials,
|
||||||
&hive_facts,
|
&hive_facts,
|
||||||
ephemeral,
|
StartMode {
|
||||||
userns,
|
ephemeral,
|
||||||
|
userns,
|
||||||
|
nix_builds,
|
||||||
|
},
|
||||||
)?;
|
)?;
|
||||||
Ok((String::new(), String::new()))
|
Ok((String::new(), String::new()))
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +84,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
|
||||||
load_credentials: Vec<CredentialMount>,
|
load_credentials: Vec<CredentialMount>,
|
||||||
hive_facts: HiveFacts,
|
hive_facts: HiveFacts,
|
||||||
private_users: Option<bool>,
|
private_users: Option<bool>,
|
||||||
|
nix_builds: Option<NixBuilds>,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
handle_write_nspawn_flags(
|
handle_write_nspawn_flags(
|
||||||
|
|
@ -88,6 +94,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
|
||||||
&load_credentials,
|
&load_credentials,
|
||||||
&hive_facts,
|
&hive_facts,
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -358,6 +365,18 @@ fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
|
||||||
.collect()
|
.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
|
/// Update `/etc/nixos-containers/<container>.conf`: strip old network vars
|
||||||
/// (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`),
|
/// (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`),
|
||||||
/// write the current network-isolation settings, then append
|
/// write the current network-isolation settings, then append
|
||||||
|
|
@ -370,11 +389,15 @@ fn write_nspawn_flags(
|
||||||
isolation: &NetworkIsolation,
|
isolation: &NetworkIsolation,
|
||||||
load_credentials: &[CredentialMount],
|
load_credentials: &[CredentialMount],
|
||||||
hive_facts: &str,
|
hive_facts: &str,
|
||||||
ephemeral: bool,
|
mode: StartMode,
|
||||||
userns: bool,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::os::unix::fs::MetadataExt as _;
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
let StartMode {
|
||||||
|
ephemeral,
|
||||||
|
userns,
|
||||||
|
nix_builds,
|
||||||
|
} = mode;
|
||||||
let path = format!("/etc/nixos-containers/{container}.conf");
|
let path = format!("/etc/nixos-containers/{container}.conf");
|
||||||
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
||||||
let lines: Vec<&str> = original
|
let lines: Vec<&str> = original
|
||||||
|
|
@ -406,7 +429,9 @@ fn write_nspawn_flags(
|
||||||
out.push_str("LOCAL_ADDRESS6=\n");
|
out.push_str("LOCAL_ADDRESS6=\n");
|
||||||
let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge);
|
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}\"");
|
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
|
||||||
// Keeps the owner and mode `nixos-container` created the file with; this
|
// Keeps the owner and mode `nixos-container` created the file with; this
|
||||||
// helper only rewrites its lines.
|
// helper only rewrites its lines.
|
||||||
|
|
@ -457,6 +482,38 @@ fn private_users_line(userns: bool) -> &'static str {
|
||||||
if userns { "PRIVATE_USERS=pick\n" } else { "" }
|
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.
|
/// 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
|
/// `--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
|
/// the flag is asked for. The git overlays and credentials follow the binds
|
||||||
/// they refer to.
|
/// 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
|
/// `userns` mounts every bind `owneridmap` and idmaps the root instead of
|
||||||
/// chowning it, so a different `pick` range never walks the tree.
|
/// chowning it, so a different `pick` range never walks the tree.
|
||||||
fn nspawn_flags(
|
fn nspawn_flags(
|
||||||
|
|
@ -471,6 +530,7 @@ fn nspawn_flags(
|
||||||
load_credentials: &[CredentialMount],
|
load_credentials: &[CredentialMount],
|
||||||
ephemeral: bool,
|
ephemeral: bool,
|
||||||
userns: bool,
|
userns: bool,
|
||||||
|
nix_builds: NixBuilds,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let mut flags: Vec<String> = ephemeral
|
let mut flags: Vec<String> = ephemeral
|
||||||
.then(|| "--ephemeral".to_owned())
|
.then(|| "--ephemeral".to_owned())
|
||||||
|
|
@ -485,6 +545,9 @@ fn nspawn_flags(
|
||||||
format!("{flag}={}:{}{idmap}", b.host_path, b.container_path)
|
format!("{flag}={}:{}{idmap}", b.host_path, b.container_path)
|
||||||
}));
|
}));
|
||||||
flags.extend(git_overlay_flags(binds));
|
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
|
// Credential forwarding: nspawn loads each host secret into the
|
||||||
// container's credential store under `<name>`; inner units inherit it
|
// container's credential store under `<name>`; inner units inherit it
|
||||||
// via `LoadCredential=<name>`. Validated (name charset + bind-path
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BindMount, HiveFacts, NetworkIsolation, git_overlay_flags, handle_write_nspawn_flags,
|
BindMount, HiveFacts, NIX_DAEMON_SHADOW_FLAG, NetworkIsolation, NixBuilds,
|
||||||
hive_priv_exe, is_owned_var, limits_dropin_body, nspawn_flags, private_users_line,
|
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,
|
publish_file, remove_service_dropin, render_hive_facts, rootfs_check_command,
|
||||||
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
|
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
|
||||||
};
|
};
|
||||||
|
|
@ -1105,10 +1169,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn ephemeral_is_passed_only_when_asked() {
|
fn ephemeral_is_passed_only_when_asked() {
|
||||||
let binds = [bind("/agents/atlas/work")];
|
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_eq!(with[0], "--ephemeral", "{with:?}");
|
||||||
assert!(with.iter().any(|f| f.starts_with("--bind-ro=")), "{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!(!without.iter().any(|f| f == "--ephemeral"), "{without:?}");
|
||||||
assert_eq!(without.len() + 1, with.len());
|
assert_eq!(without.len() + 1, with.len());
|
||||||
}
|
}
|
||||||
|
|
@ -1119,7 +1183,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn binds_are_owneridmap_only_for_a_namespaced_agent() {
|
fn binds_are_owneridmap_only_for_a_namespaced_agent() {
|
||||||
let binds = [bind("/knowledge"), bind("/agents/a/config")];
|
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!(
|
assert!(
|
||||||
plain.iter().any(|f| f.starts_with("--bind-ro=")),
|
plain.iter().any(|f| f.starts_with("--bind-ro=")),
|
||||||
"{plain:?}"
|
"{plain:?}"
|
||||||
|
|
@ -1130,7 +1194,7 @@ mod tests {
|
||||||
.all(|f| !f.contains("idmap") && !f.contains("ownership")),
|
.all(|f| !f.contains("idmap") && !f.contains("ownership")),
|
||||||
"{plain:?}"
|
"{plain:?}"
|
||||||
);
|
);
|
||||||
let userns = nspawn_flags(&binds, &[], false, true);
|
let userns = nspawn_flags(&binds, &[], false, true, NixBuilds::Host);
|
||||||
assert!(
|
assert!(
|
||||||
userns.contains(&"--private-users-ownership=map".to_owned()),
|
userns.contains(&"--private-users-ownership=map".to_owned()),
|
||||||
"{userns:?}"
|
"{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
|
/// `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
|
/// leaving the list cannot leave the line behind. `AUTO_START=` is the
|
||||||
/// control: a line that is not ours stays.
|
/// control: a line that is not ours stays.
|
||||||
|
|
@ -1166,7 +1287,8 @@ mod tests {
|
||||||
let facts = HiveFacts::default();
|
let facts = HiveFacts::default();
|
||||||
for name in ["hive-forge", "hive-matrix"] {
|
for name in ["hive-forge", "hive-matrix"] {
|
||||||
let errs = [
|
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(),
|
write_resource_limits(name, "4G", "200%", None, None).unwrap_err(),
|
||||||
remove_service_dropin(name).unwrap_err(),
|
remove_service_dropin(name).unwrap_err(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,7 @@ async fn exec(
|
||||||
load_credentials,
|
load_credentials,
|
||||||
hive_facts,
|
hive_facts,
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
} => {
|
} => {
|
||||||
container_config::handle_write_nspawn_flags_blocking(
|
container_config::handle_write_nspawn_flags_blocking(
|
||||||
container,
|
container,
|
||||||
|
|
@ -422,6 +423,7 @@ async fn exec(
|
||||||
load_credentials,
|
load_credentials,
|
||||||
*hive_facts,
|
*hive_facts,
|
||||||
private_users,
|
private_users,
|
||||||
|
nix_builds,
|
||||||
)
|
)
|
||||||
.await
|
.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
|
- **`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.
|
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`** — a validated agent-name newtype. Typing agent-name fields as
|
||||||
`Ident` (rather than bare `String`) gets serde-validated parsing **at the
|
`Ident` (rather than bare `String`) gets serde-validated parsing **at the
|
||||||
socket boundary for free**: a malformed name is rejected at deserialize time
|
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.
|
//! the [`term_msg`] row markers and the [`review`] rule.
|
||||||
//!
|
//!
|
||||||
//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate
|
//! 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
|
//! and get serde-validated parsing at the socket boundary for free — with
|
||||||
//! no cross-crate coupling and without growing `hive-sh4re`.
|
//! no cross-crate coupling and without growing `hive-sh4re`.
|
||||||
|
|
||||||
|
mod nix_builds;
|
||||||
mod permission;
|
mod permission;
|
||||||
|
|
||||||
|
pub use nix_builds::NixBuilds;
|
||||||
pub use permission::{Permission, deserialize_known};
|
pub use permission::{Permission, deserialize_known};
|
||||||
|
|
||||||
pub mod review;
|
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 = {
|
config = {
|
||||||
assertions = [
|
assertions = [
|
||||||
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
|
# 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
|
# 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
|
# is a no-op for them; it only matters for root services that would
|
||||||
# otherwise silently use the local store.
|
# 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";
|
systemd.globalEnvironment.NIX_REMOTE = "daemon";
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `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.
|
# A named agent, so the rendered script carries a name no default supplies.
|
||||||
migrate =
|
iris = agentWith { services.hyperhive.agent.user.name = "iris"; };
|
||||||
(agentWith { services.hyperhive.agent.user.name = "iris"; })
|
migrate = iris.system.activationScripts.hive-agent-user-migrate.text;
|
||||||
.system.activationScripts.hive-agent-user-migrate.text;
|
|
||||||
# The tmpfiles rules of the same named agent.
|
# 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
|
# `privateUsers` with the given pins; the agent name is the one the cases
|
||||||
# above use, so only the pins differ.
|
# above use, so only the pins differ.
|
||||||
withUsers =
|
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/.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;
|
&& 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
|
in
|
||||||
runGroup "agent-user" cases
|
runGroup "agent-user" cases
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,15 @@
|
||||||
# - a link planted at `root` in the template refuses the next start through
|
# - 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),
|
# 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
|
# 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`
|
# - an agent whose config sets `services.hyperhive.agent.user.privateUsers`
|
||||||
# (hive-c0re sends the flag from the closure record; the test sends it)
|
# (hive-c0re sends the flag from the closure record; the test sends it)
|
||||||
# starts in its own
|
# starts in its own user namespace with state it already had, owned by uid
|
||||||
# user namespace with state it already had, owned by uid 1000 on disk: the
|
# 1000 on disk: the data moves once to `dataOwner`, the agent user reads and
|
||||||
# data moves once to `dataOwner`, the agent user reads and writes it as
|
# writes it as itself, the other agent's conf (no flag sent) is untouched,
|
||||||
# itself, the other agent's conf (no flag sent) is untouched, and a second
|
# and a second `WriteNspawnFlags` moves nothing;
|
||||||
# `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
|
# 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.
|
# 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"
|
assert host.succeed(f"cat {WORK}/kept.txt").strip() == "kept"
|
||||||
as_agent("hello")
|
as_agent("hello")
|
||||||
as_agent("tree --version")
|
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"):
|
with subtest("the switch ran once"):
|
||||||
salvages = [d for d in salvage_dirs() if d.startswith("salvaged-rootfs-")]
|
salvages = [d for d in salvage_dirs() if d.startswith("salvaged-rootfs-")]
|
||||||
|
|
@ -335,10 +338,14 @@ pkgs.testers.runNixOSTest {
|
||||||
"binds": NS_BINDS,
|
"binds": NS_BINDS,
|
||||||
"isolation": {"bridge": "${bridge}", "gateway_ip": "${gateway}"},
|
"isolation": {"bridge": "${bridge}", "gateway_ip": "${gateway}"},
|
||||||
"private_users": True,
|
"private_users": True,
|
||||||
|
"nix_builds": "none",
|
||||||
}
|
}
|
||||||
priv_request(host, write)
|
priv_request(host, write)
|
||||||
ns_conf = host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
|
ns_conf = host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
|
||||||
assert "PRIVATE_USERS=pick" in ns_conf, 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
|
assert "--private-users-ownership=map" in ns_conf, ns_conf
|
||||||
# Every bind, the read-only config included, is mounted owneridmap.
|
# Every bind, the read-only config included, is mounted owneridmap.
|
||||||
assert ns_conf.count(":owneridmap") == len(NS_BINDS), ns_conf
|
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("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$'")
|
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
|
# 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.
|
# is not chowned by the next request, and the agent restarts.
|
||||||
host.succeed(f"nixos-container stop {NS}")
|
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")
|
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")
|
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()
|
boot_ns()
|
||||||
|
no_daemon()
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ async-nats.workspace = true
|
||||||
# StreamExt::next on the subscription.
|
# StreamExt::next on the subscription.
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
hive-log.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.
|
# The build metrics, exported over OTLP like the review classifier's.
|
||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
opentelemetry-otlp.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