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