1import * as diagnostics from "@distilled.cloud/cloudflare/diagnostics";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5
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 { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.Diagnostics.EndpointHealthcheck" as const;
15type TypeId = typeof TypeId;
16
17export interface EndpointHealthcheckProps {
18 /**
19 * The IP address of the host to perform checks against. Must be an
20 * on-net (private) IP reachable through Magic Transit / Magic WAN, and
21 * unique among the account's endpoint healthchecks — Cloudflare rejects
22 * public or duplicate IPs with an "Invalid request" error (code 1002,
23 * surfaced as the typed `InvalidHealthcheckEndpoint`).
24 */
25 endpoint: string;
26 /**
27 * Type of check to perform. Only `"icmp"` is supported today.
28 * @default "icmp"
29 */
30 checkType?: "icmp";
31 /**
32 * Optional name associated with this check. Not unique on Cloudflare's
33 * side. If omitted, a unique name is generated from the app, stage, and
34 * logical ID.
35 *
36 * Cannot be changed after creation — Cloudflare's PUT endpoint echoes a
37 * new name back but never persists it — so updating this property
38 * triggers a replacement.
39 * @default ${app}-${stage}-${id}
40 */
41 name?: string;
42}
43
44export interface EndpointHealthcheckAttributes {
45 /** UUID of the endpoint healthcheck. Stable across updates. */
46 healthcheckId: string;
47 /** The Cloudflare account the healthcheck belongs to. */
48 accountId: string;
49 /** Type of check performed. */
50 checkType: "icmp" | (string & {});
51 /** The IP address of the host checks are performed against. */
52 endpoint: string;
53 /** Name associated with this check. */
54 name: string;
55}
56
57export type EndpointHealthcheck = Resource<
58 TypeId,
59 EndpointHealthcheckProps,
60 EndpointHealthcheckAttributes,
61 never,
62 Providers
63>;
64
65/**
66 * A Magic Transit / Magic WAN endpoint healthcheck — a continuous ICMP
67 * probe of an on-net IP address used to monitor reachability of hosts
68 * behind Magic tunnels.
69 *
70 * The `endpoint` must be a private (on-net) IP; Cloudflare rejects public
71 * IPs with the typed `InvalidHealthcheckEndpoint` error. The `endpoint` is
72 * mutable in place via PUT (the UUID is stable across updates), but `name`
73 * is create-only — changing it triggers a replacement.
74 * ### Creating an endpoint healthcheck
75 * **Example:** Probe an on-net host
76 * ```typescript
77 * const check = yield* Cloudflare.Diagnostics.EndpointHealthcheck("core-router", {
78 * endpoint: "10.0.0.1",
79 * });
80 * ```
81 *
82 * **Example:** With an explicit name
83 * ```typescript
84 * const check = yield* Cloudflare.Diagnostics.EndpointHealthcheck("core-router", {
85 * endpoint: "10.0.0.1",
86 * name: "core-router-probe",
87 * });
88 * ```
89 *
90 * ### Updating
91 * **Example:** Re-point the probe at a different host
92 * ```typescript
93 * // Changing `endpoint` updates the same healthcheck in place.
94 * const check = yield* Cloudflare.Diagnostics.EndpointHealthcheck("core-router", {
95 * endpoint: "10.0.0.2",
96 * });
97 * ```
98 *
99 * @see https://developers.cloudflare.com/magic-wan/
100 *
101 * @resource
102 * @product Diagnostics
103 * @category Observability & Analytics
104 */
105export const EndpointHealthcheck = Resource<EndpointHealthcheck>(TypeId);
106
107/**
108 * Returns true if the given value is an EndpointHealthcheck resource.
109 */
110export const isEndpointHealthcheck = (
111 value: unknown,
112): value is EndpointHealthcheck =>
113 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
114
115export const EndpointHealthcheckProvider = () =>
116 Provider.succeed(EndpointHealthcheck, {
117 stables: ["healthcheckId", "accountId"],
118
119 diff: Effect.fn(function* ({ id, news, output }) {
120 const { accountId } = yield* yield* CloudflareEnvironment;
121 if (!isResolved(news)) return undefined;
122 // Healthchecks live under an account; moving accounts is a replace.
123 if (output !== undefined && output.accountId !== accountId) {
124 return { action: "replace" } as const;
125 }
126 // The name is create-only: Cloudflare's PUT echoes a new name back
127 // but never persists it, so a name change forces a replacement.
128 // Auto-generated names are engine-owned: the deployed name stays
129 // authoritative even if the generator would name this id differently
130 // today. Only an explicit user-provided name can force a replace.
131 const desiredName =
132 news.name ??
133 output?.name ??
134 (yield* createHealthcheckName(id, news.name));
135 if (output !== undefined && output.name !== desiredName) {
136 return { action: "replace" } as const;
137 }
138 return undefined;
139 }),
140
141 read: Effect.fn(function* ({ id, output, olds }) {
142 const { accountId } = yield* yield* CloudflareEnvironment;
143 const acct = output?.accountId ?? accountId;
144
145 if (output?.healthcheckId) {
146 const observed = yield* getHealthcheck(acct, output.healthcheckId);
147 if (observed) return toAttributes(observed, acct);
148 return undefined;
149 }
150 // Cold read — recover from lost state by matching the deterministic
151 // physical name. Healthchecks carry no ownership markers, so report
152 // the match as Unowned and let the adopt policy gate takeover.
153 const name = yield* createHealthcheckName(id, olds?.name);
154 const match = yield* findByName(acct, name);
155 if (match) return Unowned(toAttributes(match, acct));
156 return undefined;
157 }),
158
159 reconcile: Effect.fn(function* ({ id, news, output }) {
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 const name = yield* createHealthcheckName(id, news.name);
162 const desired = {
163 checkType: news.checkType ?? ("icmp" as const),
164 endpoint: news.endpoint,
165 name,
166 };
167
168 // Observe — the id cached on `output` is a hint, not a guarantee:
169 // a "No entry found" falls through to missing and we recreate.
170 // A GET right after a prior create can transiently 404 while the
171 // new healthcheck propagates; bounded-retry on the typed
172 // `EndpointHealthcheckNotFound` before concluding it is gone, so a
173 // propagation blip never leaks a duplicate (names are not unique).
174 const observed = output?.healthcheckId
175 ? yield* observeExisting(
176 output.accountId ?? accountId,
177 output.healthcheckId,
178 )
179 : undefined;
180
181 // Ensure — greenfield (or out-of-band delete): create with the full
182 // desired body. Names are not unique so there is no AlreadyExists
183 // race to tolerate.
184 if (!observed) {
185 const created = yield* diagnostics.createEndpointHealthcheck({
186 accountId,
187 ...desired,
188 });
189 return toAttributes(created, accountId);
190 }
191
192 // Sync — diff observed cloud state against desired; the update API
193 // is a PUT carrying the full body, so send everything, but skip the
194 // call entirely on a no-op. The name is create-only (a changed name
195 // is routed to replacement by `diff`; the PUT echoes a new name back
196 // without persisting it), so it never participates in the dirty
197 // check and the observed name is kept as the source of truth.
198 const dirty =
199 observed.endpoint !== desired.endpoint ||
200 observed.checkType !== desired.checkType;
201 if (!dirty) {
202 return toAttributes(observed, observed.accountId);
203 }
204
205 const updated = yield* diagnostics.updateEndpointHealthcheck({
206 accountId: observed.accountId,
207 id: observed.id ?? output!.healthcheckId,
208 ...desired,
209 name: observed.name ?? undefined,
210 });
211 // The PUT response echoes the request body; only `endpoint` is
212 // actually mutable, so overlay the persisted (observed) name.
213 return toAttributes(
214 { ...updated, name: observed.name },
215 observed.accountId,
216 );
217 }),
218
219 list: Effect.fn(function* () {
220 const { accountId } = yield* yield* CloudflareEnvironment;
221 // Account-scoped, non-paginated list (the whole collection comes
222 // back in one response array). Skip accounts that lack the Magic
223 // Transit / WAN entitlement (typed `Forbidden`) with an empty list.
224 return yield* diagnostics.listEndpointHealthchecks({ accountId }).pipe(
225 Effect.map((checks) => checks.map((hc) => toAttributes(hc, accountId))),
226 Effect.catchTag("Forbidden", () => Effect.succeed([])),
227 );
228 }),
229
230 delete: Effect.fn(function* ({ output }) {
231 yield* diagnostics
232 .deleteEndpointHealthcheck({
233 accountId: output.accountId,
234 id: output.healthcheckId,
235 })
236 .pipe(
237 Effect.catchTag("EndpointHealthcheckNotFound", () => Effect.void),
238 );
239 }),
240 });
241
242type ObservedHealthcheck = diagnostics.GetEndpointHealthcheckResponse & {
243 accountId: string;
244};
245
246/**
247 * Read a healthcheck by id, mapping "gone" (`EndpointHealthcheckNotFound`,
248 * Cloudflare error code 1022) to `undefined`.
249 */
250const getHealthcheck = (accountId: string, id: string) =>
251 diagnostics.getEndpointHealthcheck({ accountId, id }).pipe(
252 Effect.map((hc): ObservedHealthcheck => ({ ...hc, accountId })),
253 Effect.catchTag("EndpointHealthcheckNotFound", () =>
254 Effect.succeed(undefined),
255 ),
256 );
257
258/**
259 * Observe a healthcheck we believe exists (its id is cached on `output`).
260 * A GET issued right after a create can transiently 404 while the new
261 * state propagates, so bounded-retry on the typed
262 * `EndpointHealthcheckNotFound` before treating the resource as gone —
263 * this prevents a propagation blip from triggering a duplicate create.
264 */
265const observeExisting = (accountId: string, id: string) =>
266 diagnostics.getEndpointHealthcheck({ accountId, id }).pipe(
267 Effect.map((hc): ObservedHealthcheck => ({ ...hc, accountId })),
268 Effect.retry({
269 while: (e) => e._tag === "EndpointHealthcheckNotFound",
270 schedule: Schedule.max([
271 Schedule.exponential("500 millis"),
272 Schedule.recurs(6),
273 ]),
274 }),
275 Effect.catchTag("EndpointHealthcheckNotFound", () =>
276 Effect.succeed(undefined),
277 ),
278 );
279
280/**
281 * Find a healthcheck by exact name. Names are not enforced unique on
282 * Cloudflare's side; pick deterministically by id.
283 */
284const findByName = (accountId: string, name: string) =>
285 diagnostics.listEndpointHealthchecks({ accountId }).pipe(
286 Effect.map((list) =>
287 list
288 .filter((hc) => hc.name === name && typeof hc.id === "string")
289 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
290 .at(0),
291 ),
292 Effect.map(
293 (hc): ObservedHealthcheck | undefined => hc && { ...hc, accountId },
294 ),
295 );
296
297const createHealthcheckName = (id: string, name: string | undefined) =>
298 Effect.gen(function* () {
299 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
300 });
301
302const toAttributes = (
303 hc:
304 | ObservedHealthcheck
305 | diagnostics.CreateEndpointHealthcheckResponse
306 | diagnostics.UpdateEndpointHealthcheckResponse
307 | diagnostics.ListEndpointHealthchecksResponse[number],
308 accountId: string,
309): EndpointHealthcheckAttributes => ({
310 healthcheckId: hc.id ?? "",
311 accountId,
312 checkType: hc.checkType,
313 endpoint: hc.endpoint,
314 name: hc.name ?? "",
315});