Compass Server on Kubernetes
Freezes on merge; later changes supersede by citation, never rewrite. Unless a path says otherwise, every path is in RigelBuild/compass at
main.
Named decisions this record introduces: D1–D10, inline in
Design, ledgered as DL-539 to DL-547 and DL-560 in
decisions/server/. The OQ-13 ruling is DL-577. DL-545 (D7) also covers
serverName (P9).
Resolved decisions
Section titled “Resolved decisions”Question numbers match the review threads on this record.
- OQ-1 — Admin token from a Secret. A Go change (P4). The supplied token file may be a symlink, and it may be group-readable when the process holds the file’s group. There is no init container (D4).
- OQ-2 — Server secret provider.
dotenv://from a read-only mounted Secret file (D4). - OQ-3 — LLM gateway. Not deployed by this record (D1).
- OQ-4 — Server certificate. The operator supplies the certificate. The
product carries a private CA through the Runner’s
serverCAand the client--ca(D6). - OQ-5 — Postgres. The operator brings a Postgres that meets the D3 contract.
- OQ-6 — Image lane reuse.
tools/server-image/reuses the pure cores oftools/runner-image/behind--lane runner|server(P1). - OQ-7 — Image visibility. Every Compass image is public (D9, P5).
- OQ-8 — Sessions across a Server restart. The Runner must survive a Server restart. That is its own record. Until it lands, see Known limitation.
- OQ-9 — Who runs Postgres. Never the renderer (D1, D3).
- OQ-10 — Digest bumps. Paired per-push tags that image automation follows (D8, P6).
- OQ-11 — Retired. It asked how one operator moves its own state, which is not a product change.
- OQ-12 — Multi-arch images. Every Compass image is published for
linux/amd64andlinux/arm64(D10, P7). - OQ-13 — arm64 microVM boot proof. No pre-merge arm64 boot; the P7
arm64 build gate is the only pre-merge proof, and P7 is unchanged. No
GitHub-hosted arm64 runner exposes
/dev/kvm. The Runner’s startup boot canary (runMicroVMPreflight,go/cmd/compass-runner/main.go) fails closed on a node that cannot boot the guest, and the Runner README says amd64 only until an arm64 boot passes (P7). A self-hosted arm64 runner was rejected: GitHub says self-hosted runners “should almost never be used for public repositories” (DL-577).
Problem / Intent
Section titled “Problem / Intent”The Runner runs on Kubernetes: it has a digest-published image, a manifest
renderer, and projected-token identity. Nothing deploys a Compass Server
there, so the Runner’s serverAddr has no target. This record lists the
product changes that let an operator run Server plus Runner on a cluster, and
the requirements the product places on that operator. How one operator runs
its own clusters is out of scope; see
self-host and managed
and the OSS-core boundary record.
Approach
Section titled “Approach”Ground truth
Section titled “Ground truth”- Stack children.
spawnChain(go/internal/stack/stack.go) starts Postgres, the OTel collector, NATS, the LLM gateway, compass-server, then compass-runner. The first four are podman containers. The Server gets the door as--listen-fd 3(serverSpec,go/internal/stack/spec.go). - Boot order.
Serve(go/server/serve.go) binds listeners, thenopenFabricAndStoreopens NATS before the store.Open(go/internal/store/store.go) pings once without retry (“store: ping”), then applies the embedded migrations under the session-scoped advisory lockmigrationLockKey. - RLS roles.
0001_init.sqlrunsCREATE ROLE compass_system NOLOGIN BYPASSRLS, always re-runsALTER ROLE compass_system BYPASSRLS, and grantscompass_appandcompass_systemtocurrent_user. PostgreSQL lets only a superuser or a role holding BYPASSRLS set that attribute (CREATE ROLE).WithSystemRole(go/internal/store/tenant_tx.go) runsSET LOCAL ROLE compass_systemat runtime. - Door.
loadNetworkTLS(go/server/network_door.go) loads the keypair once, with a TLS 1.3 floor and h2. It has no reload. - Health.
go/server/health.goservesGET /healthzand grpc health. It reports NOT_SERVING once drain starts.compass.runneris SERVING only while a Runner is enrolled. - Secrets.
resolveMasterKey(go/server/serve.go) fails boot without a 64-hexCOMPASS_MASTER_KEY, andreconcileKeyStatefails boot when it differs from the stored key. The resolver ingo/internal/secrets/resolver.gonames the dotenv file when it cannot open it (“secrets: dotenv provider file %s: %w”), and secretspec writes an audit log under$XDG_STATE_HOME. - Admin token.
readSuppliedAdminToken(go/server/network_door.go)Lstats the path and fails oninfo.Mode()&(os.ModeType|0o077) != 0. A Secret volume file is a symlink through..data. WithfsGroup, kubelet’schangeFilePermission(pkg/volume/volume_linux.goin kubernetes/kubernetes) runsos.Lchown(filename, -1, int(*fsGroup))and addsroMask = os.FileMode(0440)on a read-only volume. The file fails both checks today. - Runner.
renderRunnerManifests(tools/runner-manifests/render-core.ts) takes an absolutehttpsserverAddr, and nowserverCAandegressAllow. The hub holds one Runner (go/internal/runnerhub/hub.go). - Runner TLS name.
NewCATrustClient(go/internal/runner/tls.go) sets onlyRootCAsandMinVersion, and without--cathe Runner useshttp.DefaultClient. Go’saddTLS(net/http/transport.go) fills an emptyServerNamewith the dialed host, so the Runner always checks the certificate against the--serverhost. - Agent egress. The microVM arms a default-deny firewall.
--egress-allow(go/cmd/compass-runner/main.go) is the only way to open a host.isValidHost(go/internal/runtime/host.go) admits DNS names and IP literals, neverhost:port. - Issuer keys.
fetchHTTPS(go/internal/auth/workload_identity.go) sends noAuthorizationheader. AjwksFileentry never fetches.validateRunnerClusters(go/internal/auth/runner_clusters.go) requires issuers to be unique only among the configured entries. - Image lanes. Each lane publishes
:git-<sha12>by digest only when its closure set changed.resolveAncestor(tools/runner-image/retag-core.ts) walks first-parent ancestors and returns the newest published one;assertNoClosureChangefails when a skipped commit changed the closure.release-runner-imageandrelease-server-image(.github/workflows/release.yml) use them to mint:vX.Y.Zby digest. - Tag order. No published tag sorts by commit. Commit timestamps are not
unique: 29 of the last 100 main commits share their second with another
commit. Flux’s
RegexFilter.Apply(internal/policy/filter.goin fluxcd/image-reflector-controller) storesf.filtered[tag] = item, so two tags with the same extracted value collapse to one. - Node shape (spike findings).
TestFullBootpassed on a bare-metal node with/dev/kvm0666, a device plugin andhostUsers: false. A host with AppArmor neededkernel.apparmor_restrict_unprivileged_userns=0.
Design
Section titled “Design”Topology (D1). Each stack child that runs as a container today becomes its own pod. The Server container supervises nothing.
| Child | On Kubernetes |
|---|---|
| compass-server | Deployment, replicas: 1, strategy: Recreate |
| NATS | StatefulSet, 1 replica, PVC, DefaultNatsImage, the stack’s R1 file JetStream config |
| Postgres | operator-provided, outside the renderer (D3) |
| OTel collector | operator-provided; OTEL_EXPORTER_OTLP_ENDPOINT points at it |
| LLM gateway | not deployed; see the gateway record |
| compass-runner | the existing DaemonSet, on exactly one KVM node |
One Server replica, because the hub holds one Runner and the
delivery-cutover record
keeps a single Server. Recreate also keeps two Servers from migrating at
once. Exactly one Runner pod per Server: enroll (go/internal/runnerhub/hub.go)
replaces the attached Runner and drives its bound accounts offline, so a
second Runner drops the first one’s sessions. Only compass-stack needs a
gateway (“gateway image is required; pass –gateway-image or
–gateway-external”, go/internal/stack/stack.go); compass-server has no
gateway flag. Agents keep a raw provider key plus an egress allowlist (D7).
Server image (D2). ghcr.io/rigelbuild/compass-server follows the Runner
image’s shape. server-image/Dockerfile copies the realised
.#compass-server closure onto a digest-pinned distroless static-debian12
base. The image runs as 65532:65532, with /nix/store/.compass-server as
its entrypoint. The closure carries secretspec and the wrapper’s bash.
SERVER_IMAGE_CLOSURE_PATHS gates publishing.
Data and migrations (D3). Migrations stay in Server boot; there is no Job. The renderer never renders Postgres. The operator’s Postgres must meet this contract:
- The DSN names a direct, session-mode endpoint. The migration lock is session-scoped, so a transaction-mode pooler breaks it.
- The login role owns the target database, so it can create objects in
public. - PostgreSQL ≥16 with a login role that holds CREATEROLE plus BYPASSRLS, or
a superuser on any version.
0001_init.sqlcreates and re-asserts the BYPASSRLS role. Before 16, “You must be a superuser to create a new role having the BYPASSRLS attribute” (CREATE ROLE, 15); from 16, “Only superuser roles or roles with BYPASSRLS can specify BYPASSRLS” (CREATE ROLE, 16). A service whose owner role cannot hold BYPASSRLS cannot run today’s schema. - The database answers at boot. A suspended database fails the single ping, and the pod restarts until it answers.
- One replica fits the default pool of 10.
Secrets (D4). The renderer names Secrets but never holds a value. The
operator creates every Secret and backs up the master key; losing it is
unrecoverable (docs/self-host.md).
COMPASS_DATABASE_DSN,COMPASS_NATS_URLandCOMPASS_S3_*come from Secrets as env.- Server secrets come from one Secret key,
server.env, mounted read-only and read through--secret-provider dotenv:///etc/compass/secrets/server.env. The file holdsCOMPASS_MASTER_KEYand theSERVER_<name>values.SpecResolver.buildManifest(go/internal/secrets/resolver.go) declares exactly those names. Unlikeenv://, the file keeps the master key out of the process env. SERVER_*values re-resolve on use.COMPASS_MASTER_KEYis read once at boot and must never change in the mounted Secret.XDG_STATE_HOMEand--state-dirsit on one emptyDir under/run/compass. The state dir is a child the Server creates at0700(ensurePrivateDir,go/server/socket.go).- The admin token Secret mounts directly,
defaultMode: 0440, under podfsGroup: 65532.--admin-token-filenames the projected file. P4 makes the Server accept it. No init container and no init image. - Agents get forge tokens only from the Server’s App broker.
buildGitCredentialBroker(go/server/git_credential.go) returns nil unless the primary App id is set, andIsReservedGitHubEnvName(go/internal/secrets/secrets.go) strips userGH_TOKEN-style secrets. So the renderer takes aforgevalue and renders--forge-*args: ids and secret names, never values. The reviewer App is optional;wireForgeWriteCaller(go/server/serve.go) wires agentForgeCallwrites only when both Apps are set. - An admin stores each agent provider key with
compass secret set <NAME> --kind provider --provider <id> --delivery file.
NATS access (D5). The stack’s NATS has no auth because it listens on
loopback. In a cluster, NATS requires a user and password, and a
NetworkPolicy admits port 4222 only from the Server pod. The password reaches
the NATS config through a variable reference, and the credentials ride in
COMPASS_NATS_URL. The fabric’s connect path keeps URL credentials out of its
errors (go/internal/fabric/fabric.go).
Door, TLS and serverAddr (D6). The Server terminates its own TLS. No ingress may terminate it: the door refuses cleartext, and the Runner’s bidi stream needs h2 end to end.
- Certificate. The operator supplies a
kubernetes.io/tlsSecret whose SANs cover the in-cluster name and any external name. A certificate that names only the public host also works when the Runner setsserverName(P9). A private CA works; the Runner’sserverCAvalue and the client--cacarry it. - Renewal. The door loads its keypair once, so a renewed certificate takes effect on restart (see Known limitation).
- Services. Service
compass-server(ClusterIP) maps 443 to 50052. An optional LoadBalancer Service on 50052 passes TCP through. - serverAddr is
https://compass-server.<namespace>.svc. - Clients. The CLI uses
--server-addr,--caand the admin token (go/cmd/compass/client.go). The desktop app uses client mode withca_cert(docs/self-host.md). A browser origin comes fromcorsAllowedOrigin. - Probes. Startup and readiness are HTTPS
httpGeton/healthz. Readiness never depends oncompass.runner, because the Runner needs the Service before it can enroll. There is no liveness probe.
Runner values (D7).
- Identity. A cluster whose issuer serves no anonymous discovery uses a
jwksFileentry: the operator reads/openid/v1/jwksat render time and passes it as ajwksvalue, and the Server renderer writes it into the runner-clusters ConfigMap. This needs no Go change. A signing-key rotation then needs a re-render and a Server rollout. An issuer with anonymous discovery keepsjwksURI. - CA.
serverCAmounts the CA and setsCOMPASS_RUNNER_CA. - Server name.
serverNamesetsCOMPASS_SERVER_NAME, the name the Runner checks on the certificate (P9). - Egress.
egressAllowrenders one--egress-allow=<hosts>arg. The renderer applies theisValidHostgrammar, sohost:portfails at render time. A session needs the provider API host,github.comandapi.github.com. - Node. The KVM node exposes
/dev/kvmas the extended resource named bykvmResourceName, stages the seccomp profile atseccompProfilePathunder the kubelet seccomp root, and supportshostUsers: false. - Session volumes.
hostPaths.sessionVolumeRootis a directory with no project id. The operator createsvolumes/inside it and puts the project id,FS_XFLAG_PROJINHERITand the limits onvolumes/. The Runner’s volume root is that subdirectory (P8). The quota check needs an unprojected reference above the volume root, and in the pod the only one is the top of the hostPath mount.
Delivery split and paired tags (D8). Public compass holds
tools/server-manifests/ and tools/runner-manifests/: values in, plain
objects out, digest-only images, no Secrets. The operator holds the values
for each cluster, the Secrets, the certificate, node config, and how the
output is applied. The head commit of every main push gets
:main-<n>-<sha12> on both the Server and Runner images (P6). n is that
commit’s first-parent count on main. Each lane’s tag names that lane’s newest
publish at or before the commit, re-tagged by digest with no rebuild. Both
tags of one n therefore pair the images built from the same Go inputs, as
long as both closure sets cover all of go/** (compass PR #2357). Main
requires linear history and forbids force-pushes, so n is unique and rises
with each push. A push of several commits tags only its head, so n has
gaps; a numerical policy does not care. Image automation follows the tags
with a numerical policy on n:
filterTags: pattern: '^main-(?P<n>[1-9][0-9]*)-[0-9a-f]{12}$' extract: '$n'policy: numerical: order: ascdigestReflectionPolicy: IfNotPresentOnly the digest may change a pod spec. The tag changes on every push even
when a lane’s digest does not, so a pod spec that carries the tag would roll
the Server and the Runner on every push and drop every session. The
renderers keep rejecting a tagged ref (assertRunnerImageDigest,
assertServerImageDigest). The operator’s setter therefore writes only the
:digest field of the policy ({"$imagepolicy": "<ns>:<policy>:digest"}),
for example on a kustomize images[].digest override of the rendered
output. Values files are JSON and carry no setter comment.
One automation over both policies bumps both digests in one commit. No operator bump job is needed. The tagger writes the Server tag, then the Runner tag, seconds apart. A scan between the two writes sees a new Server with the previous Runner for one interval; the next scan pairs them.
Public images (D9). Every Compass image is public:
compass-agent, compass-runner, compass-server, compass-guest-image
and compass-gateway under ghcr.io/rigelbuild. On 2026-10-17 all five
answered skopeo list-tags --no-creds. Neither renderer emits
imagePullSecrets. A command that only reads the registry (skopeo inspect, podman manifest add) sends no credentials; a write (skopeo copy, podman manifest push) uses them for the destination only (P5).
One read is part of a write: the probe of the exact tag a push is about to
write uses the login file. GHCR answers an anonymous request for a package
that does not exist yet with 403, and every probe classifier aborts on
403, so an anonymous probe would stop the push that creates the package.
GitHub’s REST packages API can list, get, delete and restore a
package, but has no call that sets visibility. A new package starts private,
and making it public is a one-way switch in the package settings: a manual
step at its first publish
(package visibility).
Multi-arch images (D10). Every Compass image is published for
linux/amd64 and linux/arm64, built in compass. :git-<sha12>,
:main-<n>-<sha12> and :vX.Y.Z name an OCI index of the two members, and
each member is pushed first as :git-<sha12>-<arch>. This is the agent
lane’s shape today: publish-image-amd64 and publish-image-arm64
(.github/workflows/release.yml) push the members, and
publishImageIndex (tools/agent-image-index/index.ts) composes and checks
the index against EXPECTED_PLATFORMS = "linux/amd64,linux/arm64". The
other four lanes are single-arch now:
- Runner:
IMAGE_PLATFORM = "linux/amd64"and an x64-only host check (tools/runner-image/build.ts). - Server: “server-image targets linux/amd64, but this host is
${process.arch}” (
tools/server-image/build.ts). - Gateway:
PLATFORM = "linux/amd64", andlayoutDigest(tools/gateway-image/core.ts) requires exactly one manifest. - Guest image: one artifact manifest.
fetchGuestManifest(go/internal/stack/materialize.go) accepts only a single manifest.
flake.nix lists systems = [ "x86_64-linux" ]. manifestIdentity
(tools/runner-image/retag-core.ts) refuses an index (“an image index, or
malformed”), so retag and the P6 tagger must learn to verify one.
The guest boot path is also amd64-only, in three ways. The guest agent pin record named them and left them to a follow-up (its T5). P7 is that follow-up.
- Kernel file. At the pinned nixpkgs,
linuxPackages.kernel.targetisbzImageonx86_64-linuxandImageonaarch64-linux.bzImageis hard-coded inkernelImagePath(tools/runner-image/build-core.ts), the kernel path intools/guest-image/publish.ts,guestImageEnv(tools/microvm-boot-test/run-core.ts), themicrovmjob in.github/workflows/ci.yml, and the kernelannotationNameinguestAssets(go/internal/stack/materialize.go). That name must equalASSET_FILENAMES.kernel(tools/guest-image/publish-core.ts), becausevalidateGuestLayerchecks the layer annotation. - Boot modules.
moduleConfigCheck(guest-image/default.nix) fails unless eight symbols are=m. The aarch64 kernel config hasCONFIG_VIRTIO_PCI,CONFIG_VIRTIO_BLK,CONFIG_VIRTIO_NETandCONFIG_PACKETas=y, so the arm64 initrd build fails. A built-in module is harmless: kmodmodprobeexits 0 for it, andmakeModulesClosurerecords it as abuiltindependency. - Console.
vmmArgs(go/internal/runtime/microvm/launch.go) always appendsconsole=ttyS0. The arm64 serial device isttyAMA0, as cloud-hypervisor’s AArch64 example shows, so guestd’s stderr would miss the captured console.
P7 fixes all three, with a unit test per arch for each. No CI job can boot
an arm64 guest: GitHub’s hosted arm64 runners expose no /dev/kvm
(runner-images #14062).
So P7 adds an arm64 build gate, not a boot gate. When P7 lands, the Server
and NATS run on a node of either arch. The Runner image ships both members,
but the Runner is supported on amd64 only until an arm64 microVM boot
passes (OQ-13).
Known limitation
Section titled “Known limitation”A Server restart ends every live session. A dropped session stream ends
Run (go/internal/runner/run.go, “runner sessions loop: %w”), and the
Runner exits. The Runner has no liveness probe because “a pid-1 restart tears
down all node sessions” (tools/runner-manifests/render-core.ts). Each
Server deploy and each certificate renewal therefore drops sessions, and D8
makes deploys frequent. A separate record makes the Runner survive a Server
restart: it re-dials, re-enrolls with the hub, and keeps its microVMs.
Alternatives considered
Section titled “Alternatives considered”- One pod running
compass-stack up. Rejected: the supervisor drives podman and host processes. - An init container that copies the admin token. Rejected: it adds an image value and a pod step for a check the Server can make itself (P4).
- A renderer-owned Postgres StatefulSet. Rejected: no upgrade path and no point-in-time recovery, and it would read as a supported surface.
- Ingress-terminated TLS. Rejected: an extra h2 hop and a second certificate.
- A migration Job. Rejected: no migrate-only entrypoint exists, and the advisory lock serializes migrations.
- An operator bump job, or per-image automation on
:git-<sha12>. Rejected: the first is a scheduled job per operator; the second has no order and pairs nothing. - A timestamp tag. Rejected: equal timestamps collapse in the policy.
Global Constraints
Section titled “Global Constraints”- Public repo. No operator names, hosts, zones, cluster names or private product detail.
- PRs. Each task is one compass PR, red then green. P2 merges after P4,
because its output boots only with P4. P5, P6 and P7 form one stack in
that order: all three edit
tools/runner-image/andrelease.yml. P8 edits onlytools/runner-manifests/. P9 edits the Runner’s TLS client, its flags andtools/runner-manifests/, but no line that P8 edits. P8 and P9 are independent of the rest. - Images. Digest only. Renderers reject tags, as
assertRunnerImageDigestdoes. Every image is an OCI index oflinux/amd64andlinux/arm64once P7 lands. - Registry auth. Reads send no credentials; writes use credentials for the destination only. The probe of the tag a push is about to write is part of the write and uses the login file (D9).
- Secrets. Renderers emit no Secret object and no secret value.
- Kubernetes. ≥1.33, for user namespaces.
- PostgreSQL. ≥16 for a non-superuser owner role (D3).
- Server pod. uid and gid 65532,
fsGroup: 65532,readOnlyRootFilesystem, drop ALL,RuntimeDefaultseccomp,automountServiceAccountToken: false. - Lanes. TypeScript is Bun with
bun:test. Go DB tests use thepgtestbuild tag. - Spec impact. P4 changes the requirement “The network door mints and
persists a bootstrap admin token” in
docs/specs/product/compass.md. P9 adds acompass-runner --server-namerequirement to the same file. No other task changes a spec.
P1 server image (#2355, merged) ─┐P3 runner values (#2360, merged) ┤P8 runner volume root ───────────┼──► operators render and deployP9 runner TLS server name ───────┤P4 admin token (Go) ──► P2 server renderer (#2363) ┘P5 anonymous reads ──► P6 paired main tags ──► P7 multi-arch imagesDeferred: F1 door TLS reload, F2 tokenFileP1 — Server image and publish lane
Section titled “P1 — Server image and publish lane”Merged as compass PR #2355, after #2357 widened RUNNER_IMAGE_CLOSURE_PATHS
to go/**. Its smoke checks --version, a missing-DSN exit 1, user
65532:65532, a read-only dotenv:// boot with Postgres and NATS, and a
reproducible digest. Files: server-image/{Dockerfile,moon.yml,.dockerignore},
tools/server-image/{build.ts,build-core.ts}, --lane in
tools/runner-image/{publish.ts,retag.ts}, and the publish-server-image and
release-server-image jobs. SERVER_IMAGE_CLOSURE_PATHS is
server-image/**, tools/server-image/**, tools/runner-image/**, go/**,
tools/toolchain/secretspec-env.nix, devenv.lock, flake.nix,
flake.lock, version.txt and .github/workflows/release.yml.
P2 — Server manifests renderer
Section titled “P2 — Server manifests renderer”The first renderer merged as compass PR #2363. A follow-up PR against main must drop
initImage, the init-admin-token init container and the postgres value
with its StatefulSet, Service and NetworkPolicy. It must add forge,
nodeSelector and toleration. Tests use golden output plus invariants:
replicas: 1withRecreate.serverImageandnatsImageaccept onlyrepo@sha256:<64 hex>. A tagged ref,repo:tag@sha256:…included, fails parse with the field name.- No
Secretkind, noinitContainers, no Postgres object. parseServerDeployValuesrejects apostgresorinitImagekey with an error naming the key, so an older values file fails instead of rendering without them.- The admin token volume has
defaultMode: 0o440, and--admin-token-filenames the file in that volume. - The NATS NetworkPolicy admits 4222 only from the Server label.
- Probes hit
/healthz; no liveness probe. XDG_STATE_HOMEand--state-dirsit on the emptyDir, and the state dir is not its root.- A
runnerClustersentry withjwksrenders a ConfigMap key and a matchingjwksFile;jwksplusjwksURIis rejected. forgerenders--forge-host,--forge-app-id,--forge-installation-id,--forge-app-key-secret,--forge-app-webhook-secret, and--forge-reposwhen set. Ids are positive integers.forge.revieweradds the three--forge-reviewer-*args. An absent value renders no arg.nodeSelectorandtolerationrender as in the Runner renderer, withoperator: "Equal"; absent values render none.
The README covers the render steps, the JWKS read, the server.env lines,
and the never-change rule for COMPASS_MASTER_KEY. It states the D3
Postgres contract with its floor: PostgreSQL ≥16 for a CREATEROLE plus
BYPASSRLS owner, or a superuser on any version. It states that image
automation must write only the digest (D8).
P3 — Runner renderer serverCA and egressAllow
Section titled “P3 — Runner renderer serverCA and egressAllow”Merged as compass PRs #2358 and #2360. serverCA takes exactly one of
configMapName or secretName, plus key, mounts at
/etc/compass/server-ca and sets COMPASS_RUNNER_CA. egressAllow renders
one --egress-allow= arg and rejects hosts outside isValidHost.
P4 — Admin token file from a Secret volume (Go)
Section titled “P4 — Admin token file from a Secret volume (Go)”readSuppliedAdminToken opens the path once with
O_RDONLY|O_NONBLOCK|O_CLOEXEC, which follows the symlink, and checks the
open file’s Stat before it reads. O_NONBLOCK keeps the open of a FIFO
from blocking boot; today Lstat fails a FIFO before any open. A non-regular
file fails before any read. Checking the open file removes the gap between
check and read. The file must be regular, with no setuid, setgid or sticky
bit, no group-write bit and no other bit. Group-read is allowed only when
the file’s gid is the process’s os.Getegid() or in os.Getgroups().
kubelet’s 0440 file under fsGroup passes; a 0640 file in a group the
process does not hold fails. writeSuppliedAdminTokenFile still copies the
token to a 0600 file under the state dir, and reusableAdminToken keeps its
strict Lstat check for that copy. Both are unchanged.
Tests, red first:
go/server/admin_token_mode_test.go(//go:build unix), on the pure check: 0600 passes; 0640 and 0440 pass with a held gid; 0640 fails with an unheld gid; 0660, 0604, a directory, a FIFO and a setgid file fail.TestIssueAndWriteAdminTokenRejectsMalformedSuppliedFiles(go/server/admin_token_reuse_pgtest_test.go) drops its “group readable” and “symlink input” rejection cases. It adds “group writable” (0660), “other readable” (0604), and “fifo”: asyscall.Mkfifoat 0600 with no writer, plus a symlink to it. Both fail with “must be a private regular file” within a 5-second test deadline. A plainos.Openwould block there.- A new pgtest builds the Secret volume layout:
..data/tokenat 0440 inos.Getegid(), andtoken -> ..data/token. Boot registers the token,assertAdminTokenAuthenticatespasses, and the state-dir copy is 0600.
Spec impact: the requirement “The network door mints and persists a
bootstrap admin token” (docs/specs/product/compass.md) says the file
“SHALL name a private regular file”. It changes to: a regular file, reached
directly or through symlinks, with no other bits and no group-write bit, and
group-read only for a group the Server process holds. The
--admin-token-file help in go/cmd/compass-server/main.go (“Private file
containing the supplied bootstrap-admin token”) changes to match.
P5 — Anonymous registry reads
Section titled “P5 — Anonymous registry reads”The five packages are public already. P5 makes every registry read in compass anonymous except the destination probe, so D9 holds, and keeps the packages public.
Dropping --authfile is not enough. Every publish job writes
REGISTRY_AUTH_FILE=$RUNNER_TEMP/ghcr-auth.json to $GITHUB_ENV
(.github/workflows/release.yml). skopeo’s sharedImageFlags defaults
--authfile to os.Getenv("REGISTRY_AUTH_FILE"). podman’s
GetDefaultAuthFile returns the same variable first, and podman manifest add has no --no-creds. So each read names its anonymity:
- Every
skopeo inspectof a registry ref passes--no-creds. skopeo then sets an emptyDockerAuthConfig, whichGetCredentials(containers/image,pkg/docker/config) returns before any auth file. - The exception is the destination probe: the
inspectof the exact tag a push is about to write, whose answer decides publish, skip or abort. It is part of the write and uses the same credentials as the push. On 2026-10-17, an anonymous probe of a package that does not exist got “Requesting bearer token: received unexpected HTTP status: 403 Forbidden”; a missing tag on an existing public package got “manifest unknown”. The pre-push classifiers free the tag only on a not-found answer:tagDisposition(tools/guest-image/publish-core.ts,tools/gateway-image/core.ts),guard_immutable(agent-image/publish.sh) andisManifestUnknown(tools/agent-image-index/index.ts). So an anonymous probe would abort the push that creates a new package. - Every
skopeo copypasses--src-no-credsand--dest-authfile <file>, never a bare--authfile. The rule holds for a local source too, where the source flag is a no-op. podman manifest addpasses--authfile <runnerTemp>/anonymous-auth.json, a file holding{}. A named auth file turns off every fallback path ingetAuthFilePaths(if !userSpecifiedPath).podman manifest pushkeeps the login file.
The sites:
tools/runner-image/:retag.tsbuilds its probe, copy and verify argv with newinspectArgvandcopyArgvinretag-core.ts. Its copy today passes no auth flag and leans on the environment; it now requiresREGISTRY_AUTH_FILEand failsEXIT.usagewithout it.tagArgs(publish-core.ts) moves from--authfileto the copy pair.tools/agent-image-index/index.ts:inspectCommanddropsauthFileand passes--no-creds.guardImmutableTagprobes:git-<sha12>with a newdestinationProbeCommandthat carries the login file.composeLocalIndexadds members with the empty auth file.tools/guest-image/publish.tsandtools/gateway-image/publish.ts: the pre-push probe keeps itsauthspread, the login file whenREGISTRY_AUTH_FILEis set. The post-push re-read passes--no-creds. The copy passes--src-no-creds, plus--dest-authfilewhenREGISTRY_AUTH_FILEis set.tools/release-notes/index.ts:gatherImageadds--no-creds.tools/guest-image/pin-agent-image.tspasses it already.- The
release-imagejob: threeskopeo inspect --authfile "$REGISTRY_AUTH_FILE"reads (the ancestor probe and two digest checks) and oneskopeo copy --multi-arch all. All three are source or verification reads, so all pass--no-creds. agent-image/publish.sh: theguard_immutableprobe of$destkeeps--authfile "$REGISTRY_AUTH_FILE". The post-copy re-read passes--no-creds, and thecopy "nix:$SPEC" "$dest"passes the copy pair. The pinned fork skopeo (tools/toolchain/skopeo-nix2container-env.nix, 1.24.1) has--no-creds,--src-no-credsand--dest-authfile.
classifyProbe still reads 403 as ambiguous, because a private package and
a missing package give the same 403.
Stale text goes: the release-runner-image step comment “The runner package
is private, so even the probes need auth”, the retag.ts header “so private
packages work”, and the retag-core.test.ts case name “a private package
without auth (403 Forbidden) is ambiguous” and its “on this private package”
comment.
A new check runs as the last step of each of the five publish jobs. It reads the just-published tag anonymously and fails with the package settings path when the package is not public. A new package therefore fails its first publish until someone sets it public: the one manual step, because no API sets visibility. On a new package, the anonymous post-push re-read fails first, with 403. The re-run after the package is set public finds the tag through the destination probe, skips the push, and passes the check.
Tests, red first. Each asserts the flag on every read, and the login file on the destination probe:
retag-core.test.ts:inspectArgvcarries--no-creds;copyArgvcarries--src-no-credsand--dest-authfileand no--authfile.publish-core.test.ts:tagArgscarries the copy pair and no--authfile.tools/agent-image-index/index.test.ts: theguardImmutableTagprobe of:git-<sha12>carries--authfile <AUTH>and no--no-creds; every other recordedskopeo inspectcarries--no-creds; everypodman manifest addcarries the empty auth file, whose content is{}; onlypodman manifest pushand the destination probe carry the login file.tools/guest-image/publish.test.ts: the case “threads –authfile to every registry call when the login pinned one” now requires--authfile /tmp/creds.jsonon the pre-pushinspect,--src-no-creds --dest-authfile /tmp/creds.jsonon thecopy, and--no-credson the re-read. A new case stubs skopeo to answer 403 to aninspectwithout--authfileand “manifest unknown” to one with it, and requires thecopyto run.tools/gateway-image/publish.test.ts: the case “passes the auth file to probe, copy, and re-read” expectsinspect --authfile /tmp/registry-auth.json --raw, thencopy --src-no-creds --dest-authfile /tmp/registry-auth.json, theninspect --no-creds --raw.- A new
tools/runner-image/registry-auth.test.tsjoins continuation lines inrelease.ymlandagent-image/publish.sh. Every skopeoinspectof a non-nix:ref must carry--no-creds. Everycopymust carry--src-no-credsand--dest-authfile. Onlyloginmay carry--authfile. It checks thegatherImagetemplate the same way. One case is allowed: the body ofguard_immutableinagent-image/publish.sh(fromguard_immutable() {to the next line that is exactly}) holds exactly oneinspect, and it must carry--authfile "$REGISTRY_AUTH_FILE"and no--no-creds. Today’s sources fail the first three rules. assert-public-core.test.tsmaps a manifest to public, and a 403, a “manifest unknown” and a timeout to failures that name the package.
P6 — Paired main tags
Section titled “P6 — Paired main tags”A new job tag-main-images in .github/workflows/release.yml runs on every
main push and tags the push’s head commit. It
needs: [publish-runner-image, publish-server-image], so it runs after both
lanes publish or skip, and never when either failed. It has
permissions: {contents: read, packages: write}, its own concurrency group
with cancel-in-progress: false and queue: max, and a checkout with
fetch-depth: 0; a shallow clone gives a wrong n.
tag-main.ts computes n with git rev-list --count --first-parent <sha>.
For each lane it runs resolveAncestor over git rev-list --first-parent --max-count=50 <sha> and assertNoClosureChange over the diff from the
resolved commit, as retag.ts does. It resolves both lanes before any write.
Then it copies each digest to :main-<n>-<sha12> with --preserve-digests,
Server first, and checks each with assertCoherent. A lane that published
this commit resolves at position 1.
Tests, red first:
retag-core.test.ts:mainTag(1638, <40-hex>)returnsmain-1638-<sha12>; 0, a fraction and a short sha throwEXIT.usage.resolveMainPair: both lanes found at the head; a lane absent at the head with no closure change resolves its ancestor; a lane absent with a closure change throws, and the injected writer records no call.tools/runner-image/closure-paths.test.ts:go/gen/compass/v1/compass.pb.gohits both closure sets throughclosureChanges;tag-main-imagesneeds both publish jobs and checks out withfetch-depth: 0.- The tag matches the D8 pattern, and Flux’s extract yields
n.
P7 — Multi-arch images
Section titled “P7 — Multi-arch images”Every lane publishes linux/amd64 and linux/arm64 members and one OCI
index, as the agent lane does (D10).
- Closures.
flake.nixsystemsaddsaarch64-linux, so.#compass-server,.#compass-runnerand the guest assets evaluate on both.guest-image/default.nixalready selects its agent member per system (agentPin.platformFor). - Build guards.
hostPlatform(tools/runner-image/build-core.ts) maps the host (x64→linux/amd64,arm64→linux/arm64) and throws on any other host.tools/runner-image/build.tsandtools/server-image/build.tscall it; the server lane already imports this module.PLATFORMintools/gateway-image/core.tsbecomes a parameter. TheIMAGE_PLATFORMcomment intools/runner-image/build.ts(“the cluster nodes are amd64”) goes. - Jobs. Each of
publish-runner-image,publish-server-image,publish-guest-imageandpublish-gateway-imagebecomes a matrix overubuntu-latestandubuntu-24.04-arm, aspublish-image-amd64andpublish-image-arm64are. Each leg pushes:git-<sha12>-<arch>by digest. The change gate runs once, in a<lane>-gatejob that both legs need. - Index. A new
publish-<lane>-indexjob per lane needs both legs and composes:git-<sha12>.publishImageIndexmoves from the agent-onlyIMAGE_REFto arepoinput, so all five lanes share it and itsEXPECTED_PLATFORMScheck. - Guest artifact. The guest index carries two artifact manifests with
platformset.fetchGuestManifest(go/internal/stack/materialize.go) accepts an index and picks the member forruntime.GOARCH; a digest that names one manifest still works. - Retag and the P6 tagger.
manifestIdentityaccepts an index and returns the index digest plus each member’s platform and digest.assertCoherentcompares the index digest and the member list. Every copy uses--multi-arch all --preserve-digests.tag-main-imagesneeds the index jobs, not the legs. - Kernel file.
kernelFileName(tools/runner-image/build-core.ts) mapslinux/amd64tobzImageandlinux/arm64toImage. It is the only TypeScript table.kernelImagePath,buildArgs, the kernel path intools/guest-image/publish.ts,assetFilenames(it replacesASSET_FILENAMESintools/guest-image/publish-core.ts) andguestImageEnvtake the platform and call it. Each entry point passeshostPlatform(process.arch).tools/guest-image/moon.ymlandtools/microvm-boot-test/moon.ymladd/tools/runner-image/*-core.tsto their inputs, astools/server-image/moon.ymldoes. In Go, a newguestKernelName(goarch)(go/internal/stack/materialize.go) returnsImageforarm64andbzImageotherwise, and the kernel entry ofguestAssetstakes itsannotationNamefromguestKernelName(runtime.GOARCH). An amd64 artifact then failsvalidateGuestLayeron an arm64 host.pinSmoke(guest-image/default.nix), which thecompass-guest-image:eval-arm64task evaluates, also asserts thatcompass-guest-kernel.targetisbzImageforx86_64-linuxandImageforaarch64-linux, so a nixpkgs move that renames the file fails a gate. Themicrovmjob inci.ymlstays on amd64 and keeps$kernel/bzImage. - Boot modules.
moduleConfigCheck(guest-image/default.nix) accepts=mor=y. It fails only when a symbol is absent oris not set. It becomes a function of the config file, so a fixture test can run it. - Console. A new
serialConsole(goarch)(go/internal/runtime/microvm/launch.go) returnsttyS0foramd64andttyAMA0forarm64, and errors on any other arch.launchcalls it withruntime.GOARCHbefore it starts a child and passes the result tovmmArgs, which appendsconsole=<result>. - arm64 build gate. A new
guest-arm64job in.github/workflows/ci.ymlruns onubuntu-24.04-armwhenmicrovm_affectedis true, asagent-fod-arm64does, and the rollup checks it the same way. It runsbun tools/microvm-boot-test/run.ts --build-only. That mode builds the guest and the VMM stack, fails when the kernel file is missing, and skipsgo test. It cannot boot: hosted arm64 runners have no/dev/kvm. - arm64 Runner support (OQ-13).
tools/runner-manifests/README.mdgains the line “Runner nodes must be amd64.” The PR that removes it carries a passingmoon run compass-go:test-microvmlog from an arm64 KVM host with GICv3. Until then, the Runner’s boot canary fails closed on a node that cannot boot the guest. - Docs.
docs/self-host-guest-image.mdnamesImagebesidebzImagein its file table and adds anorg.compass.guest.layer.Imagerow. Comments that name onlyttyS0orbzImagename both:go/internal/runtime/microvm/config.go,go/internal/guestd/guestd.go,go/cmd/compass-guestd/main.goandguest-image/default.nix.
Tests, red first:
retag-core.test.ts:manifestIdentityon a two-member index returns both members; an index missinglinux/arm64throwsEXIT.badManifest;assertCoherentfails on a member digest change.tools/agent-image-index/index.test.ts:publishImageIndexwith acompass-serverrepo composes the index from the-amd64and-arm64tags.go/internal/stack/materialize_test.go: an index resolves to theruntime.GOARCHmember; an index with no such member fails and names the arch.guestKernelName("arm64")isImageandguestKernelName("amd64")isbzImage.tools/runner-image/closure-paths.test.ts, which already parsesrelease.yml: each of the four publish jobs has anubuntu-latestand anubuntu-24.04-armleg, and eachpublish-<lane>-indexjob needs both.tools/runner-image/build-core.test.ts:hostPlatform("arm64")returnslinux/arm64, andhostPlatform("ia32")throws. Forlinux/arm64,kernelImagePathandbuildArgs(…).GUEST_KERNELend in/Image; forlinux/amd64they keep/bzImage.tools/guest-image/publish-core.test.ts:layoutPlanforlinux/arm64writesorg.compass.guest.layer.Imageand nobzImagekey.tools/microvm-boot-test/run-core.test.ts:guestImageEnvforlinux/arm64setsCOMPASS_TEST_GUEST_KERNELto<kernel>/Image.go/internal/runtime/microvm/launch_cmdline_test.go:serialConsolereturnsttyS0foramd64andttyAMA0forarm64, and errors forriscv64.vmmArgsgiventtyAMA0carriesconsole=ttyAMA0and nottyS0.- A new
guest-image/module-check-tests.nix, built by thecompass-guest-image:testtask: a config with all eight symbols=mor=ypasses; one symbolis not set, or absent, fails with “BUILD-BREAK”. compass-guest-image:eval-arm64: the kernel-target assertion passes at the pin, and fails when its expected map swaps the two names.
P8 — Runner volume root below the session mount
Section titled “P8 — Runner volume root below the session mount”renderRunnerManifests (tools/runner-manifests/render-core.ts) mounts
hostPaths.sessionVolumeRoot at SESSION_MOUNT_PATH
(/var/lib/compass/sessions) and sets COMPASS_MICROVM_VOLUME_ROOT to
SESSION_MOUNT_PATH too, with COMPASS_MICROVM_QUOTA_REQUIRED=true.
mountRoot (go/internal/runtime/microvm_quota_linux.go) walks up until
st_dev changes. In the pod, that boundary is the top of the hostPath bind
mount. So for the volume root, mountRoot returns distinct=false, and
readVolumeQuota fails startup: “point –microvm-volume-root (or
$COMPASS_MICROVM_VOLUME_ROOT) at a SUBDIRECTORY of the quota’d filesystem
rather than at its mount point”. Every rendered Runner fails its preflight.
The fix is a subdirectory. A new const SESSION_VOLUME_ROOT is
${SESSION_MOUNT_PATH}/volumes, and COMPASS_MICROVM_VOLUME_ROOT takes it.
The mount and the hostPath (type: "Directory") are unchanged.
The quota also moves down. The kernel projects the quota into statfs only
for an inode that has the project-inherit flag: xfs_fs_statfs
(fs/xfs/xfs_super.c) checks XFS_DIFLAG_PROJINHERIT, and ext4_statfs
(fs/ext4/super.c) checks EXT4_INODE_PROJINHERIT. If the hostPath itself
carried the project, its root would read projected totals, and the check
would compare the project with itself. So the operator puts the project on
<sessionVolumeRoot>/volumes and leaves sessionVolumeRoot without one.
The renderer cannot create or check the subdirectory. A missing one fails
closed: readVolumeQuota returns statfs "<path>": …, as
TestReadVolumeQuotaAbsentPath requires.
The alternative keeps the volume root at the mount and makes the hostPath
the quota’d directory. It does not work: the volume root is still the top of
the mount, so distinct stays false. Any layout that passes needs an
unprojected directory at the top of the mount and the project below it.
The README’s “Other node prerequisites” bullet for the session tree says to
put the project quota on <sessionVolumeRoot>/volumes, owned by uid and gid
65532, and not on sessionVolumeRoot.
Tests, red first, in tools/runner-manifests/render-core.test.ts:
- “uses runtime settings consumed by compass-runner”:
COMPASS_MICROVM_VOLUME_ROOTis/var/lib/compass/sessions/volumes. - New: the volume root is a strict subdirectory of the
session-volumesmountPath. The test reads both from the rendered DaemonSet, so it fails on today’s output, where they are equal. - “mounts only the session tree, runtime tree, and projected token” keeps
/var/lib/compass/sessionsas the mount path.
The Runner renderer has no golden file. tools/server-manifests/golden.yaml
holds no Runner object, so it does not change.
P9 — Runner TLS server name
Section titled “P9 — Runner TLS server name”An operator’s certificate may name only the public host, while
serverAddr is the in-cluster Service (D6). The Runner then checks the
certificate against the Service name and fails with x509.HostnameError
(see Ground truth, Runner TLS name). The Server needs no
change: the door holds one keypair (loadNetworkTLS,
go/server/network_door.go), and Go’s getCertificate
(crypto/tls/common.go) returns a lone certificate whatever the SNI.
compass-runner gains --server-name, default $COMPASS_SERVER_NAME. It
sets tls.Config.ServerName in go/internal/runner/tls.go: the name the
Runner checks on the certificate and sends as SNI. It changes no trust
root, so the certificate must still chain to --ca or the host roots. A new
runner.NewServerClient(caPath, serverName) builds the client. An empty
caPath leaves RootCAs nil, which means the host roots, so the setting
also works without --ca. A non-empty caPath is read with the
NewCATrustClient errors. The client keeps TLS 1.3 minimum and HTTP/1.1
plus HTTP/2. Like the --ca client today, it reads no proxy variables.
NewCATrustClient keeps its contract and its CLI and e2e callers; both
functions share one transport builder.
In go/cmd/compass-runner/main.go, a new runnerHTTPClient replaces the
--ca block. It resolves both values with orEnv. When both are empty, it
returns http.DefaultClient, as httpClientFor (go/cmd/compass/client.go)
does. Dial (go/internal/runner/runner.go) already uses that client for a
nil one, so with no server name the behaviour is unchanged.
The Runner renderer gains an optional serverName. It uses the
isValidEgressHost grammar, so a port or a URL fails at render time. When
set, the container gets COMPASS_SERVER_NAME, placed after
COMPASS_RUNNER_CA. When unset, the output is unchanged. In
tools/runner-manifests/README.md, “Two optional values:” becomes three.
The new serverName bullet says: set it when serverAddr is the in-cluster
Service and the certificate names only a public host.
P9 is independent of P2 and P4. It shares three files in
tools/runner-manifests/ with P8, but no lines: P8 edits the
COMPASS_MICROVM_VOLUME_ROOT entry, the runtime-settings test and the
README’s node prerequisites.
Spec impact: under ## Runner enrollment CLI in
docs/specs/product/compass.md, a new requirement follows the --ca
requirement: “compass-runner --server-name sets the name checked on the
Server’s certificate”. The Runner SHALL check the certificate against that
name instead of the --server host, and SHALL trust the same roots. When it
is unset, the Runner SHALL check the --server host. Its scenario is the
first test below.
Tests, red first:
go/internal/runner/tls_test.go, newTestServerNameOverride: acertgencertificate forcompass.example.testonly, served bystartTLSRunnerServerat127.0.0.1, with that certificate ascaPath.NewServerClient(caPath, "compass.example.test")enrolls.NewServerClient(caPath, "")fails at the handshake withx509.HostnameError.NewServerClient("", "compass.example.test")has a nilRootCAs, thatServerNameandMinVersionTLS 1.3. A missing or non-PEMcaPathfails with theNewCATrustClientmessages.go/cmd/compass-runner/main_test.go, newTestServerNameFlagAndEnvironmentFallback, shaped likeTestAgentBatchingFlagAndEnvironmentFallback: both values unset returnshttp.DefaultClient;$COMPASS_SERVER_NAMEalone gives a client with thatServerName; the flag overrides the environment.tools/runner-manifests/render-core.test.ts, newdescribe("serverName")afterdescribe("serverCA"): unset renders noCOMPASS_SERVER_NAME; set renders it;"",compass.example.test:443andhttps://compass.example.testthrow “serverName”.
Interfaces
Section titled “Interfaces”// tools/server-manifests/render-core.ts (P2)export type RunnerClusterValues = { name: string; issuer: string; audience?: string; namespace: string; serviceAccount: string; maxTokenLifetime?: string; jwksURI?: string; jwks?: string; caFile?: string;};export type ForgeValues = { host?: string; repos?: string[]; appId: number; installationId: number; appKeySecret: string; appWebhookSecret: string; reviewer?: { appId: number; installationId: number; appKeySecret: string };};export type ServerDeployValues = { namespace: string; serverImage: string; natsImage: string; natsStorage: string; tlsSecretName: string; dsnSecretName: string; natsSecretName: string; serverSecretsSecretName: string; adminTokenSecretName: string; s3SecretName?: string; corsAllowedOrigin?: string; otlpEndpoint?: string; external?: { port: number; loadBalancerClass?: string }; nodeSelector?: { key: string; value: string }; toleration?: { key: string; value: string; effect: "NoSchedule" | "PreferNoSchedule" | "NoExecute" }; forge?: ForgeValues; runnerClusters: RunnerClusterValues[];};export function assertServerImageDigest(ref: string): void;export function parseServerDeployValues(raw: unknown): ServerDeployValues;export function renderServerManifests(v: ServerDeployValues): ReturnType<typeof renderRunnerManifests>;// server args: --listen 0.0.0.0:50052 --tls-cert /etc/compass/tls/tls.crt// --tls-key /etc/compass/tls/tls.key --state-dir /run/compass/state// --socket /run/compass/state/server.sock// --admin-token-file /var/run/compass/admin-token/token// --runner-clusters /etc/compass/runner-clusters/clusters.yaml// --secret-provider dotenv:///etc/compass/secrets/server.env [--forge-*]// files: tools/server-manifests/{render.ts,values.example.json,README.md,moon.yml}
// tools/runner-manifests/render-core.ts (P3, merged)RunnerDeployValues.egressAllow?: readonly string[];RunnerDeployValues.serverCA?: | { configMapName: string; secretName?: undefined; key: string } | { secretName: string; configMapName?: undefined; key: string };
// tools/runner-manifests/render-core.ts (P8)const SESSION_MOUNT_PATH = "/var/lib/compass/sessions"; // unchanged: session-volumes mountPathconst SESSION_VOLUME_ROOT = `${SESSION_MOUNT_PATH}/volumes`; // new: COMPASS_MICROVM_VOLUME_ROOT value// RunnerDeployValues.hostPaths is unchanged; the operator creates <sessionVolumeRoot>/volumes with the project quota
// tools/runner-manifests/render-core.ts (P9)RunnerDeployValues.serverName?: string; // isValidEgressHost grammar; set → env COMPASS_SERVER_NAME after COMPASS_RUNNER_CA
// tools/runner-image/assert-public-core.ts (P5)export function publicVerdict(result: ProbeResult, ref: string): { ok: true } | { ok: false; message: string };// tools/runner-image/assert-public.ts: bun tools/runner-image/assert-public.ts --ref <repo:tag>// tools/runner-image/retag-core.ts (P5); retag.ts builds every registry argv with theseexport function inspectArgv(ref: string): string[]; // ["inspect","--no-creds","--raw","docker://<ref>"]export function copyArgv(sourceRef: string, destRef: string, destAuthFile: string): string[];// ["copy","--preserve-digests","--src-no-creds","--dest-authfile",<destAuthFile>,"docker://<sourceRef>","docker://<destRef>"]// tools/runner-image/publish-core.ts (P5): signature unchanged, argv moves to the copy pairexport function tagArgs(repo: string, digest: string, sha12: string, authFile: string): string[];// tools/agent-image-index/index.ts (P5): authFile leaves every read except the destination probefunction inspectCommand(tag: string, ...args: string[]): string[]; // ["skopeo","inspect",...args,"--no-creds",tag]function destinationProbeCommand(authFile: string, tag: string): string[]; // ["skopeo","inspect","--raw","--authfile",authFile,tag]// guardImmutableTag probes :git-<sha12> with destinationProbeCommand; nothing else calls it// composeLocalIndex writes "{}" to <runnerTemp>/anonymous-auth.json and passes it to every `podman manifest add`// tools/guest-image/publish.ts, tools/gateway-image/publish.ts (P5):// pre-push probe ["inspect",...auth,"--raw","docker://<tag>"], auth = ["--authfile",REGISTRY_AUTH_FILE] or []// copy ["copy","--src-no-creds",...destAuth,...,"oci:<layout>","docker://<tag>"], destAuth = ["--dest-authfile",REGISTRY_AUTH_FILE] or []// re-read ["inspect","--no-creds","--raw","docker://<tag>"]// agent-image/publish.sh (P5): guard_immutable keeps `inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$dest"`
// tools/runner-image/retag-core.ts (P6)export function mainTag(firstParentCount: number, commit: string): string; // "main-<n>-<sha12>"export async function resolveMainPair(input: { ancestors: readonly string[]; probe: Record<ImageLane, (sha12: string) => Promise<ProbeResult>>; changedSince: (commit: string) => Promise<string[]>; closurePaths: Record<ImageLane, string>;}): Promise<Record<ImageLane, Resolved>>;// tools/runner-image/tag-main.ts (P6)// bun tools/runner-image/tag-main.ts --sha <40-hex> \// --server-repo ghcr.io/rigelbuild/compass-server --runner-repo ghcr.io/rigelbuild/compass-runner// .github/workflows/release.yml: job tag-main-images
// tools/runner-image/retag-core.ts (P7); Platform comes from build-core.tsexport type ManifestIdentity = | { kind: "manifest"; manifestDigest: string; configDigest: string } | { kind: "index"; manifestDigest: string; members: { platform: Platform; digest: string }[] };export function manifestIdentity(raw: string): ManifestIdentity;export function assertCoherent(source: ManifestIdentity, release: ManifestIdentity): void;// tools/runner-image/build-core.ts (P7); tools/server-image/build.ts imports hostPlatformexport type Platform = "linux/amd64" | "linux/arm64";export function hostPlatform(arch: NodeJS.Architecture): Platform; // x64 → linux/amd64, arm64 → linux/arm64, else throwsexport function kernelFileName(platform: Platform): "bzImage" | "Image";export function kernelImagePath(kernelDir: string, platform: Platform): string; // <kernelDir>/<kernelFileName>export function buildArgs(outputs: RunnerImageOutputs, platform: Platform): Record<string, string>;// buildctlArgs(contextDir, outputs, platform, output) passes its platform to buildArgs// tools/guest-image/publish-core.ts (P7): replaces ASSET_FILENAMESexport function assetFilenames(platform: Platform): Readonly<Record<AssetName, string>>;export function layoutPlan( assets: Readonly<Record<AssetName, Asset>>, provenance: { revision: string; agentImageDigest: string }, platform: Platform,): LayoutPlan;// tools/microvm-boot-test/run-core.ts (P7)export function guestImageEnv(outPaths: readonly string[], platform: Platform): Record<string, string>;// tools/microvm-boot-test/run.ts (P7): --build-only realises the guest and VMM attrs,// checks <kernel>/<kernelFileName> exists, and exits 0 without `go test`// tools/agent-image-index/index.ts (P7)export type PublishInput = { repo: string; sha: string; authFile: string; runnerTemp: string };export async function publishImageIndex(input: PublishInput, run: CommandRunner): Promise<PublishResult>;// tools/gateway-image/core.ts (P7): the PLATFORM const becomes a platform parameter// go/server/network_door.go (P4, //go:build unix)func readSuppliedAdminToken(path string) (string, error) // os.OpenFile(path, O_RDONLY|O_NONBLOCK|O_CLOEXEC, 0), f.Stat, io.ReadAll(f)// checkSuppliedTokenMode is pure; gid comes from info.Sys().(*syscall.Stat_t).Gid.func checkSuppliedTokenMode(mode os.FileMode, gid uint32, held []int) error
// go/internal/stack/materialize.go (P7, //go:build unix)func selectGuestMember(raw []byte, arch string) (digest string, err error) // index → member digest for runtime.GOARCHfunc guestKernelName(goarch string) string // "Image" for arm64, else "bzImage"; guestAssets' kernel annotationName
// go/internal/runtime/microvm/launch.go (P7, //go:build unix)func serialConsole(goarch string) (string, error) // amd64 → "ttyS0", arm64 → "ttyAMA0", else errorfunc vmmArgs(cfg BootConfig, consolePath, console string, opts launchOptions) []string // appends "console=<console>"
// go/internal/runner/tls.go (P9, //go:build unix)// caPath "" → RootCAs nil (host roots), else the --ca pool with NewCATrustClient's errors;// serverName "" → Go checks the dialed host, else tls.Config.ServerName; TLS 1.3, HTTP/1.1 and HTTP/2func NewServerClient(caPath, serverName string) (*http.Client, error)
// go/cmd/compass-runner/main.go (P9, //go:build unix): flag --server-name, default $COMPASS_SERVER_NAME// orEnv on both values; both empty → http.DefaultClient, else runner.NewServerClientfunc runnerHTTPClient(caFlag, serverNameFlag string) (connecthttp.HTTPClient, error)Deferred follow-ups
Section titled “Deferred follow-ups”- F1 — Door reloads its TLS keypair (Go). Taken if renewals must not restart the Server. Tests: after a rewrite the next handshake presents the new serial; a corrupt rewrite keeps the old one.
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error)func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error)- F2 —
tokenFileon a Runner cluster (Go). Taken when a cluster needs key rotation without a rollout. Key fetches sendAuthorization: Bearerwith the file’s contents, re-read on every fetch.
RunnerCluster.TokenFile string // yaml:"tokenFile", optional// go/internal/auth/workload_identity.gofunc fetchHTTPS(ctx context.Context, client *http.Client, u, bearer string) ([]byte, error)- P1 — Server image and publish lane, compass PR #2355, after #2357
- P2 —
tools/server-manifestsrenderer: no Postgres, no init container,forge, placement values, digest-only images (follow-up to compass PR #2363), after P4 - P3 — Runner renderer
serverCAandegressAllow, compass PRs #2358 and #2360 - P4 — Admin token file from a Secret volume (Go), with the spec change
- P5 — Anonymous registry reads in every lane, except the authenticated destination probe; publish fails on a private package
- P6 — Paired
:main-<n>-<sha12>tags on the head of every main push, after P5 - P7 — Multi-arch (
linux/amd64,linux/arm64) images in every lane, index-aware retag and tagger, per-arch guest kernel file, boot modules and console, arm64 guest build gate, after P6 - P8 — Runner renderer sets
COMPASS_MICROVM_VOLUME_ROOTto/var/lib/compass/sessions/volumes, with the README quota layout; independent of P2 - P9 — Runner
--server-name($COMPASS_SERVER_NAME) setstls.Config.ServerName, with or without--ca; renderer valueserverName, README and spec requirement; independent of P2 and P4