1import * as networkmanagement from "@distilled.cloud/gcp/networkmanagement_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_GLOBAL,
19 MAX_CONNECTIVITY_TEST_ID_LENGTH,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 resourceName as qualifiedName,
27 rfc1035,
28 sameJson,
29 sameStringList,
30 toNetworkResource,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilGone,
35 waitUntilPresent,
36} from "./internal.ts";
37
38const COLLECTION = "connectivityTests";
39const DEFAULT_PROTOCOL = "TCP";
40
41export type ConnectivityTestNetworkType =
42 | networkmanagement.EndpointNetworkTypeEnum
43 | (string & {});
44
45export type ConnectivityTestEndpoint = {
46 /** IP address of the endpoint (internal or external). */
47 ipAddress?: string;
48 /** TCP or UDP port. Ignored for other protocols. */
49 port?: number;
50 /** Compute Engine instance URI. */
51 instance?: string;
52 /**
53 * Forwarding rule URI. Destination only. Format
54 * `projects/{project}/global/forwardingRules/{id}` or
55 * `projects/{project}/regions/{region}/forwardingRules/{id}`.
56 */
57 forwardingRule?: string;
58 /** GKE control-plane cluster URI. */
59 gkeMasterCluster?: string;
60 /**
61 * GKE control-plane DNS endpoint. Requires `gkeMasterCluster`.
62 * Destination only; cannot be combined with `ipAddress` or `network`.
63 */
64 fqdn?: string;
65 /** Cloud SQL instance URI. */
66 cloudSqlInstance?: string;
67 /** Memorystore Redis instance URI. Destination only. */
68 redisInstance?: string;
69 /** Memorystore Redis cluster URI. Destination only. */
70 redisCluster?: string;
71 /** GKE Pod URI. */
72 gkePod?: string;
73 /**
74 * Database Migration Service private connection. Format
75 * `projects/{project}/locations/{location}/privateConnections/{privateConnection}`.
76 */
77 dmsPrivateConnection?: string;
78 /** Cloud Function. Source only. */
79 cloudFunction?: { uri?: string };
80 /** App Engine service version. Source only. */
81 appEngineVersion?: { uri?: string };
82 /** Cloud Run revision. Source only. */
83 cloudRunRevision?: { uri?: string };
84 /**
85 * Cloud Run job URI. Source only. Format
86 * `projects/{project}/locations/{location}/jobs/{job}`.
87 */
88 cloudRunJob?: string;
89 /**
90 * VPC network URI. For sources, used with `networkType`. For
91 * destinations, used when the source is external and the destination
92 * is internal.
93 */
94 network?: string;
95 /** Type of network where the source endpoint lives. Source only. */
96 networkType?: ConnectivityTestNetworkType;
97 /** Source endpoint project id. Source only. */
98 projectId?: string;
99};
100
101export type ConnectivityTestProps = {
102 /**
103 * Test id (the `{test}` segment of
104 * `projects/{project}/locations/global/connectivityTests/{test}`). If
105 * omitted, a unique RFC1035 name is generated. Must be 1-40 characters,
106 * start with a letter, and end with a letter or number. Immutable —
107 * changing it replaces the test.
108 */
109 testId?: string;
110 /**
111 * Location. Must be `global`. Immutable — changing it replaces the
112 * test.
113 * @default "global"
114 */
115 location?: string;
116 /**
117 * Human-readable description (max 512 characters).
118 */
119 description?: string;
120 /**
121 * Source endpoint. Combine IP, URI, project, and VPC network to
122 * identify the origin.
123 */
124 source: ConnectivityTestEndpoint;
125 /**
126 * Destination endpoint. Combine IP, URI, project, and VPC network to
127 * identify the target.
128 */
129 destination: ConnectivityTestEndpoint;
130 /**
131 * IP protocol. When omitted, `TCP` is assumed.
132 * @default "TCP"
133 */
134 protocol?: string;
135 /**
136 * Other projects that may be relevant for reachability analysis when
137 * the path crosses project boundaries.
138 */
139 relatedProjects?: string[];
140 /**
141 * Also analyze the return path from destination to source.
142 * @default false
143 */
144 roundTrip?: boolean;
145 /**
146 * Skip firewall checking during analysis.
147 * @default false
148 */
149 bypassFirewallChecks?: boolean;
150 /**
151 * User labels. Alchemy ownership labels are merged in automatically.
152 */
153 labels?: Record<string, string>;
154};
155
156export type ConnectivityTest = Resource<
157 "GCP.NetworkManagement.ConnectivityTest",
158 ConnectivityTestProps,
159 {
160 /** Full resource name `projects/{project}/locations/global/connectivityTests/{test}`. */
161 name: string;
162 /** Test id (last path segment). */
163 testId: string;
164 /** Project id. */
165 project: string;
166 /** Location id (`global`). */
167 location: string;
168 /** User-provided description. */
169 description: string | undefined;
170 /** Source endpoint as stored by the API. */
171 source: ConnectivityTestEndpoint | undefined;
172 /** Destination endpoint as stored by the API. */
173 destination: ConnectivityTestEndpoint | undefined;
174 /** IP protocol. */
175 protocol: string | undefined;
176 /** Related projects considered during analysis. */
177 relatedProjects: string[];
178 /** Server-generated display name. */
179 displayName: string | undefined;
180 /** Whether return-path analysis is enabled. */
181 roundTrip: boolean;
182 /** Whether firewall checking is skipped. */
183 bypassFirewallChecks: boolean;
184 /** Latest forward-path reachability result. */
185 reachabilityResult: string | undefined;
186 /** Latest return-path reachability result. */
187 returnReachabilityResult: string | undefined;
188 /** Latest probing result, when probing applied. */
189 probingResult: string | undefined;
190 /** User labels (Alchemy ownership labels stripped). */
191 labels: Record<string, string>;
192 /** RFC3339 creation timestamp. */
193 createTime: string | undefined;
194 /** RFC3339 last-update timestamp. */
195 updateTime: string | undefined;
196 },
197 never,
198 Providers
199>;
200
201/**
202 * A Network Management Connectivity Test (reachability analysis).
203 *
204 * Changing `testId` or `location` replaces the test. Endpoints,
205 * protocol, related projects, round-trip, firewall bypass, description,
206 * and labels update in place. Create runs analysis as part of the
207 * long-running operation.
208 *
209 * ### Creating a ConnectivityTest
210 * **Example:** IP-to-IP TCP test
211 * ```typescript
212 * const test = yield* GCP.NetworkManagement.ConnectivityTest("DnsPath", {
213 * source: {
214 * ipAddress: "10.0.0.1",
215 * networkType: "GCP_NETWORK",
216 * network: "projects/my-project/global/networks/app-vpc",
217 * },
218 * destination: { ipAddress: "10.0.0.2", port: 443 },
219 * protocol: "TCP",
220 * labels: { env: "prod" },
221 * });
222 * ```
223 *
224 * **Example:** Named test with round trip
225 * ```typescript
226 * const test = yield* GCP.NetworkManagement.ConnectivityTest("DnsPath", {
227 * testId: "app-dns-path",
228 * source: { ipAddress: "8.8.8.8", networkType: "NON_GCP_NETWORK" },
229 * destination: { ipAddress: "1.1.1.1", port: 443 },
230 * protocol: "TCP",
231 * roundTrip: true,
232 * description: "public dns path",
233 * });
234 * ```
235 *
236 * @resource
237 * @category NetworkManagement
238 */
239export const ConnectivityTest = Resource<ConnectivityTest>(
240 "GCP.NetworkManagement.ConnectivityTest",
241);
242
243const resourceName = (project: string, location: string, testId: string) =>
244 qualifiedName(project, location, COLLECTION, testId);
245
246const toEndpoint = (
247 endpoint: ConnectivityTestEndpoint | networkmanagement.Endpoint | undefined,
248 project?: string,
249): ConnectivityTestEndpoint | undefined => {
250 if (endpoint === undefined) return undefined;
251 const next: ConnectivityTestEndpoint = {
252 ipAddress: endpoint.ipAddress,
253 port: endpoint.port,
254 instance: endpoint.instance,
255 forwardingRule: endpoint.forwardingRule,
256 gkeMasterCluster: endpoint.gkeMasterCluster,
257 fqdn: endpoint.fqdn,
258 cloudSqlInstance: endpoint.cloudSqlInstance,
259 redisInstance: endpoint.redisInstance,
260 redisCluster: endpoint.redisCluster,
261 gkePod: endpoint.gkePod,
262 dmsPrivateConnection: endpoint.dmsPrivateConnection,
263 cloudFunction: endpoint.cloudFunction
264 ? { uri: endpoint.cloudFunction.uri }
265 : undefined,
266 appEngineVersion: endpoint.appEngineVersion
267 ? { uri: endpoint.appEngineVersion.uri }
268 : undefined,
269 cloudRunRevision: endpoint.cloudRunRevision
270 ? { uri: endpoint.cloudRunRevision.uri }
271 : undefined,
272 cloudRunJob: endpoint.cloudRunJob,
273 network:
274 endpoint.network !== undefined && project !== undefined
275 ? toNetworkResource(project, endpoint.network)
276 : endpoint.network,
277 networkType: endpoint.networkType,
278 projectId: endpoint.projectId,
279 };
280 return next;
281};
282
283const endpointKey = (
284 endpoint: ConnectivityTestEndpoint | undefined,
285): unknown => {
286 if (endpoint === undefined) return undefined;
287 return {
288 ipAddress: endpoint.ipAddress ?? "",
289 port: endpoint.port ?? 0,
290 instance: endpoint.instance ?? "",
291 forwardingRule: endpoint.forwardingRule ?? "",
292 gkeMasterCluster: endpoint.gkeMasterCluster ?? "",
293 fqdn: endpoint.fqdn ?? "",
294 cloudSqlInstance: endpoint.cloudSqlInstance ?? "",
295 redisInstance: endpoint.redisInstance ?? "",
296 redisCluster: endpoint.redisCluster ?? "",
297 gkePod: endpoint.gkePod ?? "",
298 dmsPrivateConnection: endpoint.dmsPrivateConnection ?? "",
299 cloudFunction: endpoint.cloudFunction?.uri ?? "",
300 appEngineVersion: endpoint.appEngineVersion?.uri ?? "",
301 cloudRunRevision: endpoint.cloudRunRevision?.uri ?? "",
302 cloudRunJob: endpoint.cloudRunJob ?? "",
303 network: endpoint.network ?? "",
304 networkType: (endpoint.networkType ?? "").toUpperCase(),
305 projectId: endpoint.projectId ?? "",
306 };
307};
308
309const toAttrs = (test: networkmanagement.ConnectivityTest, project: string) => {
310 const name = test.name ?? "";
311 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
312 return {
313 name,
314 testId: parsed.id,
315 project: parsed.project || project,
316 location: parsed.location || DEFAULT_GLOBAL,
317 description: test.description,
318 source: toEndpoint(test.source, parsed.project || project),
319 destination: toEndpoint(test.destination, parsed.project || project),
320 protocol: test.protocol,
321 relatedProjects: test.relatedProjects ?? [],
322 displayName: test.displayName,
323 roundTrip: test.roundTrip === true,
324 bypassFirewallChecks: test.bypassFirewallChecks === true,
325 reachabilityResult: test.reachabilityDetails?.result,
326 returnReachabilityResult: test.returnReachabilityDetails?.result,
327 probingResult: test.probingDetails?.result,
328 labels: userLabels(test.labels),
329 createTime: test.createTime,
330 updateTime: test.updateTime,
331 };
332};
333
334const getByName = (name: string) =>
335 networkmanagement
336 .getProjectsLocationsGlobalConnectivityTests({ name })
337 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
338
339export const ConnectivityTestProvider = () =>
340 Provider.succeed(ConnectivityTest, {
341 stables: ["name", "testId", "project", "location", "createTime"],
342
343 diff: Effect.fn(function* ({ news, olds, output }) {
344 if (!isResolved(news)) return undefined;
345 const previousId = olds?.testId ?? output?.testId;
346 const nextId = news.testId
347 ? rfc1035(
348 news.testId,
349 "connectivity-test",
350 MAX_CONNECTIVITY_TEST_ID_LENGTH,
351 )
352 : previousId;
353 const previousLocation = normalizeLocation(
354 olds?.location ?? output?.location,
355 DEFAULT_GLOBAL,
356 );
357 const nextLocation = normalizeLocation(
358 news.location ?? olds?.location ?? output?.location,
359 DEFAULT_GLOBAL,
360 );
361 if (
362 (previousId !== undefined &&
363 nextId !== undefined &&
364 nextId !== previousId) ||
365 previousLocation !== nextLocation
366 ) {
367 return { action: "replace" as const };
368 }
369 return undefined;
370 }),
371
372 read: Effect.fn(function* ({ id, olds, output }) {
373 const env = yield* GcpEnvironment.current;
374 const testId = yield* toPhysicalId(
375 id,
376 olds?.testId,
377 output?.testId,
378 "connectivity-test",
379 MAX_CONNECTIVITY_TEST_ID_LENGTH,
380 );
381 const location = normalizeLocation(
382 olds?.location ?? output?.location,
383 DEFAULT_GLOBAL,
384 );
385 const name = output?.name ?? resourceName(env.project, location, testId);
386 const existing = yield* getByName(name);
387 if (existing === undefined) return undefined;
388 const attrs = toAttrs(existing, env.project);
389 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
390 ? attrs
391 : Unowned(attrs);
392 }),
393
394 list: () =>
395 Effect.gen(function* () {
396 const env = yield* GcpEnvironment.current;
397 const items = yield* collectPages(
398 networkmanagement.listProjectsLocationsGlobalConnectivityTests.pages({
399 parent: parentOf(env.project, DEFAULT_GLOBAL),
400 pageSize: 1000,
401 }),
402 (page) => page.resources,
403 );
404 return items
405 .filter((item) => hasAlchemyLabelKeys(item.labels))
406 .map((item) => toAttrs(item, env.project));
407 }),
408
409 reconcile: Effect.fn(function* ({ id, news, output }) {
410 const env = yield* GcpEnvironment.current;
411 const testId = yield* toPhysicalId(
412 id,
413 news.testId,
414 output?.testId,
415 "connectivity-test",
416 MAX_CONNECTIVITY_TEST_ID_LENGTH,
417 );
418 const location = normalizeLocation(
419 news.location ?? output?.location,
420 DEFAULT_GLOBAL,
421 );
422 const name = resourceName(env.project, location, testId);
423 const desiredLabels = {
424 ...toLabels(news.labels),
425 ...(yield* createInternalLabels(id)),
426 };
427 const source = toEndpoint(news.source, env.project);
428 const destination = toEndpoint(news.destination, env.project);
429 const protocol = news.protocol ?? DEFAULT_PROTOCOL;
430 const relatedProjects = news.relatedProjects ?? [];
431 const roundTrip = news.roundTrip === true;
432 const bypassFirewallChecks = news.bypassFirewallChecks === true;
433
434 let current = yield* getByName(name);
435
436 if (current === undefined) {
437 const created = yield* networkmanagement
438 .createProjectsLocationsGlobalConnectivityTests({
439 parent: parentOf(env.project, location),
440 testId,
441 body: {
442 description: news.description,
443 labels: desiredLabels,
444 source,
445 destination,
446 protocol,
447 relatedProjects:
448 relatedProjects.length > 0 ? relatedProjects : undefined,
449 roundTrip,
450 bypassFirewallChecks,
451 },
452 })
453 .pipe(
454 Effect.retry({
455 while: (error) => error._tag === "Conflict",
456 times: 5,
457 schedule: Schedule.spaced("2 seconds"),
458 }),
459 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
460 );
461 if (created !== undefined) {
462 yield* waitForOperation(created);
463 }
464 current = yield* waitUntilPresent(getByName(name), name);
465 }
466
467 const observedLabels = tagRecord(current.labels);
468 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
469 const labelsChanged = upsert.length > 0 || removed.length > 0;
470 const descriptionChanged =
471 (current.description ?? "") !== (news.description ?? "");
472 const sourceChanged = !sameJson(
473 endpointKey(toEndpoint(current.source, env.project)),
474 endpointKey(source),
475 );
476 const destinationChanged = !sameJson(
477 endpointKey(toEndpoint(current.destination, env.project)),
478 endpointKey(destination),
479 );
480 const protocolChanged =
481 (current.protocol ?? DEFAULT_PROTOCOL).toUpperCase() !==
482 protocol.toUpperCase();
483 const relatedChanged = !sameStringList(
484 current.relatedProjects,
485 relatedProjects,
486 );
487 const roundTripChanged = (current.roundTrip === true) !== roundTrip;
488 const bypassChanged =
489 (current.bypassFirewallChecks === true) !== bypassFirewallChecks;
490
491 const updateMask = changedFields([
492 ["labels", labelsChanged],
493 ["description", descriptionChanged],
494 ["source", sourceChanged],
495 ["destination", destinationChanged],
496 ["protocol", protocolChanged],
497 ["relatedProjects", relatedChanged],
498 ["roundTrip", roundTripChanged],
499 ["bypassFirewallChecks", bypassChanged],
500 ]);
501
502 if (updateMask.length > 0) {
503 const operation =
504 yield* networkmanagement.patchProjectsLocationsGlobalConnectivityTests(
505 {
506 name: current.name ?? name,
507 updateMask: updateMask.join(","),
508 body: {
509 name: current.name ?? name,
510 labels: desiredLabels,
511 description: news.description,
512 source,
513 destination,
514 protocol,
515 relatedProjects,
516 roundTrip,
517 bypassFirewallChecks,
518 },
519 },
520 );
521 yield* waitForOperation(operation);
522 current = yield* waitUntilPresent(
523 getByName(current.name ?? name),
524 current.name ?? name,
525 );
526 }
527
528 return toAttrs(current, env.project);
529 }),
530
531 delete: Effect.fn(function* ({ output }) {
532 const operation = yield* networkmanagement
533 .deleteProjectsLocationsGlobalConnectivityTests({ name: output.name })
534 .pipe(
535 Effect.retry({
536 while: (error) => error._tag === "Conflict",
537 times: 8,
538 schedule: Schedule.spaced("2 seconds"),
539 }),
540 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
541 );
542 if (operation !== undefined) {
543 yield* waitForOperation(operation, { notFoundOk: true });
544 }
545 yield* waitUntilGone(getByName(output.name), output.name);
546 }),
547 });