//! Optional Forgejo wiring — the `core/meta` repo and meta read-access //! grants. Those writes, as `core`, run only beside a local `hive-forge` //! container; reads of the forge go through [`reader_token`] on every hive. //! The swarm-wide orgs, teams and repos, and each agent's read grant on //! `internal/docs`, are swarm-controller's. Full design: //! `docs/integrations/forge.md`. mod reader; mod reconcile; mod repos; mod users; pub use reader::reader_token; pub(crate) use reconcile::FORGE_MAIN_REF; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use repos::{ clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main, fetch_config_main_into_applied, meta_read_access, push_meta, }; pub use users::core_token; use std::future::IntoFuture; use std::sync::OnceLock; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use forgejo_api::{Auth, Forgejo}; use url::Url; use users::{ensure_core_avatar, ensure_core_user_and_token}; const FORGE_CONTAINER: &str = "hive-forge"; /// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL` /// (set unconditionally by the hyperhive NixOS module on the `hive-c0re` /// unit, to `http://`). /// /// # Panics /// /// When `HIVE_FORGE_URL` is unset (`validate_env` rejects that at startup, so /// the daemon never reaches this). That is deliberate: this daemon only /// runs under the NixOS module, which always sets it, so an unset var /// means the deployment is broken. There is no loopback fallback, /// because a guess is wrong in exactly the cases that matter — the /// forge may live on a different host from the daemon, and a fallback /// turns "misconfigured" into "silently talking to the wrong machine" /// or, worse, "connection refused" surfacing far from its cause. pub(crate) fn forge_http_base() -> &'static str { static BASE: OnceLock = OnceLock::new(); BASE.get_or_init(|| { std::env::var("HIVE_FORGE_URL").expect( "HIVE_FORGE_URL is unset — the hyperhive NixOS module sets it \ unconditionally on the hive-c0re unit, so this process was \ started outside that unit", ) }) } /// Check the env the forge integration cannot run without: `HIVE_FORGE_URL` /// set and a valid URL, `HIVE_ASSETS_DIR` set. Called once from `main` /// before any worker is spawned, so a missing or malformed value fails the /// unit start naming the variable; the lazy accessors then never hit their /// `expect`s. pub(crate) fn validate_env() -> Result<()> { let url = std::env::var("HIVE_FORGE_URL").context( "HIVE_FORGE_URL is unset (the hyperhive NixOS module sets it on the hive-c0re unit)", )?; Url::parse(&url).with_context(|| format!("HIVE_FORGE_URL is not a valid URL: {url:?}"))?; std::env::var("HIVE_ASSETS_DIR").context( "HIVE_ASSETS_DIR is unset (the hyperhive NixOS module sets it on the hive-c0re unit)", )?; Ok(()) } /// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**. /// /// The token does not go here. A URL is a process argument, and `argv` is /// world-readable through `/proc//cmdline` for as long as the git child /// lives, so a credentialed remote publishes the core admin token to every /// local user on the host. Credentials travel in the environment instead, via /// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] — /// `/proc//environ` is owner-only. pub(crate) fn forge_git_url(repo: &str) -> String { git_url_with_base(forge_http_base(), repo) } /// The pure half of [`forge_git_url`], split out so it can be tested without a /// process-wide env var (which would race every other test in this binary). fn git_url_with_base(base: &str, repo: &str) -> String { format!("{base}/{repo}.git") } /// The `http.extraHeader` value authenticating as the forge core user. pub(crate) fn core_auth_header(token: &str) -> String { basic_auth_header("core", token) } /// The `http.extraHeader` value carrying `token`. Forgejo takes the account /// from the token, so `login` only labels the request. /// /// Basic auth over a header rather than userinfo in the URL, so the secret /// reaches git through the environment (see [`forge_git_url`]). Pair with /// [`crate::lifecycle::git_command_authed`], which is the only thing that /// should ever hold the result. fn basic_auth_header(login: &str, token: &str) -> String { use base64::Engine as _; let basic = base64::engine::general_purpose::STANDARD.encode(format!("{login}:{token}")); format!("Authorization: Basic {basic}") } /// Forgejo org grouping every agent's config repo. Core is a site admin /// and reads + writes every repo here. Each agent is a **write collaborator /// on its own** `agent-configs/` repo — the editable PR surface it /// pushes config-change branches to. `main`'s branch protection is /// swarm-controller's: merge and approval allowlisted to the `operators` team, /// so the agent can't push `main` or self-merge, and an operator's merge /// deploys through swarm-controller. The repos remain private, so an agent /// still can't reach *another* agent's config. hive-c0re only reads from it. pub(crate) const CONFIG_ORG: &str = "agent-configs"; /// Leak `s` to get a `&'static str` warning `kind` for the small, bounded /// set of boot warnings in [`ensure_all`] keyed by a runtime name (at /// most a handful per process). [`crate::warnings::set_boot_warning`] /// requires a `'static` kind so distinct orgs/repos don't clobber each /// other's warning entry; leaking a few short strings once per boot is /// cheap and bounded, unlike a per-request or per-loop-iteration leak. fn static_kind(s: String) -> &'static str { Box::leak(s.into_boxed_str()) } /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs /// unprivileged (privsep). pub async fn is_present() -> bool { let Ok(stdout) = crate::priv_client::list_containers().await else { return false; }; stdout.lines().any(|l| l.trim() == FORGE_CONTAINER) } /// Name a `forgejo admin` invocation for an error message without /// reproducing its arguments: keep the leading verb path, stop at the /// first flag. `["user", "create", "--username", "iris", "--password", /// "…"]` becomes `forgejo admin user create`. /// /// The verbs are an **allowlist**, and that is the whole point. Some /// callers pass a live secret as an argument value (`--password`), so a /// message built from the raw vector puts it in the log. Listing the /// flags to *hide* instead would repeat the bug this guards against: a /// newly added secret-bearing flag would leak until someone remembered /// to extend the list. Verbs are a closed set this crate chooses /// itself; argument values never are. fn describe_forge_admin(args: &[&str]) -> String { let verbs: Vec<&str> = args .iter() .take_while(|a| !a.starts_with('-')) .copied() .collect(); if verbs.is_empty() { "forgejo admin".to_owned() } else { format!("forgejo admin {}", verbs.join(" ")) } } /// Run `forgejo admin ` inside the hive-forge container as the /// forgejo user (the only uid with write access to the state dir). /// Returns stdout on success; bails with stderr context on failure. async fn forge_admin(args: &[&str]) -> Result { // Route through hive-priv (root helper) because `nixos-container run` // uses nsenter to enter the container's namespaces, which requires root. // hive-c0re runs as the unprivileged `hive-core` user and cannot call // nsenter directly — doing so produces: // nsenter: stat of /proc//ns/user failed: Permission denied let (stdout, _stderr) = crate::priv_client::run_forge_admin(args) .await .with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?; Ok(stdout) } /// Typed Forgejo API client for the local forge ([`forge_http_base()`]), /// authenticated as `token`. All Forgejo API calls that don't shell /// out to `forgejo admin` go through clients built here — one place /// for the base URL and auth. Tokens differ per call site (core admin /// token vs per-agent tokens), so the token is passed per call; the /// base URL is parsed once. Failures surface as /// `forgejo_api::ForgejoError`, whose Display carries the HTTP status /// and the API's error message (e.g. the validation reason on a 422). pub(crate) fn api(token: &str) -> Result { static URL: OnceLock = OnceLock::new(); let url = URL .get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL")) .clone(); Forgejo::new(Auth::Token(token), url).context("build forgejo api client") } /// Upper bound on one Forgejo API request. `forgejo-api` takes no /// caller-built client, so the request itself has no timeout; every request /// in this module is wrapped in [`bounded`]. const API_TIMEOUT: Duration = Duration::from_secs(10); /// Await one Forgejo API request for at most [`API_TIMEOUT`]. /// /// The outer `Err` is the deadline, naming `op`; the inner `Result` is the /// request's own outcome, left to the caller so it can still match on the /// error (`409 already exists`, `401`, ...). async fn bounded(op: &str, request: F) -> Result> where F: IntoFuture>, { tokio::time::timeout(API_TIMEOUT, request) .await .map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}")) } /// Per-agent forge sync: grant read access to `core/meta`, and wire the /// `meta` remote in its proposed repo. All operations are idempotent; /// failures are logged as warnings but don't abort the caller. /// /// `core_token` is `core_token()` — passed in so callers that already /// fetched it don't re-read the file. Pass `None` to skip the /// `meta_read_access` step (safe: the access grant is best-effort). /// /// Called by both `ensure_all()` (startup sweep) and `rebuild_agent` /// (per-rebuild) so the two paths stay equivalent. /// Returns `true` if all steps succeeded, `false` if any step failed. The /// caller can use the return value to aggregate per-agent failures into a /// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and /// relies on the journal `warn!` lines alone (a rebuild is its own retry). pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { // The agent's forge account, token and `agent-configs/` repo are // swarm-controller's; this hive creates none of them and pushes nothing // to the repo. let mut ok = true; // Grant read-only access to core/meta and wire the `meta` remote // into the proposed repo so agents can fetch their deployment context. if let Some(token) = core_token && let Err(e) = meta_read_access(name, token).await { tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); ok = false; } if let Err(e) = ensure_meta_remote(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); ok = false; } ok } /// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the /// core avatar, and removing this hive's own config-PR hook — /// every step that needs an authenticated forge client. The swarm-wide /// objects (orgs, the `operators` team, mirrors, `internal/docs`, /// `internal/knowledge`, the `agent-configs` avatar) are the /// swarm-controller's, not this hive's. Split out purely to keep /// `ensure_all` under clippy's function-length limit; not meant to be called /// from anywhere else. async fn ensure_all_orgs_and_repos(token: &str) { // Meta repo lives at core/meta — pushed from git_commit in // meta.rs on every deploy/lock-update. Make sure it exists // before the first push hits a 404. if let Err(e) = ensure_repo("meta", token).await { tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); crate::warnings::set_boot_warning( "forge_ensure_meta_repo", "crit", format!("forge: core/meta repo provisioning failed: {e}"), ); } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); crate::warnings::set_boot_warning( "forge_ensure_core_avatar", "warn", format!("forge: core avatar upload failed: {e}"), ); } if let Err(e) = remove_config_pr_webhook(token).await { tracing::warn!(error = ?e, "forge: remove_config_pr_webhook failed"); } } /// Delete this hive's own `/webhook/config-pr` hook from the /// `agent-configs` org. No hive serves that route, so such a hook fails on /// every config-repo event; swarm-controller's hook is the one that acts on /// config PRs. /// /// Only a hook whose URL equals this hive's exactly is removed, never one /// matching by path: the same path on another base is another hive's hook. /// Idempotent, and a no-op when `HYPERHIVE_HIVE_DOMAIN` is unset. async fn remove_config_pr_webhook(core_token: &str) -> Result<()> { let Some(domain) = std::env::var("HYPERHIVE_HIVE_DOMAIN") .ok() .filter(|v| !v.is_empty()) else { return Ok(()); }; let own_url = config_pr_hook_url(&domain); let client = api(core_token)?; let hooks = bounded( &format!("list webhooks for org {CONFIG_ORG}"), client.org_list_hooks(CONFIG_ORG).send(), ) .await? .with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?; for id in hook_ids_at(&hooks, &own_url) { bounded( &format!("delete webhook {id} on org {CONFIG_ORG}"), client.org_delete_hook(CONFIG_ORG, id).send(), ) .await? .with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?; tracing::info!(%own_url, id, "forge: removed this hive's config-pr webhook"); } Ok(()) } /// The URL this hive's config-PR hook was registered under. fn config_pr_hook_url(hive_domain: &str) -> String { format!("https://{hive_domain}/webhook/config-pr") } /// Ids of the hooks whose URL is exactly `url`. fn hook_ids_at(hooks: &[forgejo_api::structs::Hook], url: &str) -> Vec { hooks .iter() .filter(|h| { h.config .as_ref() .and_then(|c| c.get("url")) .is_some_and(|u| u == url) }) .filter_map(|h| h.id) .collect() } /// Poll the local Forgejo API until it answers, with a timeout of 1 minute. /// The whole boot provisioning below hits the API, and [`is_present`] only /// confirms the container exists — not that Forgejo is *listening*. A /// `nixos-rebuild` that restarts hive-forge and hive-c0re together races: /// without this gate every `ensure_*` fires at a refused socket and leaves a /// stale "provisioning failed" warning that never clears (the pass is one-shot). /// Returns whether the endpoint became ready before the deadline; on timeout /// the caller proceeds anyway so a genuinely-down forge still surfaces its real /// errors. Uses the unauthenticated `get_version` endpoint (no token yet). async fn wait_until_ready() -> bool { const READY_TIMEOUT: Duration = Duration::from_mins(1); const POLL_INTERVAL: Duration = Duration::from_millis(500); let Ok(url) = Url::parse(forge_http_base()) else { tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait"); return false; }; let Ok(client) = Forgejo::new(Auth::None, url) else { tracing::warn!("forge: could not build readiness client; skipping readiness wait"); return false; }; let deadline = Instant::now() + READY_TIMEOUT; let mut waited = false; loop { if bounded("forge readiness probe", client.get_version()) .await .is_ok_and(|probe| probe.is_ok()) { if waited { tracing::info!("forge: endpoint ready, proceeding with provisioning"); } return true; } if Instant::now() >= deadline { tracing::warn!( "forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway" ); return false; } waited = true; tokio::time::sleep(POLL_INTERVAL).await; } } /// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own /// identity for pushing the meta repo + driving the API) and its token, the /// `core/meta` repo and the local knowledge clone, then run [`sync_agent`] /// over every existing container (manager + sub-agents). Agent accounts, /// tokens and `agent-configs/` repos are the swarm-controller's, as is /// the `agent-configs` org; this sweep pushes nothing to them. /// Called once at hive-c0re startup. Per-step failures are logged /// but don't abort the sweep. pub async fn ensure_all() { if !is_present().await { tracing::debug!("forge: hive-forge container absent, skipping user sweep"); return; } // The container exists, but its HTTP may not be up yet (a rebuild restarts // hive-forge + hive-c0re together). Wait for the API to answer before the // provisioning pass so a boot race doesn't leave stale failure warnings. wait_until_ready().await; let core_token = match ensure_core_user_and_token().await { Ok(t) => Some(t), Err(e) => { tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed"); crate::warnings::set_boot_warning( "forge_ensure_core_user", "crit", format!("forge: core user/token provisioning failed: {e}"), ); None } }; if let Some(token) = core_token.as_deref() { ensure_all_orgs_and_repos(token).await; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); crate::warnings::set_boot_warning( "forge_container_list", "crit", "forge: nixos-container list failed; per-agent forge sync skipped this boot", ); return; }; let mut sync_failed: Vec = Vec::new(); for c in containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; if !sync_agent(name, core_token.as_deref()).await { sync_failed.push(name.to_owned()); } } if !sync_failed.is_empty() { // Use static_kind to mint a `&'static str` key from the failed-agent // list (bounded leak: one per hive-c0re boot, not per request). let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_"))); crate::warnings::set_boot_warning( key, "warn", format!( "forge: per-agent sync failed for: {} (see journal for per-step detail)", sync_failed.join(", ") ), ); } } #[cfg(test)] mod tests { use super::{ API_TIMEOUT, bounded, config_pr_hook_url, describe_forge_admin, git_url_with_base, hook_ids_at, }; fn hook(id: i64, url: &str) -> forgejo_api::structs::Hook { serde_json::from_value(serde_json::json!({ "id": id, "config": { "url": url } })) .expect("hook json") } /// A forge that never answers surfaces as an error naming the operation, /// at exactly [`API_TIMEOUT`] — not as a hang. #[tokio::test(start_paused = true)] async fn a_request_the_forge_never_answers_errors_at_the_deadline() { let started = tokio::time::Instant::now(); let err = bounded( "pending request", std::future::pending::>(), ) .await .expect_err("a pending request must hit the deadline"); assert_eq!(started.elapsed(), API_TIMEOUT); assert!(err.to_string().contains("pending request"), "{err}"); } /// The request's own result passes through for the caller to classify. #[tokio::test(start_paused = true)] async fn an_answered_request_keeps_its_own_result() { let ok = bounded("op", async { Ok::<_, ()>(7) }) .await .expect("in time"); assert_eq!(ok, Ok(7)); let failed = bounded("op", async { Err::<(), _>("409") }) .await .expect("in time"); assert_eq!(failed, Err("409")); } /// The same path on another base is another hive's hook, and removing it /// would cut that hive off. Only an exact URL match is this hive's. #[test] fn only_this_hives_own_config_pr_hook_is_removed() { let own = config_pr_hook_url("hive.example"); let hooks = [ hook(1, &own), hook(2, "https://other-hive.example/webhook/config-pr"), hook(3, "http://127.0.0.1:7000/webhook/config-pr"), hook(4, "https://swarm.example/webhook/forge/config-pr"), ]; assert_eq!(hook_ids_at(&hooks, &own), vec![1]); assert!(hook_ids_at(&hooks[1..], &own).is_empty()); } /// The remote carries **no credential**. `argv` is world-readable through /// `/proc//cmdline`, so a token spliced in here would be published to /// every local user for the life of the git child. /// /// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` — /// setting that here would race every other test in this binary. #[test] fn forge_git_url_carries_no_credential() { let url = git_url_with_base("http://forge.example.test", "a/iris"); assert_eq!(url, "http://forge.example.test/a/iris.git"); assert!(!url.contains('@'), "no userinfo: {url}"); } #[test] fn forge_git_url_preserves_https() { // The scheme is carried through rather than assumed: a swarm // whose forge is behind TLS must not be downgraded to http. let url = git_url_with_base("https://forge.example.test", "a/iris"); assert!(url.starts_with("https://"), "https must survive: {url}"); } /// The credential goes in an `Authorization` header instead — decodable /// back to `core:`, so the swap is genuinely equivalent auth and /// not a silent downgrade to anonymous. #[test] fn core_auth_header_is_basic_core_token() { use base64::Engine as _; let header = super::core_auth_header("s3cret"); let b64 = header .strip_prefix("Authorization: Basic ") .expect("basic auth header"); let decoded = base64::engine::general_purpose::STANDARD .decode(b64) .expect("valid base64"); assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret"); assert!( !header.contains("s3cret"), "token not in the clear: {header}" ); } #[test] fn describe_keeps_the_verb_path_and_drops_every_value() { assert_eq!( describe_forge_admin(&[ "user", "create", "--username", "iris", "--email", "iris@hyperhive.local", "--random-password", ]), "forgejo admin user create" ); } #[test] fn describe_does_not_reproduce_a_password_argument() { let described = describe_forge_admin(&[ "user", "change-password", "--username", "iris", "--password", "correct-horse-battery-staple", ]); assert_eq!(described, "forgejo admin user change-password"); assert!(!described.contains("correct-horse-battery-staple")); } #[test] fn describe_survives_a_leading_flag_and_an_empty_vector() { assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin"); assert_eq!(describe_forge_admin(&[]), "forgejo admin"); } }