Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-priv/src/container_config.rs
atlas 6ac637c7e3 hive-priv: split main.rs into one module per op family
No behaviour change: a pure move plus pub(crate) visibility. PrivRequest,
hive-priv-sock and every validation rule are untouched; the 58 tests move
next to the code they test, unchanged.

Why (#5476): the 28 root ops shared one 4.3k-line file, so no op family
could be reviewed on its own and every new root op landed there by default.

main.rs keeps the socket loop and the dispatch table. container (lifecycle),
container_config (nspawn flags, systemd drop-ins, daemon-reload), storage
(btrfs subvolumes, quotas, snapshots, du), files (pause marker, socket dir,
legacy tmpfiles) and infra (gateway nginx, forge admin, infra containers)
each carry their own validation; shared holds the agent-name checks, the
credential-name charset and StagedFile, plus the tests' scratch helpers
(one counter crate-wide, so test dirs stay unique).

hive-priv/README.md is in the diff because it named src/main.rs as the
home of container_flake_action and now describes the module layout.
2026-10-18 03:55:37 +02:00

774 lines
32 KiB
Rust

//! Container config writes: `WriteNspawnFlags` (the container's
//! `/etc/nixos-containers/<name>.conf`, plus the bridge-DNS marker and
//! hive-facts file in its rootfs), `WriteResourceLimits` /
//! `RemoveServiceDropin` (the `container@<name>.service` drop-in) and
//! `DaemonReload`.
//!
//! Every op that names a container takes its system name and refuses anything
//! [`validate_container_system_name`] does not accept.
use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use hive_priv_sock::{
AGENT_PREFIX, AGENT_RUNTIME_ROOT, BindMount, CredentialMount, HiveFacts, NetworkIsolation,
};
use tokio::process::Command;
use crate::shared::{StagedFile, open_dir, validate_credential_name, validate_name_chars};
/// `WriteNspawnFlags` — validate the container + every bind path + every
/// credential entry, then write the container's nspawn flag overrides.
pub(crate) fn handle_write_nspawn_flags(
container: &str,
binds: &[BindMount],
isolation: &NetworkIsolation,
load_credentials: &[CredentialMount],
hive_facts: &HiveFacts,
) -> Result<(String, String)> {
validate_container_system_name(container)?;
for bind in binds {
validate_bind_path(&bind.host_path)?;
validate_bind_path(&bind.container_path)?;
}
for cred in load_credentials {
validate_credential_name(&cred.name)?;
// Same path rules as binds (absolute, no colon/newline/quote/null):
// the colon ban is essential since `--load-credential=name:path`
// uses `:` as the name/path separator.
validate_bind_path(&cred.host_path)?;
}
let hive_facts = render_hive_facts(hive_facts)?;
write_nspawn_flags(container, binds, isolation, load_credentials, &hive_facts)?;
Ok((String::new(), String::new()))
}
/// Validate a system-level container name (`h-` prefix for all agents,
/// the manager included) and return the logical agent name after it.
/// Anything else is refused, the swarm's forge and matrix containers
/// included: they belong to the host that runs them, not to a hive.
fn validate_container_system_name(name: &str) -> Result<&str> {
let Some(suffix) = name.strip_prefix(AGENT_PREFIX) else {
bail!("container name {name:?} is not managed by hive");
};
validate_name_chars(suffix)?;
Ok(suffix)
}
/// `RemoveServiceDropin` — remove the container service's drop-in dir
/// if present (idempotent).
pub(crate) fn remove_service_dropin(container: &str) -> Result<(String, String)> {
validate_container_system_name(container)?;
let dir = format!("/run/systemd/system/container@{container}.service.d");
if Path::new(&dir).exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("remove {dir}"))?;
}
Ok((String::new(), String::new()))
}
/// `WriteResourceLimits` — drop the systemd resource settings into the
/// container service's drop-in dir, together with a
/// `ConditionPathIsDirectory=` guard on the agent's MCP runtime dir.
///
/// Two different kinds of setting land in the same file. `MemoryMax=` /
/// `CPUQuota=` are hard caps that throttle even on an idle host;
/// `CPUWeight=` / `IOWeight=` are cgroup v2 relative shares that only
/// decide who yields *under contention*. A weight of `None` means "not
/// configured" and omits the line, so a hive-c0re built before the weights
/// existed keeps producing the old two-line drop-in.
///
/// The condition causes systemd to *skip* (not *fail*) the unit when the
/// bind-mount source dir is absent — result is `condition`, which does not
/// increment the start-limit counter. This is belt-and-braces on top of
/// hive-c0re creating the dir in its start preamble: if it is missing at
/// start time, the unit idles rather than restart-looping into
/// `start-limit-hit`.
pub(crate) fn write_resource_limits(
container: &str,
memory_max: &str,
cpu_quota: &str,
cpu_weight: Option<u32>,
io_weight: Option<u32>,
) -> Result<(String, String)> {
let logical = validate_container_system_name(container)?;
let runtime_dir = format!("{AGENT_RUNTIME_ROOT}/{logical}");
let dir = format!("/run/systemd/system/container@{container}.service.d");
std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?;
let path = format!("{dir}/hyperhive-limits.conf");
let content = limits_dropin_body(&runtime_dir, memory_max, cpu_quota, cpu_weight, io_weight);
// Published whole: a `daemon-reload` between truncate and write would
// otherwise load the unit with its limits and start guard missing.
publish_file(Path::new(&path), content.as_bytes(), 0o644, None)?;
Ok((String::new(), String::new()))
}
/// How long a window the start-limit counts over, and how many starts it
/// allows inside it.
///
/// `container@.service` sets `Restart=on-failure` and **no** start limit, so
/// systemd's defaults apply: 5 starts per 10s, `RestartSec` 100ms. That
/// makes the bound depend on *how fast* a container dies — one that fails
/// instantly trips the limit in under a second, one that takes longer than
/// ~2s never trips it and restarts forever. Whether an agent gets bounded
/// is not meant to be a function of its failure speed.
///
/// The window has to exceed the worst-case time to burn the burst, or the
/// counter ages out between attempts and the limit is again unreachable:
/// `TimeoutStartSec` is 1min, so `BURST` slow failures plus their backoff
/// can span several minutes. 10min covers that with room.
///
/// Giving up is cheap here **because it is not terminal** — hive-c0re's
/// reconcile sweep retries later, and `reset-failed` (see `StartContainer`)
/// clears the latch first. That is what makes a tight burst safe.
const START_LIMIT_INTERVAL_SEC: u32 = 600;
/// One start plus two retries — the operator's ruling was "retry once or
/// twice", with the reconcile sweep as the slow path after that.
const START_LIMIT_BURST: u32 = 3;
/// Backoff between those retries. The 100ms default is for processes that
/// respawn instantly; a container that just failed to boot gains nothing
/// from being retried a tenth of a second later.
const RESTART_SEC: u32 = 5;
/// Render the body of `hyperhive-limits.conf`.
///
/// `[Unit]`: the condition is checked at start time — it skips (not fails)
/// the unit when the MCP socket dir is absent, avoiding restart loops —
/// plus the bounded start limit (see the constants above; `StartLimit*` are
/// `[Unit]` settings since systemd 229 and are silently ignored under
/// `[Service]`).
/// `[Service]`: the restart backoff, the hard caps, then the relative
/// weights. A weight of `None` means "not configured" and omits its line
/// entirely, so a request from a hive-c0re built before the weights
/// existed — or one whose nix option is `null` — renders no weight lines.
fn limits_dropin_body(
runtime_dir: &str,
memory_max: &str,
cpu_quota: &str,
cpu_weight: Option<u32>,
io_weight: Option<u32>,
) -> String {
// Built as two possibly-empty lines rather than pushed onto the
// string: `format!` appended to a `String` trips clippy::pedantic's
// `format_push_string`, and a `write!` would need an unwrap.
let cpu_weight_line = cpu_weight.map_or_else(String::new, |w| format!("CPUWeight={w}\n"));
let io_weight_line = io_weight.map_or_else(String::new, |w| format!("IOWeight={w}\n"));
format!(
"[Unit]\n\
ConditionPathIsDirectory={runtime_dir}\n\
StartLimitIntervalSec={START_LIMIT_INTERVAL_SEC}\n\
StartLimitBurst={START_LIMIT_BURST}\n\
\n\
[Service]\n\
RestartSec={RESTART_SEC}\n\
MemoryMax={memory_max}\n\
CPUQuota={cpu_quota}\n\
{cpu_weight_line}{io_weight_line}"
)
}
/// `DaemonReload` — `systemctl daemon-reload` on the host.
pub(crate) async fn daemon_reload() -> Result<(String, String)> {
let out = Command::new("systemctl")
.arg("daemon-reload")
.output()
.await
.context("invoke systemctl daemon-reload")?;
if !out.status.success() {
bail!(
"systemctl daemon-reload failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok((String::new(), String::new()))
}
/// Publish `content` at `path` through a [`StagedFile`], at `mode` and, when
/// given, owned by `owner` (`(uid, gid)`).
fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)>) -> Result<()> {
let (Some(dir_path), Some(filename)) =
(path.parent(), path.file_name().and_then(|n| n.to_str()))
else {
bail!(
"publish_file: {} has no directory or file name",
path.display()
);
};
let dir = open_dir(dir_path)?;
let staged = StagedFile::write(&dir, dir_path, filename, content, mode)?;
if let Some((uid, gid)) = owner {
std::os::unix::fs::fchown(staged.file(), Some(uid), Some(gid))
.with_context(|| format!("chown {}", path.display()))?;
}
staged.publish()
}
/// Validate a bind-mount path: must be absolute, non-empty, and contain
/// no newlines, null bytes, double-quotes, or colons.
///
/// The first three would break the `EXTRA_NSPAWN_FLAGS="..."` conf line
/// format. The colon is the one that matters most and had been left out
/// of this list: `--bind=SRC:DST` is colon-separated, so a path carrying
/// one does not corrupt the line — it silently becomes a *different
/// mount* than the caller asked for.
fn validate_bind_path(path: &str) -> Result<()> {
if path.is_empty()
|| !path.starts_with('/')
|| path
.bytes()
.any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':')
{
bail!(
"invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes"
);
}
Ok(())
}
/// `--tmpfs=<mount>/.git` for every bound git repo, hiding its metadata
/// from inside the container.
///
/// Two kinds of mount qualify, for one reason: **the agent is given a
/// working tree, never a repository.** `/knowledge` is the hive's shared
/// docs — whose `.git/config` has held a credential the host-side worker
/// embedded — and `/agents/<name>/config` is a config repo, an agent's own
/// or a parent's read-only view of a child's. In both cases `.git` carries
/// every branch and the full history of a document whose *currently
/// deployed* value is the only thing a reader may act on, and an abandoned
/// branch is indistinguishable from a live one.
///
/// An overlay rather than an exported copy: there is no second tree to
/// keep in sync, so nothing can go stale, and no code path has to remember
/// to refresh it.
///
/// ⚠️ Ordering matters — these must be appended **after** the `--bind`
/// flags so nspawn mounts them on top of the already-mounted trees.
/// Config mounts are matched by shape, not by a name list: the set is
/// dynamic, growing with each child bound into a parent.
fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
binds
.iter()
.map(|b| b.container_path.as_str())
.filter(|p| *p == "/knowledge" || (p.starts_with("/agents/") && p.ends_with("/config")))
.map(|p| format!("--tmpfs={p}/.git"))
.collect()
}
/// 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
/// `EXTRA_NSPAWN_FLAGS`. Always writes `PRIVATE_NETWORK=1` + veth
/// wiring — isolation is the only mode, so there is no branch that
/// leaves a container on the host's network namespace.
fn write_nspawn_flags(
container: &str,
binds: &[BindMount],
isolation: &NetworkIsolation,
load_credentials: &[CredentialMount],
hive_facts: &str,
) -> Result<()> {
use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt as _;
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
.lines()
.filter(|line| {
let t = line.trim_start();
!t.starts_with("EXTRA_NSPAWN_FLAGS=")
&& !t.starts_with("PRIVATE_NETWORK=")
&& !t.starts_with("HOST_ADDRESS=")
&& !t.starts_with("LOCAL_ADDRESS=")
&& !t.starts_with("HOST_ADDRESS6=")
&& !t.starts_with("LOCAL_ADDRESS6=")
&& !t.starts_with("HOST_BRIDGE=")
})
.collect();
let mut out = lines.join("\n");
if !out.is_empty() {
out.push('\n');
}
{
let iso = isolation;
out.push_str("PRIVATE_NETWORK=1\n");
// HOST_ADDRESS = the bridge gateway IP. nixos-container's
// container-side setup only installs a default route
// (`ip route add default via $HOST_ADDRESS`) when HOST_ADDRESS is
// non-empty; leaving it blank gave the container an address but no
// route off the bridge subnet (no internet, no api.anthropic.com).
// In bridge mode (HOST_BRIDGE set) the host-side address/route
// setup is skipped, so this only affects the container's route —
// exactly what we want.
let _ = writeln!(out, "HOST_ADDRESS={}", iso.gateway_ip);
// LOCAL_ADDRESS is intentionally empty: containers take their IP by
// DHCP from the bridge dnsmasq pool. (Why HOST_ADDRESS is still
// written: the comment directly above.)
out.push_str("LOCAL_ADDRESS=\n");
out.push_str("HOST_ADDRESS6=\n");
out.push_str("LOCAL_ADDRESS6=\n");
let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge);
}
let mut flags: Vec<String> = binds
.iter()
.map(|b| {
let flag = if b.read_only { "--bind-ro" } else { "--bind" };
format!("{flag}={}:{}", b.host_path, b.container_path)
})
.collect();
flags.extend(git_overlay_flags(binds));
// Credential forwarding: nspawn loads each host secret into the
// container's credential store under `<name>`; inner units inherit it
// via `LoadCredential=<name>`. Validated (name charset + bind-path
// rules) in handle_write_nspawn_flags above.
for cred in load_credentials {
flags.push(format!(
"--load-credential={}:{}",
cred.name, cred.host_path
));
}
let flags_joined = flags.join(" ");
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
// Keeps the owner and mode `nixos-container` created the file with; this
// helper only rewrites its lines.
let meta = std::fs::metadata(&path).with_context(|| format!("stat {path}"))?;
publish_file(
Path::new(&path),
out.as_bytes(),
meta.mode() & 0o7777,
Some((meta.uid(), meta.gid())),
)?;
// DNS marker for the in-container resolver oneshot: the oneshot only
// rewrites the container's resolv.conf when this marker exists, and the
// marker carries the gateway IP so the container need not re-derive it.
// Always written — every container is isolated, so there is no mode in
// which the marker should be absent. Why the rewrite is needed at all,
// and which unit does it: `docs/networking/network.md` § *How the isolated
// container gets its resolver*.
write_bridge_dns_marker(container, isolation)?;
// Read by the agent's environment generator at boot
// (`nix/agent-modules/hive-facts.nix`).
write_container_etc_file_in(&container_rootfs(container), HIVE_FACTS_FILE, hive_facts)?;
Ok(())
}
/// Filename of the in-container DNS marker, in the container's own `/etc`.
const BRIDGE_DNS_MARKER: &str = "hyperhive-bridge-dns";
/// Filename of the hive-facts environment file, in the container's own `/etc`.
const HIVE_FACTS_FILE: &str = "hyperhive-hive-facts";
/// The container's `/` on the host.
fn container_rootfs(container: &str) -> PathBuf {
PathBuf::from(format!("/var/lib/nixos-containers/{container}"))
}
/// Render [`HiveFacts`] as the systemd environment file the agent loads,
/// one `KEY=value` line per present fact.
///
/// A value is refused unless it is DNS-name characters only, so it can carry
/// neither a newline (a second assignment) nor anything an environment file
/// or a shell would interpret.
fn render_hive_facts(facts: &HiveFacts) -> Result<String> {
use std::fmt::Write as _;
let mut out = String::new();
for (key, value) in [
("HYPERHIVE_HIVE_NAME", &facts.name),
("HYPERHIVE_HIVE_DOMAIN", &facts.domain),
] {
let Some(value) = value else { continue };
if value.is_empty()
|| value.len() > 253
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
{
bail!("invalid {key} value {value:?}: must be 1-253 chars of [A-Za-z0-9._-]");
}
let _ = writeln!(out, "{key}={value}");
}
Ok(out)
}
/// Write the bridge-DNS marker the `hyperhive-isolated-dns` oneshot keys
/// off. The marker file contains just the gateway IP. Always written:
/// every container is isolated, so there is no host-netns case that
/// wants the marker absent.
fn write_bridge_dns_marker(container: &str, isolation: &NetworkIsolation) -> Result<()> {
write_bridge_dns_marker_in(&container_rootfs(container), &isolation.gateway_ip)
}
/// Write `<rootfs>/etc/hyperhive-bridge-dns`; see [`write_container_etc_file_in`].
fn write_bridge_dns_marker_in(rootfs: &Path, gateway_ip: &str) -> Result<()> {
write_container_etc_file_in(rootfs, BRIDGE_DNS_MARKER, &format!("{gateway_ip}\n"))
}
/// Write `<rootfs>/etc/<name>` without following a symlink.
///
/// Root inside the container owns its `/etc`, and this write runs as host
/// root, where an absolute symlink planted in the container resolves against
/// the host's `/`. So this opens `etc` with `O_DIRECTORY|O_NOFOLLOW` (a
/// symlinked `etc` fails with `ELOOP`) and publishes the file through a
/// [`StagedFile`] relative to that fd: whatever the container planted at the
/// leaf, symlink or FIFO or device node, is replaced by the rename, never
/// written through or opened.
///
/// On a fresh install the container's `/etc` may not exist yet (nixos-container
/// materialises the rootfs on the first start), so this creates it first; the
/// dir and the file persist through that start. Mode 0644: nothing written
/// here is a secret.
fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &str) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt as _;
let etc_path = rootfs.join("etc");
// The rootfs dir is the container's `/`, which the container cannot
// replace, so creating it by path is safe.
std::fs::create_dir_all(rootfs)
.with_context(|| format!("create container rootfs {}", rootfs.display()))?;
let root = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(rootfs)
.with_context(|| format!("open container rootfs {}", rootfs.display()))?;
// SAFETY: `root` is an open directory fd and the name a NUL-terminated
// literal.
if unsafe { libc::mkdirat(root.as_raw_fd(), c"etc".as_ptr(), 0o755) } != 0 {
let e = std::io::Error::last_os_error();
if e.kind() != std::io::ErrorKind::AlreadyExists {
return Err(e).with_context(|| format!("create {}", etc_path.display()));
}
}
// SAFETY: as above; this checks the result before wrapping it.
let etc_fd = unsafe {
libc::openat(
root.as_raw_fd(),
c"etc".as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if etc_fd < 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("open (no-follow) {}", etc_path.display()));
}
// SAFETY: `etc_fd` is a fresh, valid fd that nothing else owns.
let etc = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(etc_fd) });
StagedFile::write(&etc, &etc_path, name, contents.as_bytes(), 0o644)?.publish()
}
#[cfg(test)]
mod tests {
use super::{
BindMount, HiveFacts, NetworkIsolation, git_overlay_flags, handle_write_nspawn_flags,
limits_dropin_body, publish_file, remove_service_dropin, render_hive_facts,
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
};
use crate::shared::test_support::{entries, scratch};
fn bind(container_path: &str) -> BindMount {
BindMount {
host_path: "/var/lib/hyperhive/whatever".to_owned(),
container_path: container_path.to_owned(),
read_only: true,
}
}
/// Every bound git repo gets its `.git` overlaid — the knowledge tree
/// and *each* config mount, an agent's own plus every child's.
///
/// The child case is the one worth pinning: that set grows at runtime
/// as agents gain children, so a rule written as a list of names would
/// silently stop covering new ones.
#[test]
fn every_bound_git_repo_gets_its_dot_git_hidden() {
let flags = git_overlay_flags(&[
bind("/knowledge"),
bind("/agents/atlas/config"),
bind("/agents/kiddo/config"),
]);
assert_eq!(
flags,
[
"--tmpfs=/knowledge/.git",
"--tmpfs=/agents/atlas/config/.git",
"--tmpfs=/agents/kiddo/config/.git",
]
);
}
/// ...and nothing else does. A blanket "overlay .git on every bind"
/// would mask a real `.git` under `state/`, where an agent legitimately
/// keeps working clones of its own.
#[test]
fn non_repo_mounts_are_left_alone() {
let flags = git_overlay_flags(&[
bind("/agents/atlas/state"),
bind("/shared"),
bind("/applied"),
bind("/agents/atlas/config-notes"),
]);
assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}");
}
/// The whole rendered body, pinned literally — including the values,
/// so changing the restart policy is a visible test edit rather than a
/// silent one.
///
/// Placement is the part most worth pinning: `StartLimit*` are `[Unit]`
/// settings and systemd **silently ignores** them under `[Service]`, so
/// a bound that moved sections would look configured and do nothing.
/// An unset weight still emits no line at all, which is what keeps a
/// hive-c0re older than that field from changing what lands on disk.
#[test]
fn dropin_body_is_pinned_exactly() {
assert_eq!(
limits_dropin_body("/run/hyperhive/agents/iris", "4G", "200%", None, None),
"[Unit]\n\
ConditionPathIsDirectory=/run/hyperhive/agents/iris\n\
StartLimitIntervalSec=600\n\
StartLimitBurst=3\n\
\n\
[Service]\n\
RestartSec=5\n\
MemoryMax=4G\n\
CPUQuota=200%\n"
);
}
/// The bound is hive-wide **policy**, not a per-agent parameter: it is
/// rendered from constants and no caller-supplied value can omit or
/// alter it. This is the property that justifies keeping it out of the
/// wire protocol — if it ever varies by request, that argument is gone.
#[test]
fn the_start_limit_is_present_whatever_the_caller_passes() {
for (mem, cpu, cw, iw) in [
("4G", "200%", None, None),
("512M", "50%", Some(10), Some(10)),
("infinity", "infinity", Some(10_000), None),
] {
let body = limits_dropin_body("/rt/x", mem, cpu, cw, iw);
let unit = body
.split("[Service]")
.next()
.expect("the body always has a [Unit] section before [Service]");
assert!(
unit.contains("StartLimitIntervalSec=600") && unit.contains("StartLimitBurst=3"),
"start limit missing from [Unit] for ({mem}, {cpu}): {body}"
);
assert!(
body.contains("\nRestartSec=5\n"),
"restart backoff missing for ({mem}, {cpu}): {body}"
);
}
}
/// Weights are appended to the `[Service]` section, each omitted
/// independently when `None`.
#[test]
fn weights_are_emitted_only_when_set() {
let both = limits_dropin_body("/rt/x", "4G", "200%", Some(80), Some(80));
assert!(
both.ends_with("CPUQuota=200%\nCPUWeight=80\nIOWeight=80\n"),
"{both}"
);
let cpu_only = limits_dropin_body("/rt/x", "4G", "200%", Some(80), None);
assert!(
cpu_only.ends_with("CPUQuota=200%\nCPUWeight=80\n"),
"{cpu_only}"
);
assert!(!cpu_only.contains("IOWeight"), "{cpu_only}");
let io_only = limits_dropin_body("/rt/x", "4G", "200%", None, Some(80));
assert!(
io_only.ends_with("CPUQuota=200%\nIOWeight=80\n"),
"{io_only}"
);
assert!(!io_only.contains("CPUWeight"), "{io_only}");
}
/// The mode lands exactly as asked, whatever the umask would have made
/// of it (0666 under the usual 022 would otherwise come out 0644), and
/// the requested owner is on the file.
#[test]
fn publish_file_applies_mode_and_owner() {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let dir = scratch();
let path = dir.join("c.conf");
let owner = std::fs::metadata(&dir).unwrap();
publish_file(&path, b"x\n", 0o666, Some((owner.uid(), owner.gid()))).unwrap();
let meta = std::fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o666);
assert_eq!((meta.uid(), meta.gid()), (owner.uid(), owner.gid()));
publish_file(&path, b"y\n", 0o600, None).unwrap();
let meta = std::fs::metadata(&path).unwrap();
assert_eq!(meta.permissions().mode() & 0o7777, 0o600);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "y\n");
assert_eq!(entries(&dir), ["c.conf"], "temp left behind");
std::fs::remove_dir_all(&dir).ok();
}
/// The container plants a symlink where the bridge-DNS marker goes; the
/// rename replaces it without writing through it.
#[test]
fn bridge_dns_marker_replaces_symlink_leaf() {
let dir = scratch();
let rootfs = dir.join("rootfs");
std::fs::create_dir_all(rootfs.join("etc")).unwrap();
let target = dir.join("target");
std::fs::write(&target, "original").unwrap();
let leaf = rootfs.join("etc/hyperhive-bridge-dns");
std::os::unix::fs::symlink(&target, &leaf).unwrap();
write_bridge_dns_marker_in(&rootfs, "10.0.0.1").unwrap();
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"original",
"symlink target must be untouched"
);
assert!(
std::fs::symlink_metadata(&leaf)
.unwrap()
.file_type()
.is_file()
);
assert_eq!(std::fs::read_to_string(&leaf).unwrap(), "10.0.0.1\n");
std::fs::remove_dir_all(&dir).ok();
}
/// The container replaces its whole `/etc` with a symlink, so the leaf
/// name resolves inside a directory of its choosing.
#[test]
fn bridge_dns_marker_refuses_symlink_etc() {
let dir = scratch();
let rootfs = dir.join("rootfs");
std::fs::create_dir_all(&rootfs).unwrap();
let elsewhere = dir.join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
let target = elsewhere.join("hyperhive-bridge-dns");
std::fs::write(&target, "original").unwrap();
std::os::unix::fs::symlink(&elsewhere, rootfs.join("etc")).unwrap();
let res = write_bridge_dns_marker_in(&rootfs, "10.0.0.1");
assert!(res.is_err(), "O_NOFOLLOW must refuse a symlinked etc");
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"original",
"file behind the symlinked etc must be untouched"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A FIFO at the leaf is replaced, never opened, so it cannot block the
/// helper.
#[test]
fn bridge_dns_marker_replaces_fifo_leaf() {
use std::os::unix::ffi::OsStrExt as _;
let dir = scratch();
let rootfs = dir.join("rootfs");
std::fs::create_dir_all(rootfs.join("etc")).unwrap();
let fifo = rootfs.join("etc/hyperhive-bridge-dns");
let c_fifo = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
// SAFETY: `c_fifo` is a valid NUL-terminated path.
assert_eq!(unsafe { libc::mkfifo(c_fifo.as_ptr(), 0o644) }, 0);
write_bridge_dns_marker_in(&rootfs, "10.0.0.1").unwrap();
assert!(
std::fs::symlink_metadata(&fifo)
.unwrap()
.file_type()
.is_file()
);
assert_eq!(std::fs::read_to_string(&fifo).unwrap(), "10.0.0.1\n");
std::fs::remove_dir_all(&dir).ok();
}
/// The control: a plain rootfs with no `etc` yet (a fresh install) gets
/// the dir and a 0644 marker, and a second write replaces it.
#[test]
fn bridge_dns_marker_written_into_plain_etc() {
use std::os::unix::fs::PermissionsExt as _;
let dir = scratch();
let rootfs = dir.join("rootfs");
std::fs::create_dir_all(&rootfs).unwrap();
write_bridge_dns_marker_in(&rootfs, "10.0.0.1").unwrap();
let path = rootfs.join("etc/hyperhive-bridge-dns");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "10.0.0.1\n");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644, "marker must stay readable in the container");
write_bridge_dns_marker_in(&rootfs, "10.9.8.7").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "10.9.8.7\n");
std::fs::remove_dir_all(&dir).ok();
}
/// Each present fact is one environment-file line; an absent one writes
/// nothing, so the agent sees the variable unset rather than empty.
#[test]
fn hive_facts_render_one_line_per_present_fact() {
let both = HiveFacts {
name: Some("pr1ma".to_owned()),
domain: Some("pr1ma.darkest.space".to_owned()),
};
assert_eq!(
render_hive_facts(&both).unwrap(),
"HYPERHIVE_HIVE_NAME=pr1ma\nHYPERHIVE_HIVE_DOMAIN=pr1ma.darkest.space\n"
);
let domain_only = HiveFacts {
name: None,
domain: Some("h1.example.test".to_owned()),
};
assert_eq!(
render_hive_facts(&domain_only).unwrap(),
"HYPERHIVE_HIVE_DOMAIN=h1.example.test\n"
);
assert_eq!(render_hive_facts(&HiveFacts::default()).unwrap(), "");
}
/// A value that could end its line or be read as syntax is refused, not
/// escaped: a newline would add an assignment of the caller's choosing.
#[test]
fn hive_facts_refuse_values_outside_dns_characters() {
for bad in ["", "pr1ma\nLD_PRELOAD=/x", "a b", "a\"b", "$(x)"] {
let facts = HiveFacts {
name: Some(bad.to_owned()),
domain: None,
};
assert!(
render_hive_facts(&facts).is_err(),
"{bad:?} must be refused"
);
}
}
/// The swarm's forge and matrix containers are not the hive's: every
/// request that names a container as a string refuses them before
/// touching the filesystem. Control: an agent container name passes.
#[test]
fn container_name_requests_refuse_swarm_service_containers() {
let isolation = NetworkIsolation {
bridge: "hive0".to_owned(),
gateway_ip: "10.42.0.1".to_owned(),
};
let facts = HiveFacts {
name: None,
domain: None,
};
for name in ["hive-forge", "hive-matrix"] {
let errs = [
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts).unwrap_err(),
write_resource_limits(name, "4G", "200%", None, None).unwrap_err(),
remove_service_dropin(name).unwrap_err(),
];
for e in errs {
assert!(
e.to_string().contains("is not managed by hive"),
"{name}: {e:#}"
);
}
}
assert_eq!(validate_container_system_name("h-atlas").unwrap(), "atlas");
}
}