1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as container from "@distilled.cloud/gcp/container_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9import { waitForOperation } from "@/GCP/Operation";
10import { CAPACITY_ZONE, withGkeClusterSlot } from "../zones.ts";
11
12const { test } = Test.make({ providers: GCP.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// GKE cluster create and delete each take 5-10 minutes.
20const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
21
22// Pools stay empty: every GKE node holds an external IP and the project
23// allows only 8 IN_USE_ADDRESSES per region, shared by every capacity test.
24const POOL_NODE_COUNT = 0;
25
26const HOST_CLUSTER_ID = "alch-cnp-host";
27const HOST_ZONE = CAPACITY_ZONE;
28
29const waitUntilGone = (
30 project: string,
31 zone: string,
32 clusterId: string,
33 nodePoolId: string,
34) =>
35 container
36 .getProjectsZonesClustersNodePools({
37 projectId: project,
38 zone,
39 clusterId,
40 nodePoolId,
41 })
42 .pipe(
43 Effect.as("found" as const),
44 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
45 Effect.repeat({
46 schedule: Schedule.spaced("3 seconds"),
47 until: (status) => status === "gone",
48 times: 10,
49 }),
50 );
51
52const lastSegment = (value: string) => {
53 const trimmed = value.replace(/\/+$/, "");
54 const parts = trimmed.split("/");
55 return parts[parts.length - 1] || trimmed;
56};
57
58// GKE cluster create/delete takes 5–10 minutes.
59const waitClusterOp = (
60 project: string,
61 zone: string,
62 operation: container.Operation,
63) => {
64 const raw = operation.name ?? "";
65 const fromLink = operation.selfLink ?? "";
66 const operationId = lastSegment(
67 raw.includes("/operations/")
68 ? raw
69 : fromLink.includes("/operations/")
70 ? fromLink
71 : raw,
72 );
73 return waitForOperation(
74 { ...operation, name: operationId },
75 (id) =>
76 container.getProjectsZonesOperations({
77 projectId: project,
78 zone,
79 operationId: id,
80 }),
81 { budget: "20 minutes", interval: "10 seconds" },
82 );
83};
84
85test.provider(
86 "lists zonal clusters and treats a missing node pool as NotFound",
87 (stack) =>
88 Effect.gen(function* () {
89 yield* stack.destroy();
90
91 const { project } = yield* GcpEnvironment.current;
92 const page = yield* container.listProjectsZonesClusters({
93 projectId: project,
94 zone: "-",
95 });
96 expect(
97 (page.clusters ?? []).map((cluster) => cluster.name),
98 ).not.toContain("alchemy-missing-cluster");
99
100 const missing = yield* container
101 .getProjectsZonesClustersNodePools({
102 projectId: project,
103 zone: HOST_ZONE,
104 clusterId: "alchemy-missing-cluster",
105 nodePoolId: "alchemy-missing-pool",
106 })
107 .pipe(
108 Effect.as("found" as const),
109 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
110 );
111 expect(missing).toEqual("gone");
112
113 yield* stack.destroy();
114 }).pipe(logLevel),
115 { tags: ["provider:gcp", "provider:gcp:container", "live"], timeout: 90_000 },
116);
117
118/**
119 * A throwaway zonal host cluster without Workload Identity (the node pool
120 * must not request GKE_METADATA on it). Owned by this test: a leftover from
121 * an interrupted run is reused if RUNNING, replaced otherwise, and always
122 * deleted when the test ends.
123 */
124const withHostCluster = <A, E, R>(
125 project: string,
126 body: Effect.Effect<A, E, R>,
127) => {
128 const ref = {
129 projectId: project,
130 zone: HOST_ZONE,
131 clusterId: HOST_CLUSTER_ID,
132 };
133 const deleteHost = container.deleteProjectsZonesClusters(ref).pipe(
134 Effect.flatMap((operation) => waitClusterOp(project, HOST_ZONE, operation)),
135 Effect.catchTag("NotFound", () => Effect.void),
136 );
137 const ensureHost = Effect.gen(function* () {
138 const existing = yield* container
139 .getProjectsZonesClusters(ref)
140 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
141 if (existing?.status === "RUNNING") return;
142 if (existing !== undefined) yield* deleteHost;
143 const created = yield* container.createProjectsZonesClusters({
144 projectId: project,
145 zone: HOST_ZONE,
146 body: {
147 cluster: {
148 name: HOST_CLUSTER_ID,
149 ipAllocationPolicy: { useIpAliases: true },
150 nodePools: [
151 {
152 name: "default-pool",
153 initialNodeCount: 1,
154 config: {
155 machineType: "e2-medium",
156 diskSizeGb: 20,
157 diskType: "pd-standard",
158 spot: true,
159 },
160 },
161 ],
162 },
163 },
164 });
165 yield* waitClusterOp(project, HOST_ZONE, created);
166 });
167 return ensureHost.pipe(
168 Effect.andThen(body),
169 Effect.ensuring(Effect.ignore(deleteHost)),
170 );
171};
172
173test.provider.skipIf(!runLifecycle)(
174 "create, update, and delete a zonal node pool",
175 (stack) =>
176 withGkeClusterSlot(
177 Effect.gen(function* () {
178 yield* stack.destroy();
179 const { project } = yield* GcpEnvironment.current;
180 const hostId = HOST_CLUSTER_ID;
181 const hostZone = HOST_ZONE;
182 yield* withHostCluster(
183 project,
184 Effect.gen(function* () {
185 const created = yield* stack.deploy(
186 Effect.gen(function* () {
187 const pool = yield* GCP.Container.ClustersNodePool("Workers", {
188 cluster: hostId,
189 zone: hostZone,
190 nodeCount: POOL_NODE_COUNT,
191 machineType: "e2-medium",
192 diskSizeGb: 20,
193 spot: true,
194 management: { autoRepair: false, autoUpgrade: true },
195 labels: { env: "test" },
196 });
197 return { pool };
198 }),
199 );
200
201 expect(created.pool.name).toContain("/nodePools/");
202 expect(created.pool.nodePoolId).toEqual(expect.any(String));
203 expect(created.pool.clusterId).toEqual(hostId);
204 expect(created.pool.zone).toEqual(hostZone);
205 expect(created.pool.labels).toMatchObject({ env: "test" });
206 expect(created.pool.spot).toEqual(true);
207 expect(created.pool.nodeCount).toEqual(POOL_NODE_COUNT);
208 expect(["RUNNING", "RUNNING_WITH_ERROR"]).toContain(
209 created.pool.status,
210 );
211
212 const fetched = yield* container.getProjectsZonesClustersNodePools({
213 projectId: project,
214 zone: hostZone,
215 clusterId: hostId,
216 nodePoolId: created.pool.nodePoolId,
217 });
218 expect(fetched.name).toEqual(created.pool.nodePoolId);
219 expect(fetched.config?.resourceLabels?.env).toEqual("test");
220 expect(fetched.config?.spot).toEqual(true);
221
222 const updated = yield* stack.deploy(
223 Effect.gen(function* () {
224 return yield* GCP.Container.ClustersNodePool("Workers", {
225 cluster: hostId,
226 zone: hostZone,
227 nodePoolId: created.pool.nodePoolId,
228 nodeCount: POOL_NODE_COUNT,
229 machineType: "e2-medium",
230 diskSizeGb: 20,
231 spot: true,
232 management: { autoRepair: true, autoUpgrade: true },
233 labels: { env: "prod", role: "workers" },
234 });
235 }),
236 );
237
238 expect(updated.name).toEqual(created.pool.name);
239 expect(updated.labels).toMatchObject({
240 env: "prod",
241 role: "workers",
242 });
243 expect(updated.management?.autoRepair).toEqual(true);
244
245 const refetched =
246 yield* container.getProjectsZonesClustersNodePools({
247 projectId: project,
248 zone: hostZone,
249 clusterId: hostId,
250 nodePoolId: created.pool.nodePoolId,
251 });
252 expect(refetched.config?.resourceLabels?.env).toEqual("prod");
253 expect(refetched.config?.resourceLabels?.role).toEqual("workers");
254 expect(refetched.management?.autoRepair).toEqual(true);
255
256 yield* stack.destroy();
257
258 const gone = yield* waitUntilGone(
259 project,
260 hostZone,
261 hostId,
262 created.pool.nodePoolId,
263 );
264 expect(gone).toEqual("gone");
265 }),
266 );
267 }),
268 ).pipe(logLevel),
269 // Host create ~6 min, pool create/update ~5 min, host delete ~5 min.
270 {
271 tags: ["provider:gcp", "provider:gcp:container", "live"],
272 timeout: 1_800_000,
273 retry: 0,
274 },
275);