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 = "interceptEndpointGroupAssociations";
34
35export type InterceptEndpointGroupAssociationLocationDetail = {
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 InterceptEndpointGroupAssociationProps = {
43 /**
44 * Association id (the `{interceptEndpointGroupAssociation}` segment of
45 * `projects/{project}/locations/{location}/interceptEndpointGroupAssociations/{interceptEndpointGroupAssociation}`).
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 interceptEndpointGroupAssociationId?: 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 interceptEndpointGroup: 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 InterceptEndpointGroupAssociation = Resource<
74 "GCP.NetworkSecurity.InterceptEndpointGroupAssociation",
75 InterceptEndpointGroupAssociationProps,
76 {
77 /** Full resource name. */
78 name: string;
79 /** Association id (last path segment). */
80 interceptEndpointGroupAssociationId: string;
81 /** Project id. */
82 project: string;
83 /** Location id. Always `"global"`. */
84 location: string;
85 /** Connected endpoint group resource name. */
86 interceptEndpointGroup: string | undefined;
87 /** Associated VPC resource path. */
88 network: string | undefined;
89 /** VPC network id (last path segment). */
90 networkName: string | undefined;
91 /** Data-path network cookie used by GENEVE. */
92 networkCookie: number | undefined;
93 /** User labels (Alchemy ownership labels stripped). */
94 labels: Record<string, string>;
95 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
96 state: string | undefined;
97 /** Whether the API is still reconciling intended vs actual state. */
98 reconciling: boolean;
99 /** Locations configured on the linked endpoint group. */
100 locations: InterceptEndpointGroupAssociationLocationDetail[];
101 /** Locations where the association is present. */
102 locationsDetails: InterceptEndpointGroupAssociationLocationDetail[];
103 /** RFC3339 creation timestamp. */
104 createTime: string | undefined;
105 /** RFC3339 last-update timestamp. */
106 updateTime: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Network Security Integration intercept endpoint-group association —
114 * the link between a VPC and an intercept endpoint group.
115 *
116 * Creating an association does not enable intercept by itself; a firewall
117 * policy with intercept rules must also target the network. Changing
118 * `interceptEndpointGroupAssociationId`, `location`,
119 * `interceptEndpointGroup`, or `network` replaces the association. Labels
120 * update in place.
121 *
122 * ### Creating an Association
123 * **Example:** Generated name
124 * ```typescript
125 * const association = yield* GCP.NetworkSecurity.InterceptEndpointGroupAssociation("Link", {
126 * interceptEndpointGroup: endpoints.name,
127 * network: vpc.selfLink,
128 * });
129 * ```
130 *
131 * **Example:** Named association with labels
132 * ```typescript
133 * const association = yield* GCP.NetworkSecurity.InterceptEndpointGroupAssociation("Link", {
134 * interceptEndpointGroupAssociationId: "app-intercept-ega",
135 * interceptEndpointGroup: endpoints.name,
136 * network: "projects/my-project/global/networks/app-vpc",
137 * labels: { env: "prod" },
138 * });
139 * ```
140 *
141 * ### Updating an Association
142 * **Example:** Labels
143 * ```typescript
144 * const association = yield* GCP.NetworkSecurity.InterceptEndpointGroupAssociation("Link", {
145 * interceptEndpointGroupAssociationId: "app-intercept-ega",
146 * interceptEndpointGroup: endpoints.name,
147 * network: vpc.selfLink,
148 * labels: { env: "prod", role: "nsi" },
149 * });
150 * ```
151 *
152 * @resource
153 * @category NetworkSecurity
154 */
155export const InterceptEndpointGroupAssociation =
156 Resource<InterceptEndpointGroupAssociation>(
157 "GCP.NetworkSecurity.InterceptEndpointGroupAssociation",
158 );
159
160export class InterceptEndpointGroupAssociationNotResolved extends Data.TaggedError(
161 "GCP.NetworkSecurity.InterceptEndpointGroupAssociationNotResolved",
162)<{
163 name: string;
164}> {}
165
166export class InterceptEndpointGroupAssociationFailed extends Data.TaggedError(
167 "GCP.NetworkSecurity.InterceptEndpointGroupAssociationFailed",
168)<{
169 name: string;
170 state: string | undefined;
171}> {}
172
173export class InterceptEndpointGroupAssociationStillExists extends Data.TaggedError(
174 "GCP.NetworkSecurity.InterceptEndpointGroupAssociationStillExists",
175)<{
176 name: string;
177}> {}
178
179const isPendingState = (state: string | undefined) =>
180 state === "CREATING" || state === "DELETING" || state === "STATE_UNSPECIFIED";
181
182const toLocationDetails = (
183 details:
184 | networksecurity.InterceptLocationList
185 | networksecurity.InterceptEndpointGroupAssociationLocationDetailsList
186 | undefined,
187): InterceptEndpointGroupAssociationLocationDetail[] =>
188 (details ?? []).map((item) => ({
189 location: item.location,
190 state: item.state,
191 }));
192
193const toAttrs = (
194 association: networksecurity.InterceptEndpointGroupAssociation,
195 project: string,
196) => {
197 const name = association.name ?? "";
198 const parsed = parseResourceName(name, COLLECTION);
199 return {
200 name,
201 interceptEndpointGroupAssociationId: parsed.id,
202 project: parsed.project || project,
203 location: parsed.location || DEFAULT_LOCATION,
204 interceptEndpointGroup: association.interceptEndpointGroup,
205 network: association.network,
206 networkName: association.network
207 ? lastSegment(association.network)
208 : undefined,
209 networkCookie: association.networkCookie,
210 labels: userLabels(association.labels),
211 state: association.state,
212 reconciling: association.reconciling === true,
213 locations: toLocationDetails(association.locations),
214 locationsDetails: toLocationDetails(association.locationsDetails),
215 createTime: association.createTime,
216 updateTime: association.updateTime,
217 };
218};
219
220const getByName = (name: string) =>
221 networksecurity
222 .getProjectsLocationsInterceptEndpointGroupAssociations({ name })
223 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
224
225const waitUntilReady = (name: string) =>
226 getByName(name).pipe(
227 Effect.filterOrFail(
228 (
229 association,
230 ): association is networksecurity.InterceptEndpointGroupAssociation =>
231 association !== undefined,
232 () => new InterceptEndpointGroupAssociationNotResolved({ name }),
233 ),
234 Effect.filterOrFail(
235 (association) => association.state !== "DELETE_FAILED",
236 (association) =>
237 new InterceptEndpointGroupAssociationFailed({
238 name,
239 state: association.state,
240 }),
241 ),
242 Effect.filterOrFail(
243 (association) => !isPendingState(association.state),
244 () => new InterceptEndpointGroupAssociationNotResolved({ name }),
245 ),
246 Effect.retry({
247 while: (error) =>
248 error._tag ===
249 "GCP.NetworkSecurity.InterceptEndpointGroupAssociationNotResolved",
250 times: 10,
251 schedule: Schedule.spaced("2 seconds"),
252 }),
253 );
254
255const waitUntilGone = (name: string) =>
256 getByName(name).pipe(
257 Effect.flatMap((association) =>
258 association === undefined
259 ? Effect.void
260 : Effect.fail(
261 new InterceptEndpointGroupAssociationStillExists({ name }),
262 ),
263 ),
264 Effect.retry({
265 while: (error) =>
266 error._tag ===
267 "GCP.NetworkSecurity.InterceptEndpointGroupAssociationStillExists",
268 times: 10,
269 schedule: Schedule.spaced("2 seconds"),
270 }),
271 );
272
273const listOwned = (project: string) =>
274 networksecurity.listProjectsLocationsInterceptEndpointGroupAssociations
275 .pages({
276 parent: parentOf(project, "-"),
277 pageSize: 1000,
278 })
279 .pipe(
280 Stream.flatMap((page) =>
281 Stream.fromIterable(page.interceptEndpointGroupAssociations ?? []),
282 ),
283 Stream.filter((association) =>
284 Object.keys(association.labels ?? {}).some((key) =>
285 key.startsWith("alchemy-"),
286 ),
287 ),
288 Stream.map((association) => toAttrs(association, project)),
289 Stream.runCollect,
290 Effect.map((chunk) => Array.from(chunk)),
291 Effect.catchTag("NotFound", () => Effect.succeed([])),
292 );
293
294export const InterceptEndpointGroupAssociationProvider = () =>
295 Provider.succeed(InterceptEndpointGroupAssociation, {
296 stables: [
297 "name",
298 "interceptEndpointGroupAssociationId",
299 "project",
300 "location",
301 "interceptEndpointGroup",
302 "network",
303 "networkCookie",
304 "createTime",
305 ],
306
307 diff: Effect.fn(function* ({ news, olds, output }) {
308 if (!isResolved(news)) return undefined;
309 const previousId =
310 olds?.interceptEndpointGroupAssociationId ??
311 output?.interceptEndpointGroupAssociationId;
312 const nextId = news.interceptEndpointGroupAssociationId ?? previousId;
313 const previousLocation = normalizeLocation(
314 olds?.location ?? output?.location,
315 );
316 const nextLocation = normalizeLocation(
317 news.location ?? olds?.location ?? output?.location,
318 );
319 const previousGroup = lastSegment(
320 olds?.interceptEndpointGroup ?? output?.interceptEndpointGroup ?? "",
321 );
322 const nextGroup = lastSegment(news.interceptEndpointGroup);
323 const previousNetwork = lastSegment(
324 olds?.network ?? output?.networkName ?? output?.network ?? "",
325 );
326 const nextNetwork = lastSegment(news.network);
327 const replace =
328 (previousId !== undefined &&
329 nextId !== undefined &&
330 nextId !== previousId) ||
331 previousLocation !== nextLocation ||
332 (previousGroup.length > 0 && previousGroup !== nextGroup) ||
333 (previousNetwork.length > 0 && previousNetwork !== nextNetwork);
334 if (!replace) return undefined;
335 return { action: "replace" as const };
336 }),
337
338 read: Effect.fn(function* ({ id, olds, output }) {
339 const env = yield* GcpEnvironment.current;
340 const interceptEndpointGroupAssociationId = yield* toId(
341 id,
342 olds?.interceptEndpointGroupAssociationId,
343 output?.interceptEndpointGroupAssociationId,
344 "iega",
345 );
346 const location = normalizeLocation(olds?.location ?? output?.location);
347 const name =
348 output?.name ??
349 resourceName(
350 env.project,
351 location,
352 COLLECTION,
353 interceptEndpointGroupAssociationId,
354 );
355 const existing = yield* getByName(name);
356 if (existing === undefined) return undefined;
357 const attrs = toAttrs(existing, env.project);
358 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
359 ? attrs
360 : Unowned(attrs);
361 }),
362
363 list: () =>
364 Effect.gen(function* () {
365 const env = yield* GcpEnvironment.current;
366 return yield* listOwned(env.project);
367 }),
368
369 reconcile: Effect.fn(function* ({ id, news, output }) {
370 const env = yield* GcpEnvironment.current;
371 const interceptEndpointGroupAssociationId = yield* toId(
372 id,
373 news.interceptEndpointGroupAssociationId,
374 output?.interceptEndpointGroupAssociationId,
375 "iega",
376 );
377 const location = normalizeLocation(news.location ?? output?.location);
378 const name = resourceName(
379 env.project,
380 location,
381 COLLECTION,
382 interceptEndpointGroupAssociationId,
383 );
384 const interceptEndpointGroup = toResourcePath(
385 news.interceptEndpointGroup,
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 .createProjectsLocationsInterceptEndpointGroupAssociations({
398 parent: parentOf(env.project, location),
399 interceptEndpointGroupAssociationId,
400 body: {
401 interceptEndpointGroup,
402 network,
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 InterceptEndpointGroupAssociationNotResolved({
422 name,
423 });
424 }
425
426 const observedLabels = tagRecord(current.labels);
427 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
428 const labelsChanged = upsert.length > 0 || removed.length > 0;
429
430 if (labelsChanged) {
431 const operation =
432 yield* networksecurity.patchProjectsLocationsInterceptEndpointGroupAssociations(
433 {
434 name: current.name ?? name,
435 updateMask: "labels",
436 body: {
437 name: current.name ?? name,
438 labels: desiredLabels,
439 },
440 },
441 );
442 yield* waitForOperation(operation);
443 current = yield* waitUntilReady(current.name ?? name);
444 }
445
446 return toAttrs(current, env.project);
447 }),
448
449 delete: Effect.fn(function* ({ output }) {
450 const operation = yield* networksecurity
451 .deleteProjectsLocationsInterceptEndpointGroupAssociations({
452 name: output.name,
453 })
454 .pipe(
455 Effect.retry({
456 while: (error) => error._tag === "Conflict",
457 times: 8,
458 schedule: Schedule.spaced("2 seconds"),
459 }),
460 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
461 );
462 if (operation !== undefined) {
463 yield* waitForOperation(operation, { notFoundOk: true });
464 }
465 yield* waitUntilGone(output.name);
466 }),
467 });