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