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