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 { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 normalizeLocation,
14 parentOf,
15 parseResourceName,
16 resourceName,
17 toId,
18} from "./names.ts";
19import { waitForOperation } from "./operations.ts";
20import {
21 createInternalLabels,
22 encodeDescription,
23 hasAlchemyLabels,
24 hasOwnershipMarker,
25 parseDescription,
26 sameStringList,
27} from "./ownership.ts";
28
29const COLLECTION = "urlLists";
30
31export type UrlListProps = {
32 /**
33 * UrlList id (the `{urlList}` segment of
34 * `projects/{project}/locations/{location}/urlLists/{urlList}`). If
35 * omitted, a unique RFC1035 name is generated from the stack, stage,
36 * and logical id. Must match `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`.
37 * Immutable — changing it replaces the list.
38 */
39 urlListId?: string;
40 /**
41 * Location (`us-central1`, `us-east1`, …). Immutable — changing it
42 * replaces the list. `US-CENTRAL1` is accepted and normalized to
43 * `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
45 */
46 location?: string;
47 /**
48 * FQDNs, host patterns, URLs, and URL patterns in this list.
49 */
50 values: string[];
51 /**
52 * Human-readable description. UrlList has no labels field, so Alchemy
53 * ownership is stored in a `[alchemy …]` prefix and stripped from
54 * attributes.
55 */
56 description?: string;
57};
58
59export type UrlList = Resource<
60 "GCP.NetworkSecurity.UrlList",
61 UrlListProps,
62 {
63 /** Full resource name `projects/{project}/locations/{location}/urlLists/{urlList}`. */
64 name: string;
65 /** UrlList id (last path segment). */
66 urlListId: string;
67 /** Project id. */
68 project: string;
69 /** Location id (`us-central1`, …). */
70 location: string;
71 /** FQDNs and URLs currently configured. */
72 values: string[];
73 /** User description with the Alchemy ownership prefix stripped. */
74 description: string | undefined;
75 /** RFC3339 creation timestamp. */
76 createTime: string | undefined;
77 /** RFC3339 last-update timestamp. */
78 updateTime: string | undefined;
79 },
80 never,
81 Providers
82>;
83
84/**
85 * A reusable list of hosts, host patterns, URLs, and URL patterns used
86 * by Secure Web Proxy URL filtering.
87 *
88 * UrlList has no labels field, so Alchemy stamps ownership into the
89 * description for `list` / nuke. Changing `urlListId` or `location`
90 * replaces the list. `values` and `description` update in place.
91 *
92 * ### Creating a UrlList
93 * **Example:** Generated name
94 * ```typescript
95 * const blocked = yield* GCP.NetworkSecurity.UrlList("Blocked", {
96 * values: ["malware.example.com", "phishing.example.net"],
97 * });
98 * ```
99 *
100 * **Example:** Named list with a description
101 * ```typescript
102 * const blocked = yield* GCP.NetworkSecurity.UrlList("Blocked", {
103 * urlListId: "app-blocked-hosts",
104 * location: "us-central1",
105 * description: "denied destinations",
106 * values: ["malware.example.com"],
107 * });
108 * ```
109 *
110 * ### Updating a UrlList
111 * **Example:** Add values
112 * ```typescript
113 * const blocked = yield* GCP.NetworkSecurity.UrlList("Blocked", {
114 * urlListId: "app-blocked-hosts",
115 * location: "us-central1",
116 * description: "denied destinations v2",
117 * values: ["malware.example.com", "c2.example.net"],
118 * });
119 * ```
120 *
121 * @resource
122 * @category NetworkSecurity
123 */
124export const UrlList = Resource<UrlList>("GCP.NetworkSecurity.UrlList");
125
126export class UrlListNotResolved extends Data.TaggedError(
127 "GCP.NetworkSecurity.UrlListNotResolved",
128)<{
129 name: string;
130}> {}
131
132export class UrlListStillExists extends Data.TaggedError(
133 "GCP.NetworkSecurity.UrlListStillExists",
134)<{
135 name: string;
136}> {}
137
138const toAttrs = (
139 list: networksecurity.UrlList,
140 project: string,
141 region: string,
142) => {
143 const name = list.name ?? "";
144 const parsed = parseResourceName(name, COLLECTION);
145 const owned = parseDescription(list.description);
146 return {
147 name,
148 urlListId: parsed.id,
149 project: parsed.project || project,
150 location: parsed.location || region,
151 values: list.values ?? [],
152 description: owned.description,
153 createTime: list.createTime,
154 updateTime: list.updateTime,
155 };
156};
157
158const getByName = (name: string) =>
159 networksecurity
160 .getProjectsLocationsUrlLists({ name })
161 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
162
163const waitUntilExists = (name: string) =>
164 getByName(name).pipe(
165 Effect.flatMap((list) =>
166 list
167 ? Effect.succeed(list)
168 : Effect.fail(new UrlListNotResolved({ name })),
169 ),
170 Effect.retry({
171 while: (error) => error._tag === "GCP.NetworkSecurity.UrlListNotResolved",
172 times: 8,
173 schedule: Schedule.spaced("1 second"),
174 }),
175 );
176
177const waitUntilGone = (name: string) =>
178 getByName(name).pipe(
179 Effect.flatMap((list) =>
180 list === undefined
181 ? Effect.void
182 : Effect.fail(new UrlListStillExists({ name })),
183 ),
184 Effect.retry({
185 while: (error) => error._tag === "GCP.NetworkSecurity.UrlListStillExists",
186 times: 10,
187 schedule: Schedule.spaced("2 seconds"),
188 }),
189 );
190
191const listOwned = (project: string, region: string) =>
192 networksecurity.listProjectsLocationsUrlLists
193 .pages({
194 parent: parentOf(project, "-"),
195 pageSize: 1000,
196 })
197 .pipe(
198 Stream.flatMap((page) => Stream.fromIterable(page.urlLists ?? [])),
199 Stream.filter((list) => hasOwnershipMarker(list.description)),
200 Stream.map((list) => toAttrs(list, project, region)),
201 Stream.runCollect,
202 Effect.map((chunk) => Array.from(chunk)),
203 Effect.catchTag("NotFound", () => Effect.succeed([])),
204 );
205
206export const UrlListProvider = () =>
207 Provider.succeed(UrlList, {
208 stables: ["name", "urlListId", "project", "location", "createTime"],
209
210 diff: Effect.fn(function* ({ news, olds, output }) {
211 if (!isResolved(news)) return undefined;
212 const env = yield* GcpEnvironment.current;
213 const previousId = olds?.urlListId ?? output?.urlListId;
214 const nextId = news.urlListId ?? previousId;
215 const previousLocation = normalizeLocation(
216 olds?.location ?? output?.location,
217 env.region,
218 );
219 const nextLocation = normalizeLocation(
220 news.location ?? olds?.location ?? output?.location,
221 env.region,
222 );
223 if (
224 (previousId !== undefined &&
225 nextId !== undefined &&
226 nextId !== previousId) ||
227 previousLocation !== nextLocation
228 ) {
229 return { action: "replace" as const };
230 }
231 return undefined;
232 }),
233
234 read: Effect.fn(function* ({ id, olds, output }) {
235 const env = yield* GcpEnvironment.current;
236 const urlListId = yield* toId(
237 id,
238 olds?.urlListId,
239 output?.urlListId,
240 "urllist",
241 );
242 const location = normalizeLocation(
243 olds?.location ?? output?.location,
244 env.region,
245 );
246 const name =
247 output?.name ??
248 resourceName(env.project, location, COLLECTION, urlListId);
249 const existing = yield* getByName(name);
250 if (existing === undefined) return undefined;
251 const attrs = toAttrs(existing, env.project, env.region);
252 const { labels } = parseDescription(existing.description);
253 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
254 }),
255
256 list: () =>
257 Effect.gen(function* () {
258 const env = yield* GcpEnvironment.current;
259 return yield* listOwned(env.project, env.region);
260 }),
261
262 reconcile: Effect.fn(function* ({ id, news, output }) {
263 const env = yield* GcpEnvironment.current;
264 const urlListId = yield* toId(
265 id,
266 news.urlListId,
267 output?.urlListId,
268 "urllist",
269 );
270 const location = normalizeLocation(
271 news.location ?? output?.location,
272 env.region,
273 );
274 const name = resourceName(env.project, location, COLLECTION, urlListId);
275 const ownership = yield* createInternalLabels(id);
276 const desiredDescription = encodeDescription(ownership, news.description);
277 const desiredValues = news.values;
278
279 let current = yield* getByName(output?.name ?? name);
280
281 if (current === undefined) {
282 const created = yield* networksecurity
283 .createProjectsLocationsUrlLists({
284 parent: parentOf(env.project, location),
285 urlListId,
286 body: {
287 description: desiredDescription,
288 values: desiredValues,
289 },
290 })
291 .pipe(
292 Effect.retry({
293 while: (error) => error._tag === "Conflict",
294 times: 5,
295 schedule: Schedule.spaced("2 seconds"),
296 }),
297 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
298 );
299 if (created !== undefined) {
300 yield* waitForOperation(created);
301 }
302 current = yield* waitUntilExists(name);
303 }
304
305 if (current === undefined) {
306 return yield* new UrlListNotResolved({ name });
307 }
308
309 const descriptionChanged =
310 (current.description ?? "") !== desiredDescription;
311 const valuesChanged = !sameStringList(current.values, desiredValues);
312
313 if (descriptionChanged || valuesChanged) {
314 const updateMask = [
315 descriptionChanged ? "description" : undefined,
316 valuesChanged ? "values" : undefined,
317 ].filter((field): field is string => field !== undefined);
318 const operation = yield* networksecurity.patchProjectsLocationsUrlLists(
319 {
320 name: current.name ?? name,
321 updateMask: updateMask.join(","),
322 body: {
323 name: current.name ?? name,
324 description: desiredDescription,
325 values: desiredValues,
326 },
327 },
328 );
329 yield* waitForOperation(operation);
330 current = yield* waitUntilExists(current.name ?? name);
331 }
332
333 return toAttrs(current, env.project, env.region);
334 }),
335
336 delete: Effect.fn(function* ({ output }) {
337 const operation = yield* networksecurity
338 .deleteProjectsLocationsUrlLists({ name: output.name })
339 .pipe(
340 Effect.retry({
341 while: (error) => error._tag === "Conflict",
342 times: 8,
343 schedule: Schedule.spaced("2 seconds"),
344 }),
345 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
346 );
347 if (operation !== undefined) {
348 yield* waitForOperation(operation, { notFoundOk: true });
349 }
350 yield* waitUntilGone(output.name);
351 }),
352 });