diff --git a/hive-c0re/src/forge/ci_runner.rs b/hive-c0re/src/forge/ci_runner.rs new file mode 100644 index 00000000..a58f38de --- /dev/null +++ b/hive-c0re/src/forge/ci_runner.rs @@ -0,0 +1,115 @@ +//! hive-ci Forgejo Actions runner registration, driven from hive-c0re. +//! +//! Moves the runner-registration forge round-trip OFF the container's +//! boot-critical path (it was a host-side `hive-ci-prefetch` oneshot that +//! gated `container@hive-ci` start — see `nix/host-modules/hive-ci.nix`). +//! hive-c0re holds the forge admin token; here it validates the runner's +//! existing credentials and, when they're absent or stale, mints a fresh +//! registration token and hands it to hive-priv, which writes it to the host +//! env-file the container bind-mounts read-only and restarts the in-container +//! runner. The admin token never enters the container — only the registration +//! token does, exactly as the prefetch did. + +use anyhow::{Context as _, Result}; +use serde_json::Value; + +use super::forge_http_base; + +/// Host path to the hive-ci runner's persisted credentials. The container is +/// non-ephemeral, so `.runner` survives restarts; a present file with a sane +/// `id` means the runner is already registered. +const RUNNER_FILE: &str = "/var/lib/nixos-containers/hive-ci/var/lib/gitea-runner/hive/.runner"; + +/// Whether the operator enabled the CI runner. The nix module sets +/// `HYPERHIVE_FORGE_CI_ENABLED=1` on `hive-c0re.service` when +/// `services.hyperhive.forge.ci.enable` is on; absent means CI is off and +/// there is no hive-ci container to register a runner for. +fn ci_enabled() -> bool { + std::env::var("HYPERHIVE_FORGE_CI_ENABLED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +/// Ensure the hive-ci runner is registered against the forge. Best-effort: +/// every failure is logged and swallowed so it never aborts the startup +/// sweep ([`super::ensure_all`]). No-op when CI is disabled or the runner +/// already holds valid credentials (so a healthy runner is never restarted). +pub(super) async fn ensure_ci_runner_registered(core_token: &str) { + if !ci_enabled() { + return; + } + // Existing creds still valid on the forge → nothing to do. + if let Some(id) = existing_runner_id() + && runner_valid(core_token, id).await + { + return; + } + // Absent or stale → mint a fresh registration token and hand it to + // hive-priv (root) to write the env-file + restart the runner. + match fetch_registration_token(core_token).await { + Ok(token) => { + if let Err(e) = crate::priv_client::register_ci_runner(&token).await { + tracing::warn!(error = ?e, "ci runner: hive-priv register_ci_runner failed"); + } else { + tracing::info!("ci runner: registered hive-ci runner with a fresh token"); + } + } + Err(e) => tracing::warn!(error = ?e, "ci runner: fetch registration token failed"), + } +} + +/// Parse the runner id from the persisted `.runner` JSON, if present and sane +/// (a `0` id is the gitea-runner "unregistered" sentinel). +fn existing_runner_id() -> Option { + let raw = std::fs::read_to_string(RUNNER_FILE).ok()?; + let json: Value = serde_json::from_str(&raw).ok()?; + let id = json.get("id")?.as_u64()?; + (id != 0).then_some(id) +} + +/// `GET /admin/runners/{id}` — `true` iff the runner still exists on the forge +/// (HTTP 200). A 404 (deleted from the admin panel) or any other status means +/// re-registration is needed. A transport error (forge unreachable) is treated +/// as "keep the existing creds" so a network blip never wipes a valid runner. +async fn runner_valid(core_token: &str, id: u64) -> bool { + let url = format!("{}/api/v1/admin/runners/{id}", forge_http_base()); + match reqwest::Client::new() + .get(&url) + .header("Authorization", format!("token {core_token}")) + .send() + .await + { + Ok(resp) => resp.status().is_success(), + Err(e) => { + tracing::warn!(error = ?e, "ci runner: validation request failed; keeping existing creds"); + true + } + } +} + +/// `GET /admin/runners/registration-token` — mint a fresh registration token. +/// forgejo-api (0.11) doesn't wrap this endpoint, so this is a raw request +/// against the local HTTP forge ([`forge_http_base`]), mirroring the prefetch's +/// former curl; Forgejo accepts `Authorization: token `. +async fn fetch_registration_token(core_token: &str) -> Result { + let url = format!( + "{}/api/v1/admin/runners/registration-token", + forge_http_base() + ); + let resp = reqwest::Client::new() + .get(&url) + .header("Authorization", format!("token {core_token}")) + .send() + .await + .context("GET admin/runners/registration-token")?; + let status = resp.status(); + let json: Value = resp + .json() + .await + .context("parse registration-token response")?; + if !status.is_success() { + anyhow::bail!("registration-token HTTP {status}: {json}"); + } + json.get("token") + .and_then(Value::as_str) + .map(str::to_owned) + .context("registration-token response missing 'token' field") +} diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index b54a661a..f18fca49 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -4,6 +4,7 @@ //! collaborator access to for operator-curated shared content. //! No-op when `hive-forge` isn't running. Full design: `docs/forge.md`. +mod ci_runner; pub mod config_pr_poll; mod pr_merge; mod repos; @@ -266,6 +267,9 @@ pub async fn ensure_all() { if let Err(e) = ensure_config_org_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed"); } + // Register the hive-ci Actions runner (off the container's boot path; + // no-op when CI is disabled or the runner already holds valid creds). + ci_runner::ensure_ci_runner_registered(token).await; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep");