@specific.dev/spectest 0.90.3 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/components/expo.d.ts +2 -0
  2. package/dist/components/index.d.ts +1 -1
  3. package/dist/components/k3s-trust.d.ts +57 -0
  4. package/dist/components/k3s-trust.js +125 -0
  5. package/dist/components/k3s.d.ts +17 -5
  6. package/dist/components/k3s.js +77 -11
  7. package/dist/daemon.js +53 -14
  8. package/dist/harness/component-images.d.ts +31 -0
  9. package/dist/harness/component-images.js +69 -0
  10. package/dist/harness/k3s-image-loader.d.ts +5 -0
  11. package/dist/harness/k3s-image-loader.js +150 -0
  12. package/dist/harness/k3s-prepull.d.ts +7 -0
  13. package/dist/harness/k3s-prepull.js +39 -0
  14. package/dist/harness/sticky-consumer.d.ts +3 -3
  15. package/dist/harness/sticky-consumer.js +19 -4
  16. package/dist/harness/sticky-kubernetes.d.ts +15 -0
  17. package/dist/harness/sticky-kubernetes.js +163 -0
  18. package/dist/harness/sticky-paths.d.ts +5 -0
  19. package/dist/harness/sticky-paths.js +18 -0
  20. package/dist/index.d.ts +32 -2
  21. package/dist/index.js +25 -2
  22. package/package.json +1 -1
  23. package/src/components/index.ts +1 -0
  24. package/src/components/k3s-trust.test.ts +81 -0
  25. package/src/components/k3s-trust.ts +135 -0
  26. package/src/components/k3s.ts +81 -10
  27. package/src/daemon.ts +56 -15
  28. package/src/harness/component-images.test.ts +44 -0
  29. package/src/harness/component-images.ts +73 -0
  30. package/src/harness/k3s-image-loader.ts +129 -0
  31. package/src/harness/k3s-prepull.test.ts +47 -0
  32. package/src/harness/k3s-prepull.ts +36 -0
  33. package/src/harness/sticky-consumer.test.ts +28 -0
  34. package/src/harness/sticky-consumer.ts +14 -3
  35. package/src/harness/sticky-kubernetes.test.ts +88 -0
  36. package/src/harness/sticky-kubernetes.ts +165 -0
  37. package/src/harness/sticky-paths.ts +18 -0
  38. package/src/index.ts +48 -3
package/dist/index.js CHANGED
@@ -220,6 +220,19 @@ function expandServiceGroups(input) {
220
220
  }
221
221
  return out;
222
222
  }
223
+ /** @internal Keep the wire format compatible with existing control planes. */
224
+ export function normalizeServiceImage(image) {
225
+ if (typeof image === "string")
226
+ return { type: "registry", reference: image };
227
+ if (!image || typeof image !== "object" || Array.isArray(image))
228
+ throw new Error("image must be a registry reference or Dockerfile build");
229
+ if ("type" in image) {
230
+ if (image.type !== "registry" && image.type !== "dockerfile")
231
+ throw new Error("unsupported image type");
232
+ return image;
233
+ }
234
+ return { ...image, type: "dockerfile" };
235
+ }
223
236
  /** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
224
237
  const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
225
238
  function validateBuildArgs(serviceName, buildArgs) {
@@ -255,9 +268,17 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
255
268
  * checks go through the project-file resolver, so the same rules as
256
269
  * `ctx.readProjectFile` apply.
257
270
  */
258
- export function validateServiceImage(serviceName, image) {
271
+ export function validateServiceImage(serviceName, input) {
272
+ const image = normalizeServiceImage(input);
273
+ const reference = typeof input === "string" ? input : "reference" in input ? input.reference : undefined;
274
+ if (reference !== undefined && (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)))
275
+ throw new Error(`service "${serviceName}" image reference must be a non-empty image name without whitespace`);
259
276
  if (image.type !== "dockerfile")
260
277
  return image;
278
+ if (reference?.includes("@"))
279
+ throw new Error(`service "${serviceName}" build reference must be an image name/tag, not a digest`);
280
+ if (image.exclude !== undefined && (!Array.isArray(image.exclude) || image.exclude.some(e => typeof e !== "string")))
281
+ throw new Error(`service "${serviceName}" image exclude must be an array of glob patterns`);
261
282
  validateBuildArgs(serviceName, image.buildArgs);
262
283
  const hasPath = typeof image.path === "string";
263
284
  const hasContent = typeof image.content === "string";
@@ -265,7 +286,7 @@ export function validateServiceImage(serviceName, image) {
265
286
  throw new Error(`service "${serviceName}" image sets both \`path\` and \`content\` — point at the Dockerfile in your repo with \`path\`, or inline it with \`content\`, not both`);
266
287
  }
267
288
  if (!hasPath && !hasContent) {
268
- throw new Error(`service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``);
289
+ throw new Error(`service "${serviceName}" image build needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``);
269
290
  }
270
291
  if (hasPath) {
271
292
  const file = resolveExistingProjectPath(image.path, `service "${serviceName}" Dockerfile`);
@@ -403,6 +424,8 @@ export function defineEnvironment(input) {
403
424
  const { fakes, services: rawServices, ...rest } = input;
404
425
  const expanded = expandServiceGroups(rawServices);
405
426
  for (const [key, svc] of Object.entries(expanded)) {
427
+ for (const build of svc.imageBuilds ?? [])
428
+ validateServiceImage(`${key} image ${build.reference}`, build.image);
406
429
  expanded[key] = applyCoverageAdapters(key, {
407
430
  ...svc,
408
431
  image: validateServiceImage(key, svc.image),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.90.3",
3
+ "version": "0.91.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,7 @@ export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
26
26
  export {
27
27
  k3s,
28
28
  type K3sOptions,
29
+ type KubernetesImage,
29
30
  type K3sHelpers,
30
31
  type K3sClient,
31
32
  type TaggedObjectApi,
@@ -0,0 +1,81 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { request } from "node:https";
7
+ import { installPodTrust, podTrustPatch, stopPodTrust, TRUST_OPT_OUT } from "./k3s-trust";
8
+
9
+ function patched<T>(value: T, patch: ReturnType<typeof podTrustPatch>): T {
10
+ const out = structuredClone(value);
11
+ for (const operation of patch) {
12
+ const keys = operation.path.slice(1).split("/");
13
+ const key = keys.pop()!;
14
+ const parent = keys.reduce((o, k) => o[k], out as any);
15
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ test("mounts trust into regular and init containers without changing images or security settings", () => {
21
+ const pod = { spec: { containers: [{ name: "app", image: "app@sha256:original", securityContext: { runAsUser: 1000, readOnlyRootFilesystem: true } }], initContainers: [{ name: "init", image: "init:1" }] } };
22
+ const patch = podTrustPatch(pod, "bundle");
23
+ const result: any = patched(pod, patch);
24
+ expect(result.spec.containers[0].image).toBe("app@sha256:original");
25
+ expect(result.spec.containers[0].securityContext).toEqual(pod.spec.containers[0].securityContext);
26
+ expect(result.spec.initContainers[0].env).toContainEqual({ name: "SSL_CERT_FILE", value: "/spectest/ca-bundle.crt" });
27
+ expect(result.spec.volumes[0].configMap.name).toBe("bundle");
28
+ expect(result.spec.containers[0].volumeMounts.every((m: any) => m.readOnly)).toBe(true);
29
+ expect(podTrustPatch(result, "bundle")).toEqual([]);
30
+ expect(pod).not.toHaveProperty("spec.volumes");
31
+ });
32
+
33
+ test("preserves explicit trust settings and mounts, including volume name collisions", () => {
34
+ const pod = { spec: {
35
+ volumes: [{ name: "spectest-ca", emptyDir: {} }],
36
+ containers: [{ env: [{ name: "SSL_CERT_FILE", value: "/custom.pem" }], volumeMounts: [{ name: "custom", mountPath: "/etc/ssl" }] }],
37
+ } };
38
+ const result: any = patched(pod, podTrustPatch(pod, "bundle"));
39
+ expect(result.spec.volumes[1].name).toBe("spectest-ca-1");
40
+ expect(result.spec.containers[0].env.filter((e: any) => e.name === "SSL_CERT_FILE")).toEqual([{ name: "SSL_CERT_FILE", value: "/custom.pem" }]);
41
+ expect(result.spec.containers[0].volumeMounts.some((m: any) => m.mountPath === "/etc/ssl/certs/ca-certificates.crt")).toBe(false);
42
+ });
43
+
44
+ test("opt-out, Windows and ephemeral container updates are handled without changing immutable Pod volumes", () => {
45
+ expect(podTrustPatch({ metadata: { annotations: { [TRUST_OPT_OUT]: "false" } }, spec: { containers: [{}] } }, "bundle")).toEqual([]);
46
+ expect(podTrustPatch({ spec: { os: { name: "windows" }, containers: [{}] } }, "bundle")).toEqual([]);
47
+ expect(podTrustPatch({ spec: { ephemeralContainers: [{}] } }, "bundle", true)).toEqual([]);
48
+ const result: any = patched({ spec: { containers: [{}], ephemeralContainers: [{}], volumes: [{ name: "existing", configMap: { name: "bundle" } }] } }, podTrustPatch({ spec: { ephemeralContainers: [{}], volumes: [{ name: "existing", configMap: { name: "bundle" } }] } }, "bundle", true));
49
+ expect(result.spec.volumes).toHaveLength(1);
50
+ expect(result.spec.containers[0]).toEqual({});
51
+ expect(result.spec.ephemeralContainers[0].volumeMounts[0].name).toBe("existing");
52
+ });
53
+
54
+ afterEach(() => stopPodTrust("fixture"));
55
+ test("admission uses verified TLS, provisions bundles on real requests, and fails closed", async () => {
56
+ const dir = mkdtempSync(join(tmpdir(), "k3s-trust-"));
57
+ try {
58
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", join(dir, "key"), "-out", join(dir, "cert"), "-subj", "/CN=fixture", "-addext", "subjectAltName=IP:127.0.0.1", "-days", "1"], { stdio: "ignore" });
59
+ const cert = readFileSync(join(dir, "cert"), "utf8");
60
+ let config: any, calls = 0, fail = false;
61
+ await installPodTrust({ name: "fixture", gateway: "127.0.0.1", bundle: "bundle", cert, key: readFileSync(join(dir, "key"), "utf8"), ca: cert }, {
62
+ apply: async c => { config = c; },
63
+ ensureBundle: async namespace => { expect(namespace).toBe("app"); calls++; if (fail) throw new Error("API unavailable"); },
64
+ });
65
+ expect(config.webhooks[0].failurePolicy).toBe("Fail");
66
+ const send = (dryRun = false) => new Promise<{ status: number; body: any }>((resolve, reject) => {
67
+ const req = request(config.webhooks[0].clientConfig.url + "?timeout=10s", { method: "POST", ca: cert }, res => {
68
+ let body = ""; res.on("data", c => body += c); res.on("end", () => resolve({ status: res.statusCode!, body: res.statusCode === 200 ? JSON.parse(body) : body }));
69
+ });
70
+ req.on("error", reject);
71
+ req.end(JSON.stringify({ request: { uid: "request-1", resource: { resource: "pods" }, namespace: "app", dryRun, object: { spec: { containers: [{}] } } } }));
72
+ });
73
+ const dry = await send(true);
74
+ expect(dry.body.response.uid).toBe("request-1");
75
+ expect(calls).toBe(0);
76
+ expect((await send()).status).toBe(200);
77
+ expect(calls).toBe(1);
78
+ fail = true;
79
+ expect((await send()).status).toBe(500);
80
+ } finally { rmSync(dir, { recursive: true, force: true }); }
81
+ });
@@ -0,0 +1,135 @@
1
+ /** Pod trust belongs to the cluster. Admission adds read-only ConfigMap
2
+ * mounts and environment defaults, without changing any image bytes. */
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { createServer, type Server } from "node:https";
5
+
6
+ export const POD_CA_PATH = "/spectest/ca-bundle.crt";
7
+ const TRUST_ENV: Readonly<Record<string, string>> = {
8
+ NODE_EXTRA_CA_CERTS: POD_CA_PATH, SSL_CERT_FILE: POD_CA_PATH,
9
+ REQUESTS_CA_BUNDLE: POD_CA_PATH, AWS_CA_BUNDLE: POD_CA_PATH,
10
+ PGSSLROOTCERT: POD_CA_PATH, DENO_TLS_CA_STORE: "mozilla,system",
11
+ };
12
+ const MOUNT_PATHS = [POD_CA_PATH, "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
13
+ interface Container {
14
+ env?: { name: string; value?: string; valueFrom?: unknown }[];
15
+ volumeMounts?: { name: string; mountPath: string; subPath?: string; readOnly?: boolean }[];
16
+ }
17
+ interface Pod {
18
+ metadata?: { annotations?: Record<string, string> };
19
+ spec?: {
20
+ os?: { name: string };
21
+ volumes?: { name: string; configMap?: { name: string }; [key: string]: unknown }[];
22
+ containers?: Container[]; initContainers?: Container[]; ephemeralContainers?: Container[];
23
+ };
24
+ }
25
+ interface Patch { op: "add"; path: string; value: unknown }
26
+ export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
27
+
28
+ export function podTrustPatch(pod: Pod, configMap: string, ephemeral = false): Patch[] {
29
+ if (!pod.spec || pod.spec.os?.name === "windows" || pod.metadata?.annotations?.[TRUST_OPT_OUT] === "false") return [];
30
+ const patches: Patch[] = [];
31
+ const volumes = pod.spec.volumes ?? [];
32
+ // Reuse our immutable ConfigMap, but never overwrite a user's volume.
33
+ let volume = volumes.find(v => v.configMap?.name === configMap)?.name;
34
+ if (!volume && ephemeral) return []; // Pod volumes cannot change on this subresource.
35
+ if (!volume) {
36
+ const names = new Set(volumes.map(v => v.name));
37
+ volume = "spectest-ca";
38
+ for (let n = 1; names.has(volume); n++) volume = `spectest-ca-${n}`;
39
+ const value = { name: volume, configMap: { name: configMap, defaultMode: 0o444, items: [{ key: "ca-bundle.crt", path: "ca-bundle.crt" }] } };
40
+ patches.push(pod.spec.volumes
41
+ ? { op: "add", path: "/spec/volumes/-", value }
42
+ : { op: "add", path: "/spec/volumes", value: [value] });
43
+ }
44
+ const groups = ephemeral ? ["ephemeralContainers"] as const : ["containers", "initContainers"] as const;
45
+ for (const group of groups) {
46
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
47
+ const prefix = `/spec/${group}/${index}`;
48
+ const env = container.env ?? [];
49
+ const names = new Set(env.map(e => e.name));
50
+ const extraEnv = Object.entries(TRUST_ENV).filter(([name]) => !names.has(name)).map(([name, value]) => ({ name, value }));
51
+ const mounts = container.volumeMounts ?? [];
52
+ // A user mount at a path (or a parent directory) owns that path.
53
+ const extraMounts = MOUNT_PATHS.filter(path => !mounts.some(m => path === m.mountPath || path.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
54
+ .map(mountPath => ({ name: volume!, mountPath, subPath: "ca-bundle.crt", readOnly: true }));
55
+ if (extraEnv.length) patches.push({ op: "add", path: `${prefix}/env`, value: [...env, ...extraEnv] });
56
+ if (extraMounts.length) patches.push({ op: "add", path: `${prefix}/volumeMounts`, value: [...mounts, ...extraMounts] });
57
+ }
58
+ }
59
+ return patches;
60
+ }
61
+
62
+ interface AdmissionRequest {
63
+ uid: string; namespace?: string; dryRun?: boolean; subResource?: string;
64
+ resource?: { resource: string }; object?: Pod;
65
+ }
66
+ export interface TrustDependencies {
67
+ /** Ensure this immutable ConfigMap exists in the requesting namespace. */
68
+ ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
69
+ apply(configuration: unknown): Promise<void>;
70
+ }
71
+ const servers = new Map<string, Server>();
72
+
73
+ export async function installPodTrust(options: {
74
+ name: string; gateway: string; bundle: string; cert: string; key: string; ca: string;
75
+ }, deps: TrustDependencies): Promise<void> {
76
+ const configMap = `spectest-ca-${createHash("sha256").update(options.bundle).digest("hex").slice(0, 20)}`;
77
+ const endpoint = `/admit/${randomUUID()}`;
78
+ const server = createServer({ cert: options.cert, key: options.key }, async (req, res) => {
79
+ if (req.method !== "POST" || new URL(req.url ?? "/", "https://local").pathname !== endpoint) { res.writeHead(404).end(); return; }
80
+ const chunks: Buffer[] = [];
81
+ let size = 0;
82
+ try {
83
+ for await (const chunk of req) {
84
+ size += chunk.length;
85
+ if (size > 2 * 1024 * 1024) { res.writeHead(413).end(); return; }
86
+ chunks.push(Buffer.from(chunk));
87
+ }
88
+ const review = JSON.parse(Buffer.concat(chunks).toString());
89
+ const request = review.request as AdmissionRequest | undefined;
90
+ if (!request?.uid || request.resource?.resource !== "pods" || !request.object || !request.namespace) {
91
+ res.writeHead(400).end("invalid Pod admission request"); return;
92
+ }
93
+ const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
94
+ // Dry runs return the same patch but perform no API writes.
95
+ if (patch.length && !request.dryRun) await deps.ensureBundle(request.namespace, configMap, options.bundle);
96
+ res.setHeader("Content-Type", "application/json");
97
+ res.end(JSON.stringify({ apiVersion: "admission.k8s.io/v1", kind: "AdmissionReview", response: {
98
+ uid: request.uid, allowed: true,
99
+ ...(patch.length ? { patchType: "JSONPatch", patch: Buffer.from(JSON.stringify(patch)).toString("base64") } : {}),
100
+ } }));
101
+ } catch (err) {
102
+ console.error(`[k3s-trust] ${options.name}: ${err instanceof Error ? err.message : err}`);
103
+ res.writeHead(500).end("could not mount the environment CA");
104
+ }
105
+ });
106
+ server.requestTimeout = 15_000;
107
+ try {
108
+ await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, options.gateway, resolve); });
109
+ const port = (server.address() as { port: number }).port;
110
+ await deps.apply({
111
+ apiVersion: "admissionregistration.k8s.io/v1", kind: "MutatingWebhookConfiguration",
112
+ metadata: { name: "spectest-pod-trust" },
113
+ webhooks: [{
114
+ name: "pod-trust.spectest.dev", admissionReviewVersions: ["v1"],
115
+ sideEffects: "NoneOnDryRun", failurePolicy: "Fail", timeoutSeconds: 10,
116
+ reinvocationPolicy: "IfNeeded", matchPolicy: "Equivalent",
117
+ clientConfig: { url: `https://${options.gateway}:${port}${endpoint}`, caBundle: Buffer.from(options.ca).toString("base64") },
118
+ rules: [{ operations: ["CREATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods"], scope: "Namespaced" },
119
+ { operations: ["UPDATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods/ephemeralcontainers"], scope: "Namespaced" }],
120
+ }],
121
+ });
122
+ } catch (err) {
123
+ server.closeAllConnections(); server.close(); throw err;
124
+ }
125
+ const previous = servers.get(options.name);
126
+ servers.set(options.name, server);
127
+ previous?.closeAllConnections(); previous?.close();
128
+ }
129
+
130
+ /** Release a component listener on replacement or in standalone tests. */
131
+ export function stopPodTrust(name: string): void {
132
+ const server = servers.get(name);
133
+ servers.delete(name);
134
+ server?.closeAllConnections(); server?.close();
135
+ }
@@ -1,3 +1,6 @@
1
+ import { isIP } from "node:net";
2
+ import { installPodTrust } from "./k3s-trust.js";
3
+ import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
1
4
  import { AsyncLocalStorage } from "node:async_hooks";
2
5
  import { spawn as nodeSpawn } from "node:child_process";
3
6
  import { randomUUID } from "node:crypto";
@@ -31,6 +34,7 @@ import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
31
34
  import type {
32
35
  ServiceDefinition,
33
36
  ServiceHelpersContext,
37
+ ServiceSetupContext,
34
38
  SpectestContext,
35
39
  } from "../index.js";
36
40
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
@@ -41,6 +45,11 @@ import {
41
45
  nestedStoreDir,
42
46
  type ImageCachePaths,
43
47
  } from "../harness/image-cache.js";
48
+ import { stickyDiskPathSync } from "../harness/sticky-paths.js";
49
+ import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
50
+ import { prePullK3sImages } from "../harness/k3s-prepull.js";
51
+ import { componentRuntime, kubernetesImages, type KubernetesImage } from "../harness/component-images.js";
52
+ export type { KubernetesImage } from "../harness/component-images.js";
44
53
  import { deepUnwrap, readTag, wrap } from "../inspect.js";
45
54
  import type { Wrapped } from "../inspect.js";
46
55
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
@@ -50,7 +59,19 @@ export type {
50
59
  KubernetesListObject,
51
60
  } from "@kubernetes/client-node";
52
61
 
62
+ /** Kubernetes pods receive read-only environment CA mounts during admission.
63
+ * Image bytes and digests stay unchanged; explicit pod trust settings win. */
53
64
  export interface K3sOptions {
65
+ /** Images available to kubelet before project setup. Use registry strings,
66
+ * or Dockerfile builds with a full `reference` matching your manifests. Builds
67
+ * support path/content, context, exclude, target and buildArgs, and run in
68
+ * parallel with service builds through the standard BuildKit pipeline.
69
+ * Setup waits for every pull and fails if a requested image cannot be pulled.
70
+ * Sticky-enabled projects retain image content and unpacked layers between
71
+ * fresh builds automatically. Digest references can reuse the cache offline;
72
+ * tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
73
+ */
74
+ images?: readonly KubernetesImage[];
54
75
  /**
55
76
  * Image tag for the official `rancher/k3s` image. Default
56
77
  * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
@@ -178,8 +199,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
178
199
  const BOOT_MARKER = ".spectest-boot-incomplete";
179
200
 
180
201
  /**
181
- * The cluster's image store, and how it shares the environment's image
182
- * cache (`CONTAINER_STORE.md` §12).
202
+ * Sticky-enabled projects bind a per-service/version directory on the
203
+ * sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
204
+ * sticky-kubernetes adapter removes runtime state and retains images and
205
+ * unpacked layers under the shared filesystem budget. Test forks do not
206
+ * write back. The preparation wrapper below also handles delta rebuilds.
207
+ *
208
+ * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
183
209
  *
184
210
  * The cluster's embedded containerd keeps its root on the **root disk**
185
211
  * of the environment's image cache, in a directory of its own
@@ -346,8 +372,8 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
346
372
  `timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
347
373
  // containerd 2.x keeps sandboxes in a store of their own; on the 1.7
348
374
  // k3s ships they are ordinary containers and this is a no-op.
349
- `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls -q 2>/dev/null); ` +
350
- `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
375
+ `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
376
+ `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
351
377
  adopt +
352
378
  `if [ -e "$M" ]; then ` +
353
379
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
@@ -724,7 +750,7 @@ async function issueIngressCert(
724
750
  const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
725
751
  const keyPath = `/tmp/${id}.key`;
726
752
  const crtPath = `/tmp/${id}.crt`;
727
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
753
+ const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
728
754
  const r = await runProcess(
729
755
  "openssl",
730
756
  [
@@ -2007,6 +2033,7 @@ const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
2007
2033
 
2008
2034
  export function k3s(opts: K3sOptions = {}) {
2009
2035
  const version = opts.version ?? DEFAULT_K3S_VERSION;
2036
+ const images = kubernetesImages(opts.images ?? []);
2010
2037
  const extra = opts.extraArgs ?? [];
2011
2038
  assertUsableExtraArgs(extra);
2012
2039
  const registryEnabled = opts.registry !== false;
@@ -2023,7 +2050,8 @@ export function k3s(opts: K3sOptions = {}) {
2023
2050
  // The environment's image cache, when this VM carries one: the
2024
2051
  // cluster's store goes on its root disk and its layers come off the
2025
2052
  // shared layers disk (see the store section above).
2026
- const cache = imageCachePathsSync();
2053
+ const sticky = stickyDiskPathSync();
2054
+ const cache = sticky ? null : imageCachePathsSync();
2027
2055
  const serverArgs = [
2028
2056
  "k3s",
2029
2057
  "server",
@@ -2122,6 +2150,7 @@ export function k3s(opts: K3sOptions = {}) {
2122
2150
  const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
2123
2151
  const def = {
2124
2152
  image: { type: "registry", reference: `rancher/k3s:${version}` },
2153
+ imageBuilds: images.builds,
2125
2154
  command: cmd,
2126
2155
  privileged: true,
2127
2156
  tmpfs: ["/run", "/var/run"],
@@ -2135,15 +2164,17 @@ export function k3s(opts: K3sOptions = {}) {
2135
2164
  // the same netns and are reachable the same way, without appearing
2136
2165
  // in this list (it's documentation, not a firewall).
2137
2166
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
2138
- // The cluster's image store (see the store section above): its
2167
+ // Sticky disks retain an independent overlayfs store per service/version.
2168
+ // The legacy image cache (see the store section above) keeps its
2139
2169
  // containerd root on the image cache's root disk, the shared layers
2140
2170
  // disk read-only at its guest path, golden's mkfs.erofs and the adopt
2141
2171
  // helper — or, with no cache, a per-environment volume. Two clusters
2142
2172
  // in one environment get separate roots either way (the service
2143
2173
  // token names the directory), because two containerd daemons cannot
2144
2174
  // share a store: bolt takes an exclusive lock.
2145
- volumes: cache
2146
- ? [
2175
+ volumes: sticky
2176
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2177
+ : cache ? [
2147
2178
  { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
2148
2179
  { source: cache.layers, target: cache.layers, readOnly: true },
2149
2180
  { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
@@ -2164,12 +2195,52 @@ export function k3s(opts: K3sOptions = {}) {
2164
2195
  command: readyCmd,
2165
2196
  timeoutSecs: opts.readyTimeoutSecs ?? 120,
2166
2197
  },
2167
- setup: async ({ name, helpers }: { name: string; helpers: K3sHelpers }) => {
2198
+ setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
2199
+ const { name, helpers, exec } = ctx;
2200
+ const runtime = componentRuntime(ctx);
2201
+ if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2202
+ if (runtime?.caBundle && caPresent()) {
2203
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
2204
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2205
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2206
+ }, {
2207
+ apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2208
+ ensureBundle: async (namespace, configMap, bundle) => {
2209
+ try {
2210
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2211
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2212
+ } });
2213
+ } catch (err) {
2214
+ // A simultaneous Pod may have created the same namespace's bundle.
2215
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2216
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2217
+ }
2218
+ },
2219
+ });
2220
+ // The API server observes webhook registration asynchronously. A dry-run
2221
+ // Pod proves TLS reachability and admission before any project workload.
2222
+ const deadline = Date.now() + 30_000;
2223
+ for (;;) {
2224
+ try {
2225
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2226
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2227
+ } })).unwrap();
2228
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2229
+ if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2230
+ } catch (err) {
2231
+ if (Date.now() >= deadline) throw err;
2232
+ }
2233
+ await new Promise(resolve => setTimeout(resolve, 100));
2234
+ }
2235
+ }
2236
+ if (runtime) await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
2168
2237
  await setupK3sCluster(name, helpers, {
2169
2238
  registry: registryEnabled,
2170
2239
  ingressDomains,
2171
2240
  traefik: traefikEnabled,
2172
2241
  });
2242
+ await prePullK3sImages(name, images.pulls, exec);
2243
+ if (sticky) await registerStickyKubernetesStore(name, version);
2173
2244
  },
2174
2245
  helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
2175
2246
  // Read the cluster's kubeconfig and address the API server by its