1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19export type RegionUrlMapProps = {
20 /**
21 * UrlMap name (RFC1035, 1-63 characters). If omitted, a unique name is
22 * generated from the stack, stage, and logical id. Changing it replaces
23 * the resource.
24 */
25 urlMapName?: string;
26 /**
27 * Region the URL map lives in (e.g. `us-central1`). Immutable —
28 * changing it replaces the resource. `US-CENTRAL1` is accepted and
29 * normalized to `us-central1`.
30 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
31 */
32 region?: string;
33 /**
34 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
35 * prefix so `list` / nuke can find resources (Compute RegionUrlMap has
36 * no labels field).
37 */
38 description?: string;
39 /**
40 * Default regional backend service URL used when no host rule matches.
41 * Mutually exclusive with `defaultUrlRedirect` and
42 * `defaultRouteAction.weightedBackendServices`.
43 */
44 defaultService?: string;
45 /**
46 * Redirect used when no host rule matches. Mutually exclusive with
47 * `defaultService`.
48 */
49 defaultUrlRedirect?: compute.HttpRedirectAction;
50 /**
51 * Advanced routing used when no host rule matches. Mutually exclusive
52 * with `defaultService` / `defaultUrlRedirect` when
53 * `weightedBackendServices` is set.
54 */
55 defaultRouteAction?: compute.HttpRouteAction;
56 /**
57 * Host-matching rules that select a named path matcher.
58 */
59 hostRules?: compute.HostRule[];
60 /**
61 * Named path matchers referenced by `hostRules`.
62 */
63 pathMatchers?: compute.PathMatcher[];
64 /**
65 * Optional header transformations applied after path-matcher headers.
66 */
67 headerAction?: compute.HttpHeaderAction;
68 /**
69 * UrlMap tests that must pass before an update is accepted (max 100).
70 */
71 tests?: compute.UrlMapTest[];
72};
73
74export type RegionUrlMap = Resource<
75 "GCP.Compute.RegionUrlMap",
76 RegionUrlMapProps,
77 {
78 /** UrlMap name. */
79 urlMapName: string;
80 /** Project id. */
81 project: string;
82 /** Region short name (`us-central1`). */
83 region: string;
84 /** User description with the Alchemy ownership prefix stripped. */
85 description: string | undefined;
86 /** Default regional backend service URL. */
87 defaultService: string | undefined;
88 /** Default redirect, if configured. */
89 defaultUrlRedirect: compute.HttpRedirectAction | undefined;
90 /** Default route action, if configured. */
91 defaultRouteAction: compute.HttpRouteAction | undefined;
92 /** Host-matching rules. */
93 hostRules: compute.HostRule[];
94 /** Named path matchers. */
95 pathMatchers: compute.PathMatcher[];
96 /** Header transformations, if configured. */
97 headerAction: compute.HttpHeaderAction | undefined;
98 /** UrlMap tests. */
99 tests: compute.UrlMapTest[];
100 /** Server-defined URL. */
101 selfLink: string | undefined;
102 /** Server-assigned numeric id. */
103 urlMapId: string | undefined;
104 /** Optimistic-locking fingerprint. */
105 fingerprint: string | undefined;
106 /** RFC3339 creation timestamp. */
107 creationTimestamp: string | undefined;
108 /** Resource kind. */
109 kind: string | undefined;
110 },
111 never,
112 Providers
113>;
114
115/**
116 * A regional Compute Engine URL map.
117 *
118 * Regional URL maps route hostnames and URL paths to a regional backend
119 * service or HTTP redirect. They back internal Application Load Balancers
120 * and regional external / internal Application Load Balancers. This
121 * resource maps to the `regionUrlMaps` collection (the global `urlMaps`
122 * collection is `GCP.Compute.UrlMap`). Compute RegionUrlMap has no labels
123 * field — Alchemy ownership is stored in the description so nuke can find
124 * leaked maps.
125 *
126 * One of `defaultService`, `defaultUrlRedirect`, or
127 * `defaultRouteAction.weightedBackendServices` is required.
128 *
129 * ### Creating a Regional URL Map
130 * **Example:** Generated name with a default HTTPS redirect
131 * ```typescript
132 * const map = yield* GCP.Compute.RegionUrlMap("web", {
133 * region: "us-central1",
134 * defaultUrlRedirect: {
135 * httpsRedirect: true,
136 * hostRedirect: "example.com",
137 * stripQuery: false,
138 * },
139 * });
140 * ```
141 *
142 * **Example:** Host rules and path matchers
143 * ```typescript
144 * const map = yield* GCP.Compute.RegionUrlMap("web", {
145 * description: "public https",
146 * defaultUrlRedirect: {
147 * httpsRedirect: true,
148 * stripQuery: false,
149 * },
150 * hostRules: [{ hosts: ["example.com"], pathMatcher: "all" }],
151 * pathMatchers: [
152 * {
153 * name: "all",
154 * defaultUrlRedirect: {
155 * httpsRedirect: true,
156 * hostRedirect: "www.example.com",
157 * stripQuery: false,
158 * },
159 * },
160 * ],
161 * });
162 * ```
163 *
164 * **Example:** Default regional backend service
165 * ```typescript
166 * const map = yield* GCP.Compute.RegionUrlMap("web", {
167 * defaultService: backend.selfLink,
168 * });
169 * ```
170 *
171 * @resource
172 * @category Compute
173 */
174export const RegionUrlMap = Resource<RegionUrlMap>("GCP.Compute.RegionUrlMap");
175
176export class RegionUrlMapNotResolved extends Data.TaggedError(
177 "GCP.Compute.RegionUrlMapNotResolved",
178)<{
179 urlMapName: string;
180 region: string;
181}> {}
182
183const lastSegment = (value: string) => {
184 const trimmed = value.replace(/\/+$/, "");
185 const parts = trimmed.split("/");
186 return parts[parts.length - 1] || trimmed;
187};
188
189const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
190 lastSegment(region ?? defaultRegion).toLowerCase();
191
192const toName = (id: string, name: string | undefined, existing?: string) =>
193 Effect.gen(function* () {
194 if (name !== undefined) return name;
195 if (existing !== undefined) return existing;
196 const generated = yield* createPhysicalName({
197 id,
198 maxLength: 63,
199 lowercase: true,
200 });
201 return /^[a-z]/.test(generated) ? generated : `u${generated}`.slice(0, 63);
202 });
203
204const encodeDescription = (
205 labels: Record<string, string>,
206 description: string | undefined,
207): string => {
208 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
209 return description ? `${marker}\n${description}` : marker;
210};
211
212const parseDescription = (
213 description: string | undefined,
214): {
215 labels: Record<string, string>;
216 description: string | undefined;
217} => {
218 if (!description?.startsWith("[alchemy ")) {
219 return { labels: {}, description };
220 }
221 const end = description.indexOf("]");
222 if (end < 0) return { labels: {}, description };
223 const labels: Record<string, string> = {};
224 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
225 const eq = part.indexOf("=");
226 if (eq > 0) {
227 labels[part.slice(0, eq)] = part.slice(eq + 1);
228 }
229 }
230 const rest = description.slice(end + 1).replace(/^\n/, "");
231 return { labels, description: rest.length > 0 ? rest : undefined };
232};
233
234const hasOwnershipMarker = (description: string | undefined) =>
235 Object.keys(parseDescription(description).labels).some((key) =>
236 key.startsWith("alchemy-"),
237 );
238
239const toBody = (
240 urlMapName: string,
241 props: RegionUrlMapProps,
242 ownership: Record<string, string>,
243 fingerprint?: string,
244): compute.UrlMap => ({
245 name: urlMapName,
246 fingerprint,
247 description: encodeDescription(ownership, props.description),
248 defaultService: props.defaultService,
249 defaultUrlRedirect: props.defaultUrlRedirect,
250 defaultRouteAction: props.defaultRouteAction,
251 hostRules: props.hostRules,
252 pathMatchers: props.pathMatchers,
253 headerAction: props.headerAction,
254 tests: props.tests,
255});
256
257const toAttrs = (urlMap: compute.UrlMap, project: string) => {
258 const parsed = parseDescription(urlMap.description);
259 return {
260 urlMapName: urlMap.name ?? urlMap.id ?? "",
261 project,
262 region: lastSegment(urlMap.region ?? "").toLowerCase(),
263 description: parsed.description,
264 defaultService: urlMap.defaultService,
265 defaultUrlRedirect: urlMap.defaultUrlRedirect,
266 defaultRouteAction: urlMap.defaultRouteAction,
267 hostRules: urlMap.hostRules ?? [],
268 pathMatchers: urlMap.pathMatchers ?? [],
269 headerAction: urlMap.headerAction,
270 tests: urlMap.tests ?? [],
271 selfLink: urlMap.selfLink,
272 urlMapId: urlMap.id,
273 fingerprint: urlMap.fingerprint,
274 creationTimestamp: urlMap.creationTimestamp,
275 kind: urlMap.kind,
276 };
277};
278
279const isEmpty = (value: unknown): boolean =>
280 value === undefined ||
281 value === null ||
282 (Array.isArray(value) && value.length === 0);
283
284const subsetEqual = (observed: unknown, desired: unknown): boolean => {
285 if (desired === undefined || desired === null) return isEmpty(observed);
286 if (Array.isArray(desired)) {
287 if (!Array.isArray(observed) || observed.length !== desired.length) {
288 return false;
289 }
290 return desired.every((item, index) => subsetEqual(observed[index], item));
291 }
292 if (typeof desired === "object") {
293 if (
294 observed === undefined ||
295 observed === null ||
296 typeof observed !== "object"
297 ) {
298 return false;
299 }
300 const current = observed as Record<string, unknown>;
301 for (const [key, value] of Object.entries(
302 desired as Record<string, unknown>,
303 )) {
304 if (value === undefined) continue;
305 if (!subsetEqual(current[key], value)) return false;
306 }
307 return true;
308 }
309 return observed === desired;
310};
311
312const needsUpdate = (current: compute.UrlMap, desired: compute.UrlMap) =>
313 (current.description ?? "") !== (desired.description ?? "") ||
314 !subsetEqual(current.defaultService, desired.defaultService) ||
315 !subsetEqual(current.defaultUrlRedirect, desired.defaultUrlRedirect) ||
316 !subsetEqual(current.defaultRouteAction, desired.defaultRouteAction) ||
317 !subsetEqual(current.hostRules, desired.hostRules) ||
318 !subsetEqual(current.pathMatchers, desired.pathMatchers) ||
319 !subsetEqual(current.headerAction, desired.headerAction) ||
320 !subsetEqual(current.tests, desired.tests);
321
322const getByName = (project: string, region: string, urlMap: string) =>
323 compute
324 .getRegionUrlMaps({ project, region, urlMap })
325 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
326
327export const RegionUrlMapProvider = () =>
328 Provider.succeed(RegionUrlMap, {
329 stables: [
330 "urlMapName",
331 "project",
332 "region",
333 "urlMapId",
334 "selfLink",
335 "creationTimestamp",
336 ],
337
338 diff: Effect.fn(function* ({ news, olds, output }) {
339 if (!isResolved(news)) return undefined;
340 const env = yield* GcpEnvironment.current;
341 const previousName = olds?.urlMapName ?? output?.urlMapName;
342 const nextName = news.urlMapName ?? previousName;
343 const nameChanged =
344 previousName !== undefined &&
345 nextName !== undefined &&
346 previousName !== nextName;
347 const previousRegion = normalizeRegion(
348 olds?.region ?? output?.region,
349 env.region,
350 );
351 const nextRegion = normalizeRegion(
352 news.region ?? output?.region,
353 env.region,
354 );
355 const regionChanged = previousRegion !== nextRegion;
356 if (nameChanged || regionChanged) {
357 return { action: "replace" as const, deleteFirst: true };
358 }
359 return undefined;
360 }),
361
362 read: Effect.fn(function* ({ id, olds, output }) {
363 const env = yield* GcpEnvironment.current;
364 const urlMapName = yield* toName(
365 id,
366 olds?.urlMapName,
367 output?.urlMapName,
368 );
369 const region = normalizeRegion(
370 olds?.region ?? output?.region,
371 env.region,
372 );
373 const existing = yield* getByName(env.project, region, urlMapName);
374 if (existing === undefined) return undefined;
375 const attrs = toAttrs(existing, env.project);
376 const { labels } = parseDescription(existing.description);
377 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
378 }),
379
380 list: () =>
381 Effect.gen(function* () {
382 const env = yield* GcpEnvironment.current;
383 const pages = yield* compute.aggregatedListUrlMaps
384 .pages({
385 project: env.project,
386 returnPartialSuccess: true,
387 maxResults: 500,
388 })
389 .pipe(Stream.runCollect);
390 return Array.from(pages).flatMap((page) =>
391 Object.values(page.items ?? {}).flatMap((scoped) =>
392 (scoped?.urlMaps ?? [])
393 .filter((urlMap) => (urlMap.region ?? "").length > 0)
394 .filter((urlMap) => hasOwnershipMarker(urlMap.description))
395 .map((urlMap) => toAttrs(urlMap, env.project)),
396 ),
397 );
398 }),
399
400 reconcile: Effect.fn(function* ({ id, news, output }) {
401 const env = yield* GcpEnvironment.current;
402 const urlMapName = yield* toName(id, news.urlMapName, output?.urlMapName);
403 const region = normalizeRegion(news.region ?? output?.region, env.region);
404 const ownership = yield* createInternalLabels(id);
405 const desired = toBody(urlMapName, news, ownership);
406
407 let current = yield* getByName(env.project, region, urlMapName);
408
409 if (current === undefined) {
410 yield* compute
411 .insertRegionUrlMaps({
412 project: env.project,
413 region,
414 body: desired,
415 })
416 .pipe(
417 Effect.flatMap((operation) =>
418 waitRegionOperation(env.project, region, operation, {
419 ignore: ["RESOURCE_ALREADY_EXISTS"],
420 }),
421 ),
422 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
423 );
424 current = yield* getByName(env.project, region, urlMapName);
425 }
426
427 if (current === undefined) {
428 return yield* new RegionUrlMapNotResolved({ urlMapName, region });
429 }
430
431 if (needsUpdate(current, desired)) {
432 yield* compute
433 .updateRegionUrlMaps({
434 project: env.project,
435 region,
436 urlMap: urlMapName,
437 body: toBody(urlMapName, news, ownership, current.fingerprint),
438 })
439 .pipe(
440 Effect.flatMap((operation) =>
441 waitRegionOperation(env.project, region, operation),
442 ),
443 );
444 current = yield* getByName(env.project, region, urlMapName);
445 if (current === undefined) {
446 return yield* new RegionUrlMapNotResolved({ urlMapName, region });
447 }
448 }
449
450 return toAttrs(current, env.project);
451 }),
452
453 delete: Effect.fn(function* ({ output }) {
454 const env = yield* GcpEnvironment.current;
455 const region = normalizeRegion(output.region, env.region);
456 const operation = yield* compute
457 .deleteRegionUrlMaps({
458 project: env.project,
459 region,
460 urlMap: output.urlMapName,
461 })
462 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
463 if (operation !== undefined) {
464 yield* waitRegionOperation(env.project, region, operation).pipe(
465 Effect.catchTag("NotFound", () => Effect.void),
466 );
467 }
468 }),
469 });