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";
9import { DEFAULT_NETWORK, defaultNetworkSelfLink } from "../networkQuota.ts";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const region = "us-central1";
19const healthCheckName = "alchemy-sa-probe";
20const backendName = "alchemy-sa-ilb";
21
22const waitUntilGone = (serviceAttachmentName: string, projectId: string) =>
23 compute
24 .getServiceAttachments({
25 project: projectId,
26 region,
27 serviceAttachment: serviceAttachmentName,
28 })
29 .pipe(
30 Effect.as("found" as const),
31 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
32 Effect.repeat({
33 schedule: Schedule.spaced("2 seconds"),
34 until: (status) => status === "gone",
35 times: 10,
36 }),
37 );
38
39const waitRegionOp = (operation: compute.Operation) => {
40 if (operation.status === "DONE") return Effect.succeed(operation);
41 const name = (operation.name ?? "").split("/").pop() ?? "";
42 return GcpEnvironment.current.pipe(
43 Effect.flatMap(({ project }) =>
44 compute.getRegionOperations({ project, region, operation: name }).pipe(
45 Effect.repeat({
46 schedule: Schedule.spaced("2 seconds"),
47 until: (op) => op.status === "DONE",
48 times: 20,
49 }),
50 ),
51 ),
52 );
53};
54
55const waitGlobalOp = (operation: compute.Operation) => {
56 if (operation.status === "DONE") return Effect.succeed(operation);
57 const name = (operation.name ?? "").split("/").pop() ?? "";
58 return GcpEnvironment.current.pipe(
59 Effect.flatMap(({ project }) =>
60 compute.getGlobalOperations({ project, operation: name }).pipe(
61 Effect.repeat({
62 schedule: Schedule.spaced("2 seconds"),
63 until: (op) => op.status === "DONE",
64 times: 20,
65 }),
66 ),
67 ),
68 );
69};
70
71const ensureProducer = () =>
72 Effect.gen(function* () {
73 const { project } = yield* GcpEnvironment.current;
74 const existingCheck = yield* compute
75 .getHealthChecks({ project, healthCheck: healthCheckName })
76 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
77 const healthCheck =
78 existingCheck ??
79 (yield* compute
80 .insertHealthChecks({
81 project,
82 body: {
83 name: healthCheckName,
84 type: "TCP",
85 tcpHealthCheck: { port: 80 },
86 checkIntervalSec: 5,
87 timeoutSec: 5,
88 },
89 })
90 .pipe(
91 Effect.flatMap(waitGlobalOp),
92 Effect.catchTag("Conflict", () => Effect.void),
93 )
94 .pipe(
95 Effect.flatMap(() =>
96 compute.getHealthChecks({ project, healthCheck: healthCheckName }),
97 ),
98 ));
99
100 const existingBackend = yield* compute
101 .getRegionBackendServices({
102 project,
103 region,
104 backendService: backendName,
105 })
106 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
107 const backend =
108 existingBackend ??
109 (yield* compute
110 .insertRegionBackendServices({
111 project,
112 region,
113 body: {
114 name: backendName,
115 protocol: "TCP",
116 loadBalancingScheme: "INTERNAL",
117 healthChecks: healthCheck.selfLink ? [healthCheck.selfLink] : [],
118 },
119 })
120 .pipe(
121 Effect.flatMap(waitRegionOp),
122 Effect.catchTag("Conflict", () => Effect.void),
123 )
124 .pipe(
125 Effect.flatMap(() =>
126 compute.getRegionBackendServices({
127 project,
128 region,
129 backendService: backendName,
130 }),
131 ),
132 ));
133
134 return { healthCheck, backend };
135 });
136
137const deleteProducer = () =>
138 GcpEnvironment.current.pipe(
139 Effect.flatMap(({ project }) =>
140 compute
141 .deleteRegionBackendServices({
142 project,
143 region,
144 backendService: backendName,
145 })
146 .pipe(
147 Effect.flatMap(waitRegionOp),
148 Effect.catchTag("NotFound", () => Effect.void),
149 Effect.catchTag("Conflict", () => Effect.void),
150 )
151 .pipe(
152 Effect.flatMap(() =>
153 compute
154 .deleteHealthChecks({ project, healthCheck: healthCheckName })
155 .pipe(
156 Effect.flatMap(waitGlobalOp),
157 Effect.catchTag("NotFound", () => Effect.void),
158 Effect.catchTag("Conflict", () => Effect.void),
159 ),
160 ),
161 ),
162 ),
163 );
164
165test.provider(
166 "create, update, and delete a service attachment",
167 (stack) =>
168 Effect.gen(function* () {
169 const { project } = yield* GcpEnvironment.current;
170 yield* stack.destroy();
171 const producer = yield* ensureProducer();
172 expect(producer.backend.selfLink).toEqual(expect.any(String));
173
174 const created = yield* stack.deploy(
175 Effect.gen(function* () {
176 const subnet = yield* GCP.Compute.Subnetwork("ProducerSubnet", {
177 network: DEFAULT_NETWORK,
178 region,
179 ipCidrRange: "172.20.2.0/24",
180 });
181 const nat = yield* GCP.Compute.Subnetwork("NatSubnet", {
182 network: DEFAULT_NETWORK,
183 region,
184 ipCidrRange: "172.20.3.0/24",
185 purpose: "PRIVATE_SERVICE_CONNECT",
186 });
187 const rule = yield* GCP.Compute.ForwardingRule("ProducerFr", {
188 region,
189 loadBalancingScheme: "INTERNAL",
190 backendService: producer.backend.selfLink,
191 network: defaultNetworkSelfLink(project),
192 subnetwork: subnet.selfLink,
193 ipProtocol: "TCP",
194 allPorts: true,
195 });
196 const attachment = yield* GCP.Compute.ServiceAttachment("Producer", {
197 region,
198 targetService: rule.selfLink.as<string>(),
199 natSubnets: [nat.selfLink.as<string>()],
200 connectionPreference: "ACCEPT_AUTOMATIC",
201 enableProxyProtocol: false,
202 description: "psc producer",
203 labels: { env: "test" },
204 });
205 return { subnet, nat, rule, attachment };
206 }),
207 );
208
209 expect(created.attachment.serviceAttachmentName).toEqual(
210 expect.any(String),
211 );
212 expect(created.attachment.region).toEqual(region);
213 expect(created.attachment.connectionPreference).toEqual(
214 "ACCEPT_AUTOMATIC",
215 );
216 expect(created.attachment.enableProxyProtocol).toEqual(false);
217 expect(created.attachment.description).toEqual("psc producer");
218 expect(created.attachment.labels).toMatchObject({ env: "test" });
219 expect(created.attachment.selfLink).toEqual(expect.any(String));
220 expect(created.attachment.targetService).toEqual(
221 expect.stringContaining(created.rule.forwardingRuleName),
222 );
223 expect(created.attachment.natSubnets.length).toBeGreaterThan(0);
224
225 const fetched = yield* compute.getServiceAttachments({
226 project: created.attachment.project,
227 region: created.attachment.region,
228 serviceAttachment: created.attachment.serviceAttachmentName,
229 });
230 expect(fetched.name).toEqual(created.attachment.serviceAttachmentName);
231 expect(fetched.connectionPreference).toEqual("ACCEPT_AUTOMATIC");
232 expect(fetched.enableProxyProtocol).toEqual(false);
233 expect(fetched.description).toContain("alchemy-id=");
234 expect(fetched.description).toContain("env=test");
235 expect(fetched.description).toContain("psc producer");
236 expect(fetched.targetService).toEqual(
237 expect.stringContaining(created.rule.forwardingRuleName),
238 );
239
240 const updated = yield* stack.deploy(
241 Effect.gen(function* () {
242 const subnet = yield* GCP.Compute.Subnetwork("ProducerSubnet", {
243 subnetworkName: created.subnet.subnetworkName,
244 network: DEFAULT_NETWORK,
245 region,
246 ipCidrRange: "172.20.2.0/24",
247 });
248 const nat = yield* GCP.Compute.Subnetwork("NatSubnet", {
249 subnetworkName: created.nat.subnetworkName,
250 network: DEFAULT_NETWORK,
251 region,
252 ipCidrRange: "172.20.3.0/24",
253 purpose: "PRIVATE_SERVICE_CONNECT",
254 });
255 const rule = yield* GCP.Compute.ForwardingRule("ProducerFr", {
256 forwardingRuleName: created.rule.forwardingRuleName,
257 region,
258 loadBalancingScheme: "INTERNAL",
259 backendService: producer.backend.selfLink,
260 network: defaultNetworkSelfLink(project),
261 subnetwork: subnet.selfLink,
262 ipProtocol: "TCP",
263 allPorts: true,
264 });
265 return yield* GCP.Compute.ServiceAttachment("Producer", {
266 serviceAttachmentName: created.attachment.serviceAttachmentName,
267 region,
268 targetService: rule.selfLink.as<string>(),
269 natSubnets: [nat.selfLink.as<string>()],
270 connectionPreference: "ACCEPT_MANUAL",
271 enableProxyProtocol: false,
272 consumerAcceptLists: [
273 { projectIdOrNum: project, connectionLimit: 10 },
274 ],
275 reconcileConnections: true,
276 description: "psc producer updated",
277 labels: { env: "prod", role: "psc" },
278 });
279 }),
280 );
281
282 expect(updated.serviceAttachmentName).toEqual(
283 created.attachment.serviceAttachmentName,
284 );
285 expect(updated.connectionPreference).toEqual("ACCEPT_MANUAL");
286 expect(updated.description).toEqual("psc producer updated");
287 expect(updated.labels).toMatchObject({ env: "prod", role: "psc" });
288 expect(updated.reconcileConnections).toEqual(true);
289 expect(
290 updated.consumerAcceptLists.some(
291 (item) =>
292 item.projectIdOrNum === project && item.connectionLimit === 10,
293 ),
294 ).toEqual(true);
295
296 const fetchedUpdated = yield* compute.getServiceAttachments({
297 project: updated.project,
298 region: updated.region,
299 serviceAttachment: updated.serviceAttachmentName,
300 });
301 expect(fetchedUpdated.connectionPreference).toEqual("ACCEPT_MANUAL");
302 expect(fetchedUpdated.description).toContain("psc producer updated");
303 expect(fetchedUpdated.description).toContain("env=prod");
304 expect(
305 fetchedUpdated.consumerAcceptLists?.some(
306 (item) =>
307 item.projectIdOrNum === project && item.connectionLimit === 10,
308 ),
309 ).toEqual(true);
310
311 yield* stack.destroy();
312
313 const gone = yield* waitUntilGone(
314 created.attachment.serviceAttachmentName,
315 created.attachment.project,
316 );
317 expect(gone).toEqual("gone");
318 }).pipe(logLevel, Effect.ensuring(deleteProducer().pipe(Effect.ignore))),
319 { tags: ["provider:gcp", "provider:gcp:compute", "live"], timeout: 360_000 },
320);