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 = 443;
25const DEFAULT_REQUEST_PATH = "/";
26
27export type HttpsHealthCheckProps = {
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 httpsHealthCheckName?: string;
34 /**
35 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
36 * prefix so `list` / nuke can find resources (legacy HttpsHealthCheck 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 HTTPS probe.
62 * @default 443
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 HttpsHealthCheckResource = Resource<
77 "GCP.Compute.HttpsHealthCheck",
78 HttpsHealthCheckProps,
79 {
80 /** Health check name. */
81 httpsHealthCheckName: 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 httpsHealthCheckId: 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 HTTPS health check.
115 *
116 * Legacy HTTPS health checks are deprecated for most load balancers —
117 * prefer `HealthCheck` with `httpsHealthCheck`. Compute HttpsHealthCheck
118 * has no labels field — Alchemy ownership is stored in the description so
119 * nuke can find leaked checks.
120 *
121 * ### Creating an HTTPS Health Check
122 * **Example:** Generated name
123 * ```typescript
124 * const check = yield* GCP.Compute.HttpsHealthCheck("api", {});
125 * ```
126 *
127 * **Example:** Path, port, and interval
128 * ```typescript
129 * const check = yield* GCP.Compute.HttpsHealthCheck("api", {
130 * description: "frontend /health",
131 * port: 443,
132 * requestPath: "/health",
133 * checkIntervalSec: 10,
134 * timeoutSec: 5,
135 * });
136 * ```
137 *
138 * @resource
139 * @category Compute
140 */
141export const HttpsHealthCheck = Resource<HttpsHealthCheckResource>(
142 "GCP.Compute.HttpsHealthCheck",
143);
144
145export class HttpsHealthCheckNotResolved extends Data.TaggedError(
146 "GCP.Compute.HttpsHealthCheckNotResolved",
147)<{
148 httpsHealthCheckName: string;
149}> {}
150
151const rfc1035 = (name: string): string => {
152 let next = name
153 .toLowerCase()
154 .replace(/[^a-z0-9-]/g, "-")
155 .replace(/-+/g, "-")
156 .replace(/^-+/, "")
157 .replace(/-+$/, "");
158 if (!/^[a-z]/.test(next)) {
159 next = `h${next}`;
160 }
161 next = next.slice(0, 63).replace(/-+$/, "");
162 return next.length > 0 ? next : "httpshealthcheck";
163};
164
165const toName = (id: string, name: string | undefined, existing?: string) =>
166 Effect.gen(function* () {
167 if (name !== undefined) return name;
168 if (existing !== undefined) return existing;
169 return rfc1035(
170 yield* createPhysicalName({
171 id,
172 maxLength: 63,
173 lowercase: true,
174 }),
175 );
176 });
177
178const encodeDescription = (
179 labels: Record<string, string>,
180 description: string | undefined,
181): string => {
182 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
183 return description ? `${marker}\n${description}` : marker;
184};
185
186const parseDescription = (
187 description: string | undefined,
188): {
189 labels: Record<string, string>;
190 description: string | undefined;
191} => {
192 if (!description?.startsWith("[alchemy ")) {
193 return { labels: {}, description };
194 }
195 const end = description.indexOf("]");
196 if (end < 0) return { labels: {}, description };
197 const labels: Record<string, string> = {};
198 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
199 const eq = part.indexOf("=");
200 if (eq > 0) {
201 labels[part.slice(0, eq)] = part.slice(eq + 1);
202 }
203 }
204 const rest = description.slice(end + 1).replace(/^\n/, "");
205 return { labels, description: rest.length > 0 ? rest : undefined };
206};
207
208const toBody = (
209 httpsHealthCheckName: string,
210 props: HttpsHealthCheckProps,
211 ownership: Record<string, string>,
212): compute.HttpsHealthCheck => ({
213 name: httpsHealthCheckName,
214 description: encodeDescription(ownership, props.description),
215 checkIntervalSec: props.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL,
216 timeoutSec: props.timeoutSec ?? DEFAULT_TIMEOUT,
217 healthyThreshold: props.healthyThreshold ?? DEFAULT_HEALTHY,
218 unhealthyThreshold: props.unhealthyThreshold ?? DEFAULT_UNHEALTHY,
219 port: props.port ?? DEFAULT_PORT,
220 requestPath: props.requestPath ?? DEFAULT_REQUEST_PATH,
221 host: props.host,
222});
223
224const toAttrs = (
225 check: compute.HttpsHealthCheck,
226 project: string,
227): HttpsHealthCheckResource["Attributes"] => {
228 const parsed = parseDescription(check.description);
229 const host = check.host && check.host.length > 0 ? check.host : undefined;
230 return {
231 httpsHealthCheckName: check.name ?? check.id ?? "",
232 project,
233 description: parsed.description,
234 checkIntervalSec: check.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL,
235 timeoutSec: check.timeoutSec ?? DEFAULT_TIMEOUT,
236 healthyThreshold: check.healthyThreshold ?? DEFAULT_HEALTHY,
237 unhealthyThreshold: check.unhealthyThreshold ?? DEFAULT_UNHEALTHY,
238 port: check.port ?? DEFAULT_PORT,
239 requestPath: check.requestPath ?? DEFAULT_REQUEST_PATH,
240 host,
241 selfLink: check.selfLink,
242 httpsHealthCheckId: check.id,
243 creationTimestamp: check.creationTimestamp,
244 kind: check.kind,
245 };
246};
247
248const needsUpdate = (
249 current: compute.HttpsHealthCheck,
250 desired: compute.HttpsHealthCheck,
251) => {
252 if ((current.description ?? "") !== (desired.description ?? "")) return true;
253 if (
254 (current.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL) !==
255 (desired.checkIntervalSec ?? DEFAULT_CHECK_INTERVAL)
256 ) {
257 return true;
258 }
259 if (
260 (current.timeoutSec ?? DEFAULT_TIMEOUT) !==
261 (desired.timeoutSec ?? DEFAULT_TIMEOUT)
262 ) {
263 return true;
264 }
265 if (
266 (current.healthyThreshold ?? DEFAULT_HEALTHY) !==
267 (desired.healthyThreshold ?? DEFAULT_HEALTHY)
268 ) {
269 return true;
270 }
271 if (
272 (current.unhealthyThreshold ?? DEFAULT_UNHEALTHY) !==
273 (desired.unhealthyThreshold ?? DEFAULT_UNHEALTHY)
274 ) {
275 return true;
276 }
277 if ((current.port ?? DEFAULT_PORT) !== (desired.port ?? DEFAULT_PORT)) {
278 return true;
279 }
280 if (
281 (current.requestPath ?? DEFAULT_REQUEST_PATH) !==
282 (desired.requestPath ?? DEFAULT_REQUEST_PATH)
283 ) {
284 return true;
285 }
286 return (current.host ?? "") !== (desired.host ?? "");
287};
288
289const getByName = (project: string, httpsHealthCheck: string) =>
290 compute
291 .getHttpsHealthChecks({ project, httpsHealthCheck })
292 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
293
294const awaitResource = (project: string, httpsHealthCheckName: string) =>
295 getByName(project, httpsHealthCheckName).pipe(
296 Effect.repeat({
297 schedule: Schedule.spaced("1 second"),
298 until: (check) => check !== undefined,
299 times: 8,
300 }),
301 );
302
303export const HttpsHealthCheckProvider = () =>
304 Provider.succeed(HttpsHealthCheck, {
305 stables: [
306 "httpsHealthCheckName",
307 "project",
308 "httpsHealthCheckId",
309 "selfLink",
310 "creationTimestamp",
311 ],
312
313 diff: Effect.fn(function* ({ news, olds, output }) {
314 if (!isResolved(news)) return undefined;
315 const previousName =
316 olds?.httpsHealthCheckName ?? output?.httpsHealthCheckName;
317 const nextName = news.httpsHealthCheckName;
318 if (
319 previousName !== undefined &&
320 nextName !== undefined &&
321 previousName !== nextName
322 ) {
323 return { action: "replace" as const };
324 }
325 return undefined;
326 }),
327
328 read: Effect.fn(function* ({ id, olds, output }) {
329 const env = yield* GcpEnvironment.current;
330 const httpsHealthCheckName = yield* toName(
331 id,
332 olds?.httpsHealthCheckName,
333 output?.httpsHealthCheckName,
334 );
335 const existing = yield* getByName(env.project, httpsHealthCheckName);
336 if (existing === undefined) return undefined;
337 const attrs = toAttrs(existing, env.project);
338 const { labels } = parseDescription(existing.description);
339 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
340 }),
341
342 list: () =>
343 Effect.gen(function* () {
344 const env = yield* GcpEnvironment.current;
345 return yield* compute.listHttpsHealthChecks
346 .items({ project: env.project, maxResults: 500 })
347 .pipe(
348 Stream.filter((check) => {
349 const { labels } = parseDescription(check.description);
350 return Object.keys(labels).some((key) =>
351 key.startsWith("alchemy-"),
352 );
353 }),
354 Stream.map((check) => toAttrs(check, env.project)),
355 Stream.runCollect,
356 Effect.map((chunk) => Array.from(chunk)),
357 );
358 }),
359
360 reconcile: Effect.fn(function* ({ id, news, output }) {
361 const env = yield* GcpEnvironment.current;
362 const httpsHealthCheckName = yield* toName(
363 id,
364 news.httpsHealthCheckName,
365 output?.httpsHealthCheckName,
366 );
367 const ownership = yield* createInternalLabels(id);
368 const desired = toBody(httpsHealthCheckName, news, ownership);
369
370 let current = yield* getByName(env.project, httpsHealthCheckName);
371
372 if (current === undefined) {
373 yield* compute
374 .insertHttpsHealthChecks({
375 project: env.project,
376 body: desired,
377 })
378 .pipe(
379 Effect.flatMap((operation) =>
380 waitGlobalOperation(env.project, operation),
381 ),
382 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
383 );
384 current = yield* awaitResource(env.project, httpsHealthCheckName);
385 }
386
387 if (current === undefined) {
388 return yield* new HttpsHealthCheckNotResolved({
389 httpsHealthCheckName,
390 });
391 }
392
393 if (needsUpdate(current, desired)) {
394 yield* compute
395 .patchHttpsHealthChecks({
396 project: env.project,
397 httpsHealthCheck: httpsHealthCheckName,
398 body: desired,
399 })
400 .pipe(
401 Effect.flatMap((operation) =>
402 waitGlobalOperation(env.project, operation),
403 ),
404 );
405 current = yield* getByName(env.project, httpsHealthCheckName);
406 if (current === undefined) {
407 return yield* new HttpsHealthCheckNotResolved({
408 httpsHealthCheckName,
409 });
410 }
411 }
412
413 return toAttrs(current, env.project);
414 }),
415
416 delete: Effect.fn(function* ({ output }) {
417 const env = yield* GcpEnvironment.current;
418 const operation = yield* compute
419 .deleteHttpsHealthChecks({
420 project: env.project,
421 httpsHealthCheck: output.httpsHealthCheckName,
422 })
423 .pipe(
424 Effect.retry({
425 while: (error) => error._tag === "Conflict",
426 schedule: Schedule.spaced("2 seconds"),
427 times: 8,
428 }),
429 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
430 );
431 if (operation !== undefined) {
432 yield* waitGlobalOperation(env.project, operation).pipe(
433 Effect.catchTag("NotFound", () => Effect.void),
434 );
435 }
436 }),
437 });