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 TargetHttpProxyProps = {
20 /**
21 * TargetHttpProxy name (RFC1035, 1-63 characters). If omitted, a unique
22 * name is generated from the stack, stage, and logical id. Changing it
23 * replaces the resource.
24 */
25 targetHttpProxyName?: string;
26 /**
27 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
28 * prefix so `list` / nuke can find resources (Compute TargetHttpProxy has
29 * no labels field).
30 */
31 description?: string;
32 /**
33 * URL map this proxy routes to. Accepts a UrlMap self-link, a
34 * `projects/{project}/global/urlMaps/{name}` path, or the UrlMap name.
35 */
36 urlMap: string;
37 /**
38 * Bind inbound traffic to the forwarding-rule address. Only applies when
39 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`.
40 * @default false
41 */
42 proxyBind?: boolean;
43 /**
44 * Idle keep-alive timeout in seconds after a response completes with no
45 * matching traffic. Global external Application Load Balancers allow
46 * 5–1200 (default 610). Not supported on classic Application Load
47 * Balancers.
48 */
49 httpKeepAliveTimeoutSec?: number;
50};
51
52export type TargetHttpProxy = Resource<
53 "GCP.Compute.TargetHttpProxy",
54 TargetHttpProxyProps,
55 {
56 /** TargetHttpProxy name. */
57 targetHttpProxyName: string;
58 /** Project id. */
59 project: string;
60 /** User description with the Alchemy ownership prefix stripped. */
61 description: string | undefined;
62 /** URL of the attached UrlMap. */
63 urlMap: string;
64 /** Whether Envoy inbound bind is enabled. */
65 proxyBind: boolean;
66 /** HTTP keep-alive timeout in seconds, if set. */
67 httpKeepAliveTimeoutSec: number | undefined;
68 /** Server-defined URL. */
69 selfLink: string | undefined;
70 /** Server-assigned numeric id. */
71 targetHttpProxyId: string | undefined;
72 /** Optimistic-locking fingerprint. */
73 fingerprint: string | undefined;
74 /** RFC3339 creation timestamp. */
75 creationTimestamp: string | undefined;
76 /** Resource kind. */
77 kind: string | undefined;
78 },
79 never,
80 Providers
81>;
82
83/**
84 * A global Compute Engine target HTTP proxy.
85 *
86 * Target HTTP proxies are referenced by global forwarding rules and point
87 * at a URL map that routes host/path to a backend service, backend bucket,
88 * or redirect. This resource maps to the global `targetHttpProxies`
89 * collection (`regionTargetHttpProxies` is a separate resource). Compute
90 * TargetHttpProxy has no labels field — Alchemy ownership is stored in the
91 * description so nuke can find leaked proxies.
92 *
93 * ### Creating a Target HTTP Proxy
94 * **Example:** Generated name in front of a URL map
95 * ```typescript
96 * const map = yield* GCP.Compute.UrlMap("web", {
97 * defaultUrlRedirect: {
98 * httpsRedirect: true,
99 * hostRedirect: "example.com",
100 * stripQuery: false,
101 * },
102 * });
103 * const proxy = yield* GCP.Compute.TargetHttpProxy("http", {
104 * urlMap: map.urlMapName,
105 * });
106 * ```
107 *
108 * **Example:** Explicit name and description
109 * ```typescript
110 * const proxy = yield* GCP.Compute.TargetHttpProxy("http", {
111 * targetHttpProxyName: "app-http",
112 * description: "public http frontend",
113 * urlMap: map.selfLink,
114 * });
115 * ```
116 *
117 * @resource
118 * @category Compute
119 */
120export const TargetHttpProxy = Resource<TargetHttpProxy>(
121 "GCP.Compute.TargetHttpProxy",
122);
123
124export class TargetHttpProxyNotResolved extends Data.TaggedError(
125 "GCP.Compute.TargetHttpProxyNotResolved",
126)<{
127 targetHttpProxyName: string;
128}> {}
129
130const toName = (id: string, name: string | undefined, existing?: string) =>
131 Effect.gen(function* () {
132 if (name !== undefined) return name;
133 if (existing !== undefined) return existing;
134 const generated = yield* createPhysicalName({
135 id,
136 maxLength: 63,
137 lowercase: true,
138 });
139 return /^[a-z]/.test(generated) ? generated : `p${generated}`.slice(0, 63);
140 });
141
142const encodeDescription = (
143 labels: Record<string, string>,
144 description: string | undefined,
145): string => {
146 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
147 return description ? `${marker}\n${description}` : marker;
148};
149
150const parseDescription = (
151 description: string | undefined,
152): {
153 labels: Record<string, string>;
154 description: string | undefined;
155} => {
156 if (!description?.startsWith("[alchemy ")) {
157 return { labels: {}, description };
158 }
159 const end = description.indexOf("]");
160 if (end < 0) return { labels: {}, description };
161 const labels: Record<string, string> = {};
162 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
163 const eq = part.indexOf("=");
164 if (eq > 0) {
165 labels[part.slice(0, eq)] = part.slice(eq + 1);
166 }
167 }
168 const rest = description.slice(end + 1).replace(/^\n/, "");
169 return { labels, description: rest.length > 0 ? rest : undefined };
170};
171
172const resourceTail = (value: string | undefined): string => {
173 if (value === undefined || value.length === 0) return "";
174 const parts = value.split("/").filter((part) => part.length > 0);
175 return parts[parts.length - 1] ?? "";
176};
177
178const toUrlMapRef = (project: string, urlMap: string): string => {
179 if (urlMap.includes("/")) return urlMap;
180 return `projects/${project}/global/urlMaps/${urlMap}`;
181};
182
183const toAttrs = (proxy: compute.TargetHttpProxy, project: string) => {
184 const parsed = parseDescription(proxy.description);
185 return {
186 targetHttpProxyName: proxy.name ?? proxy.id ?? "",
187 project,
188 description: parsed.description,
189 urlMap: proxy.urlMap ?? "",
190 proxyBind: proxy.proxyBind === true,
191 httpKeepAliveTimeoutSec: proxy.httpKeepAliveTimeoutSec,
192 selfLink: proxy.selfLink,
193 targetHttpProxyId: proxy.id,
194 fingerprint: proxy.fingerprint,
195 creationTimestamp: proxy.creationTimestamp,
196 kind: proxy.kind,
197 };
198};
199
200const getByName = (project: string, targetHttpProxy: string) =>
201 compute
202 .getTargetHttpProxies({ project, targetHttpProxy })
203 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
204
205export const TargetHttpProxyProvider = () =>
206 Provider.succeed(TargetHttpProxy, {
207 nuke: {
208 dependsOn: ["GCP.Compute.UrlMap"],
209 },
210 stables: [
211 "targetHttpProxyName",
212 "project",
213 "targetHttpProxyId",
214 "selfLink",
215 "creationTimestamp",
216 ],
217
218 diff: Effect.fn(function* ({ news, olds, output }) {
219 if (!isResolved(news)) return undefined;
220 const previous = olds?.targetHttpProxyName ?? output?.targetHttpProxyName;
221 const next = news.targetHttpProxyName;
222 if (previous !== undefined && next !== undefined && previous !== next) {
223 return { action: "replace" as const, deleteFirst: true };
224 }
225 return undefined;
226 }),
227
228 read: Effect.fn(function* ({ id, olds, output }) {
229 const env = yield* GcpEnvironment.current;
230 const targetHttpProxyName = yield* toName(
231 id,
232 olds?.targetHttpProxyName,
233 output?.targetHttpProxyName,
234 );
235 const existing = yield* getByName(env.project, targetHttpProxyName);
236 if (existing === undefined) return undefined;
237 const attrs = toAttrs(existing, env.project);
238 const { labels } = parseDescription(existing.description);
239 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
240 }),
241
242 list: () =>
243 Effect.gen(function* () {
244 const env = yield* GcpEnvironment.current;
245 return yield* compute.listTargetHttpProxies
246 .items({ project: env.project, maxResults: 500 })
247 .pipe(
248 Stream.filter((proxy) => {
249 const { labels } = parseDescription(proxy.description);
250 return Object.keys(labels).some((key) =>
251 key.startsWith("alchemy-"),
252 );
253 }),
254 Stream.map((proxy) => toAttrs(proxy, env.project)),
255 Stream.runCollect,
256 Effect.map((chunk) => Array.from(chunk)),
257 );
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const env = yield* GcpEnvironment.current;
262 const targetHttpProxyName = yield* toName(
263 id,
264 news.targetHttpProxyName,
265 output?.targetHttpProxyName,
266 );
267 const ownership = yield* createInternalLabels(id);
268 const desiredDescription = encodeDescription(ownership, news.description);
269 const desiredUrlMap = toUrlMapRef(env.project, news.urlMap);
270
271 let current = yield* getByName(env.project, targetHttpProxyName);
272
273 if (current === undefined) {
274 const body: compute.TargetHttpProxy = {
275 name: targetHttpProxyName,
276 description: desiredDescription,
277 urlMap: desiredUrlMap,
278 };
279 if (news.proxyBind !== undefined) {
280 body.proxyBind = news.proxyBind;
281 }
282 if (news.httpKeepAliveTimeoutSec !== undefined) {
283 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
284 }
285 yield* compute
286 .insertTargetHttpProxies({
287 project: env.project,
288 body,
289 })
290 .pipe(
291 Effect.flatMap((operation) =>
292 waitGlobalOperation(env.project, operation),
293 ),
294 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
295 );
296 current = yield* getByName(env.project, targetHttpProxyName);
297 }
298
299 if (current === undefined) {
300 return yield* new TargetHttpProxyNotResolved({ targetHttpProxyName });
301 }
302
303 if (resourceTail(current.urlMap) !== resourceTail(desiredUrlMap)) {
304 yield* compute
305 .setUrlMapTargetHttpProxies({
306 project: env.project,
307 targetHttpProxy: targetHttpProxyName,
308 body: { urlMap: desiredUrlMap },
309 })
310 .pipe(
311 Effect.flatMap((operation) =>
312 waitGlobalOperation(env.project, operation),
313 ),
314 );
315 current = yield* getByName(env.project, targetHttpProxyName);
316 if (current === undefined) {
317 return yield* new TargetHttpProxyNotResolved({
318 targetHttpProxyName,
319 });
320 }
321 }
322
323 const descriptionChanged =
324 (current.description ?? "") !== desiredDescription;
325 const proxyBindChanged =
326 news.proxyBind !== undefined &&
327 (current.proxyBind === true) !== news.proxyBind;
328 const keepAliveChanged =
329 news.httpKeepAliveTimeoutSec !== undefined &&
330 current.httpKeepAliveTimeoutSec !== news.httpKeepAliveTimeoutSec;
331
332 if (descriptionChanged || proxyBindChanged || keepAliveChanged) {
333 const body: compute.TargetHttpProxy = {
334 fingerprint: current.fingerprint,
335 description: desiredDescription,
336 };
337 if (news.proxyBind !== undefined) {
338 body.proxyBind = news.proxyBind;
339 }
340 if (news.httpKeepAliveTimeoutSec !== undefined) {
341 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
342 }
343 yield* compute
344 .patchTargetHttpProxies({
345 project: env.project,
346 targetHttpProxy: targetHttpProxyName,
347 body,
348 })
349 .pipe(
350 Effect.flatMap((operation) =>
351 waitGlobalOperation(env.project, operation),
352 ),
353 );
354 current = yield* getByName(env.project, targetHttpProxyName);
355 if (current === undefined) {
356 return yield* new TargetHttpProxyNotResolved({
357 targetHttpProxyName,
358 });
359 }
360 }
361
362 return toAttrs(current, env.project);
363 }),
364
365 delete: Effect.fn(function* ({ output }) {
366 const env = yield* GcpEnvironment.current;
367 const operation = yield* compute
368 .deleteTargetHttpProxies({
369 project: env.project,
370 targetHttpProxy: output.targetHttpProxyName,
371 })
372 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
373 if (operation !== undefined) {
374 yield* waitGlobalOperation(env.project, operation).pipe(
375 Effect.catchTag("NotFound", () => Effect.void),
376 );
377 }
378 }),
379 });