diff --git a/.forgejo/workflows/coverage.yml b/.forgejo/workflows/coverage.yml index 2d38e361..2b61ecd0 100644 --- a/.forgejo/workflows/coverage.yml +++ b/.forgejo/workflows/coverage.yml @@ -4,7 +4,7 @@ name: coverage # than printed into the run log, so a scheduled run leaves something to fetch. # # ⚠️ The hour is deliberate: at the default job capacity of 1 -# (`services.hyperhive.deploy.forgejo.ci.concurrency`) this job holds the +# (`services.hyperhive.deploy.ci-runner.concurrency`) this job holds the # runner for its whole timeout and everything else queues behind it. # # Separate from ci.yml because a `workflow_dispatch` trigger there fires every diff --git a/.forgejo/workflows/flake-update.yml b/.forgejo/workflows/flake-update.yml index 6d366f4a..d67bd5f6 100644 --- a/.forgejo/workflows/flake-update.yml +++ b/.forgejo/workflows/flake-update.yml @@ -11,7 +11,7 @@ name: flake update # apply. Stale PRs just stack; newest lock wins. # # `PR_BOT_TOKEN` (repo secret) is the `flake-bot` account's write token. -# Self-signed forge cert: not worked around with `curl -k` - hive-ci.nix +# Self-signed forge cert: not worked around with `curl -k` - swarm-ci-runner.nix # already sets SSL_CERT_FILE to a bundle with the hive CA (wiring # confirmed; anchor coverage for the swarm forge specifically isn't). on: diff --git a/README.md b/README.md index e8a8cded..8f4a319d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ the swarm — one of each, each on whichever host you choose ├── swarm-grafana dashboards over both stores │ └── opt-in, not part of singleHostSwarm - ├── hive-ci Forgejo Actions runner (deploy.forgejo.ci) + ├── swarm-ci-runner Forgejo Actions runner (deploy.ci-runner) ├── swarm-snapshot-store btrfs-receive endpoint for agent snapshots └── wg-hive WireGuard mesh between hives (deploy.wireguard) diff --git a/docs/README.md b/docs/README.md index dd8217e4..e39e8b79 100644 --- a/docs/README.md +++ b/docs/README.md @@ -91,7 +91,7 @@ declarations. [`scheduler/jobq.md`](scheduler/jobq.md) — operator-facing, no implementation detail. - **How does the rebuild queue work? What are the concrete step kinds, queue sources, scheduler internals?** → [`scheduler/coordinator.md`](scheduler/coordinator.md). -- **How does the CI runner work? What's the autoregistration flow?** → +- **How does the CI runner work? How does it register?** → [`scheduler/ci.md`](scheduler/ci.md). - **How do I export Claude Code metrics (tokens, cost, tool calls) to Prometheus/Grafana?** → [`scheduler/observability.md`](scheduler/observability.md). diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 143f8f37..9cf22b40 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -31,12 +31,14 @@ by a test). swarm-controller mints every agent token with it: | `read:notification` | Poll `GET /notifications` for unread events. | | `write:notification` | Mark notifications read via `PATCH /notifications/threads/{id}`. | -**`CORE_TOKEN_SCOPES`** (`hive-c0re::forge`, for its own `core` user): -everything in `AGENT_TOKEN_SCOPES` plus `read:admin` and `write:admin`. -Site-admin membership alone isn't sufficient — Forgejo's token scope gate runs -before the user-permission check, so `/api/v1/admin/*` returns -`403 Forbidden` for any token without the admin scope bits, even when -the bearer is a site admin. +**`CORE_TOKEN_SCOPES`** (`hive-c0re::forge`, for its own `core` user): the +same set as `AGENT_TOKEN_SCOPES`, with no admin scope. Forgejo's token scope +gate runs before the user-permission check, so this token gets +`403 Forbidden` on `/api/v1/admin/*` even though `core` is a site admin. +hive-c0re only mints the token when the file at +`/var/lib/hyperhive/forge-core-token` is missing or the forge rejects it, so +a token minted with admin scopes keeps them until an operator deletes that +file and restarts hive-c0re. --- diff --git a/docs/networking/network.md b/docs/networking/network.md index d399fe85..194a5c44 100644 --- a/docs/networking/network.md +++ b/docs/networking/network.md @@ -34,21 +34,21 @@ that touches it. │ hive-br0 10.42.0.1/24 │ │ ┌──────────┼──────────────┐ │ └──────────────┼──────────┼──────────────┼───────────────────────────┘ - vb-h- vb-h- vb-hive-ci veth pairs + vb-h- vb-h- vb- veth pairs │ │ │ ┌────┴────┐ ┌───┴─────┐ ┌──────┴──┐ one private netns - │ agent a │ │ agent b │ │ hive-ci │ each; eth0 leases + │ agent a │ │ agent b │ │ runner │ each; eth0 leases │ eth0 │ │ eth0 │ │ eth0 │ from the DHCP pool └─────────┘ └─────────┘ └─────────┘ ``` -| container | netns | IPv4 | listens / reached via | -| -------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- | -| gateway (host) | host | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge | -| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.` vhost | -| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost | -| `hive-ci` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface | -| `h-` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/` → nginx sub-path; in-container UI port hashed 8100–8999 | +| container | netns | IPv4 | listens / reached via | +| ----------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- | +| gateway (host) | host | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge | +| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.` vhost | +| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost | +| `swarm-ci-runner` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface | +| `h-` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/` → nginx sub-path; in-container UI port hashed 8100–8999 | The flows, end to end: @@ -172,7 +172,7 @@ boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them from the container's (empty) port list, and deletes the host's `nixos-nat-*` chains without recreating them, silently wiping the bridge holes above plus the agents' NAT. Private-netns containers -(agents, hive-ci) may keep their own firewall — it's scoped to their +(agents, swarm-ci-runner) may keep their own firewall — it's scoped to their namespace. ### Reaching host services (`exposeHostPorts`) diff --git a/docs/scheduler/ci.md b/docs/scheduler/ci.md index 56dd7e1b..50df5869 100644 --- a/docs/scheduler/ci.md +++ b/docs/scheduler/ci.md @@ -1,13 +1,21 @@ -# hive-ci: Forgejo Actions Runner +# swarm-ci-runner: Forgejo Actions Runner -The `hive-ci` module runs a Forgejo Actions runner in a `hive-ci` nixos-container, -executing CI jobs from `.forgejo/workflows/ci.yml` on every PR. +The `swarm-ci-runner` module runs the swarm's Forgejo Actions runner in a +`swarm-ci-runner` nixos-container, executing CI jobs from +`.forgejo/workflows/ci.yml` on every PR. It's a swarm service: it runs on +whichever host sets it, and needs neither the forge nor a hive on that host. ## For operators -**Enabling it's one line**: `services.hyperhive.deploy.forgejo.ci.enable = true` -in the host NixOS config. No manual token provisioning — hive-c0re -registers the runner with the forge automatically. +**Enabling it's one line**: `services.hyperhive.deploy.ci-runner.enable = true` +in the NixOS config of the host that should run it. No manual token +provisioning: swarm-controller publishes the forge's runner registration token +to the secret store, and the runner reads it with a store identity of its own. +On the store's host `glue-bao-tls.nix` mints that identity; on any other host, copy +`ci-runner.pem` and `ci-runner-key.pem` from the store host's +`/var/lib/swarm-bao-pki/` and name them in +`services.hyperhive.deploy.bao.ciRunnerClientCertFile` and +`ciRunnerClientKeyFile`. Two things worth knowing before you rely on it: @@ -25,9 +33,8 @@ Two things worth knowing before you rely on it: disk-pressure GC config from _Host store maintenance_ below to your host's NixOS config (not optional if you plan to actually use this). -Everything below this point is implementation detail: exact -autoregistration internals, container design, and the full security -writeup. +Everything below this point is implementation detail: registration +internals, container design, and the full security writeup. ## CI checks @@ -105,62 +112,68 @@ slow); run those manually before pushing Rust changes. ## Configuration reference -The runner registers against the forge on its own host, so enable it on the -swarm's forge host (`services.hyperhive.deploy.forgejo.enable`, see -[`../swarm/services.md`](../swarm/services.md)). Evaluation fails on any other -host. There, nothing extra to enable beyond -`services.hyperhive.deploy.forgejo.ci.enable = true` (see _For operators_ above). +`services.hyperhive.deploy.ci-runner.enable = true` on the host that runs it +(see _For operators_ above). The runner reaches the forge at +`https://`, so that name has to resolve from the host: the +gateway's dnsmasq answers it locally where the forge runs, and forwards it to +the operator's DNS everywhere else. -Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge -admin panel), `concurrency` (parallel job capacity), `labels` (workflow -targeting), `jobTimeout` (per-job wall-clock cap, default `"1h"`, Go duration -string for example `"3h"` — a job that exceeds it's killed so a hung or runaway -build can't hold the runner's single slot indefinitely). +Optional tuning under `services.hyperhive.deploy.ci-runner`: `name` (runner +name in the forge admin panel), `concurrency` (parallel job capacity), `labels` +(workflow targeting; the default `hive-ci:host` is what this repo's workflows +name), `jobTimeout` (per-job wall-clock cap, default `"1h"`, Go duration string +for example `"3h"` — a job that exceeds it's killed so a hung or runaway build +can't hold the runner's single slot indefinitely). ## Container design -- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://` (resolved to the bridge IP via `networking.extraHosts`). It can't reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. -- **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter). +- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`), takes its address from the gateway's DHCP, and resolves through the gateway's dnsmasq on the bridge IP. It can't reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. +- **Non-ephemeral**: runner credentials (`.runner`) persist across restarts, written to the container's state dir on registration and reused after. - **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`. -- **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only. +- **Credential isolation**: no forge admin credential enters the container. swarm-controller holds the forge admin token; the runner holds only the registration token and its own `.runner`, and the host unit that fetches the token holds a store identity that reads that one path. -## Autoregistration flow +## Registration -Registration is **off the container's boot-critical path** — hive-c0re owns -it and runs it out of band, so a slow forge or core-token never delays the -container's start. Gotcha: don't gate the container's own start on a forge -round-trip (a host-side unit that did this could exceed the nspawn start -timeout and trip a restart loop) — registration must stay something -hive-c0re drives after the container is already up. hive-c0re holds the -core admin token only on the host; only the runner registration token -reaches the container. +1. **swarm-controller** (`swarm-controller/src/forge/runner_token.rs`) reads + the forge's instance-wide token from + `GET /api/v1/admin/runners/registration-token` at start and every five + minutes, and writes it to the secret store at + `swarm/services/ci-runner/registration-token` when it differs from the + value it last wrote. Its store grant on that path is write-only. +2. **`swarm-bao-ci-runner.service`** on the runner's host logs in to the store + with the `ci-runner` leaf, whose `swarm-ci-runner` role reads that one path + and nothing else, retrying every 30 seconds until the token is there. It + writes `TOKEN=` to the runner's `EnvironmentFile` inside the + container, and restarts the runner when the value changes. It runs at boot + and on deploy, not on a timer. +3. **The runner unit** (`gitea-runner-swarm` in the container) registers in + the upstream pre-start step whenever `.runner` is missing or the token or + labels changed. A pre-start step of this module's, ahead of it, removes + `.runner` in two cases upstream doesn't check: + - The forge has no record of the runner. Forgejo answers every call from an + unknown runner with `unregistered runner`, and the daemon exits 1 when its + startup `Declare` gets that answer. On the next start the step finds the + message in the daemon's journal since its last start, and registers again. + A daemon that's already running when an admin deletes its runner keeps polling + and logging the error without exiting; the next restart of the unit or + container registers it again. + - `.runner` names another forge address than the configured one. -### hive-c0re side (`forge/ci_runner.rs`, run during the startup sweep) + Until a token arrives the step fails, and `Restart=on-failure` retries every + 15 seconds with no start limit. -Gated on `HYPERHIVE_FORGE_CI_ENABLED` (the nix module sets it on `hive-c0re.service` when `deploy.forgejo.ci.enable`). Best-effort — hive-c0re logs failures and never aborts the sweep; a healthy runner is never restarted. - -1. If `.runner` exists at `/var/lib/nixos-containers/hive-ci/var/lib/gitea-runner/hive/.runner`, validate its id against `GET /api/v1/admin/runners/{id}` with the core admin token: - - **200**: still registered — done, no restart. - - **404 / other non-200 / malformed**: stale — re-register (below). - - **transport error** (forge unreachable): keep the existing creds; a network blip must not wipe a valid runner. -2. If absent or stale: mint a fresh token from `GET /api/v1/admin/runners/registration-token`, then hand it to hive-priv's `RegisterCiRunner`, which (as root) writes `TOKEN=` **in place** to the host env-file `/run/hive-ci/runner-token` (preserving the inode nspawn pinned into the container at start) and restarts `gitea-runner-hive.service` inside the container so it picks up the credential and registers. - -### Container side - -- The container boots immediately — nothing gates its start on registration. -- tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists. -- `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it's already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net). -- **Convergence**: because the token write targets the _host_ file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own. +A new registration is a new runner on the forge: the previous entry stays in +the forge admin panel as offline until an admin deletes it. ## Actions checkout mirror -When `deploy.forgejo.ci.enable` is set, the swarm controller autoseeds an -`actions/checkout` pull-mirror on the local forge and sets Forgejo's -`DEFAULT_ACTIONS_URL` to point at the local instance. This means CI -`uses: actions/checkout@vN` steps resolve entirely on loopback — no -external DNS on the CI critical path. +With `services.hyperhive.deploy.forgejo.actionsFromSelf` set on the forge's +host, the swarm controller seeds an `actions/checkout` pull-mirror on the forge +and Forgejo's `DEFAULT_ACTIONS_URL` points at the forge itself. CI +`uses: actions/checkout@vN` steps then resolve on the forge, with no external +DNS on the CI critical path. @@ -192,7 +205,7 @@ to avoid provisioning collisions. ## Security: unsandboxed builds and trusted contributors -**hive-ci should only run CI for trusted contributors.** The security boundary is weaker than it looks: +**The runner should only run CI for trusted contributors.** The security boundary is weaker than it looks: ### What unsandboxed builds mean @@ -200,10 +213,10 @@ nspawn containers can't create user-namespaces, so the module sets `nix.settings A malicious `default.nix` or build script in a PR can therefore: -- **Make arbitrary network requests** to any address reachable from the container. The container runs in its own netns behind the hive bridge, so it reaches the forge only through the gateway (`http://`, public/read endpoints — no admin credentials) and **can't** reach host-loopback services: the unauthenticated core dashboard at `127.0.0.1:7000` and the raw forge port are off-limits (a firewall rule drops bridge→127.0.0.0/8 traffic). +- **Make arbitrary network requests** to any address reachable from the container. The container runs in its own netns behind the hive bridge, so it reaches the forge by name at `https://` (no admin credentials) and **can't** reach host-loopback services: the unauthenticated core dashboard at `127.0.0.1:7000` and the raw forge port are off-limits (a firewall rule drops bridge→127.0.0.0/8 traffic). - **Write to the container filesystem**, including corrupting the runner's state dir or `.runner` credentials. -The core admin token (`forge-core-token`) **isn't** bind-mounted into the container. it's held and used only by hive-c0re on the host (`forge/ci_runner.rs`), which mints per-runner registration tokens; only that registration token reaches the container's env-file. A build process can still reach forge over the network, but can't use the admin token to issue privileged API calls. +No forge admin token enters the container: swarm-controller holds it and publishes only the registration token, which reaches the container's env-file. A build process can still reach the forge over the network, and it runs as the runner's own user, so it can read `.runner` and act as this runner; it can't issue admin API calls. Note: `nix flake check --no-build` (eval-only) reduces the attack surface but doesn't eliminate it — `builtins.fetchGit`, `builtins.fetchurl`, and import-from-derivation can reach the network and filesystem during evaluation. The default CI workflow runs full `nix flake check` (builds derivations), which is the higher-risk path. @@ -221,7 +234,7 @@ The current design is appropriate for a trusted-team hive where all contributors ## Host store maintenance (recommended) The CI runner builds derivations through the **host** nix-daemon — the -hive-ci container shares the host store and has no daemon of its own. Build +`swarm-ci-runner` container shares the host store and has no daemon of its own. Build outputs accumulate in `/nix/store` with no automatic collection, and a busy CI day can fill the disk until every job fails fast with `ENOSPC`. @@ -278,6 +291,7 @@ builder — disk-pressure collection at least runs while nothing is mid-copy. ## References -- `nix/host-modules/hive-ci.nix`: runner configuration, autoregistration script, container setup. +- `nix/host-modules/swarm-ci-runner.nix`: runner configuration, token fetch, re-registration step, container setup. +- `swarm-controller/src/forge/runner_token.rs`: publishes the registration token. - `.forgejo/workflows/ci.yml`: workflow definition. - `docs/process/gotchas.md`: nix sandboxing limitations in containers. diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index eed67688..a98f1f46 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -474,8 +474,8 @@ Two things to know about the weights: same value and the weight does _not_ rank agents against each other. What `80` buys is that agents yield to everything **not** on this drop-in path: host services (nginx and dnsmasq among them) and the - infra containers (`hive-ci`, `hive-forge`, `hive-gateway`, - `hive-matrix`), which stay at the kernel default of `100`. + infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`), which + stay at the kernel default of `100`. - `IOWeight=` is only honoured when the backing device runs the BFQ scheduler or has blk-iocost QoS enabled. On a host using `none`/`mq-deadline`/`kyber` without iocost, systemd writes the value diff --git a/docs/swarm/README.md b/docs/swarm/README.md index d3c3982d..b0a76b62 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -265,7 +265,7 @@ one per hive. It ensures them at start and every five minutes after whitelists = the `operators` team, and no user. The controller leaves a repo with no `main` rule alone; - the pull-mirrors from `deploy.forgejo.mirrors` on the controller's - host (with the `actions/checkout` one `deploy.forgejo.ci.enable` adds); + host (with the `actions/checkout` one `deploy.forgejo.actionsFromSelf` adds); - `internal/docs` (private) and `internal/knowledge` (public, with a seed `README.md` while empty); - the `agent-configs` org avatar diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 21b62274..89bff47c 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -213,7 +213,7 @@ Two consumers, and only one of them is fine: next to every agent's flake. The bundle is the runtime-to-build-time bridge. - **The Matrix container crosses the same bridge**, via the shared - `lib/hive-ca-trust.nix` helper that `hive-ci` and `hive-forge` already + `lib/hive-ca-trust.nix` helper that `hive-forge` and `swarm-ci-runner` use: the bundle is bind-mounted read-only into the container, and a oneshot concatenates it with the system CAs before tuwunel starts. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 186721a8..d2aadec2 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -69,6 +69,7 @@ of the cell says how. | `swarm/hives//matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives//queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/services//oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s; the bao UI's by the store's granter | must be stated | must be stated | +| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into the runner's `EnvironmentFile`; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ❌ `swarm-bao-ci-runner` reads it when it starts, at boot or deploy, and restarts the runner when the value changed | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated | diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index 313e4051..93789f78 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -302,7 +302,7 @@ next controller restart. ### Per-principal identities Bao matches a cert-auth role on the certificate's subject, so a certificate is an -identity and sharing one merges the identities. These four units read one path +identity and sharing one merges the identities. These five units read one path each and each holds a leaf, a role and a policy of its own: | unit | option pair under `deploy.bao.` | reads | @@ -311,20 +311,23 @@ each and each holds a leaf, a role and a policy of its own: | `swarm-bao-grafana-oidc` | `grafanaOidcClientCertFile` / `grafanaOidcClientKeyFile` | `swarm/services//oidc/client` | | `swarm-bao-otel-oidc` | `otelOidcClientCertFile` / `otelOidcClientKeyFile` | `swarm/services//oidc/client` | | `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services//oidc/client` | +| `swarm-bao-ci-runner` | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` | The first exists **per hive**, because the path it reads carries a hive name and every hive runs its own reader. Its subject is `-`; `swarm.nix` reserves that composed spelling as a hive name, so nobody can name a hive into another hive's -role. The other three read a path that names a swarm service rather than a hive, +role. The other four read a path that names a swarm service rather than a hive, so one role each is enough and their subjects are the flat -`deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName` and -`deploy.bao.forwarderOidcCommonName`. +`deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName`, +`deploy.bao.forwarderOidcCommonName` and `deploy.bao.ciRunnerCommonName`. -On a host that mints its own PKI, `glue-bao-tls.nix` signs all four and defaults -all eight options, and there is nothing to do. Elsewhere you issue each leaf from +On a host that mints its own PKI, `glue-bao-tls.nix` signs all five and defaults +all ten options, and there is nothing to do. Elsewhere you issue each leaf from that CA out of band and name it here — one file per principal rather than one file -shared by four, which is the whole of what this buys. +shared by five, which is the whole of what this buys. `swarm-ci-runner.nix` +refuses the build wherever the runner runs without its pair, as +`swarm-grafana.nix` does for Grafana. On a host without the store, where the forwarder never runs, forgetting one of the other six isn't a quiet degrade. A hand-configured remote-store hive that diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 40181a0d..423f4bd7 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -221,3 +221,16 @@ has one either way, reached by its swarm name through the gateway. Agent-side configuration, and what a hive's own collector does, are in [`../scheduler/observability.md`](../scheduler/observability.md). + +### CI runner + +One Forgejo Actions runner, in a `swarm-ci-runner` container on the host that +sets `deploy.ci-runner.enable`. It doesn't derive from `allSwarmServices`: a +runner executes pull-request code, so running one is a decision of its own. + +It needs neither the forge nor a hive on its host. It reaches the forge at +`https://`, and registers with the token swarm-controller +publishes to the secret store, which it reads under its own `ci-runner` leaf. +On a host without the store, that leaf is the one file to copy. Registration, +the container and the threat model are in +[`../scheduler/ci.md`](../scheduler/ci.md). diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 5bf96253..99bb2b1e 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -600,7 +600,6 @@ Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agen * `--agents` — All sub-agent containers * `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` -* `--ci` — The CI runner container (`hive-ci`) * `--forge` — The forge container (`hive-forge`) * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--matrix` — The matrix container (`hive-matrix`) @@ -621,7 +620,6 @@ Bare `hivectl start` restores the agents stopped by the last broad-scope `stop` * `--agents` — All sub-agent containers * `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` -* `--ci` — The CI runner container (`hive-ci`) * `--forge` — The forge container (`hive-forge`) * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--matrix` — The matrix container (`hive-matrix`) @@ -641,7 +639,6 @@ Bare `hivectl restart` restarts everything; scope flags narrow it. * `--agents` — All sub-agent containers * `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` -* `--ci` — The CI runner container (`hive-ci`) * `--forge` — The forge container (`hive-forge`) * `--gateway` — The gateway (`hive-gateway`) — nginx on the host, not a container * `--matrix` — The matrix container (`hive-matrix`) diff --git a/docs/trust-boundary/boundary.md b/docs/trust-boundary/boundary.md index dfc81a67..eda24e83 100644 --- a/docs/trust-boundary/boundary.md +++ b/docs/trust-boundary/boundary.md @@ -56,13 +56,13 @@ Concretely, the core daemon's dashboard `/api` carries **no application-layer authentication** — the core daemon serves operator-authority routes unauthenticated at the HTTP layer. Their protection is entirely (a) the gateway, which fronts all operator traffic and is where operator auth lives, -and (b) network isolation, which keeps agents — and `hive-ci`'s untrusted PR -builds — off host-loopback so nothing can reach `127.0.0.1:` +and (b) network isolation, which keeps agents — and the CI runner's untrusted +PR builds — off host-loopback so nothing can reach `127.0.0.1:` directly. This is deliberate given the load-bearing role of network isolation above, but it's a standing invariant: the `/api` must never bind to a non-loopback address or get exposed outside the gateway, and every new -operator-authority route inherits that assumption. hyperhive treats `hive-ci` -like an agent for this purpose — it runs untrusted PR code and is +operator-authority route inherits that assumption. hyperhive treats +`swarm-ci-runner` like an agent for this purpose — it runs untrusted PR code and is netns-isolated for the same reason. The boundary rests on three layers: diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index da648a5a..91578cf3 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -205,9 +205,9 @@ Unsandboxed nix builds run as `nixbld` users (non-root, typically UIDs 30001-300 Without sandbox isolation, a build derivation's builder script has read access to any file in the container that the nixbld user can read. -The blast radius also has a **network** dimension. hive-ci runs its unsandboxed -builds of untrusted PR code in its own private netns behind the hive bridge: a -build reaches the forge only through the gateway and can't reach host-loopback +The blast radius also has a **network** dimension. `swarm-ci-runner` runs its +unsandboxed builds of untrusted PR code in its own private netns behind the +bridge: a build reaches the forge by name and can't reach host-loopback services — including the core dashboard at `127.0.0.1:`, which has no application-layer auth of its own (see [`docs/scheduler/ci.md`](../scheduler/ci.md)). The `0600` token policy bounds file reads; network isolation bounds network reach. @@ -286,7 +286,6 @@ known operations; there is no arbitrary command pass-through: | `ControlInfraContainer` | `systemctl container@.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) | | `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` | | `SetAgentPaused` | create / remove the `//harness/paused` marker that parks an agent's turn loop | -| `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container | | `EnsureAgentSubvolume` | `btrfs subvolume create /` for a new agent — no-op when the path exists or the filesystem isn't btrfs | | `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first | | `DeleteAgentSubvolume` | `btrfs subvolume delete /` — purge path only, never a plain destroy | @@ -301,7 +300,7 @@ known operations; there is no arbitrary command pass-through: **Container allowlist** — `hive-priv` validates every request against an allowlist before any operation: the allowlist accepts only names matching the agent-name convention (char-validated) or the known -sibling service containers (`hive-forge`, `hive-matrix`, `hive-ci`), +sibling service containers (`hive-forge`, `hive-matrix`), and rejects arbitrary container names. `hive-gateway` is a host unit, not a container, so it's not in this list — see `ReloadGatewayNginx` above for how the hard-coded unit name scopes its access instead. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 654ce9b1..d316ec74 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -515,15 +515,15 @@ deep-link directly to a specific agent's journal. A "fetched N ago" chip appears after the `↻ refresh` button following each successful fetch and ticks every 30 s. -**INFRA sub-tab** — journald viewer for the four hive infrastructure -containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`), a +**INFRA sub-tab** — journald viewer for the three hive infrastructure +containers (`hive-forge`, `hive-gateway`, `hive-matrix`), a fixed client-side list (`INFRA_NAMES` in `logs.js` — no dashboard API exposes just the name list). No unit filter (infra containers don't run the per-agent hive daemons) — always the full machine journal (or, for the gateway, the host journal filtered to its own unit). Fetches `GET /api/journal/{name}?lines=500`, same "fetched N ago" ticker as AGENT. A `?agent=` deep-link routes here instead of AGENT when -the name is one of the four infra containers. +the name is one of the three infra containers. **SYSTEM sub-tab** — host-side service logs. Unit selector (`hive-c0re.service` / `hive-priv.service`). Fetches diff --git a/flake.nix b/flake.nix index a0f82a2e..69807366 100644 --- a/flake.nix +++ b/flake.nix @@ -167,7 +167,6 @@ lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; }; - hive-ci = ./nix/host-modules/hive-ci.nix; hive-forge = ./nix/host-modules/hive-forge; }; diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 1bdcaae8..b74cc42c 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -20,7 +20,7 @@ /builds.html (the build lifecycle hub). Default tab: K3PT ST4T3. The section
ids (tombstones-section, container-load-section) match what core.js's renderers target. Hive infra containers - (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no + (`hive-forge`, `hive-gateway`, `hive-matrix`) have no dashboard panel — `hivectl stop`/`start`/`restart` is the only control surface. -->