@specific.dev/spectest 0.90.2 → 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 (45) hide show
  1. package/dist/browser-coverage.d.ts +5 -5
  2. package/dist/browser-coverage.js +11 -14
  3. package/dist/components/expo.d.ts +2 -0
  4. package/dist/components/index.d.ts +1 -1
  5. package/dist/components/k3s-trust.d.ts +57 -0
  6. package/dist/components/k3s-trust.js +125 -0
  7. package/dist/components/k3s.d.ts +17 -5
  8. package/dist/components/k3s.js +77 -11
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +3 -3
  11. package/dist/daemon.js +53 -14
  12. package/dist/harness/component-images.d.ts +31 -0
  13. package/dist/harness/component-images.js +69 -0
  14. package/dist/harness/k3s-image-loader.d.ts +5 -0
  15. package/dist/harness/k3s-image-loader.js +150 -0
  16. package/dist/harness/k3s-prepull.d.ts +7 -0
  17. package/dist/harness/k3s-prepull.js +39 -0
  18. package/dist/harness/sticky-consumer.d.ts +3 -3
  19. package/dist/harness/sticky-consumer.js +19 -4
  20. package/dist/harness/sticky-kubernetes.d.ts +15 -0
  21. package/dist/harness/sticky-kubernetes.js +163 -0
  22. package/dist/harness/sticky-paths.d.ts +5 -0
  23. package/dist/harness/sticky-paths.js +18 -0
  24. package/dist/index.d.ts +32 -2
  25. package/dist/index.js +25 -2
  26. package/package.json +1 -1
  27. package/src/browser-coverage.ts +11 -13
  28. package/src/components/index.ts +1 -0
  29. package/src/components/k3s-trust.test.ts +81 -0
  30. package/src/components/k3s-trust.ts +135 -0
  31. package/src/components/k3s.ts +81 -10
  32. package/src/coverage.test.ts +50 -0
  33. package/src/coverage.ts +3 -3
  34. package/src/daemon.ts +56 -15
  35. package/src/harness/component-images.test.ts +44 -0
  36. package/src/harness/component-images.ts +73 -0
  37. package/src/harness/k3s-image-loader.ts +129 -0
  38. package/src/harness/k3s-prepull.test.ts +47 -0
  39. package/src/harness/k3s-prepull.ts +36 -0
  40. package/src/harness/sticky-consumer.test.ts +28 -0
  41. package/src/harness/sticky-consumer.ts +14 -3
  42. package/src/harness/sticky-kubernetes.test.ts +88 -0
  43. package/src/harness/sticky-kubernetes.ts +165 -0
  44. package/src/harness/sticky-paths.ts +18 -0
  45. package/src/index.ts +48 -3
@@ -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
@@ -135,6 +135,56 @@ describe("units", () => {
135
135
  });
136
136
 
137
137
  describe("browserCoverage", () => {
138
+ for (const order of ["dashboard first", "admin first", "concurrent"] as const) {
139
+ test(`services retain their own coverage and deltas: ${order}`, async () => {
140
+ const reports = new Map<string, string>();
141
+ const contexts = ["dashboard", "admin"].map((service): CoverageCaptureContext => ({
142
+ ...fakeCtx("unused"),
143
+ service,
144
+ async writeReport(name, content) {
145
+ expect(name).toBe("browser.lcov");
146
+ reports.set(service, content);
147
+ },
148
+ }));
149
+ if (order === "admin first") contexts.reverse();
150
+ const capture = async () => {
151
+ if (order === "concurrent") {
152
+ await Promise.all(contexts.map((ctx) => browser().capture!(ctx)));
153
+ } else {
154
+ for (const ctx of contexts) await browser().capture!(ctx);
155
+ }
156
+ };
157
+ foldBrowserCoverage("dashboard", new Map([["src/dashboard.ts", new Map([[1, 2]])]]));
158
+ foldBrowserCoverage("admin", new Map([["src/admin.ts", new Map([[3, 4]])]]));
159
+ await capture();
160
+ expect(reports.get("dashboard")).toBe("TN:\nSF:src/dashboard.ts\nDA:1,2\nLF:1\nLH:1\nend_of_record\n");
161
+ expect(reports.get("admin")).toBe("TN:\nSF:src/admin.ts\nDA:3,4\nLF:1\nLH:1\nend_of_record\n");
162
+
163
+ await capture();
164
+ expect(reports.get("dashboard")).toBe("TN:\n");
165
+ expect(reports.get("admin")).toBe("TN:\n");
166
+
167
+ foldBrowserCoverage("admin", new Map([["src/admin.ts", new Map([[3, 1]])]]));
168
+ await capture();
169
+ expect(reports.get("dashboard")).toBe("TN:\n");
170
+ expect(reports.get("admin")).toBe("TN:\nSF:src/admin.ts\nDA:3,1\nLF:1\nLH:1\nend_of_record\n");
171
+ });
172
+ }
173
+
174
+ test("capturing a service with no coverage preserves other services' totals", async () => {
175
+ const reports = new Map<string, string>();
176
+ foldBrowserCoverage("dashboard", new Map([["src/dashboard.ts", new Map([[1, 2]])]]));
177
+ for (const service of ["admin", "dashboard"]) {
178
+ await browser().capture!({
179
+ ...fakeCtx("unused"),
180
+ service,
181
+ async writeReport(_name, content) { reports.set(service, content); },
182
+ });
183
+ }
184
+ expect(reports.get("admin")).toBe("TN:\n");
185
+ expect(reports.get("dashboard")).toBe("TN:\nSF:src/dashboard.ts\nDA:1,2\nLF:1\nLH:1\nend_of_record\n");
186
+ });
187
+
138
188
  test("writes an empty lcov when nothing ran for the service", async () => {
139
189
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
140
190
  await browser().capture!(fakeCtx(dir));
package/src/coverage.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  import { createHash } from "node:crypto";
31
31
 
32
32
  import type { ServiceConfig } from "./index.js";
33
- import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
33
+ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
34
34
 
35
35
  import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
36
36
  export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
@@ -969,7 +969,7 @@ export async function collectNodeReports(dir: string): Promise<NodeReportStats>
969
969
  * loads from this service's origins, maps it back to source through the
970
970
  * served source map, and writes one lcov per capture (`browser.lcov`)
971
971
  * holding what ran since the previous capture: every live session is
972
- * harvested first, and the totals are taken and cleared. Nothing ran ⇒
972
+ * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
973
973
  * an lcov with no records, which is a report saying so.
974
974
  */
975
975
  export function browser(): CoverageAdapter {
@@ -981,7 +981,7 @@ export function browser(): CoverageAdapter {
981
981
  },
982
982
  async capture(ctx) {
983
983
  await harvestAllBrowserCoverage();
984
- const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
984
+ const lcov = takeBrowserCoverageReport(ctx.service);
985
985
  await ctx.writeReport("browser.lcov", lcov);
986
986
  },
987
987
  };
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
1
2
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
2
3
  // Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
3
4
  // a container). Owns:
@@ -35,6 +36,7 @@ import {
35
36
  dnsName as makeDnsDecl,
36
37
  isWildcard,
37
38
  validateServiceImage,
39
+ normalizeServiceImage,
38
40
  proxy as makeProxyDecl,
39
41
  } from "./index.js";
40
42
  import type { DnsTarget, LoweredIngress } from "./index.js";
@@ -154,6 +156,7 @@ import {
154
156
  } from "./harness/ingress-table.js";
155
157
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
156
158
  import { runContainerArgs } from "./harness/container-run.js";
159
+ import { isOnStickyDisk } from "./harness/sticky-paths.js";
157
160
  import {
158
161
  BUILDKIT_CACHE_DIR,
159
162
  BUILDKIT_CACHE_UNION,
@@ -239,7 +242,7 @@ import type {
239
242
  ServiceConfig,
240
243
  ServiceDefinition,
241
244
  ServiceHandles,
242
- ServiceImage,
245
+ NormalizedServiceImage,
243
246
  SpectestContext,
244
247
  Terminal,
245
248
  TerminalOpts,
@@ -256,10 +259,21 @@ import type { InternalTerminal, TerminalFrameSink } from "./terminal.js";
256
259
  * name (container name, image tag, ready-probe host). Widened to
257
260
  * accept any helpers shape so a typed `setup({ helpers })` call from
258
261
  * a component (e.g. K3sHelpers) lines up at the call site. */
259
- type NamedService = ServiceDefinition<Record<string, unknown>> & { name: string };
262
+ type NamedService = Omit<ServiceDefinition<Record<string, unknown>>, "image"> & { name: string; image: NormalizedServiceImage };
260
263
 
261
264
  function namedServices(cfg: EnvironmentConfig): NamedService[] {
262
- return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
265
+ return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
266
+ }
267
+
268
+ /** Build tasks participate in image preparation, never container startup. */
269
+ function imageTasks(services: NamedService[]): NamedService[] {
270
+ const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
271
+ const names = new Set(services.map(s => s.name));
272
+ for (const task of tasks) {
273
+ if (names.has(task.name)) throw new Error(`image task name conflicts with service: ${task.name}`);
274
+ names.add(task.name);
275
+ }
276
+ return [...services, ...tasks];
263
277
  }
264
278
 
265
279
  const DEFAULT_TEST_TIMEOUT_MS = 60_000;
@@ -977,7 +991,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
977
991
  // A directory on a cache disk is the same kind of thing: a nested
978
992
  // runtime's containerd root (`k3s()`), kept as a cache by the
979
993
  // lineage exactly as the container store one level up is.
980
- const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
994
+ const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
981
995
  if (vol.source?.startsWith("/") && !durable) {
982
996
  await recordAbsoluteVolumeDir(host);
983
997
  }
@@ -1365,6 +1379,18 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
1365
1379
  async function prepareServiceImage(
1366
1380
  svc: NamedService,
1367
1381
  opts?: { dedup?: boolean },
1382
+ ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1383
+ const result = await prepareServiceImageInner(svc, opts);
1384
+ if (svc.image.type === "dockerfile" && svc.image.reference) {
1385
+ const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
1386
+ if (tagged.code !== 0) throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
1387
+ }
1388
+ return result;
1389
+ }
1390
+
1391
+ async function prepareServiceImageInner(
1392
+ svc: NamedService,
1393
+ opts?: { dedup?: boolean },
1368
1394
  ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1369
1395
  const tag = imageTag(svc.name);
1370
1396
  if (svc.image.type === "registry") {
@@ -1414,7 +1440,7 @@ async function prepareServiceImage(
1414
1440
  progressService(svc.name, { status: "failed" });
1415
1441
  throw err;
1416
1442
  }
1417
- // first.tag is already CA-layered; tagging it covers this service too.
1443
+ // Reuse the same application image; runtime trust is added separately.
1418
1444
  const tagr = await docker(["tag", first.tag, tag], 30_000);
1419
1445
  if (tagr.code !== 0) {
1420
1446
  throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
@@ -3175,7 +3201,8 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
3175
3201
  // run here: the image is checked (a `path` Dockerfile and a `context`
3176
3202
  // must exist), and coverage adapters get their configure step.
3177
3203
  const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
3178
- return { name, ...applyCoverageAdapters(name, svc) };
3204
+ const configured = applyCoverageAdapters(name, svc);
3205
+ return { name, ...configured, image: normalizeServiceImage(configured.image) };
3179
3206
  }
3180
3207
 
3181
3208
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -3507,12 +3534,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3507
3534
  const bootStart = Date.now();
3508
3535
  const cfg: EnvironmentConfig = requireLoaded().project.environment;
3509
3536
  const services = namedServices(cfg);
3537
+ const tasks = imageTasks(services);
3538
+ const componentTasks = componentBuildTasks(services);
3510
3539
  const timings = new Map<string, ServiceTiming>();
3511
3540
  if (prepared && (prepared.version !== 1 || !prepared.images ||
3512
- Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
3541
+ Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
3513
3542
  throw new Error("prepared image manifest does not match the environment's services");
3514
3543
  }
3515
- progressInit(services);
3544
+ progressInit(tasks);
3516
3545
  // Build dedup is only valid within one workspace generation — a fresh
3517
3546
  // bootstrap may follow a workspace re-upload with the same dockerfile
3518
3547
  // text but different build-context content.
@@ -3535,7 +3564,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3535
3564
  if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
3536
3565
  await fs.writeFile(
3537
3566
  path.join(WORKSPACE, ".dockerignore"),
3538
- unionDockerignore(services),
3567
+ unionDockerignore(tasks),
3539
3568
  );
3540
3569
  })(),
3541
3570
  ]);
@@ -3572,11 +3601,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3572
3601
  if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3573
3602
  throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3574
3603
  }
3575
- return { tag: entry.image, buildSteps: entry.buildSteps };
3604
+ const tag = imageTag(svc.name);
3605
+ const tagged = await docker(["tag", entry.image, tag], 30_000);
3606
+ if (tagged.code !== 0) throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
3607
+ return { tag, buildSteps: entry.buildSteps };
3576
3608
  };
3577
3609
  const prep = new Map<string, Promise<void>>();
3578
3610
  const prepOffsets = new Map<string, number>();
3579
- for (const svc of services) {
3611
+ for (const svc of tasks) {
3580
3612
  const p = (async () => {
3581
3613
  const t0 = Date.now();
3582
3614
  prepOffsets.set(svc.name, Date.now() - bootStart);
@@ -3602,7 +3634,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3602
3634
  // No application services, setup hooks, ingress, or runtime state in the builder.
3603
3635
  await Promise.all(prep.values());
3604
3636
  const images: PreparedImages["images"] = Object.create(null);
3605
- for (const svc of services) {
3637
+ for (const svc of tasks) {
3606
3638
  const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
3607
3639
  const image = out.stdout.trim();
3608
3640
  if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
@@ -3654,6 +3686,8 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3654
3686
  progressService(svc.name, { status: "probing", detail: "ready check" });
3655
3687
  await waitForReady(svc);
3656
3688
  const tSetup = Date.now();
3689
+ const builds = componentTasks.filter(task => task.owner === svc.name);
3690
+ await Promise.all(builds.map(task => prep.get(task.name)!));
3657
3691
  if (svc.setup) {
3658
3692
  progressService(svc.name, { status: "probing", detail: "running setup" });
3659
3693
  const helpers = await ensureHelpers(svc.name, svc);
@@ -3662,11 +3696,17 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3662
3696
  name: svc.name,
3663
3697
  helpers,
3664
3698
  ...(await spectestContext({ service: svc.name, includeSelf: true })),
3699
+ ...{ [COMPONENT_RUNTIME]: {
3700
+ images: builds.map(task => ({ source: tags.get(task.name)!, reference: task.reference })),
3701
+ gateway: await bridgeGatewayIp(),
3702
+ caBundle: await ensureCaBundle(),
3703
+ } },
3665
3704
  });
3666
3705
  } catch (err) {
3667
3706
  throw await withContainerPostMortem(svc.name, err);
3668
3707
  }
3669
3708
  }
3709
+ for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
3670
3710
  progressService(svc.name, { status: "ready", detail: undefined });
3671
3711
  const ti = timings.get(svc.name);
3672
3712
  if (ti) {
@@ -4223,9 +4263,10 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
4223
4263
  async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
4224
4264
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
4225
4265
  const sources: ReachSource[] = [];
4226
- for (const svc of all) {
4266
+ const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
4267
+ for (const svc of imageTasks(all)) {
4227
4268
  const src: ReachSource = {
4228
- service: svc.name,
4269
+ service: owners.get(svc.name) ?? svc.name,
4229
4270
  volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
4230
4271
  ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
4231
4272
  };
@@ -6720,7 +6761,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6720
6761
  bootstrap: async (params) =>
6721
6762
  exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6722
6763
  const prepared = params.prepared === undefined ? undefined : preparedImages(
6723
- params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
6764
+ params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name),
6724
6765
  );
6725
6766
  return { ok: true, timings: await bootstrap(prepared) };
6726
6767
  }),
@@ -0,0 +1,44 @@
1
+ import { expect, test } from "bun:test";
2
+ import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
3
+ import { validateServiceImage, type BuildImage } from "../index";
4
+
5
+ test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
6
+ const image: BuildImage & { reference: string } = {
7
+ reference: "k8s.internal:5000/runtime:spectest", content: "FROM scratch AS test",
8
+ target: "test", context: ".", exclude: ["fixtures"], buildArgs: { MODE: "test" },
9
+ };
10
+ expect(validateServiceImage("app", { ...image, context: undefined })).toEqual({ ...image, context: undefined, type: "dockerfile" });
11
+ expect(validateServiceImage("redis", "redis:7.4")).toEqual({ type: "registry", reference: "redis:7.4" });
12
+ const parsed = kubernetesImages(["redis:7.4", image]);
13
+ expect(parsed.pulls).toEqual(["redis:7.4"]);
14
+ expect(parsed.builds).toEqual([{ reference: image.reference, image }]);
15
+ const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: parsed.builds }]);
16
+ expect(tasks[0].image).toBe(image);
17
+ expect(tasks[0].owner).toBe("cluster");
18
+ expect(tasks[0].name).toMatch(/^spectest-image-[a-f0-9]+$/);
19
+ });
20
+
21
+ test("image identity validates references and rejects conflicting definitions", () => {
22
+ expect(canonicalImageReference("redis:7.4")).toBe("docker.io/library/redis:7.4");
23
+ expect(canonicalImageReference("docker.io/redis:7.4")).toBe("docker.io/library/redis:7.4");
24
+ expect(canonicalImageReference("company/api")).toBe("docker.io/company/api:latest");
25
+ expect(canonicalImageReference("localhost:5000/api")).toBe("localhost:5000/api:latest");
26
+ expect(kubernetesImages(["redis:7.4", "redis:7.4"]).pulls).toHaveLength(1);
27
+ for (const value of ["", "--help", "https://example.com/api", "has space", "bad:tag:extra"]) {
28
+ expect(() => kubernetesImages([value])).toThrow();
29
+ }
30
+ expect(() => kubernetesImages([{ content: "FROM scratch" } as never])).toThrow("reference");
31
+ expect(() => kubernetesImages([{ type: "registry", reference: "redis:7" } as never])).toThrow();
32
+ expect(() => kubernetesImages([{ reference: `api@sha256:${"a".repeat(64)}`, content: "FROM scratch" }])).toThrow("digest");
33
+ expect(() => kubernetesImages(["api:1", { reference: "docker.io/library/api:1", content: "FROM scratch" }])).toThrow("conflicting");
34
+ });
35
+
36
+ test("build task identity survives list reordering but is scoped to the owning cluster", () => {
37
+ const a = { reference: "app:1", image: { content: "FROM scratch" } };
38
+ const b = { reference: "app:2", image: { content: "FROM scratch" } };
39
+ const tasks = (name: string, imageBuilds: typeof a[]) => componentBuildTasks([{ name, image: "k3s:1", imageBuilds }]);
40
+ expect(tasks("cluster", [a, b])[0].name).toBe(tasks("cluster", [b, a])[1].name);
41
+ expect(tasks("cluster", [a])[0].name).not.toBe(tasks("other", [a])[0].name);
42
+ expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
43
+ expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
44
+ });
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
3
+
4
+ export type KubernetesImage = string | (BuildImage & { reference: string });
5
+
6
+ /** containerd/CRI use fully qualified names, including the default tag. */
7
+ export function canonicalImageReference(reference: string): string {
8
+ if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
9
+ throw new Error("image reference must be a non-empty image name without whitespace");
10
+ }
11
+ const slash = reference.indexOf("/");
12
+ const first = slash < 0 ? "" : reference.slice(0, slash);
13
+ let result = slash < 0 ? `docker.io/library/${reference}`
14
+ : first.includes(".") || first.includes(":") || first === "localhost" ? reference : `docker.io/${reference}`;
15
+ if (result.startsWith("index.docker.io/")) result = result.replace("index.docker.io/", "docker.io/");
16
+ if (result.startsWith("docker.io/") && !result.slice(10).includes("/")) result = `docker.io/library/${result.slice(10)}`;
17
+ const [name, digest] = result.split("@");
18
+ const tagAt = name.lastIndexOf(":") > name.lastIndexOf("/") ? name.lastIndexOf(":") : -1;
19
+ const repository = tagAt < 0 ? name : name.slice(0, tagAt);
20
+ if (!/^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$/.test(repository)
21
+ || (tagAt >= 0 && !/^[\w][\w.-]{0,127}$/.test(name.slice(tagAt + 1)))
22
+ || (result.includes("@") && (!digest || !/^sha256:[a-f0-9]{64}$/.test(digest) || result.split("@").length !== 2))) {
23
+ throw new Error(`invalid image reference: ${reference}`);
24
+ }
25
+ if (tagAt < 0 && !digest) result += ":latest";
26
+ return result;
27
+ }
28
+
29
+ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
30
+ if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
31
+ const pulls: string[] = [];
32
+ const builds: ComponentImageBuild[] = [];
33
+ const seen = new Map<string, string>();
34
+ for (const image of images) {
35
+ if (typeof image !== "string" && (!image || typeof image !== "object" || "type" in image || !image.reference)) {
36
+ throw new Error("k3s(): images expects registry strings or Dockerfile builds with a reference");
37
+ }
38
+ const reference = canonicalImageReference(typeof image === "string" ? image : image.reference);
39
+ if (typeof image !== "string" && reference.includes("@")) throw new Error("k3s(): a build reference must be an image name/tag, not a digest");
40
+ const identity = JSON.stringify(image);
41
+ const previous = seen.get(reference);
42
+ if (previous !== undefined) {
43
+ if (previous !== identity) throw new Error(`k3s(): conflicting images for ${reference}`);
44
+ continue;
45
+ }
46
+ seen.set(reference, identity);
47
+ if (typeof image === "string") pulls.push(image);
48
+ else builds.push({ reference, image });
49
+ }
50
+ return { pulls, builds };
51
+ }
52
+
53
+ /** Stable internal task names, separate from the service graph. */
54
+ export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
55
+ return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
56
+ name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
57
+ owner: svc.name,
58
+ reference: build.reference,
59
+ image: build.image,
60
+ })));
61
+ }
62
+
63
+ /** Private handoff to a component's setup hook. Builds stay generic; the
64
+ * component decides how its runtime consumes them and establishes trust. */
65
+ export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
66
+ export interface ComponentRuntime {
67
+ images: readonly { source: string; reference: string }[];
68
+ gateway: string;
69
+ caBundle: string | null;
70
+ }
71
+ export function componentRuntime(context: object): ComponentRuntime | undefined {
72
+ return (context as { [COMPONENT_RUNTIME]?: ComponentRuntime })[COMPONENT_RUNTIME];
73
+ }