| 1 | import * as Kubernetes from "@/Kubernetes"; |
| 2 | import { connectCluster, readObject } from "@/Kubernetes/internal/client.ts"; |
| 3 | import { |
| 4 | kindClusterConfig, |
| 5 | kindConfigOf, |
| 6 | REGISTRY_CONTAINERD_PATCH, |
| 7 | } from "@/Kubernetes/internal/kind.ts"; |
| 8 | import { imagePlatformOf } from "@/Kubernetes/internal/workload.ts"; |
| 9 | import * as Provider from "@/Provider"; |
| 10 | import * as Test from "@/Test/Alchemy"; |
| 11 | import * as Core from "@/Test/Core"; |
| 12 | import { describe, expect } from "alchemy-test"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Schedule from "effect/Schedule"; |
| 15 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 16 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | import LocalEffectJob from "./fixtures/local-job.ts"; |
| 18 | import LocalEffectServer from "./fixtures/local-server.ts"; |
| 19 | import { TestLocalCluster } from "./fixtures/local.ts"; |
| 20 | |
| 21 | const testOptions = { providers: Kubernetes.providers() }; |
| 22 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 23 | |
| 24 | const tags = ["provider:kubernetes", "provider:kubernetes:localcluster"]; |
| 25 | |
| 26 | const 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 | |
| 40 | test.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 | |
| 101 | test.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 | |
| 141 | test.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 | |
| 153 | const 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. |
| 157 | describe.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 | ); |