651 lines
28 KiB
Rust
651 lines
28 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
/// 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<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 [`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<String> {
|
|
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<bool> {
|
|
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<std::collections::HashMap<String, ConfigPrStatus>> {
|
|
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<Vec<String>, 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);
|
|
}
|
|
}
|
|
}
|