Phase 5c: separate proposed (manager) and applied (hive-c0re) repos; per-agent gitconfig
This commit is contained in:
parent
f6d681c2e2
commit
2fd80dbd68
4 changed files with 147 additions and 64 deletions
|
|
@ -14,7 +14,14 @@ use crate::broker::Broker;
|
||||||
|
|
||||||
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
||||||
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
||||||
|
/// Manager-editable per-agent config repos. Bind-mounted RW into the manager
|
||||||
|
/// container as `/agents/<name>/`. Hive-c0re only writes to these on first
|
||||||
|
/// spawn (initial commit); after that it's manager-only.
|
||||||
const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
|
const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||||
|
/// Hive-c0re-only authoritative per-agent config repos. Containers build from
|
||||||
|
/// these. Manager has no filesystem access; the only way to update is via
|
||||||
|
/// `request_apply_commit` + user approval.
|
||||||
|
const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied";
|
||||||
|
|
||||||
pub struct Coordinator {
|
pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
pub broker: Arc<Broker>,
|
||||||
|
|
@ -73,7 +80,14 @@ impl Coordinator {
|
||||||
Self::manager_dir().join("mcp.sock")
|
Self::manager_dir().join("mcp.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn agent_config_dir(name: &str) -> PathBuf {
|
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||||
|
/// container as `/agents/<name>/config/`.
|
||||||
|
pub fn agent_proposed_dir(name: &str) -> PathBuf {
|
||||||
PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config"))
|
PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authoritative applied config repo. Hive-c0re-only.
|
||||||
|
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,14 @@ pub async fn spawn(
|
||||||
name: &str,
|
name: &str,
|
||||||
hyperhive_flake: &str,
|
hyperhive_flake: &str,
|
||||||
agent_dir: &Path,
|
agent_dir: &Path,
|
||||||
config_dir: &Path,
|
proposed_dir: &Path,
|
||||||
|
applied_dir: &Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
setup_config(config_dir, name, hyperhive_flake).await?;
|
setup_proposed(proposed_dir, name).await?;
|
||||||
|
setup_applied(applied_dir, name, hyperhive_flake).await?;
|
||||||
let container = container_name(name);
|
let container = container_name(name);
|
||||||
let flake_ref = format!("{}#default", config_dir.display());
|
let flake_ref = format!("{}#default", applied_dir.display());
|
||||||
run(&["create", &container, "--flake", &flake_ref]).await?;
|
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||||
set_nspawn_flags(&container, agent_dir)?;
|
set_nspawn_flags(&container, agent_dir)?;
|
||||||
run(&["start", &container]).await
|
run(&["start", &container]).await
|
||||||
|
|
@ -61,12 +63,12 @@ pub async fn rebuild(
|
||||||
name: &str,
|
name: &str,
|
||||||
hyperhive_flake: &str,
|
hyperhive_flake: &str,
|
||||||
agent_dir: &Path,
|
agent_dir: &Path,
|
||||||
config_dir: &Path,
|
applied_dir: &Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
setup_config(config_dir, name, hyperhive_flake).await?;
|
setup_applied(applied_dir, name, hyperhive_flake).await?;
|
||||||
let container = container_name(name);
|
let container = container_name(name);
|
||||||
let flake_ref = format!("{}#default", config_dir.display());
|
let flake_ref = format!("{}#default", applied_dir.display());
|
||||||
set_nspawn_flags(&container, agent_dir)?;
|
set_nspawn_flags(&container, agent_dir)?;
|
||||||
run(&["update", &container, "--flake", &flake_ref]).await?;
|
run(&["update", &container, "--flake", &flake_ref]).await?;
|
||||||
// Restart so any nspawn-level changes (bind mounts, networking, etc.) apply.
|
// Restart so any nspawn-level changes (bind mounts, networking, etc.) apply.
|
||||||
|
|
@ -95,15 +97,34 @@ pub async fn list() -> Result<Vec<String>> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure `config_dir` exists as a git repo containing a per-agent flake. The
|
/// Initialize the manager-editable proposed repo. Contains only `agent.nix`
|
||||||
/// `flake.nix` is rewritten every call (so a new hyperhive store path
|
/// (the file the manager edits). Touched by hive-c0re only on first spawn —
|
||||||
/// propagates on rebuild); `agent.nix` is written only the first time
|
/// never again — so the manager can't be surprised by hive-c0re commits or
|
||||||
/// (manager-editable thereafter).
|
/// working-tree resets.
|
||||||
pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> {
|
pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
|
||||||
std::fs::create_dir_all(config_dir)
|
if proposed_dir.join(".git").exists() {
|
||||||
.with_context(|| format!("create {}", config_dir.display()))?;
|
return Ok(());
|
||||||
|
}
|
||||||
|
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()))?;
|
||||||
|
}
|
||||||
|
git(proposed_dir, &["init", "--initial-branch=main"]).await?;
|
||||||
|
git(proposed_dir, &["add", "agent.nix"]).await?;
|
||||||
|
git_commit(proposed_dir, "hive-c0re init").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call
|
||||||
|
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix`
|
||||||
|
/// only on first call. `apply_commit` overwrites `agent.nix` later.
|
||||||
|
pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(applied_dir)
|
||||||
|
.with_context(|| format!("create {}", applied_dir.display()))?;
|
||||||
|
|
||||||
let flake_path = config_dir.join("flake.nix");
|
|
||||||
let flake_body = format!(
|
let flake_body = format!(
|
||||||
r#"{{
|
r#"{{
|
||||||
description = "hyperhive sub-agent {name}";
|
description = "hyperhive sub-agent {name}";
|
||||||
|
|
@ -112,32 +133,83 @@ pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str)
|
||||||
{{ hyperhive, ... }}:
|
{{ hyperhive, ... }}:
|
||||||
{{
|
{{
|
||||||
nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{
|
nixosConfigurations.default = hyperhive.nixosConfigurations.agent-base.extendModules {{
|
||||||
modules = [ ./agent.nix ];
|
modules = [
|
||||||
|
./agent.nix
|
||||||
|
{{
|
||||||
|
environment.etc."gitconfig".text = ''
|
||||||
|
[user]
|
||||||
|
name = {name}
|
||||||
|
email = {name}@hyperhive
|
||||||
|
[init]
|
||||||
|
defaultBranch = main
|
||||||
|
'';
|
||||||
|
}}
|
||||||
|
];
|
||||||
}};
|
}};
|
||||||
}};
|
}};
|
||||||
}}
|
}}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
std::fs::write(&flake_path, flake_body)
|
std::fs::write(applied_dir.join("flake.nix"), flake_body)
|
||||||
.with_context(|| format!("write {}", flake_path.display()))?;
|
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?;
|
||||||
|
|
||||||
let agent_path = config_dir.join("agent.nix");
|
let agent_path = applied_dir.join("agent.nix");
|
||||||
if !agent_path.exists() {
|
if !agent_path.exists() {
|
||||||
let initial = format!(
|
std::fs::write(&agent_path, initial_agent_nix(name))
|
||||||
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
|
|
||||||
);
|
|
||||||
std::fs::write(&agent_path, initial)
|
|
||||||
.with_context(|| format!("write {}", agent_path.display()))?;
|
.with_context(|| format!("write {}", agent_path.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config_dir.join(".git").exists() {
|
if !applied_dir.join(".git").exists() {
|
||||||
git(config_dir, &["init", "--initial-branch=main"]).await?;
|
git(applied_dir, &["init", "--initial-branch=main"]).await?;
|
||||||
}
|
}
|
||||||
git(config_dir, &["add", "-A"]).await?;
|
git(applied_dir, &["add", "-A"]).await?;
|
||||||
let clean = git_status(config_dir, &["diff", "--cached", "--quiet"]).await?;
|
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
|
||||||
if !clean {
|
if !clean {
|
||||||
|
git_commit(applied_dir, "hive-c0re sync").await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the
|
||||||
|
/// proposed repo, write it into the applied repo, commit. Hive-c0re alone
|
||||||
|
/// advances `applied`'s `main`; the manager only sees `proposed/`.
|
||||||
|
pub async fn apply_commit(
|
||||||
|
applied_dir: &Path,
|
||||||
|
proposed_dir: &Path,
|
||||||
|
commit_ref: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(proposed_dir)
|
||||||
|
.args(["show", &format!("{commit_ref}:agent.nix")])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git show in {}", proposed_dir.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"agent.nix at commit {commit_ref} not found in {}: {}",
|
||||||
|
proposed_dir.display(),
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(applied_dir.join("agent.nix"), &out.stdout)
|
||||||
|
.with_context(|| format!("write {}/agent.nix", applied_dir.display()))?;
|
||||||
|
git(applied_dir, &["add", "agent.nix"]).await?;
|
||||||
|
let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?;
|
||||||
|
if !clean {
|
||||||
|
git_commit(applied_dir, &format!("apply {commit_ref}")).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_agent_nix(name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
git(
|
git(
|
||||||
config_dir,
|
dir,
|
||||||
&[
|
&[
|
||||||
"-c",
|
"-c",
|
||||||
&format!("user.name={GIT_NAME}"),
|
&format!("user.name={GIT_NAME}"),
|
||||||
|
|
@ -145,29 +217,10 @@ pub async fn setup_config(config_dir: &Path, name: &str, hyperhive_flake: &str)
|
||||||
&format!("user.email={GIT_EMAIL}"),
|
&format!("user.email={GIT_EMAIL}"),
|
||||||
"commit",
|
"commit",
|
||||||
"-m",
|
"-m",
|
||||||
"hive-c0re sync",
|
message,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify `commit_ref` exists in the config repo, advance `main` to it, and
|
|
||||||
/// reset the working tree. Caller is responsible for the subsequent rebuild.
|
|
||||||
pub async fn apply_commit(config_dir: &Path, commit_ref: &str) -> Result<()> {
|
|
||||||
let st = Command::new("git")
|
|
||||||
.current_dir(config_dir)
|
|
||||||
.args(["cat-file", "-e", commit_ref])
|
|
||||||
.status()
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("git cat-file in {}", config_dir.display()))?;
|
|
||||||
if !st.success() {
|
|
||||||
bail!("commit {commit_ref} not found in {}", config_dir.display());
|
|
||||||
}
|
|
||||||
git(config_dir, &["update-ref", "refs/heads/main", commit_ref]).await?;
|
|
||||||
git(config_dir, &["reset", "--hard", commit_ref]).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
tracing::info!(%name, "manager: spawn");
|
tracing::info!(%name, "manager: spawn");
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
let config_dir = Coordinator::agent_config_dir(name);
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||||
if let Err(e) =
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await
|
if let Err(e) = lifecycle::spawn(
|
||||||
|
name,
|
||||||
|
&coord.hyperhive_flake,
|
||||||
|
&agent_dir,
|
||||||
|
&proposed_dir,
|
||||||
|
&applied_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
coord.unregister_agent(name);
|
coord.unregister_agent(name);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,16 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
|
||||||
HostRequest::Spawn { name } => {
|
HostRequest::Spawn { name } => {
|
||||||
tracing::info!(%name, "spawn");
|
tracing::info!(%name, "spawn");
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
let config_dir = Coordinator::agent_config_dir(name);
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||||
if let Err(e) =
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
lifecycle::spawn(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await
|
if let Err(e) = lifecycle::spawn(
|
||||||
|
name,
|
||||||
|
&coord.hyperhive_flake,
|
||||||
|
&agent_dir,
|
||||||
|
&proposed_dir,
|
||||||
|
&applied_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
// Roll back socket registration if container creation failed.
|
// Roll back socket registration if container creation failed.
|
||||||
coord.unregister_agent(name);
|
coord.unregister_agent(name);
|
||||||
|
|
@ -80,24 +87,26 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
|
||||||
HostRequest::Rebuild { name } => {
|
HostRequest::Rebuild { name } => {
|
||||||
tracing::info!(%name, "rebuild");
|
tracing::info!(%name, "rebuild");
|
||||||
let agent_dir = coord.register_agent(name)?;
|
let agent_dir = coord.register_agent(name)?;
|
||||||
let config_dir = Coordinator::agent_config_dir(name);
|
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||||
lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &config_dir).await?;
|
lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
||||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||||
HostRequest::Approve { id } => {
|
HostRequest::Approve { id } => {
|
||||||
let approval = coord.approvals.mark_approved(*id)?;
|
let approval = coord.approvals.mark_approved(*id)?;
|
||||||
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval applied: advancing main + rebuilding");
|
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding");
|
||||||
let agent_dir = coord.register_agent(&approval.agent)?;
|
let agent_dir = coord.register_agent(&approval.agent)?;
|
||||||
let config_dir = Coordinator::agent_config_dir(&approval.agent);
|
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||||
|
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||||
let result: anyhow::Result<()> = async {
|
let result: anyhow::Result<()> = async {
|
||||||
lifecycle::apply_commit(&config_dir, &approval.commit_ref).await?;
|
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref)
|
||||||
|
.await?;
|
||||||
lifecycle::rebuild(
|
lifecycle::rebuild(
|
||||||
&approval.agent,
|
&approval.agent,
|
||||||
&coord.hyperhive_flake,
|
&coord.hyperhive_flake,
|
||||||
&agent_dir,
|
&agent_dir,
|
||||||
&config_dir,
|
&applied_dir,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue