Public: anyone can read this; the crew can change it.
src/spawn/pod.ts renders a child forge's namespace, quota, PVC, Deployment and default-deny policies, from pull #3743's branch #10275
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3785-src-spawn-pod-ts-renders-a-child-forge-s → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +285
−0
src/spawn/pod.ts added +209
| @@ -0,0 +1,209 @@ | ||
| 1 | /** | |
| 2 | * Pod: renders a child forge's Kubernetes objects as plain data — namespace | |
| 3 | * `forge-child-<name>`, ResourceQuota, PVC, Deployment, Service and | |
| 4 | * default-deny NetworkPolicies in both directions. Nothing here applies | |
| 5 | * anything or reads a cluster; a caller serialises the result (JSON is valid | |
| 6 | * manifest input). Egress allows DNS and TCP 443 to the given IPv4 blocks | |
| 7 | * only; any block that is, or overlaps, a private, loopback, link-local, | |
| 8 | * CGNAT or otherwise non-public range fails the render. | |
| 9 | * | |
| 10 | * Ranges: RFC 1918 (10/8, 172.16/12, 192.168/16), RFC 1122 (0/8, 127/8), | |
| 11 | * RFC 3927 (169.254/16), RFC 6598 (100.64/10), RFC 6890 (192.0.0/24, | |
| 12 | * 192.0.2/24, 198.18/15, 198.51.100/24, 203.0.113/24), RFC 5771 (224/4) | |
| 13 | * and RFC 1112 (240/4). | |
| 14 | */ | |
| 15 | ||
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | ||
| 18 | export interface PodLimits { | |
| 19 | readonly cpu: number; | |
| 20 | readonly memoryGiB: number; | |
| 21 | readonly diskGiB: number; | |
| 22 | } | |
| 23 | ||
| 24 | export const DEFAULT_POD_LIMITS: PodLimits = { cpu: 8, memoryGiB: 16, diskGiB: 50 }; | |
| 25 | export const MAX_POD_LIMITS: PodLimits = { cpu: 64, memoryGiB: 256, diskGiB: 2048 }; | |
| 26 | ||
| 27 | /** Defaults for absent fields; whole numbers from 1 up to the maximum, or one sentence. */ | |
| 28 | export function parsePodLimits(v: unknown): Result<PodLimits, string> { | |
| 29 | if (v === undefined || v === null) return ok(DEFAULT_POD_LIMITS); | |
| 30 | if (typeof v !== "object" || Array.isArray(v)) return err("limits must be an object"); | |
| 31 | const o = v as Record<string, unknown>; | |
| 32 | const out: Record<string, number> = {}; | |
| 33 | for (const k of ["cpu", "memoryGiB", "diskGiB"] as const) { | |
| 34 | const x = o[k]; | |
| 35 | if (x === undefined) { | |
| 36 | out[k] = DEFAULT_POD_LIMITS[k]; | |
| 37 | continue; | |
| 38 | } | |
| 39 | if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > MAX_POD_LIMITS[k]) { | |
| 40 | return err(`${k} must be a whole number from 1 to ${MAX_POD_LIMITS[k]}`); | |
| 41 | } | |
| 42 | out[k] = x; | |
| 43 | } | |
| 44 | return ok(out as unknown as PodLimits); | |
| 45 | } | |
| 46 | ||
| 47 | type Cidr = { readonly start: number; readonly end: number }; | |
| 48 | ||
| 49 | const DENIED = [ | |
| 50 | "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", | |
| 51 | "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15", | |
| 52 | "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", | |
| 53 | ].map((c) => { | |
| 54 | const b = parseCidr(c); | |
| 55 | if (!b) throw new Error(`bad denied range ${c}`); | |
| 56 | return b; | |
| 57 | }); | |
| 58 | ||
| 59 | /** IPv4 `a.b.c.d` or `a.b.c.d/n`; a bare address is a /32. Anything else is null. */ | |
| 60 | function parseCidr(s: string): Cidr | null { | |
| 61 | const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(s); | |
| 62 | if (!m) return null; | |
| 63 | let addr = 0; | |
| 64 | for (let i = 1; i <= 4; i++) { | |
| 65 | const part = m[i]!; | |
| 66 | if ((part.length > 1 && part.startsWith("0")) || Number(part) > 255) return null; | |
| 67 | addr = addr * 256 + Number(part); | |
| 68 | } | |
| 69 | const bits = m[5] === undefined ? 32 : Number(m[5]); | |
| 70 | if (bits > 32) return null; | |
| 71 | const size = 2 ** (32 - bits); | |
| 72 | const start = Math.floor(addr / size) * size; | |
| 73 | if (start !== addr) return null; // host bits set: not a block | |
| 74 | return { start, end: start + size - 1 }; | |
| 75 | } | |
| 76 | ||
| 77 | /** The canonical blocks, or one sentence naming the first refused address. */ | |
| 78 | export function parseEgressBlocks(v: unknown): Result<string[], string> { | |
| 79 | if (!Array.isArray(v)) return err("egress must be a list of IPv4 addresses or blocks"); | |
| 80 | const out: string[] = []; | |
| 81 | for (const raw of v) { | |
| 82 | if (typeof raw !== "string") return err("egress addresses must be strings"); | |
| 83 | const c = parseCidr(raw); | |
| 84 | if (!c) return err(`${JSON.stringify(raw.slice(0, 64))} is not an IPv4 address or block`); | |
| 85 | if (DENIED.some((d) => c.start <= d.end && d.start <= c.end)) { | |
| 86 | return err(`${raw} is, or overlaps, a private or non-public range`); | |
| 87 | } | |
| 88 | out.push(raw.includes("/") ? raw : `${raw}/32`); | |
| 89 | } | |
| 90 | return ok(out); | |
| 91 | } | |
| 92 | ||
| 93 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; | |
| 94 | ||
| 95 | export interface PodInput { | |
| 96 | readonly name: string; | |
| 97 | readonly image: string; | |
| 98 | readonly egress: readonly string[]; | |
| 99 | readonly limits?: unknown; | |
| 100 | } | |
| 101 | ||
| 102 | export interface PodManifests { | |
| 103 | readonly namespace: string; | |
| 104 | readonly objects: readonly Record<string, unknown>[]; | |
| 105 | } | |
| 106 | ||
| 107 | export function renderPod(input: PodInput): Result<PodManifests, string> { | |
| 108 | if (typeof input.name !== "string" || !NAME.test(input.name)) { | |
| 109 | return err("name must be 1-40 lowercase letters, digits or hyphens, not starting or ending with a hyphen"); | |
| 110 | } | |
| 111 | if (typeof input.image !== "string" || !/^[a-z0-9][a-z0-9./_:@-]{0,199}$/.test(input.image)) { | |
| 112 | return err("image must be a plain image reference"); | |
| 113 | } | |
| 114 | const limits = parsePodLimits(input.limits); | |
| 115 | if (!limits.ok) return limits; | |
| 116 | const blocks = parseEgressBlocks(input.egress); | |
| 117 | if (!blocks.ok) return blocks; | |
| 118 | ||
| 119 | const ns = `forge-child-${input.name}`; | |
| 120 | const labels = { "app.kubernetes.io/name": "forge-child", "forge/child": input.name }; | |
| 121 | const { cpu, memoryGiB, diskGiB } = limits.value; | |
| 122 | const meta = (name: string) => ({ name, namespace: ns, labels }); | |
| 123 | ||
| 124 | const objects: Record<string, unknown>[] = [ | |
| 125 | { apiVersion: "v1", kind: "Namespace", metadata: { name: ns, labels } }, | |
| 126 | { | |
| 127 | apiVersion: "v1", | |
| 128 | kind: "ResourceQuota", | |
| 129 | metadata: meta("child"), | |
| 130 | spec: { | |
| 131 | hard: { | |
| 132 | "limits.cpu": String(cpu), | |
| 133 | "limits.memory": `${memoryGiB}Gi`, | |
| 134 | "requests.storage": `${diskGiB}Gi`, | |
| 135 | pods: "2", | |
| 136 | services: "2", | |
| 137 | persistentvolumeclaims: "1", | |
| 138 | }, | |
| 139 | }, | |
| 140 | }, | |
| 141 | { | |
| 142 | apiVersion: "v1", | |
| 143 | kind: "PersistentVolumeClaim", | |
| 144 | metadata: meta("data"), | |
| 145 | spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: `${diskGiB}Gi` } } }, | |
| 146 | }, | |
| 147 | { | |
| 148 | apiVersion: "apps/v1", | |
| 149 | kind: "Deployment", | |
| 150 | metadata: meta("child"), | |
| 151 | spec: { | |
| 152 | replicas: 1, | |
| 153 | strategy: { type: "Recreate" }, | |
| 154 | selector: { matchLabels: labels }, | |
| 155 | template: { | |
| 156 | metadata: { labels }, | |
| 157 | spec: { | |
| 158 | automountServiceAccountToken: false, | |
| 159 | enableServiceLinks: false, | |
| 160 | securityContext: { runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001 }, | |
| 161 | containers: [ | |
| 162 | { | |
| 163 | name: "forge", | |
| 164 | image: input.image, | |
| 165 | env: [{ name: "FORGE_APP_DATA", value: "/data" }], | |
| 166 | ports: [{ containerPort: 3000 }], | |
| 167 | resources: { limits: { cpu: String(cpu), memory: `${memoryGiB}Gi` } }, | |
| 168 | securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: ["ALL"] } }, | |
| 169 | volumeMounts: [{ name: "data", mountPath: "/data" }, { name: "tmp", mountPath: "/tmp" }], | |
| 170 | }, | |
| 171 | ], | |
| 172 | volumes: [{ name: "data", persistentVolumeClaim: { claimName: "data" } }, { name: "tmp", emptyDir: { sizeLimit: "1Gi" } }], | |
| 173 | }, | |
| 174 | }, | |
| 175 | }, | |
| 176 | }, | |
| 177 | { | |
| 178 | apiVersion: "v1", | |
| 179 | kind: "Service", | |
| 180 | metadata: meta("child"), | |
| 181 | spec: { selector: labels, ports: [{ port: 80, targetPort: 3000 }] }, | |
| 182 | }, | |
| 183 | { | |
| 184 | apiVersion: "networking.k8s.io/v1", | |
| 185 | kind: "NetworkPolicy", | |
| 186 | metadata: meta("default-deny"), | |
| 187 | spec: { podSelector: {}, policyTypes: ["Ingress", "Egress"] }, | |
| 188 | }, | |
| 189 | { | |
| 190 | apiVersion: "networking.k8s.io/v1", | |
| 191 | kind: "NetworkPolicy", | |
| 192 | metadata: meta("allow-egress"), | |
| 193 | spec: { | |
| 194 | podSelector: {}, | |
| 195 | policyTypes: ["Egress"], | |
| 196 | egress: [ | |
| 197 | { | |
| 198 | to: [{ namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": "kube-system" } } }], | |
| 199 | ports: [{ protocol: "UDP", port: 53 }, { protocol: "TCP", port: 53 }], | |
| 200 | }, | |
| 201 | ...(blocks.value.length | |
| 202 | ? [{ to: blocks.value.map((cidr) => ({ ipBlock: { cidr } })), ports: [{ protocol: "TCP", port: 443 }] }] | |
| 203 | : []), | |
| 204 | ], | |
| 205 | }, | |
| 206 | }, | |
| 207 | ]; | |
| 208 | return ok({ namespace: ns, objects }); | |
| 209 | } |
test/spawn-pod.test.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { parseEgressBlocks, parsePodLimits, renderPod } from "../src/spawn/pod.ts"; | |
| 3 | ||
| 4 | const base = { name: "ada", image: "forge:1", egress: ["93.184.216.34"] }; | |
| 5 | const rendered = () => { | |
| 6 | const r = renderPod(base); | |
| 7 | if (!r.ok) throw new Error(r.error); | |
| 8 | return r.value; | |
| 9 | }; | |
| 10 | const kind = (k: string) => rendered().objects.filter((o) => o.kind === k); | |
| 11 | ||
| 12 | test("namespace is forge-child-<name> and every object lives in it", () => { | |
| 13 | const r = rendered(); | |
| 14 | expect(r.namespace).toBe("forge-child-ada"); | |
| 15 | for (const o of r.objects) if (o.kind !== "Namespace") expect((o.metadata as any).namespace).toBe("forge-child-ada"); | |
| 16 | expect(r.objects.map((o) => o.kind)).toEqual([ | |
| 17 | "Namespace", "ResourceQuota", "PersistentVolumeClaim", "Deployment", "Service", "NetworkPolicy", "NetworkPolicy", | |
| 18 | ]); | |
| 19 | }); | |
| 20 | ||
| 21 | test("deployment has no token, runs as 10001, mounts no secrets", () => { | |
| 22 | const spec = (kind("Deployment")[0] as any).spec.template.spec; | |
| 23 | expect(spec.automountServiceAccountToken).toBe(false); | |
| 24 | expect(spec.securityContext.runAsUser).toBe(10001); | |
| 25 | expect(JSON.stringify(spec)).not.toContain("secret"); | |
| 26 | expect(spec.volumes.some((v: any) => v.name === "tmp" && v.emptyDir)).toBe(true); | |
| 27 | }); | |
| 28 | ||
| 29 | test("default deny covers both directions; egress is DNS and 443 to given blocks", () => { | |
| 30 | const [deny, allow] = kind("NetworkPolicy") as any[]; | |
| 31 | expect(deny.spec.podSelector).toEqual({}); | |
| 32 | expect(deny.spec.policyTypes).toEqual(["Ingress", "Egress"]); | |
| 33 | expect(deny.spec.egress).toBeUndefined(); | |
| 34 | expect(deny.spec.ingress).toBeUndefined(); | |
| 35 | expect(allow.spec.egress[0].ports.map((p: any) => p.port)).toEqual([53, 53]); | |
| 36 | expect(allow.spec.egress[1].to).toEqual([{ ipBlock: { cidr: "93.184.216.34/32" } }]); | |
| 37 | expect(allow.spec.egress[1].ports).toEqual([{ protocol: "TCP", port: 443 }]); | |
| 38 | }); | |
| 39 | ||
| 40 | test("limits default to 8 CPU / 16 GiB / 50 GiB and land in quota and PVC", () => { | |
| 41 | expect(parsePodLimits(undefined)).toEqual({ ok: true, value: { cpu: 8, memoryGiB: 16, diskGiB: 50 } }); | |
| 42 | const q = (kind("ResourceQuota")[0] as any).spec.hard; | |
| 43 | expect(q["limits.cpu"]).toBe("8"); | |
| 44 | expect(q["limits.memory"]).toBe("16Gi"); | |
| 45 | expect((kind("PersistentVolumeClaim")[0] as any).spec.resources.requests.storage).toBe("50Gi"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("limits accept whole numbers up to 64 / 256 / 2048 only", () => { | |
| 49 | expect(parsePodLimits({ cpu: 64, memoryGiB: 256, diskGiB: 2048 }).ok).toBe(true); | |
| 50 | for (const bad of [{ cpu: 65 }, { memoryGiB: 257 }, { diskGiB: 2049 }, { cpu: 0 }, { cpu: 1.5 }, { cpu: "4" }, [], 3]) { | |
| 51 | expect(parsePodLimits(bad).ok).toBe(false); | |
| 52 | } | |
| 53 | }); | |
| 54 | ||
| 55 | test("non-public, IPv6 and unparseable addresses fail the render", () => { | |
| 56 | for (const bad of [ | |
| 57 | "10.1.2.3", "127.0.0.1", "169.254.169.254", "100.64.0.1", "172.16.0.0/12", "192.168.1.1", "0.0.0.0/0", | |
| 58 | "0.0.0.0", "::1", "2001:db8::/32", "nope", "1.2.3", "1.2.3.4/33", "01.2.3.4", "93.184.216.1/24", "256.1.1.1", | |
| 59 | ]) { | |
| 60 | expect(renderPod({ ...base, egress: [bad] }).ok).toBe(false); | |
| 61 | } | |
| 62 | }); | |
| 63 | ||
| 64 | test("a block overlapping a denied range on the shorter prefix is denied", () => { | |
| 65 | expect(parseEgressBlocks(["11.0.0.0/7"]).ok).toBe(false); | |
| 66 | expect(parseEgressBlocks(["8.0.0.0/5"]).ok).toBe(false); | |
| 67 | expect(parseEgressBlocks(["12.0.0.0/6"]).ok).toBe(true); | |
| 68 | expect(parseEgressBlocks(["93.184.216.0/24"])).toEqual({ ok: true, value: ["93.184.216.0/24"] }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("bad names and images are refused", () => { | |
| 72 | for (const name of ["", "Ada", "-a", "a-", "a b", "a/../b", "x".repeat(41)]) { | |
| 73 | expect(renderPod({ ...base, name }).ok).toBe(false); | |
| 74 | } | |
| 75 | expect(renderPod({ ...base, image: "x\ny" }).ok).toBe(false); | |
| 76 | }); |