swarm-controller: provision a forge user account before adding it as a collaborator

This commit is contained in:
damocles 2026-08-24 13:23:01 +02:00
commit 32c5973956
3 changed files with 177 additions and 30 deletions

View file

@ -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

View file

@ -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

View file

@ -116,7 +116,15 @@ fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
}
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
fn generate_hex_secret() -> Result<String> {
///
/// `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<String> {
use std::io::Read as _;
let mut buf = [0u8; 32];