drop the proposed config repo: applied seeds from forge main, and the dead pending-init topology list goes with it
This commit is contained in:
parent
08b8eb3f73
commit
25295fd33f
5 changed files with 225 additions and 520 deletions
|
|
@ -1,4 +1,4 @@
|
|||
//! Git shellout helpers for the per-agent proposed/applied repos: run
|
||||
//! Git shellout helpers for the per-agent applied repo: run
|
||||
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
||||
//! fetch proposal commits into the applied repo.
|
||||
|
||||
|
|
@ -71,25 +71,6 @@ async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>
|
|||
const GIT_NAME: &str = "c0re";
|
||||
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
||||
|
||||
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
|
||||
/// Used to seed the applied repo at the template baseline rather than at
|
||||
/// `main`, so `deployed/0` records the template, not the manager's first commit.
|
||||
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
|
||||
let out = git_command()
|
||||
.current_dir(dir)
|
||||
.args(["rev-list", "--max-parents=0", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git rev-list --max-parents=0 failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
git(
|
||||
dir,
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ fn config_bind_source(name: &str) -> PathBuf {
|
|||
///
|
||||
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
|
||||
/// the `config` flag and must not be read as a reason to widen it: that runs
|
||||
/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
|
||||
/// as hive-c0re against the host path (see `lifecycle::setup_applied`), and
|
||||
/// `read_only` on a bind constrains writers *inside* the container only.
|
||||
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
||||
let Ok(child) = hive_types::Ident::parse(child) else {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub use git::{
|
|||
pub use host_config::write_dropins;
|
||||
pub use setup::{
|
||||
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
|
||||
setup_applied, setup_proposed,
|
||||
setup_applied,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
|
@ -205,8 +205,8 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
|
|||
}
|
||||
|
||||
/// First-spawn pre-create provisioning — the job queue's `Provision`
|
||||
/// node. Fail fast on a port collision, set up the proposed/applied
|
||||
/// repos + state subvolume + claude/notes dirs, then register the new
|
||||
/// node. Fail fast on a port collision, set up the applied
|
||||
/// repo + state subvolume + claude/notes dirs, then register the new
|
||||
/// agent in the host-side files that follow the agent set
|
||||
/// ([`crate::agent_set::refresh`]). Does NOT
|
||||
/// create the container — that's `create_only` / the `Create` node.
|
||||
|
|
@ -222,8 +222,7 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> {
|
|||
// repo, and the build takes it from the archetype.
|
||||
crate::archetypes::refresh(name).await?;
|
||||
if crate::archetypes::of(name)?.is_none() {
|
||||
setup_proposed(&paths.proposed, name).await?;
|
||||
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
|
||||
setup_applied(&paths.applied, name).await?;
|
||||
}
|
||||
ensure_agent_state_subvolume(name).await?;
|
||||
ensure_claude_dir(&paths.claude)?;
|
||||
|
|
@ -250,12 +249,9 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> {
|
|||
/// in `provision_container`. Recover from `agent-configs/<name>` on the
|
||||
/// forge when that's the case (forge `main` is the last operator-merged
|
||||
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
|
||||
/// rather than bailing outright. Deliberately **no fallback to
|
||||
/// `proposed`**: that repo is seeded once at first spawn and never touched
|
||||
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
|
||||
/// stale for a long-lived agent — restoring from it would silently revive
|
||||
/// the *wrong* config rather than recovering the right one, which is worse
|
||||
/// than the clear bail this replaces.
|
||||
/// rather than bailing outright. Deliberately **no template fallback**: a
|
||||
/// template would silently revive the *wrong* config rather than recovering
|
||||
/// the right one, which is worse than the clear bail this replaces.
|
||||
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
|
||||
validate(name)?;
|
||||
if let Some(other) = port_collision(name).await? {
|
||||
|
|
@ -276,7 +272,7 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()>
|
|||
paths.applied.display()
|
||||
);
|
||||
}
|
||||
setup_applied(&paths.applied, None, name).await?;
|
||||
setup_applied(&paths.applied, name).await?;
|
||||
}
|
||||
ensure_agent_state_subvolume(name).await?;
|
||||
ensure_claude_dir(&paths.claude)?;
|
||||
|
|
|
|||
|
|
@ -1,159 +1,53 @@
|
|||
//! First-spawn provisioning: seed the agent's proposed config repo and
|
||||
//! the hive-c0re-owned applied repo, and ensure the per-agent state /
|
||||
//! claude-credentials dirs (btrfs subvolume when available) exist.
|
||||
//! First-spawn provisioning: seed the hive-c0re-owned applied repo, and
|
||||
//! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when
|
||||
//! available) exist.
|
||||
//!
|
||||
//! Seeding is the only write either repo gets from here. Later config
|
||||
//! changes arrive as forge PRs authored in a clone — nothing edits a
|
||||
//! working tree in place.
|
||||
//! Seeding is the only write the repo gets from here. Later config changes
|
||||
//! arrive as forge PRs authored in a clone - nothing edits a working tree in
|
||||
//! place.
|
||||
|
||||
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::git::{git, git_commit, git_rev_parse, git_tag};
|
||||
|
||||
/// Initialize an agent's config repo, preferring the one the forge
|
||||
/// already has. `agent-configs/<name>` is authored by whoever created
|
||||
/// the agent — at swarm level that is the controller, which writes the
|
||||
/// config before any hive is told to deploy it — so this **clones**
|
||||
/// when there is something to clone and seeds a template only when
|
||||
/// there is not. Two authors for one file is the failure mode being
|
||||
/// avoided: whichever wrote second would win, and neither knows about
|
||||
/// the other.
|
||||
/// Seed an agent's applied repo on first spawn, preferring the config the
|
||||
/// forge already has. `agent-configs/<name>` is authored by whoever created
|
||||
/// the agent (at swarm level the controller, which writes the config before
|
||||
/// any hive is told to deploy it), so this takes the forge's `main` when
|
||||
/// there is one and writes a template only when there is not. Two authors for
|
||||
/// one file is the failure mode being avoided: whichever wrote second would
|
||||
/// win, and neither knows about the other.
|
||||
///
|
||||
/// The template ([`seed_template`]) is what this still falls back to when
|
||||
/// there's genuinely nothing to clone yet — `swarm-controller` creates
|
||||
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't
|
||||
/// necessarily pushed `main` to it yet, or the forge is unreachable, or
|
||||
/// this hive has no forge read token.
|
||||
/// The template ([`seed_template`]) is the fallback when there is genuinely
|
||||
/// nothing to take yet: `swarm-controller` creates `agent-configs/<name>`
|
||||
/// before telling a hive to deploy, but hasn't necessarily pushed `main` to
|
||||
/// it, or the forge is unreachable, or this hive has no forge read token.
|
||||
///
|
||||
/// **Seeding is the whole of hive-c0re's write.** Changes to the repo
|
||||
/// arrive as PRs from a clone, via the forge, like any other code
|
||||
/// change — nothing edits a working tree in place, here or in the
|
||||
/// agent's bind-mounted `config/` (which is a read-only copy).
|
||||
/// Seeding is the only write hive-c0re makes here. Later config changes
|
||||
/// arrive as forge PRs and are fetched from the forge's `main` (see
|
||||
/// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed
|
||||
/// and rides along on every fetch.
|
||||
///
|
||||
/// Touched by hive-c0re only on first spawn — never again — so a
|
||||
/// hive-c0re commit or working-tree reset can't surprise anyone
|
||||
/// holding a clone.
|
||||
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()))?;
|
||||
if !crate::forge::clone_config_into_proposed(proposed_dir, name).await {
|
||||
seed_template(proposed_dir, name).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.
|
||||
ensure_applied_remote(proposed_dir, name).await
|
||||
}
|
||||
|
||||
/// Author the initial config in place: `agent.nix` (the agent's own module)
|
||||
/// 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). `flake.nix`
|
||||
/// isn't meant to be edited, but it's tracked so it can be read.
|
||||
///
|
||||
/// The fallback half of [`setup_proposed`]: reached only when the forge has
|
||||
/// no config for this agent to take.
|
||||
async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> {
|
||||
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
|
||||
}
|
||||
|
||||
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 config changes are fetched from the forge's `main`
|
||||
/// (see `actions::advance_applied_to_rev`).
|
||||
///
|
||||
/// `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<()> {
|
||||
/// A repo that is already there is only checked for its shape (a rebuild
|
||||
/// finds it already seeded): a missing `deployed/0` tag means it predates the
|
||||
/// meta-flake layout and is an error.
|
||||
pub async fn setup_applied(applied_dir: &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`, so `deployed/0` is the template baseline rather
|
||||
// than whatever the manager may have already committed on top.
|
||||
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?;
|
||||
if crate::forge::fetch_config_main_into_applied(name).await {
|
||||
return Ok(());
|
||||
}
|
||||
seed_template(applied_dir, name).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,
|
||||
// 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
|
||||
|
|
@ -168,6 +62,30 @@ pub async fn setup_applied(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Author the initial config in place: `agent.nix` (the agent's own module)
|
||||
/// 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). `flake.nix`
|
||||
/// isn't meant to be edited, but it's tracked so it can be read.
|
||||
///
|
||||
/// The fallback half of [`setup_applied`]: reached only when the forge has
|
||||
/// no config for this agent to take.
|
||||
async fn seed_template(dir: &Path, name: &str) -> Result<()> {
|
||||
let agent_path = 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 = dir.join("flake.nix");
|
||||
if !flake_path.exists() {
|
||||
std::fs::write(&flake_path, initial_flake_nix())
|
||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
||||
}
|
||||
git(dir, &["init", "--initial-branch=main"]).await?;
|
||||
git(dir, &["add", "agent.nix", "flake.nix"]).await?;
|
||||
git_commit(dir, "hive-c0re init").await
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -3,27 +3,31 @@
|
|||
|
||||
use super::*;
|
||||
|
||||
/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix
|
||||
/// in the initial commit. Before commit 5b5a93e flake.nix was missing from
|
||||
/// the scaffold, requiring manual creation (seen with the damocles agent).
|
||||
/// Regression test: `setup_applied` must seed both agent.nix and flake.nix
|
||||
/// in the initial commit, and tag it `deployed/0`. Before commit 5b5a93e
|
||||
/// flake.nix was missing from the scaffold, requiring manual creation (seen
|
||||
/// with the damocles agent).
|
||||
///
|
||||
/// Exercises the template arm: there is no forge to clone from in a test
|
||||
/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls
|
||||
/// through to `seed_template` — which is the arm this asserts about.
|
||||
/// Exercises the template arm: there is no forge to take a config from in a
|
||||
/// test (no reader token), so `setup_applied` falls through to
|
||||
/// `seed_template` — which is the arm this asserts about.
|
||||
#[tokio::test]
|
||||
async fn setup_proposed_seeds_flake_nix() {
|
||||
async fn setup_applied_seeds_flake_nix() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let proposed = dir.path().join("proposed");
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
let applied = dir.path().join("applied");
|
||||
setup_applied(&applied, "test-agent")
|
||||
.await
|
||||
.expect("setup_proposed");
|
||||
.expect("setup_applied");
|
||||
git_rev_parse(&applied, "refs/tags/deployed/0")
|
||||
.await
|
||||
.expect("the seed commit is tagged deployed/0");
|
||||
|
||||
// Both files must exist on disk.
|
||||
assert!(proposed.join("agent.nix").exists(), "agent.nix missing");
|
||||
assert!(proposed.join("flake.nix").exists(), "flake.nix missing");
|
||||
assert!(applied.join("agent.nix").exists(), "agent.nix missing");
|
||||
assert!(applied.join("flake.nix").exists(), "flake.nix missing");
|
||||
|
||||
// flake.nix must export nixosModules.default (the meta-flake contract).
|
||||
let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap();
|
||||
let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap();
|
||||
assert!(
|
||||
flake.contains("nixosModules.default"),
|
||||
"flake.nix does not export nixosModules.default"
|
||||
|
|
@ -31,7 +35,7 @@ async fn setup_proposed_seeds_flake_nix() {
|
|||
|
||||
// Both files must be tracked in the initial git commit.
|
||||
let out = git_command()
|
||||
.current_dir(&proposed)
|
||||
.current_dir(&applied)
|
||||
.args(["show", "--name-only", "--format=", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
|
|
@ -128,22 +132,22 @@ fn network_isolation_errors_name_the_offending_variable() {
|
|||
);
|
||||
}
|
||||
|
||||
/// `setup_proposed` is idempotent: calling it on an existing repo is a
|
||||
/// no-op (the fresh guard skips all writes).
|
||||
/// `setup_applied` is idempotent: calling it on an already seeded repo is a
|
||||
/// no-op (the shape check passes and nothing is written).
|
||||
#[tokio::test]
|
||||
async fn setup_proposed_idempotent() {
|
||||
async fn setup_applied_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let proposed = dir.path().join("proposed");
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
let applied = dir.path().join("applied");
|
||||
setup_applied(&applied, "test-agent")
|
||||
.await
|
||||
.expect("first call");
|
||||
// Second call must not error even though .git already exists.
|
||||
setup_proposed(&proposed, "test-agent")
|
||||
setup_applied(&applied, "test-agent")
|
||||
.await
|
||||
.expect("second call");
|
||||
// Still one commit.
|
||||
let out = git_command()
|
||||
.current_dir(&proposed)
|
||||
.current_dir(&applied)
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
|
|
@ -158,10 +162,10 @@ async fn setup_proposed_idempotent() {
|
|||
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
|
||||
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo = dir.path().join("proposed");
|
||||
setup_proposed(&repo, "test-agent")
|
||||
let repo = dir.path().join("applied");
|
||||
setup_applied(&repo, "test-agent")
|
||||
.await
|
||||
.expect("setup_proposed");
|
||||
.expect("setup_applied");
|
||||
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
|
||||
|
||||
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");
|
||||
|
|
|
|||
Loading…
Reference in a new issue