Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ci-runner: run the forge CI runner as a swarm service

The CI runner was a hive deploy module: hive-ci.nix ran it beside the
forge, and hive-c0re registered it through the forge's admin API with
the `core` token, writing the registration token through hive-priv. It
could only run on the forge's host, and it kept an admin-scoped forge
token on a hive.

Now the runner is a swarm service:

- swarm-ci-runner.nix runs it in a `swarm-ci-runner` container on the
  host that sets deploy.ci-runner.enable, reaching the forge by name.
  It needs neither the forge nor a hive there.
- swarm-controller reads the forge's runner registration token with its
  own admin token and writes it to swarm/services/ci-runner/
  registration-token whenever it differs from what it last wrote. Its
  grant on that path is create/update only.
- The runner's own leaf (CN swarm-ci-runner, role and policy of the same
  name) reads that one path. swarm-bao-ci-runner.service fetches it into
  the runner's EnvironmentFile and restarts the runner on a change.
- A pre-start step removes .runner when the daemon's journal since its
  last start holds Forgejo's "unregistered runner" refusal (the daemon
  exits 1 when its startup Declare gets it), or when .runner names
  another forge address; upstream's pre-start step then registers again.

Removed from the hive side: hive-ci.nix and deploy.forgejo.ci.*,
forge/ci_runner.rs, PrivRequest::RegisterCiRunner and its handler and
ReadWritePaths entry, InfraContainer::Ci with hivectl's --ci and the
dashboard's hive-ci journal entry, HYPERHIVE_FORGE_CI_ENABLED, and
read:admin,write:admin from CORE_TOKEN_SCOPES: nothing else in hive-c0re
calls /api/v1/admin/*. The old ci options, under both swarm.forge.ci and
deploy.forgejo.ci, now fail evaluation naming deploy.ci-runner.

The forge's half of the old ci.enable, the actions/checkout mirror and
DEFAULT_ACTIONS_URL = self, is deploy.forgejo.actionsFromSelf.

Closes #4954
This commit is contained in:
atlas 2026-10-05 02:25:05 +02:00 • committed by mara
commit e2da5509ae
60 changed files with 1088 additions and 1201 deletions

View file

@ -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).

View file

@ -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.
---

View file

@ -34,21 +34,21 @@ that touches it.
│ hive-br0 10.42.0.1/24 │
│ ┌──────────┼──────────────┐ │
└──────────────┼──────────┼──────────────┼───────────────────────────┘
vb-h-<a> vb-h-<b> vb-hive-ci veth pairs
vb-h-<a> vb-h-<b> vb-<runner> 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.<swarm-domain>` 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-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → 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.<swarm-domain>` 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-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → 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`)

View file

@ -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://<swarm.forge.domain>`, 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://<forge.domain>` (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=<value>` 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=<real>` **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
<!-- vale write-good.Passive = NO -->
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.
<!-- vale write-good.Passive = YES -->
@ -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://<forge.domain>`, 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://<forge.domain>` (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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -69,6 +69,7 @@ of the cell says how.
| `swarm/hives/<hive>/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/<hive>/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/<clientId>/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 |
<!-- vale write-good.Passive = YES -->

View file

@ -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/<grafana client id>/oidc/client` |
| `swarm-bao-otel-oidc` | `otelOidcClientCertFile` / `otelOidcClientKeyFile` | `swarm/services/<collector client id>/oidc/client` |
| `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/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
`<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `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

View file

@ -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://<swarm.forge.domain>`, 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).

View file

@ -600,7 +600,6 @@ Bare `hivectl stop` stops everything; scope flags narrow it to specific sub-agen
* `--agents` — All sub-agent containers
* `--agent <NAME>` — 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 <NAME>` — 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 <NAME>` — 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`)

View file

@ -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:<dashboard_port>`
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:<dashboard_port>`
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:

View file

@ -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:<dashboard_port>`, 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 <action> container@<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 `<state>/<name>/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 <state>/<name>` 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 <state>/<name>` — 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.

View file

@ -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=<name>` 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