mod.rs keeps the container verbs + priv_run plumbing; git helpers, repo/dir setup, and host drop-in config move to their own files
251 lines
12 KiB
Rust
251 lines
12 KiB
Rust
//! First-spawn provisioning: seed the manager-editable proposed repo and
|
|
//! the hive-c0re-owned applied repo, and ensure the per-agent state /
|
|
//! claude-credentials dirs (btrfs subvolume when available) exist.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
|
|
use super::git::{
|
|
git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag,
|
|
};
|
|
use super::host_config::HOST_AGENTS_ROOT;
|
|
|
|
/// Initialize the manager-editable proposed repo. Seeds two tracked
|
|
/// files: `agent.nix` (the module the manager edits) and `flake.nix`
|
|
/// (the boilerplate that lets the meta flake import this repo as an
|
|
/// input — meta locks at a specific sha and reads
|
|
/// `nixosModules.default`, so `flake.nix` must be in the commit). The
|
|
/// manager shouldn't edit `flake.nix` (the prompt says so) but it's
|
|
/// visible so they can introspect.
|
|
///
|
|
/// Touched by hive-c0re only on first spawn — never again — so the
|
|
/// manager can't be surprised by hive-c0re commits or working-tree
|
|
/// resets.
|
|
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
|
|
let fresh = !proposed_dir.join(".git").exists();
|
|
if fresh {
|
|
std::fs::create_dir_all(proposed_dir)
|
|
.with_context(|| format!("create {}", proposed_dir.display()))?;
|
|
let agent_path = proposed_dir.join("agent.nix");
|
|
if !agent_path.exists() {
|
|
std::fs::write(&agent_path, initial_agent_nix(name))
|
|
.with_context(|| format!("write {}", agent_path.display()))?;
|
|
}
|
|
let flake_path = proposed_dir.join("flake.nix");
|
|
if !flake_path.exists() {
|
|
std::fs::write(&flake_path, initial_flake_nix())
|
|
.with_context(|| format!("write {}", flake_path.display()))?;
|
|
}
|
|
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
|
|
git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?;
|
|
git_commit(proposed_dir, "hive-c0re init").await?;
|
|
}
|
|
// Idempotently wire the `applied` remote — purely for the
|
|
// manager's ergonomics. The URL is the path inside the manager
|
|
// container (`/applied/<n>/.git`), where the RO bind in
|
|
// `set_nspawn_flags` makes it real. hive-c0re itself never
|
|
// dereferences this remote; the host-side fetch in
|
|
// `request_apply_commit` uses absolute host paths.
|
|
ensure_applied_remote(proposed_dir, name).await
|
|
}
|
|
|
|
async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> {
|
|
let want = format!("/applied/{name}/.git");
|
|
let existing = git_command()
|
|
.current_dir(proposed_dir)
|
|
.args(["remote", "get-url", "applied"])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?;
|
|
if existing.status.success() {
|
|
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
|
if current == want {
|
|
return Ok(());
|
|
}
|
|
// URL drifted (path scheme changed, etc.) — re-point it.
|
|
return git(proposed_dir, &["remote", "set-url", "applied", &want]).await;
|
|
}
|
|
git(proposed_dir, &["remote", "add", "applied", &want]).await
|
|
}
|
|
|
|
/// Set up the applied repo. First-spawn only: init the repo, pull
|
|
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
|
|
/// This is the *only* time hive-c0re reads from `proposed` for an
|
|
/// agent — subsequent proposals are fetched at `request_apply_commit`
|
|
/// time and tagged `proposal/<id>` (see `actions::approve` for the
|
|
/// tag state machine).
|
|
///
|
|
/// `proposed_dir` is `None` on rebuild paths where the repo already
|
|
/// exists — we just verify it's the right shape and bail otherwise.
|
|
/// Unlike the pre-overhaul code path, `flake.nix` is no longer
|
|
/// regenerated at the host level: it's tracked in proposed (seeded by
|
|
/// `setup_proposed`) and rides along on every fetch.
|
|
pub async fn setup_applied(
|
|
applied_dir: &Path,
|
|
proposed_dir: Option<&Path>,
|
|
name: &str,
|
|
) -> Result<()> {
|
|
std::fs::create_dir_all(applied_dir)
|
|
.with_context(|| format!("create {}", applied_dir.display()))?;
|
|
|
|
if !applied_dir.join(".git").exists() {
|
|
let Some(proposed) = proposed_dir else {
|
|
bail!(
|
|
"applied repo at {} is missing its .git directory; \
|
|
cannot rebuild without a proposed source to seed from. \
|
|
destroy --purge and re-spawn this agent.",
|
|
applied_dir.display()
|
|
);
|
|
};
|
|
git(applied_dir, &["init", "--initial-branch=main"]).await?;
|
|
let proposed_str = proposed.display().to_string();
|
|
// Seed the applied repo at the root (template) commit of proposed,
|
|
// not at `main`. This ensures `deployed/0` is the template baseline
|
|
// so the first ApplyCommit diff shows the manager's real changes
|
|
// rather than an empty diff (which happens when the manager has
|
|
// already committed their config and proposed/main == proposal/<id>).
|
|
let root_sha = git_root_commit(proposed).await?;
|
|
git(
|
|
applied_dir,
|
|
// --update-head-ok lets us fetch into refs/heads/main while
|
|
// HEAD still points there. git's default safeguard refuses
|
|
// to avoid index/working-tree desync, but the working tree
|
|
// is empty (we just `init`'d) and we read-tree-reset right
|
|
// after, so the safeguard is moot here.
|
|
&[
|
|
"fetch",
|
|
"--no-tags",
|
|
"--update-head-ok",
|
|
&proposed_str,
|
|
&format!("{root_sha}:refs/heads/main"),
|
|
],
|
|
)
|
|
.await?;
|
|
git_read_tree_reset(applied_dir, "refs/heads/main").await?;
|
|
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
|
|
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
|
|
.await
|
|
.is_err()
|
|
{
|
|
// Pre-overhaul applied repo — no deployed/* tag scheme,
|
|
// flake.nix may be untracked, agent.nix possibly authored by
|
|
// hive-c0re directly. The startup auto-migration fixes this
|
|
// in place; if it didn't run (or got skipped), surface a
|
|
// clear error.
|
|
bail!(
|
|
"applied repo at {} predates the meta-flake layout. \
|
|
Restart hive-c0re to let the auto-migration run, or \
|
|
destroy --purge {name} and re-spawn.",
|
|
applied_dir.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core
|
|
/// needs read+execute to list the directory so `claude_has_session` can detect a
|
|
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so
|
|
/// secrets stay private regardless of the directory mode. Idempotent: existing
|
|
/// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate).
|
|
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
|
|
/// dirs without calling the full `spawn`.
|
|
pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
|
|
use std::io;
|
|
if !claude_dir.exists() {
|
|
std::fs::create_dir_all(claude_dir)
|
|
.with_context(|| format!("create {}", claude_dir.display()))?;
|
|
}
|
|
// 0755: hive-core (different user from the agent) needs read+execute to
|
|
// list the directory so `claude_has_session` can detect a valid session.
|
|
// The credential files inside (`.credentials.json` etc.) are 0600 so the
|
|
// secrets themselves stay private regardless of the directory mode.
|
|
//
|
|
// Best-effort: on the first container boot, `hive-agent-user-migrate`
|
|
// chowns this dir to the agent user. After that, hive-core (a different
|
|
// user) cannot chmod it (EPERM) — that's fine because the mode set during
|
|
// initial creation (0755) is preserved through the chown. Any other error
|
|
// (ENOENT, I/O error) is unexpected and propagated.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
match std::fs::set_permissions(claude_dir, std::fs::Permissions::from_mode(0o755)) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
|
tracing::debug!(
|
|
path = %claude_dir.display(),
|
|
"ensure_claude_dir: chmod 755 skipped (dir likely owned by agent user after migration)"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
return Err(e).with_context(|| format!("chmod 755 {}", claude_dir.display()));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
|
|
/// dirs without calling the full `spawn`. Also creates the sibling `harness/`
|
|
/// dir so the first harness startup can write its sqlite files immediately.
|
|
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
|
if !notes_dir.exists() {
|
|
std::fs::create_dir_all(notes_dir)
|
|
.with_context(|| format!("create {}", notes_dir.display()))?;
|
|
}
|
|
// Harness dir is a sibling of the agent-visible state dir.
|
|
if let Some(parent) = notes_dir.parent() {
|
|
let harness_dir = parent.join("harness");
|
|
if !harness_dir.exists() {
|
|
std::fs::create_dir_all(&harness_dir)
|
|
.with_context(|| format!("create {}", harness_dir.display()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure agent `name`'s persistent state root
|
|
/// (`/var/lib/hyperhive/agents/<name>`) is a btrfs subvolume — when the host
|
|
/// filesystem supports it — BEFORE the per-agent subdirs (`state/`, `claude/`,
|
|
/// `harness/`) are created by `ensure_state_dir` / `ensure_claude_dir`.
|
|
///
|
|
/// Progressive enhancement: if the root already exists
|
|
/// (any agent provisioned before this landed, plain dir or subvol) it's left
|
|
/// exactly as-is — no auto-migration — and the priv round-trip is skipped. On
|
|
/// a non-btrfs host the priv op no-ops and the root is later created as a
|
|
/// plain dir by `ensure_*_dir`, identical to the old behaviour. Only a
|
|
/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation
|
|
/// is privileged, so it's delegated to hive-priv.
|
|
pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> {
|
|
let root = Path::new(HOST_AGENTS_ROOT).join(name);
|
|
if root.exists() {
|
|
return Ok(());
|
|
}
|
|
crate::priv_client::ensure_agent_subvolume(name)
|
|
.await
|
|
.with_context(|| format!("ensure btrfs subvolume for agent {name}"))
|
|
}
|
|
|
|
fn initial_agent_nix(name: &str) -> String {
|
|
format!(
|
|
"{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n",
|
|
)
|
|
}
|
|
|
|
/// Module-only flake exposed by every agent's repo. Consumed by the
|
|
/// hive-c0re-owned meta flake at `/var/lib/hyperhive/meta/` as a flake
|
|
/// input. The wrapper is intentionally permissive:
|
|
///
|
|
/// - Manager edits `inputs.* = …` to add other flakes (e.g. an MCP
|
|
/// server's own flake) — the lock for those lands in the agent's
|
|
/// own `flake.lock` and rolls up into meta's lock transitively.
|
|
/// - The outputs block forwards every input (minus `self`) into
|
|
/// `agent.nix` as the `flakeInputs` module argument, so the
|
|
/// manager just references `flakeInputs.<name>.packages.${pkgs.system}.default`
|
|
/// without further plumbing.
|
|
///
|
|
/// Identity injection (`HIVE_PORT` / `HIVE_LABEL` / dashboard port /
|
|
/// git committer) still lives in the meta flake's wrapper.
|
|
pub fn initial_flake_nix() -> &'static str {
|
|
"{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n"
|
|
}
|