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-np-host";
27const HOST_LOCATION = CAPACITY_ZONE;
28
29const waitUntilGone = (name: string) =>
30 container.getProjectsLocationsClustersNodePools({ name }).pipe(
31 Effect.as("found" as const),
32 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
33 Effect.repeat({
34 schedule: Schedule.spaced("3 seconds"),
35 until: (status) => status === "gone",
36 times: 10,
37 }),
38 );
39
40// GKE cluster create/delete takes 5–10 minutes.
41const waitClusterOp = (project: string, operation: container.Operation) => {
42 const raw = operation.name ?? "";
43 const fromLink = operation.selfLink ?? "";
44 const name = raw.includes("/operations/")
45 ? raw.slice(Math.max(0, raw.indexOf("projects/")))
46 : fromLink.includes("/operations/")
47 ? fromLink.slice(Math.max(0, fromLink.indexOf("projects/")))
48 : `projects/${project}/locations/${HOST_LOCATION}/operations/${raw}`;
49 return waitForOperation(
50 { ...operation, name },
51 (operationName) =>
52 container.getProjectsLocationsOperations({ name: operationName }),
53 { budget: "20 minutes", interval: "10 seconds" },
54 );
55};
56
57test.provider(
58 "lists clusters and treats a missing node pool as NotFound",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 const { project } = yield* GcpEnvironment.current;
64 const page = yield* container.listProjectsLocationsClusters({
65 parent: `projects/${project}/locations/-`,
66 });
67 expect(
68 (page.clusters ?? []).map((cluster) => cluster.name),
69 ).not.toContain("alchemy-missing-cluster");
70
71 const missing = yield* container
72 .getProjectsLocationsClustersNodePools({
73 name: `projects/${project}/locations/${HOST_LOCATION}/clusters/alchemy-missing-cluster/nodePools/alchemy-missing-pool`,
74 })
75 .pipe(
76 Effect.as("found" as const),
77 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
78 );
79 expect(missing).toEqual("gone");
80
81 yield* stack.destroy();
82 }).pipe(logLevel),
83 { tags: ["provider:gcp", "provider:gcp:container", "live"], timeout: 90_000 },
84);
85
86/**
87 * A throwaway host cluster without Workload Identity (the node pool must
88 * not request GKE_METADATA on it). Owned by this test: a leftover from an
89 * interrupted run is reused if RUNNING, replaced otherwise, and always
90 * deleted when the test ends.
91 */
92const withHostCluster = <A, E, R>(
93 project: string,
94 body: (host: {
95 clusterId: string;
96 location: string;
97 }) => Effect.Effect<A, E, R>,
98) => {
99 const clusterName = `projects/${project}/locations/${HOST_LOCATION}/clusters/${HOST_CLUSTER_ID}`;
100 const deleteHost = container
101 .deleteProjectsLocationsClusters({ name: clusterName })
102 .pipe(
103 Effect.flatMap((operation) => waitClusterOp(project, operation)),
104 Effect.catchTag("NotFound", () => Effect.void),
105 );
106 const ensureHost = Effect.gen(function* () {
107 const existing = yield* container
108 .getProjectsLocationsClusters({ name: clusterName })
109 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
110 if (existing?.status === "RUNNING") return;
111 if (existing !== undefined) yield* deleteHost;
112 const created = yield* container.createProjectsLocationsClusters({
113 parent: `projects/${project}/locations/${HOST_LOCATION}`,
114 body: {
115 cluster: {
116 name: HOST_CLUSTER_ID,
117 ipAllocationPolicy: { useIpAliases: true },
118 nodePools: [
119 {
120 name: "default-pool",
121 initialNodeCount: 1,
122 config: {
123 machineType: "e2-medium",
124 diskSizeGb: 20,
125 diskType: "pd-standard",
126 spot: true,
127 },
128 },
129 ],
130 },
131 },
132 });
133 yield* waitClusterOp(project, created);
134 });
135 return ensureHost.pipe(
136 Effect.andThen(
137 body({ clusterId: HOST_CLUSTER_ID, location: HOST_LOCATION }),
138 ),
139 Effect.ensuring(Effect.ignore(deleteHost)),
140 );
141};
142
143test.provider.skipIf(!runLifecycle)(
144 "create, update, and delete a node pool",
145 (stack) =>
146 withGkeClusterSlot(
147 Effect.gen(function* () {
148 yield* stack.destroy();
149 const { project } = yield* GcpEnvironment.current;
150 yield* withHostCluster(
151 project,
152 ({ clusterId: hostId, location: hostLocation }) =>
153 Effect.gen(function* () {
154 const created = yield* stack.deploy(
155 Effect.gen(function* () {
156 return yield* GCP.Container.NodePool("Workers", {
157 cluster: hostId,
158 location: hostLocation,
159 nodeCount: POOL_NODE_COUNT,
160 machineType: "e2-medium",
161 diskSizeGb: 20,
162 spot: true,
163 management: { autoRepair: false, autoUpgrade: true },
164 labels: { env: "test" },
165 });
166 }),
167 );
168
169 expect(created.name).toContain("/nodePools/");
170 expect(created.nodePoolId).toEqual(expect.any(String));
171 expect(created.clusterId).toEqual(hostId);
172 expect(created.location).toEqual(hostLocation);
173 expect(created.labels).toMatchObject({ env: "test" });
174 expect(created.spot).toEqual(true);
175 expect(created.nodeCount).toEqual(POOL_NODE_COUNT);
176 expect(["RUNNING", "RUNNING_WITH_ERROR"]).toContain(
177 created.status,
178 );
179
180 const fetched =
181 yield* container.getProjectsLocationsClustersNodePools({
182 name: created.name,
183 });
184 expect(fetched.name).toEqual(created.nodePoolId);
185 expect(fetched.config?.resourceLabels?.env).toEqual("test");
186 expect(fetched.config?.spot).toEqual(true);
187
188 const updated = yield* stack.deploy(
189 Effect.gen(function* () {
190 return yield* GCP.Container.NodePool("Workers", {
191 cluster: hostId,
192 location: hostLocation,
193 nodePoolId: created.nodePoolId,
194 nodeCount: POOL_NODE_COUNT,
195 machineType: "e2-medium",
196 diskSizeGb: 20,
197 spot: true,
198 management: { autoRepair: true, autoUpgrade: true },
199 labels: { env: "prod", role: "workers" },
200 });
201 }),
202 );
203
204 expect(updated.name).toEqual(created.name);
205 expect(updated.labels).toMatchObject({
206 env: "prod",
207 role: "workers",
208 });
209 expect(updated.management?.autoRepair).toEqual(true);
210
211 const refetched =
212 yield* container.getProjectsLocationsClustersNodePools({
213 name: created.name,
214 });
215 expect(refetched.config?.resourceLabels?.env).toEqual("prod");
216 expect(refetched.config?.resourceLabels?.role).toEqual("workers");
217 expect(refetched.management?.autoRepair).toEqual(true);
218
219 yield* stack.destroy();
220
221 const gone = yield* waitUntilGone(created.name);
222 expect(gone).toEqual("gone");
223 }),
224 );
225 }),
226 ).pipe(logLevel),
227 // Host create ~6 min, pool create/update ~5 min, host delete ~5 min.
228 {
229 tags: ["provider:gcp", "provider:gcp:container", "live"],
230 timeout: 1_800_000,
231 retry: 0,
232 },
233);