1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const DEFAULT_CONNECTION_PREFERENCE = "ACCEPT_AUTOMATIC";
23const MAX_NAME_LENGTH = 63;
24
25export type ServiceAttachmentConnectionPreference =
26 | compute.ServiceAttachmentConnectionPreferenceEnum
27 | (string & {});
28
29export type ServiceAttachmentConsumerAccept = {
30 /** Consumer project id or number allowed to connect. */
31 projectIdOrNum?: string;
32 /** Consumer network URL allowed to connect. */
33 networkUrl?: string;
34 /** Consumer endpoint URL allowed to connect. */
35 endpointUrl?: string;
36 /** Max consumer forwarding rules (or 1 for an endpoint). */
37 connectionLimit?: number;
38};
39
40export type ServiceAttachmentConnectedEndpoint = {
41 /** Connection status (`PENDING`, `ACCEPTED`, `REJECTED`, …). */
42 status: string | undefined;
43 /** Consumer forwarding-rule URL. */
44 endpoint: string | undefined;
45 /** Consumer network URL. */
46 consumerNetwork: string | undefined;
47 /** PSC connection id. */
48 pscConnectionId: string | undefined;
49 /** Propagated NCC spoke count. */
50 propagatedConnectionCount: number | undefined;
51 /** NAT IPs of the connected endpoint. */
52 natIps: ReadonlyArray<string>;
53};
54
55export type ServiceAttachmentProps = {
56 /**
57 * Service attachment name (RFC1035, 1-63 characters). If omitted, a
58 * unique name is generated from the stack, stage, and logical id.
59 * Immutable — changing it replaces the attachment.
60 */
61 serviceAttachmentName?: string;
62 /**
63 * Region the attachment lives in. Immutable — changing it replaces
64 * the attachment. `US-CENTRAL1` is accepted and normalized to
65 * `us-central1`.
66 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
67 */
68 region?: string;
69 /**
70 * Optional description. Compute service attachments have no labels
71 * field, so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
72 * `alchemy-id`) plus user `labels` are stored in a `[alchemy …]`
73 * prefix for `list` / nuke. Updated in place via `patch`.
74 */
75 description?: string;
76 /**
77 * URL of the producer forwarding rule (or other target service) this
78 * attachment exposes. Required. Names are expanded to
79 * `projects/{project}/regions/{region}/forwardingRules/{name}`.
80 * Updated in place via `patch`.
81 */
82 targetService: string;
83 /**
84 * Subnet URLs or names the producer provides for PSC NAT. Each subnet
85 * must have purpose `PRIVATE_SERVICE_CONNECT`. Required. Updated in
86 * place via `patch`.
87 */
88 natSubnets: string[];
89 /**
90 * How consumer connections are admitted.
91 * `ACCEPT_AUTOMATIC` always accepts; `ACCEPT_MANUAL` uses the accept
92 * and reject lists.
93 * @default "ACCEPT_AUTOMATIC"
94 */
95 connectionPreference?: ServiceAttachmentConnectionPreference;
96 /**
97 * Send PROXY protocol v1 headers to backends. Must be `false` for
98 * passthrough internal load balancers.
99 * @default false
100 */
101 enableProxyProtocol?: boolean;
102 /**
103 * Projects, networks, or endpoints allowed to connect, each with a
104 * connection limit. Used with `ACCEPT_MANUAL`. A given attachment
105 * manages connections at one level — projects, networks, or
106 * endpoints — not mixed. Updated in place via `patch`.
107 */
108 consumerAcceptLists?: ServiceAttachmentConsumerAccept[];
109 /**
110 * Projects or networks that must not connect. Must use the same
111 * level as `consumerAcceptLists`. Updated in place via `patch`.
112 */
113 consumerRejectLists?: string[];
114 /**
115 * When `true`, accept/reject-list changes also move existing
116 * `ACCEPTED` / `REJECTED` endpoints. When `false`, only `PENDING`
117 * endpoints are affected. Updated in place via `patch`.
118 */
119 reconcileConnections?: boolean;
120 /**
121 * Max Network Connectivity Center spokes a connected endpoint may
122 * propagate to. Default `250` when omitted.
123 */
124 propagatedConnectionLimit?: number;
125 /**
126 * NAT IPs allocated per connected endpoint.
127 * @default 1
128 */
129 natIpsPerEndpoint?: number;
130 /**
131 * DNS domain names used when integrating connected endpoints with
132 * Cloud DNS (max 1, for example `p.mycompany.com.`). Immutable —
133 * changing them replaces the attachment.
134 */
135 domainNames?: string[];
136 /**
137 * User labels. Packed into the description marker (and `metadata`)
138 * together with Alchemy ownership labels.
139 */
140 labels?: Record<string, string>;
141};
142
143export type ServiceAttachment = Resource<
144 "GCP.Compute.ServiceAttachment",
145 ServiceAttachmentProps,
146 {
147 /** Service attachment name. */
148 serviceAttachmentName: string;
149 /** Project id. */
150 project: string;
151 /** Region short name (`us-central1`). */
152 region: string;
153 /** Server-assigned numeric id. */
154 serviceAttachmentId: string | undefined;
155 /** Resource self-link. */
156 selfLink: string | undefined;
157 /** User description with the Alchemy ownership prefix stripped. */
158 description: string | undefined;
159 /** Producer forwarding rule / target service URL. */
160 targetService: string | undefined;
161 /** Producer forwarding-rule URL, if the API reports it separately. */
162 producerForwardingRule: string | undefined;
163 /** NAT subnet URLs. */
164 natSubnets: ReadonlyArray<string>;
165 /** Connection preference. */
166 connectionPreference: string | undefined;
167 /** Whether PROXY protocol is enabled. */
168 enableProxyProtocol: boolean;
169 /** Accepted consumers. */
170 consumerAcceptLists: ReadonlyArray<ServiceAttachmentConsumerAccept>;
171 /** Rejected projects or networks. */
172 consumerRejectLists: ReadonlyArray<string>;
173 /** Whether list changes reconcile existing endpoints. */
174 reconcileConnections: boolean;
175 /** Propagated connection limit. */
176 propagatedConnectionLimit: number | undefined;
177 /** NAT IPs per connected endpoint. */
178 natIpsPerEndpoint: number | undefined;
179 /** Cloud DNS domain names. */
180 domainNames: ReadonlyArray<string>;
181 /** Connected consumer endpoints. */
182 connectedEndpoints: ReadonlyArray<ServiceAttachmentConnectedEndpoint>;
183 /** 128-bit PSC service-attachment id. */
184 pscServiceAttachmentId:
185 | { high: string | undefined; low: string | undefined }
186 | undefined;
187 /** User labels (Alchemy ownership labels stripped). */
188 labels: Record<string, string>;
189 /** RFC3339 creation timestamp. */
190 creationTimestamp: string | undefined;
191 /** Resource kind. */
192 kind: string | undefined;
193 },
194 never,
195 Providers
196>;
197
198/**
199 * A regional Compute Engine Private Service Connect service attachment.
200 *
201 * A service attachment is how a producer exposes an internal load
202 * balancer (or other target service) to consumers over Private Service
203 * Connect. It points at a producer forwarding rule, lists NAT subnets
204 * with purpose `PRIVATE_SERVICE_CONNECT`, and admits consumers either
205 * automatically or via accept/reject lists.
206 *
207 * Compute ServiceAttachment has no labels field — Alchemy ownership is
208 * stored in the description so nuke can find leaked attachments.
209 *
210 * ### Creating a Service Attachment
211 * **Example:** Generated name, automatic accept
212 * ```typescript
213 * const attachment = yield* GCP.Compute.ServiceAttachment("Producer", {
214 * region: "us-central1",
215 * targetService: forwardingRule.selfLink,
216 * natSubnets: [natSubnet.selfLink],
217 * connectionPreference: "ACCEPT_AUTOMATIC",
218 * enableProxyProtocol: false,
219 * });
220 * ```
221 *
222 * **Example:** Named attachment with labels
223 * ```typescript
224 * const attachment = yield* GCP.Compute.ServiceAttachment("Producer", {
225 * serviceAttachmentName: "app-psc",
226 * region: "us-central1",
227 * targetService: forwardingRule.selfLink,
228 * natSubnets: [natSubnet.selfLink],
229 * description: "private service connect",
230 * labels: { env: "prod" },
231 * });
232 * ```
233 *
234 * ### Manual admission
235 * **Example:** Accept a consumer project
236 * ```typescript
237 * const attachment = yield* GCP.Compute.ServiceAttachment("Producer", {
238 * targetService: forwardingRule.selfLink,
239 * natSubnets: [natSubnet.selfLink],
240 * connectionPreference: "ACCEPT_MANUAL",
241 * consumerAcceptLists: [
242 * { projectIdOrNum: "my-consumer-project", connectionLimit: 10 },
243 * ],
244 * reconcileConnections: true,
245 * });
246 * ```
247 *
248 * @resource
249 * @category Compute
250 */
251export const ServiceAttachment = Resource<ServiceAttachment>(
252 "GCP.Compute.ServiceAttachment",
253);
254
255export class ServiceAttachmentNotResolved extends Data.TaggedError(
256 "GCP.Compute.ServiceAttachmentNotResolved",
257)<{
258 serviceAttachmentName: string;
259 region: string;
260}> {}
261
262export class ServiceAttachmentPending extends Data.TaggedError(
263 "GCP.Compute.ServiceAttachmentPending",
264)<{
265 serviceAttachmentName: string;
266 status: string;
267}> {}
268
269const userLabels = (
270 labels: Record<string, string | undefined> | null | undefined,
271): Record<string, string> => stripInternalLabels(tagRecord(labels));
272
273const lastSegment = (value: string | undefined) => {
274 if (!value) return "";
275 const trimmed = value.replace(/\/+$/, "");
276 const parts = trimmed.split("/");
277 return parts[parts.length - 1] || trimmed;
278};
279
280const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
281 lastSegment(region ?? defaultRegion).toLowerCase();
282
283const resourceRefOf = (value: string | undefined) => lastSegment(value);
284
285const refsKey = (values: ReadonlyArray<string> | undefined) =>
286 [...(values ?? [])]
287 .map((value) => resourceRefOf(value))
288 .filter((value) => value.length > 0)
289 .sort()
290 .join(",");
291
292const connectionPreferenceOf = (value: string | undefined) =>
293 value && value.length > 0 ? value : DEFAULT_CONNECTION_PREFERENCE;
294
295const domainNamesKey = (names: ReadonlyArray<string> | undefined) =>
296 [...(names ?? [])].map(String).sort().join(",");
297
298const acceptKey = (item: ServiceAttachmentConsumerAccept) =>
299 JSON.stringify({
300 projectIdOrNum: item.projectIdOrNum ?? "",
301 networkUrl: resourceRefOf(item.networkUrl),
302 endpointUrl: resourceRefOf(item.endpointUrl),
303 connectionLimit: item.connectionLimit ?? 0,
304 });
305
306const acceptListsKey = (
307 lists: ReadonlyArray<ServiceAttachmentConsumerAccept> | undefined,
308) => JSON.stringify([...(lists ?? [])].map(acceptKey).sort());
309
310const encodeDescription = (
311 user: string | undefined,
312 labels: Record<string, string>,
313): string => {
314 const packed = Object.entries(labels)
315 .sort(([left], [right]) => left.localeCompare(right))
316 .map(([key, value]) => `${key}=${value}`)
317 .join(" ");
318 const marker = `[alchemy ${packed}]`;
319 return user ? `${marker}\n${user}` : marker;
320};
321
322const parseDescription = (
323 description: string | undefined,
324): { labels: Record<string, string>; user: string | undefined } => {
325 if (!description?.startsWith("[alchemy ")) {
326 return { labels: {}, user: description };
327 }
328 const end = description.indexOf("]");
329 if (end < 0) return { labels: {}, user: description };
330 const labels: Record<string, string> = {};
331 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
332 const eq = part.indexOf("=");
333 if (eq > 0) {
334 labels[part.slice(0, eq)] = part.slice(eq + 1);
335 }
336 }
337 const rest = description.slice(end + 1).replace(/^\n/, "");
338 return { labels, user: rest.length > 0 ? rest : undefined };
339};
340
341const hasOwnershipMarker = (description: string | undefined): boolean =>
342 (description ?? "").startsWith("[alchemy ");
343
344const toName = (id: string, name: string | undefined, existing?: string) =>
345 Effect.gen(function* () {
346 if (name !== undefined) return name;
347 if (existing !== undefined) return existing;
348 const generated = yield* createPhysicalName({
349 id,
350 maxLength: MAX_NAME_LENGTH,
351 lowercase: true,
352 });
353 return /^[a-z]/.test(generated)
354 ? generated
355 : `s${generated}`.slice(0, MAX_NAME_LENGTH);
356 });
357
358const toForwardingRuleUrl = (
359 project: string,
360 region: string,
361 value: string,
362) => {
363 if (value.includes("/")) return value;
364 return `projects/${project}/regions/${region}/forwardingRules/${value}`;
365};
366
367const toSubnetworkUrl = (project: string, region: string, value: string) => {
368 if (value.includes("/")) return value;
369 return `projects/${project}/regions/${region}/subnetworks/${value}`;
370};
371
372const toConnectedEndpoint = (
373 endpoint: compute.ServiceAttachmentConnectedEndpoint,
374): ServiceAttachmentConnectedEndpoint => ({
375 status: endpoint.status,
376 endpoint: endpoint.endpoint,
377 consumerNetwork: endpoint.consumerNetwork,
378 pscConnectionId: endpoint.pscConnectionId,
379 propagatedConnectionCount: endpoint.propagatedConnectionCount,
380 natIps: endpoint.natIps ?? [],
381});
382
383const toAccept = (
384 item: compute.ServiceAttachmentConsumerProjectLimit,
385): ServiceAttachmentConsumerAccept => ({
386 projectIdOrNum: item.projectIdOrNum,
387 networkUrl: item.networkUrl,
388 endpointUrl: item.endpointUrl,
389 connectionLimit: item.connectionLimit,
390});
391
392const toAttrs = (
393 attachment: compute.ServiceAttachment,
394 project: string,
395): ServiceAttachment["Attributes"] => {
396 const decoded = parseDescription(attachment.description);
397 const fromMetadata = userLabels(attachment.metadata);
398 const fromDescription = userLabels(decoded.labels);
399 return {
400 serviceAttachmentName: attachment.name ?? "",
401 project,
402 region: lastSegment(attachment.region).toLowerCase(),
403 serviceAttachmentId: attachment.id,
404 selfLink: attachment.selfLink,
405 description: decoded.user,
406 targetService: attachment.targetService,
407 producerForwardingRule: attachment.producerForwardingRule,
408 natSubnets: attachment.natSubnets ?? [],
409 connectionPreference: attachment.connectionPreference,
410 enableProxyProtocol: attachment.enableProxyProtocol === true,
411 consumerAcceptLists: (attachment.consumerAcceptLists ?? []).map(toAccept),
412 consumerRejectLists: attachment.consumerRejectLists ?? [],
413 reconcileConnections: attachment.reconcileConnections === true,
414 propagatedConnectionLimit: attachment.propagatedConnectionLimit,
415 natIpsPerEndpoint: attachment.natIpsPerEndpoint,
416 domainNames: attachment.domainNames ?? [],
417 connectedEndpoints: (attachment.connectedEndpoints ?? []).map(
418 toConnectedEndpoint,
419 ),
420 pscServiceAttachmentId: attachment.pscServiceAttachmentId
421 ? {
422 high: attachment.pscServiceAttachmentId.high,
423 low: attachment.pscServiceAttachmentId.low,
424 }
425 : undefined,
426 labels:
427 Object.keys(fromMetadata).length > 0 ? fromMetadata : fromDescription,
428 creationTimestamp: attachment.creationTimestamp,
429 kind: attachment.kind,
430 };
431};
432
433const getByName = (
434 project: string,
435 region: string,
436 serviceAttachment: string,
437) =>
438 compute
439 .getServiceAttachments({ project, region, serviceAttachment })
440 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
441
442const requireAttachment = (
443 project: string,
444 region: string,
445 serviceAttachmentName: string,
446) =>
447 getByName(project, region, serviceAttachmentName).pipe(
448 Effect.flatMap((attachment) =>
449 attachment
450 ? Effect.succeed(attachment)
451 : Effect.fail(
452 new ServiceAttachmentNotResolved({
453 serviceAttachmentName,
454 region,
455 }),
456 ),
457 ),
458 Effect.retry({
459 while: (e) => e._tag === "GCP.Compute.ServiceAttachmentNotResolved",
460 schedule: Schedule.spaced("1 second"),
461 times: 8,
462 }),
463 );
464
465const waitUntilGone = (
466 project: string,
467 region: string,
468 serviceAttachmentName: string,
469) =>
470 getByName(project, region, serviceAttachmentName).pipe(
471 Effect.flatMap((attachment) =>
472 attachment === undefined
473 ? Effect.void
474 : Effect.fail(
475 new ServiceAttachmentPending({
476 serviceAttachmentName,
477 status: "EXISTS",
478 }),
479 ),
480 ),
481 Effect.retry({
482 while: (e) => e._tag === "GCP.Compute.ServiceAttachmentPending",
483 times: 10,
484 schedule: Schedule.spaced("2 seconds"),
485 }),
486 );
487
488const runOp = <E, R>(
489 project: string,
490 region: string,
491 serviceAttachmentName: string,
492 operation: Effect.Effect<compute.Operation, E, R>,
493) =>
494 operation.pipe(
495 Effect.flatMap((op) => waitRegionOperation(project, region, op)),
496 );
497
498export const ServiceAttachmentProvider = () =>
499 Provider.succeed(ServiceAttachment, {
500 stables: [
501 "serviceAttachmentName",
502 "project",
503 "region",
504 "serviceAttachmentId",
505 "selfLink",
506 "creationTimestamp",
507 ],
508
509 diff: Effect.fn(function* ({ news, olds, output }) {
510 if (!isResolved(news)) return undefined;
511 const env = yield* GcpEnvironment.current;
512 const previousName =
513 olds.serviceAttachmentName ?? output?.serviceAttachmentName;
514 const nextName = news.serviceAttachmentName ?? previousName;
515 const nameChanged =
516 previousName !== undefined &&
517 nextName !== undefined &&
518 nextName !== previousName;
519
520 const previousRegion = normalizeRegion(
521 olds.region ?? output?.region,
522 env.region,
523 );
524 const nextRegion = normalizeRegion(
525 news.region ?? output?.region,
526 env.region,
527 );
528 const regionChanged = previousRegion !== nextRegion;
529
530 const previousDomains = domainNamesKey(
531 olds.domainNames ?? output?.domainNames,
532 );
533 const nextDomains = domainNamesKey(
534 news.domainNames ?? olds.domainNames ?? output?.domainNames,
535 );
536 const domainChanged =
537 news.domainNames !== undefined && previousDomains !== nextDomains;
538
539 if (nameChanged || regionChanged) {
540 return { action: "replace" as const, deleteFirst: false };
541 }
542 if (domainChanged) {
543 return {
544 action: "replace" as const,
545 deleteFirst:
546 previousName !== undefined &&
547 (news.serviceAttachmentName === undefined ||
548 news.serviceAttachmentName === previousName),
549 };
550 }
551 return undefined;
552 }),
553
554 read: Effect.fn(function* ({ id, olds, output }) {
555 const env = yield* GcpEnvironment.current;
556 const serviceAttachmentName = yield* toName(
557 id,
558 olds?.serviceAttachmentName,
559 output?.serviceAttachmentName,
560 );
561 const region = normalizeRegion(
562 olds?.region ?? output?.region,
563 env.region,
564 );
565 const existing = yield* getByName(
566 env.project,
567 region,
568 serviceAttachmentName,
569 );
570 if (existing === undefined) return undefined;
571 const attrs = toAttrs(existing, env.project);
572 const decoded = parseDescription(existing.description);
573 const owned =
574 (yield* hasAlchemyLabels(id, decoded.labels)) ||
575 (yield* hasAlchemyLabels(id, tagRecord(existing.metadata)));
576 return owned ? attrs : Unowned(attrs);
577 }),
578
579 list: () =>
580 Effect.gen(function* () {
581 const env = yield* GcpEnvironment.current;
582 const pages = yield* compute.aggregatedListServiceAttachments
583 .pages({
584 project: env.project,
585 returnPartialSuccess: true,
586 maxResults: 500,
587 })
588 .pipe(Stream.runCollect);
589 return Array.from(pages).flatMap((page) =>
590 Object.values(page.items ?? {}).flatMap((scoped) =>
591 (scoped?.serviceAttachments ?? [])
592 .filter(
593 (item) =>
594 hasOwnershipMarker(item.description) ||
595 Object.keys(item.metadata ?? {}).some((key) =>
596 key.startsWith("alchemy-"),
597 ),
598 )
599 .map((item) => toAttrs(item, env.project)),
600 ),
601 );
602 }),
603
604 reconcile: Effect.fn(function* ({ id, news, output }) {
605 const env = yield* GcpEnvironment.current;
606 const serviceAttachmentName = yield* toName(
607 id,
608 news.serviceAttachmentName,
609 output?.serviceAttachmentName,
610 );
611 const region = normalizeRegion(news.region ?? output?.region, env.region);
612 const desiredLabels = {
613 ...toLabels(news.labels),
614 ...(yield* createInternalLabels(id)),
615 };
616 const desiredDescription = encodeDescription(
617 news.description,
618 desiredLabels,
619 );
620 const targetService = toForwardingRuleUrl(
621 env.project,
622 region,
623 news.targetService,
624 );
625 const natSubnets = news.natSubnets.map((subnet) =>
626 toSubnetworkUrl(env.project, region, subnet),
627 );
628 const connectionPreference = connectionPreferenceOf(
629 news.connectionPreference,
630 );
631 const enableProxyProtocol = news.enableProxyProtocol === true;
632
633 let current = yield* getByName(
634 env.project,
635 region,
636 serviceAttachmentName,
637 );
638
639 if (current === undefined) {
640 const created = yield* compute
641 .insertServiceAttachments({
642 project: env.project,
643 region,
644 body: {
645 name: serviceAttachmentName,
646 description: desiredDescription,
647 targetService,
648 natSubnets,
649 connectionPreference,
650 enableProxyProtocol,
651 consumerAcceptLists: news.consumerAcceptLists,
652 consumerRejectLists: news.consumerRejectLists,
653 reconcileConnections: news.reconcileConnections,
654 propagatedConnectionLimit: news.propagatedConnectionLimit,
655 natIpsPerEndpoint: news.natIpsPerEndpoint,
656 domainNames: news.domainNames,
657 metadata: desiredLabels,
658 },
659 })
660 .pipe(
661 Effect.flatMap((operation) =>
662 waitRegionOperation(env.project, region, operation, {
663 ignore: ["RESOURCE_ALREADY_EXISTS"],
664 }).pipe(
665 Effect.flatMap(() =>
666 requireAttachment(env.project, region, serviceAttachmentName),
667 ),
668 ),
669 ),
670 Effect.catchTag("Conflict", () =>
671 requireAttachment(env.project, region, serviceAttachmentName),
672 ),
673 );
674 current = created ?? undefined;
675 }
676
677 if (current === undefined) {
678 return yield* new ServiceAttachmentNotResolved({
679 serviceAttachmentName,
680 region,
681 });
682 }
683
684 const observedPreference = connectionPreferenceOf(
685 current.connectionPreference,
686 );
687 const observedTarget = resourceRefOf(
688 current.targetService ?? current.producerForwardingRule,
689 );
690 const observedNat = refsKey(current.natSubnets);
691 const observedAccept = acceptListsKey(
692 (current.consumerAcceptLists ?? []).map(toAccept),
693 );
694 const observedReject = refsKey(current.consumerRejectLists);
695 const decoded = parseDescription(current.description);
696 const observedMetadata = tagRecord(current.metadata);
697 const labelsChanged =
698 JSON.stringify(observedMetadata) !== JSON.stringify(desiredLabels) ||
699 decoded.user !== (news.description ?? undefined) ||
700 JSON.stringify(userLabels(decoded.labels)) !==
701 JSON.stringify(userLabels(desiredLabels));
702
703 const needsPatch =
704 labelsChanged ||
705 observedPreference !== connectionPreference ||
706 observedTarget !== resourceRefOf(targetService) ||
707 observedNat !== refsKey(natSubnets) ||
708 (current.enableProxyProtocol === true) !== enableProxyProtocol ||
709 (news.consumerAcceptLists !== undefined &&
710 observedAccept !== acceptListsKey(news.consumerAcceptLists)) ||
711 (news.consumerRejectLists !== undefined &&
712 observedReject !== refsKey(news.consumerRejectLists)) ||
713 (news.reconcileConnections !== undefined &&
714 (current.reconcileConnections === true) !==
715 news.reconcileConnections) ||
716 (news.propagatedConnectionLimit !== undefined &&
717 (current.propagatedConnectionLimit ?? 250) !==
718 news.propagatedConnectionLimit) ||
719 (news.natIpsPerEndpoint !== undefined &&
720 (current.natIpsPerEndpoint ?? 1) !== news.natIpsPerEndpoint);
721
722 if (needsPatch) {
723 const latest =
724 (yield* getByName(env.project, region, serviceAttachmentName)) ??
725 current;
726 yield* runOp(
727 env.project,
728 region,
729 serviceAttachmentName,
730 compute.patchServiceAttachments({
731 project: env.project,
732 region,
733 serviceAttachment: serviceAttachmentName,
734 body: {
735 name: serviceAttachmentName,
736 fingerprint: latest.fingerprint,
737 description: desiredDescription,
738 targetService,
739 natSubnets,
740 connectionPreference,
741 enableProxyProtocol,
742 consumerAcceptLists:
743 news.consumerAcceptLists ?? current.consumerAcceptLists,
744 consumerRejectLists:
745 news.consumerRejectLists ?? current.consumerRejectLists,
746 reconcileConnections:
747 news.reconcileConnections ?? current.reconcileConnections,
748 propagatedConnectionLimit:
749 news.propagatedConnectionLimit ??
750 current.propagatedConnectionLimit,
751 natIpsPerEndpoint:
752 news.natIpsPerEndpoint ?? current.natIpsPerEndpoint,
753 metadata: desiredLabels,
754 },
755 }),
756 ).pipe(
757 Effect.retry({
758 while: (e) => e._tag === "Conflict",
759 times: 5,
760 schedule: Schedule.spaced("1 second"),
761 }),
762 );
763 current =
764 (yield* getByName(env.project, region, serviceAttachmentName)) ??
765 current;
766 }
767
768 return toAttrs(current, env.project);
769 }),
770
771 delete: Effect.fn(function* ({ output }) {
772 const env = yield* GcpEnvironment.current;
773 const project = output.project || env.project;
774 const region = normalizeRegion(output.region, env.region);
775 yield* compute
776 .deleteServiceAttachments({
777 project,
778 region,
779 serviceAttachment: output.serviceAttachmentName,
780 })
781 .pipe(
782 Effect.flatMap((operation) =>
783 waitRegionOperation(project, region, operation, {
784 ignore: ["RESOURCE_NOT_FOUND"],
785 }),
786 ),
787 Effect.catchTag("NotFound", () => Effect.void),
788 Effect.retry({
789 while: (e) => e._tag === "Conflict",
790 times: 8,
791 schedule: Schedule.spaced("2 seconds"),
792 }),
793 );
794 yield* waitUntilGone(project, region, output.serviceAttachmentName);
795 }),
796 });