@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
@@ -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,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";
@@ -31,6 +35,7 @@ import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
31
35
  import type {
32
36
  ServiceDefinition,
33
37
  ServiceHelpersContext,
38
+ ServiceSetupContext,
34
39
  SpectestContext,
35
40
  } from "../index.js";
36
41
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
@@ -41,6 +46,11 @@ import {
41
46
  nestedStoreDir,
42
47
  type ImageCachePaths,
43
48
  } from "../harness/image-cache.js";
49
+ import { stickyDiskPathSync } from "../harness/sticky-paths.js";
50
+ import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
51
+ import { prePullK3sImages } from "../harness/k3s-prepull.js";
52
+ import { componentRuntime, kubernetesImages, type KubernetesImage } from "../harness/component-images.js";
53
+ export type { KubernetesImage } from "../harness/component-images.js";
44
54
  import { deepUnwrap, readTag, wrap } from "../inspect.js";
45
55
  import type { Wrapped } from "../inspect.js";
46
56
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
@@ -50,7 +60,19 @@ export type {
50
60
  KubernetesListObject,
51
61
  } from "@kubernetes/client-node";
52
62
 
63
+ /** Kubernetes pods receive read-only environment CA mounts during admission.
64
+ * Image bytes and digests stay unchanged; explicit pod trust settings win. */
53
65
  export interface K3sOptions {
66
+ /** Images available to kubelet before project setup. Use registry strings,
67
+ * or Dockerfile builds with a full `reference` matching your manifests. Builds
68
+ * support path/content, context, exclude, target and buildArgs, and run in
69
+ * parallel with service builds through the standard BuildKit pipeline.
70
+ * Setup waits for every pull and fails if a requested image cannot be pulled.
71
+ * Sticky-enabled projects retain image content and unpacked layers between
72
+ * fresh builds automatically. Digest references can reuse the cache offline;
73
+ * tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
74
+ */
75
+ images?: readonly KubernetesImage[];
54
76
  /**
55
77
  * Image tag for the official `rancher/k3s` image. Default
56
78
  * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
@@ -178,8 +200,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
178
200
  const BOOT_MARKER = ".spectest-boot-incomplete";
179
201
 
180
202
  /**
181
- * The cluster's image store, and how it shares the environment's image
182
- * cache (`CONTAINER_STORE.md` §12).
203
+ * Sticky-enabled projects bind a per-service/version directory on the
204
+ * sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
205
+ * sticky-kubernetes adapter removes runtime state and retains images and
206
+ * unpacked layers under the shared filesystem budget. Test forks do not
207
+ * write back. The preparation wrapper below also handles delta rebuilds.
208
+ *
209
+ * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
183
210
  *
184
211
  * The cluster's embedded containerd keeps its root on the **root disk**
185
212
  * of the environment's image cache, in a directory of its own
@@ -346,8 +373,8 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
346
373
  `timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
347
374
  // containerd 2.x keeps sandboxes in a store of their own; on the 1.7
348
375
  // 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; }; ` +
376
+ `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
377
+ `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
351
378
  adopt +
352
379
  `if [ -e "$M" ]; then ` +
353
380
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
@@ -724,7 +751,7 @@ async function issueIngressCert(
724
751
  const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
725
752
  const keyPath = `/tmp/${id}.key`;
726
753
  const crtPath = `/tmp/${id}.crt`;
727
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
754
+ const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
728
755
  const r = await runProcess(
729
756
  "openssl",
730
757
  [
@@ -1839,48 +1866,52 @@ async function setupK3sCluster(
1839
1866
  helpers: K3sHelpers,
1840
1867
  opts: { registry: boolean; ingressDomains: string[]; traefik: boolean },
1841
1868
  ): Promise<void> {
1842
- const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
1843
- if (tlsEnabled) {
1844
- const { cert, key } = await issueIngressCert(
1845
- opts.ingressDomains.map((d) => `*.${d}`),
1846
- );
1847
- // Apply the cert Secret + dynamic-config ConfigMap before the
1848
- // Deployment that mounts them. `stringData` lets us hand over plain
1849
- // PEM; the API server base64-encodes it.
1850
- await helpers.client.core.createNamespacedSecret({
1851
- namespace: "kube-system",
1852
- body: {
1853
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1854
- type: "kubernetes.io/tls",
1855
- stringData: { "tls.crt": cert, "tls.key": key },
1856
- },
1857
- });
1858
- await helpers.client.core.createNamespacedConfigMap({
1859
- namespace: "kube-system",
1860
- body: {
1861
- metadata: {
1862
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1863
- namespace: "kube-system",
1864
- },
1865
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1866
- },
1867
- });
1868
- }
1869
- if (opts.traefik) await helpers.apply(buildTraefikManifest(tlsEnabled));
1870
- if (opts.registry) await helpers.apply(REGISTRY_MANIFEST);
1871
- // Both rollouts proceed independently inside the cluster — wait on them
1872
- // concurrently (they used to serialize, wasting up to a rollout's tail).
1873
- // 250ms polling: these two sit on the cold start's critical path, where
1874
- // the default 500ms interval wastes real time.
1869
+ // The registry can start while Traefik's certificates are being prepared.
1875
1870
  const wait = (deployment: string): Promise<void> =>
1876
1871
  helpers.waitForRollout(deployment, {
1877
1872
  namespace: "kube-system",
1878
1873
  timeoutMs: 120_000,
1879
1874
  intervalMs: 250,
1880
1875
  });
1881
- const waits = opts.traefik ? [wait("traefik")] : [];
1882
- if (opts.registry) waits.push(wait("spectest-registry"));
1883
- await Promise.all(waits);
1876
+ await waitForAll([
1877
+ (async () => {
1878
+ if (!opts.traefik) return;
1879
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1880
+ if (tlsEnabled) {
1881
+ const { cert, key } = await issueIngressCert(
1882
+ opts.ingressDomains.map((d) => `*.${d}`),
1883
+ );
1884
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1885
+ // Deployment that mounts them. `stringData` lets us hand over plain
1886
+ // PEM; the API server base64-encodes it.
1887
+ await helpers.client.core.createNamespacedSecret({
1888
+ namespace: "kube-system",
1889
+ body: {
1890
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1891
+ type: "kubernetes.io/tls",
1892
+ stringData: { "tls.crt": cert, "tls.key": key },
1893
+ },
1894
+ });
1895
+ await helpers.client.core.createNamespacedConfigMap({
1896
+ namespace: "kube-system",
1897
+ body: {
1898
+ metadata: {
1899
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1900
+ namespace: "kube-system",
1901
+ },
1902
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1903
+ },
1904
+ });
1905
+ }
1906
+ await helpers.apply(buildTraefikManifest(tlsEnabled));
1907
+ await wait("traefik");
1908
+ })(),
1909
+ (async () => {
1910
+ if (!opts.registry) return;
1911
+ await helpers.apply(REGISTRY_MANIFEST);
1912
+ await wait("spectest-registry");
1913
+ })(),
1914
+ ]);
1884
1915
  }
1885
1916
 
1886
1917
  /**
@@ -2007,6 +2038,7 @@ const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
2007
2038
 
2008
2039
  export function k3s(opts: K3sOptions = {}) {
2009
2040
  const version = opts.version ?? DEFAULT_K3S_VERSION;
2041
+ const images = kubernetesImages(opts.images ?? []);
2010
2042
  const extra = opts.extraArgs ?? [];
2011
2043
  assertUsableExtraArgs(extra);
2012
2044
  const registryEnabled = opts.registry !== false;
@@ -2023,7 +2055,8 @@ export function k3s(opts: K3sOptions = {}) {
2023
2055
  // The environment's image cache, when this VM carries one: the
2024
2056
  // cluster's store goes on its root disk and its layers come off the
2025
2057
  // shared layers disk (see the store section above).
2026
- const cache = imageCachePathsSync();
2058
+ const sticky = stickyDiskPathSync();
2059
+ const cache = sticky ? null : imageCachePathsSync();
2027
2060
  const serverArgs = [
2028
2061
  "k3s",
2029
2062
  "server",
@@ -2122,6 +2155,7 @@ export function k3s(opts: K3sOptions = {}) {
2122
2155
  const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
2123
2156
  const def = {
2124
2157
  image: { type: "registry", reference: `rancher/k3s:${version}` },
2158
+ imageBuilds: images.builds,
2125
2159
  command: cmd,
2126
2160
  privileged: true,
2127
2161
  tmpfs: ["/run", "/var/run"],
@@ -2135,15 +2169,17 @@ export function k3s(opts: K3sOptions = {}) {
2135
2169
  // the same netns and are reachable the same way, without appearing
2136
2170
  // in this list (it's documentation, not a firewall).
2137
2171
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
2138
- // The cluster's image store (see the store section above): its
2172
+ // Sticky disks retain an independent overlayfs store per service/version.
2173
+ // The legacy image cache (see the store section above) keeps its
2139
2174
  // containerd root on the image cache's root disk, the shared layers
2140
2175
  // disk read-only at its guest path, golden's mkfs.erofs and the adopt
2141
2176
  // helper — or, with no cache, a per-environment volume. Two clusters
2142
2177
  // in one environment get separate roots either way (the service
2143
2178
  // token names the directory), because two containerd daemons cannot
2144
2179
  // share a store: bolt takes an exclusive lock.
2145
- volumes: cache
2146
- ? [
2180
+ volumes: sticky
2181
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2182
+ : cache ? [
2147
2183
  { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
2148
2184
  { source: cache.layers, target: cache.layers, readOnly: true },
2149
2185
  { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
@@ -2164,12 +2200,56 @@ export function k3s(opts: K3sOptions = {}) {
2164
2200
  command: readyCmd,
2165
2201
  timeoutSecs: opts.readyTimeoutSecs ?? 120,
2166
2202
  },
2167
- setup: async ({ name, helpers }: { name: string; helpers: K3sHelpers }) => {
2168
- await setupK3sCluster(name, helpers, {
2169
- registry: registryEnabled,
2170
- ingressDomains,
2171
- traefik: traefikEnabled,
2172
- });
2203
+ setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
2204
+ const { name, helpers, exec } = ctx;
2205
+ const runtime = componentRuntime(ctx);
2206
+ if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2207
+ await waitForAll([
2208
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
2209
+ prePullK3sImages(name, images.pulls, exec),
2210
+ (async () => {
2211
+ if (runtime?.caBundle && caPresent()) {
2212
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
2213
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2214
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2215
+ }, {
2216
+ apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2217
+ ensureBundle: async (namespace, configMap, bundle) => {
2218
+ try {
2219
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2220
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2221
+ } });
2222
+ } catch (err) {
2223
+ // A simultaneous Pod may have created the same namespace's bundle.
2224
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2225
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2226
+ }
2227
+ },
2228
+ });
2229
+ // The API server observes webhook registration asynchronously. A dry-run
2230
+ // Pod proves TLS reachability and admission before any project workload.
2231
+ const deadline = Date.now() + 30_000;
2232
+ for (;;) {
2233
+ try {
2234
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2235
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2236
+ } })).unwrap();
2237
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2238
+ if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2239
+ } catch (err) {
2240
+ if (Date.now() >= deadline) throw err;
2241
+ }
2242
+ await new Promise(resolve => setTimeout(resolve, 100));
2243
+ }
2244
+ }
2245
+ await setupK3sCluster(name, helpers, {
2246
+ registry: registryEnabled,
2247
+ ingressDomains,
2248
+ traefik: traefikEnabled,
2249
+ });
2250
+ })(),
2251
+ ]);
2252
+ if (sticky) await registerStickyKubernetesStore(name, version);
2173
2253
  },
2174
2254
  helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
2175
2255
  // Read the cluster's kubeconfig and address the API server by its
package/src/daemon.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
+ import { waitForAll } from "./harness/concurrency.js";
1
3
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
2
4
  // Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
3
5
  // a container). Owns:
@@ -35,6 +37,7 @@ import {
35
37
  dnsName as makeDnsDecl,
36
38
  isWildcard,
37
39
  validateServiceImage,
40
+ normalizeServiceImage,
38
41
  proxy as makeProxyDecl,
39
42
  } from "./index.js";
40
43
  import type { DnsTarget, LoweredIngress } from "./index.js";
@@ -154,6 +157,7 @@ import {
154
157
  } from "./harness/ingress-table.js";
155
158
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
156
159
  import { runContainerArgs } from "./harness/container-run.js";
160
+ import { isOnStickyDisk } from "./harness/sticky-paths.js";
157
161
  import {
158
162
  BUILDKIT_CACHE_DIR,
159
163
  BUILDKIT_CACHE_UNION,
@@ -239,7 +243,7 @@ import type {
239
243
  ServiceConfig,
240
244
  ServiceDefinition,
241
245
  ServiceHandles,
242
- ServiceImage,
246
+ NormalizedServiceImage,
243
247
  SpectestContext,
244
248
  Terminal,
245
249
  TerminalOpts,
@@ -256,10 +260,21 @@ import type { InternalTerminal, TerminalFrameSink } from "./terminal.js";
256
260
  * name (container name, image tag, ready-probe host). Widened to
257
261
  * accept any helpers shape so a typed `setup({ helpers })` call from
258
262
  * a component (e.g. K3sHelpers) lines up at the call site. */
259
- type NamedService = ServiceDefinition<Record<string, unknown>> & { name: string };
263
+ type NamedService = Omit<ServiceDefinition<Record<string, unknown>>, "image"> & { name: string; image: NormalizedServiceImage };
260
264
 
261
265
  function namedServices(cfg: EnvironmentConfig): NamedService[] {
262
- return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
266
+ return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
267
+ }
268
+
269
+ /** Build tasks participate in image preparation, never container startup. */
270
+ function imageTasks(services: NamedService[]): NamedService[] {
271
+ const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
272
+ const names = new Set(services.map(s => s.name));
273
+ for (const task of tasks) {
274
+ if (names.has(task.name)) throw new Error(`image task name conflicts with service: ${task.name}`);
275
+ names.add(task.name);
276
+ }
277
+ return [...services, ...tasks];
263
278
  }
264
279
 
265
280
  const DEFAULT_TEST_TIMEOUT_MS = 60_000;
@@ -977,7 +992,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
977
992
  // A directory on a cache disk is the same kind of thing: a nested
978
993
  // runtime's containerd root (`k3s()`), kept as a cache by the
979
994
  // lineage exactly as the container store one level up is.
980
- const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
995
+ const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
981
996
  if (vol.source?.startsWith("/") && !durable) {
982
997
  await recordAbsoluteVolumeDir(host);
983
998
  }
@@ -1365,6 +1380,18 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
1365
1380
  async function prepareServiceImage(
1366
1381
  svc: NamedService,
1367
1382
  opts?: { dedup?: boolean },
1383
+ ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1384
+ const result = await prepareServiceImageInner(svc, opts);
1385
+ if (svc.image.type === "dockerfile" && svc.image.reference) {
1386
+ const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
1387
+ if (tagged.code !== 0) throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
1388
+ }
1389
+ return result;
1390
+ }
1391
+
1392
+ async function prepareServiceImageInner(
1393
+ svc: NamedService,
1394
+ opts?: { dedup?: boolean },
1368
1395
  ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1369
1396
  const tag = imageTag(svc.name);
1370
1397
  if (svc.image.type === "registry") {
@@ -1414,7 +1441,7 @@ async function prepareServiceImage(
1414
1441
  progressService(svc.name, { status: "failed" });
1415
1442
  throw err;
1416
1443
  }
1417
- // first.tag is already CA-layered; tagging it covers this service too.
1444
+ // Reuse the same application image; runtime trust is added separately.
1418
1445
  const tagr = await docker(["tag", first.tag, tag], 30_000);
1419
1446
  if (tagr.code !== 0) {
1420
1447
  throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
@@ -3175,7 +3202,8 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
3175
3202
  // run here: the image is checked (a `path` Dockerfile and a `context`
3176
3203
  // must exist), and coverage adapters get their configure step.
3177
3204
  const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
3178
- return { name, ...applyCoverageAdapters(name, svc) };
3205
+ const configured = applyCoverageAdapters(name, svc);
3206
+ return { name, ...configured, image: normalizeServiceImage(configured.image) };
3179
3207
  }
3180
3208
 
3181
3209
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -3507,12 +3535,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3507
3535
  const bootStart = Date.now();
3508
3536
  const cfg: EnvironmentConfig = requireLoaded().project.environment;
3509
3537
  const services = namedServices(cfg);
3538
+ const tasks = imageTasks(services);
3539
+ const componentTasks = componentBuildTasks(services);
3510
3540
  const timings = new Map<string, ServiceTiming>();
3511
3541
  if (prepared && (prepared.version !== 1 || !prepared.images ||
3512
- Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
3542
+ Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
3513
3543
  throw new Error("prepared image manifest does not match the environment's services");
3514
3544
  }
3515
- progressInit(services);
3545
+ progressInit(tasks);
3516
3546
  // Build dedup is only valid within one workspace generation — a fresh
3517
3547
  // bootstrap may follow a workspace re-upload with the same dockerfile
3518
3548
  // text but different build-context content.
@@ -3535,7 +3565,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3535
3565
  if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
3536
3566
  await fs.writeFile(
3537
3567
  path.join(WORKSPACE, ".dockerignore"),
3538
- unionDockerignore(services),
3568
+ unionDockerignore(tasks),
3539
3569
  );
3540
3570
  })(),
3541
3571
  ]);
@@ -3572,11 +3602,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3572
3602
  if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3573
3603
  throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3574
3604
  }
3575
- return { tag: entry.image, buildSteps: entry.buildSteps };
3605
+ const tag = imageTag(svc.name);
3606
+ const tagged = await docker(["tag", entry.image, tag], 30_000);
3607
+ if (tagged.code !== 0) throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
3608
+ return { tag, buildSteps: entry.buildSteps };
3576
3609
  };
3577
3610
  const prep = new Map<string, Promise<void>>();
3578
3611
  const prepOffsets = new Map<string, number>();
3579
- for (const svc of services) {
3612
+ for (const svc of tasks) {
3580
3613
  const p = (async () => {
3581
3614
  const t0 = Date.now();
3582
3615
  prepOffsets.set(svc.name, Date.now() - bootStart);
@@ -3602,7 +3635,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3602
3635
  // No application services, setup hooks, ingress, or runtime state in the builder.
3603
3636
  await Promise.all(prep.values());
3604
3637
  const images: PreparedImages["images"] = Object.create(null);
3605
- for (const svc of services) {
3638
+ for (const svc of tasks) {
3606
3639
  const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
3607
3640
  const image = out.stdout.trim();
3608
3641
  if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
@@ -3654,19 +3687,30 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3654
3687
  progressService(svc.name, { status: "probing", detail: "ready check" });
3655
3688
  await waitForReady(svc);
3656
3689
  const tSetup = Date.now();
3657
- if (svc.setup) {
3658
- progressService(svc.name, { status: "probing", detail: "running setup" });
3659
- const helpers = await ensureHelpers(svc.name, svc);
3660
- try {
3661
- await svc.setup({
3662
- name: svc.name,
3663
- helpers,
3664
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3665
- });
3666
- } catch (err) {
3667
- throw await withContainerPostMortem(svc.name, err);
3690
+ const builds = componentTasks.filter(task => task.owner === svc.name);
3691
+ // Setup can overlap pulls/infrastructure with builds, and consume each
3692
+ // image as it becomes ready. Still drain every build if setup fails.
3693
+ await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
3694
+ if (svc.setup) {
3695
+ progressService(svc.name, { status: "probing", detail: "running setup" });
3696
+ const helpers = await ensureHelpers(svc.name, svc);
3697
+ try {
3698
+ await svc.setup({
3699
+ name: svc.name,
3700
+ helpers,
3701
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3702
+ ...{ [COMPONENT_RUNTIME]: {
3703
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3704
+ gateway: await bridgeGatewayIp(),
3705
+ caBundle: await ensureCaBundle(),
3706
+ } },
3707
+ });
3708
+ } catch (err) {
3709
+ throw await withContainerPostMortem(svc.name, err);
3710
+ }
3668
3711
  }
3669
- }
3712
+ })()]);
3713
+ for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
3670
3714
  progressService(svc.name, { status: "ready", detail: undefined });
3671
3715
  const ti = timings.get(svc.name);
3672
3716
  if (ti) {
@@ -4223,9 +4267,10 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
4223
4267
  async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
4224
4268
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
4225
4269
  const sources: ReachSource[] = [];
4226
- for (const svc of all) {
4270
+ const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
4271
+ for (const svc of imageTasks(all)) {
4227
4272
  const src: ReachSource = {
4228
- service: svc.name,
4273
+ service: owners.get(svc.name) ?? svc.name,
4229
4274
  volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
4230
4275
  ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
4231
4276
  };
@@ -6720,7 +6765,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6720
6765
  bootstrap: async (params) =>
6721
6766
  exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6722
6767
  const prepared = params.prepared === undefined ? undefined : preparedImages(
6723
- params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
6768
+ params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name),
6724
6769
  );
6725
6770
  return { ok: true, timings: await bootstrap(prepared) };
6726
6771
  }),