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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/3785-src-spawn-pod-ts-renders-a-child-forge-s → main 2 commits fork 1 thread · 1 unresolved

  • Merged. torque/3785-src-spawn-pod-ts-renders-a-child-forge-s was merged into main as a6750c7.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs