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 Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_CHECK_INTERVAL = 5;
21const DEFAULT_TIMEOUT = 5;
22const DEFAULT_HEALTHY = 2;
23const DEFAULT_UNHEALTHY = 2;
24const DEFAULT_PORT = 80;
25const DEFAULT_REQUEST_PATH = "/";
26
27export type HttpHealthCheckProps = {
28 /**
29 * Health check name (RFC1035, 1-63 chars). If omitted, a unique name is
30 * generated from the stack, stage, and logical id. Changing it replaces
31 * the resource.
32 */
33 httpHealthCheckName?: string;
34 /**
35 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
36 * prefix so `list` / nuke can find resources (legacy HttpHealthCheck has
37 * no labels field).
38 */
39 description?: string;
40 /**
41 * How often to probe, in seconds.
42 * @default 5
43 */
44 checkIntervalSec?: number;
45 /**
46 * Probe timeout in seconds. Must be ≤ `checkIntervalSec`.
47 * @default 5
48 */
49 timeoutSec?: number;
50 /**
51 * Consecutive successes before an instance is healthy.
52 * @default 2
53 */
54 healthyThreshold?: number;
55 /**
56 * Consecutive failures before an instance is unhealthy.
57 * @default 2
58 */
59 unhealthyThreshold?: number;
60 /**
61 * TCP port for the HTTP probe.
62 * @default 80
63 */
64 port?: number;
65 /**
66 * Request path. Query parameters are not supported.
67 * @default "/"
68 */
69 requestPath?: string;
70 /**
71 * Host header. Empty uses the destination IP.
72 */
73 host?: string;
74};
75
76export type HttpHealthCheck = Resource<
77 "GCP.Compute.HttpHealthCheck",
78 HttpHealthCheckProps,
79 {
80 /** Health check name. */
81 httpHealthCheckName: string;
82 /** Project id. */
83 project: string;
84 /** User description with the Alchemy ownership prefix stripped. */
85 description: string | undefined;
86 /** Probe interval in seconds. */
87 checkIntervalSec: number;
88 /** Probe timeout in seconds. */
89 timeoutSec: number;
90 /** Consecutive successes before healthy. */
91 healthyThreshold: number;
92 /** Consecutive failures before unhealthy. */
93 unhealthyThreshold: number;
94 /** TCP port. */
95 port: number;
96 /** Request path. */
97 requestPath: string;
98 /** Host header, if set. */
99 host: string | undefined;
100 /** Server-defined URL. */
101 selfLink: string | undefined;
102 /** Server-assigned numeric id. */
103 httpHealthCheckId: string | undefined;
104 /** RFC3339 creation timestamp. */
105 creationTimestamp: string | undefined;
106 /** Resource kind. */
107 kind: string | undefined;
108 },
109 never,
110 Providers
111>;
112
113/**
114 * A legacy global Compute Engine HTTP health check.
115 *
116 * Legacy HTTP health checks are required by target-pool network load
117 * balancers. Other load balancers and MIG autohealing should use
118 * `HealthCheck`. Compute HttpHealthCheck has no labels field — Alchemy
119 * ownership is stored in the description so nuke can find leaked checks.
120 *
121 * ### Creating an HTTP Health Check
122 * **Example:** Generated name
123 * ```typescript
124 * const check = yield* GCP.Compute.HttpHealthCheck("api", {});
125 * ```
126 *
127 * **Example:** Path, port, and interval
128 * ```typescript
129 * const check = yield* GCP.Compute.HttpHealthCheck("api", {
130 * description: "frontend /health",
131 * port: 80,
132 * requestPath: "/health",
133 * checkIntervalSec: 10,
134 * timeoutSec: 5,
135 * });
136 * ```
137 *
138 * ### Target Pools
139 * **Example:** Attach to a target pool
140 * ```typescript
141 * const check = yield* GCP.Compute.HttpHealthCheck("api", {
142 * requestPath: "/health",
143 * });
144 * const pool = yield* GCP.Compute.TargetPool("backends", {
145 * healthChecks: [check.httpHealthCheckName],
146 * });
147 * ```
148 *
149 * @resource
150 * @category Compute
151 */
152export const HttpHealthCheck = Resource<HttpHealthCheck>(
153 "GCP.Compute.HttpHealthCheck",
154);
155
156export class HttpHealthCheckNotResolved extends Data.TaggedError(
157 "GCP.Compute.HttpHealthCheckNotResolved",
158)<{
159 httpHealthCheckName: string;
160}> {}
161
162const rfc1035 = (name: string): string => {
163 let next = name
164 .toLowerCase()
165 .replace(/[^a-z0-9-]/g, "-")
166 .replace(/-+/g, "-")
167 .replace(/^-+/, "")
168 .replace(/-+$/, "");
169 if (!/^[a-z]/.test(next)) {
170 next = `h${next}`;
171 }
172 next = next.slice(0, 63).replace(/-+$/, "");
173 return next.length > 0 ? next : "httphealthcheck";
174};
175
176const toName = (id: string, name: string | undefined, existing?: string) =>
177 Effect.gen(function* () {
178 if (name !== undefined) return name;
179 if (existing !== undefined) return existing;
180 return rfc1035(
181 yield* createPhysicalName({
182 id,
183 maxLength: 63,
184 lowercase: true,
185 }),
186 );
187 });
188
189const encodeDescription = (
190 labels: Record<string, string>,
191 description: string | undefined,
192): string => {
193 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
194 return description ? `${marker}\n${description}` : marker;
195};
196
197const parseDescription = (
198 description: string | undefined,
199): {
200 labels: Record<string, string>;
201 description: string | undefined;
202} => {
203 if (!description?.startsWith("[alchemy ")) {
204 return { labels: {}, description };
205 }
206 const end = description.indexOf("]");
207 if (end < 0) return { labels: {}, description };
208 const labels: Record<string, string> = {};
209 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
210 const eq = part.indexOf("=");
211 if (eq > 0) {
212 labels[part.slice(0, eq)] = part.slice(eq + 1);
213 }
214 }
215 const rest = description.slice(end + 1).replace(/^\n/, "");
216 return { labels, description: rest.length > 0 ? rest : undefined };
217};
218
219const toBody = (
220 httpHealthCheckName: string,
221 props: HttpHealthCheckProps,
222 ownership: Record<string, string>,
223): compute.HttpHealthCheck => ({
224 name: httpHealthCheckName,
225 description: encodeDescription(ownership, props.description),
226 checkIntervalSec: props.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL,
227 timeoutSec: props.timeoutSec ?? DEFAULT_TIMEOUT,
228 healthyThreshold: props.healthyThreshold ?? DEFAULT_HEALTHY,
229 unhealthyThreshold: props.unhealthyThreshold ?? DEFAULT_UNHEALTHY,
230 port: props.port ?? DEFAULT_PORT,
231 requestPath: props.requestPath ?? DEFAULT_REQUEST_PATH,
232 host: props.host,
233});
234
235const toAttrs = (
236 check: compute.HttpHealthCheck,
237 project: string,
238): HttpHealthCheck["Attributes"] => {
239 const parsed = parseDescription(check.description);
240 const host = check.host && check.host.length > 0 ? check.host : undefined;
241 return {
242 httpHealthCheckName: check.name ?? check.id ?? "",
243 project,
244 description: parsed.description,
245 checkIntervalSec: check.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL,
246 timeoutSec: check.timeoutSec ?? DEFAULT_TIMEOUT,
247 healthyThreshold: check.healthyThreshold ?? DEFAULT_HEALTHY,
248 unhealthyThreshold: check.unhealthyThreshold ?? DEFAULT_UNHEALTHY,
249 port: check.port ?? DEFAULT_PORT,
250 requestPath: check.requestPath ?? DEFAULT_REQUEST_PATH,
251 host,
252 selfLink: check.selfLink,
253 httpHealthCheckId: check.id,
254 creationTimestamp: check.creationTimestamp,
255 kind: check.kind,
256 };
257};
258
259const needsUpdate = (
260 current: compute.HttpHealthCheck,
261 desired: compute.HttpHealthCheck,
262) => {
263 if ((current.description ?? "") !== (desired.description ?? "")) return true;
264 if (
265 (current.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL) !==
266 (desired.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL)
267 ) {
268 return true;
269 }
270 if (
271 (current.timeoutSec ?? DEFAULT_TIMEOUT) !==
272 (desired.timeoutSec ?? DEFAULT_TIMEOUT)
273 ) {
274 return true;
275 }
276 if (
277 (current.healthyThreshold ?? DEFAULT_HEALTHY) !==
278 (desired.healthyThreshold ?? DEFAULT_HEALTHY)
279 ) {
280 return true;
281 }
282 if (
283 (current.unhealthyThreshold ?? DEFAULT_UNHEALTHY) !==
284 (desired.unhealthyThreshold ?? DEFAULT_UNHEALTHY)
285 ) {
286 return true;
287 }
288 if ((current.port ?? DEFAULT_PORT) !== (desired.port ?? DEFAULT_PORT)) {
289 return true;
290 }
291 if (
292 (current.requestPath ?? DEFAULT_REQUEST_PATH) !==
293 (desired.requestPath ?? DEFAULT_REQUEST_PATH)
294 ) {
295 return true;
296 }
297 return (current.host ?? "") !== (desired.host ?? "");
298};
299
300const getByName = (project: string, httpHealthCheck: string) =>
301 compute
302 .getHttpHealthChecks({ project, httpHealthCheck })
303 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
304
305const awaitResource = (project: string, httpHealthCheckName: string) =>
306 getByName(project, httpHealthCheckName).pipe(
307 Effect.repeat({
308 schedule: Schedule.spaced("1 second"),
309 until: (check) => check !== undefined,
310 times: 8,
311 }),
312 );
313
314export const HttpHealthCheckProvider = () =>
315 Provider.succeed(HttpHealthCheck, {
316 stables: [
317 "httpHealthCheckName",
318 "project",
319 "httpHealthCheckId",
320 "selfLink",
321 "creationTimestamp",
322 ],
323
324 diff: Effect.fn(function* ({ news, olds, output }) {
325 if (!isResolved(news)) return undefined;
326 const previousName =
327 olds?.httpHealthCheckName ?? output?.httpHealthCheckName;
328 const nextName = news.httpHealthCheckName;
329 if (
330 previousName !== undefined &&
331 nextName !== undefined &&
332 previousName !== nextName
333 ) {
334 return { action: "replace" as const };
335 }
336 return undefined;
337 }),
338
339 read: Effect.fn(function* ({ id, olds, output }) {
340 const env = yield* GcpEnvironment.current;
341 const httpHealthCheckName = yield* toName(
342 id,
343 olds?.httpHealthCheckName,
344 output?.httpHealthCheckName,
345 );
346 const existing = yield* getByName(env.project, httpHealthCheckName);
347 if (existing === undefined) return undefined;
348 const attrs = toAttrs(existing, env.project);
349 const { labels } = parseDescription(existing.description);
350 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
351 }),
352
353 list: () =>
354 Effect.gen(function* () {
355 const env = yield* GcpEnvironment.current;
356 return yield* compute.listHttpHealthChecks
357 .items({ project: env.project, maxResults: 500 })
358 .pipe(
359 Stream.filter((check) => {
360 const { labels } = parseDescription(check.description);
361 return Object.keys(labels).some((key) =>
362 key.startsWith("alchemy-"),
363 );
364 }),
365 Stream.map((check) => toAttrs(check, 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 httpHealthCheckName = yield* toName(
374 id,
375 news.httpHealthCheckName,
376 output?.httpHealthCheckName,
377 );
378 const ownership = yield* createInternalLabels(id);
379 const desired = toBody(httpHealthCheckName, news, ownership);
380
381 let current = yield* getByName(env.project, httpHealthCheckName);
382
383 if (current === undefined) {
384 yield* compute
385 .insertHttpHealthChecks({
386 project: env.project,
387 body: desired,
388 })
389 .pipe(
390 Effect.flatMap((operation) =>
391 waitGlobalOperation(env.project, operation),
392 ),
393 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
394 );
395 current = yield* awaitResource(env.project, httpHealthCheckName);
396 }
397
398 if (current === undefined) {
399 return yield* new HttpHealthCheckNotResolved({ httpHealthCheckName });
400 }
401
402 if (needsUpdate(current, desired)) {
403 yield* compute
404 .patchHttpHealthChecks({
405 project: env.project,
406 httpHealthCheck: httpHealthCheckName,
407 body: desired,
408 })
409 .pipe(
410 Effect.flatMap((operation) =>
411 waitGlobalOperation(env.project, operation),
412 ),
413 );
414 current = yield* getByName(env.project, httpHealthCheckName);
415 if (current === undefined) {
416 return yield* new HttpHealthCheckNotResolved({
417 httpHealthCheckName,
418 });
419 }
420 }
421
422 return toAttrs(current, env.project);
423 }),
424
425 delete: Effect.fn(function* ({ output }) {
426 const env = yield* GcpEnvironment.current;
427 const operation = yield* compute
428 .deleteHttpHealthChecks({
429 project: env.project,
430 httpHealthCheck: output.httpHealthCheckName,
431 })
432 .pipe(
433 Effect.retry({
434 while: (error) => error._tag === "Conflict",
435 schedule: Schedule.spaced("2 seconds"),
436 times: 8,
437 }),
438 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
439 );
440 if (operation !== undefined) {
441 yield* waitGlobalOperation(env.project, operation).pipe(
442 Effect.catchTag("NotFound", () => Effect.void),
443 );
444 }
445 }),
446 });