1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_ZONE,
19 canonicalizeLink,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 linkKey,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 rfc1035,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "firewallEndpointAssociations";
36
37export type FirewallEndpointAssociationState =
38 | networksecurity.FirewallEndpointAssociationStateEnum
39 | (string & {});
40
41export type FirewallEndpointAssociationProps = {
42 /**
43 * Association id (the `{firewallEndpointAssociation}` segment of
44 * `projects/{project}/locations/{location}/firewallEndpointAssociations/{firewallEndpointAssociation}`).
45 * If omitted, a unique name is generated from the stack, stage, and
46 * logical id. Immutable — changing it replaces the association.
47 */
48 firewallEndpointAssociationId?: string;
49 /**
50 * Zone of the association (`us-central1-a`, …). Immutable — changing
51 * it replaces the association. `US-CENTRAL1-A` is accepted and
52 * normalized to `us-central1-a`.
53 * @default "us-central1-a"
54 */
55 location?: string;
56 /**
57 * FirewallEndpoint resource name
58 * (`projects/{project}/locations/{location}/firewallEndpoints/{firewallEndpoint}`).
59 * Immutable — changing it replaces the association.
60 */
61 firewallEndpoint: string;
62 /**
63 * VPC network resource name
64 * (`projects/{project}/global/networks/{network}`). Immutable —
65 * changing it replaces the association.
66 */
67 network: string;
68 /**
69 * Optional TlsInspectionPolicy resource name attached to this
70 * association.
71 */
72 tlsInspectionPolicy?: string;
73 /**
74 * When true, the association exists but does not intercept traffic.
75 * @default false
76 */
77 disabled?: boolean;
78 /**
79 * User labels. Alchemy ownership labels are merged in automatically.
80 */
81 labels?: Record<string, string>;
82};
83
84export type FirewallEndpointAssociation = Resource<
85 "GCP.NetworkSecurity.FirewallEndpointAssociation",
86 FirewallEndpointAssociationProps,
87 {
88 /** Full resource name `projects/{project}/locations/{location}/firewallEndpointAssociations/{firewallEndpointAssociation}`. */
89 name: string;
90 /** Association id (last path segment). */
91 firewallEndpointAssociationId: string;
92 /** Project id. */
93 project: string;
94 /** Zone id (`us-central1-a`). */
95 location: string;
96 /** Associated FirewallEndpoint resource name. */
97 firewallEndpoint: string | undefined;
98 /** Associated VPC network resource name. */
99 network: string | undefined;
100 /** Attached TlsInspectionPolicy resource name, if any. */
101 tlsInspectionPolicy: string | undefined;
102 /** Whether the association is disabled. */
103 disabled: boolean;
104 /** User labels (Alchemy ownership labels stripped). */
105 labels: Record<string, string>;
106 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
107 state: string | undefined;
108 /** Whether reconciling is in progress. */
109 reconciling: boolean;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * Associates a Cloud NGFW FirewallEndpoint with a VPC network so
121 * matching traffic is intercepted for inspection.
122 *
123 * Changing `firewallEndpointAssociationId`, `location`, `network`, or
124 * `firewallEndpoint` replaces the association. Labels, `disabled`, and
125 * `tlsInspectionPolicy` update in place.
126 *
127 * ### Creating a FirewallEndpointAssociation
128 * **Example:** Attach an endpoint to a VPC
129 * ```typescript
130 * const association = yield* GCP.NetworkSecurity.FirewallEndpointAssociation(
131 * "Inspect",
132 * {
133 * location: "us-central1-a",
134 * firewallEndpoint: endpoint.name,
135 * network: `projects/${vpc.project}/global/networks/${vpc.networkName}`,
136 * },
137 * );
138 * ```
139 *
140 * **Example:** Disable interception
141 * ```typescript
142 * const association = yield* GCP.NetworkSecurity.FirewallEndpointAssociation(
143 * "Inspect",
144 * {
145 * location: "us-central1-a",
146 * firewallEndpoint: endpoint.name,
147 * network: `projects/${vpc.project}/global/networks/${vpc.networkName}`,
148 * disabled: true,
149 * },
150 * );
151 * ```
152 *
153 * @resource
154 * @category NetworkSecurity
155 */
156export const FirewallEndpointAssociation =
157 Resource<FirewallEndpointAssociation>(
158 "GCP.NetworkSecurity.FirewallEndpointAssociation",
159 );
160
161const resourceName = (
162 project: string,
163 location: string,
164 firewallEndpointAssociationId: string,
165) =>
166 `projects/${project}/locations/${location}/firewallEndpointAssociations/${firewallEndpointAssociationId}`;
167
168const toAttrs = (
169 association: networksecurity.FirewallEndpointAssociation,
170 project: string,
171) => {
172 const name = association.name ?? "";
173 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
174 return {
175 name,
176 firewallEndpointAssociationId: parsed.id,
177 project: parsed.project || project,
178 location: parsed.location || DEFAULT_ZONE,
179 firewallEndpoint: association.firewallEndpoint
180 ? canonicalizeLink(association.firewallEndpoint)
181 : undefined,
182 network: association.network
183 ? canonicalizeLink(association.network)
184 : undefined,
185 tlsInspectionPolicy: association.tlsInspectionPolicy
186 ? canonicalizeLink(association.tlsInspectionPolicy)
187 : undefined,
188 disabled: association.disabled === true,
189 labels: userLabels(association.labels),
190 state: association.state,
191 reconciling: association.reconciling === true,
192 createTime: association.createTime,
193 updateTime: association.updateTime,
194 };
195};
196
197const getByName = (name: string) =>
198 networksecurity
199 .getProjectsLocationsFirewallEndpointAssociations({ name })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202export const FirewallEndpointAssociationProvider = () =>
203 Provider.succeed(FirewallEndpointAssociation, {
204 stables: [
205 "name",
206 "firewallEndpointAssociationId",
207 "project",
208 "location",
209 "firewallEndpoint",
210 "network",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const previousId =
217 olds?.firewallEndpointAssociationId ??
218 output?.firewallEndpointAssociationId;
219 const nextId = news.firewallEndpointAssociationId
220 ? rfc1035(
221 news.firewallEndpointAssociationId,
222 "firewall-endpoint-association",
223 )
224 : previousId;
225 const previousLocation = normalizeLocation(
226 olds?.location ?? output?.location,
227 DEFAULT_ZONE,
228 );
229 const nextLocation = normalizeLocation(
230 news.location ?? olds?.location ?? output?.location,
231 DEFAULT_ZONE,
232 );
233 const previousEndpoint = linkKey(
234 olds?.firewallEndpoint ?? output?.firewallEndpoint,
235 );
236 const nextEndpoint = linkKey(news.firewallEndpoint);
237 const previousNetwork = linkKey(olds?.network ?? output?.network);
238 const nextNetwork = linkKey(news.network);
239 if (
240 (previousId !== undefined &&
241 nextId !== undefined &&
242 nextId !== previousId) ||
243 previousLocation !== nextLocation ||
244 (previousEndpoint.length > 0 && previousEndpoint !== nextEndpoint) ||
245 (previousNetwork.length > 0 && previousNetwork !== nextNetwork)
246 ) {
247 return { action: "replace" as const };
248 }
249 return undefined;
250 }),
251
252 read: Effect.fn(function* ({ id, olds, output }) {
253 const env = yield* GcpEnvironment.current;
254 const firewallEndpointAssociationId = yield* toPhysicalId(
255 id,
256 olds?.firewallEndpointAssociationId,
257 output?.firewallEndpointAssociationId,
258 "firewall-endpoint-association",
259 );
260 const location = normalizeLocation(
261 olds?.location ?? output?.location,
262 DEFAULT_ZONE,
263 );
264 const name =
265 output?.name ??
266 resourceName(env.project, location, firewallEndpointAssociationId);
267 const existing = yield* getByName(name);
268 if (existing === undefined) return undefined;
269 const attrs = toAttrs(existing, env.project);
270 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
271 ? attrs
272 : Unowned(attrs);
273 }),
274
275 list: () =>
276 Effect.gen(function* () {
277 const env = yield* GcpEnvironment.current;
278 const items = yield* collectPages(
279 networksecurity.listProjectsLocationsFirewallEndpointAssociations.pages(
280 {
281 parent: parentOf(env.project, "-"),
282 pageSize: 1000,
283 },
284 ),
285 (page) => page.firewallEndpointAssociations,
286 );
287 return items
288 .filter((item) => hasAlchemyLabelKeys(item.labels))
289 .map((item) => toAttrs(item, env.project));
290 }),
291
292 reconcile: Effect.fn(function* ({ id, news, output }) {
293 const env = yield* GcpEnvironment.current;
294 const firewallEndpointAssociationId = yield* toPhysicalId(
295 id,
296 news.firewallEndpointAssociationId,
297 output?.firewallEndpointAssociationId,
298 "firewall-endpoint-association",
299 );
300 const location = normalizeLocation(
301 news.location ?? output?.location,
302 DEFAULT_ZONE,
303 );
304 const name = resourceName(
305 env.project,
306 location,
307 firewallEndpointAssociationId,
308 );
309 const desiredLabels = {
310 ...toLabels(news.labels),
311 ...(yield* createInternalLabels(id)),
312 };
313 const firewallEndpoint = canonicalizeLink(news.firewallEndpoint);
314 const network = canonicalizeLink(news.network);
315 const tlsInspectionPolicy = news.tlsInspectionPolicy
316 ? canonicalizeLink(news.tlsInspectionPolicy)
317 : undefined;
318 const disabled = news.disabled === true;
319
320 let current = yield* getByName(name);
321
322 if (current === undefined) {
323 const created = yield* networksecurity
324 .createProjectsLocationsFirewallEndpointAssociations({
325 parent: parentOf(env.project, location),
326 firewallEndpointAssociationId,
327 body: {
328 firewallEndpoint,
329 network,
330 tlsInspectionPolicy,
331 disabled,
332 labels: desiredLabels,
333 },
334 })
335 .pipe(
336 Effect.retry({
337 while: (error) => error._tag === "Conflict",
338 times: 5,
339 schedule: Schedule.spaced("2 seconds"),
340 }),
341 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
342 );
343 if (created !== undefined) {
344 yield* waitForOperation(created);
345 }
346 current = yield* waitUntilPresent(getByName(name), name);
347 }
348
349 const observedLabels = tagRecord(current.labels);
350 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
351 const labelsChanged = upsert.length > 0 || removed.length > 0;
352 const disabledChanged = (current.disabled === true) !== disabled;
353 const tlsChanged =
354 canonicalizeLink(current.tlsInspectionPolicy) !==
355 (tlsInspectionPolicy ?? "");
356 const updateMask = changedFields([
357 ["labels", labelsChanged],
358 ["disabled", disabledChanged],
359 ["tlsInspectionPolicy", tlsChanged],
360 ]);
361
362 if (updateMask.length > 0) {
363 const operation =
364 yield* networksecurity.patchProjectsLocationsFirewallEndpointAssociations(
365 {
366 name: current.name ?? name,
367 updateMask: updateMask.join(","),
368 body: {
369 name: current.name ?? name,
370 labels: desiredLabels,
371 disabled,
372 tlsInspectionPolicy,
373 },
374 },
375 );
376 yield* waitForOperation(operation);
377 current = yield* waitUntilPresent(
378 getByName(current.name ?? name),
379 current.name ?? name,
380 );
381 }
382
383 return toAttrs(current, env.project);
384 }),
385
386 delete: Effect.fn(function* ({ output }) {
387 const operation = yield* networksecurity
388 .deleteProjectsLocationsFirewallEndpointAssociations({
389 name: output.name,
390 })
391 .pipe(
392 Effect.retry({
393 while: (error) => error._tag === "Conflict",
394 times: 8,
395 schedule: Schedule.spaced("2 seconds"),
396 }),
397 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
398 );
399 if (operation !== undefined) {
400 yield* waitForOperation(operation, { notFoundOk: true });
401 }
402 yield* waitUntilGone(getByName(output.name), output.name);
403 }),
404 });