Deploying gunnar
Two shapes, one product: a Kubernetes workload and a systemd service. They are the same binary with the same hardening written in two syntaxes, and cargo xtask deploy-check asserts that neither one has drifted.
cargo xtask deploy-check # the rules hold
cargo xtask deploy-check --red # and every one of them still bites
<ops>/ — the site-specific half, and why it is not here
Deploying a PRODUCT and operating ONE SITE are different jobs, and only the first belongs in a public repository. The cutover and front-deploy scripts, the reverse proxy's configuration, the operational drills (checks/*.sh) and the live-site walk (sitetest/) all carry one deployment's addresses, hostnames and procedure, so they live in a separate private operations repository. Notes throughout this repository name files there with the prefix <ops>/ — <ops>/checks/failover-drill.sh is that repository's checks/failover-drill.sh.
What stays here is what is true of every gunnar: the Kubernetes manifests, the systemd units, and deploy/upcloud/ — the reinstall-appliance verb, which carries the PROCEDURE and takes the site's own facts (--server-uuid, --verify-addr, --front, or a $GUNNAR_SITE file) as required arguments rather than as constants. See deploy/upcloud/src/site.rs.
In a cluster
kubectl apply -k deploy/kubernetes
Before that, two things, in this order:
- Make the KEK. Everything gunnar seals — the transparency key, the provenance key — is sealed under it. Back it up somewhere that is not the cluster. Losing it means the log can no longer be headed, and every chain stops at the last signed head.
kubectl create namespace gunnar
kubectl -n gunnar create secret generic gunnar-keys \
--from-literal=GUNNAR_SECRET_KEK="$(openssl rand -hex 32)"
Then delete the placeholder Secret document from gunnar.yaml. It ships with an obvious non-key in it so the shape is documented and so the check has something to check — it is not a key.
- Pin the image.
deploy/kubernetes/gunnar.yamlnames a digest, anddeploy-checkrefuses a tag. That is not fussiness: a forge whose whole claim is you can prove what this binary was built from cannot deploy a name somebody can move under it.
gunnar registry resolve --name team/gunnar --tag v1 # prints sha256:…
One replica, and it is a StatefulSet. The object store is a znippy archive with a lock; a second replica is a second writer. Scaling reads is gunnar mirror, not replicas: 2.
On your own iron
install -m 0755 target/release/gunnar /usr/local/bin/gunnar
install -d -m 0700 -o gunnar -g gunnar /var/lib/gunnar
install -d -m 0755 /etc/gunnar
install -m 0600 deploy/systemd/gunnar.env.example /etc/gunnar/gunnar.env
$EDITOR /etc/gunnar/gunnar.env # the KEK goes here, and only here
install -m 0644 deploy/systemd/gunnar.service /etc/systemd/system/
install -m 0644 deploy/systemd/gunnar-runner.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now gunnar gunnar-runner
gunnar service install writes the same unit over D-Bus. The file is in the repository so it can be read before it is run.
The runner is a separate workload, on both
The server holds the signing keys; the runner executes other people's build recipes. One process for both would mean a build that escaped the sandbox landed where the keys are.
The sandbox is unprivileged — unshare(CLONE_NEWUSER) then unshare(CLONE_NEWNET), no setuid helper, no CAP_SYS_ADMIN — so neither form needs a privileged container, and deploy-check fails if one appears. What it does need is unprivileged user namespaces enabled on the host, which is the default everywhere modern. Where they are off, gunnar build refuses to run rather than building without isolation.
What both forms serve
| port | what |
| 2222 | git over SSH |
| 8080 | git over smart HTTP and the OCI registry — one listener, branching on /v2/ |
| 50051 | the gRPC control plane |
| 9090 | Prometheus counters |
/v2/ is the readiness probe because it answers without a credential and only when the registry is wired, so it says more than a TCP connect does.