diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e2a868ae..85a9d90e 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -913,6 +913,98 @@ async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { } } +/// One operator-declared pull-mirror, forwarded from the nix +/// `services.hyperhive.forge.mirrors` option as JSON in +/// `HYPERHIVE_FORGE_MIRRORS`. +#[derive(serde::Deserialize)] +struct Mirror { + /// Upstream clone URL to mirror from (e.g. `https://github.com/actions/checkout`). + upstream: String, + /// Local `/` the mirror is created at. + dest: String, +} + +/// Ensure each `HYPERHIVE_FORGE_MIRRORS` entry exists as a real Forgejo +/// pull-mirror. The env carries the JSON-encoded nix `forge.mirrors` list +/// (plus the CI-auto `actions/checkout` entry). Absent/empty env = no-op. +/// Per-mirror failures warn and continue — never abort the startup sweep. +async fn ensure_mirrors(admin_token: &str) { + let raw = match std::env::var("HYPERHIVE_FORGE_MIRRORS") { + Ok(s) if !s.trim().is_empty() => s, + _ => return, + }; + let mirrors: Vec = match serde_json::from_str(&raw) { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = ?e, "forge: HYPERHIVE_FORGE_MIRRORS is not valid JSON; skipping mirror seed"); + return; + } + }; + for m in mirrors { + let Some((owner, repo)) = m.dest.split_once('/') else { + tracing::warn!(dest = %m.dest, "forge: mirror dest is not /; skipping"); + continue; + }; + // Create the dest org first (idempotent); the mirror can't land + // without its owner existing. + if let Err(e) = ensure_org(owner, admin_token).await { + tracing::warn!(%owner, error = ?e, "forge: ensure_org for mirror failed"); + continue; + } + if let Err(e) = ensure_mirror_repo(&m.upstream, owner, repo, admin_token).await { + tracing::warn!(dest = %m.dest, error = ?e, "forge: ensure_mirror_repo failed"); + } + } +} + +/// Create `owner/repo` as a pull-mirror of `upstream` via the migrate API. +/// Idempotent: a cheap existence check skips an already-seeded mirror (it +/// persists in the non-ephemeral forge state across reboots); a 409 (a race +/// between that check and the POST) is also treated as success. +async fn ensure_mirror_repo( + upstream: &str, + owner: &str, + repo: &str, + admin_token: &str, +) -> Result<()> { + let get_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}"); + let (status, _) = forge_http(reqwest::Method::GET, &get_url, admin_token, "").await?; + if status.is_success() { + tracing::debug!(%owner, %repo, "forge: pull-mirror already present"); + return Ok(()); + } + // serde_json::json! → the upstream URL is escaped safely (no string + // interpolation into the JSON body). + let body = serde_json::json!({ + "clone_addr": upstream, + "repo_owner": owner, + "repo_name": repo, + "mirror": true, + "service": "git", + "private": false, + }) + .to_string(); + let url = format!("{FORGE_HTTP}/api/v1/repos/migrate"); + let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?; + match status.as_u16() { + 201 => { + tracing::info!(%owner, %repo, %upstream, "forge: created pull-mirror"); + Ok(()) + } + // 409 = a race created it between our GET check and here (the GET + // is the real idempotency guard). NOT 422: for the migrate endpoint + // 422 is a validation error (bad clone_addr / service), so it must + // surface via the bail arm, not be swallowed as "already exists". + 409 => { + tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)"); + Ok(()) + } + other => { + anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {text}") + } + } +} + /// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated /// repo creation must never target — `internal` (operator-curated /// shared content) + `agent-configs` / `core` (hive-c0re-internal). The @@ -1141,6 +1233,11 @@ pub async fn ensure_all() { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); } } + // Seed the operator-declared pull-mirrors (nix `forge.mirrors` + + // the CI-auto `actions/checkout`, forwarded via the + // `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so + // this is independent of the SEEDED_ORGS loop above. + ensure_mirrors(token).await; // Provision the operator merge-gate team (empty) inside the agents // org so branch protection can reference it before anyone joins //. The operator adds herself as a member out-of-band. diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index cc99f7bc..c59e4d9a 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -34,6 +34,25 @@ let else "http://${cfg.domain}:${toString cfg.httpPort}/"; effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; + + # When CI is enabled, the runner needs `actions/checkout` resolvable + # without external DNS (hive-ci shares the host netns, so a host-resolver + # blip otherwise reds every `actions/checkout@vN` fetch from + # data.forgejo.org). Auto-append a pull-mirror of it and point + # forgejo's DEFAULT_ACTIONS_URL at this instance so `uses:` resolves local. + ciEnabled = config.services.hyperhive.forge.ci.enable; + actionCheckoutMirror = { + upstream = "https://github.com/actions/checkout"; + dest = "actions/checkout"; + }; + # Auto-append the actions/checkout mirror only when CI is on AND the + # operator hasn't already declared that dest themselves (else CI-on + + # an explicit `actions/checkout` entry would duplicate it). + effectiveMirrors = + cfg.mirrors + ++ lib.optional ( + ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors) + ) actionCheckoutMirror; in { # Private Forgejo in a `hive-forge` nixos-container, shared host @@ -177,6 +196,49 @@ in config before rebuilding. ''; }; + + mirrors = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + upstream = lib.mkOption { + type = lib.types.str; + example = "https://github.com/actions/checkout"; + description = "Upstream clone URL to mirror from."; + }; + dest = lib.mkOption { + type = lib.types.str; + example = "actions/checkout"; + description = '' + Local `/` the pull-mirror is created at. The + `` org is auto-created if missing. Keep mirror dests + in their own orgs (e.g. `actions/*`) — separate from the + hive-c0re-managed namespaces (config/shared/agents/core) so + the seed never collides with core's own provisioning. + ''; + }; + }; + } + ); + default = [ ]; + example = lib.literalExpression '' + [ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } ] + ''; + description = '' + General-purpose Forgejo **pull-mirrors** to auto-seed on the local + forge. Each entry is created as a real Forgejo pull-mirror (it + re-syncs from `upstream` out-of-band), not a one-off pushed clone — + so a host-resolver blip leaves a *stale* mirror, never a hard + failure on whatever reads it. + + When `services.hyperhive.forge.ci.enable` is set, an + `actions/checkout` mirror is auto-appended to this list and + forgejo's `DEFAULT_ACTIONS_URL` is pointed at this instance, so CI + `uses: actions/checkout@vN` steps resolve entirely on loopback with + no external DNS on the critical path (the seed/re-sync needs + external DNS, but that's off the CI path). + ''; + }; }; config = lib.mkIf config.services.hyperhive.enable { @@ -207,6 +269,35 @@ in hostname like "forge.example.com" or "git.internal". ''; } + { + # Each mirror dest must be exactly `/` — the seed + # splits on the single slash to create the org + repo. + assertion = lib.all (m: lib.length (lib.splitString "/" m.dest) == 2) effectiveMirrors; + message = '' + Every services.hyperhive.forge.mirrors[].dest must be exactly + "/" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors} + ''; + } + { + # Keep mirror orgs out of the hive-c0re-managed namespaces + # (config/shared/agents/core) so the seed never races / collides + # with hive-c0re's own startup provisioning of those orgs. + assertion = lib.all ( + m: + !(lib.elem (builtins.elemAt (lib.splitString "/" m.dest) 0) [ + "config" + "shared" + "agents" + "core" + ]) + ) effectiveMirrors; + message = '' + services.hyperhive.forge.mirrors[].dest must not place a mirror + in a hive-c0re-managed org (config / shared / agents / core) — + those are provisioned by hive-c0re and a mirror there would + collide. Use a dedicated org (e.g. "actions/checkout"). + ''; + } ]; containers.hive-forge = { @@ -302,6 +393,13 @@ in # of token scopes. Required by `hive-ci-register.service` # in the hive-ci container. actions.ENABLED = true; + # When CI is enabled, resolve `uses: /@vN` from + # THIS instance (the seeded `actions/checkout` pull-mirror) + # instead of the upstream default `data.forgejo.org` — keeps + # the checkout step on loopback, immune to a host-resolver + # blip. `self` = forgejo expands actions against its + # own ROOT_URL. + actions.DEFAULT_ACTIONS_URL = lib.mkIf ciEnabled "self"; # F3 (federation) computes its data dir relative to the # forgejo binary, which lands in the read-only nix # store and crashes anything that touches the F3 @@ -412,5 +510,13 @@ in cfg.sshPort ]; }; + + # Forward the declared pull-mirrors to hive-c0re, which seeds them in + # its forge provisioning sweep (`forge.rs::ensure_mirrors`, alongside + # the SEEDED_ORGS ensure). c0re already holds the core admin token and + # ensures the orgs there, so the seeding lives in one place rather than + # a parallel host-side unit. JSON-encoded list of { upstream, dest }; + # `[]` when nothing to seed (c0re no-ops). + systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS = builtins.toJSON effectiveMirrors; }; }