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