@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.
@@ -0,0 +1,67 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * Generate a leaf cert + key for one ingress route (fake or service
9
+ * proxy), signed by the in-VM root CA. SANs cover every
10
+ * hostname the route answers to, so a client connecting with TLS
11
+ * verifies cleanly regardless of which hostname it used. Shells out
12
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
13
+ * bookworm ships 3.0.x).
14
+ *
15
+ * `label` is a diagnostic tag baked into the cert Subject CN and the temp
16
+ * file names — only used for diagnostics, not for TLS verification.
17
+ */
18
+ export async function generateHostCert(label, hostnames, caPath, caKeyPath) {
19
+ // CN is diagnostic only; SANs retain the full hostnames. Bound the label
20
+ // for both OpenSSL's 64-byte CN limit and filesystem component limits.
21
+ const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
22
+ const id = `${commonName}-${randomUUID()}`;
23
+ const keyPath = path.join("/tmp", `${id}.key`);
24
+ const crtPath = path.join("/tmp", `${id}.crt`);
25
+ const sans = hostnames.map((h) => `DNS:${h}`).join(",");
26
+ const args = [
27
+ "req",
28
+ "-newkey",
29
+ "rsa:2048",
30
+ "-nodes",
31
+ "-keyout",
32
+ keyPath,
33
+ "-out",
34
+ crtPath,
35
+ "-x509",
36
+ "-CA",
37
+ caPath,
38
+ "-CAkey",
39
+ caKeyPath,
40
+ "-days",
41
+ "3650",
42
+ "-subj",
43
+ `/CN=${commonName}`,
44
+ "-addext",
45
+ `subjectAltName=${sans}`,
46
+ "-addext",
47
+ "basicConstraints=CA:FALSE",
48
+ "-addext",
49
+ "extendedKeyUsage=serverAuth",
50
+ "-addext",
51
+ "keyUsage=digitalSignature,keyEncipherment",
52
+ ];
53
+ try {
54
+ await execFileAsync("openssl", args, { timeout: 30_000 });
55
+ const [cert, key] = await Promise.all([
56
+ fs.readFile(crtPath, "utf8"),
57
+ fs.readFile(keyPath, "utf8"),
58
+ ]);
59
+ return { cert, key };
60
+ }
61
+ finally {
62
+ await Promise.all([
63
+ fs.unlink(keyPath).catch(() => { }),
64
+ fs.unlink(crtPath).catch(() => { }),
65
+ ]);
66
+ }
67
+ }
@@ -1,65 +1,14 @@
1
1
  /** A short-lived, read-only OCI endpoint over the host containerd content store.
2
2
  * k3s fetches only missing blobs and unpacks only missing snapshots. No Docker
3
3
  * archive, staging registry, or second persistent copy of application blobs. */
4
- import { execFile, spawn } from "node:child_process";
4
+ import { spawn } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
- import { promisify } from "node:util";
8
7
  import { canonicalImageReference } from "./component-images.js";
9
8
  import { waitForK3sRuntime } from "./k3s-prepull.js";
9
+ import { hostImage } from "./built-image.js";
10
10
  import { waitForAll } from "./concurrency.js";
11
- const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
12
- const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
13
- const run = promisify(execFile);
14
11
  const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
15
- async function hostContent(id) {
16
- if (!/^sha256:[a-f0-9]{64}$/.test(id))
17
- throw new Error(`invalid content digest ${id}`);
18
- const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
19
- if (digest(result.stdout) !== id)
20
- throw new Error(`corrupt image metadata ${id}`);
21
- return result.stdout;
22
- }
23
- /** Preserve the original index/manifest bytes and all application descriptors. */
24
- async function hostImage(source) {
25
- const name = canonicalImageReference(source);
26
- const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
27
- const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
28
- if (!found)
29
- throw new Error(`built image ${name} is missing from the VM container store`);
30
- const metadata = new Map();
31
- const allowed = new Map();
32
- let configDigest;
33
- async function visit(id, selected, depth = 0) {
34
- if (depth > 4)
35
- throw new Error(`built image ${name} has a recursive manifest index`);
36
- const bytes = await hostContent(id);
37
- const obj = JSON.parse(bytes.toString());
38
- const desc = descriptor(bytes, obj.mediaType);
39
- metadata.set(id, bytes);
40
- allowed.set(id, desc);
41
- if (obj.config && Array.isArray(obj.layers)) {
42
- const manifest = obj;
43
- for (const blob of [manifest.config, ...manifest.layers])
44
- allowed.set(blob.digest, blob);
45
- if (selected)
46
- configDigest = manifest.config.digest;
47
- }
48
- else if (Array.isArray(obj.manifests)) {
49
- for (const entry of obj.manifests) {
50
- const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
51
- await visit(entry.digest, selected && matches, depth + 1);
52
- }
53
- }
54
- else
55
- throw new Error(`built image ${name} has invalid image metadata`);
56
- return desc;
57
- }
58
- const target = await visit(found, true);
59
- if (!configDigest)
60
- throw new Error(`built image ${name} has no image for this VM's platform`);
61
- return { target, metadata, allowed, configDigest };
62
- }
63
12
  export async function loadK3sBuildImages(service, images, gateway, exec) {
64
13
  if (!images.length)
65
14
  return;
@@ -0,0 +1,12 @@
1
+ import { hostImage } from "./built-image.js";
2
+ import { type PreparedComponentImage } from "./component-images.js";
3
+ export declare const REGISTRY_DATA = "/var/lib/spectest-registry";
4
+ export declare const REGISTRY_CONTENT = "/spectest-registry-content";
5
+ export declare const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
6
+ export declare const REGISTRY_IMAGE = "registry:2.8.3";
7
+ export declare const registryDirectory: (service: string) => string;
8
+ /** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
9
+ * before starting the registry so it cannot cache partial publication.
10
+ * The prepared build's containerd image record retains the referenced blobs. */
11
+ export declare function seedRegistryImage(directory: string, contentDirectory: string, reference: string, image: Awaited<ReturnType<typeof hostImage>>, mountedContent?: string): Promise<void>;
12
+ export declare function seedRegistry(service: string, contentDirectory: string, images: readonly PreparedComponentImage[]): Promise<void>;
@@ -0,0 +1,61 @@
1
+ /** Distribution v2 publication. Blob data stays in containerd, mounted
2
+ * read-only in the registry. Repository metadata and uploads MUST live on
3
+ * the environment rootfs, outside persistent image/build caches. */
4
+ import { mkdir, stat, symlink, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import { hostImage } from "./built-image.js";
7
+ import { canonicalImageReference } from "./component-images.js";
8
+ export const REGISTRY_DATA = "/var/lib/spectest-registry";
9
+ export const REGISTRY_CONTENT = "/spectest-registry-content";
10
+ export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
11
+ export const REGISTRY_IMAGE = "registry:2.8.3";
12
+ export const registryDirectory = (service) => `/var/lib/spectest-registries/${service}`;
13
+ /** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
14
+ * before starting the registry so it cannot cache partial publication.
15
+ * The prepared build's containerd image record retains the referenced blobs. */
16
+ export async function seedRegistryImage(directory, contentDirectory, reference, image, mountedContent = REGISTRY_CONTENT) {
17
+ const canonical = canonicalImageReference(reference);
18
+ if (canonical.includes("@"))
19
+ throw new Error("registry publication must name a tag");
20
+ const repository = canonical.slice(canonical.indexOf("/") + 1, canonical.lastIndexOf(":"));
21
+ const tag = canonical.slice(canonical.lastIndexOf(":") + 1);
22
+ const root = `${directory}/docker/registry/v2`;
23
+ const repo = `${root}/repositories/${repository}`;
24
+ const link = async (path, value) => {
25
+ await mkdir(dirname(path), { recursive: true });
26
+ await writeFile(path, value);
27
+ };
28
+ for (const [id, descriptor] of image.allowed) {
29
+ if (!/^sha256:[a-f0-9]{64}$/.test(id))
30
+ throw new Error(`invalid registry seed digest ${id}`);
31
+ const hex = id.slice(7);
32
+ const info = await stat(`${contentDirectory}/${hex}`);
33
+ if (!info.isFile() || info.size !== descriptor.size)
34
+ throw new Error(`missing or incomplete registry seed blob ${id}`);
35
+ const destination = `${root}/blobs/sha256/${hex.slice(0, 2)}/${hex}/data`;
36
+ await mkdir(dirname(destination), { recursive: true });
37
+ try {
38
+ await symlink(`${mountedContent}/${hex}`, destination);
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "EEXIST")
42
+ throw error;
43
+ }
44
+ await link(`${repo}/_layers/sha256/${hex}/link`, id);
45
+ if (image.metadata.has(id))
46
+ await link(`${repo}/_manifests/revisions/sha256/${hex}/link`, id);
47
+ }
48
+ const id = image.target.digest;
49
+ await link(`${repo}/_manifests/tags/${tag}/index/sha256/${id.slice(7)}/link`, id);
50
+ await link(`${repo}/_manifests/tags/${tag}/current/link`, id);
51
+ }
52
+ export async function seedRegistry(service, contentDirectory, images) {
53
+ // Sequential metadata updates avoid races when several tags share blobs.
54
+ // Builds themselves still run concurrently with all environment preparation.
55
+ for (const image of images) {
56
+ await image.ready;
57
+ const built = await hostImage(image.source);
58
+ await seedRegistryImage(registryDirectory(service), contentDirectory, image.reference, built);
59
+ console.log(`[k3s-registry] ${service}: ${image.reference} @ ${built.target.digest} (cached blobs, no export)`);
60
+ }
61
+ }
@@ -27,3 +27,6 @@ export declare function validateServiceGraph<T extends GraphNode>(services: read
27
27
  * graph is walkable at all.
28
28
  */
29
29
  export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
30
+ /** Start each service after its dependencies finish setup. Drain all branches
31
+ * before reporting failure so teardown cannot race containers or hooks. */
32
+ export declare function startServices<T extends GraphNode>(services: readonly T[], startOne: (svc: T) => Promise<void>): Promise<void>;
@@ -1,12 +1,4 @@
1
- /**
2
- * The `dependsOn` graph: validation, and the traversal order bring-up
3
- * uses.
4
- *
5
- * Ported out of `daemon.ts`. Validation runs before anything starts, so
6
- * the DAG runner can assume a clean graph — an unknown dependency or a
7
- * cycle must be a clear error at load, not a container that never becomes
8
- * ready and eventually times out with no explanation.
9
- */
1
+ import { waitForAll } from "./concurrency.js";
10
2
  /**
11
3
  * Check the graph and return the name→node map used to walk it.
12
4
  *
@@ -90,3 +82,21 @@ export function topologicalOrder(services) {
90
82
  visit(s.name);
91
83
  return out;
92
84
  }
85
+ /** Start each service after its dependencies finish setup. Drain all branches
86
+ * before reporting failure so teardown cannot race containers or hooks. */
87
+ export async function startServices(services, startOne) {
88
+ const byName = validateServiceGraph(services);
89
+ const started = new Map();
90
+ const start = (svc) => {
91
+ const existing = started.get(svc.name);
92
+ if (existing)
93
+ return existing;
94
+ const pending = (async () => {
95
+ await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name))));
96
+ await startOne(svc);
97
+ })();
98
+ started.set(svc.name, pending);
99
+ return pending;
100
+ };
101
+ await waitForAll(services.map(start));
102
+ }
package/dist/index.d.ts CHANGED
@@ -590,11 +590,10 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
590
590
  * the typecheck on any service declaring a hook, while the same literal
591
591
  * written inline passed. */
592
592
  export type ServicesMap = Record<string, ServiceDefinition<any>>;
593
- /** Awaited return type of a service's `helpers` factory, or `never` if
594
- * the service doesn't ship one. */
595
- type HelpersOf<D> = D extends {
596
- helpers: (...args: any) => infer R;
597
- } ? Awaited<R> : never;
593
+ /** Awaited return type of a service's helpers factory. Check key presence
594
+ * before removing optionality: annotations keep `helpers?`, while plain
595
+ * service literals with no factory must stay out of ctx.svc. */
596
+ type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] extends [never] ? never : NonNullable<D["helpers"]> extends (...args: any[]) => infer R ? Awaited<R> : never : never;
598
597
  /**
599
598
  * Per-service handles derived from a concrete services map. Only
600
599
  * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
@@ -832,7 +831,8 @@ export type BuildImage = (Omit<Extract<NormalizedServiceImage, {
832
831
  }>, "type">) & {
833
832
  /** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
834
833
  * Optional for a service; required for a Kubernetes build. This names a
835
- * local image and does not publish it to a registry. */
834
+ * local image. In k3s, builds targeting `registry.host` are also published
835
+ * to that component's registry; other references are only preloaded. */
836
836
  reference?: string;
837
837
  };
838
838
  /** A registry reference or Dockerfile build. Tagged objects from older SDKs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,7 @@ export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
26
26
  export {
27
27
  k3s,
28
28
  type K3sOptions,
29
+ type K3sRegistryOptions,
29
30
  type KubernetesImage,
30
31
  type K3sHelpers,
31
32
  type K3sClient,
@@ -0,0 +1,50 @@
1
+ import { expect, test } from "bun:test";
2
+ import { k3s } from "./k3s";
3
+ import { REGISTRY_CONTENT, REGISTRY_DATA } from "../harness/registry-seed";
4
+
5
+ test("HTTPS registry is component-owned, with explicit containerd trust and no Traefik dependency", () => {
6
+ const cluster = k3s({ registry: { host: "Registry.Test" }, traefik: false });
7
+ expect(cluster.tls).toEqual([{ hostname: "registry.test", port: 5000 }]);
8
+ const registries = cluster.files.find(file => file.path.endsWith("registries.yaml"))!.content;
9
+ expect(registries).toContain('"registry.test":\n tls:\n ca_file:');
10
+ expect(registries).not.toContain('"https://127.0.0.1');
11
+ expect(registries).toContain('"http://127.0.0.1:5000"');
12
+ });
13
+
14
+ test("only builds targeting the configured registry require publication storage", () => {
15
+ const image = { content: "FROM scratch", reference: "registry.test/codenode:latest" };
16
+ const cluster = k3s({ registry: { host: "registry.test" }, images: [image] });
17
+ expect(cluster.imageBuilds).toHaveLength(1);
18
+ expect(cluster.volumes.find(v => v.target === REGISTRY_DATA)?.source).toBe(
19
+ "/var/lib/spectest-registries/{{SPECTEST_SERVICE}}",
20
+ );
21
+ expect(cluster.volumes.find(v => v.target === REGISTRY_CONTENT)?.readOnly).toBe(true);
22
+ for (const registry of [undefined, false, true, { host: "other.test" }]) {
23
+ expect(k3s({ registry, images: [image] }).volumes.some(v => v.target === REGISTRY_DATA)).toBe(false);
24
+ }
25
+ expect(k3s({ registry: { host: "registry.test" }, images: [image.reference] }).volumes.some(v => v.target === REGISTRY_DATA)).toBe(false);
26
+ });
27
+
28
+ test("registry host rejects ports, URLs, wildcard and reserved hostnames", () => {
29
+ for (const host of ["", "https://registry.test", "registry.test:443", "*.registry.test", "k8s.internal", "registry.test/path"]) {
30
+ expect(() => k3s({ registry: { host } })).toThrow();
31
+ }
32
+ for (const registry of [null, "registry.test", {}, { host: 123 }]) {
33
+ expect(() => k3s({ registry } as never)).toThrow();
34
+ }
35
+ });
36
+
37
+ test("registry upload URLs are relative and local pre-pulls wait for the registry", async () => {
38
+ const cluster = k3s({ registry: { host: "registry.test" }, traefik: false, images: ["registry.test/app:latest"] });
39
+ let ready = false;
40
+ await cluster.setup({ name: "cluster", helpers: {
41
+ apply: async (manifest: string) => {
42
+ expect(manifest).toContain('REGISTRY_HTTP_RELATIVEURLS\n value: "true"');
43
+ expect(manifest).toContain("emptyDir: {}");
44
+ },
45
+ waitForRollout: async () => { ready = true; },
46
+ }, exec: async (_name: string, args: string[]) => {
47
+ if (args[3] === "pull") expect(ready).toBe(true);
48
+ return { exitCode: 0, stdout: "", stderr: "" };
49
+ } } as never);
50
+ });
@@ -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";
@@ -60,6 +61,15 @@ export type {
60
61
  KubernetesListObject,
61
62
  } from "@kubernetes/client-node";
62
63
 
64
+ /** HTTPS exposure and publication for the component's writable registry. */
65
+ export interface K3sRegistryOptions {
66
+ /** Exact DNS hostname exposed through Spectest's VM HTTPS proxy on port 443.
67
+ * Clients and Kubernetes's containerd trust the environment CA automatically.
68
+ * Builds in `images` whose reference starts with this host are also published
69
+ * here, using their original cached blobs and manifest digests. */
70
+ host: string;
71
+ }
72
+
63
73
  /** Kubernetes pods receive read-only environment CA mounts during admission.
64
74
  * Image bytes and digests stay unchanged; explicit pod trust settings win. */
65
75
  export interface K3sOptions {
@@ -67,6 +77,9 @@ export interface K3sOptions {
67
77
  * or Dockerfile builds with a full `reference` matching your manifests. Builds
68
78
  * support path/content, context, exclude, target and buildArgs, and run in
69
79
  * parallel with service builds through the standard BuildKit pipeline.
80
+ * With `registry: { host }`, builds targeting that host are also published
81
+ * to the writable registry before setup completes. Other images remain
82
+ * direct preloads; no external registry receives pushes.
70
83
  * Setup waits for every pull and fails if a requested image cannot be pulled.
71
84
  * Sticky-enabled projects retain image content and unpacked layers between
72
85
  * fresh builds automatically. Digest references can reuse the cache offline;
@@ -141,10 +154,15 @@ export interface K3sOptions {
141
154
  * into your platform, e.g. `env: { REGISTRY_URL: "cluster.internal:5000" }`.
142
155
  * Plain HTTP, so configure your push client for an insecure registry.
143
156
  *
157
+ * Set `{ host: "registry.test" }` for verified HTTPS on port 443 through
158
+ * the built-in VM proxy, including containerd trust and relative upload URLs.
159
+ * This does not require Traefik. Builds targeting that host in `images` are
160
+ * published automatically; tags and test uploads remain isolated per fork.
161
+ *
144
162
  * On by default. Set `false` for clusters that only ever run public
145
163
  * images — that skips the extra pod.
146
164
  */
147
- registry?: boolean;
165
+ registry?: boolean | K3sRegistryOptions;
148
166
  /**
149
167
  * Domains to route into this cluster's ingress via **wildcard DNS**. For
150
168
  * each `"example.com"`, spectest-resolver answers any `*.example.com`
@@ -1290,7 +1308,7 @@ const HUB_MIRROR = { registry: "docker.io", mirror: "https://mirror.gcr.io", ups
1290
1308
  * shares the node's netns, so this needs no in-container DNS and
1291
1309
  * can't be broken by a clobbered `hostnames`.
1292
1310
  */
1293
- function buildRegistriesYaml(registryEnabled: boolean): string | null {
1311
+ function buildRegistriesYaml(registryEnabled: boolean, host?: string): string | null {
1294
1312
  const lines: string[] = [
1295
1313
  "mirrors:",
1296
1314
  ` "${HUB_MIRROR.registry}":`,
@@ -1312,6 +1330,11 @@ function buildRegistriesYaml(registryEnabled: boolean): string | null {
1312
1330
  ` insecure_skip_verify: true`,
1313
1331
  );
1314
1332
  }
1333
+ if (host) {
1334
+ // This names the verified HTTPS endpoint itself; do not mirror it to HTTP.
1335
+ // container-run mounts the environment CA before k3s/containerd starts.
1336
+ lines.push(` "${host}":`, " tls:", ` ca_file: ${JSON.stringify(CA_PATH)}`);
1337
+ }
1315
1338
  return lines.join("\n") + "\n";
1316
1339
  }
1317
1340
 
@@ -1320,11 +1343,13 @@ function buildRegistriesYaml(registryEnabled: boolean): string | null {
1320
1343
  * binds the cluster container's `:5000` directly — the same trick
1321
1344
  * Traefik uses — so peer services reach it at `<cluster-key>.internal:5000`
1322
1345
  * (the cluster service's own alias) and the node's own containerd reaches
1323
- * it at `127.0.0.1:5000`. Storage is an `emptyDir`, so pushed images live
1324
- * in the cluster and are captured by snapshot / isolated per test fork
1325
- * like all other in-VM state.
1346
+ * it at `127.0.0.1:5000`. Storage is an `emptyDir` by default. Declared
1347
+ * publications use a rootfs directory plus a read-only content mount; both
1348
+ * variants keep writable registry state outside persistent image caches and
1349
+ * capture it with the VM snapshot, isolated per test fork.
1326
1350
  */
1327
- const REGISTRY_MANIFEST = `apiVersion: apps/v1
1351
+ function registryManifest(seeded: boolean): string {
1352
+ return `apiVersion: apps/v1
1328
1353
  kind: Deployment
1329
1354
  metadata:
1330
1355
  name: spectest-registry
@@ -1347,11 +1372,13 @@ spec:
1347
1372
  - operator: Exists
1348
1373
  containers:
1349
1374
  - name: registry
1350
- image: registry:2
1375
+ image: ${seeded ? REGISTRY_IMAGE : "registry:2"}
1351
1376
  imagePullPolicy: IfNotPresent
1352
1377
  env:
1353
1378
  - name: REGISTRY_HTTP_ADDR
1354
1379
  value: ":${K3S_REGISTRY_PORT}"
1380
+ - name: REGISTRY_HTTP_RELATIVEURLS
1381
+ value: "true"
1355
1382
  - name: REGISTRY_STORAGE_DELETE_ENABLED
1356
1383
  value: "true"
1357
1384
  ports:
@@ -1360,10 +1387,20 @@ spec:
1360
1387
  volumeMounts:
1361
1388
  - name: data
1362
1389
  mountPath: /var/lib/registry
1363
- volumes:
1390
+ ${seeded ? ` - name: content
1391
+ mountPath: ${REGISTRY_CONTENT}
1392
+ readOnly: true
1393
+ ` : ""} volumes:
1364
1394
  - name: data
1365
- emptyDir: {}
1395
+ ${seeded ? ` hostPath:
1396
+ path: ${REGISTRY_DATA}
1397
+ type: Directory
1398
+ - name: content
1399
+ hostPath:
1400
+ path: ${REGISTRY_CONTENT}
1401
+ type: Directory` : " emptyDir: {}"}
1366
1402
  `;
1403
+ }
1367
1404
 
1368
1405
  // ────────────────────────────────────────────────────────────────────────
1369
1406
  // Apply — server-side, converging
@@ -1883,7 +1920,7 @@ async function waitForJob(
1883
1920
  async function setupK3sCluster(
1884
1921
  name: string,
1885
1922
  helpers: K3sHelpers,
1886
- opts: { registry: boolean; ingressDomains: string[]; traefik: boolean },
1923
+ opts: { registry: boolean; ingressDomains: string[]; traefik: boolean; seed?: () => Promise<void> },
1887
1924
  ): Promise<void> {
1888
1925
  // The registry can start while Traefik's certificates are being prepared.
1889
1926
  const wait = (deployment: string): Promise<void> =>
@@ -1927,7 +1964,8 @@ async function setupK3sCluster(
1927
1964
  })(),
1928
1965
  (async () => {
1929
1966
  if (!opts.registry) return;
1930
- await helpers.apply(REGISTRY_MANIFEST);
1967
+ await opts.seed?.();
1968
+ await helpers.apply(registryManifest(!!opts.seed));
1931
1969
  await wait("spectest-registry");
1932
1970
  })(),
1933
1971
  ]);
@@ -2061,6 +2099,16 @@ export function k3s(opts: K3sOptions = {}) {
2061
2099
  const extra = opts.extraArgs ?? [];
2062
2100
  assertUsableExtraArgs(extra);
2063
2101
  const registryEnabled = opts.registry !== false;
2102
+ let registryHost: string | undefined;
2103
+ if (opts.registry !== undefined && typeof opts.registry !== "boolean") {
2104
+ if (!opts.registry || typeof opts.registry.host !== "string" || opts.registry.host.startsWith("*.")) {
2105
+ throw new Error("k3s(): registry must be a boolean or { host: an exact DNS hostname }");
2106
+ }
2107
+ // Reuse ingress validation (no scheme, port, path, or reserved .internal).
2108
+ registryHost = dnsName(opts.registry.host, { ingress: true }).hostname;
2109
+ }
2110
+ const isPublished = (reference: string) => !!registryHost && reference.startsWith(`${registryHost}/`);
2111
+ const publishBuilds = images.builds.filter(image => isPublished(image.reference));
2064
2112
  const traefikEnabled = opts.traefik !== false;
2065
2113
  const loadBalancerEnabled = opts.loadBalancer !== false;
2066
2114
  // Wildcard ingress domains. Drives both the `provides(... dnsName)`
@@ -2070,12 +2118,13 @@ export function k3s(opts: K3sOptions = {}) {
2070
2118
  // `/etc/rancher/k3s/registries.yaml` (host-cache mirrors + trust for
2071
2119
  // the in-cluster registry). Seeded via `files` because k3s reads it
2072
2120
  // only at startup, before any setup hook could run.
2073
- const registriesYaml = buildRegistriesYaml(registryEnabled);
2121
+ const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
2074
2122
  // The environment's image cache, when this VM carries one: the
2075
2123
  // cluster's store goes on its root disk and its layers come off the
2076
2124
  // shared layers disk (see the store section above).
2077
2125
  const sticky = stickyDiskPathSync();
2078
2126
  const cache = sticky ? null : imageCachePathsSync();
2127
+ const contentDirectory = `${sticky ? `${sticky}/containerd` : cache?.root ?? "/var/lib/containerd"}/${CONTENT_BLOBS}`;
2079
2128
  const serverArgs = [
2080
2129
  "k3s",
2081
2130
  "server",
@@ -2175,6 +2224,7 @@ export function k3s(opts: K3sOptions = {}) {
2175
2224
  const def = {
2176
2225
  image: { type: "registry", reference: `rancher/k3s:${version}` },
2177
2226
  imageBuilds: images.builds,
2227
+ tls: registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
2178
2228
  command: cmd,
2179
2229
  privileged: true,
2180
2230
  tmpfs: ["/run", "/var/run"],
@@ -2196,7 +2246,7 @@ export function k3s(opts: K3sOptions = {}) {
2196
2246
  // in one environment get separate roots either way (the service
2197
2247
  // token names the directory), because two containerd daemons cannot
2198
2248
  // share a store: bolt takes an exclusive lock.
2199
- volumes: sticky
2249
+ volumes: [...(sticky
2200
2250
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2201
2251
  : cache ? [
2202
2252
  { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
@@ -2204,7 +2254,12 @@ export function k3s(opts: K3sOptions = {}) {
2204
2254
  { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
2205
2255
  { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
2206
2256
  ]
2207
- : [{ target: CLUSTER_STORE_TARGET }],
2257
+ : [{ target: CLUSTER_STORE_TARGET }]),
2258
+ ...(publishBuilds.length ? [
2259
+ { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2260
+ { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
2261
+ ] : []),
2262
+ ],
2208
2263
  files: [
2209
2264
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2210
2265
  ...(cache
@@ -2223,49 +2278,56 @@ export function k3s(opts: K3sOptions = {}) {
2223
2278
  const { name, helpers, exec } = ctx;
2224
2279
  const runtime = componentRuntime(ctx);
2225
2280
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2226
- await waitForAll([
2227
- runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
2228
- prePullK3sImages(name, images.pulls, exec),
2229
- (async () => {
2230
- if (runtime?.caBundle && caPresent()) {
2231
- const { cert, key } = await issueIngressCert([runtime.gateway]);
2232
- await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2233
- ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2234
- }, {
2235
- apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2236
- ensureBundle: async (namespace, configMap, bundle) => {
2237
- try {
2238
- await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2239
- metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2240
- } });
2241
- } catch (err) {
2242
- // A simultaneous Pod may have created the same namespace's bundle.
2243
- const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2244
- if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2245
- }
2246
- },
2247
- });
2248
- // The API server observes webhook registration asynchronously. A dry-run
2249
- // Pod proves TLS reachability and admission before any project workload.
2250
- const deadline = Date.now() + 30_000;
2251
- for (;;) {
2281
+ const infrastructure = (async () => {
2282
+ if (runtime?.caBundle && caPresent()) {
2283
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
2284
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2285
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2286
+ }, {
2287
+ apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2288
+ ensureBundle: async (namespace, configMap, bundle) => {
2252
2289
  try {
2253
- const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2254
- metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2255
- } })).unwrap();
2256
- if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2257
- if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2290
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2291
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2292
+ } });
2258
2293
  } catch (err) {
2259
- if (Date.now() >= deadline) throw err;
2294
+ // A simultaneous Pod may have created the same namespace's bundle.
2295
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2296
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2260
2297
  }
2261
- await new Promise(resolve => setTimeout(resolve, 100));
2298
+ },
2299
+ });
2300
+ // The API server observes webhook registration asynchronously. A dry-run
2301
+ // Pod proves TLS reachability and admission before any project workload.
2302
+ const deadline = Date.now() + 30_000;
2303
+ for (;;) {
2304
+ try {
2305
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2306
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2307
+ } })).unwrap();
2308
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2309
+ if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2310
+ } catch (err) {
2311
+ if (Date.now() >= deadline) throw err;
2262
2312
  }
2313
+ await new Promise(resolve => setTimeout(resolve, 100));
2263
2314
  }
2264
- await setupK3sCluster(name, helpers, {
2265
- registry: registryEnabled,
2266
- ingressDomains,
2267
- traefik: traefikEnabled,
2268
- });
2315
+ }
2316
+ await setupK3sCluster(name, helpers, {
2317
+ registry: registryEnabled,
2318
+ seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory,
2319
+ runtime!.images.filter(image => isPublished(image.reference))) : undefined,
2320
+ ingressDomains,
2321
+ traefik: traefikEnabled,
2322
+ });
2323
+ })();
2324
+ await waitForAll([
2325
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
2326
+ prePullK3sImages(name, images.pulls.filter(ref => !isPublished(ref)), exec),
2327
+ (async () => {
2328
+ // Registry-local references must not race registry startup/publication.
2329
+ await infrastructure;
2330
+ await prePullK3sImages(name, images.pulls.filter(isPublished), exec);
2269
2331
  })(),
2270
2332
  ]);
2271
2333
  if (sticky) await registerStickyKubernetesStore(name, version);