1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 DEFAULT_LOCATION,
21 lastSegment,
22 normalizeLocation,
23 parentOf,
24 parseResourceName,
25 resourceName,
26 toId,
27 toNetworkResource,
28 toResourcePath,
29 userLabels,
30} from "./names.ts";
31import { waitForOperation } from "./operations.ts";
32
33const COLLECTION = "mirroringEndpointGroupAssociations";
34
35export type MirroringEndpointGroupAssociationLocationDetail = {
36 /** Cloud location, e.g. `us-central1-a`. */
37 location: string | undefined;
38 /** Association state in this location (`ACTIVE`, `OUT_OF_SYNC`, …). */
39 state: string | undefined;
40};
41
42export type MirroringEndpointGroupAssociationProps = {
43 /**
44 * Association id (the `{mirroringEndpointGroupAssociation}` segment of
45 * `projects/{project}/locations/{location}/mirroringEndpointGroupAssociations/{mirroringEndpointGroupAssociation}`).
46 * If omitted, a unique RFC1035 name is generated from the stack, stage,
47 * and logical id. Immutable — changing it replaces the association.
48 */
49 mirroringEndpointGroupAssociationId?: string;
50 /**
51 * Location of the association. Associations are global — always
52 * `"global"`. Immutable — changing it replaces the association.
53 * `GLOBAL` is accepted and normalized to `global`.
54 * @default "global"
55 */
56 location?: string;
57 /**
58 * Endpoint group this association is connected to, as a full resource
59 * name. Immutable — changing it replaces the association.
60 */
61 mirroringEndpointGroup: string;
62 /**
63 * VPC to associate, as a name (`app-vpc`), resource path, or Compute
64 * self-link. Immutable — changing it replaces the association.
65 */
66 network: string;
67 /**
68 * User labels. Alchemy ownership labels are merged in automatically.
69 */
70 labels?: Record<string, string>;
71};
72
73export type MirroringEndpointGroupAssociation = Resource<
74 "GCP.NetworkSecurity.MirroringEndpointGroupAssociation",
75 MirroringEndpointGroupAssociationProps,
76 {
77 /** Full resource name. */
78 name: string;
79 /** Association id (last path segment). */
80 mirroringEndpointGroupAssociationId: string;
81 /** Project id. */
82 project: string;
83 /** Location id. Always `"global"`. */
84 location: string;
85 /** Connected endpoint group resource name. */
86 mirroringEndpointGroup: string | undefined;
87 /** Associated VPC resource path. */
88 network: string | undefined;
89 /** VPC network id (last path segment). */
90 networkName: string | undefined;
91 /** User labels (Alchemy ownership labels stripped). */
92 labels: Record<string, string>;
93 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
94 state: string | undefined;
95 /** Whether the API is still reconciling intended vs actual state. */
96 reconciling: boolean;
97 /** Locations configured on the linked endpoint group. */
98 locations: MirroringEndpointGroupAssociationLocationDetail[];
99 /** Locations where the association is present. */
100 locationsDetails: MirroringEndpointGroupAssociationLocationDetail[];
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A Network Security Integration mirroring endpoint-group association —
112 * the link between a VPC and a mirroring endpoint group.
113 *
114 * Creating an association does not enable mirroring by itself; a firewall
115 * policy with mirroring rules must also target the network. Changing
116 * `mirroringEndpointGroupAssociationId`, `location`,
117 * `mirroringEndpointGroup`, or `network` replaces the association. Labels
118 * update in place.
119 *
120 * ### Creating an Association
121 * **Example:** Generated name
122 * ```typescript
123 * const association = yield* GCP.NetworkSecurity.MirroringEndpointGroupAssociation("Link", {
124 * mirroringEndpointGroup: endpoints.name,
125 * network: vpc.selfLink,
126 * });
127 * ```
128 *
129 * **Example:** Named association with labels
130 * ```typescript
131 * const association = yield* GCP.NetworkSecurity.MirroringEndpointGroupAssociation("Link", {
132 * mirroringEndpointGroupAssociationId: "app-mirroring-ega",
133 * mirroringEndpointGroup: endpoints.name,
134 * network: "projects/my-project/global/networks/app-vpc",
135 * labels: { env: "prod" },
136 * });
137 * ```
138 *
139 * ### Updating an Association
140 * **Example:** Labels
141 * ```typescript
142 * const association = yield* GCP.NetworkSecurity.MirroringEndpointGroupAssociation("Link", {
143 * mirroringEndpointGroupAssociationId: "app-mirroring-ega",
144 * mirroringEndpointGroup: endpoints.name,
145 * network: vpc.selfLink,
146 * labels: { env: "prod", role: "nsi" },
147 * });
148 * ```
149 *
150 * @resource
151 * @category NetworkSecurity
152 */
153export const MirroringEndpointGroupAssociation =
154 Resource<MirroringEndpointGroupAssociation>(
155 "GCP.NetworkSecurity.MirroringEndpointGroupAssociation",
156 );
157
158export class MirroringEndpointGroupAssociationNotResolved extends Data.TaggedError(
159 "GCP.NetworkSecurity.MirroringEndpointGroupAssociationNotResolved",
160)<{
161 name: string;
162}> {}
163
164export class MirroringEndpointGroupAssociationFailed extends Data.TaggedError(
165 "GCP.NetworkSecurity.MirroringEndpointGroupAssociationFailed",
166)<{
167 name: string;
168 state: string | undefined;
169}> {}
170
171export class MirroringEndpointGroupAssociationStillExists extends Data.TaggedError(
172 "GCP.NetworkSecurity.MirroringEndpointGroupAssociationStillExists",
173)<{
174 name: string;
175}> {}
176
177const isPendingState = (state: string | undefined) =>
178 state === "CREATING" || state === "DELETING" || state === "STATE_UNSPECIFIED";
179
180const toLocationDetails = (
181 details:
182 | networksecurity.MirroringLocationList
183 | networksecurity.MirroringEndpointGroupAssociationLocationDetailsList
184 | undefined,
185): MirroringEndpointGroupAssociationLocationDetail[] =>
186 (details ?? []).map((item) => ({
187 location: item.location,
188 state: item.state,
189 }));
190
191const toAttrs = (
192 association: networksecurity.MirroringEndpointGroupAssociation,
193 project: string,
194) => {
195 const name = association.name ?? "";
196 const parsed = parseResourceName(name, COLLECTION);
197 return {
198 name,
199 mirroringEndpointGroupAssociationId: parsed.id,
200 project: parsed.project || project,
201 location: parsed.location || DEFAULT_LOCATION,
202 mirroringEndpointGroup: association.mirroringEndpointGroup,
203 network: association.network,
204 networkName: association.network
205 ? lastSegment(association.network)
206 : undefined,
207 labels: userLabels(association.labels),
208 state: association.state,
209 reconciling: association.reconciling === true,
210 locations: toLocationDetails(association.locations),
211 locationsDetails: toLocationDetails(association.locationsDetails),
212 createTime: association.createTime,
213 updateTime: association.updateTime,
214 };
215};
216
217const getByName = (name: string) =>
218 networksecurity
219 .getProjectsLocationsMirroringEndpointGroupAssociations({ name })
220 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
221
222const waitUntilReady = (name: string) =>
223 getByName(name).pipe(
224 Effect.filterOrFail(
225 (
226 association,
227 ): association is networksecurity.MirroringEndpointGroupAssociation =>
228 association !== undefined,
229 () => new MirroringEndpointGroupAssociationNotResolved({ name }),
230 ),
231 Effect.filterOrFail(
232 (association) => association.state !== "DELETE_FAILED",
233 (association) =>
234 new MirroringEndpointGroupAssociationFailed({
235 name,
236 state: association.state,
237 }),
238 ),
239 Effect.filterOrFail(
240 (association) => !isPendingState(association.state),
241 () => new MirroringEndpointGroupAssociationNotResolved({ name }),
242 ),
243 Effect.retry({
244 while: (error) =>
245 error._tag ===
246 "GCP.NetworkSecurity.MirroringEndpointGroupAssociationNotResolved",
247 times: 10,
248 schedule: Schedule.spaced("2 seconds"),
249 }),
250 );
251
252const waitUntilGone = (name: string) =>
253 getByName(name).pipe(
254 Effect.flatMap((association) =>
255 association === undefined
256 ? Effect.void
257 : Effect.fail(
258 new MirroringEndpointGroupAssociationStillExists({ name }),
259 ),
260 ),
261 Effect.retry({
262 while: (error) =>
263 error._tag ===
264 "GCP.NetworkSecurity.MirroringEndpointGroupAssociationStillExists",
265 times: 10,
266 schedule: Schedule.spaced("2 seconds"),
267 }),
268 );
269
270const listOwned = (project: string) =>
271 networksecurity.listProjectsLocationsMirroringEndpointGroupAssociations
272 .pages({
273 parent: parentOf(project, "-"),
274 pageSize: 1000,
275 })
276 .pipe(
277 Stream.flatMap((page) =>
278 Stream.fromIterable(page.mirroringEndpointGroupAssociations ?? []),
279 ),
280 Stream.filter((association) =>
281 Object.keys(association.labels ?? {}).some((key) =>
282 key.startsWith("alchemy-"),
283 ),
284 ),
285 Stream.map((association) => toAttrs(association, project)),
286 Stream.runCollect,
287 Effect.map((chunk) => Array.from(chunk)),
288 Effect.catchTag("NotFound", () => Effect.succeed([])),
289 );
290
291export const MirroringEndpointGroupAssociationProvider = () =>
292 Provider.succeed(MirroringEndpointGroupAssociation, {
293 stables: [
294 "name",
295 "mirroringEndpointGroupAssociationId",
296 "project",
297 "location",
298 "mirroringEndpointGroup",
299 "network",
300 "createTime",
301 ],
302
303 diff: Effect.fn(function* ({ news, olds, output }) {
304 if (!isResolved(news)) return undefined;
305 const previousId =
306 olds?.mirroringEndpointGroupAssociationId ??
307 output?.mirroringEndpointGroupAssociationId;
308 const nextId = news.mirroringEndpointGroupAssociationId ?? previousId;
309 const previousLocation = normalizeLocation(
310 olds?.location ?? output?.location,
311 );
312 const nextLocation = normalizeLocation(
313 news.location ?? olds?.location ?? output?.location,
314 );
315 const previousGroup = lastSegment(
316 olds?.mirroringEndpointGroup ?? output?.mirroringEndpointGroup ?? "",
317 );
318 const nextGroup = lastSegment(news.mirroringEndpointGroup);
319 const previousNetwork = lastSegment(
320 olds?.network ?? output?.networkName ?? output?.network ?? "",
321 );
322 const nextNetwork = lastSegment(news.network);
323 const replace =
324 (previousId !== undefined &&
325 nextId !== undefined &&
326 nextId !== previousId) ||
327 previousLocation !== nextLocation ||
328 (previousGroup.length > 0 && previousGroup !== nextGroup) ||
329 (previousNetwork.length > 0 && previousNetwork !== nextNetwork);
330 if (!replace) return undefined;
331 return { action: "replace" as const };
332 }),
333
334 read: Effect.fn(function* ({ id, olds, output }) {
335 const env = yield* GcpEnvironment.current;
336 const mirroringEndpointGroupAssociationId = yield* toId(
337 id,
338 olds?.mirroringEndpointGroupAssociationId,
339 output?.mirroringEndpointGroupAssociationId,
340 "mega",
341 );
342 const location = normalizeLocation(olds?.location ?? output?.location);
343 const name =
344 output?.name ??
345 resourceName(
346 env.project,
347 location,
348 COLLECTION,
349 mirroringEndpointGroupAssociationId,
350 );
351 const existing = yield* getByName(name);
352 if (existing === undefined) return undefined;
353 const attrs = toAttrs(existing, env.project);
354 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
355 ? attrs
356 : Unowned(attrs);
357 }),
358
359 list: () =>
360 Effect.gen(function* () {
361 const env = yield* GcpEnvironment.current;
362 return yield* listOwned(env.project);
363 }),
364
365 reconcile: Effect.fn(function* ({ id, news, output }) {
366 const env = yield* GcpEnvironment.current;
367 const mirroringEndpointGroupAssociationId = yield* toId(
368 id,
369 news.mirroringEndpointGroupAssociationId,
370 output?.mirroringEndpointGroupAssociationId,
371 "mega",
372 );
373 const location = normalizeLocation(news.location ?? output?.location);
374 const name = resourceName(
375 env.project,
376 location,
377 COLLECTION,
378 mirroringEndpointGroupAssociationId,
379 );
380 const mirroringEndpointGroup = toResourcePath(
381 news.mirroringEndpointGroup,
382 );
383 const network = toNetworkResource(env.project, news.network);
384 const desiredLabels = {
385 ...toLabels(news.labels),
386 ...(yield* createInternalLabels(id)),
387 };
388
389 let current = yield* getByName(name);
390
391 if (current === undefined) {
392 const created = yield* networksecurity
393 .createProjectsLocationsMirroringEndpointGroupAssociations({
394 parent: parentOf(env.project, location),
395 mirroringEndpointGroupAssociationId,
396 body: {
397 mirroringEndpointGroup,
398 network,
399 labels: desiredLabels,
400 },
401 })
402 .pipe(
403 Effect.retry({
404 while: (error) => error._tag === "Conflict",
405 times: 5,
406 schedule: Schedule.spaced("2 seconds"),
407 }),
408 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
409 );
410 if (created !== undefined) {
411 yield* waitForOperation(created);
412 }
413 current = yield* waitUntilReady(name);
414 }
415
416 if (current === undefined) {
417 return yield* new MirroringEndpointGroupAssociationNotResolved({
418 name,
419 });
420 }
421
422 const observedLabels = tagRecord(current.labels);
423 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
424 const labelsChanged = upsert.length > 0 || removed.length > 0;
425
426 if (labelsChanged) {
427 const operation =
428 yield* networksecurity.patchProjectsLocationsMirroringEndpointGroupAssociations(
429 {
430 name: current.name ?? name,
431 updateMask: "labels",
432 body: {
433 name: current.name ?? name,
434 labels: desiredLabels,
435 },
436 },
437 );
438 yield* waitForOperation(operation);
439 current = yield* waitUntilReady(current.name ?? name);
440 }
441
442 return toAttrs(current, env.project);
443 }),
444
445 delete: Effect.fn(function* ({ output }) {
446 const operation = yield* networksecurity
447 .deleteProjectsLocationsMirroringEndpointGroupAssociations({
448 name: output.name,
449 })
450 .pipe(
451 Effect.retry({
452 while: (error) => error._tag === "Conflict",
453 times: 8,
454 schedule: Schedule.spaced("2 seconds"),
455 }),
456 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
457 );
458 if (operation !== undefined) {
459 yield* waitForOperation(operation, { notFoundOk: true });
460 }
461 yield* waitUntilGone(output.name);
462 }),
463 });