1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 listOrganizations,
20 normalizeZone,
21 organizationParent,
22 parseResourceName,
23 ResourceNotResolved,
24 resolveOrganization,
25 toPhysicalId,
26 userLabels,
27 waitForOperation,
28 waitUntilExists,
29 waitUntilGone,
30} from "./operations.ts";
31
32export type OrganizationsFirewallEndpointState =
33 | networksecurity.FirewallEndpointStateEnum
34 | (string & {});
35
36export type OrganizationsFirewallEndpointSettings = {
37 /**
38 * Whether jumbo frames are enabled. Immutable — changing it replaces
39 * the endpoint.
40 * @default false
41 */
42 jumboFramesEnabled?: boolean;
43};
44
45export type OrganizationsFirewallEndpointAssociation = {
46 /** FirewallEndpointAssociation resource name. */
47 name: string | undefined;
48 /** VPC network `projects/{project}/global/networks/{name}`. */
49 network: string | undefined;
50};
51
52export type OrganizationsFirewallEndpointProps = {
53 /**
54 * Firewall endpoint id. If omitted, a unique RFC1035 name is generated
55 * from the stack, stage, and logical id. Immutable — changing it
56 * replaces the endpoint.
57 */
58 firewallEndpointId?: string;
59 /**
60 * Organization id or `organizations/{organization}`. If omitted,
61 * Alchemy uses `GOOGLE_ORGANIZATION_ID` or the project's Resource
62 * Manager parent. Immutable — changing it replaces the endpoint.
63 */
64 organization?: string;
65 /**
66 * Zone (`us-central1-a`, …). Firewall endpoints are zonal. Immutable —
67 * changing it replaces the endpoint.
68 * @default "us-central1-a"
69 */
70 location?: string;
71 /**
72 * Project billed for the endpoint. Required for organization-scoped
73 * endpoints. Defaults to the stack project.
74 */
75 billingProjectId?: string;
76 /**
77 * Endpoint settings. `jumboFramesEnabled` is immutable.
78 */
79 endpointSettings?: OrganizationsFirewallEndpointSettings;
80 /**
81 * Human-readable description (max 2048 characters).
82 */
83 description?: string;
84 /**
85 * User labels. Alchemy ownership labels are merged in automatically.
86 */
87 labels?: Record<string, string>;
88};
89
90export type OrganizationsFirewallEndpoint = Resource<
91 "GCP.NetworkSecurity.OrganizationsFirewallEndpoint",
92 OrganizationsFirewallEndpointProps,
93 {
94 /** Full resource name `organizations/{organization}/locations/{location}/firewallEndpoints/{firewallEndpoint}`. */
95 name: string;
96 /** Firewall endpoint id (last path segment). */
97 firewallEndpointId: string;
98 /** Organization id. */
99 organization: string;
100 /** Zone id. */
101 location: string;
102 /** Project billed for the endpoint. */
103 billingProjectId: string | undefined;
104 /** Whether jumbo frames are enabled. */
105 jumboFramesEnabled: boolean;
106 /** User-provided description. */
107 description: string | undefined;
108 /** User labels (Alchemy ownership labels stripped). */
109 labels: Record<string, string>;
110 /** Server-reported lifecycle state. */
111 state: string | undefined;
112 /** Whether the endpoint is reconciling. */
113 reconciling: boolean;
114 /** Associated FirewallEndpointAssociations. */
115 associations: OrganizationsFirewallEndpointAssociation[];
116 /** Associated VPC networks. */
117 associatedNetworks: string[];
118 /** RFC3339 creation timestamp. */
119 createTime: string | undefined;
120 /** RFC3339 last-update timestamp. */
121 updateTime: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * An organization-scoped Cloud NGFW firewall endpoint.
129 *
130 * Endpoints are zonal and bill to `billingProjectId`. Changing
131 * `firewallEndpointId`, `organization`, `location`, `billingProjectId`,
132 * or jumbo-frame settings replaces the endpoint. Description and labels
133 * update in place. Provisioning is slow — tests skip when `FAST` is set.
134 *
135 * ### Creating a Firewall Endpoint
136 * **Example:** Generated name
137 * ```typescript
138 * const endpoint = yield* GCP.NetworkSecurity.OrganizationsFirewallEndpoint(
139 * "Ngfw",
140 * { location: "us-central1-a" },
141 * );
142 * ```
143 *
144 * **Example:** Named endpoint
145 * ```typescript
146 * const endpoint = yield* GCP.NetworkSecurity.OrganizationsFirewallEndpoint(
147 * "Ngfw",
148 * {
149 * firewallEndpointId: "app-ngfw",
150 * organization: "123456789",
151 * location: "us-central1-a",
152 * billingProjectId: "my-project",
153 * description: "prod NGFW",
154 * labels: { env: "prod" },
155 * },
156 * );
157 * ```
158 *
159 * @resource
160 * @category NetworkSecurity
161 */
162export const OrganizationsFirewallEndpoint =
163 Resource<OrganizationsFirewallEndpoint>(
164 "GCP.NetworkSecurity.OrganizationsFirewallEndpoint",
165 );
166
167const resourceName = (
168 organization: string,
169 location: string,
170 firewallEndpointId: string,
171) =>
172 `organizations/${organization}/locations/${location}/firewallEndpoints/${firewallEndpointId}`;
173
174const jumboOf = (settings: OrganizationsFirewallEndpointSettings | undefined) =>
175 settings?.jumboFramesEnabled === true;
176
177const toAttrs = (endpoint: networksecurity.FirewallEndpoint) => {
178 const name = endpoint.name ?? "";
179 const parsed = parseResourceName(name);
180 return {
181 name,
182 firewallEndpointId: parsed.id,
183 organization: parsed.parentId,
184 location: parsed.location,
185 billingProjectId: endpoint.billingProjectId,
186 jumboFramesEnabled: endpoint.endpointSettings?.jumboFramesEnabled === true,
187 description: endpoint.description,
188 labels: userLabels(endpoint.labels),
189 state: endpoint.state,
190 reconciling: endpoint.reconciling === true,
191 associations: (endpoint.associations ?? []).map((association) => ({
192 name: association.name,
193 network: association.network,
194 })),
195 associatedNetworks: endpoint.associatedNetworks ?? [],
196 createTime: endpoint.createTime,
197 updateTime: endpoint.updateTime,
198 };
199};
200
201const getByName = (name: string) =>
202 networksecurity
203 .getOrganizationsLocationsFirewallEndpoints({ name })
204 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
205
206const isPendingState = (state: string | undefined) =>
207 state === "CREATING" || state === "DELETING" || state === "STATE_UNSPECIFIED";
208
209const waitUntilReady = (name: string) =>
210 getByName(name).pipe(
211 Effect.filterOrFail(
212 (endpoint): endpoint is networksecurity.FirewallEndpoint =>
213 endpoint !== undefined,
214 () => new ResourceNotResolved({ name }),
215 ),
216 Effect.filterOrFail(
217 (endpoint) => !isPendingState(endpoint.state),
218 () => new ResourceNotResolved({ name }),
219 ),
220 Effect.retry({
221 while: (error) =>
222 error._tag === "GCP.NetworkSecurity.ResourceNotResolved",
223 times: 10,
224 schedule: Schedule.spaced("5 seconds"),
225 }),
226 );
227
228const listOwned = (organization: string) =>
229 networksecurity.listOrganizationsLocationsFirewallEndpoints
230 .pages({
231 parent: organizationParent(organization, "-"),
232 pageSize: 1000,
233 })
234 .pipe(
235 Stream.flatMap((page) =>
236 Stream.fromIterable(page.firewallEndpoints ?? []),
237 ),
238 Stream.filter((endpoint) =>
239 Object.keys(endpoint.labels ?? {}).some((key) =>
240 key.startsWith("alchemy-"),
241 ),
242 ),
243 Stream.map(toAttrs),
244 Stream.runCollect,
245 Effect.map((chunk) => Array.from(chunk)),
246 Effect.catchTag("NotFound", () => Effect.succeed([])),
247 );
248
249export const OrganizationsFirewallEndpointProvider = () =>
250 Provider.succeed(OrganizationsFirewallEndpoint, {
251 stables: [
252 "name",
253 "firewallEndpointId",
254 "organization",
255 "location",
256 "billingProjectId",
257 "jumboFramesEnabled",
258 "createTime",
259 ],
260
261 diff: Effect.fn(function* ({ news, olds, output }) {
262 if (!isResolved(news)) return undefined;
263 const previousId = olds?.firewallEndpointId ?? output?.firewallEndpointId;
264 const nextId = news.firewallEndpointId ?? previousId;
265 const previousOrg = olds?.organization ?? output?.organization;
266 const nextOrg = news.organization ?? previousOrg;
267 const previousLocation = normalizeZone(
268 olds?.location ?? output?.location,
269 );
270 const nextLocation = normalizeZone(
271 news.location ?? olds?.location ?? output?.location,
272 );
273 const previousBilling =
274 olds?.billingProjectId ?? output?.billingProjectId;
275 const nextBilling = news.billingProjectId ?? previousBilling;
276 const previousJumbo = jumboOf(olds?.endpointSettings);
277 const nextJumbo =
278 news.endpointSettings !== undefined
279 ? jumboOf(news.endpointSettings)
280 : (output?.jumboFramesEnabled ?? previousJumbo);
281 const replace =
282 (previousId !== undefined &&
283 nextId !== undefined &&
284 nextId !== previousId) ||
285 (previousOrg !== undefined &&
286 nextOrg !== undefined &&
287 nextOrg !== previousOrg) ||
288 previousLocation !== nextLocation ||
289 (previousBilling !== undefined &&
290 nextBilling !== undefined &&
291 previousBilling !== nextBilling) ||
292 previousJumbo !== nextJumbo;
293 if (!replace) return undefined;
294 return {
295 action: "replace" as const,
296 deleteFirst:
297 previousLocation === nextLocation &&
298 previousOrg === nextOrg &&
299 previousId !== undefined &&
300 nextId === previousId,
301 };
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const firewallEndpointId = yield* toPhysicalId(
306 id,
307 olds?.firewallEndpointId,
308 output?.firewallEndpointId,
309 "fwep",
310 );
311 const organization = yield* resolveOrganization(
312 olds?.organization ?? output?.organization,
313 output?.organization,
314 ).pipe(
315 Effect.catchTag("GCP.NetworkSecurity.OrganizationRequired", () =>
316 Effect.succeed(output?.organization ?? ""),
317 ),
318 );
319 const location = normalizeZone(olds?.location ?? output?.location);
320 const name =
321 output?.name ??
322 (organization.length > 0
323 ? resourceName(organization, location, firewallEndpointId)
324 : "");
325 if (name.length === 0) return undefined;
326 const existing = yield* getByName(name);
327 if (existing === undefined) return undefined;
328 const attrs = toAttrs(existing);
329 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
330 ? attrs
331 : Unowned(attrs);
332 }),
333
334 list: () =>
335 Effect.gen(function* () {
336 const env = yield* GcpEnvironment.current;
337 const orgs = yield* listOrganizations(env.project);
338 const listed: OrganizationsFirewallEndpoint["Attributes"][] = [];
339 for (const organization of orgs) {
340 listed.push(...(yield* listOwned(organization)));
341 }
342 return listed;
343 }),
344
345 reconcile: Effect.fn(function* ({ id, news, output }) {
346 const env = yield* GcpEnvironment.current;
347 const firewallEndpointId = yield* toPhysicalId(
348 id,
349 news.firewallEndpointId,
350 output?.firewallEndpointId,
351 "fwep",
352 );
353 const organization = yield* resolveOrganization(
354 news.organization ?? output?.organization,
355 output?.organization,
356 );
357 const location = normalizeZone(news.location ?? output?.location);
358 const name = resourceName(organization, location, firewallEndpointId);
359 const billingProjectId = news.billingProjectId ?? env.project;
360 const jumboFramesEnabled = jumboOf(news.endpointSettings);
361 const desiredLabels = {
362 ...toLabels(news.labels),
363 ...(yield* createInternalLabels(id)),
364 };
365
366 let current = yield* getByName(name);
367
368 if (current === undefined) {
369 const created = yield* networksecurity
370 .createOrganizationsLocationsFirewallEndpoints({
371 parent: organizationParent(organization, location),
372 firewallEndpointId,
373 body: {
374 billingProjectId,
375 description: news.description,
376 labels: desiredLabels,
377 endpointSettings: { jumboFramesEnabled },
378 },
379 })
380 .pipe(
381 Effect.retry({
382 while: (error) => error._tag === "Conflict",
383 times: 5,
384 schedule: Schedule.spaced("2 seconds"),
385 }),
386 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
387 );
388 if (created !== undefined) {
389 yield* waitForOperation(created);
390 }
391 current = yield* waitUntilReady(name);
392 }
393
394 if (current === undefined) {
395 return yield* new ResourceNotResolved({ name });
396 }
397
398 const observedLabels = tagRecord(current.labels);
399 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
400 const labelsChanged = upsert.length > 0 || removed.length > 0;
401 const descriptionChanged =
402 (current.description ?? "") !== (news.description ?? "");
403
404 if (labelsChanged || descriptionChanged) {
405 const updateMask = [
406 labelsChanged ? "labels" : undefined,
407 descriptionChanged ? "description" : undefined,
408 ].filter((field): field is string => field !== undefined);
409
410 const operation =
411 yield* networksecurity.patchOrganizationsLocationsFirewallEndpoints({
412 name: current.name ?? name,
413 updateMask: updateMask.join(","),
414 body: {
415 name: current.name ?? name,
416 labels: desiredLabels,
417 description: news.description,
418 },
419 });
420 yield* waitForOperation(operation);
421 current = yield* waitUntilExists(
422 getByName(current.name ?? name),
423 current.name ?? name,
424 );
425 }
426
427 return toAttrs(current);
428 }),
429
430 delete: Effect.fn(function* ({ output }) {
431 const operation = yield* networksecurity
432 .deleteOrganizationsLocationsFirewallEndpoints({ name: output.name })
433 .pipe(
434 Effect.retry({
435 while: (error) => error._tag === "Conflict",
436 times: 8,
437 schedule: Schedule.spaced("2 seconds"),
438 }),
439 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
440 );
441 if (operation !== undefined) {
442 yield* waitForOperation(operation, { notFoundOk: true });
443 }
444 yield* waitUntilGone(getByName(output.name), output.name);
445 }),
446 });