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