Watch
0
0
Fork
You've already forked hyperhive
0

hive reads the forge for nix as its own account, not core

hive-c0re copies the hive's store read token to /run/hyperhive/forge-hive-token
for nix's git credential helper, and no longer mints, reads or falls back to
the core admin token. drops the core user sweep, avatar upload, webhook
cleanup and hive-priv's RunForgeAdmin.
This commit is contained in:
atlas 2026-10-25 17:56:14 +01:00
commit b79896c237

View file

@ -1,7 +1,6 @@
//! Optional Forgejo wiring — the `core` admin user and token and its avatar.
//! 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
//! The hive's forge access: reads of the forge go through [`reader_token`] on
//! every hive, and [`ensure_all`] publishes that token for nix's `git`. 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`.
@ -9,24 +8,20 @@ mod internal_meta;
mod reader;
mod reconcile;
mod repos;
mod users;
pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token;
pub use reader::{publish_nix_credential, reader_token};
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
pub use users::core_token;
use std::future::IntoFuture;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use std::time::Duration;
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`
@ -55,18 +50,14 @@ pub(crate) fn forge_http_base() -> &'static str {
}
/// 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.
/// set and a valid URL. 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(())
}
@ -74,7 +65,7 @@ pub(crate) fn validate_env() -> Result<()> {
///
/// The token does not go here. A URL is a process argument, and `argv` is
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
/// lives, so a credentialed remote publishes the core admin token to every
/// lives, so a credentialed remote publishes the forge token to every
/// local user on the host. Credentials travel in the environment instead, via
/// [`basic_auth_header`] and [`crate::lifecycle::git_command_authed`] —
/// `/proc/<pid>/environ` is owner-only.
@ -101,8 +92,7 @@ fn basic_auth_header(login: &str, token: &str) -> String {
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
/// Forgejo org grouping every agent's config repo. Each agent is a **write collaborator
/// on its own** `agent-configs/<name>` 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,
@ -122,52 +112,10 @@ pub async fn is_present() -> bool {
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 <args>` 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<String> {
// 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/<pid>/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
/// authenticated as `token`. All Forgejo API calls go through clients built
/// here — one place for the base URL and auth. 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<Forgejo> {
@ -197,169 +145,19 @@ where
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
}
/// The `core_token.is_some()` half of [`ensure_all`]: 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) {
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<i64> {
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 driving the API, and the one nix's git reads as) and its token,
/// then its avatar. Agent accounts,
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is
/// the `agent-configs` org; this sweep touches none of them.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
/// Publish this hive's forge read token for nix's `git` at startup, so a build
/// that starts before any `internal/meta` read still has a credential. Every
/// hive runs it: the token is the hive's own, in the store, whether or not a
/// forge container is local.
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;
if publish_nix_credential().await {
tracing::debug!("forge: nix's forge credential is current");
}
}
#[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")
}
use super::{API_TIMEOUT, bounded, git_url_with_base};
/// A forge that never answers surfaces as an error naming the operation,
/// at exactly [`API_TIMEOUT`] — not as a hang.
@ -389,21 +187,6 @@ mod tests {
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/<pid>/cmdline`, so a token spliced in here would be published to
/// every local user for the life of the git child.
@ -426,58 +209,22 @@ mod tests {
}
/// The credential goes in an `Authorization` header instead — decodable
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// back to `<login>:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous.
#[test]
fn basic_auth_header_is_basic_core_token() {
fn basic_auth_header_is_basic_login_token() {
use base64::Engine as _;
let header = super::basic_auth_header("core", "s3cret");
let header = super::basic_auth_header("hive-alpha", "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_eq!(String::from_utf8_lossy(&decoded), "hive-alpha: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");
}
}