swarm-controller: CreateRepo/AddRepoMember/InitAgentConfigRepo forge nodes

This commit is contained in:
damocles 2026-08-16 23:05:13 +02:00 committed by mara
commit 1d31bb6e80
4 changed files with 647 additions and 46 deletions

View file

@ -0,0 +1,410 @@
//! 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,
CreateRepoOption, RepoGetContentsQuery,
};
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
use reqwest::StatusCode;
/// The forge org that owns agent repos. Same org `hive-c0re::forge`
/// already uses for its own single-hive `CreateRepo` path — this is
/// the same forge instance, not a separate one, so the same org.
pub const AGENTS_ORG: &str = "agents";
/// 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
/// [`apply_operator_branch_protection`]'s doc comment.
const OPERATORS_TEAM: &str = "operators";
/// 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<Option<Self>> {
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 [`AGENTS_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(AGENTS_ORG, Self::repo_option(name, true))
.await
{
Ok(_) => {
tracing::info!(%name, "swarm forge: created repo in {AGENTS_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 {AGENTS_ORG}/{name}")),
}
}
/// Add `user` as a collaborator on `AGENTS_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(
AGENTS_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 {AGENTS_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 [`AGENTS_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(AGENTS_ORG, repo, rule)
.await
else {
tracing::info!(%repo, "swarm forge: applied operator branch protection");
return Ok(());
};
match self
.api
.repo_get_branch_protection(AGENTS_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 {AGENTS_ORG}/{repo} not applied: create failed \
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
),
}
}
/// Create `repo` in [`AGENTS_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<String> {
self.ensure_org_repo(repo).await?;
self.apply_operator_branch_protection(repo).await?;
tracing::info!(%repo, "swarm forge: created repo in {AGENTS_ORG} with operator merge gate");
Ok(format!("{AGENTS_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(
AGENTS_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 {AGENTS_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<bool> {
match self
.api
.repo_get_contents(AGENTS_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 {AGENTS_ORG}/{repo}")),
}
}
}
/// 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).
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);
}
}
}