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 normalizeLocation,
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 OrganizationsSecurityProfileGroupProps = {
33 /**
34 * Security profile group id. If omitted, a unique RFC1035 name is
35 * generated from the stack, stage, and logical id. Immutable —
36 * changing it replaces the group.
37 */
38 securityProfileGroupId?: string;
39 /**
40 * Organization id or `organizations/{organization}`. If omitted,
41 * Alchemy uses `GOOGLE_ORGANIZATION_ID` or the project's Resource
42 * Manager parent. Immutable — changing it replaces the group.
43 */
44 organization?: string;
45 /**
46 * Location. Profile groups live in `global`. Immutable — changing it
47 * replaces the group.
48 * @default "global"
49 */
50 location?: string;
51 /**
52 * Threat-prevention SecurityProfile resource name.
53 */
54 threatPreventionProfile?: string;
55 /**
56 * URL-filtering SecurityProfile resource name.
57 */
58 urlFilteringProfile?: string;
59 /**
60 * Custom-mirroring SecurityProfile resource name.
61 */
62 customMirroringProfile?: string;
63 /**
64 * Custom-intercept SecurityProfile resource name.
65 */
66 customInterceptProfile?: string;
67 /**
68 * Human-readable description (max 2048 characters).
69 */
70 description?: string;
71 /**
72 * User labels. Alchemy ownership labels are merged in automatically.
73 */
74 labels?: Record<string, string>;
75};
76
77export type OrganizationsSecurityProfileGroup = Resource<
78 "GCP.NetworkSecurity.OrganizationsSecurityProfileGroup",
79 OrganizationsSecurityProfileGroupProps,
80 {
81 /** Full resource name `organizations/{organization}/locations/{location}/securityProfileGroups/{securityProfileGroup}`. */
82 name: string;
83 /** Security profile group id (last path segment). */
84 securityProfileGroupId: string;
85 /** Organization id. */
86 organization: string;
87 /** Location id. */
88 location: string;
89 /** Threat-prevention profile name, if set. */
90 threatPreventionProfile: string | undefined;
91 /** URL-filtering profile name, if set. */
92 urlFilteringProfile: string | undefined;
93 /** Custom-mirroring profile name, if set. */
94 customMirroringProfile: string | undefined;
95 /** Custom-intercept profile name, if set. */
96 customInterceptProfile: string | undefined;
97 /** User-provided description. */
98 description: string | undefined;
99 /** User labels (Alchemy ownership labels stripped). */
100 labels: Record<string, string>;
101 /** Data-path identifier. */
102 dataPathId: string | undefined;
103 /** Server-computed checksum. */
104 etag: string | undefined;
105 /** RFC3339 creation timestamp. */
106 createTime: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * An organization-scoped group of Network Security profiles.
116 *
117 * Changing `securityProfileGroupId`, `organization`, or `location`
118 * replaces the group. Profile references, description, and labels update
119 * in place.
120 *
121 * ### Creating a Security Profile Group
122 * **Example:** Empty group
123 * ```typescript
124 * const group = yield* GCP.NetworkSecurity.OrganizationsSecurityProfileGroup(
125 * "Profiles",
126 * {},
127 * );
128 * ```
129 *
130 * **Example:** Attach a threat-prevention profile
131 * ```typescript
132 * const group = yield* GCP.NetworkSecurity.OrganizationsSecurityProfileGroup(
133 * "Profiles",
134 * {
135 * threatPreventionProfile: profile.name,
136 * labels: { env: "prod" },
137 * },
138 * );
139 * ```
140 *
141 * @resource
142 * @category NetworkSecurity
143 */
144export const OrganizationsSecurityProfileGroup =
145 Resource<OrganizationsSecurityProfileGroup>(
146 "GCP.NetworkSecurity.OrganizationsSecurityProfileGroup",
147 );
148
149const resourceName = (
150 organization: string,
151 location: string,
152 securityProfileGroupId: string,
153) =>
154 `organizations/${organization}/locations/${location}/securityProfileGroups/${securityProfileGroupId}`;
155
156const toAttrs = (group: networksecurity.SecurityProfileGroup) => {
157 const name = group.name ?? "";
158 const parsed = parseResourceName(name);
159 return {
160 name,
161 securityProfileGroupId: parsed.id,
162 organization: parsed.parentId,
163 location: parsed.location,
164 threatPreventionProfile: group.threatPreventionProfile,
165 urlFilteringProfile: group.urlFilteringProfile,
166 customMirroringProfile: group.customMirroringProfile,
167 customInterceptProfile: group.customInterceptProfile,
168 description: group.description,
169 labels: userLabels(group.labels),
170 dataPathId: group.dataPathId,
171 etag: group.etag,
172 createTime: group.createTime,
173 updateTime: group.updateTime,
174 };
175};
176
177const getByName = (name: string) =>
178 networksecurity
179 .getOrganizationsLocationsSecurityProfileGroups({ name })
180 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
181
182const listOwned = (organization: string) =>
183 networksecurity.listOrganizationsLocationsSecurityProfileGroups
184 .pages({
185 parent: organizationParent(organization, "-"),
186 pageSize: 1000,
187 })
188 .pipe(
189 Stream.flatMap((page) =>
190 Stream.fromIterable(page.securityProfileGroups ?? []),
191 ),
192 Stream.filter((group) =>
193 Object.keys(group.labels ?? {}).some((key) =>
194 key.startsWith("alchemy-"),
195 ),
196 ),
197 Stream.map(toAttrs),
198 Stream.runCollect,
199 Effect.map((chunk) => Array.from(chunk)),
200 Effect.catchTag("NotFound", () => Effect.succeed([])),
201 );
202
203export const OrganizationsSecurityProfileGroupProvider = () =>
204 Provider.succeed(OrganizationsSecurityProfileGroup, {
205 stables: [
206 "name",
207 "securityProfileGroupId",
208 "organization",
209 "location",
210 "dataPathId",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const previousId =
217 olds?.securityProfileGroupId ?? output?.securityProfileGroupId;
218 const nextId = news.securityProfileGroupId ?? previousId;
219 const previousOrg = olds?.organization ?? output?.organization;
220 const nextOrg = news.organization ?? previousOrg;
221 const previousLocation = normalizeLocation(
222 olds?.location ?? output?.location,
223 );
224 const nextLocation = normalizeLocation(
225 news.location ?? olds?.location ?? output?.location,
226 );
227 const replace =
228 (previousId !== undefined &&
229 nextId !== undefined &&
230 nextId !== previousId) ||
231 (previousOrg !== undefined &&
232 nextOrg !== undefined &&
233 nextOrg !== previousOrg) ||
234 previousLocation !== nextLocation;
235 if (!replace) return undefined;
236 return {
237 action: "replace" as const,
238 deleteFirst:
239 previousLocation === nextLocation &&
240 previousOrg === nextOrg &&
241 previousId !== undefined &&
242 nextId === previousId,
243 };
244 }),
245
246 read: Effect.fn(function* ({ id, olds, output }) {
247 const securityProfileGroupId = yield* toPhysicalId(
248 id,
249 olds?.securityProfileGroupId,
250 output?.securityProfileGroupId,
251 "secprofilegroup",
252 );
253 const organization = yield* resolveOrganization(
254 olds?.organization ?? output?.organization,
255 output?.organization,
256 ).pipe(
257 Effect.catchTag("GCP.NetworkSecurity.OrganizationRequired", () =>
258 Effect.succeed(output?.organization ?? ""),
259 ),
260 );
261 const location = normalizeLocation(olds?.location ?? output?.location);
262 const name =
263 output?.name ??
264 (organization.length > 0
265 ? resourceName(organization, location, securityProfileGroupId)
266 : "");
267 if (name.length === 0) return undefined;
268 const existing = yield* getByName(name);
269 if (existing === undefined) return undefined;
270 const attrs = toAttrs(existing);
271 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
272 ? attrs
273 : Unowned(attrs);
274 }),
275
276 list: () =>
277 Effect.gen(function* () {
278 const env = yield* GcpEnvironment.current;
279 const orgs = yield* listOrganizations(env.project);
280 const listed: OrganizationsSecurityProfileGroup["Attributes"][] = [];
281 for (const organization of orgs) {
282 listed.push(...(yield* listOwned(organization)));
283 }
284 return listed;
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const securityProfileGroupId = yield* toPhysicalId(
289 id,
290 news.securityProfileGroupId,
291 output?.securityProfileGroupId,
292 "secprofilegroup",
293 );
294 const organization = yield* resolveOrganization(
295 news.organization ?? output?.organization,
296 output?.organization,
297 );
298 const location = normalizeLocation(news.location ?? output?.location);
299 const name = resourceName(organization, location, securityProfileGroupId);
300 const desiredLabels = {
301 ...toLabels(news.labels),
302 ...(yield* createInternalLabels(id)),
303 };
304
305 let current = yield* getByName(name);
306
307 if (current === undefined) {
308 const created = yield* networksecurity
309 .createOrganizationsLocationsSecurityProfileGroups({
310 parent: organizationParent(organization, location),
311 securityProfileGroupId,
312 body: {
313 description: news.description,
314 labels: desiredLabels,
315 threatPreventionProfile: news.threatPreventionProfile,
316 urlFilteringProfile: news.urlFilteringProfile,
317 customMirroringProfile: news.customMirroringProfile,
318 customInterceptProfile: news.customInterceptProfile,
319 },
320 })
321 .pipe(
322 Effect.retry({
323 while: (error) => error._tag === "Conflict",
324 times: 5,
325 schedule: Schedule.spaced("2 seconds"),
326 }),
327 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
328 );
329 if (created !== undefined) {
330 yield* waitForOperation(created);
331 }
332 current = yield* waitUntilExists(getByName(name), name);
333 }
334
335 if (current === undefined) {
336 return yield* new ResourceNotResolved({ name });
337 }
338
339 const observedLabels = tagRecord(current.labels);
340 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
341 const labelsChanged = upsert.length > 0 || removed.length > 0;
342 const descriptionChanged =
343 (current.description ?? "") !== (news.description ?? "");
344 const threatChanged =
345 (current.threatPreventionProfile ?? "") !==
346 (news.threatPreventionProfile ?? "");
347 const urlChanged =
348 (current.urlFilteringProfile ?? "") !==
349 (news.urlFilteringProfile ?? "");
350 const mirrorChanged =
351 (current.customMirroringProfile ?? "") !==
352 (news.customMirroringProfile ?? "");
353 const interceptChanged =
354 (current.customInterceptProfile ?? "") !==
355 (news.customInterceptProfile ?? "");
356
357 if (
358 labelsChanged ||
359 descriptionChanged ||
360 threatChanged ||
361 urlChanged ||
362 mirrorChanged ||
363 interceptChanged
364 ) {
365 const updateMask = [
366 labelsChanged ? "labels" : undefined,
367 descriptionChanged ? "description" : undefined,
368 threatChanged ? "threatPreventionProfile" : undefined,
369 urlChanged ? "urlFilteringProfile" : undefined,
370 mirrorChanged ? "customMirroringProfile" : undefined,
371 interceptChanged ? "customInterceptProfile" : undefined,
372 ].filter((field): field is string => field !== undefined);
373
374 const operation =
375 yield* networksecurity.patchOrganizationsLocationsSecurityProfileGroups(
376 {
377 name: current.name ?? name,
378 updateMask: updateMask.join(","),
379 body: {
380 name: current.name ?? name,
381 labels: desiredLabels,
382 description: news.description,
383 threatPreventionProfile: news.threatPreventionProfile,
384 urlFilteringProfile: news.urlFilteringProfile,
385 customMirroringProfile: news.customMirroringProfile,
386 customInterceptProfile: news.customInterceptProfile,
387 },
388 },
389 );
390 yield* waitForOperation(operation);
391 current = yield* waitUntilExists(
392 getByName(current.name ?? name),
393 current.name ?? name,
394 );
395 }
396
397 return toAttrs(current);
398 }),
399
400 delete: Effect.fn(function* ({ output }) {
401 const operation = yield* networksecurity
402 .deleteOrganizationsLocationsSecurityProfileGroups({
403 name: output.name,
404 etag: output.etag,
405 })
406 .pipe(
407 Effect.retry({
408 while: (error) => error._tag === "Conflict",
409 times: 8,
410 schedule: Schedule.spaced("2 seconds"),
411 }),
412 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
413 );
414 if (operation !== undefined) {
415 yield* waitForOperation(operation, { notFoundOk: true });
416 }
417 yield* waitUntilGone(getByName(output.name), output.name);
418 }),
419 });