@specific.dev/spectest 0.120.1 → 0.122.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.
@@ -1,5 +1,7 @@
1
- import { expect, test } from "bun:test";
1
+ import { expect, spyOn, test } from "bun:test";
2
2
  import { k3s } from "./k3s";
3
+ import * as imageLoader from "../harness/k3s-image-loader";
4
+ import { COMPONENT_RUNTIME } from "../harness/component-images";
3
5
 
4
6
  function deferred() {
5
7
  let resolve!: () => void;
@@ -22,7 +24,7 @@ test("registry, Traefik, and every declared pull start without waiting for one a
22
24
  waitForRollout: async () => {},
23
25
  }, exec: async (_name: string, args: string[]) => {
24
26
  if (args[3] === "pull") await enter(args.at(-1)!);
25
- return { exitCode: 0, stdout: "", stderr: "" };
27
+ return { exitCode: 0, stdout: args[0] === "kubectl" ? JSON.stringify({ items: [{ metadata: { name: "cluster" }, status: { conditions: [{ type: "Ready", status: "True" }] } }] }) : "", stderr: "" };
26
28
  } } as never).then(() => { finished = true; });
27
29
  try {
28
30
  await Promise.race([allStarted.promise, Bun.sleep(1000).then(() => { throw new Error(`serialized setup: ${[...started]}`); })]);
@@ -49,7 +51,7 @@ test("a failed infrastructure rollout waits for other rollouts and image pulls t
49
51
  },
50
52
  }, exec: async (_name: string, args: string[]) => {
51
53
  if (args[3] === "pull") await hold();
52
- return { exitCode: 0, stdout: "", stderr: "" };
54
+ return { exitCode: 0, stdout: args[0] === "kubectl" ? JSON.stringify({ items: [{ metadata: { name: "cluster" }, status: { conditions: [{ type: "Ready", status: "True" }] } }] }) : "", stderr: "" };
53
55
  } } as never).then(() => { finished = true; }, error => { finished = true; return error; });
54
56
  try {
55
57
  await started.promise;
@@ -58,3 +60,74 @@ test("a failed infrastructure rollout waits for other rollouts and image pulls t
58
60
  expect(await work).toBe(failure);
59
61
  expect(active).toBe(0);
60
62
  });
63
+
64
+ test("all nodes pull concurrently while readiness and Traefik are pending; local pulls only wait for registry", async () => {
65
+ const nodes = ["cluster", "cluster-node-2", "cluster-node-3"];
66
+ const pulls = new Set<string>();
67
+ const allPulling = deferred(), registryStarted = deferred(), registryReady = deferred();
68
+ const nodesReady = deferred(), traefikReady = deferred(), pullsDone = deferred();
69
+ let finished = false;
70
+ const work = k3s({ nodes: 3, registry: { host: "registry.test" }, images: ["alpine:3.20", "registry.test/app:1"] }).setup!({
71
+ name: "cluster", helpers: {
72
+ apply: async () => {},
73
+ waitForRollout: async (name: string) => {
74
+ if (name === "traefik") await traefikReady.promise;
75
+ else { registryStarted.resolve(); await registryReady.promise; }
76
+ },
77
+ }, exec: async (name: string, args: string[]) => {
78
+ if (args[0] === "kubectl") {
79
+ await nodesReady.promise;
80
+ return { exitCode: 0, stderr: "", stdout: JSON.stringify({ items: nodes.map(name => ({ metadata: { name }, status: { conditions: [{ type: "Ready", status: "True" }] } })) }) };
81
+ }
82
+ if (args[3] === "pull") {
83
+ pulls.add(`${name}/${args.at(-1)}`);
84
+ if (pulls.size === 6) allPulling.resolve();
85
+ await pullsDone.promise;
86
+ }
87
+ return { exitCode: 0, stderr: "", stdout: "" };
88
+ },
89
+ } as never).then(() => { finished = true; });
90
+ try {
91
+ await registryStarted.promise;
92
+ // The runtimes and external pulls can progress without Node readiness.
93
+ await Bun.sleep(0);
94
+ expect(pulls.size).toBe(3);
95
+ expect([...pulls].every(pull => pull.endsWith("alpine:3.20"))).toBe(true);
96
+ registryReady.resolve();
97
+ await Promise.race([allPulling.promise, Bun.sleep(1000).then(() => { throw new Error(`serialized node pulls: ${[...pulls]}`); })]);
98
+ expect(finished).toBe(false);
99
+ for (const node of nodes) expect(pulls.has(`${node}/registry.test/app:1`)).toBe(true);
100
+ } finally {
101
+ registryReady.resolve(); nodesReady.resolve(); traefikReady.resolve(); pullsDone.resolve();
102
+ await work;
103
+ }
104
+ });
105
+
106
+ test("one prepared build fans out to every runtime and failed imports drain the other nodes", async () => {
107
+ const nodes = ["cluster", "cluster-node-2", "cluster-node-3"];
108
+ const prepared = [{ source: "prepared-once", reference: "example.test/app:v1" }];
109
+ const allStarted = deferred(), release = deferred();
110
+ const started: string[] = [];
111
+ const failure = new Error("agent import failed");
112
+ const loader = spyOn(imageLoader, "loadK3sBuildImages").mockImplementation(async (node, images) => {
113
+ expect(images).toBe(prepared);
114
+ started.push(node);
115
+ if (started.length === 3) allStarted.resolve();
116
+ if (node === "cluster-node-2") throw failure;
117
+ await release.promise;
118
+ });
119
+ let finished = false;
120
+ const work = k3s({ nodes: 3, registry: false, traefik: false, images: [{ reference: prepared[0].reference, content: "FROM scratch" }] }).setup!({
121
+ name: "cluster", helpers: {},
122
+ [COMPONENT_RUNTIME]: { images: prepared, gateway: "127.0.0.1", caBundle: null },
123
+ exec: async (_name: string, args: string[]) => ({ exitCode: 0, stderr: "", stdout: args[0] === "kubectl"
124
+ ? JSON.stringify({ items: nodes.map(name => ({ metadata: { name }, status: { conditions: [{ type: "Ready", status: "True" }] } })) }) : "" }),
125
+ } as never).then(() => { finished = true; }, error => { finished = true; return error; });
126
+ try {
127
+ await allStarted.promise;
128
+ expect(started).toEqual(nodes);
129
+ expect(finished).toBe(false);
130
+ release.resolve();
131
+ expect(await work).toBe(failure);
132
+ } finally { release.resolve(); await work; loader.mockRestore(); }
133
+ });
@@ -0,0 +1,140 @@
1
+ // Real k3s nodes on an isolated Docker network. Snapshot acceptance lives in
2
+ // examples/k3s-multinode, which runs through Spectest's actual VM lifecycle.
3
+ // SPECTEST_TEST_K3S_DOCKER=1 bun test sdk/src/components/k3s-docker.test.ts
4
+ import { expect, test } from "bun:test";
5
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { defineEnvironment, type ServiceDefinition, type ServiceSetupContext } from "../index";
9
+ import { k3s, type K3sHelpers } from "./k3s";
10
+
11
+ async function run(args: string[], stdin = "") {
12
+ const child = Bun.spawn(args, { stdin: new Blob([stdin]), stdout: "pipe", stderr: "pipe" });
13
+ const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
14
+ return { exitCode, stdout, stderr };
15
+ }
16
+ async function checked(args: string[], stdin = "") {
17
+ const result = await run(args, stdin);
18
+ if (result.exitCode) throw new Error(`${args.slice(0, 4).join(" ")}: ${result.stderr}\n${result.stdout}`);
19
+ return result.stdout;
20
+ }
21
+
22
+ for (const count of [1, 3]) test.skipIf(!process.env.SPECTEST_TEST_K3S_DOCKER)(`${count} real nodes: readiness, images, routing, registry and restart`, async () => {
23
+ const name = `spectest-k3s-${process.pid}-${count}`;
24
+ const root = mkdtempSync(join(tmpdir(), `${name}-`));
25
+ const services = defineEnvironment({ name, services: { [name]: k3s({ nodes: count, images: ["busybox:1.37"] }) } }).config.services;
26
+ const names = Object.keys(services);
27
+ const primary = services[name] as ServiceDefinition<K3sHelpers>;
28
+ const image = (primary.image as { reference: string }).reference;
29
+ const volumes = new Set<string>();
30
+ const exec = (node: string, args: string[]) => run(["docker", "exec", node, ...args]);
31
+ const kubectl = (args: string[], stdin = "") => checked(["docker", "exec", "-i", name, "kubectl", ...args], stdin);
32
+ const helpers = {
33
+ apply: async (yaml: string) => { await kubectl(["apply", "-f", "-"], yaml); },
34
+ waitForRollout: async (deployment: string, opts: { namespace: string }) => {
35
+ await kubectl(["rollout", "status", `deployment/${deployment}`, "-n", opts.namespace, "--timeout=120s"]);
36
+ },
37
+ };
38
+ try {
39
+ await checked(["docker", "pull", image]);
40
+ await checked(["docker", "network", "create", name]);
41
+ const start = async (node: string) => {
42
+ const service = services[node];
43
+ const args = ["docker", "run", "-d", "--privileged", "--cgroupns=host", "--name", node, "--hostname", node,
44
+ "--network", name, "--network-alias", `${node}.internal`, "--tmpfs", "/run", "--tmpfs", "/var/run"];
45
+ for (const volume of service.volumes ?? []) {
46
+ // This opt-in test uses fresh stores, not any developer's cache mounts.
47
+ if (volume.source) throw new Error("Docker regression requires no guest image-cache manifest");
48
+ const source = volume.name ?? `${node}-store`;
49
+ volumes.add(source);
50
+ args.push("-v", `${source}:${volume.target}`);
51
+ }
52
+ for (const [i, file] of (service.files ?? []).entries()) {
53
+ const path = join(root, `${node}-${i}`);
54
+ writeFileSync(path, file.content.replaceAll("{{SPECTEST_SERVICE}}", node));
55
+ args.push("-v", `${path}:${file.path}:ro`);
56
+ }
57
+ await checked([...args, "--entrypoint", "sh", image, "-c", service.command!]);
58
+ };
59
+ await Promise.all(names.filter(node => node !== name).map(start));
60
+ await start(name);
61
+ const deadline = Date.now() + 120_000;
62
+ while ((await exec(name, ["kubectl", "get", "--raw=/readyz"])).exitCode !== 0) {
63
+ if ((await checked(["docker", "inspect", "--format", "{{.State.Running}}", name])).trim() !== "true") throw new Error("Server exited during startup");
64
+ if (Date.now() >= deadline) throw new Error("API server readiness timed out");
65
+ await Bun.sleep(250);
66
+ }
67
+ await primary.setup!({ name, helpers, exec } as unknown as ServiceSetupContext<K3sHelpers>);
68
+ await helpers.apply(JSON.stringify({ apiVersion: "apps/v1", kind: "DaemonSet", metadata: { name: "web" }, spec: {
69
+ selector: { matchLabels: { app: "web" } }, template: { metadata: { labels: { app: "web" } }, spec: {
70
+ containers: [{ name: "web", image: "busybox:1.37", imagePullPolicy: "Never",
71
+ command: ["sh", "-ec", "mkdir /www; echo hello >/www/index.html; exec httpd -f -p 8080 -h /www"],
72
+ readinessProbe: { httpGet: { path: "/", port: 8080 } } }],
73
+ } },
74
+ } }));
75
+ await kubectl(["rollout", "status", "daemonset/web", "--timeout=90s"]);
76
+ const pods = JSON.parse(await kubectl(["get", "pods", "-l", "app=web", "-o", "json"])).items;
77
+ expect(pods).toHaveLength(count);
78
+ expect(new Set(pods.map((pod: any) => pod.spec.nodeName)).size).toBe(count);
79
+ await Promise.all(pods.map(async (pod: any) => {
80
+ for (const target of pods) expect((await kubectl(["exec", pod.metadata.name, "--", "wget", "-T", "5", "-qO-", `http://${target.status.podIP}:8080`])).trim()).toBe("hello");
81
+ }));
82
+ await helpers.apply(JSON.stringify({ apiVersion: "v1", kind: "List", items: [
83
+ { apiVersion: "v1", kind: "Service", metadata: { name: "web" }, spec: { selector: { app: "web" }, ports: [{ port: 80, targetPort: 8080 }] } },
84
+ { apiVersion: "networking.k8s.io/v1", kind: "Ingress", metadata: { name: "web" }, spec: { rules: [{ host: "web.test", http: { paths: [{
85
+ path: "/", pathType: "Prefix", backend: { service: { name: "web", port: { number: 80 } } },
86
+ }] } }] } },
87
+ ] }));
88
+ const ingressDeadline = Date.now() + 30_000;
89
+ for (;;) {
90
+ const response = await exec(name, ["wget", "-T", "5", "-qO-", "--header=Host: web.test", "http://127.0.0.1"]);
91
+ if (response.exitCode === 0 && response.stdout.trim() === "hello") break;
92
+ if (Date.now() >= ingressDeadline) throw new Error(`Ingress failed: ${response.stderr}`);
93
+ await Bun.sleep(250);
94
+ }
95
+ // Publish from the server, then resolve and pull the tag on every node.
96
+ const registryImage = `${name}.internal:5000/busybox:smoke`;
97
+ await checked(["docker", "exec", name, "ctr", "-n", "k8s.io", "images", "tag", "docker.io/library/busybox:1.37", registryImage]);
98
+ const arch = (await checked(["docker", "exec", name, "uname", "-m"])).trim() === "aarch64" ? "arm64" : "amd64";
99
+ // The local push path selects a single manifest. The transfer service's
100
+ // platform filter still pushes the original multi-platform index.
101
+ await checked(["docker", "exec", name, "ctr", "-n", "k8s.io", "images", "push", "--local", "--plain-http",
102
+ "--platform", `linux/${arch}`, registryImage]);
103
+ const tags = await checked(["docker", "exec", name, "wget", "-qO-", `http://${name}.internal:5000/v2/busybox/tags/list`]);
104
+ expect(JSON.parse(tags).tags).toContain("smoke");
105
+ await Promise.all(names.map(async node => {
106
+ await checked(["docker", "exec", node, "crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "pull", registryImage]);
107
+ }));
108
+ // Restart an agent (or the server for the default topology) and make sure
109
+ // another kubelet hasn't destroyed its pod cgroups while it was absent.
110
+ const restarted = names.at(-1)!;
111
+ await checked(["docker", "restart", restarted]);
112
+ const restartDeadline = Date.now() + 120_000;
113
+ while ((await exec(name, ["kubectl", "get", "--raw=/readyz"])).exitCode !== 0) {
114
+ if (Date.now() >= restartDeadline) throw new Error("Server did not recover after restart");
115
+ await Bun.sleep(250);
116
+ }
117
+ await kubectl(["wait", "node", restarted, "--for=condition=Ready", "--timeout=120s"]);
118
+ await kubectl(["rollout", "status", "daemonset/web", "--timeout=120s"]);
119
+ for (const pod of pods) {
120
+ for (;;) {
121
+ const response = await exec(name, ["kubectl", "exec", pod.metadata.name, "--", "wget", "-T", "5", "-qO-", "http://web.default.svc.cluster.local"]);
122
+ if (response.exitCode === 0 && response.stdout.trim() === "hello") break;
123
+ if (Date.now() >= restartDeadline) throw new Error(`Pod did not recover after restart: ${response.stderr}`);
124
+ await Bun.sleep(250);
125
+ }
126
+ }
127
+ } catch (error) {
128
+ console.error((await run(["docker", "exec", name, "kubectl", "logs", "-n", "kube-system", "deployment/spectest-registry", "--tail=25"])).stdout);
129
+ for (const node of names) console.error(`${node}:\n${(await run(["docker", "logs", "--tail", "60", node])).stderr}`);
130
+ throw error;
131
+ } finally {
132
+ await Promise.all(names.map(node => run(["docker", "rm", "-fv", node])));
133
+ // These trees are deliberately outside Docker's own container cgroups.
134
+ await run(["docker", "run", "--rm", "--privileged", "--cgroupns=host", "--entrypoint", "sh", image, "-c",
135
+ 'for node in "$@"; do find "/sys/fs/cgroup/spectest-$node" -depth -type d -exec rmdir {} \\; 2>/dev/null; done', "sh", ...names]);
136
+ await Promise.all([...volumes].map(volume => run(["docker", "volume", "rm", volume])));
137
+ await run(["docker", "network", "rm", name]);
138
+ rmSync(root, { recursive: true, force: true });
139
+ }
140
+ }, 600_000);
@@ -0,0 +1,73 @@
1
+ import { expect, test } from "bun:test";
2
+ import { defineEnvironment } from "../index";
3
+ import { k3s } from "./k3s";
4
+ import { waitForK3sNodes } from "./k3s-nodes";
5
+ import { loadAllYaml } from "@kubernetes/client-node";
6
+
7
+ const ready = (name: string) => ({ metadata: { name }, status: { conditions: [{ type: "Ready", status: "True" }] } });
8
+ const result = (stdout = "", exitCode = 0) => ({ stdout, stderr: "", exitCode });
9
+
10
+ test("one and multiple nodes use the same topology and preserve primary overrides", () => {
11
+ const config = (nodes?: number) => defineEnvironment({ name: "test", services: {
12
+ cluster: { ...k3s({ nodes }), hostnames: ["apps.test"] },
13
+ app: { image: "alpine", dependsOn: ["cluster"] },
14
+ } }).config;
15
+ expect(JSON.stringify(config().services)).toEqual(JSON.stringify(config(1).services));
16
+ const one = config(1), three = config(3);
17
+ expect(Object.keys(three.services)).toEqual(["cluster", "cluster-node-2", "cluster-node-3", "app"]);
18
+ expect(three.services.cluster.dependsOn).toEqual(["cluster-node-2", "cluster-node-3"]);
19
+ expect(three.services.cluster.hostnames).toEqual(["apps.test"]);
20
+ expect(three.services["cluster-node-2"].hostnames).toBeUndefined();
21
+ expect(three.services.cluster.command).toBe(one.services.cluster.command);
22
+ for (const name of ["cluster-node-2", "cluster-node-3"]) {
23
+ const node = three.services[name];
24
+ expect(node.command).toContain("exec k3s agent");
25
+ expect(node.command).toContain("--server=https://cluster.internal:6443");
26
+ expect(node.command).toContain("--kubelet-arg=cgroup-root=/spectest-$(hostname)");
27
+ expect(node.dependsOn).toBeUndefined(); // joins while the server boots
28
+ expect(node.imageBuilds).toHaveLength(0); // build once, import into every node
29
+ expect(node.files?.find(f => f.path.endsWith("registries.yaml"))?.content).toContain("http://cluster.internal:5000");
30
+ expect(node.volumes?.find(v => v.name)?.name).toBe("spectest-k3s-cluster-join");
31
+ }
32
+ });
33
+
34
+ test("node counts and generated name collisions fail at configuration time", () => {
35
+ for (const nodes of [0, -1, 1.5, NaN, Infinity, "2", null]) expect(() => k3s({ nodes: nodes as number })).toThrow("positive integer");
36
+ expect(() => defineEnvironment({ name: "test", services: {
37
+ cluster: k3s({ nodes: 2 }), "cluster-node-2": { image: "alpine" },
38
+ } })).toThrow("produced by both");
39
+ const config = defineEnvironment({ name: "test", services: { first: k3s({ nodes: 2 }), second: k3s({ nodes: 2 }) } }).config;
40
+ expect(config.services.first.volumes?.[0].name).not.toBe(config.services.second.volumes?.[0].name);
41
+ });
42
+
43
+ test("all declared node identities must be Ready, not just a matching count", async () => {
44
+ let reads = 0;
45
+ await waitForK3sNodes("cluster", ["cluster", "cluster-node-2"], async () => result(JSON.stringify({
46
+ items: reads++ === 0 ? [ready("cluster"), ready("unrelated")] : [ready("cluster"), ready("cluster-node-2")],
47
+ })), 1000);
48
+ expect(reads).toBe(2);
49
+ await expect(waitForK3sNodes("cluster", ["cluster", "cluster-node-2"], async (_name, args) =>
50
+ result(args[0] === "kubectl" ? JSON.stringify({ items: [ready("cluster")] }) : "", args[0] === "kubectl" ? 0 : 1), 0))
51
+ .rejects.toThrow("cluster-node-2: not registered");
52
+ });
53
+
54
+ test("registry and ingress remain on the public node and boot markers clear on every node", async () => {
55
+ const manifests: any[] = [], cleared: string[] = [];
56
+ await k3s({ nodes: 3 }).setup!({ name: "cluster", helpers: {
57
+ apply: async (yaml: string) => { manifests.push(...loadAllYaml(yaml)); },
58
+ waitForRollout: async () => {},
59
+ }, exec: async (name: string, args: string[]) => {
60
+ if (args[0] === "rm") cleared.push(name);
61
+ return result(args[0] === "kubectl" ? JSON.stringify({ items: ["cluster", "cluster-node-2", "cluster-node-3"].map(ready) }) : "");
62
+ } } as never);
63
+ const deployments = manifests.filter(doc => doc.kind === "Deployment");
64
+ expect(deployments).toHaveLength(2);
65
+ for (const deployment of deployments) expect(deployment.spec.template.spec.nodeSelector).toEqual({ "kubernetes.io/hostname": "cluster" });
66
+ expect(cleared.sort()).toEqual(["cluster", "cluster-node-2", "cluster-node-3"]);
67
+ });
68
+
69
+ test("cluster identity arguments cannot silently invalidate node routing and readiness", () => {
70
+ for (const flag of ["--node-name=other", "--with-node-id", "--token-file=/tmp/token", "--disable-agent"]) {
71
+ expect(() => k3s({ extraArgs: [flag] })).toThrow("cluster identity");
72
+ }
73
+ });
@@ -0,0 +1,35 @@
1
+ import type { SpectestContext } from "../index.js";
2
+
3
+ /** /readyz only checks the API server. Check the expected identities, not just
4
+ * a count: an unregistered agent must not be hidden by an unrelated node.
5
+ * This runs alongside image imports and infrastructure rollouts. */
6
+ export async function waitForK3sNodes(
7
+ server: string, names: readonly string[], exec: SpectestContext["exec"], timeoutMs: number,
8
+ ): Promise<void> {
9
+ const deadline = Date.now() + timeoutMs;
10
+ let detail = "no nodes registered";
11
+ for (;;) {
12
+ const result = await exec(server, ["kubectl", "get", "nodes", "-o", "json", "--request-timeout=5s"], { timeoutMs: 10_000 });
13
+ if (result.exitCode === 0) {
14
+ const nodes: { metadata?: { name?: string }; status?: { conditions?: { type: string; status: string; message?: string }[] } }[] = JSON.parse(result.stdout).items;
15
+ const pending = names.flatMap(name => {
16
+ const node = nodes.find(node => node.metadata?.name === name);
17
+ const ready = node?.status?.conditions?.find(condition => condition.type === "Ready");
18
+ return ready?.status === "True" ? [] : [`${name}: ${node ? ready?.message || `Ready=${ready?.status ?? "Unknown"}` : "not registered"}`];
19
+ });
20
+ if (!pending.length) return;
21
+ detail = pending.join("; ");
22
+ } else detail = result.stderr || result.stdout;
23
+ if (Date.now() >= deadline) {
24
+ // Agent startup errors otherwise look like a missing Kubernetes Node.
25
+ const diagnostics = await Promise.all(names.map(async name => {
26
+ try {
27
+ const result = await exec(name, ["sh", "-c", "test -S /run/k3s/containerd/containerd.sock"], { timeoutMs: 5_000 });
28
+ return `${name}: ${result.exitCode === 0 ? "containerd socket present" : result.stderr || "containerd socket missing"}`;
29
+ } catch (error) { return `${name}: ${String(error)}`; }
30
+ }));
31
+ throw new Error(`k3s(${server}): nodes did not become Ready within ${timeoutMs / 1000}s: ${detail}\n${diagnostics.join("\n")}`);
32
+ }
33
+ await new Promise(resolve => setTimeout(resolve, 250));
34
+ }
35
+ }
@@ -1,28 +1,33 @@
1
1
  import { expect, test } from "bun:test";
2
- import { k3s } from "./k3s";
2
+ import { k3s, type K3sOptions } from "./k3s";
3
+ import { defineEnvironment } from "../index";
4
+
5
+ function primary(options: K3sOptions = {}) {
6
+ return defineEnvironment({ name: "test", services: { cluster: k3s(options) } }).config.services.cluster;
7
+ }
3
8
  import { REGISTRY_CONTENT, REGISTRY_DATA } from "../harness/registry-seed";
4
9
 
5
10
  test("HTTPS registry is component-owned, with explicit containerd trust and no Traefik dependency", () => {
6
- const cluster = k3s({ registry: { host: "Registry.Test" }, traefik: false });
11
+ const cluster = primary({ registry: { host: "Registry.Test" }, traefik: false });
7
12
  expect(cluster.tls).toEqual([{ hostname: "registry.test", port: 5000 }]);
8
13
  const registries = cluster.files.find(file => file.path.endsWith("registries.yaml"))!.content;
9
14
  expect(registries).toContain('"registry.test":\n tls:\n ca_file:');
10
15
  expect(registries).not.toContain('"https://127.0.0.1');
11
- expect(registries).toContain('"http://127.0.0.1:5000"');
16
+ expect(registries).toContain('"http://cluster.internal:5000"');
12
17
  });
13
18
 
14
19
  test("only builds targeting the configured registry require publication storage", () => {
15
20
  const image = { content: "FROM scratch", reference: "registry.test/codenode:latest" };
16
- const cluster = k3s({ registry: { host: "registry.test" }, images: [image] });
21
+ const cluster = primary({ registry: { host: "registry.test" }, images: [image] });
17
22
  expect(cluster.imageBuilds).toHaveLength(1);
18
23
  expect(cluster.volumes.find(v => v.target === REGISTRY_DATA)?.source).toBe(
19
24
  "/var/lib/spectest-registries/{{SPECTEST_SERVICE}}",
20
25
  );
21
26
  expect(cluster.volumes.find(v => v.target === REGISTRY_CONTENT)?.readOnly).toBe(true);
22
27
  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);
28
+ expect(primary({ registry, images: [image] }).volumes.some(v => v.target === REGISTRY_DATA)).toBe(false);
24
29
  }
25
- expect(k3s({ registry: { host: "registry.test" }, images: [image.reference] }).volumes.some(v => v.target === REGISTRY_DATA)).toBe(false);
30
+ expect(primary({ registry: { host: "registry.test" }, images: [image.reference] }).volumes.some(v => v.target === REGISTRY_DATA)).toBe(false);
26
31
  });
27
32
 
28
33
  test("registry host rejects ports, URLs, wildcard and reserved hostnames", () => {
@@ -35,7 +40,7 @@ test("registry host rejects ports, URLs, wildcard and reserved hostnames", () =>
35
40
  });
36
41
 
37
42
  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"] });
43
+ const cluster = primary({ registry: { host: "registry.test" }, traefik: false, images: ["registry.test/app:latest"] });
39
44
  let ready = false;
40
45
  await cluster.setup({ name: "cluster", helpers: {
41
46
  apply: async (manifest: string) => {
@@ -45,6 +50,6 @@ test("registry upload URLs are relative and local pre-pulls wait for the registr
45
50
  waitForRollout: async () => { ready = true; },
46
51
  }, exec: async (_name: string, args: string[]) => {
47
52
  if (args[3] === "pull") expect(ready).toBe(true);
48
- return { exitCode: 0, stdout: "", stderr: "" };
53
+ return { exitCode: 0, stdout: args[0] === "kubectl" ? JSON.stringify({ items: [{ metadata: { name: "cluster" }, status: { conditions: [{ type: "Ready", status: "True" }] } }] }) : "", stderr: "" };
49
54
  } } as never);
50
55
  });