Last of the swarm.* -> deploy.* moves for the enable-shaped options. Whether this host also runs the CI runner is a per-machine decision, so it was sitting in the namespace that has to be identical swarm-wide. Renames all five options, not just enable: name, concurrency, labels and package all describe the runner THIS host would run, and leaving them behind would keep the original defect for four more options. One rename entry each, because ci is a plain attrset rather than a submodule type, so there is no parent path to rename in one go. deploy.forgejo is the name deploy.nix's own header already specifies, including this exact case -- "ci (does this host run the runner too) is exactly that shape". It is the only entry with no enable: the forge is not optional, it deploys with hyperhive itself, so running the runner is the only deployment decision it has. Also fixes hive-c0re/src/forge/ci_runner.rs, whose doc comment named services.hyperhive.forge.ci.enable -- missing the swarm. segment, so it had never resolved to a real option.
230 lines
9.2 KiB
Rust
230 lines
9.2 KiB
Rust
//! 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<u64> {
|
|
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::<Value>(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 <admin>`.
|
|
async fn fetch_registration_token(core_token: &str) -> Result<String> {
|
|
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",
|
|
));
|
|
}
|
|
}
|