1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as compute from "@distilled.cloud/gcp/compute_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";
9
10const { test } = Test.make({ providers: GCP.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const region = "us-central1";
18const zone = "us-central1-a";
19const names = {
20 healthCheck: "alchemy-pm-hc",
21 instanceGroup: "alchemy-pm-ig",
22 backend: "alchemy-pm-bs",
23 forwardingRule: "alchemy-pm-fr",
24};
25
26const lastSegment = (value: string | undefined) => {
27 if (value === undefined || value.length === 0) return "";
28 const parts = value.split("/").filter((part) => part.length > 0);
29 return parts[parts.length - 1] ?? "";
30};
31
32const waitUntilGone = (packetMirroringName: string) =>
33 GcpEnvironment.current.pipe(
34 Effect.flatMap(({ project }) =>
35 compute
36 .getPacketMirrorings({
37 project,
38 region,
39 packetMirroring: packetMirroringName,
40 })
41 .pipe(
42 Effect.as("found" as const),
43 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
44 Effect.repeat({
45 schedule: Schedule.spaced("2 seconds"),
46 until: (status) => status === "gone",
47 times: 10,
48 }),
49 ),
50 ),
51 );
52
53const waitOp = (
54 operation: compute.Operation,
55 scope: "global" | "region" | "zone",
56) => {
57 if (operation.status === "DONE") return Effect.succeed(operation);
58 const name = lastSegment(operation.name);
59 if (name.length === 0) return Effect.succeed(operation);
60 return GcpEnvironment.current.pipe(
61 Effect.flatMap(({ project }) => {
62 const poll =
63 scope === "global"
64 ? compute.getGlobalOperations({ project, operation: name })
65 : scope === "zone"
66 ? compute.getZoneOperations({ project, zone, operation: name })
67 : compute.getRegionOperations({ project, region, operation: name });
68 return poll.pipe(
69 Effect.catchTag("NotFound", () =>
70 Effect.succeed({ status: "DONE" } as compute.Operation),
71 ),
72 Effect.repeat({
73 schedule: Schedule.spaced("2 seconds"),
74 until: (op) => op.status === "DONE",
75 times: 8,
76 }),
77 );
78 }),
79 );
80};
81
82const ensureCollector = () =>
83 Effect.gen(function* () {
84 const { project } = yield* GcpEnvironment.current;
85 const network = yield* compute.getNetworks({
86 project,
87 network: "default",
88 });
89 const subnet = yield* compute.getSubnetworks({
90 project,
91 region,
92 subnetwork: "default",
93 });
94
95 const healthCheck = yield* compute
96 .getHealthChecks({ project, healthCheck: names.healthCheck })
97 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
98 if (healthCheck === undefined) {
99 yield* compute
100 .insertHealthChecks({
101 project,
102 body: {
103 name: names.healthCheck,
104 type: "TCP",
105 tcpHealthCheck: { port: 80 },
106 description: "alchemy packet-mirroring collector",
107 },
108 })
109 .pipe(
110 Effect.flatMap((operation) => waitOp(operation, "global")),
111 Effect.catchTag("Conflict", () => Effect.void),
112 );
113 }
114
115 const instanceGroup = yield* compute
116 .getInstanceGroups({
117 project,
118 zone,
119 instanceGroup: names.instanceGroup,
120 })
121 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
122 if (instanceGroup === undefined) {
123 yield* compute
124 .insertInstanceGroups({
125 project,
126 zone,
127 body: {
128 name: names.instanceGroup,
129 network: network.selfLink,
130 description: "alchemy packet-mirroring collector",
131 },
132 })
133 .pipe(
134 Effect.flatMap((operation) => waitOp(operation, "zone")),
135 Effect.catchTag("Conflict", () => Effect.void),
136 );
137 }
138
139 const backend = yield* compute
140 .getRegionBackendServices({
141 project,
142 region,
143 backendService: names.backend,
144 })
145 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
146 if (backend === undefined) {
147 const hc = yield* compute.getHealthChecks({
148 project,
149 healthCheck: names.healthCheck,
150 });
151 const group = yield* compute.getInstanceGroups({
152 project,
153 zone,
154 instanceGroup: names.instanceGroup,
155 });
156 yield* compute
157 .insertRegionBackendServices({
158 project,
159 region,
160 body: {
161 name: names.backend,
162 protocol: "TCP",
163 loadBalancingScheme: "INTERNAL",
164 healthChecks: hc.selfLink ? [hc.selfLink] : undefined,
165 backends: group.selfLink ? [{ group: group.selfLink }] : undefined,
166 network: network.selfLink,
167 description: "alchemy packet-mirroring collector",
168 },
169 })
170 .pipe(
171 Effect.flatMap((operation) => waitOp(operation, "region")),
172 Effect.catchTag("Conflict", () => Effect.void),
173 );
174 }
175
176 const rule = yield* compute
177 .getForwardingRules({
178 project,
179 region,
180 forwardingRule: names.forwardingRule,
181 })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183 if (rule === undefined) {
184 const backendService = yield* compute.getRegionBackendServices({
185 project,
186 region,
187 backendService: names.backend,
188 });
189 yield* compute
190 .insertForwardingRules({
191 project,
192 region,
193 body: {
194 name: names.forwardingRule,
195 loadBalancingScheme: "INTERNAL",
196 isMirroringCollector: true,
197 IPProtocol: "TCP",
198 allPorts: true,
199 backendService: backendService.selfLink,
200 network: network.selfLink,
201 subnetwork: subnet.selfLink,
202 description: "alchemy packet-mirroring collector",
203 },
204 })
205 .pipe(
206 Effect.flatMap((operation) => waitOp(operation, "region")),
207 Effect.catchTag("Conflict", () => Effect.void),
208 );
209 }
210
211 const forwardingRule = yield* compute.getForwardingRules({
212 project,
213 region,
214 forwardingRule: names.forwardingRule,
215 });
216 return {
217 network:
218 network.selfLink ?? `projects/${project}/global/networks/default`,
219 forwardingRule:
220 forwardingRule.selfLink ??
221 `projects/${project}/regions/${region}/forwardingRules/${names.forwardingRule}`,
222 };
223 });
224
225const deleteCollector = () =>
226 Effect.gen(function* () {
227 const { project } = yield* GcpEnvironment.current;
228 yield* compute
229 .deleteForwardingRules({
230 project,
231 region,
232 forwardingRule: names.forwardingRule,
233 })
234 .pipe(
235 Effect.flatMap((operation) => waitOp(operation, "region")),
236 Effect.catchTag("NotFound", () => Effect.void),
237 Effect.catchTag("Conflict", () => Effect.void),
238 );
239 yield* compute
240 .deleteRegionBackendServices({
241 project,
242 region,
243 backendService: names.backend,
244 })
245 .pipe(
246 Effect.flatMap((operation) => waitOp(operation, "region")),
247 Effect.catchTag("NotFound", () => Effect.void),
248 Effect.catchTag("Conflict", () => Effect.void),
249 );
250 yield* compute
251 .deleteInstanceGroups({
252 project,
253 zone,
254 instanceGroup: names.instanceGroup,
255 })
256 .pipe(
257 Effect.flatMap((operation) => waitOp(operation, "zone")),
258 Effect.catchTag("NotFound", () => Effect.void),
259 Effect.catchTag("Conflict", () => Effect.void),
260 );
261 yield* compute
262 .deleteHealthChecks({ project, healthCheck: names.healthCheck })
263 .pipe(
264 Effect.flatMap((operation) => waitOp(operation, "global")),
265 Effect.catchTag("NotFound", () => Effect.void),
266 Effect.catchTag("Conflict", () => Effect.void),
267 );
268 });
269
270test.provider(
271 "create, update, replace, and delete a packet mirroring policy",
272 (stack) =>
273 Effect.gen(function* () {
274 yield* stack.destroy();
275 const collector = yield* ensureCollector();
276 expect(collector.forwardingRule).toEqual(expect.any(String));
277
278 const created = yield* stack.deploy(
279 Effect.gen(function* () {
280 return yield* GCP.Compute.PacketMirroring("Capture", {
281 region,
282 description: "test capture",
283 network: collector.network,
284 collectorIlb: collector.forwardingRule,
285 mirroredResources: { tags: ["alchemy-pm"] },
286 });
287 }),
288 );
289
290 expect(created.packetMirroringName).toEqual(expect.any(String));
291 expect(created.region).toEqual(region);
292 expect(created.description).toEqual("test capture");
293 expect(created.enable).toEqual(true);
294 expect(created.priority).toEqual(1000);
295 expect(created.network).toEqual(expect.stringContaining("networks/"));
296 expect(created.collectorIlb).toEqual(
297 expect.stringContaining(names.forwardingRule),
298 );
299 expect(created.mirroredResources.tags).toEqual(["alchemy-pm"]);
300
301 const fetched = yield* compute.getPacketMirrorings({
302 project: created.project,
303 region: created.region,
304 packetMirroring: created.packetMirroringName,
305 });
306 expect(fetched.name).toEqual(created.packetMirroringName);
307 expect(fetched.description).toContain("[alchemy ");
308 expect(fetched.description).toContain("test capture");
309 expect(fetched.enable).toEqual("TRUE");
310 expect(fetched.mirroredResources?.tags).toEqual(["alchemy-pm"]);
311 expect(fetched.collectorIlb?.url).toEqual(
312 expect.stringContaining(names.forwardingRule),
313 );
314
315 const updated = yield* stack.deploy(
316 Effect.gen(function* () {
317 return yield* GCP.Compute.PacketMirroring("Capture", {
318 packetMirroringName: created.packetMirroringName,
319 region,
320 description: "test capture",
321 network: collector.network,
322 collectorIlb: collector.forwardingRule,
323 mirroredResources: { tags: ["alchemy-pm", "alchemy-pm-web"] },
324 enable: false,
325 priority: 800,
326 filter: {
327 direction: "INGRESS",
328 ipProtocols: ["tcp"],
329 cidrRanges: ["10.0.0.0/8"],
330 },
331 });
332 }),
333 );
334
335 expect(updated.packetMirroringName).toEqual(created.packetMirroringName);
336 expect(updated.packetMirroringId).toEqual(created.packetMirroringId);
337 expect(updated.enable).toEqual(false);
338 expect(updated.priority).toEqual(800);
339 expect([...updated.mirroredResources.tags].sort()).toEqual(
340 ["alchemy-pm", "alchemy-pm-web"].sort(),
341 );
342 expect(updated.filter?.direction).toEqual("INGRESS");
343 expect(updated.filter?.ipProtocols).toEqual(["tcp"]);
344 expect(updated.filter?.cidrRanges).toEqual(["10.0.0.0/8"]);
345
346 const fetchedUpdate = yield* compute.getPacketMirrorings({
347 project: updated.project,
348 region: updated.region,
349 packetMirroring: updated.packetMirroringName,
350 });
351 expect(fetchedUpdate.enable).toEqual("FALSE");
352 expect(fetchedUpdate.priority).toEqual(800);
353 expect(fetchedUpdate.filter?.direction).toEqual("INGRESS");
354
355 const replaced = yield* stack.deploy(
356 Effect.gen(function* () {
357 return yield* GCP.Compute.PacketMirroring("Capture", {
358 packetMirroringName: created.packetMirroringName,
359 region,
360 description: "replaced capture",
361 network: collector.network,
362 collectorIlb: collector.forwardingRule,
363 mirroredResources: { tags: ["alchemy-pm"] },
364 });
365 }),
366 );
367
368 expect(replaced.packetMirroringName).toEqual(created.packetMirroringName);
369 expect(replaced.description).toEqual("replaced capture");
370 expect(replaced.enable).toEqual(true);
371 expect(replaced.packetMirroringId).not.toEqual(created.packetMirroringId);
372
373 const fetchedReplace = yield* compute.getPacketMirrorings({
374 project: replaced.project,
375 region: replaced.region,
376 packetMirroring: replaced.packetMirroringName,
377 });
378 expect(fetchedReplace.description).toContain("replaced capture");
379 expect(fetchedReplace.enable).toEqual("TRUE");
380
381 yield* stack.destroy();
382
383 const gone = yield* waitUntilGone(created.packetMirroringName);
384 expect(gone).toEqual("gone");
385 }).pipe(logLevel, Effect.ensuring(deleteCollector().pipe(Effect.ignore))),
386 { tags: ["provider:gcp", "provider:gcp:compute", "live"], timeout: 180_000 },
387);