1import { ignoredCodes } from "./internal.ts";
2import * as compute from "@distilled.cloud/gcp/compute_v1";
3import { waitRegionOperation } from "./operations.ts";
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 { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 alchemyLabelKeys,
16 createInternalLabels,
17 hasAlchemyLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const DEFAULT_CONNECTION_PREFERENCE = "ACCEPT_AUTOMATIC";
22const MAX_NAME_LENGTH = 63;
23
24export type NetworkAttachmentConnectionPreference =
25 | compute.NetworkAttachmentConnectionPreferenceEnum
26 | (string & {});
27export type NetworkAttachmentConnectedEndpoint =
28 compute.NetworkAttachmentConnectedEndpoint;
29
30export type NetworkAttachmentProps = {
31 /**
32 * Attachment name (RFC1035, 1-63 characters). If omitted, a unique name
33 * is generated from the stack, stage, and logical id. Immutable —
34 * changing it replaces the attachment.
35 */
36 networkAttachmentName?: string;
37 /**
38 * Region the attachment lives in. Immutable — changing it replaces the
39 * attachment. `US-CENTRAL1` is accepted and normalized to `us-central1`.
40 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
41 */
42 region?: string;
43 /**
44 * Subnet URLs or names the consumer provides for producer endpoints.
45 * All subnets must be in the same VPC. Required. Updated in place via
46 * `patch`.
47 */
48 subnetworks: string[];
49 /**
50 * How producer connections are admitted. `ACCEPT_AUTOMATIC` always
51 * accepts; `ACCEPT_MANUAL` uses the accept and reject lists.
52 * Immutable — GCP rejects `patch` of this field, so changing it
53 * replaces the attachment.
54 * @default "ACCEPT_AUTOMATIC"
55 */
56 connectionPreference?: NetworkAttachmentConnectionPreference;
57 /**
58 * Optional description. Compute network attachments have no labels
59 * field, so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
60 * `alchemy-id`) is stored in a `[alchemy …]` prefix for `list` / nuke.
61 * Updated in place via `patch`.
62 */
63 description?: string;
64 /**
65 * Producer projects allowed to connect (id or number). Used with
66 * `ACCEPT_MANUAL`. Updated in place via `patch`.
67 */
68 producerAcceptLists?: string[];
69 /**
70 * Producer projects that must not connect. Updated in place via
71 * `patch`.
72 */
73 producerRejectLists?: string[];
74};
75
76export type NetworkAttachment = Resource<
77 "GCP.Compute.NetworkAttachment",
78 NetworkAttachmentProps,
79 {
80 /** Attachment name. */
81 networkAttachmentName: string;
82 /** Project id. */
83 project: string;
84 /** Region short name (`us-central1`). */
85 region: string;
86 /** Parent VPC network URL. */
87 network: string | undefined;
88 /** Consumer subnet URLs. */
89 subnetworks: ReadonlyArray<string>;
90 /** Connection preference. */
91 connectionPreference: string | undefined;
92 /** User description with the Alchemy ownership prefix stripped. */
93 description: string | undefined;
94 /** Accepted producer projects. */
95 producerAcceptLists: ReadonlyArray<string>;
96 /** Rejected producer projects. */
97 producerRejectLists: ReadonlyArray<string>;
98 /** Connected producer endpoints. */
99 connectionEndpoints: ReadonlyArray<NetworkAttachmentConnectedEndpoint>;
100 /** Optimistic-locking fingerprint. */
101 fingerprint: string | undefined;
102 /** Server-assigned numeric id. */
103 networkAttachmentId: string | undefined;
104 /** Resource self-link. */
105 selfLink: string | undefined;
106 /** Self-link including the numeric id. */
107 selfLinkWithId: string | undefined;
108 /** RFC3339 creation timestamp. */
109 creationTimestamp: string | undefined;
110 /** Resource kind. */
111 kind: string | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A regional Compute Engine Private Service Connect network attachment.
119 *
120 * A network attachment lets a producer VPC initiate connections into a
121 * consumer VPC through a PSC interface. It lists consumer subnets and
122 * admits producers either automatically or via accept/reject lists.
123 * Compute NetworkAttachment has no labels field — Alchemy ownership is
124 * stored in the description so nuke can find leaked attachments.
125 *
126 * ### Creating a Network Attachment
127 * **Example:** Generated name, automatic accept
128 * ```typescript
129 * const attachment = yield* GCP.Compute.NetworkAttachment("Consumer", {
130 * region: "us-central1",
131 * subnetworks: [subnet.selfLink],
132 * connectionPreference: "ACCEPT_AUTOMATIC",
133 * });
134 * ```
135 *
136 * **Example:** Manual admission
137 * ```typescript
138 * const attachment = yield* GCP.Compute.NetworkAttachment("Consumer", {
139 * networkAttachmentName: "app-na",
140 * subnetworks: [subnet.selfLink],
141 * connectionPreference: "ACCEPT_MANUAL",
142 * producerAcceptLists: ["my-producer-project"],
143 * });
144 * ```
145 *
146 * @resource
147 * @category Compute
148 */
149export const NetworkAttachment = Resource<NetworkAttachment>(
150 "GCP.Compute.NetworkAttachment",
151);
152
153export class NetworkAttachmentNotResolved extends Data.TaggedError(
154 "GCP.Compute.NetworkAttachmentNotResolved",
155)<{
156 networkAttachmentName: string;
157 region: string;
158}> {}
159
160export class NetworkAttachmentStillExists extends Data.TaggedError(
161 "GCP.Compute.NetworkAttachmentStillExists",
162)<{
163 networkAttachmentName: string;
164}> {}
165
166const lastSegment = (value: string | undefined): string => {
167 if (value === undefined || value.length === 0) return "";
168 const trimmed = value.replace(/\/+$/, "");
169 const parts = trimmed.split("/");
170 return parts[parts.length - 1] || trimmed;
171};
172
173const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
174 lastSegment(region ?? defaultRegion).toLowerCase();
175
176const rfc1035 = (name: string): string => {
177 let next = name
178 .toLowerCase()
179 .replace(/[^a-z0-9-]/g, "-")
180 .replace(/-+/g, "-")
181 .replace(/^-+/, "")
182 .replace(/-+$/, "");
183 if (!/^[a-z]/.test(next)) next = `n${next}`;
184 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
185 return next.length > 0 ? next : "attachment";
186};
187
188const toName = (id: string, name: string | undefined, existing?: string) =>
189 Effect.gen(function* () {
190 if (name !== undefined) return name;
191 if (existing !== undefined) return existing;
192 return rfc1035(
193 yield* createPhysicalName({
194 id,
195 maxLength: MAX_NAME_LENGTH,
196 lowercase: true,
197 }),
198 );
199 });
200
201const encodeDescription = (
202 labels: Record<string, string>,
203 description: string | undefined,
204): string => {
205 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
206 return description ? `${marker}\n${description}` : marker;
207};
208
209const parseDescription = (
210 description: string | undefined,
211): {
212 labels: Record<string, string>;
213 description: string | undefined;
214} => {
215 if (!description?.startsWith("[alchemy ")) {
216 return { labels: {}, description };
217 }
218 const end = description.indexOf("]");
219 if (end < 0) return { labels: {}, description };
220 const labels: Record<string, string> = {};
221 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
222 const eq = part.indexOf("=");
223 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
224 }
225 const rest = description.slice(end + 1).replace(/^\n/, "");
226 return { labels, description: rest.length > 0 ? rest : undefined };
227};
228
229const hasOwnershipMarker = (description: string | undefined) =>
230 Object.keys(parseDescription(description).labels).some((key) =>
231 key.startsWith("alchemy-"),
232 );
233
234const preferenceOf = (value: string | undefined) =>
235 value && value.length > 0 ? value : DEFAULT_CONNECTION_PREFERENCE;
236
237const toSubnetworkUrl = (project: string, region: string, value: string) => {
238 if (value.includes("/")) return value;
239 return `projects/${project}/regions/${region}/subnetworks/${value}`;
240};
241
242const refsKey = (values: ReadonlyArray<string> | undefined) =>
243 [...(values ?? [])]
244 .map((value) => lastSegment(value))
245 .filter((value) => value.length > 0)
246 .sort()
247 .join(",");
248
249const toAttrs = (
250 attachment: compute.NetworkAttachment,
251 project: string,
252 fallbackName?: string,
253): NetworkAttachment["Attributes"] => {
254 const parsed = parseDescription(attachment.description);
255 const region = lastSegment(attachment.region).toLowerCase();
256 const networkAttachmentName =
257 attachment.name ||
258 lastSegment(attachment.selfLink) ||
259 lastSegment(attachment.selfLinkWithId) ||
260 fallbackName ||
261 "";
262 const selfLink =
263 attachment.selfLink && attachment.selfLink.length > 0
264 ? attachment.selfLink
265 : networkAttachmentName.length > 0
266 ? `https://www.googleapis.com/compute/v1/projects/${project}/regions/${region}/networkAttachments/${networkAttachmentName}`
267 : undefined;
268 return {
269 networkAttachmentName,
270 project,
271 region,
272 network: attachment.network,
273 subnetworks: attachment.subnetworks ?? [],
274 connectionPreference: attachment.connectionPreference,
275 description: parsed.description,
276 producerAcceptLists: attachment.producerAcceptLists ?? [],
277 producerRejectLists: attachment.producerRejectLists ?? [],
278 connectionEndpoints: attachment.connectionEndpoints ?? [],
279 fingerprint: attachment.fingerprint,
280 networkAttachmentId: attachment.id,
281 selfLink,
282 selfLinkWithId: attachment.selfLinkWithId ?? selfLink,
283 creationTimestamp: attachment.creationTimestamp,
284 kind: attachment.kind,
285 };
286};
287
288const getByName = (
289 project: string,
290 region: string,
291 networkAttachment: string,
292) =>
293 compute
294 .getNetworkAttachments({ project, region, networkAttachment })
295 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
296
297const awaitResource = (
298 project: string,
299 region: string,
300 networkAttachmentName: string,
301) =>
302 getByName(project, region, networkAttachmentName).pipe(
303 Effect.flatMap((attachment) =>
304 attachment !== undefined
305 ? Effect.succeed(attachment)
306 : Effect.fail(
307 new NetworkAttachmentNotResolved({
308 networkAttachmentName,
309 region,
310 }),
311 ),
312 ),
313 Effect.retry({
314 while: (error) =>
315 error._tag === "GCP.Compute.NetworkAttachmentNotResolved",
316 times: 8,
317 schedule: Schedule.spaced("1 second"),
318 }),
319 );
320
321const waitUntilGone = (
322 project: string,
323 region: string,
324 networkAttachmentName: string,
325) =>
326 getByName(project, region, networkAttachmentName).pipe(
327 Effect.flatMap((attachment) =>
328 attachment === undefined
329 ? Effect.void
330 : Effect.fail(
331 new NetworkAttachmentStillExists({ networkAttachmentName }),
332 ),
333 ),
334 Effect.retry({
335 while: (error) =>
336 error._tag === "GCP.Compute.NetworkAttachmentStillExists",
337 times: 10,
338 schedule: Schedule.spaced("2 seconds"),
339 }),
340 Effect.catchTag(
341 "GCP.Compute.NetworkAttachmentStillExists",
342 () => Effect.void,
343 ),
344 );
345
346const runOp = <E extends { readonly _tag: string }, R>(
347 project: string,
348 region: string,
349 networkAttachmentName: string,
350 start: Effect.Effect<compute.Operation, E, R>,
351 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
352) =>
353 start.pipe(
354 Effect.flatMap((operation) =>
355 waitRegionOperation(project, region, operation, {
356 ignore: ignoredCodes(options),
357 }),
358 ),
359 Effect.retry({
360 while: (error) => error._tag === "Conflict",
361 times: 5,
362 schedule: Schedule.spaced("1 second"),
363 }),
364 );
365
366export const NetworkAttachmentProvider = () =>
367 Provider.succeed(NetworkAttachment, {
368 stables: [
369 "networkAttachmentName",
370 "project",
371 "region",
372 "networkAttachmentId",
373 "selfLink",
374 "selfLinkWithId",
375 "creationTimestamp",
376 ],
377
378 diff: Effect.fn(function* ({ news, olds, output }) {
379 if (!isResolved(news)) return undefined;
380 const env = yield* GcpEnvironment.current;
381 const previousName =
382 olds?.networkAttachmentName ?? output?.networkAttachmentName;
383 const nextName = news.networkAttachmentName ?? previousName;
384 const nameChanged =
385 previousName !== undefined &&
386 nextName !== undefined &&
387 previousName !== nextName;
388 const previousRegion = normalizeRegion(
389 olds?.region ?? output?.region,
390 env.region,
391 );
392 const nextRegion = normalizeRegion(
393 news.region ?? previousRegion,
394 env.region,
395 );
396 const previousPreference = preferenceOf(
397 olds?.connectionPreference ?? output?.connectionPreference,
398 );
399 const nextPreference = preferenceOf(news.connectionPreference);
400 if (nameChanged) {
401 return { action: "replace" as const, deleteFirst: false };
402 }
403 if (
404 previousRegion !== nextRegion ||
405 previousPreference !== nextPreference
406 ) {
407 return { action: "replace" as const, deleteFirst: true };
408 }
409 return undefined;
410 }),
411
412 read: Effect.fn(function* ({ id, olds, output }) {
413 const env = yield* GcpEnvironment.current;
414 const networkAttachmentName = yield* toName(
415 id,
416 olds?.networkAttachmentName,
417 output?.networkAttachmentName,
418 );
419 const region = normalizeRegion(
420 olds?.region ?? output?.region,
421 env.region,
422 );
423 const existing = yield* getByName(
424 env.project,
425 region,
426 networkAttachmentName,
427 );
428 if (existing === undefined) return undefined;
429 const attrs = toAttrs(existing, env.project, networkAttachmentName);
430 const { labels } = parseDescription(existing.description);
431 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
432 }),
433
434 list: () =>
435 Effect.gen(function* () {
436 const env = yield* GcpEnvironment.current;
437 const pages = yield* compute.aggregatedListNetworkAttachments
438 .pages({
439 project: env.project,
440 returnPartialSuccess: true,
441 maxResults: 500,
442 })
443 .pipe(Stream.runCollect);
444 return Array.from(pages).flatMap((page) =>
445 Object.values(page.items ?? {}).flatMap((scoped) =>
446 (scoped?.networkAttachments ?? [])
447 .filter((item) => hasOwnershipMarker(item.description))
448 .map((item) => toAttrs(item, env.project)),
449 ),
450 );
451 }),
452
453 reconcile: Effect.fn(function* ({ id, news, output }) {
454 const env = yield* GcpEnvironment.current;
455 const networkAttachmentName = yield* toName(
456 id,
457 news.networkAttachmentName,
458 output?.networkAttachmentName,
459 );
460 const region = normalizeRegion(news.region ?? output?.region, env.region);
461 const ownership = yield* createInternalLabels(id);
462 const desiredDescription = encodeDescription(ownership, news.description);
463 const subnetworks = news.subnetworks.map((subnet) =>
464 toSubnetworkUrl(env.project, region, subnet),
465 );
466 const connectionPreference = preferenceOf(news.connectionPreference);
467
468 let current = yield* getByName(
469 env.project,
470 region,
471 networkAttachmentName,
472 );
473
474 if (current === undefined) {
475 yield* compute
476 .insertNetworkAttachments({
477 project: env.project,
478 region,
479 body: {
480 name: networkAttachmentName,
481 description: desiredDescription,
482 subnetworks,
483 connectionPreference,
484 producerAcceptLists: news.producerAcceptLists,
485 producerRejectLists: news.producerRejectLists,
486 },
487 })
488 .pipe(
489 Effect.flatMap((operation) =>
490 waitRegionOperation(env.project, region, operation, {
491 ignore: ["RESOURCE_ALREADY_EXISTS"],
492 }),
493 ),
494 Effect.catchTag("Conflict", () => Effect.void),
495 );
496 current = yield* awaitResource(
497 env.project,
498 region,
499 networkAttachmentName,
500 );
501 }
502
503 const needsPatch =
504 (current.description ?? "") !== desiredDescription ||
505 refsKey(current.subnetworks) !== refsKey(subnetworks) ||
506 (news.producerAcceptLists !== undefined &&
507 refsKey(current.producerAcceptLists) !==
508 refsKey(news.producerAcceptLists)) ||
509 (news.producerRejectLists !== undefined &&
510 refsKey(current.producerRejectLists) !==
511 refsKey(news.producerRejectLists));
512
513 if (needsPatch) {
514 const latest =
515 (yield* getByName(env.project, region, networkAttachmentName)) ??
516 current;
517 yield* runOp(
518 env.project,
519 region,
520 networkAttachmentName,
521 compute.patchNetworkAttachments({
522 project: env.project,
523 region,
524 networkAttachment: networkAttachmentName,
525 body: {
526 fingerprint: latest.fingerprint,
527 description: desiredDescription,
528 subnetworks,
529 producerAcceptLists:
530 news.producerAcceptLists ?? current.producerAcceptLists,
531 producerRejectLists:
532 news.producerRejectLists ?? current.producerRejectLists,
533 },
534 }),
535 );
536 current =
537 (yield* getByName(env.project, region, networkAttachmentName)) ??
538 current;
539 }
540
541 return toAttrs(current, env.project, networkAttachmentName);
542 }),
543
544 delete: Effect.fn(function* ({ output }) {
545 if (!output.networkAttachmentName) return;
546 const env = yield* GcpEnvironment.current;
547 const project = output.project || env.project;
548 const region = normalizeRegion(output.region, env.region);
549 yield* compute
550 .deleteNetworkAttachments({
551 project,
552 region,
553 networkAttachment: output.networkAttachmentName,
554 })
555 .pipe(
556 Effect.flatMap((operation) =>
557 waitRegionOperation(project, region, operation, {
558 ignore: ["RESOURCE_NOT_FOUND"],
559 }),
560 ),
561 Effect.catchTag("NotFound", () => Effect.void),
562 Effect.retry({
563 while: (error) => error._tag === "Conflict",
564 times: 8,
565 schedule: Schedule.spaced("2 seconds"),
566 }),
567 );
568 yield* waitUntilGone(project, region, output.networkAttachmentName);
569 }),
570 });