//! Swarm-controller's own forgejo client. Self-contained — deliberately //! not sharing code with `hive-c0re::forge` across the crate boundary //! (forcing that split now, over a handful of idempotent CRUD-ish //! calls, is premature plumbing; factor out a shared crate later if the //! duplication actually starts to hurt). //! //! Reads its identity from the credentials `swarm-controller.nix`'s //! `forgeEnv` wires in (`SWARM_CONTROLLER_FORGE_URL` + //! `SWARM_CONTROLLER_FORGE_TOKEN_FILE`, the latter pointing at a //! systemd `LoadCredential`-delivered file) — see that module and //! `hive-forge/default.nix`'s `forgejo-swarm-controller-account` / //! `hive-forge-swarm-controller-token` units for how the token itself //! gets minted and delivered. Absent env (forge not configured on this //! host) means [`Client::from_env`] returns `None` — every caller //! treats that as "no forge access here" and continues, the same //! graceful-absence shape the queue coordinates already use. use anyhow::{Context, Result}; use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, }; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; use reqwest::StatusCode; use serde::Serialize; use std::collections::BTreeMap; use utoipa::ToSchema; use crate::webhook::DeliveryKind; /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// and `GET /api/agents/{name}/config-pr` serves it. #[derive(Clone, Debug, PartialEq, Serialize, ToSchema)] pub struct ConfigPrStatus { pub pr_number: u64, /// Absent only if Forgejo itself omitted the field — every real PR has /// one; not worth failing the whole scan over. pub html_url: Option, } /// The `operators` team, whitelisted for the merge gate on every repo /// this client protects — provisioned by `hive-c0re::forge::repos` /// already (`ensure_operators_team`), not re-provisioned here. If that /// assumption ever breaks (this becomes reachable before any hive's /// `hive-c0re` has run its startup sweep), branch-protection creation /// below will fail loudly rather than silently no-op — see /// [`Client::create_repo`]'s doc comment. const OPERATORS_TEAM: &str = "operators"; /// The org owning per-agent config repos — where this daemon creates them, /// seeds them, and where the `pull_request` hook lives. Same value as /// `hive-c0re::forge::CONFIG_ORG` — one forge, one org. /// /// ⚠️ Duplicated across the crate boundary (this crate deliberately does not /// depend on `hive-c0re`), so nothing makes the two fail together. The /// failure mode if they drift is quiet: the hook is created on an org that /// exists, forgejo reports it healthy, and it simply never fires. /// /// ⚠️ **Not `agents`.** That org exists and is a different thing: it is the /// namespace repos an *agent asks for* land in, per /// `hive-c0re::forge::AGENTS_ORG`. Agent **config** repos have always lived /// here, which is where `hive-c0re` reconciles, merges and mirrors them — a /// config repo created in `agents` is invisible to every one of those paths, /// and nothing errors, because both orgs exist and both accept a repo. /// /// The merge gate survives the distinction: `hive-c0re` provisions the /// `operators` team in **both** orgs precisely so branch protection can be /// applied in either. const CONFIG_ORG: &str = "agent-configs"; /// The hive-wide knowledge repo, where the `push` hook lives. Same values as /// `hive-c0re::workers::knowledge::{ORG, REPO}`. const KNOWLEDGE_ORG: &str = "internal"; const KNOWLEDGE_REPO: &str = "knowledge"; /// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here /// once so [`Client::from_env`] and the nix module can't drift silently /// — a rename on one side without the other fails loudly (env var /// absent) rather than silently losing forge access. const URL_ENV: &str = "SWARM_CONTROLLER_FORGE_URL"; const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE"; /// Typed forgejo client, built once at startup from env. pub struct Client { api: Forgejo, } impl Client { /// Build from env. `Ok(None)` (not an error) when neither env var is /// set — forge isn't configured on this host, which is a normal, /// expected deployment shape (see the module doc comment), not a /// failure. `Err` only for a genuine misconfiguration: one of the /// two env vars present without the other, the token file unreadable, /// or the URL/client failing to build — those ARE failures, because /// they mean forge was *meant* to be configured here and isn't /// usable, which should be loud rather than silently downgrading to /// "no forge access." pub fn from_env() -> Result> { let url = std::env::var(URL_ENV).ok(); let token_file = std::env::var(TOKEN_FILE_ENV).ok(); let (url, token_file) = match (url, token_file) { (None, None) => return Ok(None), (Some(url), Some(token_file)) => (url, token_file), (Some(_), None) => { anyhow::bail!("{URL_ENV} is set but {TOKEN_FILE_ENV} is not — partial forge config") } (None, Some(_)) => { anyhow::bail!("{TOKEN_FILE_ENV} is set but {URL_ENV} is not — partial forge config") } }; let token = std::fs::read_to_string(&token_file) .with_context(|| format!("read forge token from {token_file}"))?; let token = token.trim(); if token.is_empty() { anyhow::bail!("forge token file {token_file} is empty"); } let parsed_url = url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?; let api = Forgejo::new(Auth::Token(token), parsed_url).context("build forgejo client")?; Ok(Some(Self { api })) } /// Creation options for an empty repo defaulting to `main`. fn repo_option(name: &str, private: bool) -> CreateRepoOption { CreateRepoOption { auto_init: Some(false), default_branch: Some("main".to_owned()), description: None, gitignores: None, issue_labels: None, license: None, name: name.to_owned(), object_format_name: None, private: Some(private), readme: None, template: None, trust_model: None, } } /// Create `name` inside [`CONFIG_ORG`]. Idempotent: an existing repo /// (409/422) is folded into success. async fn ensure_org_repo(&self, name: &str) -> Result<()> { match self .api .create_org_repo(CONFIG_ORG, Self::repo_option(name, true)) .await { Ok(_) => { tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}"); Ok(()) } Err(e) if is_already_exists(&e) => { tracing::debug!(%name, "swarm forge: repo already exists"); Ok(()) } Err(e) => Err(e).with_context(|| format!("create repo {CONFIG_ORG}/{name}")), } } /// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`. /// Idempotent: re-adding an existing collaborator just updates its /// permission (forgejo answers 204 either way; a 201 from older /// server versions is tolerated defensively). async fn add_collaborator( &self, repo: &str, user: &str, permission: AddCollaboratorOptionPermission, ) -> Result<()> { let res = self .api .repo_add_collaborator( CONFIG_ORG, repo, user, AddCollaboratorOption { permission: Some(permission), }, ) .await; match res { Ok(()) => {} Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {} Err(e) => { return Err(e) .with_context(|| format!("add collaborator {user} to {CONFIG_ORG}/{repo}")); } } tracing::debug!(%repo, %user, ?permission, "swarm forge: collaborator set"); Ok(()) } /// Apply the operator merge-gate branch protection to `repo`'s /// default branch — only [`OPERATORS_TEAM`] members can merge, and /// an approving review from that team is required, so the agent (a /// write-level collaborator, not in the team) cannot merge its own /// PR. Mirrors `hive-c0re::forge::repos::apply_operator_branch_protection` /// exactly (same policy, same verify-don't-trust shape): a create /// failure is ambiguous (already-exists vs. a silent reject that /// created no rule), so on any error this GETs the `main` rule and /// only treats it as success if the rule is actually present — a /// fail-open merge gate is a security bug, not a shrug. /// /// Assumes [`OPERATORS_TEAM`] already exists in [`CONFIG_ORG`] /// (provisioned by `hive-c0re::forge::repos::ensure_operators_team` /// on its own startup sweep, not re-provisioned here) — if it /// doesn't yet, this fails loudly rather than silently leaving the /// repo unprotected, which is the correct failure mode for a /// prerequisite that's supposed to already be there. async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> { let rule = CreateBranchProtectionOption { apply_to_admins: None, approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]), approvals_whitelist_username: None, block_on_official_review_requests: Some(true), block_on_outdated_branch: None, block_on_rejected_reviews: None, branch_name: Some("main".to_owned()), dismiss_stale_approvals: None, enable_approvals_whitelist: Some(true), enable_merge_whitelist: Some(true), enable_push: None, enable_push_whitelist: None, enable_status_check: None, ignore_stale_approvals: None, merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]), merge_whitelist_usernames: None, protected_file_patterns: None, push_whitelist_deploy_keys: None, push_whitelist_teams: None, push_whitelist_usernames: None, require_signed_commits: None, required_approvals: Some(1), rule_name: None, status_check_contexts: None, unprotected_file_patterns: None, }; let Err(create_err) = self .api .repo_create_branch_protection(CONFIG_ORG, repo, rule) .await else { tracing::info!(%repo, "swarm forge: applied operator branch protection"); return Ok(()); }; match self .api .repo_get_branch_protection(CONFIG_ORG, repo, "main") .await { Ok(_) => { tracing::debug!( %repo, create_error = %create_err, "swarm forge: operator branch protection already present" ); Ok(()) } Err(check_err) => anyhow::bail!( "branch protection for {CONFIG_ORG}/{repo} not applied: create failed \ ({create_err}); GET main rule failed ({check_err}), no `main` rule present" ), } } /// Create `repo` in [`CONFIG_ORG`] and apply the operator merge gate to /// its default branch — the whole job of the `CreateRepo` node. /// Branch protection is folded in here rather than a separate node: it /// has no independent retry value apart from the repo existing (there /// is nothing useful to retry "branch protection on a repo that /// doesn't exist yet"), unlike collaborator-add and config-seed, which /// are genuinely separate operations against an already-existing repo. /// Idempotent — safe to call again. pub async fn create_repo(&self, repo: &str) -> Result { self.ensure_org_repo(repo).await?; self.apply_operator_branch_protection(repo).await?; tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate"); Ok(format!("{CONFIG_ORG}/{repo}")) } /// Add `agent` as a **write** collaborator on `repo` (can push + open /// PRs, cannot bypass branch protection) — the whole job of the /// `AddRepoMember` node. Idempotent. pub async fn add_repo_member(&self, repo: &str, agent: &str) -> Result<()> { self.add_collaborator(repo, agent, AddCollaboratorOptionPermission::Write) .await } /// 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 /// input) — same content `hive-c0re::lifecycle::setup::setup_proposed` /// writes at the per-hive level, committed here in one atomic /// `repo_change_files` call instead of a local `git commit` (this /// process has no working tree to commit from — it only ever talks to /// the forge over HTTP). The whole job of the `InitAgentConfigRepo` /// node. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo /// would overwrite `agent.nix` with the seed content again, clobbering /// any edits merged since — so this checks for `agent.nix`'s presence /// first and treats it as "already seeded, nothing to do" rather than /// re-committing over it. pub async fn seed_agent_config(&self, repo: &str, agent: &str) -> Result<()> { if self.file_exists(repo, "agent.nix").await? { tracing::debug!(%repo, "swarm forge: config already seeded"); return Ok(()); } let files = vec![ ChangeFileOperation { content: Some(base64_encode(&initial_agent_nix(agent))), from_path: None, operation: ChangeFileOperationOperation::Create, path: "agent.nix".to_owned(), sha: None, }, ChangeFileOperation { content: Some(base64_encode(initial_flake_nix())), from_path: None, operation: ChangeFileOperationOperation::Create, path: "flake.nix".to_owned(), sha: None, }, ]; self.api .repo_change_files( CONFIG_ORG, repo, ChangeFilesOptions { author: None, branch: None, committer: None, dates: None, files, force_overwrite_new_branch: None, message: Some("swarm-controller: seed agent config".to_owned()), new_branch: None, signoff: None, }, ) .await .with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?; tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix"); Ok(()) } /// Whether `path` exists on `repo`'s default branch — the idempotency /// check [`Self::seed_agent_config`] uses instead of catching a /// create-conflict, since `repo_change_files` has no /// already-exists-is-fine status the way `create_org_repo` does. async fn file_exists(&self, repo: &str, path: &str) -> Result { match self .api .repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default()) .await { Ok(_) => Ok(true), Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false), Err(e) => Err(e).with_context(|| format!("check for {path} in {CONFIG_ORG}/{repo}")), } } /// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent /// name. Mirrors `hive-c0re::forge::config_pr_poll::poll_open_config_prs`'s /// scan shape (list repos in the org, list open PRs per repo) but returns /// data instead of side-effecting an approval queue — this daemon has no /// approval system of its own; it exists so `GET /// /api/agents/{name}/config-pr` has something to answer from, /// independent of any one hive being up. /// /// A single repo's list failing does not fail the whole scan — logged and /// skipped, so one flaky repo can't blank out every other agent's status. pub async fn list_open_config_prs( &self, ) -> Result> { let repos = self .api .org_list_repos(CONFIG_ORG) .all() .await .with_context(|| format!("list repos in {CONFIG_ORG}"))?; let mut out = std::collections::HashMap::new(); for repo in repos { let Some(agent) = repo.name.as_deref() else { continue; }; let query = RepoListPullRequestsQuery { state: Some(RepoListPullRequestsQueryState::Open), sort: None, milestone: None, labels: None, poster: None, base: None, head: None, }; let prs = match self .api .repo_list_pull_requests(CONFIG_ORG, agent, query) .all() .await { Ok(prs) => prs, Err(e) => { tracing::debug!(%agent, error = %e, "swarm forge: listing config PRs failed, skipping repo"); continue; } }; // Only the first open PR matters for the panel — a config repo // is meant to carry at most one live proposal at a time (the // same assumption `hive-c0re`'s poller and the `MergeConfigPr` // approval flow both make). if let Some(pr) = prs.into_iter().next() { let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else { continue; }; out.insert( agent.to_owned(), ConfigPrStatus { pr_number, html_url: pr.html_url.map(|u| u.to_string()), }, ); } } Ok(out) } /// Register the swarm-wide hooks against this controller, so a real /// forge event reaches [`crate::webhook`] instead of the endpoint only /// being reachable by hand. /// /// **These are registered ALONGSIDE the per-hive hooks, not instead of /// them.** Every hive keeps receiving and acting on its own deliveries /// exactly as today; the controller receives a copy and (for now) logs /// it. Taking the hive-side registration away is a later step, and it /// has to be later: fan-out swarm→hive does not exist yet, so a hook /// moved now would point at a receiver that forwards nowhere — silent on /// both sides, indistinguishable from no activity. /// /// ⛔ **No stale-hook deletion arm, unlike the two per-hive registrars /// this otherwise mirrors.** Their arm deletes hooks matching their own /// path with a foreign base; copying it here would delete the hives' /// live hooks, which are not stale — they are the path still in /// production. The controller only ever adds its own. /// /// Idempotent: an existing hook with the same `target_url` is left /// alone, so this is safe on every boot. /// /// # Errors /// /// Returns the first failure. A listing failure is not fatal — it falls /// through to the create attempt, which is idempotent server-side by way /// of the already-exists fold, the same best-effort shape `hive-c0re` /// uses. pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> { for kind in DeliveryKind::ALL { let target_url = kind.target_url(public_base); let (event, scope) = match kind { DeliveryKind::Knowledge => ( "push", HookScope::Repo { org: KNOWLEDGE_ORG, repo: KNOWLEDGE_REPO, }, ), DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }), }; self.ensure_hook(&scope, &target_url, event, secret) .await .with_context(|| format!("register swarm webhook {target_url}"))?; } Ok(()) } /// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`] /// so the org-scoped and repo-scoped calls do not each grow their own /// copy of the list-then-create logic. async fn ensure_hook( &self, scope: &HookScope<'_>, target_url: &str, event: &str, secret: &str, ) -> Result<()> { match scope.list_hook_urls(&self.api).await { Ok(urls) => { if urls.iter().any(|u| u == target_url) { tracing::debug!(%target_url, "swarm forge: webhook already registered"); return Ok(()); } } Err(e) => { // Best-effort, same as the per-hive registrars: a forge that // cannot be listed may still accept a create, and a // duplicate create is folded into success below. tracing::debug!(error = %e, %target_url, "swarm forge: listing hooks failed; attempting create"); } } let mut additional = BTreeMap::new(); additional.insert("secret".to_owned(), secret.to_owned()); let hook = CreateHookOption { active: Some(true), authorization_header: None, branch_filter: None, config: CreateHookOptionConfig { content_type: "json".to_owned(), url: url::Url::parse(target_url) .with_context(|| format!("parse webhook target url {target_url}"))?, additional, }, events: Some(vec![event.to_owned()]), r#type: CreateHookOptionType::Forgejo, }; match scope.create_hook(&self.api, hook).await { Ok(()) => { tracing::info!(%target_url, %event, "swarm forge: webhook registered"); Ok(()) } Err(e) if is_already_exists(&e) => { tracing::debug!(%target_url, "swarm forge: webhook already present"); Ok(()) } Err(e) => Err(e.into()), } } } /// Where a hook lives. The knowledge hook is repo-scoped and the config-PR /// hook is org-scoped, mirroring exactly where the per-hive registrars put /// theirs — a hook on the wrong scope would never fire, and forgejo would /// report that as a perfectly healthy hook with no deliveries. enum HookScope<'a> { Repo { org: &'a str, repo: &'a str }, Org { org: &'a str }, } impl HookScope<'_> { /// The `url` config value of every hook currently on this scope. async fn list_hook_urls(&self, api: &Forgejo) -> Result, ForgejoError> { let hooks = match self { Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?, Self::Org { org } => api.org_list_hooks(org).send().await?, }; Ok(hooks .iter() .filter_map(|h| h.config.as_ref()?.get("url").cloned()) .collect()) } async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> { match self { Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop), Self::Org { org } => api.org_create_hook(org, hook).await.map(drop), } } } /// Base64-encode `content` for a forgejo content-API call — every file /// write on this API takes base64, never raw bytes. fn base64_encode(content: &str) -> String { use base64::Engine as _; base64::engine::general_purpose::STANDARD.encode(content) } /// Per-agent NixOS module seed — same content /// `hive-c0re::lifecycle::setup::initial_agent_nix` writes at the per-hive /// level (this process has no access to that function across the crate /// boundary, and it's three lines — not worth a shared crate for). /// /// Deliberately does **not** record which hive the agent belongs to, even /// though `POST /api/agents` now takes one: an agent's config should carry /// no reference to the hive it runs on. The hive is an address the swarm /// routes on, not a property of the agent — a /// config that named its own hive would be a second place stating where /// the agent lives, free to disagree with the queue that actually /// delivers to it. fn initial_agent_nix(name: &str) -> String { format!( "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n", ) } /// Module-only flake seed, byte-identical to /// `hive-c0re::lifecycle::setup::initial_flake_nix` — see that function's /// doc comment for why the shape looks the way it does (`flakeInputs` /// forwarding, meta-flake consumption). Not shared across the crate /// boundary for the same reason [`initial_agent_nix`] isn't. fn initial_flake_nix() -> &'static str { "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" } /// Whether a create-style call failed because the object already /// exists. Forgejo signals this as HTTP 409 (conflict) or 422 /// (validation) — match both defensively, same as /// `hive-c0re::forge::repos::is_already_exists`. fn is_already_exists(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::ValidationFailed => true, ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT, _ => false, }, ForgejoError::UnexpectedStatusCode(s) => { *s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY } _ => false, } } #[cfg(test)] mod tests { use super::*; // Both scenarios live in ONE test, not two, deliberately: `cargo // test` runs tests in parallel by default, and two tests mutating // the same process-global env vars would race each other. A single // test's body is one thread, so sequential set/clear within it is // race-free without needing a cross-test lock. #[test] fn from_env_reads_both_vars_or_neither() { // SAFETY: test-only mutation of env vars this module itself // owns (`URL_ENV`/`TOKEN_FILE_ENV`), scoped to this one test // function so there's no cross-test race (see comment above). unsafe { std::env::remove_var(URL_ENV); std::env::remove_var(TOKEN_FILE_ENV); } assert!(Client::from_env().unwrap().is_none()); unsafe { std::env::set_var(URL_ENV, "https://forge.example.invalid"); } // Not `.unwrap_err()`: that needs `Client: Debug` for its panic // message, and the wrapped `forgejo_api::Forgejo` doesn't derive // it. Match directly instead of adding a `Debug` impl nothing // else needs. match Client::from_env() { Err(e) => assert!(e.to_string().contains(TOKEN_FILE_ENV)), Ok(_) => panic!("expected an error for a partial forge config"), } unsafe { std::env::remove_var(URL_ENV); } } }