diff --git a/bun.lock b/bun.lock index e6b48cd87..0db396392 100644 --- a/bun.lock +++ b/bun.lock @@ -310,6 +310,16 @@ "typescript": "catalog:", }, }, + "tools/runner-manifests": { + "name": "@compass/runner-manifests", + "bin": { + "runner-manifests-render": "./render.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/sea-ref-gate": { "name": "@compass/sea-ref-gate", "bin": { @@ -581,6 +591,8 @@ "@compass/runner-image": ["@compass/runner-image@workspace:tools/runner-image"], + "@compass/runner-manifests": ["@compass/runner-manifests@workspace:tools/runner-manifests"], + "@compass/sea-ref-gate": ["@compass/sea-ref-gate@workspace:tools/sea-ref-gate"], "@compass/sql-migration-gate": ["@compass/sql-migration-gate@workspace:tools/sql-migration-gate"], diff --git a/docs/designs/platform/compass-runner-containerization/design.md b/docs/designs/platform/compass-runner-containerization/design.md index 39a3ba940..e7ac27302 100644 --- a/docs/designs/platform/compass-runner-containerization/design.md +++ b/docs/designs/platform/compass-runner-containerization/design.md @@ -237,7 +237,8 @@ capacity sizing); this record fixes the *shape*. §Privilege shape. - `spec.nodeName` via `fieldRef` into the environment, so the Runner can identify its node. - - A scrape annotation for the metrics endpoint. + - No scrape annotation: the Runner pushes metrics over OTLP + (`OTEL_EXPORTER_OTLP_ENDPOINT`) and serves no scrape endpoint. - **Liveness probe: conservative, or absent.** A probe-driven container restart is the same full-session teardown as a rollout (§Privilege shape, restart semantics) — pid 1 dies and every session on the node dies with diff --git a/runner-image/Dockerfile b/runner-image/Dockerfile index 166872001..9807748eb 100644 --- a/runner-image/Dockerfile +++ b/runner-image/Dockerfile @@ -23,9 +23,9 @@ # proving the loader comes from the closure rather than the base. # # The pinned digest is the nonroot variant (uid/gid 65532). The Runner needs no -# root: /dev/kvm access is granted by supplementary group at the pod layer. -# Pinned by digest, never by tag — GHCR-style tag mutability gives no -# immutability guarantee. +# root: the device plugin grants the cgroup allowance and node mode 0666 grants +# DAC access. Pinned by digest, never by tag — GHCR-style tag mutability gives +# no immutability guarantee. FROM gcr.io/distroless/static-debian12@sha256:c0f429e16b13e583da7e5a6ec20dd656d325d88e6819cafe0adb0828976529dc # The realised nix closure: the Runner binary, the KVM userland and the guest diff --git a/tools/runner-manifests/README.md b/tools/runner-manifests/README.md new file mode 100644 index 000000000..f6f171df1 --- /dev/null +++ b/tools/runner-manifests/README.md @@ -0,0 +1,45 @@ +# Runner manifests + +Render Kubernetes objects from an operator values file: + +```sh +bun run render.ts values.example.json +``` + +The image must be a `repo@sha256:` reference. Replace the example node +selector, taint, host paths, Server address, and token Secret before applying. + +## KVM device delivery + +- Install an operator-selected, digest-pinned device plugin that advertises the + configured `kvmResourceName` for `/dev/kvm`. The plugin injects the device and + its cgroup device allowance; no `/dev` hostPath is used. +- Set node `/dev/kvm` mode to `0666`. User-namespaced pods see the device as + `65534:65534`, so a host `kvm` group grant does not work. + +## Other node prerequisites + +- Enable Kubernetes user-namespace support. +- Set `kernel.apparmor_restrict_unprivileged_userns=0` where the node image + enables that restriction. +- Stage `seccomp/compass-runner.json` at the kubelet seccomp root under the + configured `seccompProfilePath` before starting the DaemonSet. It is the + profile measured to boot, not yet minimized: `clone`, `clone3`, `unshare` + and `mount` are allowed without argument filters. Narrow it with a + remove-one test on a real node before production use. +- Provide the session-volume host tree on a filesystem configured for project + quotas. Provide the separate runtime host tree for stale-session reaping. + Own both by uid and gid 65532: user-namespaced pods mount them idmapped. Not + yet verified on a real node. +- Create the `runnerTokenSecret` with the key named in the values file. + +**Single-node only for now.** A Runner token is minted for one runner ID, and +each pod enrolls as its node name. One shared Secret therefore enrolls only +the node whose name matches the token. Per-node token delivery is not +designed yet. + +Metrics: set `OTEL_EXPORTER_OTLP_ENDPOINT` to push them. The Runner serves no +scrape endpoint. + +The Runner does not call the Kubernetes API. Its ServiceAccount disables +credential automount, and no Role is rendered. diff --git a/tools/runner-manifests/biome.json b/tools/runner-manifests/biome.json new file mode 100644 index 000000000..ece11dd9b --- /dev/null +++ b/tools/runner-manifests/biome.json @@ -0,0 +1,4 @@ +{ + "extends": "//", + "linter": { "rules": { "suspicious": { "noConsole": "off" } } } +} diff --git a/tools/runner-manifests/moon.yml b/tools/runner-manifests/moon.yml new file mode 100644 index 000000000..b810bd012 --- /dev/null +++ b/tools/runner-manifests/moon.yml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# runner-manifests: values-driven DaemonSet rendering and behavior tests. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock'] + test: + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock', 'seccomp/*.json', '../../runner-image/Dockerfile'] diff --git a/tools/runner-manifests/package.json b/tools/runner-manifests/package.json new file mode 100644 index 000000000..0b93487fd --- /dev/null +++ b/tools/runner-manifests/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/runner-manifests", + "private": true, + "type": "module", + "description": "Render operator-configured Kubernetes manifests for compass-runner.", + "module": "render.ts", + "bin": { + "runner-manifests-render": "./render.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/runner-manifests/render-core.test.ts b/tools/runner-manifests/render-core.test.ts new file mode 100644 index 000000000..ff0dffcf1 --- /dev/null +++ b/tools/runner-manifests/render-core.test.ts @@ -0,0 +1,333 @@ +// Render tests pin operator-visible Kubernetes behavior and the privilege boundary. + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + assertRunnerImageDigest, + assertRunnerMaxUnavailable, + RUNNER_UID, + type RunnerDeployValues, + renderRunnerManifests, +} from "./render-core.ts"; + +const values: RunnerDeployValues = { + namespace: "compass-runners", + image: `registry.example.test/compass/runner@sha256:${"a".repeat(64)}`, + nodeSelector: { key: "workload", value: "compass" }, + toleration: { key: "workload", value: "compass", effect: "NoSchedule" }, + kvmResourceName: "devices.example.test/kvm", + seccompProfilePath: "profiles/compass-runner.json", + sessionCapacity: 2, + guestMemoryMiB: 1024, + guestCpus: 2, + runnerOverheadMiB: 512, + hostPaths: { + sessionVolumeRoot: "/srv/compass/sessions", + runtimeRoot: "/srv/compass/runtime", + }, + serverAddr: "https://compass-server.example.test:7443", + runnerTokenSecret: { name: "compass-runner-token", key: "token" }, + maxUnavailable: 1, + perSessionReapSeconds: 180, + priorityClassValue: 1000000, +}; + +type JsonObject = Record; + +function object(value: unknown): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("expected an object"); + } + return value as JsonObject; +} + +function nested(value: unknown, ...keys: string[]): unknown { + let current = value; + for (const key of keys) current = object(current)[key]; + return current; +} + +function runnerDaemonSet(manifests: readonly unknown[]): JsonObject { + const daemonSet = manifests.find( + (manifest) => object(manifest).kind === "DaemonSet", + ); + if (daemonSet === undefined) throw new Error("rendered DaemonSet is missing"); + return object(daemonSet); +} + +function containers(daemonSet: JsonObject): JsonObject[] { + const list = nested(daemonSet, "spec", "template", "spec", "containers"); + if (!Array.isArray(list)) throw new Error("expected container list"); + return list.map(object); +} + +function assertPrivilegeShape(daemonSet: unknown): void { + const podSpec = nested(daemonSet, "spec", "template", "spec"); + const runner = containers(object(daemonSet))[0]; + if (runner === undefined) throw new Error("runner container is missing"); + const security = object(runner.securityContext); + + expect(podSpec).toHaveProperty("hostUsers", false); + expect(podSpec).toHaveProperty("hostNetwork", false); + expect(podSpec).toHaveProperty("hostPID", false); + expect(podSpec).toHaveProperty("hostIPC", false); + expect(object(podSpec)).not.toHaveProperty("supplementalGroups"); + expect(security.runAsNonRoot).toBe(true); + expect(security.runAsUser).toBe(RUNNER_UID); + expect(security.runAsGroup).toBe(RUNNER_UID); + expect(security.allowPrivilegeEscalation).toBe(false); + expect(security.procMount).toBe("Unmasked"); + expect(security.appArmorProfile).toEqual({ type: "Unconfined" }); + expect(security.seccompProfile).toEqual({ + type: "Localhost", + localhostProfile: values.seccompProfilePath, + }); + expect(nested(security, "capabilities", "drop")).toEqual(["ALL"]); + expect(security.privileged ?? false).toBe(false); + expect(security).not.toHaveProperty("supplementalGroups"); + expect(runner).not.toHaveProperty("livenessProbe"); + + const volumes = nested(podSpec, "volumes"); + if (!Array.isArray(volumes)) throw new Error("expected volume list"); + const hostPaths = volumes.map((volume) => object(object(volume).hostPath)); + expect(hostPaths).toHaveLength(2); + expect(hostPaths.map((hostPath) => hostPath.type).sort()).toEqual([ + "Directory", + "DirectoryOrCreate", + ]); + expect( + hostPaths.some((hostPath) => String(hostPath.path).startsWith("/dev")), + ).toBe(false); +} + +describe("renderRunnerManifests", () => { + test("keeps the complete non-privileged user-namespace shape", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + assertPrivilegeShape(daemonSet); + + const podFields = ["hostUsers", "hostNetwork", "hostPID", "hostIPC"]; + for (const field of podFields) { + const broken = structuredClone(daemonSet); + delete object(nested(broken, "spec", "template", "spec"))[field]; + expect(() => assertPrivilegeShape(broken)).toThrow(); + } + const privileged = structuredClone(daemonSet); + const runner = containers(privileged)[0]; + if (runner === undefined) throw new Error("runner container is missing"); + object(runner.securityContext).privileged = true; + expect(() => assertPrivilegeShape(privileged)).toThrow(); + }); + + test("fails the privilege assertion when any required security field is removed", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + for (const field of [ + "runAsNonRoot", + "runAsUser", + "runAsGroup", + "allowPrivilegeEscalation", + "procMount", + "seccompProfile", + "appArmorProfile", + "capabilities", + ]) { + const copy = structuredClone(daemonSet); + const runner = containers(copy)[0]; + if (runner === undefined) throw new Error("runner container is missing"); + delete object(runner.securityContext)[field]; + expect(() => assertPrivilegeShape(copy)).toThrow(); + } + }); + + test("sizes equal memory requests and limits from guest capacity, including one session", () => { + const oneSession = { ...values, sessionCapacity: 1 }; + const daemonSet = runnerDaemonSet(renderRunnerManifests(oneSession)); + const runner = containers(daemonSet)[0]; + if (runner === undefined) throw new Error("runner container is missing"); + const resources = object(runner.resources); + const requests = object(resources.requests); + const limits = object(resources.limits); + expect(requests.memory).toBe("1536Mi"); + expect(limits.memory).toBe(requests.memory); + expect(requests.cpu).toBe("2"); + expect(limits).not.toHaveProperty("cpu"); + + const twoSessionRunner = containers( + runnerDaemonSet(renderRunnerManifests(values)), + )[0]; + if (twoSessionRunner === undefined) + throw new Error("runner container is missing"); + const twoSessionResources = object(twoSessionRunner.resources); + expect(object(twoSessionResources.requests).memory).toBe("2560Mi"); + expect(object(twoSessionResources.requests).cpu).toBe("4"); + }); + + test("mounts only the session tree and runtime tree at the Runner paths", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + const podSpec = object(nested(daemonSet, "spec", "template", "spec")); + const runner = containers(daemonSet)[0]; + if (runner === undefined) throw new Error("runner container is missing"); + const volumes = podSpec.volumes; + const mounts = runner.volumeMounts; + if (!Array.isArray(volumes) || !Array.isArray(mounts)) + throw new Error("expected volumes and mounts"); + expect(volumes).toHaveLength(2); + expect(mounts.map((mount) => object(mount).mountPath).sort()).toEqual([ + "/run/compass", + "/var/lib/compass/sessions", + ]); + const hostPaths = volumes.map((volume) => object(object(volume).hostPath)); + expect(hostPaths).toEqual([ + { path: values.hostPaths.sessionVolumeRoot, type: "Directory" }, + { path: values.hostPaths.runtimeRoot, type: "DirectoryOrCreate" }, + ]); + }); + + test("uses fieldRef, secretKeyRef, and runtime settings consumed by compass-runner", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + const runner = containers(daemonSet)[0]; + if (runner === undefined || !Array.isArray(runner.env)) + throw new Error("runner env is missing"); + const env = Object.fromEntries( + runner.env.map((entry) => [String(object(entry).name), object(entry)]), + ); + expect(nested(env, "COMPASS_RUNNER_ID", "valueFrom")).toEqual({ + fieldRef: { fieldPath: "spec.nodeName" }, + }); + expect(nested(env, "COMPASS_RUNNER_TOKEN", "valueFrom")).toEqual({ + secretKeyRef: { + name: values.runnerTokenSecret.name, + key: values.runnerTokenSecret.key, + }, + }); + expect(nested(env, "COMPASS_SERVER_ADDR", "value")).toBe(values.serverAddr); + expect(nested(env, "COMPASS_MICROVM_RUNROOT", "value")).toBe( + "/run/compass/microvm", + ); + expect(nested(env, "COMPASS_MICROVM_VOLUME_ROOT", "value")).toBe( + "/var/lib/compass/sessions", + ); + expect(nested(env, "COMPASS_MICROVM_MEMORY_MB", "value")).toBe("1024"); + expect(nested(env, "COMPASS_MICROVM_CPUS", "value")).toBe("2"); + expect(nested(env, "COMPASS_MICROVM_QUOTA_REQUIRED", "value")).toBe("true"); + }); + + test("derives termination grace from the reap budget and bounds the rollout", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + expect( + nested( + daemonSet, + "spec", + "template", + "spec", + "terminationGracePeriodSeconds", + ), + ).toBe(360); + expect( + nested( + daemonSet, + "spec", + "updateStrategy", + "rollingUpdate", + "maxUnavailable", + ), + ).toBe(1); + }); + + test("rejects mutable image tags and accepts a digest reference", () => { + expect(() => + assertRunnerImageDigest("registry.example.test/runner:latest"), + ).toThrow(); + expect(() => + assertRunnerImageDigest( + `registry.example.test/runner:tag@sha256:${"a".repeat(64)}`, + ), + ).toThrow(); + expect(() => assertRunnerImageDigest(values.image)).not.toThrow(); + }); + + test("rejects rollout concurrency outside the safe single-node bound", () => { + expect(() => assertRunnerMaxUnavailable(0)).toThrow(); + expect(() => assertRunnerMaxUnavailable(2)).toThrow(); + expect(() => assertRunnerMaxUnavailable(1)).not.toThrow(); + }); + + test("requests the operator-named KVM extended resource without a device hostPath", () => { + const daemonSet = runnerDaemonSet(renderRunnerManifests(values)); + const runner = containers(daemonSet)[0]; + if (runner === undefined) throw new Error("runner container is missing"); + expect(nested(runner, "resources", "limits", values.kvmResourceName)).toBe( + 1, + ); + assertPrivilegeShape(daemonSet); + }); + + test("keeps uid parity with the image and stages the required seccomp syscalls", () => { + const dockerfile = readFileSync( + join(import.meta.dir, "../../runner-image/Dockerfile"), + "utf8", + ); + expect(dockerfile).toMatch( + new RegExp(`^USER ${RUNNER_UID}:${RUNNER_UID}$`, "m"), + ); + const profileText = readFileSync( + join(import.meta.dir, "seccomp/compass-runner.json"), + "utf8", + ); + const profile: unknown = JSON.parse(profileText); + const syscalls = nested(profile, "syscalls"); + if (!Array.isArray(syscalls)) throw new Error("seccomp syscalls missing"); + const names = new Set( + syscalls.flatMap((entry) => { + const syscall = object(entry); + return Array.isArray(syscall.names) + ? syscall.names.filter( + (name): name is string => typeof name === "string", + ) + : []; + }), + ); + for (const name of [ + "unshare", + "mount", + "umount2", + "pivot_root", + "setns", + "clone", + "clone3", + ]) { + expect(names.has(name)).toBe(true); + } + expect(nested(profile, "defaultAction")).toMatch( + /^SCMP_ACT_(ERRNO|KILL|TRAP)$/, + ); + // OCI profiles have no Docker-only conditions; a runtime would drop them and allow unconditionally. + expect(object(profile)).not.toHaveProperty("archMap"); + for (const entry of syscalls) { + expect(object(entry)).not.toHaveProperty("includes"); + expect(object(entry)).not.toHaveProperty("excludes"); + } + // The pod drops every capability, so capability-gated syscalls must stay denied. + for (const name of [ + "bpf", + "init_module", + "reboot", + "kexec_load", + "chroot", + ]) { + expect(names.has(name)).toBe(false); + } + }); + + test("disables Kubernetes API credentials for its unused ServiceAccount", () => { + const manifests = renderRunnerManifests(values); + const serviceAccount = manifests.find( + (manifest) => object(manifest).kind === "ServiceAccount", + ); + if (serviceAccount === undefined) throw new Error("ServiceAccount missing"); + expect(object(serviceAccount).automountServiceAccountToken).toBe(false); + expect(manifests.some((manifest) => object(manifest).kind === "Role")).toBe( + false, + ); + }); +}); diff --git a/tools/runner-manifests/render-core.ts b/tools/runner-manifests/render-core.ts new file mode 100644 index 000000000..8ebe29f8b --- /dev/null +++ b/tools/runner-manifests/render-core.ts @@ -0,0 +1,408 @@ +export const DEFAULT_SECCOMP_PROFILE_PATH = "profiles/compass-runner.json"; +export const RUNNER_UID = 65532; + +const RUNNER_NAME = "compass-runner"; +const SESSION_MOUNT_PATH = "/var/lib/compass/sessions"; +const RUNTIME_MOUNT_PATH = "/run/compass"; + +export interface RunnerDeployValues { + namespace: string; + image: string; + nodeSelector: { key: string; value: string }; + toleration: { + key: string; + value: string; + effect: "NoSchedule" | "PreferNoSchedule" | "NoExecute"; + }; + kvmResourceName: string; + seccompProfilePath?: string; + sessionCapacity: number; + guestMemoryMiB: number; + guestCpus: number; + runnerOverheadMiB: number; + hostPaths: { sessionVolumeRoot: string; runtimeRoot: string }; + serverAddr: string; + runnerTokenSecret: { name: string; key: string }; + maxUnavailable: number; + perSessionReapSeconds: number; + priorityClassValue: number; +} + +type ValidatedRunnerDeployValues = Omit< + RunnerDeployValues, + "seccompProfilePath" +> & { + seccompProfilePath: string; +}; + +export function assertRunnerImageDigest(image: string): void { + const match = /^([A-Za-z0-9][A-Za-z0-9._:/-]*)@sha256:([a-f0-9]{64})$/.exec( + image, + ); + const repository = match?.[1]; + const finalComponent = repository?.split("/").at(-1); + if ( + match === null || + finalComponent === undefined || + finalComponent.length === 0 || + finalComponent.includes(":") + ) { + throw new Error( + "runner image must be an untagged repository@sha256 digest reference", + ); + } +} + +export function assertRunnerMaxUnavailable(maxUnavailable: number): void { + if (!Number.isSafeInteger(maxUnavailable) || maxUnavailable !== 1) { + throw new Error("maxUnavailable must be the integer 1"); + } +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer`); + } +} + +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } +} + +function assertNonEmpty(name: string, value: string): void { + if (value.trim().length === 0) throw new Error(`${name} must not be empty`); +} + +function assertPath(name: string, value: string): void { + assertNonEmpty(name, value); + if (!value.startsWith("/") || value.includes("\0")) { + throw new Error(`${name} must be an absolute path`); + } +} + +function assertNamespace(namespace: string): void { + if ( + namespace.length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(namespace) + ) { + throw new Error("namespace must be a DNS label"); + } +} + +function assertResourceName(name: string): void { + const parts = name.split("/"); + const resource = parts.at(-1); + const prefix = parts.length === 2 ? parts[0] : undefined; + if ( + parts.length > 2 || + resource === undefined || + !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(resource) || + (prefix !== undefined && !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(prefix)) + ) { + throw new Error( + "kvmResourceName must be a Kubernetes extended resource name", + ); + } +} + +function validateValues(values: ValidatedRunnerDeployValues): void { + assertNamespace(values.namespace); + assertRunnerImageDigest(values.image); + assertNonEmpty("nodeSelector.key", values.nodeSelector.key); + assertNonEmpty("nodeSelector.value", values.nodeSelector.value); + assertNonEmpty("toleration.key", values.toleration.key); + assertNonEmpty("toleration.value", values.toleration.value); + assertResourceName(values.kvmResourceName); + assertNonEmpty("seccompProfilePath", values.seccompProfilePath); + if ( + values.seccompProfilePath.startsWith("/") || + values.seccompProfilePath.includes("..") + ) { + throw new Error( + "seccompProfilePath must be relative to the kubelet seccomp root", + ); + } + assertPositiveInteger("sessionCapacity", values.sessionCapacity); + assertPositiveInteger("guestMemoryMiB", values.guestMemoryMiB); + assertPositiveInteger("guestCpus", values.guestCpus); + assertNonNegativeInteger("runnerOverheadMiB", values.runnerOverheadMiB); + assertPath("hostPaths.sessionVolumeRoot", values.hostPaths.sessionVolumeRoot); + assertPath("hostPaths.runtimeRoot", values.hostPaths.runtimeRoot); + try { + new URL(values.serverAddr); + } catch { + throw new Error("serverAddr must be an absolute URL"); + } + assertNonEmpty("runnerTokenSecret.name", values.runnerTokenSecret.name); + assertNonEmpty("runnerTokenSecret.key", values.runnerTokenSecret.key); + assertRunnerMaxUnavailable(values.maxUnavailable); + assertPositiveInteger("perSessionReapSeconds", values.perSessionReapSeconds); + if ( + !Number.isSafeInteger(values.priorityClassValue) || + values.priorityClassValue < 0 || + values.priorityClassValue > 1_000_000_000 + ) { + throw new Error("priorityClassValue must be from 0 through 1000000000"); + } + const memoryMiB = + values.sessionCapacity * values.guestMemoryMiB + values.runnerOverheadMiB; + const cpuRequest = values.sessionCapacity * values.guestCpus; + const graceSeconds = values.perSessionReapSeconds * values.sessionCapacity; + if ( + !Number.isSafeInteger(memoryMiB) || + !Number.isSafeInteger(cpuRequest) || + !Number.isSafeInteger(graceSeconds) || + graceSeconds > 2_147_483_647 + ) { + throw new Error( + "capacity-derived pod resources exceed Kubernetes integer limits", + ); + } +} + +function objectValue(value: unknown, name: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value as Record; +} + +function stringValue( + record: Record, + key: string, + name: string, +): string { + const value = record[key]; + if (typeof value !== "string") throw new Error(`${name} must be a string`); + return value; +} + +function numberValue( + record: Record, + key: string, + name: string, +): number { + const value = record[key]; + if (typeof value !== "number") throw new Error(`${name} must be a number`); + return value; +} + +export function parseRunnerDeployValues( + input: unknown, +): ValidatedRunnerDeployValues { + const root = objectValue(input, "values"); + const selector = objectValue(root.nodeSelector, "nodeSelector"); + const toleration = objectValue(root.toleration, "toleration"); + const hostPaths = objectValue(root.hostPaths, "hostPaths"); + const secret = objectValue(root.runnerTokenSecret, "runnerTokenSecret"); + const effect = stringValue(toleration, "effect", "toleration.effect"); + if ( + effect !== "NoSchedule" && + effect !== "PreferNoSchedule" && + effect !== "NoExecute" + ) { + throw new Error( + "toleration.effect must be NoSchedule, PreferNoSchedule, or NoExecute", + ); + } + const values: ValidatedRunnerDeployValues = { + namespace: stringValue(root, "namespace", "namespace"), + image: stringValue(root, "image", "image"), + nodeSelector: { + key: stringValue(selector, "key", "nodeSelector.key"), + value: stringValue(selector, "value", "nodeSelector.value"), + }, + toleration: { + key: stringValue(toleration, "key", "toleration.key"), + value: stringValue(toleration, "value", "toleration.value"), + effect, + }, + kvmResourceName: stringValue(root, "kvmResourceName", "kvmResourceName"), + seccompProfilePath: + root.seccompProfilePath === undefined + ? DEFAULT_SECCOMP_PROFILE_PATH + : stringValue(root, "seccompProfilePath", "seccompProfilePath"), + sessionCapacity: numberValue(root, "sessionCapacity", "sessionCapacity"), + guestMemoryMiB: numberValue(root, "guestMemoryMiB", "guestMemoryMiB"), + guestCpus: numberValue(root, "guestCpus", "guestCpus"), + runnerOverheadMiB: numberValue( + root, + "runnerOverheadMiB", + "runnerOverheadMiB", + ), + hostPaths: { + sessionVolumeRoot: stringValue( + hostPaths, + "sessionVolumeRoot", + "hostPaths.sessionVolumeRoot", + ), + runtimeRoot: stringValue( + hostPaths, + "runtimeRoot", + "hostPaths.runtimeRoot", + ), + }, + serverAddr: stringValue(root, "serverAddr", "serverAddr"), + runnerTokenSecret: { + name: stringValue(secret, "name", "runnerTokenSecret.name"), + key: stringValue(secret, "key", "runnerTokenSecret.key"), + }, + maxUnavailable: numberValue(root, "maxUnavailable", "maxUnavailable"), + perSessionReapSeconds: numberValue( + root, + "perSessionReapSeconds", + "perSessionReapSeconds", + ), + priorityClassValue: numberValue( + root, + "priorityClassValue", + "priorityClassValue", + ), + }; + validateValues(values); + return values; +} + +export function renderRunnerManifests( + input: RunnerDeployValues, +): Record[] { + const values = parseRunnerDeployValues(input); + const labels = { "app.kubernetes.io/name": RUNNER_NAME }; + const memoryMiB = + values.sessionCapacity * values.guestMemoryMiB + values.runnerOverheadMiB; + const cpuRequest = values.sessionCapacity * values.guestCpus; + const runtimePath = `${RUNTIME_MOUNT_PATH}/microvm`; + + return [ + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { name: RUNNER_NAME, namespace: values.namespace }, + automountServiceAccountToken: false, + }, + { + apiVersion: "scheduling.k8s.io/v1", + kind: "PriorityClass", + metadata: { name: RUNNER_NAME }, + value: values.priorityClassValue, + globalDefault: false, + description: + "Prioritizes the node-local Compass Runner over ordinary workloads.", + }, + { + apiVersion: "apps/v1", + kind: "DaemonSet", + metadata: { name: RUNNER_NAME, namespace: values.namespace, labels }, + spec: { + selector: { matchLabels: labels }, + updateStrategy: { + type: "RollingUpdate", + rollingUpdate: { maxUnavailable: values.maxUnavailable }, + }, + template: { + metadata: { labels }, + spec: { + hostUsers: false, + hostNetwork: false, + hostPID: false, + hostIPC: false, + serviceAccountName: RUNNER_NAME, + priorityClassName: RUNNER_NAME, + nodeSelector: { + [values.nodeSelector.key]: values.nodeSelector.value, + }, + tolerations: [ + { + key: values.toleration.key, + operator: "Equal", + value: values.toleration.value, + effect: values.toleration.effect, + }, + ], + terminationGracePeriodSeconds: + values.perSessionReapSeconds * values.sessionCapacity, + containers: [ + { + name: RUNNER_NAME, + image: values.image, + imagePullPolicy: "IfNotPresent", + securityContext: { + runAsNonRoot: true, + runAsUser: RUNNER_UID, + runAsGroup: RUNNER_UID, + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + procMount: "Unmasked", + seccompProfile: { + type: "Localhost", + localhostProfile: values.seccompProfilePath, + }, + appArmorProfile: { type: "Unconfined" }, + }, + resources: { + // No CPU limit avoids VMM throttling; requests still reflect guest vCPUs. + requests: { + memory: `${memoryMiB}Mi`, + cpu: String(cpuRequest), + }, + limits: { + memory: `${memoryMiB}Mi`, + [values.kvmResourceName]: 1, + }, + }, + volumeMounts: [ + { name: "session-volumes", mountPath: SESSION_MOUNT_PATH }, + { name: "runner-runtime", mountPath: RUNTIME_MOUNT_PATH }, + ], + // No liveness probe: a pid-1 restart tears down all node sessions. + env: [ + { + name: "COMPASS_RUNNER_ID", + valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } }, + }, + { name: "COMPASS_SERVER_ADDR", value: values.serverAddr }, + { + name: "COMPASS_RUNNER_TOKEN", + valueFrom: { secretKeyRef: values.runnerTokenSecret }, + }, + { name: "COMPASS_MICROVM_RUNROOT", value: runtimePath }, + { + name: "COMPASS_MICROVM_VOLUME_ROOT", + value: SESSION_MOUNT_PATH, + }, + { + name: "COMPASS_MICROVM_MEMORY_MB", + value: String(values.guestMemoryMiB), + }, + { + name: "COMPASS_MICROVM_CPUS", + value: String(values.guestCpus), + }, + { name: "COMPASS_MICROVM_QUOTA_REQUIRED", value: "true" }, + ], + }, + ], + volumes: [ + { + name: "session-volumes", + hostPath: { + path: values.hostPaths.sessionVolumeRoot, + type: "Directory", + }, + }, + { + name: "runner-runtime", + hostPath: { + path: values.hostPaths.runtimeRoot, + type: "DirectoryOrCreate", + }, + }, + ], + }, + }, + }, + }, + ]; +} diff --git a/tools/runner-manifests/render.ts b/tools/runner-manifests/render.ts new file mode 100644 index 000000000..73a4f0fae --- /dev/null +++ b/tools/runner-manifests/render.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; +import { + parseRunnerDeployValues, + renderRunnerManifests, +} from "./render-core.ts"; + +const valuesPath = Bun.argv[2]; +if (valuesPath === undefined) { + throw new Error("usage: bun run render.ts "); +} +const raw: unknown = JSON.parse(await readFile(valuesPath, "utf8")); +const values = parseRunnerDeployValues(raw); +// A JSON round-trip un-shares the label objects, which would otherwise render as YAML anchors. +const documents = renderRunnerManifests(values).map((manifest) => + Bun.YAML.stringify(JSON.parse(JSON.stringify(manifest)), null, 2), +); +process.stdout.write(`${documents.join("\n---\n")}\n`); diff --git a/tools/runner-manifests/seccomp/compass-runner.json b/tools/runner-manifests/seccomp/compass-runner.json new file mode 100644 index 000000000..6c17511f2 --- /dev/null +++ b/tools/runner-manifests/seccomp/compass-runner.json @@ -0,0 +1,775 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "syscalls": [ + { + "names": [ + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "arch_prctl", + "arm_fadvise64_64", + "arm_sync_file_range", + "bind", + "breakpoint", + "brk", + "cacheflush", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "get_robust_list", + "get_thread_area", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "io_setup", + "io_submit", + "ioctl", + "ioprio_get", + "ioprio_set", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "modify_ldt", + "mount", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pivot_root", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "pselect6", + "pselect6_time64", + "ptrace", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "set_tls", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setns", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "sync_file_range2", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "umount2", + "uname", + "unlink", + "unlinkat", + "unshare", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 3, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 7, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 9, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 10, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 13, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 14, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 15, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 16, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 17, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 18, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 21, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 22, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 24, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 26, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 27, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 28, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 29, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 30, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 31, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 32, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 33, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 35, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 36, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 41, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 42, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 43, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 44, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["socket"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 45, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["personality"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["personality"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["personality"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["personality"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": ["personality"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + } + ] +} diff --git a/tools/runner-manifests/tsconfig.json b/tools/runner-manifests/tsconfig.json new file mode 100644 index 000000000..d40cc9e50 --- /dev/null +++ b/tools/runner-manifests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "moduleDetection": "force", + "allowJs": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "types": ["bun"] + } +} diff --git a/tools/runner-manifests/values.example.json b/tools/runner-manifests/values.example.json new file mode 100644 index 000000000..26312f446 --- /dev/null +++ b/tools/runner-manifests/values.example.json @@ -0,0 +1,31 @@ +{ + "namespace": "compass-runners", + "image": "ghcr.io/example/compass-runner@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "nodeSelector": { + "key": "workload", + "value": "compass" + }, + "toleration": { + "key": "workload", + "value": "compass", + "effect": "NoSchedule" + }, + "kvmResourceName": "devices.kubelet.io/kvm", + "seccompProfilePath": "profiles/compass-runner.json", + "sessionCapacity": 4, + "guestMemoryMiB": 2048, + "guestCpus": 2, + "runnerOverheadMiB": 512, + "hostPaths": { + "sessionVolumeRoot": "/srv/compass/sessions", + "runtimeRoot": "/srv/compass/runtime" + }, + "serverAddr": "https://compass-server.example.test:7443", + "runnerTokenSecret": { + "name": "compass-runner-token", + "key": "token" + }, + "maxUnavailable": 1, + "perSessionReapSeconds": 180, + "priorityClassValue": 1000000 +}