@specific.dev/spectest 0.90.3 → 0.91.1

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 (46) hide show
  1. package/dist/android-step.js +2 -2
  2. package/dist/components/expo.d.ts +2 -0
  3. package/dist/components/index.d.ts +1 -1
  4. package/dist/components/k3s-trust.d.ts +57 -0
  5. package/dist/components/k3s-trust.js +125 -0
  6. package/dist/components/k3s.d.ts +17 -5
  7. package/dist/components/k3s.js +126 -53
  8. package/dist/daemon.js +71 -28
  9. package/dist/harness/component-images.d.ts +34 -0
  10. package/dist/harness/component-images.js +69 -0
  11. package/dist/harness/concurrency.d.ts +2 -0
  12. package/dist/harness/concurrency.js +9 -0
  13. package/dist/harness/k3s-image-loader.d.ts +3 -0
  14. package/dist/harness/k3s-image-loader.js +152 -0
  15. package/dist/harness/k3s-prepull.d.ts +8 -0
  16. package/dist/harness/k3s-prepull.js +35 -0
  17. package/dist/harness/sticky-consumer.d.ts +3 -3
  18. package/dist/harness/sticky-consumer.js +19 -4
  19. package/dist/harness/sticky-kubernetes.d.ts +15 -0
  20. package/dist/harness/sticky-kubernetes.js +163 -0
  21. package/dist/harness/sticky-paths.d.ts +5 -0
  22. package/dist/harness/sticky-paths.js +18 -0
  23. package/dist/index.d.ts +32 -2
  24. package/dist/index.js +25 -2
  25. package/package.json +1 -1
  26. package/src/android-step.test.ts +3 -3
  27. package/src/android-step.ts +2 -2
  28. package/src/android.test.ts +3 -3
  29. package/src/components/index.ts +1 -0
  30. package/src/components/k3s-concurrency.test.ts +60 -0
  31. package/src/components/k3s-trust.test.ts +81 -0
  32. package/src/components/k3s-trust.ts +135 -0
  33. package/src/components/k3s.ts +131 -51
  34. package/src/daemon.ts +72 -27
  35. package/src/harness/component-images.test.ts +44 -0
  36. package/src/harness/component-images.ts +79 -0
  37. package/src/harness/concurrency.ts +8 -0
  38. package/src/harness/k3s-image-loader.ts +131 -0
  39. package/src/harness/k3s-prepull.test.ts +57 -0
  40. package/src/harness/k3s-prepull.ts +33 -0
  41. package/src/harness/sticky-consumer.test.ts +28 -0
  42. package/src/harness/sticky-consumer.ts +14 -3
  43. package/src/harness/sticky-kubernetes.test.ts +88 -0
  44. package/src/harness/sticky-kubernetes.ts +165 -0
  45. package/src/harness/sticky-paths.ts +18 -0
  46. package/src/index.ts +48 -3
@@ -15,7 +15,7 @@ export function androidTargetLabel(target) {
15
15
  case "id":
16
16
  return `id ${quoted}`;
17
17
  case "accessibility":
18
- return `accessibility label ${quoted}`;
18
+ return `label ${quoted}`;
19
19
  case "xpath":
20
20
  return `XPath ${quoted}`;
21
21
  case "custom":
@@ -74,5 +74,5 @@ export function androidStepTitle(step) {
74
74
  label = `run command ${JSON.stringify(step.command)}`;
75
75
  break;
76
76
  }
77
- return `${step.device}: ${label}`;
77
+ return label;
78
78
  }
@@ -103,6 +103,7 @@ export declare function nodeMajorFromRange(range: string): number | null;
103
103
  export declare function expo(opts?: ExpoOptions): {
104
104
  image: {
105
105
  type: "dockerfile";
106
+ reference?: string;
106
107
  path: string;
107
108
  content?: never;
108
109
  context?: string;
@@ -111,6 +112,7 @@ export declare function expo(opts?: ExpoOptions): {
111
112
  buildArgs?: Readonly<Record<string, string>>;
112
113
  } | {
113
114
  type: "dockerfile";
115
+ reference?: string;
114
116
  content: string;
115
117
  path?: never;
116
118
  context?: string;
@@ -2,7 +2,7 @@ export { temporal, temporalClient, type TemporalOptions, type TemporalClient, ty
2
2
  export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
3
3
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
4
4
  export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
5
- export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
5
+ export { k3s, type K3sOptions, type KubernetesImage, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
6
6
  export { expo, type ExpoOptions, type ExpoHelpers, type ExpoPackageManager, } from "./expo.js";
7
7
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
8
8
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
@@ -0,0 +1,57 @@
1
+ export declare const POD_CA_PATH = "/spectest/ca-bundle.crt";
2
+ interface Container {
3
+ env?: {
4
+ name: string;
5
+ value?: string;
6
+ valueFrom?: unknown;
7
+ }[];
8
+ volumeMounts?: {
9
+ name: string;
10
+ mountPath: string;
11
+ subPath?: string;
12
+ readOnly?: boolean;
13
+ }[];
14
+ }
15
+ interface Pod {
16
+ metadata?: {
17
+ annotations?: Record<string, string>;
18
+ };
19
+ spec?: {
20
+ os?: {
21
+ name: string;
22
+ };
23
+ volumes?: {
24
+ name: string;
25
+ configMap?: {
26
+ name: string;
27
+ };
28
+ [key: string]: unknown;
29
+ }[];
30
+ containers?: Container[];
31
+ initContainers?: Container[];
32
+ ephemeralContainers?: Container[];
33
+ };
34
+ }
35
+ interface Patch {
36
+ op: "add";
37
+ path: string;
38
+ value: unknown;
39
+ }
40
+ export declare const TRUST_OPT_OUT = "spectest.dev/inject-ca";
41
+ export declare function podTrustPatch(pod: Pod, configMap: string, ephemeral?: boolean): Patch[];
42
+ export interface TrustDependencies {
43
+ /** Ensure this immutable ConfigMap exists in the requesting namespace. */
44
+ ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
45
+ apply(configuration: unknown): Promise<void>;
46
+ }
47
+ export declare function installPodTrust(options: {
48
+ name: string;
49
+ gateway: string;
50
+ bundle: string;
51
+ cert: string;
52
+ key: string;
53
+ ca: string;
54
+ }, deps: TrustDependencies): Promise<void>;
55
+ /** Release a component listener on replacement or in standalone tests. */
56
+ export declare function stopPodTrust(name: string): void;
57
+ export {};
@@ -0,0 +1,125 @@
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 } from "node:https";
5
+ export const POD_CA_PATH = "/spectest/ca-bundle.crt";
6
+ const TRUST_ENV = {
7
+ NODE_EXTRA_CA_CERTS: POD_CA_PATH, SSL_CERT_FILE: POD_CA_PATH,
8
+ REQUESTS_CA_BUNDLE: POD_CA_PATH, AWS_CA_BUNDLE: POD_CA_PATH,
9
+ PGSSLROOTCERT: POD_CA_PATH, DENO_TLS_CA_STORE: "mozilla,system",
10
+ };
11
+ const MOUNT_PATHS = [POD_CA_PATH, "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
12
+ export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
13
+ export function podTrustPatch(pod, configMap, ephemeral = false) {
14
+ if (!pod.spec || pod.spec.os?.name === "windows" || pod.metadata?.annotations?.[TRUST_OPT_OUT] === "false")
15
+ return [];
16
+ const patches = [];
17
+ const volumes = pod.spec.volumes ?? [];
18
+ // Reuse our immutable ConfigMap, but never overwrite a user's volume.
19
+ let volume = volumes.find(v => v.configMap?.name === configMap)?.name;
20
+ if (!volume && ephemeral)
21
+ return []; // Pod volumes cannot change on this subresource.
22
+ if (!volume) {
23
+ const names = new Set(volumes.map(v => v.name));
24
+ volume = "spectest-ca";
25
+ for (let n = 1; names.has(volume); n++)
26
+ volume = `spectest-ca-${n}`;
27
+ const value = { name: volume, configMap: { name: configMap, defaultMode: 0o444, items: [{ key: "ca-bundle.crt", path: "ca-bundle.crt" }] } };
28
+ patches.push(pod.spec.volumes
29
+ ? { op: "add", path: "/spec/volumes/-", value }
30
+ : { op: "add", path: "/spec/volumes", value: [value] });
31
+ }
32
+ const groups = ephemeral ? ["ephemeralContainers"] : ["containers", "initContainers"];
33
+ for (const group of groups) {
34
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
35
+ const prefix = `/spec/${group}/${index}`;
36
+ const env = container.env ?? [];
37
+ const names = new Set(env.map(e => e.name));
38
+ const extraEnv = Object.entries(TRUST_ENV).filter(([name]) => !names.has(name)).map(([name, value]) => ({ name, value }));
39
+ const mounts = container.volumeMounts ?? [];
40
+ // A user mount at a path (or a parent directory) owns that path.
41
+ const extraMounts = MOUNT_PATHS.filter(path => !mounts.some(m => path === m.mountPath || path.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
42
+ .map(mountPath => ({ name: volume, mountPath, subPath: "ca-bundle.crt", readOnly: true }));
43
+ if (extraEnv.length)
44
+ patches.push({ op: "add", path: `${prefix}/env`, value: [...env, ...extraEnv] });
45
+ if (extraMounts.length)
46
+ patches.push({ op: "add", path: `${prefix}/volumeMounts`, value: [...mounts, ...extraMounts] });
47
+ }
48
+ }
49
+ return patches;
50
+ }
51
+ const servers = new Map();
52
+ export async function installPodTrust(options, deps) {
53
+ const configMap = `spectest-ca-${createHash("sha256").update(options.bundle).digest("hex").slice(0, 20)}`;
54
+ const endpoint = `/admit/${randomUUID()}`;
55
+ const server = createServer({ cert: options.cert, key: options.key }, async (req, res) => {
56
+ if (req.method !== "POST" || new URL(req.url ?? "/", "https://local").pathname !== endpoint) {
57
+ res.writeHead(404).end();
58
+ return;
59
+ }
60
+ const chunks = [];
61
+ let size = 0;
62
+ try {
63
+ for await (const chunk of req) {
64
+ size += chunk.length;
65
+ if (size > 2 * 1024 * 1024) {
66
+ res.writeHead(413).end();
67
+ return;
68
+ }
69
+ chunks.push(Buffer.from(chunk));
70
+ }
71
+ const review = JSON.parse(Buffer.concat(chunks).toString());
72
+ const request = review.request;
73
+ if (!request?.uid || request.resource?.resource !== "pods" || !request.object || !request.namespace) {
74
+ res.writeHead(400).end("invalid Pod admission request");
75
+ return;
76
+ }
77
+ const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
78
+ // Dry runs return the same patch but perform no API writes.
79
+ if (patch.length && !request.dryRun)
80
+ await deps.ensureBundle(request.namespace, configMap, options.bundle);
81
+ res.setHeader("Content-Type", "application/json");
82
+ res.end(JSON.stringify({ apiVersion: "admission.k8s.io/v1", kind: "AdmissionReview", response: {
83
+ uid: request.uid, allowed: true,
84
+ ...(patch.length ? { patchType: "JSONPatch", patch: Buffer.from(JSON.stringify(patch)).toString("base64") } : {}),
85
+ } }));
86
+ }
87
+ catch (err) {
88
+ console.error(`[k3s-trust] ${options.name}: ${err instanceof Error ? err.message : err}`);
89
+ res.writeHead(500).end("could not mount the environment CA");
90
+ }
91
+ });
92
+ server.requestTimeout = 15_000;
93
+ try {
94
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, options.gateway, resolve); });
95
+ const port = server.address().port;
96
+ await deps.apply({
97
+ apiVersion: "admissionregistration.k8s.io/v1", kind: "MutatingWebhookConfiguration",
98
+ metadata: { name: "spectest-pod-trust" },
99
+ webhooks: [{
100
+ name: "pod-trust.spectest.dev", admissionReviewVersions: ["v1"],
101
+ sideEffects: "NoneOnDryRun", failurePolicy: "Fail", timeoutSeconds: 10,
102
+ reinvocationPolicy: "IfNeeded", matchPolicy: "Equivalent",
103
+ clientConfig: { url: `https://${options.gateway}:${port}${endpoint}`, caBundle: Buffer.from(options.ca).toString("base64") },
104
+ rules: [{ operations: ["CREATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods"], scope: "Namespaced" },
105
+ { operations: ["UPDATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods/ephemeralcontainers"], scope: "Namespaced" }],
106
+ }],
107
+ });
108
+ }
109
+ catch (err) {
110
+ server.closeAllConnections();
111
+ server.close();
112
+ throw err;
113
+ }
114
+ const previous = servers.get(options.name);
115
+ servers.set(options.name, server);
116
+ previous?.closeAllConnections();
117
+ previous?.close();
118
+ }
119
+ /** Release a component listener on replacement or in standalone tests. */
120
+ export function stopPodTrust(name) {
121
+ const server = servers.get(name);
122
+ servers.delete(name);
123
+ server?.closeAllConnections();
124
+ server?.close();
125
+ }
@@ -1,8 +1,22 @@
1
1
  import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, PatchStrategy, type KubernetesListObject, type KubernetesObject, type V1DeleteOptions, type V1Job, type V1Status } from "@kubernetes/client-node";
2
- import type { ServiceHelpersContext } from "../index.js";
2
+ import type { ServiceHelpersContext, ServiceSetupContext } from "../index.js";
3
+ import { type KubernetesImage } from "../harness/component-images.js";
4
+ export type { KubernetesImage } from "../harness/component-images.js";
3
5
  import type { Wrapped } from "../inspect.js";
4
6
  export type { KubernetesObject, KubernetesListObject, } from "@kubernetes/client-node";
7
+ /** Kubernetes pods receive read-only environment CA mounts during admission.
8
+ * Image bytes and digests stay unchanged; explicit pod trust settings win. */
5
9
  export interface K3sOptions {
10
+ /** Images available to kubelet before project setup. Use registry strings,
11
+ * or Dockerfile builds with a full `reference` matching your manifests. Builds
12
+ * support path/content, context, exclude, target and buildArgs, and run in
13
+ * parallel with service builds through the standard BuildKit pipeline.
14
+ * Setup waits for every pull and fails if a requested image cannot be pulled.
15
+ * Sticky-enabled projects retain image content and unpacked layers between
16
+ * fresh builds automatically. Digest references can reuse the cache offline;
17
+ * tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
18
+ */
19
+ images?: readonly KubernetesImage[];
6
20
  /**
7
21
  * Image tag for the official `rancher/k3s` image. Default
8
22
  * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
@@ -319,6 +333,7 @@ export declare function k3s(opts?: K3sOptions): {
319
333
  type: "registry";
320
334
  reference: string;
321
335
  };
336
+ imageBuilds: import("../index.js").ComponentImageBuild[];
322
337
  command: string;
323
338
  privileged: true;
324
339
  tmpfs: string[];
@@ -344,9 +359,6 @@ export declare function k3s(opts?: K3sOptions): {
344
359
  command: string;
345
360
  timeoutSecs: number;
346
361
  };
347
- setup: ({ name, helpers }: {
348
- name: string;
349
- helpers: K3sHelpers;
350
- }) => Promise<void>;
362
+ setup: (ctx: ServiceSetupContext<K3sHelpers>) => Promise<void>;
351
363
  helpers: ({ name, exec, poll }: ServiceHelpersContext) => Promise<K3sHelpers>;
352
364
  };
@@ -1,3 +1,7 @@
1
+ import { isIP } from "node:net";
2
+ import { installPodTrust } from "./k3s-trust.js";
3
+ import { waitForAll } from "../harness/concurrency.js";
4
+ import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
1
5
  import { AsyncLocalStorage } from "node:async_hooks";
2
6
  import { spawn as nodeSpawn } from "node:child_process";
3
7
  import { randomUUID } from "node:crypto";
@@ -7,6 +11,10 @@ import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, Patc
7
11
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
8
12
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
9
13
  import { MKFS_EROFS_PATH, STORE_ADOPT_PATH, imageCachePathsSync, nestedStoreDir, } from "../harness/image-cache.js";
14
+ import { stickyDiskPathSync } from "../harness/sticky-paths.js";
15
+ import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
16
+ import { prePullK3sImages } from "../harness/k3s-prepull.js";
17
+ import { componentRuntime, kubernetesImages } from "../harness/component-images.js";
10
18
  import { deepUnwrap, readTag, wrap } from "../inspect.js";
11
19
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
12
20
  /** Port the in-cluster registry listens on (plain HTTP). */
@@ -32,8 +40,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
32
40
  */
33
41
  const BOOT_MARKER = ".spectest-boot-incomplete";
34
42
  /**
35
- * The cluster's image store, and how it shares the environment's image
36
- * cache (`CONTAINER_STORE.md` §12).
43
+ * Sticky-enabled projects bind a per-service/version directory on the
44
+ * sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
45
+ * sticky-kubernetes adapter removes runtime state and retains images and
46
+ * unpacked layers under the shared filesystem budget. Test forks do not
47
+ * write back. The preparation wrapper below also handles delta rebuilds.
48
+ *
49
+ * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
37
50
  *
38
51
  * The cluster's embedded containerd keeps its root on the **root disk**
39
52
  * of the environment's image cache, in a directory of its own
@@ -195,8 +208,8 @@ function storePrepareSh(cache) {
195
208
  `timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
196
209
  // containerd 2.x keeps sandboxes in a store of their own; on the 1.7
197
210
  // k3s ships they are ordinary containers and this is a no-op.
198
- `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls -q 2>/dev/null); ` +
199
- `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
211
+ `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
212
+ `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
200
213
  adopt +
201
214
  `if [ -e "$M" ]; then ` +
202
215
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
@@ -287,7 +300,7 @@ async function issueIngressCert(hostnames) {
287
300
  const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
288
301
  const keyPath = `/tmp/${id}.key`;
289
302
  const crtPath = `/tmp/${id}.crt`;
290
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
303
+ const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
291
304
  const r = await runProcess("openssl", [
292
305
  "req",
293
306
  "-newkey",
@@ -1231,48 +1244,51 @@ async function waitForJob(deps, name, opts) {
1231
1244
  * reachable over HTTPS with a cert the test framework already trusts.
1232
1245
  */
1233
1246
  async function setupK3sCluster(name, helpers, opts) {
1234
- const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
1235
- if (tlsEnabled) {
1236
- const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1237
- // Apply the cert Secret + dynamic-config ConfigMap before the
1238
- // Deployment that mounts them. `stringData` lets us hand over plain
1239
- // PEM; the API server base64-encodes it.
1240
- await helpers.client.core.createNamespacedSecret({
1241
- namespace: "kube-system",
1242
- body: {
1243
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1244
- type: "kubernetes.io/tls",
1245
- stringData: { "tls.crt": cert, "tls.key": key },
1246
- },
1247
- });
1248
- await helpers.client.core.createNamespacedConfigMap({
1249
- namespace: "kube-system",
1250
- body: {
1251
- metadata: {
1252
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1253
- namespace: "kube-system",
1254
- },
1255
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1256
- },
1257
- });
1258
- }
1259
- if (opts.traefik)
1260
- await helpers.apply(buildTraefikManifest(tlsEnabled));
1261
- if (opts.registry)
1262
- await helpers.apply(REGISTRY_MANIFEST);
1263
- // Both rollouts proceed independently inside the cluster — wait on them
1264
- // concurrently (they used to serialize, wasting up to a rollout's tail).
1265
- // 250ms polling: these two sit on the cold start's critical path, where
1266
- // the default 500ms interval wastes real time.
1247
+ // The registry can start while Traefik's certificates are being prepared.
1267
1248
  const wait = (deployment) => helpers.waitForRollout(deployment, {
1268
1249
  namespace: "kube-system",
1269
1250
  timeoutMs: 120_000,
1270
1251
  intervalMs: 250,
1271
1252
  });
1272
- const waits = opts.traefik ? [wait("traefik")] : [];
1273
- if (opts.registry)
1274
- waits.push(wait("spectest-registry"));
1275
- await Promise.all(waits);
1253
+ await waitForAll([
1254
+ (async () => {
1255
+ if (!opts.traefik)
1256
+ return;
1257
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1258
+ if (tlsEnabled) {
1259
+ const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1260
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1261
+ // Deployment that mounts them. `stringData` lets us hand over plain
1262
+ // PEM; the API server base64-encodes it.
1263
+ await helpers.client.core.createNamespacedSecret({
1264
+ namespace: "kube-system",
1265
+ body: {
1266
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1267
+ type: "kubernetes.io/tls",
1268
+ stringData: { "tls.crt": cert, "tls.key": key },
1269
+ },
1270
+ });
1271
+ await helpers.client.core.createNamespacedConfigMap({
1272
+ namespace: "kube-system",
1273
+ body: {
1274
+ metadata: {
1275
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1276
+ namespace: "kube-system",
1277
+ },
1278
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1279
+ },
1280
+ });
1281
+ }
1282
+ await helpers.apply(buildTraefikManifest(tlsEnabled));
1283
+ await wait("traefik");
1284
+ })(),
1285
+ (async () => {
1286
+ if (!opts.registry)
1287
+ return;
1288
+ await helpers.apply(REGISTRY_MANIFEST);
1289
+ await wait("spectest-registry");
1290
+ })(),
1291
+ ]);
1276
1292
  }
1277
1293
  /**
1278
1294
  * Snapshot of a namespace's state, dumped whenever a wait times out: pod
@@ -1393,6 +1409,7 @@ async function collectDiagnostics(client, namespace) {
1393
1409
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
1394
1410
  export function k3s(opts = {}) {
1395
1411
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1412
+ const images = kubernetesImages(opts.images ?? []);
1396
1413
  const extra = opts.extraArgs ?? [];
1397
1414
  assertUsableExtraArgs(extra);
1398
1415
  const registryEnabled = opts.registry !== false;
@@ -1409,7 +1426,8 @@ export function k3s(opts = {}) {
1409
1426
  // The environment's image cache, when this VM carries one: the
1410
1427
  // cluster's store goes on its root disk and its layers come off the
1411
1428
  // shared layers disk (see the store section above).
1412
- const cache = imageCachePathsSync();
1429
+ const sticky = stickyDiskPathSync();
1430
+ const cache = sticky ? null : imageCachePathsSync();
1413
1431
  const serverArgs = [
1414
1432
  "k3s",
1415
1433
  "server",
@@ -1507,6 +1525,7 @@ export function k3s(opts = {}) {
1507
1525
  const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
1508
1526
  const def = {
1509
1527
  image: { type: "registry", reference: `rancher/k3s:${version}` },
1528
+ imageBuilds: images.builds,
1510
1529
  command: cmd,
1511
1530
  privileged: true,
1512
1531
  tmpfs: ["/run", "/var/run"],
@@ -1520,21 +1539,23 @@ export function k3s(opts = {}) {
1520
1539
  // the same netns and are reachable the same way, without appearing
1521
1540
  // in this list (it's documentation, not a firewall).
1522
1541
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
1523
- // The cluster's image store (see the store section above): its
1542
+ // Sticky disks retain an independent overlayfs store per service/version.
1543
+ // The legacy image cache (see the store section above) keeps its
1524
1544
  // containerd root on the image cache's root disk, the shared layers
1525
1545
  // disk read-only at its guest path, golden's mkfs.erofs and the adopt
1526
1546
  // helper — or, with no cache, a per-environment volume. Two clusters
1527
1547
  // in one environment get separate roots either way (the service
1528
1548
  // token names the directory), because two containerd daemons cannot
1529
1549
  // share a store: bolt takes an exclusive lock.
1530
- volumes: cache
1531
- ? [
1550
+ volumes: sticky
1551
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1552
+ : cache ? [
1532
1553
  { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
1533
1554
  { source: cache.layers, target: cache.layers, readOnly: true },
1534
1555
  { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
1535
1556
  { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
1536
1557
  ]
1537
- : [{ target: CLUSTER_STORE_TARGET }],
1558
+ : [{ target: CLUSTER_STORE_TARGET }],
1538
1559
  files: [
1539
1560
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1540
1561
  ...(cache
@@ -1549,12 +1570,64 @@ export function k3s(opts = {}) {
1549
1570
  command: readyCmd,
1550
1571
  timeoutSecs: opts.readyTimeoutSecs ?? 120,
1551
1572
  },
1552
- setup: async ({ name, helpers }) => {
1553
- await setupK3sCluster(name, helpers, {
1554
- registry: registryEnabled,
1555
- ingressDomains,
1556
- traefik: traefikEnabled,
1557
- });
1573
+ setup: async (ctx) => {
1574
+ const { name, helpers, exec } = ctx;
1575
+ const runtime = componentRuntime(ctx);
1576
+ if (images.builds.length && !runtime)
1577
+ throw new Error(`k3s(${name}): prepared image inputs are missing`);
1578
+ await waitForAll([
1579
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
1580
+ prePullK3sImages(name, images.pulls, exec),
1581
+ (async () => {
1582
+ if (runtime?.caBundle && caPresent()) {
1583
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
1584
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1585
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1586
+ }, {
1587
+ apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1588
+ ensureBundle: async (namespace, configMap, bundle) => {
1589
+ try {
1590
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1591
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1592
+ } });
1593
+ }
1594
+ catch (err) {
1595
+ // A simultaneous Pod may have created the same namespace's bundle.
1596
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1597
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1598
+ throw err;
1599
+ }
1600
+ },
1601
+ });
1602
+ // The API server observes webhook registration asynchronously. A dry-run
1603
+ // Pod proves TLS reachability and admission before any project workload.
1604
+ const deadline = Date.now() + 30_000;
1605
+ for (;;) {
1606
+ try {
1607
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1608
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1609
+ } })).unwrap();
1610
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1611
+ break;
1612
+ if (Date.now() >= deadline)
1613
+ throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1614
+ }
1615
+ catch (err) {
1616
+ if (Date.now() >= deadline)
1617
+ throw err;
1618
+ }
1619
+ await new Promise(resolve => setTimeout(resolve, 100));
1620
+ }
1621
+ }
1622
+ await setupK3sCluster(name, helpers, {
1623
+ registry: registryEnabled,
1624
+ ingressDomains,
1625
+ traefik: traefikEnabled,
1626
+ });
1627
+ })(),
1628
+ ]);
1629
+ if (sticky)
1630
+ await registerStickyKubernetesStore(name, version);
1558
1631
  },
1559
1632
  helpers: async ({ name, exec, poll }) => {
1560
1633
  // Read the cluster's kubeconfig and address the API server by its