1import * as Kubernetes from "@/Kubernetes";
2import { connectCluster, readObject } from "@/Kubernetes/internal/client.ts";
3import {
4 kindClusterConfig,
5 kindConfigOf,
6 REGISTRY_CONTAINERD_PATCH,
7} from "@/Kubernetes/internal/kind.ts";
8import { imagePlatformOf } from "@/Kubernetes/internal/workload.ts";
9import * as Provider from "@/Provider";
10import * as Test from "@/Test/Alchemy";
11import * as Core from "@/Test/Core";
12import { describe, expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import LocalEffectJob from "./fixtures/local-job.ts";
18import LocalEffectServer from "./fixtures/local-server.ts";
19import { TestLocalCluster } from "./fixtures/local.ts";
20
21const testOptions = { providers: Kubernetes.providers() };
22const { test, beforeAll, afterAll } = Test.make(testOptions);
23
24const tags = ["provider:kubernetes", "provider:kubernetes:localcluster"];
25
26const attrs = (name: string): Kubernetes.LocalCluster["Attributes"] => ({
27 name,
28 context: `kind-${name}`,
29 kubeconfig: undefined,
30 registry: { server: "localhost:5001" },
31 registryContainer: `${name}-registry`,
32 architecture: "arm64",
33 connection: {
34 auth: { kind: "kubeconfig", context: `kind-${name}` },
35 registry: { server: "localhost:5001" },
36 architecture: "arm64",
37 },
38});
39
40test.provider(
41 "diff replaces the cluster when its name or node image changes",
42 () =>
43 Effect.gen(function* () {
44 const provider = yield* Provider.findProvider(Kubernetes.LocalCluster);
45 const base = {
46 id: "Cluster",
47 fqn: "Cluster",
48 instanceId: "instance",
49 oldBindings: [],
50 newBindings: [],
51 output: attrs("dev"),
52 };
53 const renamed = yield* provider.diff!({
54 ...base,
55 olds: { name: "dev" },
56 news: { name: "other" },
57 });
58 expect(renamed?.action).toBe("replace");
59
60 const reimaged = yield* provider.diff!({
61 ...base,
62 olds: { name: "dev" },
63 news: { name: "dev", nodeImage: "kindest/node:v1.33.1" },
64 });
65 expect(reimaged?.action).toBe("replace");
66
67 const ports = [{ containerPort: 30080, hostPort: 8080 }];
68 const reconfigured = yield* provider.diff!({
69 ...base,
70 olds: { name: "dev" },
71 news: {
72 name: "dev",
73 nodes: [{ role: "control-plane", extraPortMappings: ports }],
74 },
75 });
76 expect(reconfigured?.action).toBe("replace");
77
78 const sameConfig = yield* provider.diff!({
79 ...base,
80 olds: {
81 name: "dev",
82 nodes: [{ role: "control-plane", extraPortMappings: ports }],
83 },
84 news: {
85 name: "dev",
86 nodes: [{ extraPortMappings: ports, role: "control-plane" }],
87 },
88 });
89 expect(sameConfig).toBeUndefined();
90
91 const moved = yield* provider.diff!({
92 ...base,
93 olds: { name: "dev" },
94 news: { name: "dev", registryPort: 5002 },
95 });
96 expect(moved).toBeUndefined();
97 }),
98 { tags },
99);
100
101test.provider(
102 "user kind config is kept and the registry patch is appended",
103 () =>
104 Effect.sync(() => {
105 expect(kindClusterConfig(kindConfigOf({ name: "dev" }))).toEqual({
106 kind: "Cluster",
107 apiVersion: "kind.x-k8s.io/v1alpha4",
108 containerdConfigPatches: [REGISTRY_CONTAINERD_PATCH],
109 });
110 const config = kindClusterConfig(
111 kindConfigOf({
112 name: "dev",
113 registryPort: 5002,
114 nodeImage: "kindest/node:v1.33.1",
115 nodes: [
116 {
117 role: "control-plane",
118 extraPortMappings: [{ containerPort: 80, hostPort: 8080 }],
119 },
120 { role: "worker" },
121 ],
122 networking: { podSubnet: "10.244.0.0/16" },
123 containerdConfigPatches: ["# user patch"],
124 }),
125 );
126 // Alchemy's own props never reach kind's config.
127 expect(config.name).toBeUndefined();
128 expect(config.registryPort).toBeUndefined();
129 expect(config.nodeImage).toBeUndefined();
130 expect(config.networking).toEqual({ podSubnet: "10.244.0.0/16" });
131 expect(config.kind).toBe("Cluster");
132 expect(config.nodes).toHaveLength(2);
133 expect(config.containerdConfigPatches).toEqual([
134 "# user patch",
135 REGISTRY_CONTAINERD_PATCH,
136 ]);
137 }),
138 { tags },
139);
140
141test.provider(
142 "workloads build for the connection's node architecture by default",
143 () =>
144 Effect.sync(() => {
145 const connection = attrs("dev").connection;
146 expect(imagePlatformOf(undefined, connection)).toBe("linux/arm64");
147 expect(imagePlatformOf("amd64", connection)).toBe("linux/amd64");
148 expect(imagePlatformOf(undefined, undefined)).toBe("linux/amd64");
149 }),
150 { tags },
151);
152
153const stack = Core.scratchStack(testOptions, "LocalClusterE2E");
154
155// Creates a real kind cluster (~30s) and builds images with Docker; needs
156// Docker and the kind CLI.
157describe.skipIf(!process.env.KUBERNETES_TEST_KIND)(
158 "Kubernetes LocalCluster E2E",
159 { tags: [...tags, "live"] },
160 () => {
161 let cluster: Kubernetes.LocalCluster["Attributes"];
162 let jobName: string;
163
164 beforeAll(
165 Effect.gen(function* () {
166 yield* stack.destroy();
167 const deployed = yield* stack.deploy(
168 Effect.gen(function* () {
169 const cluster = yield* TestLocalCluster;
170 const job = yield* LocalEffectJob;
171 yield* LocalEffectServer;
172 return { cluster, jobName: job.jobName };
173 }),
174 );
175 cluster = deployed.cluster;
176 jobName = deployed.jobName;
177 }),
178 { timeout: 300_000 },
179 );
180
181 afterAll.skipIf(!!process.env.NO_DESTROY)(
182 Effect.gen(function* () {
183 yield* stack.destroy();
184 // Out-of-band: the kind cluster and its registry container are gone.
185 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
186 const clusters = yield* spawner.lines(
187 ChildProcess.make("kind", ["get", "clusters"]),
188 );
189 expect(clusters).not.toContain(cluster.name);
190 const containers = yield* spawner.lines(
191 ChildProcess.make("docker", [
192 "ps",
193 "--all",
194 "--format",
195 "{{.Names}}",
196 ]),
197 );
198 expect(containers).not.toContain(cluster.registryContainer);
199 }),
200 { timeout: 180_000 },
201 );
202
203 test.provider(
204 "the cluster exposes a connection with its registry and architecture",
205 () =>
206 Effect.sync(() => {
207 expect(cluster.context).toBe("kind-alchemy-test-local");
208 expect(cluster.registry.server).toBe("localhost:5061");
209 expect(cluster.connection.registry?.server).toBe("localhost:5061");
210 expect(["amd64", "arm64"]).toContain(cluster.architecture);
211 }),
212 );
213
214 test.provider(
215 "an Effect Job is built into the local registry and runs to completion",
216 () =>
217 Effect.gen(function* () {
218 const transport = yield* connectCluster(cluster.connection);
219 const job = yield* readObject({
220 transport,
221 object: {
222 apiVersion: "batch/v1",
223 kind: "Job",
224 name: jobName,
225 namespace: "default",
226 },
227 }).pipe(
228 Effect.repeat({
229 schedule: Schedule.spaced("2 seconds"),
230 until: (job) =>
231 ((job as { status?: { succeeded?: number } }).status
232 ?.succeeded ?? 0) >= 1,
233 times: 45,
234 }),
235 );
236 const spec = job as {
237 status?: { succeeded?: number };
238 spec?: {
239 template?: { spec?: { containers?: { image?: string }[] } };
240 };
241 };
242 expect(spec.status?.succeeded).toBe(1);
243 expect(spec.spec?.template?.spec?.containers?.[0]?.image).toMatch(
244 /^localhost:5061\//,
245 );
246 }),
247 { timeout: 120_000 },
248 );
249
250 test.provider(
251 "an Effect server Deployment becomes available",
252 () =>
253 Effect.gen(function* () {
254 const transport = yield* connectCluster(cluster.connection);
255 const deployment = yield* readObject({
256 transport,
257 object: {
258 apiVersion: "apps/v1",
259 kind: "Deployment",
260 name: "local-effect-server",
261 namespace: "default",
262 },
263 }).pipe(
264 Effect.repeat({
265 schedule: Schedule.spaced("2 seconds"),
266 until: (d) =>
267 ((d as { status?: { availableReplicas?: number } }).status
268 ?.availableReplicas ?? 0) >= 1,
269 times: 45,
270 }),
271 );
272 expect(
273 (deployment as { status?: { availableReplicas?: number } }).status
274 ?.availableReplicas,
275 ).toBe(1);
276 }),
277 { timeout: 120_000 },
278 );
279 },
280);