1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as servicenetworking from "@distilled.cloud/gcp/servicenetworking_v1";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import { waitForOperation as waitForLongRunning } from "../Operation.ts";
14
15const DEFAULT_SERVICE = "servicenetworking.googleapis.com";
16const DEFAULT_PEERING = "servicenetworking-googleapis-com";
17
18export type ConnectionProps = {
19 /**
20 * Consumer VPC network. Accepts a name (`main`), a resource path
21 * (`projects/{project}/global/networks/{network}`), or a Compute
22 * self-link. Immutable — changing it replaces the connection.
23 */
24 network: string;
25 /**
26 * Peering service that manages producer connectivity. For Google
27 * services this is `servicenetworking.googleapis.com`. A `services/`
28 * prefix is accepted. Immutable — changing it replaces the connection.
29 * @default "servicenetworking.googleapis.com"
30 */
31 service?: string;
32 /**
33 * Names of allocated `VPC_PEERING` global addresses reserved for this
34 * producer. Updating the list PATCHes the connection (`force` is sent
35 * when a previously allocated range is dropped).
36 */
37 reservedPeeringRanges: string[];
38 /**
39 * When `ABANDON`, destroy removes the resource from state without
40 * calling the API. Use this when Cloud SQL (or another managed
41 * service) still depends on the peering.
42 */
43 deletionPolicy?: "ABANDON" | "DELETE" | (string & {});
44};
45
46export type Connection = Resource<
47 "GCP.ServiceNetworking.Connection",
48 ConnectionProps,
49 {
50 /**
51 * Consumer network path
52 * `projects/{projectNumber}/global/networks/{network}`.
53 */
54 network: string;
55 /** VPC network id (last path segment). */
56 networkName: string;
57 /** Peering service, as `services/{service}`. */
58 service: string;
59 /** VPC Network Peering name created by the producer. */
60 peering: string;
61 /** Allocated IP range names assigned to this connection. */
62 reservedPeeringRanges: ReadonlyArray<string>;
63 /**
64 * Resource name
65 * `services/{service}/connections/{peering}`.
66 */
67 name: string;
68 /** Project id. */
69 project: string;
70 /** Numeric project used in Service Networking paths. */
71 projectNumber: string;
72 },
73 never,
74 Providers
75>;
76
77/**
78 * A private services access connection (VPC Network Peering to a
79 * Google or third-party service producer).
80 *
81 * The API has no labels or description. Alchemy treats existence at
82 * `(network, service)` as ownership. `list` returns connections whose
83 * consumer VPC carries Alchemy ownership markers so `pnpm nuke:gcp`
84 * can still clean leaks.
85 *
86 * `network` and `service` are immutable. `reservedPeeringRanges`
87 * update in place.
88 *
89 * ### Creating a Connection
90 * **Example:** Private services access on a VPC
91 * ```typescript
92 * const network = yield* GCP.Compute.Network("Vpc", {
93 * autoCreateSubnetworks: false,
94 * });
95 * const range = yield* GCP.Compute.GlobalAddress("PsaRange", {
96 * addressType: "INTERNAL",
97 * purpose: "VPC_PEERING",
98 * network: network.selfLink,
99 * prefixLength: 24,
100 * });
101 * const connection = yield* GCP.ServiceNetworking.Connection("Psa", {
102 * network: network.networkName,
103 * reservedPeeringRanges: [range.addressName],
104 * });
105 * ```
106 *
107 * **Example:** Explicit service and additional ranges
108 * ```typescript
109 * const connection = yield* GCP.ServiceNetworking.Connection("Psa", {
110 * network: network.networkName,
111 * service: "servicenetworking.googleapis.com",
112 * reservedPeeringRanges: [range.addressName, extra.addressName],
113 * });
114 * ```
115 *
116 * @resource
117 * @category ServiceNetworking
118 */
119export const Connection = Resource<Connection>(
120 "GCP.ServiceNetworking.Connection",
121);
122
123export class ConnectionNotResolved extends Data.TaggedError(
124 "GCP.ServiceNetworking.ConnectionNotResolved",
125)<{
126 network: string;
127 service: string;
128}> {}
129
130export class ConnectionNetworkMissing extends Data.TaggedError(
131 "GCP.ServiceNetworking.ConnectionNetworkMissing",
132)<{
133 message: string;
134}> {}
135
136export class ConnectionRangesMissing extends Data.TaggedError(
137 "GCP.ServiceNetworking.ConnectionRangesMissing",
138)<{
139 network: string;
140 message: string;
141}> {}
142
143export class ConnectionProjectNumberMissing extends Data.TaggedError(
144 "GCP.ServiceNetworking.ConnectionProjectNumberMissing",
145)<{
146 project: string;
147}> {}
148
149export class ConnectionStillExists extends Data.TaggedError(
150 "GCP.ServiceNetworking.ConnectionStillExists",
151)<{
152 network: string;
153}> {}
154
155const lastSegment = (value: string) => {
156 const trimmed = value.replace(/\/+$/, "");
157 const parts = trimmed.split("/");
158 return parts[parts.length - 1] || trimmed;
159};
160
161const linkKey = (value: string | undefined) =>
162 value === undefined || value === "" ? "" : lastSegment(value).toLowerCase();
163
164const stripServicesPrefix = (service: string) =>
165 service.startsWith("services/") ? service.slice("services/".length) : service;
166
167const parentServiceOf = (service: string | undefined) =>
168 `services/${stripServicesPrefix(service || DEFAULT_SERVICE)}`;
169
170const serviceIdOf = (service: string | undefined) =>
171 stripServicesPrefix(service || DEFAULT_SERVICE);
172
173const consumerNetworkOf = (projectNumber: string, networkName: string) =>
174 `projects/${projectNumber}/global/networks/${networkName}`;
175
176const connectionNameOf = (parent: string, peering: string | undefined) =>
177 `${parent}/connections/${peering && peering.length > 0 ? peering : "-"}`;
178
179const rangeNamesOf = (ranges: readonly string[] | undefined) =>
180 [...(ranges ?? [])]
181 .map((range) => lastSegment(range))
182 .filter((range) => range.length > 0);
183
184const rangesKey = (ranges: readonly string[] | undefined) =>
185 [...rangeNamesOf(ranges)].sort().join("\n");
186
187const isAlchemyNetwork = (network: compute.Network) =>
188 (network.description ?? "").includes("alchemy-id=");
189
190const toAttrs = (
191 connection: servicenetworking.Connection,
192 project: string,
193 projectNumber: string,
194 fallbackNetwork: string,
195 fallbackService: string,
196) => {
197 const network = connection.network ?? fallbackNetwork;
198 const service = connection.service ?? parentServiceOf(fallbackService);
199 const peering = connection.peering ?? DEFAULT_PEERING;
200 return {
201 network,
202 networkName: lastSegment(network),
203 service,
204 peering,
205 reservedPeeringRanges: connection.reservedPeeringRanges ?? [],
206 name: connectionNameOf(parentServiceOf(service), peering),
207 project,
208 projectNumber,
209 };
210};
211
212const getProjectNumber = (project: string) =>
213 Effect.gen(function* () {
214 if (/^\d+$/.test(project)) return project;
215 const resource = yield* resourcemanager.getProjects({
216 name: `projects/${project}`,
217 });
218 const number = lastSegment(resource.name ?? "");
219 if (!/^\d+$/.test(number)) {
220 return yield* new ConnectionProjectNumberMissing({ project });
221 }
222 return number;
223 });
224
225const listForNetwork = (parent: string, consumerNetwork: string) =>
226 servicenetworking
227 .listServicesConnections({
228 parent,
229 network: consumerNetwork,
230 })
231 .pipe(
232 Effect.map((page) => page.connections ?? []),
233 Effect.catchTag("NotFound", () =>
234 Effect.succeed([] as servicenetworking.Connection[]),
235 ),
236 );
237
238const getByNetwork = (parent: string, consumerNetwork: string) =>
239 listForNetwork(parent, consumerNetwork).pipe(
240 Effect.map((connections): servicenetworking.Connection | undefined => {
241 if (connections.length === 0) {
242 return undefined;
243 }
244 const match = connections.find(
245 (connection) =>
246 linkKey(connection.network) === linkKey(consumerNetwork) ||
247 connections.length === 1,
248 );
249 return match ?? connections[0];
250 }),
251 );
252
253/**
254 * Wait for a Service Networking operation. Peering changes settle within a
255 * few minutes. `ALREADY_EXISTS` always succeeds; `notFoundOk` accepts
256 * `NOT_FOUND` (deletes) and `ignoreRangeConflict` accepts
257 * `FAILED_PRECONDITION` (another writer already changed the ranges).
258 */
259const waitForOperation = (
260 operation: servicenetworking.Operation,
261 options?: { notFoundOk?: boolean; ignoreRangeConflict?: boolean },
262) =>
263 waitForLongRunning(
264 operation,
265 (name) => servicenetworking.getOperations({ name }),
266 { budget: "10 minutes" },
267 ).pipe(
268 Effect.catchIf(
269 (error) =>
270 error._tag === "GCP.OperationFailed" &&
271 (error.code === 6 ||
272 (options?.notFoundOk === true && error.code === 5) ||
273 (options?.ignoreRangeConflict === true && error.code === 9)),
274 () => Effect.void,
275 ),
276 Effect.catchIf(
277 (error) => options?.notFoundOk === true && error._tag === "NotFound",
278 () => Effect.void,
279 ),
280 );
281
282const waitUntilPresent = (parent: string, consumerNetwork: string) =>
283 getByNetwork(parent, consumerNetwork).pipe(
284 Effect.filterOrFail(
285 (connection): connection is servicenetworking.Connection =>
286 connection !== undefined,
287 () =>
288 new ConnectionNotResolved({
289 network: consumerNetwork,
290 service: parent,
291 }),
292 ),
293 Effect.retry({
294 while: (error) =>
295 error._tag === "GCP.ServiceNetworking.ConnectionNotResolved",
296 times: 8,
297 schedule: Schedule.spaced("2 seconds"),
298 }),
299 );
300
301const waitUntilGone = (parent: string, consumerNetwork: string) =>
302 getByNetwork(parent, consumerNetwork).pipe(
303 Effect.flatMap((connection) =>
304 connection === undefined
305 ? Effect.void
306 : Effect.fail(
307 new ConnectionStillExists({
308 network: consumerNetwork,
309 }),
310 ),
311 ),
312 Effect.retry({
313 while: (error) =>
314 error._tag === "GCP.ServiceNetworking.ConnectionStillExists",
315 times: 10,
316 schedule: Schedule.spaced("8 seconds"),
317 }),
318 );
319
320const patchRanges = (
321 parent: string,
322 peering: string | undefined,
323 consumerNetwork: string,
324 ranges: readonly string[],
325) =>
326 Effect.gen(function* () {
327 const operation = yield* servicenetworking.patchServicesConnections({
328 name: connectionNameOf(parent, peering),
329 updateMask: "reservedPeeringRanges",
330 force: true,
331 body: {
332 network: consumerNetwork,
333 reservedPeeringRanges: [...ranges],
334 },
335 });
336 yield* waitForOperation(operation);
337 });
338
339export const ConnectionProvider = () =>
340 Provider.succeed(Connection, {
341 stables: [
342 "network",
343 "networkName",
344 "service",
345 "peering",
346 "name",
347 "project",
348 "projectNumber",
349 ],
350
351 diff: Effect.fn(function* ({ news, olds, output }) {
352 if (!isResolved(news)) return undefined;
353 const previousNetwork = linkKey(olds?.network ?? output?.network);
354 const nextNetwork = linkKey(news.network ?? output?.network);
355 const previousService = serviceIdOf(olds?.service ?? output?.service);
356 const nextService = serviceIdOf(news.service ?? output?.service);
357 if (
358 (previousNetwork.length > 0 && nextNetwork !== previousNetwork) ||
359 (output?.service !== undefined && nextService !== previousService)
360 ) {
361 return { action: "replace" as const, deleteFirst: false };
362 }
363 return undefined;
364 }),
365
366 read: Effect.fn(function* ({ olds, output }) {
367 const env = yield* GcpEnvironment.current;
368 const networkName = lastSegment(
369 olds?.network ?? output?.networkName ?? output?.network ?? "",
370 );
371 if (networkName.length === 0) return undefined;
372 const parent = parentServiceOf(olds?.service ?? output?.service);
373 const projectNumber =
374 output?.projectNumber ?? (yield* getProjectNumber(env.project));
375 const consumerNetwork = consumerNetworkOf(projectNumber, networkName);
376 const existing = yield* getByNetwork(parent, consumerNetwork);
377 if (existing === undefined) return undefined;
378 // Connections have no labels. Existence at (network, service) is
379 // ownership — the API allows only one PSA peering per pair.
380 return toAttrs(
381 existing,
382 env.project,
383 projectNumber,
384 consumerNetwork,
385 parent,
386 );
387 }),
388
389 list: () =>
390 Effect.gen(function* () {
391 const env = yield* GcpEnvironment.current;
392 const projectNumber = yield* getProjectNumber(env.project);
393 const networks = yield* compute.listNetworks
394 .items({ project: env.project, maxResults: 500 })
395 .pipe(
396 Stream.filter(isAlchemyNetwork),
397 Stream.runCollect,
398 Effect.map((chunk) => Array.from(chunk)),
399 );
400 const pages = yield* Effect.forEach(
401 networks,
402 (network) => {
403 const networkName = network.name;
404 if (!networkName) {
405 return Effect.succeed([] as ReturnType<typeof toAttrs>[]);
406 }
407 const consumerNetwork = consumerNetworkOf(
408 projectNumber,
409 networkName,
410 );
411 return listForNetwork("services/-", consumerNetwork).pipe(
412 Effect.map((connections) =>
413 connections.map((connection) =>
414 toAttrs(
415 connection,
416 env.project,
417 projectNumber,
418 consumerNetwork,
419 connection.service ?? DEFAULT_SERVICE,
420 ),
421 ),
422 ),
423 );
424 },
425 { concurrency: 4 },
426 );
427 return pages.flat();
428 }),
429
430 reconcile: Effect.fn(function* ({ news, output }) {
431 const env = yield* GcpEnvironment.current;
432 const networkName = lastSegment(news.network ?? output?.network ?? "");
433 if (networkName.length === 0) {
434 return yield* new ConnectionNetworkMissing({
435 message:
436 "Service Networking connections require `network` (VPC name or URL).",
437 });
438 }
439 const parent = parentServiceOf(news.service ?? output?.service);
440 const projectNumber =
441 output?.projectNumber ?? (yield* getProjectNumber(env.project));
442 const consumerNetwork = consumerNetworkOf(projectNumber, networkName);
443 const desiredRanges = rangeNamesOf(news.reservedPeeringRanges);
444
445 let current = yield* getByNetwork(parent, consumerNetwork);
446
447 if (current === undefined) {
448 if (desiredRanges.length === 0) {
449 return yield* new ConnectionRangesMissing({
450 network: consumerNetwork,
451 message:
452 "Service Networking connections require `reservedPeeringRanges`.",
453 });
454 }
455 const created = yield* servicenetworking
456 .createServicesConnections({
457 parent,
458 body: {
459 network: consumerNetwork,
460 reservedPeeringRanges: [...desiredRanges],
461 },
462 })
463 .pipe(
464 Effect.catchTag(["Conflict", "ConnectionAlreadyExists"], () =>
465 Effect.succeed(undefined),
466 ),
467 );
468 if (created !== undefined) {
469 yield* waitForOperation(created, { ignoreRangeConflict: true });
470 }
471 current = yield* waitUntilPresent(parent, consumerNetwork).pipe(
472 Effect.catchTag("GCP.ServiceNetworking.ConnectionNotResolved", () =>
473 Effect.succeed(undefined),
474 ),
475 );
476 if (
477 current !== undefined &&
478 desiredRanges.length > 0 &&
479 rangesKey(current.reservedPeeringRanges) !== rangesKey(desiredRanges)
480 ) {
481 yield* patchRanges(
482 parent,
483 current.peering,
484 consumerNetwork,
485 desiredRanges,
486 ).pipe(
487 Effect.catchTag(
488 ["Conflict", "ConnectionAlreadyExists"],
489 () => Effect.void,
490 ),
491 );
492 current = yield* waitUntilPresent(parent, consumerNetwork).pipe(
493 Effect.catchTag("GCP.ServiceNetworking.ConnectionNotResolved", () =>
494 Effect.succeed(undefined),
495 ),
496 );
497 }
498 }
499
500 if (current === undefined) {
501 return yield* new ConnectionNotResolved({
502 network: consumerNetwork,
503 service: parent,
504 });
505 }
506
507 if (
508 desiredRanges.length > 0 &&
509 rangesKey(current.reservedPeeringRanges) !== rangesKey(desiredRanges)
510 ) {
511 yield* patchRanges(
512 parent,
513 current.peering,
514 consumerNetwork,
515 desiredRanges,
516 );
517 current = yield* waitUntilPresent(parent, consumerNetwork);
518 }
519
520 if (current === undefined) {
521 return yield* new ConnectionNotResolved({
522 network: consumerNetwork,
523 service: parent,
524 });
525 }
526
527 return toAttrs(
528 current,
529 env.project,
530 projectNumber,
531 consumerNetwork,
532 parent,
533 );
534 }),
535
536 delete: Effect.fn(function* ({ olds, output }) {
537 if ((olds?.deletionPolicy ?? "DELETE").toUpperCase() === "ABANDON") {
538 return;
539 }
540 const parent = parentServiceOf(output.service);
541 const consumerNetwork = output.network;
542 const operation = yield* servicenetworking
543 .deleteConnectionServicesConnections({
544 name: output.name || connectionNameOf(parent, output.peering),
545 body: { consumerNetwork },
546 })
547 .pipe(
548 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
549 Effect.retry({
550 while: (error) => error._tag === "Conflict",
551 times: 8,
552 schedule: Schedule.spaced("8 seconds"),
553 }),
554 );
555 if (operation !== undefined) {
556 yield* waitForOperation(operation, { notFoundOk: true });
557 }
558 yield* waitUntilGone(parent, consumerNetwork);
559 }),
560 });