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