//! 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 url::Url; 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.deploy.forgejo.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, AND pointed at the forge // address we are configured for → nothing to do. if let Some(id) = existing_runner_id() && runner_address_current() && 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"); crate::warnings::set_boot_warning( "ci_runner_register", "crit", format!("ci runner: hive-priv register_ci_runner failed: {e}"), ); } 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"); crate::warnings::set_boot_warning( "ci_runner_fetch_token", "crit", format!("ci runner: fetch registration token failed: {e}"), ); } } } /// 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) } /// Whether the persisted `.runner` still names the forge host we are /// configured for. /// /// `act_runner` records the `--instance` URL it was registered with and the /// daemon reads the address from there and nowhere else — the nix option only /// reaches `register`, and upstream re-registers on a changed token or labels, /// never on a changed URL. So a forge rename leaves a runner dialling a name /// its container's `extraHosts` no longer resolves, with every existing check /// still green: the runner id is untouched, so `runner_valid` says yes. /// /// Fail-safe in the same direction as [`runner_valid`]: anything we cannot /// read or parse returns `true` (keep the existing creds). Only a *positive* /// disagreement between two parseable hosts is treated as stale, because the /// cost of a false stale is re-registering on every boot. fn runner_address_current() -> bool { let Ok(raw) = std::fs::read_to_string(RUNNER_FILE) else { return true; }; runner_address_matches(&raw, forge_http_base()) } /// Host-only comparison of `.runner`'s `address` against the configured base. /// /// Split from [`runner_address_current`] so it is testable without the /// filesystem. Compares **hosts**, not whole URLs: both strings come from the /// same nix expression (`http://${swarm.forge.domain}`, reaching the runner as /// `instances.hive.url` and this daemon as `HIVE_FORGE_URL`), but scheme, /// port and trailing slash are exactly the kind of cosmetic difference that /// would otherwise re-register the runner on every boot. fn runner_address_matches(raw: &str, configured: &str) -> bool { let Some(address) = serde_json::from_str::(raw) .ok() .and_then(|j| j.get("address")?.as_str().map(str::to_owned)) else { return true; }; match (Url::parse(&address), Url::parse(configured)) { (Ok(a), Ok(b)) => match (a.host_str(), b.host_str()) { (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), _ => true, }, _ => true, } } /// `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") } #[cfg(test)] mod tests { use super::runner_address_matches; fn runner(address: &str) -> String { format!(r#"{{"id":7,"uuid":"u","name":"hive","token":"t","address":"{address}"}}"#) } /// The case the bug is about: a forge rename leaves the old host behind. #[test] fn differing_host_is_stale() { assert!(!runner_address_matches( &runner("http://forge.pr1ma.darkest.space"), "http://forge.constellation.darkest.space", )); } /// Control for the arm above — without it, a helper that always returned /// `false` would pass `differing_host_is_stale` and re-register forever. #[test] fn same_host_is_current() { assert!(runner_address_matches( &runner("http://forge.example"), "http://forge.example", )); } /// Cosmetic differences must NOT count: both sides are rendered from one /// nix expression, but a port or trailing slash appearing on one side is /// not a rename. #[test] fn port_scheme_and_trailing_slash_do_not_count() { assert!(runner_address_matches( &runner("http://forge.example:80/"), "https://FORGE.example", )); } /// Fail-safe: anything unreadable keeps the existing credentials, so a /// malformed or older `.runner` never costs a working runner. #[test] fn unparseable_input_keeps_creds() { assert!(runner_address_matches("not json", "http://forge.example")); assert!(runner_address_matches( r#"{"id":7}"#, "http://forge.example" )); assert!(runner_address_matches( &runner("://nonsense"), "http://forge.example", )); } }