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 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 rfc1035,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilGone,
30 waitUntilPresent,
31} from "./internal.ts";
32
33const COLLECTION = "firewallEndpoints";
34
35export type FirewallEndpointState =
36 | networksecurity.FirewallEndpointStateEnum
37 | (string & {});
38
39export type FirewallEndpointAssociationRef = {
40 /** Association resource name. */
41 name: string | undefined;
42};
43
44export type FirewallEndpointProps = {
45 /**
46 * FirewallEndpoint id (the `{firewallEndpoint}` segment of
47 * `projects/{project}/locations/{location}/firewallEndpoints/{firewallEndpoint}`).
48 * If omitted, a unique name is generated from the stack, stage, and
49 * logical id. Immutable — changing it replaces the endpoint.
50 */
51 firewallEndpointId?: string;
52 /**
53 * Zone of the endpoint (`us-central1-a`, …). Immutable — changing it
54 * replaces the endpoint. `US-CENTRAL1-A` is accepted and normalized to
55 * `us-central1-a`.
56 * @default "us-central1-a"
57 */
58 location?: string;
59 /**
60 * Project billed for the endpoint. Required for organization-scoped
61 * endpoints; omit for project-scoped endpoints. Immutable — changing
62 * it replaces the endpoint.
63 */
64 billingProjectId?: string;
65 /**
66 * Human-readable description (max 2048 characters).
67 */
68 description?: string;
69 /**
70 * Enable jumbo frames on the endpoint. Immutable — changing it
71 * replaces the endpoint.
72 * @default false
73 */
74 jumboFramesEnabled?: boolean;
75 /**
76 * User labels. Alchemy ownership labels are merged in automatically.
77 */
78 labels?: Record<string, string>;
79};
80
81export type FirewallEndpoint = Resource<
82 "GCP.NetworkSecurity.FirewallEndpoint",
83 FirewallEndpointProps,
84 {
85 /** Full resource name `projects/{project}/locations/{location}/firewallEndpoints/{firewallEndpoint}`. */
86 name: string;
87 /** FirewallEndpoint id (last path segment). */
88 firewallEndpointId: string;
89 /** Project id. */
90 project: string;
91 /** Zone id (`us-central1-a`). */
92 location: string;
93 /** Project billed for the endpoint, if set. */
94 billingProjectId: string | undefined;
95 /** User-provided description. */
96 description: string | undefined;
97 /** Whether jumbo frames are enabled. */
98 jumboFramesEnabled: boolean;
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 reconciling is in progress. */
104 reconciling: boolean;
105 /** Associated FirewallEndpointAssociation names. */
106 associations: FirewallEndpointAssociationRef[];
107 /** Associated VPC network names (deprecated projection). */
108 associatedNetworks: string[];
109 /** RFC3339 creation timestamp. */
110 createTime: string | undefined;
111 /** RFC3339 last-update timestamp. */
112 updateTime: string | undefined;
113 },
114 never,
115 Providers
116>;
117
118/**
119 * A project-scoped Cloud NGFW firewall endpoint. Endpoints terminate
120 * inspected traffic in a zone; associate them with a VPC via
121 * FirewallEndpointAssociation.
122 *
123 * Changing `firewallEndpointId`, `location`, `billingProjectId`, or
124 * jumbo-frame settings replaces the endpoint. Description and labels
125 * update in place. Provisioning is asynchronous and can take several
126 * minutes.
127 *
128 * ### Creating a FirewallEndpoint
129 * **Example:** Generated name
130 * ```typescript
131 * const endpoint = yield* GCP.NetworkSecurity.FirewallEndpoint("Ngfw", {
132 * location: "us-central1-a",
133 * });
134 * ```
135 *
136 * **Example:** Named endpoint with labels
137 * ```typescript
138 * const endpoint = yield* GCP.NetworkSecurity.FirewallEndpoint("Ngfw", {
139 * firewallEndpointId: "app-ngfw",
140 * location: "us-central1-a",
141 * description: "prod inspection",
142 * labels: { env: "prod" },
143 * });
144 * ```
145 *
146 * @resource
147 * @category NetworkSecurity
148 */
149export const FirewallEndpoint = Resource<FirewallEndpoint>(
150 "GCP.NetworkSecurity.FirewallEndpoint",
151);
152
153const resourceName = (
154 project: string,
155 location: string,
156 firewallEndpointId: string,
157) =>
158 `projects/${project}/locations/${location}/firewallEndpoints/${firewallEndpointId}`;
159
160const toAttrs = (
161 endpoint: networksecurity.FirewallEndpoint,
162 project: string,
163) => {
164 const name = endpoint.name ?? "";
165 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
166 return {
167 name,
168 firewallEndpointId: parsed.id,
169 project: parsed.project || project,
170 location: parsed.location || DEFAULT_ZONE,
171 billingProjectId: endpoint.billingProjectId,
172 description: endpoint.description,
173 jumboFramesEnabled: endpoint.endpointSettings?.jumboFramesEnabled === true,
174 labels: userLabels(endpoint.labels),
175 state: endpoint.state,
176 reconciling: endpoint.reconciling === true,
177 associations: (endpoint.associations ?? []).map((association) => ({
178 name: association.name,
179 })),
180 associatedNetworks: endpoint.associatedNetworks ?? [],
181 createTime: endpoint.createTime,
182 updateTime: endpoint.updateTime,
183 };
184};
185
186const getByName = (name: string) =>
187 networksecurity
188 .getProjectsLocationsFirewallEndpoints({ name })
189 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
190
191export const FirewallEndpointProvider = () =>
192 Provider.succeed(FirewallEndpoint, {
193 stables: [
194 "name",
195 "firewallEndpointId",
196 "project",
197 "location",
198 "billingProjectId",
199 "jumboFramesEnabled",
200 "createTime",
201 ],
202
203 diff: Effect.fn(function* ({ news, olds, output }) {
204 if (!isResolved(news)) return undefined;
205 const previousId = olds?.firewallEndpointId ?? output?.firewallEndpointId;
206 const nextId = news.firewallEndpointId
207 ? rfc1035(news.firewallEndpointId, "firewall-endpoint")
208 : previousId;
209 const previousLocation = normalizeLocation(
210 olds?.location ?? output?.location,
211 DEFAULT_ZONE,
212 );
213 const nextLocation = normalizeLocation(
214 news.location ?? olds?.location ?? output?.location,
215 DEFAULT_ZONE,
216 );
217 const previousBilling =
218 olds?.billingProjectId ?? output?.billingProjectId ?? "";
219 const nextBilling = news.billingProjectId ?? previousBilling;
220 const previousJumbo =
221 olds?.jumboFramesEnabled ?? output?.jumboFramesEnabled ?? false;
222 const nextJumbo = news.jumboFramesEnabled ?? previousJumbo;
223 if (
224 (previousId !== undefined &&
225 nextId !== undefined &&
226 nextId !== previousId) ||
227 previousLocation !== nextLocation ||
228 previousBilling !== nextBilling ||
229 previousJumbo !== nextJumbo
230 ) {
231 return { action: "replace" as const };
232 }
233 return undefined;
234 }),
235
236 read: Effect.fn(function* ({ id, olds, output }) {
237 const env = yield* GcpEnvironment.current;
238 const firewallEndpointId = yield* toPhysicalId(
239 id,
240 olds?.firewallEndpointId,
241 output?.firewallEndpointId,
242 "firewall-endpoint",
243 );
244 const location = normalizeLocation(
245 olds?.location ?? output?.location,
246 DEFAULT_ZONE,
247 );
248 const name =
249 output?.name ?? resourceName(env.project, location, firewallEndpointId);
250 const existing = yield* getByName(name);
251 if (existing === undefined) return undefined;
252 const attrs = toAttrs(existing, env.project);
253 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
254 ? attrs
255 : Unowned(attrs);
256 }),
257
258 list: () =>
259 Effect.gen(function* () {
260 const env = yield* GcpEnvironment.current;
261 const items = yield* collectPages(
262 networksecurity.listProjectsLocationsFirewallEndpoints.pages({
263 parent: parentOf(env.project, "-"),
264 pageSize: 1000,
265 }),
266 (page) => page.firewallEndpoints,
267 );
268 return items
269 .filter((item) => hasAlchemyLabelKeys(item.labels))
270 .map((item) => toAttrs(item, env.project));
271 }),
272
273 reconcile: Effect.fn(function* ({ id, news, output }) {
274 const env = yield* GcpEnvironment.current;
275 const firewallEndpointId = yield* toPhysicalId(
276 id,
277 news.firewallEndpointId,
278 output?.firewallEndpointId,
279 "firewall-endpoint",
280 );
281 const location = normalizeLocation(
282 news.location ?? output?.location,
283 DEFAULT_ZONE,
284 );
285 const name = resourceName(env.project, location, firewallEndpointId);
286 const desiredLabels = {
287 ...toLabels(news.labels),
288 ...(yield* createInternalLabels(id)),
289 };
290 const jumboFramesEnabled = news.jumboFramesEnabled === true;
291
292 let current = yield* getByName(name);
293
294 if (current === undefined) {
295 const created = yield* networksecurity
296 .createProjectsLocationsFirewallEndpoints({
297 parent: parentOf(env.project, location),
298 firewallEndpointId,
299 body: {
300 billingProjectId: news.billingProjectId,
301 description: news.description,
302 labels: desiredLabels,
303 endpointSettings: { jumboFramesEnabled },
304 },
305 })
306 .pipe(
307 Effect.retry({
308 while: (error) => error._tag === "Conflict",
309 times: 5,
310 schedule: Schedule.spaced("2 seconds"),
311 }),
312 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
313 );
314 if (created !== undefined) {
315 yield* waitForOperation(created);
316 }
317 current = yield* waitUntilPresent(getByName(name), name);
318 }
319
320 const observedLabels = tagRecord(current.labels);
321 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
322 const labelsChanged = upsert.length > 0 || removed.length > 0;
323 const descriptionChanged =
324 (current.description ?? "") !== (news.description ?? "");
325 const updateMask = changedFields([
326 ["labels", labelsChanged],
327 ["description", descriptionChanged],
328 ]);
329
330 if (updateMask.length > 0) {
331 const operation =
332 yield* networksecurity.patchProjectsLocationsFirewallEndpoints({
333 name: current.name ?? name,
334 updateMask: updateMask.join(","),
335 body: {
336 name: current.name ?? name,
337 labels: desiredLabels,
338 description: news.description,
339 },
340 });
341 yield* waitForOperation(operation);
342 current = yield* waitUntilPresent(
343 getByName(current.name ?? name),
344 current.name ?? name,
345 );
346 }
347
348 return toAttrs(current, env.project);
349 }),
350
351 delete: Effect.fn(function* ({ output }) {
352 const operation = yield* networksecurity
353 .deleteProjectsLocationsFirewallEndpoints({ name: output.name })
354 .pipe(
355 Effect.retry({
356 while: (error) => error._tag === "Conflict",
357 times: 8,
358 schedule: Schedule.spaced("2 seconds"),
359 }),
360 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
361 );
362 if (operation !== undefined) {
363 yield* waitForOperation(operation, { notFoundOk: true });
364 }
365 yield* waitUntilGone(getByName(output.name), output.name);
366 }),
367 });