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