hive-sock-client: each attempt now bounds connect (5s), write (10s) and the wait for the response (60s by default). The response bound is per call through the new `request_within`, which hive-agent's serve-loop `Recv` uses with its 180s long-poll plus 30s headroom. A response timeout is terminal rather than retried: the server holds the request, so a retry re-sends something it may still act on and multiplies the wait by the backoff schedule. Outbound HTTP: the matrix login/whoami clients in swarm-controller and hive-c0re's dashboard (5s connect, 30s request), the authelia-bridge client (5s/30s; ensuring an identity runs an argon2 hash first) and the ci-runner forge calls (5s/15s, config_pr_poll's forge budget) get a connect_timeout and a request timeout. Timeout errors name the bound that fired. hive-agent's unix-socket extra web proxy bounds the connect (5s) and the wait for the response head (30s, the http sibling's budget); the body read stays unbounded. Refs #4723
267 lines
11 KiB
Rust
267 lines
11 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,
|
|
}
|
|
}
|
|
|
|
/// Bound on reaching the forge.
|
|
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
/// Bound on one whole forge admin call, body included; the same budget
|
|
/// `config_pr_poll` gives this forge.
|
|
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
|
|
|
/// A client with both forge bounds applied.
|
|
fn http_client() -> Result<reqwest::Client> {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
|
.timeout(HTTP_TIMEOUT)
|
|
.build()
|
|
.context("build HTTP client")
|
|
}
|
|
|
|
/// Context for `what` failing with `e`; a timeout names the bound that fired.
|
|
fn http_context(what: &str, e: &reqwest::Error) -> String {
|
|
if e.is_connect() && e.is_timeout() {
|
|
format!("{what}: connect timed out after {HTTP_CONNECT_TIMEOUT:?}")
|
|
} else if e.is_timeout() {
|
|
format!("{what}: timed out after {HTTP_TIMEOUT:?}")
|
|
} else {
|
|
what.to_owned()
|
|
}
|
|
}
|
|
|
|
/// `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());
|
|
let client = match http_client() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "ci runner: build HTTP client failed; keeping existing creds");
|
|
return true;
|
|
}
|
|
};
|
|
match client
|
|
.get(&url)
|
|
.header("Authorization", format!("token {core_token}"))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) => resp.status().is_success(),
|
|
Err(e) => {
|
|
let what = http_context("validation request failed", &e);
|
|
tracing::warn!(error = ?e, "ci runner: {what}; 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 = http_client()?
|
|
.get(&url)
|
|
.header("Authorization", format!("token {core_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
let what = http_context("GET admin/runners/registration-token", &e);
|
|
anyhow::Error::new(e).context(what)
|
|
})?;
|
|
let status = resp.status();
|
|
let json: Value = resp.json().await.map_err(|e| {
|
|
let what = http_context("parse registration-token response", &e);
|
|
anyhow::Error::new(e).context(what)
|
|
})?;
|
|
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",
|
|
));
|
|
}
|
|
}
|