1import * as route53 from "@distilled.cloud/aws/route-53";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags } from "../../Tags.ts";
10import { durationToSeconds } from "../IAM/common.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface HealthCheckProps {
14 /**
15 * Health check protocol/type (e.g. `"HTTP"`, `"HTTPS"`, `"TCP"`,
16 * `"CALCULATED"`, `"CLOUDWATCH_METRIC"`). Immutable — changing it forces
17 * replacement.
18 */
19 type: route53.HealthCheckType;
20 /**
21 * IP address of the endpoint to check.
22 */
23 ipAddress?: string;
24 /**
25 * Port of the endpoint to check.
26 */
27 port?: number;
28 /**
29 * Path requested for HTTP/HTTPS checks (e.g. `"/health"`).
30 */
31 resourcePath?: string;
32 /**
33 * Fully qualified domain name of the endpoint.
34 */
35 fullyQualifiedDomainName?: string;
36 /**
37 * String the response body must contain for the check to pass.
38 */
39 searchString?: string;
40 /**
41 * Time between checks, e.g. `"30 seconds"` or `Duration.seconds(10)` (a
42 * bare number is milliseconds). Rounded to whole seconds on the wire
43 * (10 or 30). Immutable — changing it forces replacement.
44 * @default 30 seconds
45 */
46 requestInterval?: Duration.Input;
47 /**
48 * Number of consecutive failures before the endpoint is considered unhealthy.
49 * @default 3
50 */
51 failureThreshold?: number;
52 /**
53 * Whether Route 53 measures latency. Immutable — changing it forces
54 * replacement.
55 */
56 measureLatency?: boolean;
57 /**
58 * Invert the health check result.
59 */
60 inverted?: boolean;
61 /**
62 * Disable the health check (treated as healthy).
63 */
64 disabled?: boolean;
65 /**
66 * For CALCULATED checks, the number of child checks that must be healthy.
67 */
68 healthThreshold?: number;
69 /**
70 * For CALCULATED checks, the child health check IDs.
71 */
72 childHealthChecks?: string[];
73 /**
74 * Send SNI to the endpoint for HTTPS checks.
75 */
76 enableSNI?: boolean;
77 /**
78 * Regions from which Route 53 checks the endpoint.
79 */
80 regions?: route53.HealthCheckRegion[];
81 /**
82 * Tags applied to the health check.
83 */
84 tags?: Record<string, string>;
85}
86
87export interface HealthCheck extends Resource<
88 "AWS.Route53.HealthCheck",
89 HealthCheckProps,
90 {
91 /**
92 * Health check ID.
93 */
94 id: string;
95 /**
96 * Alias of `id`.
97 */
98 healthCheckId: string;
99 /**
100 * Health check type.
101 */
102 type: route53.HealthCheckType;
103 },
104 never,
105 Providers
106> {}
107
108/**
109 * A Route 53 health check.
110 *
111 * `HealthCheck` monitors the health of an endpoint and can gate failover and
112 * other routing policies on a `Record` via `record.healthCheckId`.
113 * ### Creating a Health Check
114 * **Example:** HTTP Health Check
115 * ```typescript
116 * const check = yield* HealthCheck("ApiHealth", {
117 * type: "HTTP",
118 * fullyQualifiedDomainName: "api.example.com",
119 * resourcePath: "/health",
120 * port: 80,
121 * requestInterval: "30 seconds",
122 * failureThreshold: 3,
123 * });
124 * ```
125 *
126 * ### Gating DNS Failover
127 * **Example:** Fail Over a Record When the Check Fails
128 * ```typescript
129 * const check = yield* HealthCheck("PrimaryHealth", {
130 * type: "HTTPS",
131 * fullyQualifiedDomainName: "primary.example.com",
132 * resourcePath: "/health",
133 * port: 443,
134 * });
135 * // Route 53 answers with the PRIMARY record only while the check passes;
136 * // see `Record` for the matching SECONDARY record.
137 * const primary = yield* Record("Primary", {
138 * hostedZoneId: zone.id,
139 * name: "app.example.com",
140 * type: "A",
141 * ttl: "60 seconds",
142 * records: ["1.2.3.4"],
143 * setIdentifier: "primary",
144 * failover: "PRIMARY",
145 * healthCheckId: check.id,
146 * });
147 * ```
148 *
149 * @resource
150 */
151export const HealthCheck = Resource<HealthCheck>("AWS.Route53.HealthCheck");
152
153/**
154 * Whether a Route 53 health check can be managed directly through Route 53.
155 *
156 * Health checks with `LinkedService` are owned by another AWS service (for
157 * example, Cloud Map). Route 53 refuses direct deletion of those checks, and
158 * the owning service may retain them as tombstones after its resource is
159 * deleted. They therefore must not be returned to account-wide nuke inventory.
160 */
161export const isNukeableHealthCheck = (check: {
162 LinkedService?: route53.LinkedService;
163}): boolean => check.LinkedService === undefined;
164
165const toConfig = (props: HealthCheckProps): route53.HealthCheckConfig => ({
166 Type: props.type,
167 IPAddress: props.ipAddress,
168 Port: props.port,
169 ResourcePath: props.resourcePath,
170 FullyQualifiedDomainName: props.fullyQualifiedDomainName,
171 SearchString: props.searchString,
172 RequestInterval: durationToSeconds(props.requestInterval),
173 FailureThreshold: props.failureThreshold,
174 MeasureLatency: props.measureLatency,
175 Inverted: props.inverted,
176 Disabled: props.disabled,
177 HealthThreshold: props.healthThreshold,
178 ChildHealthChecks: props.childHealthChecks,
179 EnableSNI: props.enableSNI,
180 Regions: props.regions,
181});
182
183// Fields settable via UpdateHealthCheck (i.e. everything except the immutable
184// Type / RequestInterval / MeasureLatency).
185const mutableFields = (props: HealthCheckProps) => ({
186 IPAddress: props.ipAddress,
187 Port: props.port,
188 ResourcePath: props.resourcePath,
189 FullyQualifiedDomainName: props.fullyQualifiedDomainName,
190 SearchString: props.searchString,
191 FailureThreshold: props.failureThreshold,
192 Inverted: props.inverted,
193 Disabled: props.disabled,
194 HealthThreshold: props.healthThreshold,
195 ChildHealthChecks: props.childHealthChecks,
196 EnableSNI: props.enableSNI,
197 Regions: props.regions,
198});
199
200const mutableDiffers = (
201 observed: route53.HealthCheckConfig,
202 desired: HealthCheckProps,
203): boolean => {
204 const d = mutableFields(desired);
205 return (
206 observed.IPAddress !== d.IPAddress ||
207 observed.Port !== d.Port ||
208 observed.ResourcePath !== d.ResourcePath ||
209 observed.FullyQualifiedDomainName !== d.FullyQualifiedDomainName ||
210 observed.SearchString !== d.SearchString ||
211 (observed.FailureThreshold ?? undefined) !== d.FailureThreshold ||
212 (observed.Inverted ?? undefined) !== d.Inverted ||
213 (observed.Disabled ?? undefined) !== d.Disabled ||
214 (observed.HealthThreshold ?? undefined) !== d.HealthThreshold ||
215 (observed.EnableSNI ?? undefined) !== d.EnableSNI
216 );
217};
218
219export const HealthCheckProvider = () =>
220 Provider.effect(
221 HealthCheck,
222 Effect.gen(function* () {
223 const observe = Effect.fn(function* (id: string) {
224 return yield* route53.getHealthCheck({ HealthCheckId: id }).pipe(
225 Effect.map((r) => r.HealthCheck),
226 Effect.catchTag("NoSuchHealthCheck", () => Effect.succeed(undefined)),
227 );
228 });
229
230 const observedTags = Effect.fn(function* (id: string) {
231 const response = yield* route53.listTagsForResource({
232 ResourceType: "healthcheck",
233 ResourceId: id,
234 });
235 const record: Record<string, string> = {};
236 for (const tag of response.ResourceTagSet.Tags ?? []) {
237 if (tag.Key !== undefined && tag.Value !== undefined) {
238 record[tag.Key] = tag.Value;
239 }
240 }
241 return record;
242 });
243
244 const syncTags = Effect.fn(function* (
245 id: string,
246 logicalId: string,
247 userTags: Record<string, string> | undefined,
248 ) {
249 const internalTags = yield* createInternalTags(logicalId);
250 const newTags = { ...userTags, ...internalTags };
251 const oldTags = yield* observedTags(id);
252 const { upsert, removed } = diffTags(oldTags, newTags);
253 if (upsert.length === 0 && removed.length === 0) {
254 return;
255 }
256 yield* route53.changeTagsForResource({
257 ResourceType: "healthcheck",
258 ResourceId: id,
259 AddTags: upsert.length > 0 ? upsert : undefined,
260 RemoveTagKeys: removed.length > 0 ? removed : undefined,
261 });
262 });
263
264 return {
265 stables: ["id", "healthCheckId"],
266 list: () =>
267 route53.listHealthChecks.pages({}).pipe(
268 Stream.runCollect,
269 Effect.map((chunk) =>
270 Array.from(chunk).flatMap((page) =>
271 (page.HealthChecks ?? [])
272 .filter(isNukeableHealthCheck)
273 .map((check) => ({
274 id: check.Id,
275 healthCheckId: check.Id,
276 type: check.HealthCheckConfig.Type,
277 })),
278 ),
279 ),
280 ),
281 diff: Effect.fn(function* ({ olds, news }) {
282 if (!isResolved(news)) return undefined;
283 if (
284 olds.type !== news.type ||
285 (durationToSeconds(olds.requestInterval) ?? 30) !==
286 (durationToSeconds(news.requestInterval) ?? 30) ||
287 (olds.measureLatency ?? false) !== (news.measureLatency ?? false)
288 ) {
289 return { action: "replace" } as const;
290 }
291 }),
292 read: Effect.fn(function* ({ output }) {
293 if (!output?.id) {
294 return undefined;
295 }
296 const check = yield* observe(output.id);
297 if (!check) {
298 return undefined;
299 }
300 return {
301 id: check.Id,
302 healthCheckId: check.Id,
303 type: check.HealthCheckConfig.Type,
304 };
305 }),
306 reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
307 // Observe.
308 let check = output?.id ? yield* observe(output.id) : undefined;
309
310 // Ensure — CallerReference makes create idempotent.
311 if (!check) {
312 check = yield* route53
313 .createHealthCheck({
314 CallerReference: instanceId,
315 HealthCheckConfig: toConfig(news),
316 })
317 .pipe(
318 Effect.map((r) => r.HealthCheck),
319 Effect.catchTag("HealthCheckAlreadyExists", () =>
320 // Same CallerReference already created it; the engine stores
321 // output, so on a true re-run output.id observes above. A bare
322 // race here means we must re-read — but the API gives us no id,
323 // so fall back to the stored output if present.
324 output?.id
325 ? observe(output.id).pipe(
326 Effect.flatMap((existing) =>
327 existing
328 ? Effect.succeed(existing)
329 : Effect.die(
330 new Error(
331 "health check exists but could not be observed",
332 ),
333 ),
334 ),
335 )
336 : Effect.die(
337 new Error(
338 "health check already exists for caller reference",
339 ),
340 ),
341 ),
342 );
343 }
344
345 // Sync config — diff observed mutable fields against desired.
346 if (mutableDiffers(check.HealthCheckConfig, news)) {
347 const updated = yield* route53
348 .updateHealthCheck({
349 HealthCheckId: check.Id,
350 HealthCheckVersion: check.HealthCheckVersion,
351 ...mutableFields(news),
352 })
353 .pipe(
354 Effect.map((r) => r.HealthCheck),
355 // Optimistic-lock retry: re-read for the latest version.
356 Effect.retry({
357 while: (e) => e._tag === "HealthCheckVersionMismatch",
358 schedule: Schedule.max([
359 Schedule.fixed("1 second"),
360 Schedule.recurs(5),
361 ]),
362 }),
363 );
364 check = updated;
365 }
366
367 // Sync tags.
368 yield* syncTags(check.Id, id, news.tags);
369
370 return {
371 id: check.Id,
372 healthCheckId: check.Id,
373 type: check.HealthCheckConfig.Type,
374 };
375 }),
376 delete: Effect.fn(function* ({ output }) {
377 yield* route53.deleteHealthCheck({ HealthCheckId: output.id }).pipe(
378 Effect.asVoid,
379 Effect.catchTag("NoSuchHealthCheck", () => Effect.void),
380 // Still referenced by a record whose delete is propagating.
381 Effect.retry({
382 while: (e) => e._tag === "HealthCheckInUse",
383 schedule: Schedule.max([
384 Schedule.fixed("3 seconds"),
385 Schedule.recurs(10),
386 ]),
387 }),
388 Effect.catchTag("HealthCheckInUse", () => Effect.void),
389 );
390 }),
391 };
392 }),
393 );