@specific.dev/spectest 0.92.0 → 0.93.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.
package/README.md CHANGED
@@ -106,3 +106,10 @@ renderer DOM from acquisition onward, not native OS surfaces.
106
106
 
107
107
  Run `spectest docs /components/electron` for the full configuration reference,
108
108
  backend setup, session behavior, and replay scope (CLI 0.2.124 or later).
109
+
110
+ `k3s({ registry: { host: "registry.test" }, images: [...] })` exposes the
111
+ component's writable registry over verified HTTPS through the VM proxy.
112
+ Dockerfile builds in `images` targeting `registry.test/...` are published and
113
+ preloaded from the same cached build, preserving digests without an export/push
114
+ step. Other images keep direct preloading. Registry writes stay isolated across
115
+ test forks; client and containerd CA trust are configured automatically.
@@ -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 KubernetesImage, 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 K3sRegistryOptions, 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";
@@ -4,6 +4,14 @@ import { type KubernetesImage } from "../harness/component-images.js";
4
4
  export type { KubernetesImage } from "../harness/component-images.js";
5
5
  import type { Wrapped } from "../inspect.js";
6
6
  export type { KubernetesObject, KubernetesListObject, } from "@kubernetes/client-node";
7
+ /** HTTPS exposure and publication for the component's writable registry. */
8
+ export interface K3sRegistryOptions {
9
+ /** Exact DNS hostname exposed through Spectest's VM HTTPS proxy on port 443.
10
+ * Clients and Kubernetes's containerd trust the environment CA automatically.
11
+ * Builds in `images` whose reference starts with this host are also published
12
+ * here, using their original cached blobs and manifest digests. */
13
+ host: string;
14
+ }
7
15
  /** Kubernetes pods receive read-only environment CA mounts during admission.
8
16
  * Image bytes and digests stay unchanged; explicit pod trust settings win. */
9
17
  export interface K3sOptions {
@@ -11,6 +19,9 @@ export interface K3sOptions {
11
19
  * or Dockerfile builds with a full `reference` matching your manifests. Builds
12
20
  * support path/content, context, exclude, target and buildArgs, and run in
13
21
  * parallel with service builds through the standard BuildKit pipeline.
22
+ * With `registry: { host }`, builds targeting that host are also published
23
+ * to the writable registry before setup completes. Other images remain
24
+ * direct preloads; no external registry receives pushes.
14
25
  * Setup waits for every pull and fails if a requested image cannot be pulled.
15
26
  * Sticky-enabled projects retain image content and unpacked layers between
16
27
  * fresh builds automatically. Digest references can reuse the cache offline;
@@ -85,10 +96,15 @@ export interface K3sOptions {
85
96
  * into your platform, e.g. `env: { REGISTRY_URL: "cluster.internal:5000" }`.
86
97
  * Plain HTTP, so configure your push client for an insecure registry.
87
98
  *
99
+ * Set `{ host: "registry.test" }` for verified HTTPS on port 443 through
100
+ * the built-in VM proxy, including containerd trust and relative upload URLs.
101
+ * This does not require Traefik. Builds targeting that host in `images` are
102
+ * published automatically; tags and test uploads remain isolated per fork.
103
+ *
88
104
  * On by default. Set `false` for clusters that only ever run public
89
105
  * images — that skips the extra pod.
90
106
  */
91
- registry?: boolean;
107
+ registry?: boolean | K3sRegistryOptions;
92
108
  /**
93
109
  * Domains to route into this cluster's ingress via **wildcard DNS**. For
94
110
  * each `"example.com"`, spectest-resolver answers any `*.example.com`
@@ -336,6 +352,10 @@ export declare function k3s(opts?: K3sOptions): {
336
352
  reference: string;
337
353
  };
338
354
  imageBuilds: import("../index.js").ComponentImageBuild[];
355
+ tls: {
356
+ hostname: string;
357
+ port: number;
358
+ }[];
339
359
  command: string;
340
360
  privileged: true;
341
361
  tmpfs: string[];
@@ -348,10 +368,12 @@ export declare function k3s(opts?: K3sOptions): {
348
368
  } | {
349
369
  source: string;
350
370
  target: string;
351
- readOnly: true;
352
- })[] | {
371
+ readOnly: boolean;
372
+ } | {
353
373
  target: string;
354
- }[];
374
+ source?: undefined;
375
+ readOnly?: undefined;
376
+ })[];
355
377
  files: {
356
378
  path: string;
357
379
  content: string;
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { installPodTrust } from "./k3s-trust.js";
3
3
  import { waitForAll } from "../harness/concurrency.js";
4
+ import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
4
5
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
5
6
  import { AsyncLocalStorage } from "node:async_hooks";
6
7
  import { spawn as nodeSpawn } from "node:child_process";
@@ -792,7 +793,7 @@ const HUB_MIRROR = { registry: "docker.io", mirror: "https://mirror.gcr.io", ups
792
793
  * shares the node's netns, so this needs no in-container DNS and
793
794
  * can't be broken by a clobbered `hostnames`.
794
795
  */
795
- function buildRegistriesYaml(registryEnabled) {
796
+ function buildRegistriesYaml(registryEnabled, host) {
796
797
  const lines = [
797
798
  "mirrors:",
798
799
  ` "${HUB_MIRROR.registry}":`,
@@ -807,6 +808,11 @@ function buildRegistriesYaml(registryEnabled) {
807
808
  // makes that explicit and disables any TLS attempt against it.
808
809
  ` "127.0.0.1:${K3S_REGISTRY_PORT}":`, ` tls:`, ` insecure_skip_verify: true`);
809
810
  }
811
+ if (host) {
812
+ // This names the verified HTTPS endpoint itself; do not mirror it to HTTP.
813
+ // container-run mounts the environment CA before k3s/containerd starts.
814
+ lines.push(` "${host}":`, " tls:", ` ca_file: ${JSON.stringify(CA_PATH)}`);
815
+ }
810
816
  return lines.join("\n") + "\n";
811
817
  }
812
818
  /**
@@ -814,11 +820,13 @@ function buildRegistriesYaml(registryEnabled) {
814
820
  * binds the cluster container's `:5000` directly — the same trick
815
821
  * Traefik uses — so peer services reach it at `<cluster-key>.internal:5000`
816
822
  * (the cluster service's own alias) and the node's own containerd reaches
817
- * it at `127.0.0.1:5000`. Storage is an `emptyDir`, so pushed images live
818
- * in the cluster and are captured by snapshot / isolated per test fork
819
- * like all other in-VM state.
823
+ * it at `127.0.0.1:5000`. Storage is an `emptyDir` by default. Declared
824
+ * publications use a rootfs directory plus a read-only content mount; both
825
+ * variants keep writable registry state outside persistent image caches and
826
+ * capture it with the VM snapshot, isolated per test fork.
820
827
  */
821
- const REGISTRY_MANIFEST = `apiVersion: apps/v1
828
+ function registryManifest(seeded) {
829
+ return `apiVersion: apps/v1
822
830
  kind: Deployment
823
831
  metadata:
824
832
  name: spectest-registry
@@ -841,11 +849,13 @@ spec:
841
849
  - operator: Exists
842
850
  containers:
843
851
  - name: registry
844
- image: registry:2
852
+ image: ${seeded ? REGISTRY_IMAGE : "registry:2"}
845
853
  imagePullPolicy: IfNotPresent
846
854
  env:
847
855
  - name: REGISTRY_HTTP_ADDR
848
856
  value: ":${K3S_REGISTRY_PORT}"
857
+ - name: REGISTRY_HTTP_RELATIVEURLS
858
+ value: "true"
849
859
  - name: REGISTRY_STORAGE_DELETE_ENABLED
850
860
  value: "true"
851
861
  ports:
@@ -854,10 +864,20 @@ spec:
854
864
  volumeMounts:
855
865
  - name: data
856
866
  mountPath: /var/lib/registry
857
- volumes:
867
+ ${seeded ? ` - name: content
868
+ mountPath: ${REGISTRY_CONTENT}
869
+ readOnly: true
870
+ ` : ""} volumes:
858
871
  - name: data
859
- emptyDir: {}
872
+ ${seeded ? ` hostPath:
873
+ path: ${REGISTRY_DATA}
874
+ type: Directory
875
+ - name: content
876
+ hostPath:
877
+ path: ${REGISTRY_CONTENT}
878
+ type: Directory` : " emptyDir: {}"}
860
879
  `;
880
+ }
861
881
  // ────────────────────────────────────────────────────────────────────────
862
882
  // Apply — server-side, converging
863
883
  //
@@ -1310,7 +1330,8 @@ async function setupK3sCluster(name, helpers, opts) {
1310
1330
  (async () => {
1311
1331
  if (!opts.registry)
1312
1332
  return;
1313
- await helpers.apply(REGISTRY_MANIFEST);
1333
+ await opts.seed?.();
1334
+ await helpers.apply(registryManifest(!!opts.seed));
1314
1335
  await wait("spectest-registry");
1315
1336
  })(),
1316
1337
  ]);
@@ -1438,6 +1459,16 @@ export function k3s(opts = {}) {
1438
1459
  const extra = opts.extraArgs ?? [];
1439
1460
  assertUsableExtraArgs(extra);
1440
1461
  const registryEnabled = opts.registry !== false;
1462
+ let registryHost;
1463
+ if (opts.registry !== undefined && typeof opts.registry !== "boolean") {
1464
+ if (!opts.registry || typeof opts.registry.host !== "string" || opts.registry.host.startsWith("*.")) {
1465
+ throw new Error("k3s(): registry must be a boolean or { host: an exact DNS hostname }");
1466
+ }
1467
+ // Reuse ingress validation (no scheme, port, path, or reserved .internal).
1468
+ registryHost = dnsName(opts.registry.host, { ingress: true }).hostname;
1469
+ }
1470
+ const isPublished = (reference) => !!registryHost && reference.startsWith(`${registryHost}/`);
1471
+ const publishBuilds = images.builds.filter(image => isPublished(image.reference));
1441
1472
  const traefikEnabled = opts.traefik !== false;
1442
1473
  const loadBalancerEnabled = opts.loadBalancer !== false;
1443
1474
  // Wildcard ingress domains. Drives both the `provides(... dnsName)`
@@ -1447,12 +1478,13 @@ export function k3s(opts = {}) {
1447
1478
  // `/etc/rancher/k3s/registries.yaml` (host-cache mirrors + trust for
1448
1479
  // the in-cluster registry). Seeded via `files` because k3s reads it
1449
1480
  // only at startup, before any setup hook could run.
1450
- const registriesYaml = buildRegistriesYaml(registryEnabled);
1481
+ const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
1451
1482
  // The environment's image cache, when this VM carries one: the
1452
1483
  // cluster's store goes on its root disk and its layers come off the
1453
1484
  // shared layers disk (see the store section above).
1454
1485
  const sticky = stickyDiskPathSync();
1455
1486
  const cache = sticky ? null : imageCachePathsSync();
1487
+ const contentDirectory = `${sticky ? `${sticky}/containerd` : cache?.root ?? "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1456
1488
  const serverArgs = [
1457
1489
  "k3s",
1458
1490
  "server",
@@ -1551,6 +1583,7 @@ export function k3s(opts = {}) {
1551
1583
  const def = {
1552
1584
  image: { type: "registry", reference: `rancher/k3s:${version}` },
1553
1585
  imageBuilds: images.builds,
1586
+ tls: registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
1554
1587
  command: cmd,
1555
1588
  privileged: true,
1556
1589
  tmpfs: ["/run", "/var/run"],
@@ -1572,15 +1605,20 @@ export function k3s(opts = {}) {
1572
1605
  // in one environment get separate roots either way (the service
1573
1606
  // token names the directory), because two containerd daemons cannot
1574
1607
  // share a store: bolt takes an exclusive lock.
1575
- volumes: sticky
1576
- ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1577
- : cache ? [
1578
- { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
1579
- { source: cache.layers, target: cache.layers, readOnly: true },
1580
- { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
1581
- { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
1582
- ]
1583
- : [{ target: CLUSTER_STORE_TARGET }],
1608
+ volumes: [...(sticky
1609
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1610
+ : cache ? [
1611
+ { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
1612
+ { source: cache.layers, target: cache.layers, readOnly: true },
1613
+ { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
1614
+ { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
1615
+ ]
1616
+ : [{ target: CLUSTER_STORE_TARGET }]),
1617
+ ...(publishBuilds.length ? [
1618
+ { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1619
+ { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
1620
+ ] : []),
1621
+ ],
1584
1622
  files: [
1585
1623
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1586
1624
  ...(cache
@@ -1600,55 +1638,61 @@ export function k3s(opts = {}) {
1600
1638
  const runtime = componentRuntime(ctx);
1601
1639
  if (images.builds.length && !runtime)
1602
1640
  throw new Error(`k3s(${name}): prepared image inputs are missing`);
1603
- await waitForAll([
1604
- runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
1605
- prePullK3sImages(name, images.pulls, exec),
1606
- (async () => {
1607
- if (runtime?.caBundle && caPresent()) {
1608
- const { cert, key } = await issueIngressCert([runtime.gateway]);
1609
- await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1610
- ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1611
- }, {
1612
- apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1613
- ensureBundle: async (namespace, configMap, bundle) => {
1614
- try {
1615
- await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1616
- metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1617
- } });
1618
- }
1619
- catch (err) {
1620
- // A simultaneous Pod may have created the same namespace's bundle.
1621
- const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1622
- if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1623
- throw err;
1624
- }
1625
- },
1626
- });
1627
- // The API server observes webhook registration asynchronously. A dry-run
1628
- // Pod proves TLS reachability and admission before any project workload.
1629
- const deadline = Date.now() + 30_000;
1630
- for (;;) {
1641
+ const infrastructure = (async () => {
1642
+ if (runtime?.caBundle && caPresent()) {
1643
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
1644
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1645
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1646
+ }, {
1647
+ apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1648
+ ensureBundle: async (namespace, configMap, bundle) => {
1631
1649
  try {
1632
- const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1633
- metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1634
- } })).unwrap();
1635
- if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1636
- break;
1637
- if (Date.now() >= deadline)
1638
- throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1650
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1651
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1652
+ } });
1639
1653
  }
1640
1654
  catch (err) {
1641
- if (Date.now() >= deadline)
1655
+ // A simultaneous Pod may have created the same namespace's bundle.
1656
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1657
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1642
1658
  throw err;
1643
1659
  }
1644
- await new Promise(resolve => setTimeout(resolve, 100));
1660
+ },
1661
+ });
1662
+ // The API server observes webhook registration asynchronously. A dry-run
1663
+ // Pod proves TLS reachability and admission before any project workload.
1664
+ const deadline = Date.now() + 30_000;
1665
+ for (;;) {
1666
+ try {
1667
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1668
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1669
+ } })).unwrap();
1670
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1671
+ break;
1672
+ if (Date.now() >= deadline)
1673
+ throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1674
+ }
1675
+ catch (err) {
1676
+ if (Date.now() >= deadline)
1677
+ throw err;
1645
1678
  }
1679
+ await new Promise(resolve => setTimeout(resolve, 100));
1646
1680
  }
1647
- await setupK3sCluster(name, helpers, {
1648
- registry: registryEnabled,
1649
- ingressDomains,
1650
- traefik: traefikEnabled,
1651
- });
1681
+ }
1682
+ await setupK3sCluster(name, helpers, {
1683
+ registry: registryEnabled,
1684
+ seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory, runtime.images.filter(image => isPublished(image.reference))) : undefined,
1685
+ ingressDomains,
1686
+ traefik: traefikEnabled,
1687
+ });
1688
+ })();
1689
+ await waitForAll([
1690
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
1691
+ prePullK3sImages(name, images.pulls.filter(ref => !isPublished(ref)), exec),
1692
+ (async () => {
1693
+ // Registry-local references must not race registry startup/publication.
1694
+ await infrastructure;
1695
+ await prePullK3sImages(name, images.pulls.filter(isPublished), exec);
1652
1696
  })(),
1653
1697
  ]);
1654
1698
  if (sticky)
package/dist/daemon.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateHostCert as mintHostCert } from "./harness/certificate.js";
1
2
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
3
  import { waitForAll } from "./harness/concurrency.js";
3
4
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
@@ -39,7 +40,7 @@ import { isDesktopApp } from "./desktop.js";
39
40
  // exact-then-longest-suffix rule and the one-label certificate rule are
40
41
  // each easy to restate subtly differently.
41
42
  import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
42
- import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
43
+ import { startServices } from "./harness/service-graph.js";
43
44
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
44
45
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
45
46
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
@@ -1453,29 +1454,6 @@ async function withContainerPostMortem(name, err) {
1453
1454
  return err;
1454
1455
  }
1455
1456
  }
1456
- /** Validate the `dependsOn` graph and return the name→service map used to
1457
- * walk it. Rules live in `harness/service-graph.ts`. */
1458
- function validateServiceGraph(services) {
1459
- return validateGraph(services);
1460
- }
1461
- async function startServices(services, startOne) {
1462
- const byName = validateServiceGraph(services);
1463
- const started = new Map();
1464
- const start = (svc) => {
1465
- const existing = started.get(svc.name);
1466
- if (existing)
1467
- return existing;
1468
- const p = (async () => {
1469
- await Promise.all((svc.dependsOn ?? [])
1470
- .filter((d) => byName.has(d))
1471
- .map((d) => start(byName.get(d))));
1472
- await startOne(svc);
1473
- })();
1474
- started.set(svc.name, p);
1475
- return p;
1476
- };
1477
- await Promise.all(services.map(start));
1478
- }
1479
1457
  // ────────────────────────────────────────────────────────────────────────
1480
1458
  // Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
1481
1459
  //
@@ -1625,67 +1603,6 @@ function buildIngress(project) {
1625
1603
  });
1626
1604
  }
1627
1605
  }
1628
- /**
1629
- * Generate a leaf cert + key for one ingress route (fake or service
1630
- * proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
1631
- * hostname the route answers to, so a client connecting with TLS
1632
- * verifies cleanly regardless of which hostname it used. Shells out
1633
- * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
1634
- * bookworm ships 3.0.x).
1635
- *
1636
- * `label` is a short tag baked into the cert Subject CN and the temp
1637
- * file names — only used for diagnostics, not for TLS verification.
1638
- */
1639
- async function generateHostCert(label, hostnames) {
1640
- const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
1641
- const keyPath = path.join("/tmp", `${id}.key`);
1642
- const crtPath = path.join("/tmp", `${id}.crt`);
1643
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
1644
- const args = [
1645
- "req",
1646
- "-newkey",
1647
- "rsa:2048",
1648
- "-nodes",
1649
- "-keyout",
1650
- keyPath,
1651
- "-out",
1652
- crtPath,
1653
- "-x509",
1654
- "-CA",
1655
- CA_PATH,
1656
- "-CAkey",
1657
- CA_KEY_PATH,
1658
- "-days",
1659
- "3650",
1660
- "-subj",
1661
- `/CN=spectest-${label}`,
1662
- "-addext",
1663
- `subjectAltName=${sans}`,
1664
- "-addext",
1665
- "basicConstraints=CA:FALSE",
1666
- "-addext",
1667
- "extendedKeyUsage=serverAuth",
1668
- "-addext",
1669
- "keyUsage=digitalSignature,keyEncipherment",
1670
- ];
1671
- const r = await shx("openssl", args, 30_000);
1672
- if (r.code !== 0) {
1673
- throw new Error(`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`);
1674
- }
1675
- try {
1676
- const [cert, key] = await Promise.all([
1677
- fs.readFile(crtPath, "utf8"),
1678
- fs.readFile(keyPath, "utf8"),
1679
- ]);
1680
- return { cert, key };
1681
- }
1682
- finally {
1683
- await Promise.all([
1684
- fs.unlink(keyPath).catch(() => { }),
1685
- fs.unlink(crtPath).catch(() => { }),
1686
- ]);
1687
- }
1688
- }
1689
1606
  /** Resolve the spectest-net bridge gateway IP — the address containers
1690
1607
  * use to reach the VM host. Asks dockerd via the docker CLI; cached for
1691
1608
  * the daemon's life because the network is recreated only on reload. */
@@ -1719,7 +1636,10 @@ async function bridgeGatewayIp() {
1719
1636
  * that don't pass through as primitives.
1720
1637
  */
1721
1638
  const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
1722
- /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
1639
+ /** Mint ingress and container certificates with the environment CA. */
1640
+ function generateHostCert(label, hostnames) {
1641
+ return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
1642
+ }
1723
1643
  async function startIngress() {
1724
1644
  const hasIngress = FAKES.size > 0 ||
1725
1645
  LOWERED.proxies.length > 0 ||
@@ -2038,6 +1958,9 @@ serve = { proto: "http" }) {
2038
1958
  // {@link stopIngressServers} depends on: a half-alive listener shares
2039
1959
  // the port silently instead of colliding loudly.
2040
1960
  reusePort: true,
1961
+ // OCI layer uploads can exceed Bun's default 128 MiB request limit.
1962
+ // Large proxy bodies stream through; this limit does not allocate a buffer.
1963
+ maxRequestBodySize: 1024 ** 4,
2041
1964
  // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
2042
1965
  // request whose upstream takes >10s to produce bytes — under parallel
2043
1966
  // test load that surfaced as "fetch failed"/"other side closed" on
@@ -2942,10 +2865,14 @@ async function bootstrap(prepared) {
2942
2865
  // hook's — into the boot log, so GET /boot-log can explain a slow or
2943
2866
  // finished boot. Restored in the finally at the end.
2944
2867
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2868
+ // One dispatcher for the whole bootstrap: service hooks run concurrently.
2869
+ // Only each hook's async scope opts into wrapped fetch responses.
2870
+ const restoreFetch = installFetchWrapper();
2945
2871
  try {
2946
2872
  return await bootstrapInner(prepared);
2947
2873
  }
2948
2874
  finally {
2875
+ restoreFetch();
2949
2876
  restoreBootLog();
2950
2877
  }
2951
2878
  }
@@ -3111,22 +3038,28 @@ async function bootstrapInner(prepared, identity) {
3111
3038
  await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
3112
3039
  if (svc.setup) {
3113
3040
  progressService(svc.name, { status: "probing", detail: "running setup" });
3114
- const helpers = await ensureHelpers(svc.name, svc);
3041
+ const fetchScope = createInstrumentationScope(false);
3115
3042
  try {
3116
- await svc.setup({
3117
- name: svc.name,
3118
- helpers,
3119
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3120
- ...{ [COMPONENT_RUNTIME]: {
3121
- images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
3122
- gateway: await bridgeGatewayIp(),
3123
- caBundle: await ensureCaBundle(),
3124
- } },
3043
+ await runInstrumented(fetchScope, async () => {
3044
+ const helpers = await ensureHelpers(svc.name, svc);
3045
+ await svc.setup({
3046
+ name: svc.name,
3047
+ helpers,
3048
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3049
+ ...{ [COMPONENT_RUNTIME]: {
3050
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
3051
+ gateway: await bridgeGatewayIp(),
3052
+ caBundle: await ensureCaBundle(),
3053
+ } },
3054
+ });
3125
3055
  });
3126
3056
  }
3127
3057
  catch (err) {
3128
3058
  throw await withContainerPostMortem(svc.name, err);
3129
3059
  }
3060
+ finally {
3061
+ fetchScope.active = false;
3062
+ }
3130
3063
  }
3131
3064
  })()]);
3132
3065
  for (const task of builds)
@@ -4013,7 +3946,7 @@ async function spectestContext(scope = {}) {
4013
3946
  readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
4014
3947
  exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
4015
3948
  // Read `globalThis.fetch` at call time: the wrapper is installed for
4016
- // the duration of a test / eval / project setup, so a context built
3949
+ // the duration of bootstrap / a test / eval / project setup, so a context built
4017
3950
  // before it lands still gets the wrapped one.
4018
3951
  fetch: ((input, init) => globalThis.fetch(input, init)),
4019
3952
  svc: handles,
@@ -0,0 +1,16 @@
1
+ export interface Descriptor {
2
+ digest: string;
3
+ size: number;
4
+ mediaType: string;
5
+ platform?: {
6
+ os: string;
7
+ architecture: string;
8
+ };
9
+ }
10
+ /** Preserve the original index/manifest bytes and all application descriptors. */
11
+ export declare function hostImage(source: string): Promise<{
12
+ target: Descriptor;
13
+ metadata: Map<string, Buffer<ArrayBufferLike>>;
14
+ allowed: Map<string, Descriptor>;
15
+ configDigest: string;
16
+ }>;
@@ -0,0 +1,57 @@
1
+ /** Read build results without reserializing manifests or exporting layers. */
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { promisify } from "node:util";
5
+ import { canonicalImageReference } from "./component-images.js";
6
+ const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
7
+ const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
8
+ const run = promisify(execFile);
9
+ const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
10
+ async function hostContent(id) {
11
+ if (!/^sha256:[a-f0-9]{64}$/.test(id))
12
+ throw new Error(`invalid content digest ${id}`);
13
+ const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
14
+ if (digest(result.stdout) !== id)
15
+ throw new Error(`corrupt image metadata ${id}`);
16
+ return result.stdout;
17
+ }
18
+ /** Preserve the original index/manifest bytes and all application descriptors. */
19
+ export async function hostImage(source) {
20
+ const name = canonicalImageReference(source);
21
+ const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
22
+ const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
23
+ if (!found)
24
+ throw new Error(`built image ${name} is missing from the VM container store`);
25
+ const metadata = new Map();
26
+ const allowed = new Map();
27
+ let configDigest;
28
+ async function visit(id, selected, depth = 0) {
29
+ if (depth > 4)
30
+ throw new Error(`built image ${name} has a recursive manifest index`);
31
+ const bytes = await hostContent(id);
32
+ const obj = JSON.parse(bytes.toString());
33
+ const desc = descriptor(bytes, obj.mediaType);
34
+ metadata.set(id, bytes);
35
+ allowed.set(id, desc);
36
+ if (obj.config && Array.isArray(obj.layers)) {
37
+ const manifest = obj;
38
+ for (const blob of [manifest.config, ...manifest.layers])
39
+ allowed.set(blob.digest, blob);
40
+ if (selected)
41
+ configDigest = manifest.config.digest;
42
+ }
43
+ else if (Array.isArray(obj.manifests)) {
44
+ for (const entry of obj.manifests) {
45
+ const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
46
+ await visit(entry.digest, selected && matches, depth + 1);
47
+ }
48
+ }
49
+ else
50
+ throw new Error(`built image ${name} has invalid image metadata`);
51
+ return desc;
52
+ }
53
+ const target = await visit(found, true);
54
+ if (!configDigest)
55
+ throw new Error(`built image ${name} has no image for this VM's platform`);
56
+ return { target, metadata, allowed, configDigest };
57
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Generate a leaf cert + key for one ingress route (fake or service
3
+ * proxy), signed by the in-VM root CA. SANs cover every
4
+ * hostname the route answers to, so a client connecting with TLS
5
+ * verifies cleanly regardless of which hostname it used. Shells out
6
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
7
+ * bookworm ships 3.0.x).
8
+ *
9
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
10
+ * file names — only used for diagnostics, not for TLS verification.
11
+ */
12
+ export declare function generateHostCert(label: string, hostnames: string[], caPath: string, caKeyPath: string): Promise<{
13
+ cert: string;
14
+ key: string;
15
+ }>;