From 32c5973956b847701a1b2830e5fb5bf00685021d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 24 Aug 2026 13:23:01 +0200 Subject: [PATCH] swarm-controller: provision a forge user account before adding it as a collaborator --- swarm-controller/src/forge.rs | 65 +++++++++++++++- swarm-controller/src/main.rs | 132 +++++++++++++++++++++++++------- swarm-controller/src/webhook.rs | 10 ++- 3 files changed, 177 insertions(+), 30 deletions(-) diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index d2f8c3e0..17753d21 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -20,7 +20,8 @@ use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, - RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, + CreateUserOption, RepoGetContentsQuery, RepoListPullRequestsQuery, + RepoListPullRequestsQueryState, }; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; use reqwest::StatusCode; @@ -288,6 +289,68 @@ impl Client { .await } + /// Ensure `agent` exists as a Forgejo user account — the whole job of + /// the `CreateForgeUser` node, and the fix for the "user does not + /// exist" failure `AddRepoMember` hit before this node existed: adding + /// a nonexistent user as a collaborator is a Forgejo validation error, + /// not an idempotent no-op, so something has to create the account + /// first. Mirrors `hive-c0re::forge::users::ensure_user_exists`'s + /// intent (an agent's Forgejo identity is provisioned once, up front, + /// authenticates by token thereafter, and its password is never read) + /// but not its mechanism: that function shells out to the local + /// `forgejo admin` CLI, which assumes co-location with the forge host. + /// This daemon has no such assumption — like every other call in this + /// file, it only ever talks to the forge over HTTP — so this goes + /// through `admin_create_user` instead. + /// + /// The password itself is a throwaway: 32 random bytes, generated once, + /// never persisted anywhere, and never needed again (unlike + /// `hive-c0re`'s CLI path, which can ask forgejo to `--random-password` + /// on its own, the HTTP admin API requires a real value up front — see + /// [`crate::webhook::generate_hex_secret`], reused here rather than + /// duplicated for the same reason a webhook secret and this password + /// are both "32 random bytes nothing reads back"). + /// + /// Idempotent: an existing user (409/422) is folded into success, same + /// as [`Self::ensure_org_repo`]. Deliberately does not attempt to align + /// the account's email or disable its own repo-creation rights the way + /// `hive-c0re`'s per-hive provisioning does (`ensure_user_email`, + /// `ensure_repo_creation_disabled`) — this account only exists so + /// `AddRepoMember` has something to add, and the agent's owning hive + /// still runs its own full provisioning pass once the agent actually + /// spawns there, which self-heals both of those. + pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> { + let password = crate::webhook::generate_hex_secret() + .context("generating a throwaway password for the agent's forge account")?; + let res = self + .api + .admin_create_user(CreateUserOption { + created_at: None, + email: format!("{agent}@hyperhive.local"), + full_name: None, + login_name: None, + must_change_password: Some(false), + password: Some(password), + restricted: None, + send_notify: None, + source_id: None, + username: agent.to_owned(), + visibility: None, + }) + .await; + match res { + Ok(_) => { + tracing::info!(%agent, "swarm forge: created agent forge user"); + Ok(()) + } + Err(e) if is_already_exists(&e) => { + tracing::debug!(%agent, "swarm forge: agent forge user already exists"); + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("create forge user {agent}")), + } + } + /// Seed `repo` with the two files every agent config repo needs: /// `agent.nix` (the agent's own module) and `flake.nix` (the /// boilerplate that lets the meta flake import this repo as a flake diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index fed8555a..6d33f80d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -65,6 +65,13 @@ enum SwarmNodeKind { /// Create the agent's repo in `forge::CONFIG_ORG` with the operator /// merge gate on its default branch. See `forge::Client::create_repo`. CreateRepo { agent: String }, + /// Ensure `agent` exists as a Forgejo user account. Independent of + /// `CreateIdentity`/`CreateRepo` (a forge user needs neither an + /// authelia subject nor an existing repo) but a prerequisite for + /// `AddRepoMember`, which fails with "user does not exist" against an + /// account this node hasn't created yet. See + /// `forge::Client::ensure_agent_user`. + CreateForgeUser { agent: String }, /// Add `agent` as a write collaborator on its own repo. See /// `forge::Client::add_repo_member`. AddRepoMember { agent: String }, @@ -84,6 +91,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { match self { SwarmNodeKind::CreateIdentity { .. } => "create_identity".to_owned(), SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(), + SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(), SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(), SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), } @@ -98,6 +106,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { match self { SwarmNodeKind::CreateIdentity { agent } | SwarmNodeKind::CreateRepo { agent } + | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } => { serde_json::json!({ "agent": agent }) @@ -182,6 +191,17 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, + SwarmNodeKind::CreateForgeUser { agent } => match deps.forge { + None => Outcome::Failed( + "no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \ + SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)" + .to_owned(), + ), + Some(client) => match client.ensure_agent_user(&agent).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + }, + }, SwarmNodeKind::AddRepoMember { agent } => match deps.forge { None => Outcome::Failed( "no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \ @@ -663,33 +683,29 @@ struct CreateAgentResponse { node_id: u64, } -/// Queue the whole agent-creation job graph for `name` — `CreateIdentity` -/// then, once that succeeds, `CreateRepo`; once THAT succeeds, -/// `AddRepoMember` and `InitAgentConfigRepo` both run off it — a fan-out, -/// not a chain, since adding a collaborator and seeding config files are -/// independent operations against the same already-created, already- -/// protected repo and have no ordering requirement on each other (mara, -/// design review: `InitAgentConfigRepo` does not depend on -/// `AddRepoMember` — it's a jobq graph, not a linear chain). Returns as -/// soon as the graph is inserted — **not** once any of it has run; `run_swarm_node` does that -/// work asynchronously off the scheduler loop already running -/// (`spawn_jobq_worker`), same as every other node kind. The response -/// reports `CreateIdentity`'s id, the graph's entry point — a caller -/// watches the whole thing settle via `/api/jobq/graph`, which serves -/// every root, not just this one. -/// -/// This endpoint is also the first genuine non-test caller all four -/// `SwarmNodeKind` variants have: each node kind's `dead_code` bound is -/// the whole reason the executor arm and this endpoint had to land in the -/// same change as the variant, not as a follow-up. +/// Queue the whole agent-creation job graph for `name` — two independent +/// roots, `CreateIdentity` and `CreateForgeUser`, since an authelia +/// subject and a forge account need nothing from each other. `CreateRepo` +/// runs once `CreateIdentity` succeeds; once THAT succeeds, +/// `AddRepoMember` and `InitAgentConfigRepo` both run off it — except +/// `AddRepoMember` also waits on `CreateForgeUser`, because adding a +/// collaborator that does not yet exist as a forge user is a Forgejo +/// validation error, not an idempotent no-op. `InitAgentConfigRepo` has no +/// such second dependency: seeding files uses this daemon's own forge +/// token, never the agent's, so it only ever needed the repo (mara, design +/// review: it's a jobq graph, not a linear chain). Returns as soon as the +/// graph is inserted — **not** once any of it has run; `run_swarm_node` +/// does that asynchronously off the scheduler loop already running +/// (`spawn_jobq_worker`). The response reports `CreateIdentity`'s id, one +/// of the graph's two roots — a caller watches the whole thing settle via +/// `/api/jobq/graph`, which serves every root, not just this one. /// /// `name` is validated with [`hive_types::Ident::parse`] before it becomes /// `agent`/`repo` anywhere downstream — not the *only* gate (`CreateIdentity` -/// runs first and validates server-side too), but the upstream check is a -/// few hops removed from where an unvalidated name would do damage -/// (`forge::seed_agent_config` interpolates `agent` into a nix comment -/// line). Flagged in review as safe today but fragile if a second caller -/// of these nodes ever appears; validating here closes it locally. +/// validates server-side too), but a few hops removed from where an +/// unvalidated name would do damage (`forge::seed_agent_config` +/// interpolates `agent` into a nix comment line). Fragile if a second +/// caller of these nodes ever appears; validating here closes it locally. #[utoipa::path( post, path = "/api/agents", @@ -745,19 +761,28 @@ async fn create_agent( let create_identity = b.node(SwarmNodeKind::CreateIdentity { agent: agent.clone(), }); + // A second, independent root: a forge user needs neither an + // authelia subject nor an existing repo, so it does not chain + // off `create_identity` (see the doc comment above). + let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser { + agent: agent.clone(), + }); let create_repo = b .node(SwarmNodeKind::CreateRepo { agent: agent.clone(), }) .after_ok(create_identity); - // Both fan out from `create_repo` directly — independent - // operations on the same repo, no ordering requirement on - // each other (see the doc comment above). + // `AddRepoMember` needs both parents: the repo to add a + // collaborator to, and the forge user to add as one — adding a + // nonexistent user is a Forgejo validation error, not + // an idempotent no-op. `InitAgentConfigRepo` needs only the + // repo — see the doc comment above for why. let _add_repo_member = b .node(SwarmNodeKind::AddRepoMember { agent: agent.clone(), }) - .after_ok(create_repo); + .after_ok(create_repo) + .after_ok(create_forge_user); let _init_config = b .node(SwarmNodeKind::InitAgentConfigRepo { agent }) .after_ok(create_repo); @@ -1373,6 +1398,57 @@ mod tests { ); } + /// Sibling of the `CreateRepo` test above, same shape: drives + /// `SwarmNodeKind::CreateForgeUser` through the real scheduler with no + /// forge configured, asserting the graceful-absence-is-failure branch — + /// this node's happy path needs a live forge, same caveat as + /// `CreateRepo`'s. + #[tokio::test] + async fn create_forge_user_node_runs_end_to_end_and_fails_without_forge_configured() { + unsafe { + std::env::remove_var("SWARM_CONTROLLER_FORGE_URL"); + std::env::remove_var("SWARM_CONTROLLER_FORGE_TOKEN_FILE"); + } + + let mut sched = hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + ); + let id = sched + .append( + SwarmNodeKind::CreateForgeUser { + agent: "atlas".to_owned(), + }, + Vec::new(), + None, + ) + .expect("insert"); + let sched = std::sync::Arc::new(std::sync::Mutex::new(sched)); + + let deps = WorkerDeps { + auth: None, + forge: None, + }; + let runner = + hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { + run_swarm_node(id, kind, builder, deps) + }) + .expect("the node just inserted is runnable"); + runner + .await + .1 + .expect("no growth declared, nothing to reject"); + + let guard = sched.lock().unwrap(); + let node = guard.graph().node(id).expect("node still present"); + assert_eq!(node.state, hive_jobq::State::Failed); + assert!( + node.error.as_deref().unwrap_or_default().contains("forge"), + "expected a forge-not-configured error, got {:?}", + node.error + ); + } + /// The socket must not share a directory with anything else, because /// the socket is `0666` and the directory is therefore the only access /// control it has. `/run/hyperhive` in particular holds hive-c0re's diff --git a/swarm-controller/src/webhook.rs b/swarm-controller/src/webhook.rs index 5412e6e9..b93f272d 100644 --- a/swarm-controller/src/webhook.rs +++ b/swarm-controller/src/webhook.rs @@ -116,7 +116,15 @@ fn load_or_generate_at(path: &std::path::Path) -> Result { } /// Read 32 random bytes from `/dev/urandom` and hex-encode them. -fn generate_hex_secret() -> Result { +/// +/// `pub(super)` rather than private: `forge::Client::ensure_agent_user` +/// reuses this for the throwaway account password Forgejo's admin +/// create-user API requires (unlike the `forgejo admin` CLI's +/// `--random-password`, the HTTP endpoint has no "generate one for me" +/// option — see that function's doc comment). Same shape as the webhook +/// secret above: a value nothing ever reads back, so 32 random bytes is +/// as good as any other generator. +pub(super) fn generate_hex_secret() -> Result { use std::io::Read as _; let mut buf = [0u8; 32];