Files
hearth/infra/gitea-runners/runbook.md
T
yukkop cb62bd3044
runner ubuntu smoke / ubuntu-latest label smoke (push) Has been cancelled
runner nix smoke / nix label and flake smoke (push) Has been cancelled
feat: maintain server until hour
2026-09-11 02:05:39 +00:00

668 lines
31 KiB
Markdown

# Gitea Runner Infrastructure Runbook
## Scope
This directory is the repo-owned boundary for the Gitea Actions runner pool.
The controller is the active on-demand path; Kubernetes manifests and the
Nix-capable image are retained for manual rollback and maintenance.
The target service is `https://gitea.hectic-lab.com` for the Gitea organization
`hectic-lab`. The pool is trusted-only. "Ephemeral" means each controller VM
and workflow job is disposable; the Kubernetes StatefulSet is rollback-only.
## Immutable decisions
- Infrastructure is managed with OpenTofu command examples only, using the
`tofu` CLI.
- Cloud provider is Hetzner; cluster bootstrap uses kube-hetzner.
- Remote state uses the S3 backend bucket `gitea-runner-hectic-lab`.
- Runner implementation is the non-Enterprise `gitea/runner`.
- Runner registration uses a Gitea organization-scoped token for `hectic-lab`.
- Runtime token delivery is SOPS-backed and mounted into the runner pod as a
file read through `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`; plaintext token
environment variables are not the contract.
- Kubernetes runner lifecycle uses a StatefulSet with one PVC per pod for
`/data`, including `/data/.runner`.
- Container builds run through privileged rootful DinD inside trusted runner
pods; host Docker socket mounting is not an implementation path.
- `ubuntu-latest` and `nix` are controller-managed on-demand aliases for
`gross-x86` and `gross-nix-x86`; the Kubernetes pool has no active labels.
- First scope is trusted internal workflows only, with no untrusted fork or PR
workflow support.
- On-demand allocation is handled by the repo-owned controller; Kubernetes is
not an active autoscaling path.
## Lifecycle boundaries
- `infra/gitea-runners/opentofu/`: OpenTofu stack for the S3 backend
contract, Hetzner provider configuration, and kube-hetzner module wiring.
- `infra/gitea-runners/k8s/`: rollback-only namespace, ConfigMap, Secret mount,
StatefulSet, PVC, DinD sidecar, cleanup, and operational manifest work.
- `infra/gitea-runners/image/`: notes and handoff for the optional Kubernetes
rollback image; active on-demand Nix image is selected by Hetzner image ID.
- `infra/gitea-runners/runbook.md`: this contract plus later operational
commands, rollback notes, and acceptance evidence references.
## Guardrails
- Enterprise ARC/actions-runner-controller are rejected alternatives and must
not be implemented here. Do not add ARC custom resources, controller install
instructions, or GitHub Actions ARC assumptions.
- Untrusted fork/PR workflows are out of first scope; privileged DinD is only
acceptable for trusted internal jobs.
- The persistent StatefulSet is rollback-only and defaults to zero replicas;
normal jobs use controller-managed on-demand VMs.
- No actual secrets are committed: no kubeconfig, runner token, Hetzner token,
S3 credentials, decrypted SOPS files, or SOPS age keys.
- OpenTofu must not manage plaintext Kubernetes Secrets containing the Gitea
runner token; Kubernetes receives the token as a mounted file secret instead.
- Do not use `localhost` or `127.0.0.1` as the Gitea URL inside job containers;
jobs must reach the public HTTPS service.
## Initial acceptance commands
Run from the repository root:
```sh
test -d infra/gitea-runners/opentofu && test -d infra/gitea-runners/k8s && test -d infra/gitea-runners/image
test -f infra/gitea-runners/runbook.md
grep -n "OpenTofu\|kube-hetzner\|StatefulSet\|DinD\|SOPS\|trusted" infra/gitea-runners/runbook.md
grep -R "[E]nterprise ARC\|[a]ctions-runner-controller" infra/gitea-runners
grep -R "[t]erraform " infra/gitea-runners || true
grep -R "[D]ECISION NEEDED" infra/gitea-runners || true
```
Expected outcomes: the directory and file checks exit 0; the architecture-term
grep shows this contract; ARC references appear only in the rejected-alternative
guardrail above; there are no forbidden CLI command examples and no unresolved
decision placeholders.
## Downstream placeholders
- Task 2: add OpenTofu backend/provider files and verify S3 state safety.
- Task 3: add SOPS secret contract and runtime token delivery details.
- Task 4: define or package the Nix-capable runner image for the `nix` label.
- Task 5+: provision kube-hetzner, add Kubernetes resources, verify workflows,
and document cleanup, rollback, and scaling operations.
## Runner lifecycle cleanup
All lifecycle commands are scoped to the runner namespace:
```sh
kubectl -n gitea-runners get statefulset gitea-runner
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
```
The scheduled cleanup manifest is dry-run only. It lists the StatefulSet, active
runner pods, runner PVCs, and PVCs whose expected StatefulSet pod is absent. It
does not delete pods, PVCs, Docker data, or Gitea runner registrations.
Run the same inventory on demand without waiting for the schedule:
```sh
kubectl -n gitea-runners create job gitea-runner-cleanup-dry-run-manual --from=cronjob/gitea-runner-cleanup-dry-run
kubectl -n gitea-runners wait --for=condition=complete job/gitea-runner-cleanup-dry-run-manual --timeout=2m
kubectl -n gitea-runners logs job/gitea-runner-cleanup-dry-run-manual -c cleanup-dry-run
```
The cleanup job template has `ttlSecondsAfterFinished: 3600`, so completed
manual dry-run jobs are garbage-collected by Kubernetes instead of requiring an
operator to remove finished jobs manually.
If a PVC such as `data-gitea-runner-3` is intentionally deleted, the matching
pod loses `/data/.runner`. That runner identity must then be deregistered from
Gitea or the replacement pod must be allowed to re-register intentionally with
the current organization runner token. Do not delete an active runner PVC as a
normal cleanup step.
Non-UI Gitea registration reconciliation uses the Gitea API with a separate
admin token. Store that token outside this repository and pass it as a file; do
not print it:
```sh
kubectl -n gitea-runners create secret generic gitea-runner-admin-token --from-file=token=/secure/path/gitea-admin-token
kubectl -n gitea-runners run gitea-runner-registration-dry-run \
--restart=Never \
--image=curlimages/curl:8.10.1 \
--overrides='{"spec":{"containers":[{"name":"gitea-runner-registration-dry-run","image":"curlimages/curl:8.10.1","command":["/bin/sh","-ec","umask 077; cfg=$(mktemp); trap '\''rm -f \"$cfg\"'\'' EXIT; { printf '\''header = \"Authorization: token '\''; cat /admin-token/token; printf '\''\"\\n'\''; printf '\''url = \"https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners\"\\n'\''; } > \"$cfg\"; curl -fsS --config \"$cfg\""],"volumeMounts":[{"name":"admin-token","mountPath":"/admin-token","readOnly":true}]}],"volumes":[{"name":"admin-token","secret":{"secretName":"gitea-runner-admin-token","defaultMode":256}}]}}'
kubectl -n gitea-runners logs pod/gitea-runner-registration-dry-run
```
Delete the temporary `gitea-runner-admin-token` Secret only after the dry-run pod
has completed and its logs have been collected. Do not keep this admin token in
the runner namespace longer than the reconciliation window.
Only remove a stale Gitea runner registration after the corresponding pod/PVC
was intentionally deleted or `/data/.runner` was intentionally reset. Prefer a
Gitea CLI/API deletion from the Gitea server or an admin workstation; manual UI
cleanup is a fallback, not the only path. Record the removed runner name and the
Kubernetes PVC/pod deletion that made it stale.
After the dry-run list identifies a stale registration and the PVC/pod deletion
has been recorded, remove that exact Gitea runner by id through the API:
```sh
runner_id='REPLACE_WITH_STALE_RUNNER_ID'
umask 077
curl_config=$(mktemp /tmp/gitea-runner-admin-curl.XXXXXX)
trap 'rm -f "$curl_config"' EXIT
{
printf 'request = "DELETE"\n'
printf 'header = "Authorization: token '
cat /secure/path/gitea-admin-token
printf '"\n'
printf 'url = "https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners/%s"\n' "$runner_id"
} > "$curl_config"
curl -fsS --config "$curl_config"
```
Do not run the delete command for a runner that still has an active
`gitea-runner-*` pod or a retained `data-gitea-runner-*` PVC unless that PVC is
being intentionally reset for re-registration.
## Docker-in-Docker storage cleanup
Docker layers live inside each DinD sidecar at `/var/lib/docker`, backed by the
pod-local `docker-graph` `emptyDir`; the host Docker socket is not used. Always
list disk usage before pruning, and run the command only against the `docker`
container in runner pods in `gitea-runners`. Because this storage is pod-local,
loop over pods for pool-wide cleanup:
```sh
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system df
done
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system prune --all --force --filter until=24h
done
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system df
done
```
For one pod, replace the StatefulSet target with the pod name:
```sh
kubectl -n gitea-runners exec pod/gitea-runner-0 -c docker -- docker system df
kubectl -n gitea-runners exec pod/gitea-runner-0 -c docker -- docker system prune --all --force --filter until=24h
```
Do not run host-level Docker cleanup commands and do not mount or prune a host
Docker socket. If a pod is deleted, its `emptyDir` Docker graph is removed by
Kubernetes; the `/data` PVC remains and still controls runner identity.
## Token rotation
Rotate the Gitea organization runner token without printing decrypted values:
```sh
sops sus/gitea-runners.yaml
umask 077
token_file=$(mktemp /tmp/gitea-runner-token.XXXXXX)
trap 'rm -f "$token_file"' EXIT
sops -d --extract '["gitea"]["hectic-lab"]["org-runner-registration-token"]' sus/gitea-runners.yaml > "$token_file"
kubectl -n gitea-runners create secret generic gitea-runner-token \
--from-file=token="$token_file" \
--dry-run=client \
-o yaml | kubectl -n gitea-runners apply -f -
kubectl -n gitea-runners rollout restart statefulset/gitea-runner
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200 | grep -Eq 'token|GITEA_RUNNER_REGISTRATION_TOKEN' && exit 1 || true
```
The `rollout restart` command above is the controlled restart path for this
StatefulSet. Observe the rollout and each ordinal until all replacement pods are
Ready; do not delete runner pods directly as part of normal token rotation:
```sh
kubectl -n gitea-runners wait --for=condition=Ready pod/gitea-runner-0 --timeout=5m
kubectl -n gitea-runners wait --for=condition=Ready pod/gitea-runner-1 --timeout=5m
kubectl -n gitea-runners wait --for=condition=Ready pod/gitea-runner-2 --timeout=5m
kubectl -n gitea-runners wait --for=condition=Ready pod/gitea-runner-3 --timeout=5m
kubectl -n gitea-runners wait --for=condition=Ready pod/gitea-runner-4 --timeout=5m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
```
Verification must confirm the token file mount remains present while the token
value never appears in logs or evidence:
```sh
kubectl -n gitea-runners describe pod gitea-runner-0 | grep -n '/runner-secrets\|gitea-runner-token'
kubectl -n gitea-runners logs pod/gitea-runner-0 -c runner --tail=200 | grep -Eq 'token|GITEA_RUNNER_REGISTRATION_TOKEN' && exit 1 || true
```
## Deploy and status
These commands are executable only when the external inputs are available:
- `TF_VAR_hcloud_token`
- `TF_VAR_ssh_public_key`
- `TF_VAR_ssh_private_key`
- S3 backend credentials and endpoint access
- a matching SOPS age identity for `sus/gitea-runners.yaml`
- `kubectl` access to the target cluster
- a valid `GCR_NIX_IMAGE_ID` for controller-managed Nix jobs
If any input is missing, stop before `tofu apply`. Do not guess values or reuse
stale kubeconfig files.
Before production Kubernetes apply or rollout, satisfy both manifest gates:
1. Create or update the `gitea-runner-token` Secret from SOPS. The active
Kustomize overlay intentionally does not include a placeholder Secret, but
the StatefulSet still mounts `secretName: gitea-runner-token` as
`/runner-secrets/token` for `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`.
2. Keep the persistent-pool ConfigMap labels empty. Runner labels belong to the
controller; Nix image readiness is governed by `GCR_NIX_IMAGE_ID`.
Use the same SOPS materialization pattern as token rotation before applying the
Kubernetes overlay. Applying the namespace alone is allowed so the Secret has a
target namespace; the full overlay remains gated on the Secret.
```sh
kubectl apply -f infra/gitea-runners/k8s/namespace.yaml
umask 077
token_file=$(mktemp /tmp/gitea-runner-token.XXXXXX)
trap 'rm -f "$token_file"' EXIT
sops -d --extract '["gitea"]["hectic-lab"]["org-runner-registration-token"]' sus/gitea-runners.yaml > "$token_file"
kubectl -n gitea-runners create secret generic gitea-runner-token \
--from-file=token="$token_file" \
--dry-run=client \
-o yaml | kubectl -n gitea-runners apply -f -
```
Do not run `kubectl apply -k infra/gitea-runners/k8s` until the Secret command
above succeeds. The persistent pool ConfigMap must retain empty labels.
```sh
tofu -chdir=infra/gitea-runners/opentofu init
tofu -chdir=infra/gitea-runners/opentofu validate
tofu -chdir=infra/gitea-runners/opentofu plan -out=.sisyphus/evidence/task-12-deploy.plan
tofu -chdir=infra/gitea-runners/opentofu apply .sisyphus/evidence/task-12-deploy.plan
export KUBECONFIG="$(tofu -chdir=infra/gitea-runners/opentofu output -raw kubeconfig_path)"
kubectl config current-context
kubectl get nodes -o wide
kubectl get sc
kubectl apply -k infra/gitea-runners/k8s
kubectl -n gitea-runners get statefulset gitea-runner
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get events --sort-by=.lastTimestamp | tail -n 50
kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200
```
Expected status after deploy:
- `kubectl config current-context` names the runner cluster context.
- `kubectl get nodes -o wide` shows all expected Hetzner nodes Ready.
- `kubectl get sc` shows the Hetzner CSI storage class used by runner PVCs.
- `kubectl -n gitea-runners get statefulset gitea-runner` shows 0 desired and 0 ready replicas.
- `kubectl -n gitea-runners get pvc` shows no active runner PVCs; retained PVCs are rollback-only.
- The controller host reports healthy and owns runner registrations; no persistent runner claims `ubuntu-latest` or `nix`.
## Legacy rollback pool scaling (manual only)
Persistent-pool scaling is not part of normal operation. Use only after restoring
its labels and disabling the zero-idle controller as described in `Rollback`.
```sh
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=10
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
# Run only workflows supported by restored persistent labels.
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=5
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
```
After scaling back down, inspect the cleanup dry-run and deregister any stale
runner registrations only for pods or PVCs that were intentionally removed.
## Cleanup and stale runner deregistration
Use the dry-run cleanup job to list the StatefulSet, active pods, PVCs, and any
PVC candidates whose pod is gone. It must not delete active resources.
```sh
kubectl -n gitea-runners create job gitea-runner-cleanup-dry-run-manual --from=cronjob/gitea-runner-cleanup-dry-run
kubectl -n gitea-runners wait --for=condition=complete job/gitea-runner-cleanup-dry-run-manual --timeout=2m
kubectl -n gitea-runners logs job/gitea-runner-cleanup-dry-run-manual -c cleanup-dry-run
```
Pool-wide DinD storage checks and cleanup:
```sh
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system df
done
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system prune --all --force --filter until=24h
done
for pod in $(kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'); do
kubectl -n gitea-runners exec "pod/${pod}" -c docker -- docker system df
done
```
If a PVC such as `data-gitea-runner-3` is intentionally deleted, the matching
pod loses `/data/.runner`. Deregister that runner from Gitea, or let the
replacement pod re-register intentionally with the current organization token.
Never delete an active runner PVC as routine cleanup.
Non-UI Gitea registration reconciliation uses an admin token stored outside this
repository:
```sh
kubectl -n gitea-runners create secret generic gitea-runner-admin-token --from-file=token=/secure/path/gitea-admin-token
kubectl -n gitea-runners run gitea-runner-registration-dry-run \
--restart=Never \
--image=curlimages/curl:8.10.1 \
--overrides='{"spec":{"containers":[{"name":"gitea-runner-registration-dry-run","image":"curlimages/curl:8.10.1","command":["/bin/sh","-ec","umask 077; cfg=$(mktemp); trap '\''rm -f \"$cfg\"'\'' EXIT; { printf '\''header = \"Authorization: token '\''; cat /admin-token/token; printf '\''\"\\n'\''; printf '\''url = \"https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners\"\\n'\''; } > \"$cfg\"; curl -fsS --config \"$cfg\""],"volumeMounts":[{"name":"admin-token","mountPath":"/admin-token","readOnly":true}]}],"volumes":[{"name":"admin-token","secret":{"secretName":"gitea-runner-admin-token","defaultMode":256}}]}}'
kubectl -n gitea-runners logs pod/gitea-runner-registration-dry-run
```
Delete the temporary `gitea-runner-admin-token` Secret only after the dry-run
pod has completed and its logs have been collected.
After the dry-run list identifies a stale registration and the PVC or pod
deletion has been recorded, remove that exact Gitea runner by id through the
API:
```sh
runner_id='REPLACE_WITH_STALE_RUNNER_ID'
umask 077
curl_config=$(mktemp /tmp/gitea-runner-admin-curl.XXXXXX)
trap 'rm -f "$curl_config"' EXIT
{
printf 'request = "DELETE"\n'
printf 'header = "Authorization: token '
cat /secure/path/gitea-admin-token
printf '"\n'
printf 'url = "https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners/%s"\n' "$runner_id"
} > "$curl_config"
curl -fsS --config "$curl_config"
```
Do not run the delete command for a runner that still has an active
`gitea-runner-*` pod or retained `data-gitea-runner-*` PVC unless that PVC is
being intentionally reset for re-registration.
## Legacy Kubernetes application rollback
Rollback the app layer only. Do not use this section to destroy the cluster.
```sh
kubectl -n gitea-runners rollout history statefulset/gitea-runner
kubectl -n gitea-runners rollout undo statefulset/gitea-runner --to-revision=<known-good-revision>
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200
```
If a manifest rollback is needed, reapply the repo overlay after checking out the
known-good revision, then re-run the rollout checks:
```sh
kubectl -n gitea-runners apply -k infra/gitea-runners/k8s
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
```
## Full cluster teardown
This destroys Hetzner resources owned by the kube-hetzner stack, including the
`gitea-runners` cluster nodes, the `control-plane` node pool, the
`runner-workers` node pool, the cluster network, load balancer resources,
firewall objects, and any attached Hetzner CSI volumes still managed by the
stack. Do not run teardown unless the destruction is intentional.
```sh
tofu -chdir=infra/gitea-runners/opentofu plan -destroy -out=.sisyphus/evidence/task-12-destroy.plan
tofu -chdir=infra/gitea-runners/opentofu show -no-color .sisyphus/evidence/task-12-destroy.plan
tofu -chdir=infra/gitea-runners/opentofu apply .sisyphus/evidence/task-12-destroy.plan
```
## Partial OpenTofu apply recovery
If `tofu apply` fails after creating some resources, do not destroy blindly.
First reconcile state and inspect what the stack thinks exists:
```sh
tofu -chdir=infra/gitea-runners/opentofu plan -refresh-only -out=.sisyphus/evidence/task-12-refresh.plan
tofu -chdir=infra/gitea-runners/opentofu show -no-color .sisyphus/evidence/task-12-refresh.plan
tofu -chdir=infra/gitea-runners/opentofu state list
```
Then rerun the normal plan path. Use `-target` only as a last resort when a
single resource is stuck and the drift is understood.
## S3 backend recovery
If backend init or state access fails, first verify the bucket and versioning
outside OpenTofu, then reconfigure the backend:
```sh
nix run nixpkgs#awscli2 -- s3api head-bucket --bucket gitea-runner-hectic-lab
nix run nixpkgs#awscli2 -- s3api get-bucket-versioning --bucket gitea-runner-hectic-lab
tofu -chdir=infra/gitea-runners/opentofu init -reconfigure
tofu -chdir=infra/gitea-runners/opentofu plan
```
If the backend reports a stale lock, confirm no `tofu` process is active, then
use `tofu force-unlock <LOCK_ID>` with the lock id from the error. Never force
unlock a live plan or apply.
## Gitea outage troubleshooting
Use the public HTTPS service, not `localhost` or `127.0.0.1` inside job
containers.
```sh
kubectl -n gitea-runners run gitea-outage-probe --rm --restart=Never --image=curlimages/curl:8.10.1 -- curl -fsS https://gitea.hectic-lab.com/api/healthz
kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200 | grep -E 'connection refused|timeout|tls|certificate|temporary failure' || true
kubectl -n gitea-runners get events --sort-by=.lastTimestamp | tail -n 50
```
If Gitea is down, keep the existing StatefulSet and PVCs intact. Do not delete
`/data/.runner` just because the service is unavailable. Once Gitea returns,
repeat the token rotation or re-registration path if a pod restarted while the
service was unavailable and lost its runner identity.
## Release checklist
Do not release unless the following evidence files exist and are readable:
- `.sisyphus/evidence/task-5-cluster-plan.txt`
- `.sisyphus/evidence/task-5-secret-plan-scan.txt`
- `.sisyphus/evidence/task-9-deploy.txt`
- `.sisyphus/evidence/task-9-secret-mount.txt`
- `.sisyphus/evidence/task-10-ubuntu-workflow.txt`
- `.sisyphus/evidence/task-10-nix-workflow.txt`
- `.sisyphus/evidence/task-11-scale.txt`
- `.sisyphus/evidence/task-11-restart-cleanup.txt`
If any evidence file is missing, stop and collect it before treating the runbook
as complete.
## Ephemeral VM runner cutover
This section governs replacing the legacy persistent K8s runner pool with the
ephemeral-VM controller (`package/gitea-runner-controller`) on this host.
The K8s pool above remains rollback-only until cutover is explicitly accepted.
### Operator gates (all three required before enable)
1. Secrets — add to `sus/gitea-runners.yaml` under
`gitea/hectic-lab/controller/*`:
```sh
sops sus/gitea-runners.yaml
# add keys:
# gitea:
# hectic-lab:
# controller:
# hcloud-token: <Hetzner API token, VM create/destroy scope>
# webhook-secret: <random 32+ bytes; also set as Gitea webhook secret>
# admin-token: <Gitea token with admin:runner scope for stale cleanup>
```
The org registration token key
`gitea/hectic-lab/org-runner-registration-token` already exists and is reused.
2. Base image — build the MicroOS snapshot and record its id:
```sh
nix develop .#gitea-runners -c gitea-runners-build-microos-snapshots x86
hcloud image list --selector '' -o json | jq '.[] | select(.type=="snapshot")'
```
3. DNS — A record `runners.hectic-lab.com -> 128.140.75.58` (ACME needs it).
### Enable
```sh
# nixos/system/hectic-lab/hectic-lab.nix: resolve the FIXME block
hectic.services.gitea-runner-controller = {
enable = true;
imageId = "<snapshot-id-from-gate-2>";
};
nixos-rebuild --target root@128.140.75.58 switch
systemctl status gitea-runner-controller.service gitea-runner-webhook.service
```
### Register the Gitea webhook
Org-level (preferred) or per-repo, on `https://gitea.hectic-lab.com`:
- URL: `https://runners.hectic-lab.com/`
- Method: `POST`, content type: JSON
- Secret: value of `gitea/hectic-lab/controller/webhook-secret`
- Trigger events: `Workflow jobs` only (`workflow_job`)
### Long CUDA/Magma deployment time budgets
The `deploy-neuro` workflow uses these nested limits:
| Layer | Limit |
| --- | --- |
| Wrapped build/deploy command | 6 hours (`WITH_ATTIC_BUILD_TIMEOUT=21600`) |
| Final cache drain | 1 hour (`WITH_ATTIC_DRAIN_TIMEOUT=3600`) |
| Workflow job | 435 minutes, including 15 minutes of setup/cleanup margin |
| `gross-nix-x86-perf` runner | 480 minutes |
| `gross-nix-x86-highmem` runner | 480 minutes |
| Gitea `actions.ENDLESS_TASK_TIMEOUT` | 8 hours |
| VM hard lifetime from allocation | 480 minutes; no destruction grace |
`ubuntu-latest` keeps a 180-minute limit; `nix` uses a 480-minute limit for
long-running Nix deployments. Deploy the controller and Gitea watchdog settings
before dispatching the longer workflow. Already
allocated VMs retain the TTL and runner configuration assigned when they were
created; updating the controller does not extend a running job.
`gross-nix-x86-highmem` is an explicit costly high-memory escape hatch backed
only by Hetzner CCX53 in `nbg1`, `fsn1`, or `hel1`; it may fall back by region
only, never to a lower-RAM server type. Current Hetzner public pricing for
Germany/Finland CCX53 is 0.8550 EUR/hour excluding IPv4, so one 480-minute
allocation reserves 6.84 EUR against the controller budget before VM creation.
These are maximum lifetimes. Failed, cancelled, skipped, and unbootstrapped jobs
still trigger immediate VM teardown; failed jobs retain pre-destroy diagnostics.
After a successful job, its bootstrapped VM stays running until next hourly
boundary measured from original VM creation, capped by profile TTL. Same-repo,
same-label queued work can atomically claim that idle VM. Reuse preserves
original Hetzner labels and runner name, creates no server, fetches no new
registration token, and makes no second budget reservation. Idle VMs are still
billed: controller deletes them at slot expiry and never relies on stopping a
server to avoid charges.
Reuse retains runner host filesystem and registration identity. It is therefore
restricted to same repository and exact label inside this trusted-only pool;
allowed repositories must not run untrusted fork or pull-request code. A failed
or otherwise non-successful job is never reused.
Active and idle VMs are deleted at profile hard TTL without grace. Idle reuse is
allowed only when at least one configured reconcile interval remains before both
slot expiry and hard TTL. Controller budget reservation still uses full label TTL
on initial creation, so a long-running label reserves more of existing monthly
budget. Do not raise that budget or disable timeout safeguards just to bypass a
refused allocation.
After changing any timeout, verify the complete chain rather than only
`timeout-minutes`; a shorter wrapper, runner, server watchdog, or VM TTL wins.
### Pre-flight verification (before first real job)
```sh
curl -fsS https://runners.hectic-lab.com/ -o /dev/null -w '%{http_code}\n' # any 4xx from handler = reachable
journalctl -u gitea-runner-webhook -n 20 --no-pager
hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed")] | length' # expect 0
```
Zero managed VMs is expected after retained billing slots expire. Immediately
after successful work, one managed VM per retained profile may remain until its
recorded hourly boundary.
### End-to-end acceptance (Task 9)
Trigger `.gitea/workflows/runner-nix-smoke.yaml` via workflow_dispatch, then:
```sh
watch_labels() { hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed") | {id,name,labels}]'; }
watch_labels # exactly one VM while queued/running
journalctl -f -u gitea-runner-controller # vm-created / vm-destroyed events
watch_labels # VM may remain until next hourly boundary
curl -fsS -H "Authorization: token $ADMIN" \
https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners \
| jq '[.entries[] | select(.name | startswith("gcr-"))] | length' # may remain during retained slot
```
Failure paths to verify identically: duplicate delivery (send same webhook twice
via Gitea UI "Test delivery"), cancelled job, unknown-label job.
### Rollback
K8s rollback pool now defaults to deleted state:
- `infra/gitea-runners/k8s/statefulset.yaml` keeps `replicas: 0`
- old kube-hetzner nodes may be deleted to preserve zero idle cost
- PVCs and IaC remain for manual rollback only
Re-enable sequence:
```sh
# 1. edit nixos/system/hectic-lab/hectic-lab.nix and set
# services.gitea-runner-controller.enable = false, then rebuild:
nixos-rebuild --target root@128.140.75.58 switch
# 2. reprovision old kube-hetzner nodes when they were deleted:
tofu -chdir=infra/gitea-runners/opentofu apply
# 3. while controller is disabled, destroy every surviving managed VM and
# verify no gcr-* runner registration remains online:
hcloud server list -o json \
| jq -r '.[] | select(.labels["gitea-runner-controller"]=="managed") | .id' \
| xargs -r -n1 hcloud server delete
# 4. restore kubeconfig / cluster access, restore `ubuntu-latest` in the
# ConfigMap labels, then re-enable K8s runner pool. The legacy image does not
# provide `nix`; do not dispatch Nix workflows until a Nix-capable K8s image
# and label mapping are restored:
kubectl -n gitea-runners edit configmap/gitea-runner-config
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=5
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
```
Any managed VM or `gcr-*` registration found after step 3 must be removed before
restoring persistent labels; otherwise both pools can claim the same job.