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 normalizeLocation,
22 parentOf,
23 parseResourceName,
24 resourceName,
25 toId,
26 toResourcePath,
27 userLabels,
28} from "./names.ts";
29import { waitForOperation } from "./operations.ts";
30
31const COLLECTION = "securityProfileGroups";
32
33export type SecurityProfileGroupProps = {
34 /**
35 * SecurityProfileGroup id (the `{securityProfileGroup}` segment of
36 * `projects/{project}/locations/{location}/securityProfileGroups/{securityProfileGroup}`).
37 * If omitted, a unique name is generated from the stack, stage, and
38 * logical id. Must be 1-63 characters, letters, numbers, hyphens, and
39 * underscores, and must not start with a number. Immutable — changing
40 * it replaces the group.
41 */
42 securityProfileGroupId?: string;
43 /**
44 * Location. Security profile groups live in `global`. Immutable —
45 * changing it replaces the group.
46 * @default "global"
47 */
48 location?: string;
49 /**
50 * Human-readable description (max 2048 characters).
51 */
52 description?: string;
53 /**
54 * User labels. Alchemy ownership labels are merged in automatically.
55 */
56 labels?: Record<string, string>;
57 /**
58 * SecurityProfile resource name with ThreatPrevention configuration.
59 */
60 threatPreventionProfile?: string;
61 /**
62 * SecurityProfile resource name with UrlFiltering configuration.
63 */
64 urlFilteringProfile?: string;
65 /**
66 * SecurityProfile resource name with CustomMirroring configuration.
67 */
68 customMirroringProfile?: string;
69 /**
70 * SecurityProfile resource name with CustomIntercept configuration.
71 */
72 customInterceptProfile?: string;
73};
74
75export type SecurityProfileGroup = Resource<
76 "GCP.NetworkSecurity.SecurityProfileGroup",
77 SecurityProfileGroupProps,
78 {
79 /** Full resource name `projects/{project}/locations/{location}/securityProfileGroups/{securityProfileGroup}`. */
80 name: string;
81 /** SecurityProfileGroup id (last path segment). */
82 securityProfileGroupId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. Typically `"global"`. */
86 location: string;
87 /** User-provided description. */
88 description: string | undefined;
89 /** User labels (Alchemy ownership labels stripped). */
90 labels: Record<string, string>;
91 /** Threat-prevention profile name, if set. */
92 threatPreventionProfile: string | undefined;
93 /** URL-filtering profile name, if set. */
94 urlFilteringProfile: string | undefined;
95 /** Custom mirroring profile name, if set. */
96 customMirroringProfile: string | undefined;
97 /** Custom intercept profile name, if set. */
98 customInterceptProfile: string | undefined;
99 /** Data-path identifier unique within `{container, location}`. */
100 dataPathId: string | undefined;
101 /** Server-computed checksum used on update and delete. */
102 etag: string | undefined;
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 SecurityProfileGroup — a named bundle of
114 * SecurityProfile references applied together by firewall policy rules.
115 *
116 * Changing `securityProfileGroupId` or `location` replaces the group.
117 * Description, labels, and profile references update in place.
118 *
119 * ### Creating a SecurityProfileGroup
120 * **Example:** Empty group
121 * ```typescript
122 * const group = yield* GCP.NetworkSecurity.SecurityProfileGroup("Ngfw", {});
123 * ```
124 *
125 * **Example:** Group bound to a threat-prevention profile
126 * ```typescript
127 * const profile = yield* GCP.NetworkSecurity.SecurityProfile("Threat", {
128 * type: "THREAT_PREVENTION",
129 * });
130 * const group = yield* GCP.NetworkSecurity.SecurityProfileGroup("Ngfw", {
131 * securityProfileGroupId: "app-ngfw",
132 * description: "prod ngfw",
133 * labels: { env: "prod" },
134 * threatPreventionProfile: profile.name,
135 * });
136 * ```
137 *
138 * ### Updating a SecurityProfileGroup
139 * **Example:** Attach a profile and change labels
140 * ```typescript
141 * const group = yield* GCP.NetworkSecurity.SecurityProfileGroup("Ngfw", {
142 * securityProfileGroupId: "app-ngfw",
143 * description: "prod ngfw v2",
144 * labels: { env: "prod", role: "ngfw" },
145 * threatPreventionProfile: profile.name,
146 * });
147 * ```
148 *
149 * @resource
150 * @category NetworkSecurity
151 */
152export const SecurityProfileGroup = Resource<SecurityProfileGroup>(
153 "GCP.NetworkSecurity.SecurityProfileGroup",
154);
155
156export class SecurityProfileGroupNotResolved extends Data.TaggedError(
157 "GCP.NetworkSecurity.SecurityProfileGroupNotResolved",
158)<{
159 name: string;
160}> {}
161
162export class SecurityProfileGroupStillExists extends Data.TaggedError(
163 "GCP.NetworkSecurity.SecurityProfileGroupStillExists",
164)<{
165 name: string;
166}> {}
167
168const refOf = (value: string | undefined) => {
169 if (value === undefined || value.length === 0) return undefined;
170 return toResourcePath(value);
171};
172
173const toAttrs = (
174 group: networksecurity.SecurityProfileGroup,
175 project: string,
176) => {
177 const name = group.name ?? "";
178 const parsed = parseResourceName(name, COLLECTION);
179 return {
180 name,
181 securityProfileGroupId: parsed.id,
182 project: parsed.project || project,
183 location: parsed.location || DEFAULT_LOCATION,
184 description: group.description,
185 labels: userLabels(group.labels),
186 threatPreventionProfile: group.threatPreventionProfile,
187 urlFilteringProfile: group.urlFilteringProfile,
188 customMirroringProfile: group.customMirroringProfile,
189 customInterceptProfile: group.customInterceptProfile,
190 dataPathId: group.dataPathId,
191 etag: group.etag,
192 createTime: group.createTime,
193 updateTime: group.updateTime,
194 };
195};
196
197const getByName = (name: string) =>
198 networksecurity
199 .getProjectsLocationsSecurityProfileGroups({ name })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202const waitUntilExists = (name: string) =>
203 getByName(name).pipe(
204 Effect.flatMap((group) =>
205 group
206 ? Effect.succeed(group)
207 : Effect.fail(new SecurityProfileGroupNotResolved({ name })),
208 ),
209 Effect.retry({
210 while: (error) =>
211 error._tag === "GCP.NetworkSecurity.SecurityProfileGroupNotResolved",
212 times: 8,
213 schedule: Schedule.spaced("1 second"),
214 }),
215 );
216
217const waitUntilGone = (name: string) =>
218 getByName(name).pipe(
219 Effect.flatMap((group) =>
220 group === undefined
221 ? Effect.void
222 : Effect.fail(new SecurityProfileGroupStillExists({ name })),
223 ),
224 Effect.retry({
225 while: (error) =>
226 error._tag === "GCP.NetworkSecurity.SecurityProfileGroupStillExists",
227 times: 10,
228 schedule: Schedule.spaced("2 seconds"),
229 }),
230 );
231
232const listOwned = (project: string) =>
233 networksecurity.listProjectsLocationsSecurityProfileGroups
234 .pages({
235 parent: parentOf(project, "-"),
236 pageSize: 1000,
237 })
238 .pipe(
239 Stream.flatMap((page) =>
240 Stream.fromIterable(page.securityProfileGroups ?? []),
241 ),
242 Stream.filter((group) =>
243 Object.keys(group.labels ?? {}).some((key) =>
244 key.startsWith("alchemy-"),
245 ),
246 ),
247 Stream.map((group) => toAttrs(group, project)),
248 Stream.runCollect,
249 Effect.map((chunk) => Array.from(chunk)),
250 Effect.catchTag("NotFound", () => Effect.succeed([])),
251 );
252
253export const SecurityProfileGroupProvider = () =>
254 Provider.succeed(SecurityProfileGroup, {
255 stables: [
256 "name",
257 "securityProfileGroupId",
258 "project",
259 "location",
260 "dataPathId",
261 "createTime",
262 ],
263
264 diff: Effect.fn(function* ({ news, olds, output }) {
265 if (!isResolved(news)) return undefined;
266 const previousId =
267 olds?.securityProfileGroupId ?? output?.securityProfileGroupId;
268 const nextId = news.securityProfileGroupId ?? previousId;
269 const previousLocation = normalizeLocation(
270 olds?.location ?? output?.location,
271 );
272 const nextLocation = normalizeLocation(
273 news.location ?? olds?.location ?? output?.location,
274 );
275 if (
276 (previousId !== undefined &&
277 nextId !== undefined &&
278 nextId !== previousId) ||
279 previousLocation !== nextLocation
280 ) {
281 return { action: "replace" as const };
282 }
283 return undefined;
284 }),
285
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const env = yield* GcpEnvironment.current;
288 const securityProfileGroupId = yield* toId(
289 id,
290 olds?.securityProfileGroupId,
291 output?.securityProfileGroupId,
292 "secgroup",
293 );
294 const location = normalizeLocation(olds?.location ?? output?.location);
295 const name =
296 output?.name ??
297 resourceName(env.project, location, COLLECTION, securityProfileGroupId);
298 const existing = yield* getByName(name);
299 if (existing === undefined) return undefined;
300 const attrs = toAttrs(existing, env.project);
301 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
302 ? attrs
303 : Unowned(attrs);
304 }),
305
306 list: () =>
307 Effect.gen(function* () {
308 const env = yield* GcpEnvironment.current;
309 return yield* listOwned(env.project);
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output }) {
313 const env = yield* GcpEnvironment.current;
314 const securityProfileGroupId = yield* toId(
315 id,
316 news.securityProfileGroupId,
317 output?.securityProfileGroupId,
318 "secgroup",
319 );
320 const location = normalizeLocation(news.location ?? output?.location);
321 const name = resourceName(
322 env.project,
323 location,
324 COLLECTION,
325 securityProfileGroupId,
326 );
327 const desiredLabels = {
328 ...toLabels(news.labels),
329 ...(yield* createInternalLabels(id)),
330 };
331 const threatPreventionProfile = refOf(news.threatPreventionProfile);
332 const urlFilteringProfile = refOf(news.urlFilteringProfile);
333 const customMirroringProfile = refOf(news.customMirroringProfile);
334 const customInterceptProfile = refOf(news.customInterceptProfile);
335
336 let current = yield* getByName(output?.name ?? name);
337
338 if (current === undefined) {
339 const created = yield* networksecurity
340 .createProjectsLocationsSecurityProfileGroups({
341 parent: parentOf(env.project, location),
342 securityProfileGroupId,
343 body: {
344 description: news.description,
345 labels: desiredLabels,
346 threatPreventionProfile,
347 urlFilteringProfile,
348 customMirroringProfile,
349 customInterceptProfile,
350 },
351 })
352 .pipe(
353 Effect.retry({
354 while: (error) => error._tag === "Conflict",
355 times: 5,
356 schedule: Schedule.spaced("2 seconds"),
357 }),
358 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
359 );
360 if (created !== undefined) {
361 yield* waitForOperation(created);
362 }
363 current = yield* waitUntilExists(name);
364 }
365
366 if (current === undefined) {
367 return yield* new SecurityProfileGroupNotResolved({ name });
368 }
369
370 const observedLabels = tagRecord(current.labels);
371 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
372 const labelsChanged = upsert.length > 0 || removed.length > 0;
373 const descriptionChanged =
374 (current.description ?? "") !== (news.description ?? "");
375 const threatChanged =
376 (current.threatPreventionProfile ?? "") !==
377 (threatPreventionProfile ?? "");
378 const urlChanged =
379 (current.urlFilteringProfile ?? "") !== (urlFilteringProfile ?? "");
380 const mirroringChanged =
381 (current.customMirroringProfile ?? "") !==
382 (customMirroringProfile ?? "");
383 const interceptChanged =
384 (current.customInterceptProfile ?? "") !==
385 (customInterceptProfile ?? "");
386
387 if (
388 labelsChanged ||
389 descriptionChanged ||
390 threatChanged ||
391 urlChanged ||
392 mirroringChanged ||
393 interceptChanged
394 ) {
395 const updateMask = [
396 labelsChanged ? "labels" : undefined,
397 descriptionChanged ? "description" : undefined,
398 threatChanged ? "threatPreventionProfile" : undefined,
399 urlChanged ? "urlFilteringProfile" : undefined,
400 mirroringChanged ? "customMirroringProfile" : undefined,
401 interceptChanged ? "customInterceptProfile" : undefined,
402 ].filter((field): field is string => field !== undefined);
403 const operation =
404 yield* networksecurity.patchProjectsLocationsSecurityProfileGroups({
405 name: current.name ?? name,
406 updateMask: updateMask.join(","),
407 body: {
408 name: current.name ?? name,
409 etag: current.etag,
410 labels: desiredLabels,
411 description: news.description,
412 threatPreventionProfile,
413 urlFilteringProfile,
414 customMirroringProfile,
415 customInterceptProfile,
416 },
417 });
418 yield* waitForOperation(operation);
419 current = yield* waitUntilExists(current.name ?? name);
420 }
421
422 return toAttrs(current, env.project);
423 }),
424
425 delete: Effect.fn(function* ({ output }) {
426 const operation = yield* networksecurity
427 .deleteProjectsLocationsSecurityProfileGroups({
428 name: output.name,
429 etag: output.etag,
430 })
431 .pipe(
432 Effect.retry({
433 while: (error) => error._tag === "Conflict",
434 times: 8,
435 schedule: Schedule.spaced("2 seconds"),
436 }),
437 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
438 );
439 if (operation !== undefined) {
440 yield* waitForOperation(operation, { notFoundOk: true });
441 }
442 yield* waitUntilGone(output.name);
443 }),
444 });