diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index 80974157..dd1399fd 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -46,6 +46,82 @@ let dest = "actions/checkout"; }; effectiveMirrors = cfg.mirrors ++ lib.optional ciEnabled actionCheckoutMirror; + + # Host-side core admin token hive-c0re mints after provisioning the forge + # admin (same file hive-ci-prefetch reads). Root-only; never enters a + # container — so the mirror seed runs host-side, exactly like + # hive-ci-prefetch, rather than minting a second token in-container. + coreTokenPath = "/var/lib/hyperhive/forge-core-token"; + + # Idempotently create each `effectiveMirrors` entry as a real Forgejo + # pull-mirror via the migrate API. Host-side: only talks to the forge on + # loopback (forgejo itself does the upstream clone, so the upstream-DNS + # dependency lives in the container + is off the CI critical path). + # Modelled on hive-ci-prefetch's wait-for-core-token loop. + seedMirrorsScript = pkgs.writeShellScript "forgejo-seed-mirrors" '' + set -uo pipefail + FORGE_URL="http://127.0.0.1:${toString cfg.httpPort}" + + CORE_TOKEN="" + for i in $(seq 1 60); do + if [ -f "${coreTokenPath}" ]; then CORE_TOKEN=$(cat "${coreTokenPath}"); break; fi + echo "forgejo-seed-mirrors: waiting for core token ($i/60)..." >&2 + sleep 1 + done + if [ -z "''${CORE_TOKEN:-}" ]; then + echo "forgejo-seed-mirrors: core token absent after 60s — cannot seed mirrors" >&2 + exit 1 + fi + AUTH="Authorization: token $CORE_TOKEN" + rc=0 + + seed_one() { + upstream="$1"; owner="$2"; repo="$3" + # Ensure the dest org (idempotent: 201 created / 422 already exists). + ohttp=$(${pkgs.curl}/bin/curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "$AUTH" -H 'Content-Type: application/json' \ + "$FORGE_URL/api/v1/orgs" -d "{\"username\":\"$owner\"}" || echo 000) + case "$ohttp" in + 201 | 422) ;; + *) echo "forgejo-seed-mirrors: ensure org '$owner' returned HTTP $ohttp" >&2 ;; + esac + # Skip if the repo already exists (the mirror persists across reboots + # in the non-ephemeral forge state, so this no-ops on every reboot + # after the first). + rhttp=$(${pkgs.curl}/bin/curl -s -o /dev/null -w '%{http_code}' \ + -H "$AUTH" "$FORGE_URL/api/v1/repos/$owner/$repo" || echo 000) + if [ "$rhttp" = 200 ]; then + echo "forgejo-seed-mirrors: $owner/$repo already present — skipping" >&2 + return 0 + fi + # Create the pull-mirror. service=git → generic git clone of + # clone_addr (no upstream API token needed); mirror=true → forgejo + # keeps it re-syncing on its mirror interval. + resp=$(${pkgs.curl}/bin/curl -s -w $'\n%{http_code}' -X POST \ + -H "$AUTH" -H 'Content-Type: application/json' \ + "$FORGE_URL/api/v1/repos/migrate" \ + -d "{\"clone_addr\":\"$upstream\",\"repo_owner\":\"$owner\",\"repo_name\":\"$repo\",\"mirror\":true,\"service\":\"git\",\"private\":false}" \ + || printf '\n000') + mhttp=$(printf '%s' "$resp" | tail -n1) + case "$mhttp" in + 2*) echo "forgejo-seed-mirrors: created pull-mirror $owner/$repo from $upstream" >&2 ;; + *) + echo "forgejo-seed-mirrors: migrate $owner/$repo failed HTTP $mhttp: $(printf '%s' "$resp" | sed '$d')" >&2 + rc=1 + ;; + esac + } + + ${lib.concatMapStringsSep "\n" ( + m: + let + parts = lib.splitString "/" m.dest; + in + "seed_one ${lib.escapeShellArg m.upstream} ${lib.escapeShellArg (builtins.elemAt parts 0)} ${lib.escapeShellArg (builtins.elemAt parts 1)}" + ) effectiveMirrors} + + exit $rc + ''; in { # Private Forgejo in a `hive-forge` nixos-container, shared host @@ -262,6 +338,37 @@ 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 = { @@ -474,5 +581,27 @@ in cfg.sshPort ]; }; + + # Seed the configured pull-mirrors once the forge + core token are up. + # Host-side (the core admin token never enters a container) and modelled + # on hive-ci-prefetch. partOf the forge container so it re-runs (and + # re-ensures, idempotently) on every forge (re)start. Only present when + # there's something to seed. + systemd.services.forgejo-seed-mirrors = lib.mkIf (effectiveMirrors != [ ]) { + description = "Seed Forgejo pull-mirrors (host-side)"; + after = [ + "hive-c0re.service" + "container@hive-forge.service" + ]; + wants = [ "container@hive-forge.service" ]; + wantedBy = [ "multi-user.target" ]; + partOf = [ "container@hive-forge.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = seedMirrorsScript; + SyslogIdentifier = "forgejo-seed-mirrors"; + }; + }; }; }