feat(#702): drop hive-c0re from root to hive-core user (privsep phase 2)
This commit is contained in:
parent
5e0da97ee5
commit
0bec3af933
2 changed files with 128 additions and 261 deletions
|
|
@ -3,6 +3,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use hive_sh4re::priv_proto::BindMount;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Sub-agent container prefix. `nixos-container` caps the total container name
|
||||
|
|
@ -206,12 +207,11 @@ pub async fn spawn(
|
|||
)
|
||||
.await?;
|
||||
let container = container_name(name);
|
||||
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
|
||||
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
|
||||
set_resource_limits(&container)?;
|
||||
priv_run("create", name).await?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
|
||||
set_resource_limits(&container).await?;
|
||||
systemd_daemon_reload().await?;
|
||||
run(&["start", &container]).await
|
||||
priv_run("start", name).await
|
||||
}
|
||||
|
||||
/// Build the `AgentSpec` list for the meta flake from `nixos-container
|
||||
|
|
@ -280,14 +280,12 @@ pub async fn container_exists(name: &str) -> bool {
|
|||
|
||||
pub async fn kill(name: &str) -> Result<()> {
|
||||
validate(name)?;
|
||||
let container = container_name(name);
|
||||
run(&["stop", &container]).await
|
||||
priv_run("stop", name).await
|
||||
}
|
||||
|
||||
pub async fn start(name: &str) -> Result<()> {
|
||||
validate(name)?;
|
||||
let container = container_name(name);
|
||||
run(&["start", &container]).await
|
||||
priv_run("start", name).await
|
||||
}
|
||||
|
||||
/// Stop + start without regenerating any config. For "kick the container"
|
||||
|
|
@ -317,12 +315,12 @@ pub async fn destroy(name: &str) -> Result<()> {
|
|||
let container = container_name(name);
|
||||
// nixos-container destroy handles stop + removal of /var/lib/nixos-containers/<C>
|
||||
// and /etc/nixos-containers/<C>.conf. Tolerate "no such container".
|
||||
if let Err(e) = run(&["destroy", &container]).await {
|
||||
if let Err(e) = priv_run("destroy", name).await {
|
||||
tracing::warn!(error = ?e, "nixos-container destroy returned an error; continuing cleanup");
|
||||
}
|
||||
let dropin_dir = format!("/run/systemd/system/container@{container}.service.d");
|
||||
if std::path::Path::new(&dropin_dir).exists() {
|
||||
std::fs::remove_dir_all(&dropin_dir).with_context(|| format!("remove {dropin_dir}"))?;
|
||||
// Remove the systemd resource-limits drop-in via hive-priv.
|
||||
if let Err(e) = crate::priv_client::remove_service_dropin(&container).await {
|
||||
tracing::warn!(error = ?e, "remove service drop-in failed (non-fatal)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -399,43 +397,43 @@ pub async fn rebuild_no_meta(
|
|||
// Rebuild strategy: stop-before-update + pre-build.
|
||||
// See `docs/coordinator.md::Container lifecycle`.
|
||||
let was_running = is_running(name).await;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
|
||||
set_resource_limits(&container)?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
|
||||
set_resource_limits(&container).await?;
|
||||
systemd_daemon_reload().await?;
|
||||
if was_running {
|
||||
on_step("nix build");
|
||||
prebuild_toplevel(name, &flake_ref).await?;
|
||||
on_step("nixos-container stop");
|
||||
run(&["stop", &container]).await?;
|
||||
priv_run("stop", name).await?;
|
||||
}
|
||||
on_step("nixos-container update");
|
||||
run(&["update", &container, "--flake", &flake_ref]).await?;
|
||||
priv_run("update", name).await?;
|
||||
if was_running {
|
||||
// Cold-start fallback on activation errors.
|
||||
// See `docs/coordinator.md::Cold-start fallback`.
|
||||
on_step("nixos-container start");
|
||||
if let Err(start_err) = run(&["start", &container]).await {
|
||||
if let Err(start_err) = priv_run("start", name).await {
|
||||
tracing::warn!(
|
||||
container = %container,
|
||||
error = %start_err,
|
||||
"start after rebuild failed (possible activation error); \
|
||||
retrying via stop + kill + start"
|
||||
);
|
||||
run(&["stop", &container]).await.unwrap_or_else(|e| {
|
||||
priv_run("stop", name).await.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
container = %container,
|
||||
error = %e,
|
||||
"stop before cold-start retry failed (ignored)"
|
||||
);
|
||||
});
|
||||
run(&["kill", &container]).await.unwrap_or_else(|e| {
|
||||
priv_run("kill", name).await.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
container = %container,
|
||||
error = %e,
|
||||
"kill before cold-start retry failed (ignored)"
|
||||
);
|
||||
});
|
||||
run(&["start", &container]).await.map_err(|e| {
|
||||
priv_run("start", name).await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"cold-start fallback also failed: {e:#} \
|
||||
(original start error: {start_err:#})"
|
||||
|
|
@ -451,12 +449,12 @@ pub async fn rebuild_no_meta(
|
|||
// Spawn path: create is atomic, no prebuild needed.
|
||||
// See `docs/coordinator.md::Spawn path`.
|
||||
on_step("nixos-container create");
|
||||
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
|
||||
set_resource_limits(&container)?;
|
||||
priv_run("create", name).await?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?;
|
||||
set_resource_limits(&container).await?;
|
||||
systemd_daemon_reload().await?;
|
||||
on_step("nixos-container start");
|
||||
run(&["start", &container]).await
|
||||
priv_run("start", name).await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -570,21 +568,8 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> {
|
|||
}
|
||||
|
||||
pub async fn list() -> Result<Vec<String>> {
|
||||
let out = Command::new("nixos-container")
|
||||
.arg("list")
|
||||
.output()
|
||||
.await
|
||||
.context("invoke nixos-container list")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"nixos-container list exited with status {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
let stdout = crate::priv_client::list_containers().await?;
|
||||
Ok(stdout.lines().map(str::trim)
|
||||
.filter(|line| line.starts_with(AGENT_PREFIX))
|
||||
.map(str::to_owned)
|
||||
.collect())
|
||||
|
|
@ -983,36 +968,12 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<(
|
|||
/// Write a systemd drop-in for `container@<container>.service` that applies
|
||||
/// our default resource caps. Goes under `/run/systemd/system/...` so it's
|
||||
/// ephemeral (regenerated on every spawn / rebuild).
|
||||
fn set_resource_limits(container: &str) -> Result<()> {
|
||||
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 =
|
||||
format!("[Service]\nMemoryMax={DEFAULT_MEMORY_MAX}\nCPUQuota={DEFAULT_CPU_QUOTA}\n");
|
||||
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
|
||||
tracing::info!(
|
||||
%path,
|
||||
memory_max = DEFAULT_MEMORY_MAX,
|
||||
cpu_quota = DEFAULT_CPU_QUOTA,
|
||||
"wrote resource limits drop-in"
|
||||
);
|
||||
Ok(())
|
||||
async fn set_resource_limits(container: &str) -> Result<()> {
|
||||
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA).await
|
||||
}
|
||||
|
||||
async fn systemd_daemon_reload() -> Result<()> {
|
||||
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(())
|
||||
crate::priv_client::daemon_reload().await
|
||||
}
|
||||
|
||||
/// Idempotently rewrite the lines in `/etc/nixos-containers/<container>.conf`
|
||||
|
|
@ -1052,35 +1013,28 @@ const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
|||
/// `binds`. All three are RW so the parent can read/write state and
|
||||
/// submit config-change requests. Creates missing host-side directories
|
||||
/// so nspawn doesn't refuse to start; missing dirs are non-fatal.
|
||||
fn bind_child_agent_dirs(child: &str, binds: &mut String) {
|
||||
use std::fmt::Write as _;
|
||||
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
||||
let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state");
|
||||
let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness");
|
||||
let config_dir = format!("{HOST_AGENTS_ROOT}/{child}/config");
|
||||
for dir in [&state_dir, &harness_dir, &config_dir] {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let _ = write!(binds, " --bind={state_dir}:/agents/{child}/state");
|
||||
let _ = write!(binds, " --bind={harness_dir}:/agents/{child}/harness");
|
||||
let _ = write!(binds, " --bind={config_dir}:/agents/{child}/config");
|
||||
binds.push(BindMount { host_path: state_dir, container_path: format!("/agents/{child}/state"), read_only: false });
|
||||
binds.push(BindMount { host_path: harness_dir, container_path: format!("/agents/{child}/harness"), read_only: false });
|
||||
binds.push(BindMount { host_path: config_dir, container_path: format!("/agents/{child}/config"), read_only: false });
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn set_nspawn_flags(
|
||||
async fn set_nspawn_flags(
|
||||
container: &str,
|
||||
runtime_dir: &Path,
|
||||
claude_dir: &Path,
|
||||
notes_dir: &Path,
|
||||
) -> Result<()> {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
|
||||
std::fs::create_dir_all(HOST_SHARED_ROOT)
|
||||
.with_context(|| format!("create {HOST_SHARED_ROOT}"))?;
|
||||
|
||||
let path = format!("/etc/nixos-containers/{container}.conf");
|
||||
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
||||
|
||||
// Logical agent name — strip the `h-` prefix.
|
||||
// For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`.
|
||||
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
|
||||
|
|
@ -1092,37 +1046,26 @@ fn set_nspawn_flags(
|
|||
// is needed here — the bind alone is enough.
|
||||
let claude_mount = container_claude_mount(agent_name);
|
||||
|
||||
let mut binds = format!(
|
||||
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{claude_mount} --bind={shared}:{CONTAINER_SHARED_MOUNT}",
|
||||
runtime = runtime_dir.display(),
|
||||
claude = claude_dir.display(),
|
||||
shared = HOST_SHARED_ROOT,
|
||||
);
|
||||
let mut binds: Vec<BindMount> = vec![
|
||||
BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false },
|
||||
BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false },
|
||||
BindMount { host_path: HOST_SHARED_ROOT.to_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false },
|
||||
];
|
||||
|
||||
// Own state, harness, and config dirs — same for every agent including
|
||||
// root. Config is RO: an agent must not edit its own config; changes
|
||||
// the manager. Config is RO: an agent must not edit its own config; changes
|
||||
// only ever flow through the approval queue.
|
||||
{
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind={notes}:/agents/{agent_name}/state",
|
||||
notes = notes_dir.display(),
|
||||
);
|
||||
if let Some(state_parent) = notes_dir.parent() {
|
||||
let harness_dir = state_parent.join("harness");
|
||||
if !harness_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&harness_dir);
|
||||
}
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind={harness}:/agents/{agent_name}/harness",
|
||||
harness = harness_dir.display(),
|
||||
);
|
||||
binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false });
|
||||
if let Some(state_parent) = notes_dir.parent() {
|
||||
let harness_dir = state_parent.join("harness");
|
||||
if !harness_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&harness_dir);
|
||||
}
|
||||
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
|
||||
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
|
||||
let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config");
|
||||
binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false });
|
||||
}
|
||||
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
|
||||
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
|
||||
binds.push(BindMount { host_path: own_config, container_path: format!("/agents/{agent_name}/config"), read_only: true });
|
||||
|
||||
// Topology-driven child mounts: every direct child of this agent gets
|
||||
// its state, harness, and config dirs bind-mounted RW so the parent
|
||||
|
|
@ -1153,16 +1096,10 @@ fn set_nspawn_flags(
|
|||
// fires first (e.g. cold start with no agents).
|
||||
std::fs::create_dir_all(HOST_META_ROOT)
|
||||
.with_context(|| format!("create {HOST_META_ROOT}"))?;
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind-ro={HOST_APPLIED_ROOT}:{CONTAINER_MANAGER_APPLIED_MOUNT}",
|
||||
);
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind-ro={HOST_META_ROOT}:{mount}",
|
||||
mount = crate::meta::CONTAINER_MANAGER_META_MOUNT,
|
||||
);
|
||||
binds.push(BindMount { host_path: HOST_APPLIED_ROOT.to_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true });
|
||||
binds.push(BindMount { host_path: HOST_META_ROOT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true });
|
||||
}
|
||||
|
||||
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
|
||||
// container so the harness can bind `web.sock` there and the host-side
|
||||
// gateway sees it. Subdir bind (not socket file) keeps the inode
|
||||
|
|
@ -1175,159 +1112,74 @@ fn set_nspawn_flags(
|
|||
// Falls back to 0777 on first spawn when uid lookup returns None
|
||||
// (container /etc/passwd not yet rendered).
|
||||
if let Some((uid, gid)) = agent_uid_gid(agent_name) {
|
||||
std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid))
|
||||
.with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?;
|
||||
} else {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777))
|
||||
.with_context(|| format!("chmod 0777 {}", socket_dir.display()))?;
|
||||
if let Err(e) = crate::priv_client::chown_socket_dir(agent_name, uid, gid).await {
|
||||
tracing::warn!(%agent_name, error = ?e, "chown socket dir failed");
|
||||
}
|
||||
} else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await {
|
||||
tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed");
|
||||
}
|
||||
let _ = write!(
|
||||
binds,
|
||||
" --bind={socket_dir}:{socket_dir}",
|
||||
socket_dir = socket_dir.display(),
|
||||
);
|
||||
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
|
||||
let mut lines: Vec<String> = original
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
// Strip any network-namespace knobs nixos-container's create
|
||||
// might have populated. The start script adds `--network-veth`
|
||||
// whenever HOST_ADDRESS / LOCAL_ADDRESS (or their IPv6 cousins)
|
||||
// are non-empty — and veth implies a private netns, hiding our
|
||||
// web-UI port from the host. Force host netns.
|
||||
!trimmed.starts_with("EXTRA_NSPAWN_FLAGS=")
|
||||
&& !trimmed.starts_with("PRIVATE_NETWORK=")
|
||||
&& !trimmed.starts_with("HOST_ADDRESS=")
|
||||
&& !trimmed.starts_with("LOCAL_ADDRESS=")
|
||||
&& !trimmed.starts_with("HOST_ADDRESS6=")
|
||||
&& !trimmed.starts_with("LOCAL_ADDRESS6=")
|
||||
&& !trimmed.starts_with("HOST_BRIDGE=")
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
lines.push("PRIVATE_NETWORK=0".to_owned());
|
||||
lines.push("HOST_ADDRESS=".to_owned());
|
||||
lines.push("LOCAL_ADDRESS=".to_owned());
|
||||
lines.push("HOST_ADDRESS6=".to_owned());
|
||||
lines.push("LOCAL_ADDRESS6=".to_owned());
|
||||
lines.push("HOST_BRIDGE=".to_owned());
|
||||
lines.push(bind_flag);
|
||||
let mut content = lines.join("\n");
|
||||
content.push('\n');
|
||||
std::fs::write(&path, content).with_context(|| format!("write {path}"))?;
|
||||
tracing::info!(%path, "set PRIVATE_NETWORK=0 + EXTRA_NSPAWN_FLAGS");
|
||||
Ok(())
|
||||
binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false });
|
||||
|
||||
// Delegate the actual conf-file rewrite to hive-priv (runs as root).
|
||||
crate::priv_client::write_nspawn_flags(container, &binds).await
|
||||
}
|
||||
|
||||
/// Spawn `nixos-container <args>` and pipe its stdout + stderr into
|
||||
/// `tracing` one line at a time so a long-running command (most
|
||||
/// notably `update`, which kicks off a full nix build that can run
|
||||
/// for minutes on a stale flake) shows progress in journald as it
|
||||
/// happens. The buffered `.output()` we used before only flushed the
|
||||
/// summary at exit, which made "slow" and "stuck" look identical to
|
||||
/// the operator watching `journalctl -u hive-c0re -f`.
|
||||
///
|
||||
/// stdout lines log at INFO, stderr at WARN. The same lines are
|
||||
/// captured per-attempt into `build_logs.sqlite` so the dashboard
|
||||
/// can surface the full stream to the operator; on failure we bail
|
||||
/// with a `see build log #<id>` pointer instead of the legacy
|
||||
/// 32-line ring-buffer tail that routinely truncated eval errors.
|
||||
async fn run(args: &[&str]) -> Result<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let cmdline = args.join(" ");
|
||||
|
||||
// Convention: `nixos-container <verb> <container> ...` — the
|
||||
// verb is `args[0]` (kind) and the container is `args[1]`
|
||||
// (h-<name> | root | hive-matrix | ...) for every long-running
|
||||
// case we care about. Strip the `h-` prefix for sub-agents so the
|
||||
// build_logs row's `agent` column matches the agent's bare name
|
||||
// (`alice` rather than `h-alice`) — that's what the dashboard
|
||||
// groups by. Manager + sibling containers pass through as-is.
|
||||
let kind = args.first().copied().unwrap_or("nixos-container");
|
||||
let agent = args
|
||||
.get(1)
|
||||
.copied()
|
||||
.map_or_else(|| "<unknown>".to_string(), |c| c.strip_prefix(AGENT_PREFIX).unwrap_or(c).to_string());
|
||||
/// Execute a container operation via hive-priv and integrate with
|
||||
/// build_logs.sqlite. hive-priv runs as root and logs output to
|
||||
/// journald as it arrives; this function captures the final stdout +
|
||||
/// stderr into build_logs for the dashboard after the operation
|
||||
/// completes. For `create` and `update` (the long-running ops)
|
||||
/// hive-priv already logs each line to its own journald stream —
|
||||
/// streaming into build_logs is deferred to a follow-up that adds a
|
||||
/// streaming mode to the priv protocol.
|
||||
async fn priv_run(kind: &str, name: &str) -> Result<()> {
|
||||
let container = container_name(name);
|
||||
let cmdline = format!("nixos-container {kind} {container}");
|
||||
|
||||
let logs = crate::build_logs::global();
|
||||
let log_id = logs.as_ref().and_then(|h| {
|
||||
h.start(&agent, kind, &cmdline)
|
||||
h.start(name, kind, &cmdline)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = ?e, "build_logs: start failed (nixos-container log dropped)");
|
||||
tracing::warn!(error = ?e, "build_logs: start failed (priv_run log dropped)");
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
let mut child = Command::new("nixos-container")
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("invoke nixos-container {cmdline}"))?;
|
||||
let result: Result<(String, String)> = match kind {
|
||||
"create" => crate::priv_client::create_container(name).await,
|
||||
"update" => crate::priv_client::update_container(name).await,
|
||||
"start" => crate::priv_client::start_container(name).await.map(|()| (String::new(), String::new())),
|
||||
"stop" => crate::priv_client::stop_container(name).await.map(|()| (String::new(), String::new())),
|
||||
"kill" => crate::priv_client::kill_container(name).await.map(|()| (String::new(), String::new())),
|
||||
"destroy" => crate::priv_client::destroy_container(name).await.map(|()| (String::new(), String::new())),
|
||||
other => Err(anyhow::anyhow!("unknown container op: {other}")),
|
||||
};
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
|
||||
let stdout_cmdline = cmdline.clone();
|
||||
let stdout_logs = logs.clone();
|
||||
let pump_stdout = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!(target: "nixos-container", cmdline = %stdout_cmdline, "{line}");
|
||||
if let (Some(h), Some(id)) = (&stdout_logs, log_id) {
|
||||
h.append_stdout(id, &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stderr_cmdline = cmdline.clone();
|
||||
let stderr_logs = logs.clone();
|
||||
let pump_stderr = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::warn!(target: "nixos-container", cmdline = %stderr_cmdline, "{line}");
|
||||
if let (Some(h), Some(id)) = (&stderr_logs, log_id) {
|
||||
h.append_stderr(id, &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.with_context(|| format!("wait nixos-container {cmdline}"))?;
|
||||
let _ = pump_stdout.await;
|
||||
let _ = pump_stderr.await;
|
||||
|
||||
let ok = status.success();
|
||||
let ok = result.is_ok();
|
||||
if let (Some(h), Some(id)) = (&logs, log_id) {
|
||||
h.finish(
|
||||
id,
|
||||
if ok {
|
||||
crate::build_logs::BuildStatus::Ok
|
||||
} else {
|
||||
crate::build_logs::BuildStatus::Fail
|
||||
},
|
||||
);
|
||||
if let Ok((ref stdout, ref stderr)) = result {
|
||||
for line in stdout.lines() {
|
||||
tracing::info!(target: "nixos-container", cmdline = %cmdline, "{line}");
|
||||
h.append_stdout(id, line);
|
||||
}
|
||||
for line in stderr.lines() {
|
||||
tracing::warn!(target: "nixos-container", cmdline = %cmdline, "{line}");
|
||||
h.append_stderr(id, line);
|
||||
}
|
||||
}
|
||||
h.finish(id, if ok { crate::build_logs::BuildStatus::Ok } else { crate::build_logs::BuildStatus::Fail });
|
||||
}
|
||||
if !ok {
|
||||
// `container_journal_tail` is best-effort + only fires on
|
||||
// `update`; the captured build log holds the full host-side
|
||||
// stderr regardless, so the bail message can stay terse: a
|
||||
// pointer to the log id + the journal tail (when available)
|
||||
// is enough for the operator to drill in without flooding
|
||||
// every notification with the eval-error verbatim.
|
||||
let journal = container_journal_tail(args).await;
|
||||
match log_id {
|
||||
Some(id) => {
|
||||
bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}")
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let journal = if kind == "update" { container_journal_tail(&container).await } else { String::new() };
|
||||
match log_id {
|
||||
Some(id) => bail!("{e:#}; see build log #{id}{journal}"),
|
||||
None => bail!("{e:#}{journal}"),
|
||||
}
|
||||
None => bail!("nixos-container {cmdline} failed ({status}){journal}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On a failed `nixos-container update`, the stderr nixos-container
|
||||
|
|
@ -1342,13 +1194,7 @@ async fn run(args: &[&str]) -> Result<()> {
|
|||
/// `journalctl -M` works. Best-effort — returns "" for other verbs
|
||||
/// or when the journal can't be read (machine gone, journalctl
|
||||
/// missing); it never produces an error of its own.
|
||||
async fn container_journal_tail(args: &[&str]) -> String {
|
||||
if args.first().copied() != Some("update") {
|
||||
return String::new();
|
||||
}
|
||||
let Some(container) = args.get(1) else {
|
||||
return String::new();
|
||||
};
|
||||
async fn container_journal_tail(container: &str) -> String {
|
||||
let out = Command::new("journalctl")
|
||||
.args(["-M", container, "-n", "40", "--no-pager", "--output=short"])
|
||||
.output()
|
||||
|
|
|
|||
|
|
@ -315,6 +315,17 @@ in
|
|||
managerToplevel
|
||||
];
|
||||
|
||||
# Unprivileged coordinator user. hive-c0re runs as this user
|
||||
# (privsep phase 2); privileged operations are delegated to
|
||||
# hive-priv which runs as root, socket-activated at
|
||||
# /run/hive/priv.sock.
|
||||
users.users.hive-core = {
|
||||
isSystemUser = true;
|
||||
group = "hive-core";
|
||||
description = "hive-c0re coordinator daemon user";
|
||||
};
|
||||
users.groups.hive-core = { };
|
||||
|
||||
# Open the per-agent web-port range when the gateway is *off* —
|
||||
# otherwise the gateway nginx is the sole external entry point.
|
||||
# See `docs/gateway.md::Firewall posture (host-level)`.
|
||||
|
|
@ -412,12 +423,22 @@ in
|
|||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}";
|
||||
# One-time migration: chown existing state tree to the service
|
||||
# user after upgrading from a root-run install. The `+` prefix
|
||||
# runs this step as root even though User = hive-core. systemd's
|
||||
# StateDirectory chowns the top-level dir at every start, but
|
||||
# pre-existing files inside may still be root-owned.
|
||||
ExecStartPre = "+${pkgs.coreutils}/bin/sh -c 'chown -R hive-core:hive-core /var/lib/hyperhive || true'";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
User = "hive-core";
|
||||
Group = "hive-core";
|
||||
SupplementaryGroups = [ "systemd-journal" ];
|
||||
RuntimeDirectory = "hyperhive";
|
||||
RuntimeDirectoryMode = "0750";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
StateDirectory = "hyperhive";
|
||||
StateDirectoryMode = "0750";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -451,18 +472,18 @@ in
|
|||
# privileged operations on behalf of hive-c0re. Systemd creates and
|
||||
# holds `/run/hive/priv.sock` before the first connection arrives.
|
||||
#
|
||||
# Mode 0660 root:root is correct for phase 1 (hive-c0re still runs as
|
||||
# root and is the only caller). Phase 2 (privsep: hive-c0re drops to a
|
||||
# non-root user) will add `SocketGroup = hive-core` so the unprivileged
|
||||
# hive-c0re process can still connect.
|
||||
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
|
||||
# connect. hive-priv (server) runs as root and validates every request
|
||||
# against a strict allowlist before executing any privileged op.
|
||||
systemd.sockets.hive-priv = {
|
||||
description = "hive-priv privileged helper socket";
|
||||
wantedBy = [ "sockets.target" ];
|
||||
socketConfig = {
|
||||
ListenStream = "/run/hive/priv.sock";
|
||||
SocketMode = "0660";
|
||||
# Create /run/hive/ if absent; 0755 so future unprivileged callers
|
||||
# can traverse into it to reach the socket.
|
||||
SocketGroup = "hive-core";
|
||||
# Create /run/hive/ if absent; 0755 so the hive-core user can
|
||||
# traverse into it to reach the socket.
|
||||
DirectoryMode = "0755";
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue