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