1import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.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.LoadBalancer.Pool" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * A single origin server within a Load Balancing pool.
19 */
20export interface PoolOrigin {
21 /**
22 * The IP address (IPv4 or IPv6) or publicly-resolvable hostname of the
23 * origin server.
24 */
25 address: string;
26 /**
27 * A human-identifiable name for the origin.
28 */
29 name?: string;
30 /**
31 * Whether to enable (the default) this origin within the pool.
32 * @default true
33 */
34 enabled?: boolean;
35 /**
36 * Relative weight of this origin (`0`–`1`) for traffic distribution.
37 * @default 1
38 */
39 weight?: number;
40 /**
41 * The port to override the standard port for this origin.
42 */
43 port?: number;
44 /**
45 * Request headers to send to this origin — typically a `Host` header.
46 */
47 header?: { host?: ReadonlyArray<string> };
48 /**
49 * Whether to flatten a CNAME `address` to its final IP.
50 */
51 flattenCname?: boolean;
52 /**
53 * The virtual network subnet the origin belongs to (private origins).
54 */
55 virtualNetworkId?: string;
56}
57
58/**
59 * Load shedding configuration for a pool.
60 */
61export interface PoolLoadShedding {
62 /** Percent (0–100) of new (non-affine) traffic to shed. @default 0 */
63 defaultPercent?: number;
64 /** Policy for shedding new traffic. @default "random" */
65 defaultPolicy?: "random" | "hash";
66 /** Percent (0–100) of session-affine traffic to shed. @default 0 */
67 sessionPercent?: number;
68 /** Policy for shedding session-affine traffic. */
69 sessionPolicy?: "hash";
70}
71
72/**
73 * Filters pool/origin health notifications by resource type or health
74 * status.
75 */
76export interface PoolNotificationFilter {
77 origin?: { disable?: boolean; healthy?: boolean };
78 pool?: { disable?: boolean; healthy?: boolean };
79}
80
81export interface PoolProps {
82 /**
83 * A short name (tag) for the pool. Only alphanumeric characters, hyphens,
84 * and underscores are allowed. If omitted, a unique name is generated
85 * from the app, stage, and logical ID.
86 * @default ${app}-${stage}-${id}
87 */
88 name?: string;
89 /**
90 * The list of origins within this pool. Traffic directed at this pool is
91 * balanced across all currently healthy origins.
92 */
93 origins: ReadonlyArray<PoolOrigin>;
94 /**
95 * A human-readable description of the pool.
96 */
97 description?: string;
98 /**
99 * Whether to enable (the default) or disable this pool. Disabled pools
100 * receive no traffic and are excluded from health checks.
101 * @default true
102 */
103 enabled?: boolean;
104 /**
105 * The ID of the Monitor to use for checking the health of origins within
106 * this pool. Mutually exclusive with `monitorGroup`.
107 */
108 monitor?: string;
109 /**
110 * The ID of the Monitor Group to use for checking the health of origins
111 * within this pool. Mutually exclusive with `monitor`.
112 */
113 monitorGroup?: string;
114 /**
115 * The minimum number of origins that must be healthy for this pool to
116 * serve traffic.
117 * @default 1
118 */
119 minimumOrigins?: number;
120 /**
121 * The latitude of the data center containing this pool's origins, in
122 * decimal degrees. Must be set together with `longitude`.
123 */
124 latitude?: number;
125 /**
126 * The longitude of the data center containing this pool's origins, in
127 * decimal degrees. Must be set together with `latitude`.
128 */
129 longitude?: number;
130 /**
131 * Load shedding policies and percentages for the pool.
132 */
133 loadShedding?: PoolLoadShedding;
134 /**
135 * How origins are selected for new sessions / traffic without session
136 * affinity.
137 * @default { policy: "random" }
138 */
139 originSteering?: {
140 policy?:
141 | "random"
142 | "hash"
143 | "least_outstanding_requests"
144 | "least_connections";
145 };
146 /**
147 * Filter pool and origin health notifications by resource type or health
148 * status.
149 */
150 notificationFilter?: PoolNotificationFilter;
151 /**
152 * Deprecated upstream — the email address to send health status
153 * notifications to. Prefer Cloudflare's centralized notification service.
154 */
155 notificationEmail?: string;
156}
157
158export interface PoolAttributes {
159 /** Cloudflare-assigned pool identifier. */
160 poolId: string;
161 /** The Cloudflare account the pool belongs to. */
162 accountId: string;
163 /** Pool name. */
164 name: string;
165 /** Whether the pool is enabled. */
166 enabled: boolean;
167 /** Resolved monitor id attached to the pool, if any. */
168 monitor: string | undefined;
169 /** ISO8601 creation timestamp. */
170 createdOn: string | undefined;
171 /** ISO8601 last-modified timestamp. */
172 modifiedOn: string | undefined;
173}
174
175export type Pool = Resource<
176 TypeId,
177 PoolProps,
178 PoolAttributes,
179 never,
180 Providers
181>;
182
183/**
184 * A Cloudflare Load Balancing pool — an account-scoped group of origin
185 * servers that Load Balancers route traffic to. Pools optionally reference
186 * a {@link Monitor} for active health checking.
187 *
188 * Requires the Load Balancing subscription on the account; without it, pool
189 * creation fails with the typed `PoolAccessFailed` error.
190 * ### Creating a Pool
191 * **Example:** Pool with one origin
192 * ```typescript
193 * const pool = yield* Cloudflare.LoadBalancer.Pool("ApiPool", {
194 * origins: [{ name: "origin-1", address: "203.0.113.10" }],
195 * });
196 * ```
197 *
198 * **Example:** Health-checked pool
199 * ```typescript
200 * const monitor = yield* Cloudflare.LoadBalancer.Monitor("ApiMonitor", {
201 * type: "https",
202 * path: "/health",
203 * expectedCodes: "2xx",
204 * });
205 *
206 * const pool = yield* Cloudflare.LoadBalancer.Pool("ApiPool", {
207 * origins: [
208 * { name: "origin-1", address: "203.0.113.10", weight: 0.7 },
209 * { name: "origin-2", address: "203.0.113.11", weight: 0.3 },
210 * ],
211 * monitor: monitor.monitorId,
212 * minimumOrigins: 1,
213 * });
214 * ```
215 *
216 * ### Using with a Load Balancer
217 * **Example:** Pool as default and fallback
218 * ```typescript
219 * yield* Cloudflare.LoadBalancer.LoadBalancer("ApiLb", {
220 * zoneId: zone.zoneId,
221 * name: "api.example.com",
222 * defaultPools: [pool.poolId],
223 * fallbackPool: pool.poolId,
224 * });
225 * ```
226 *
227 * @see https://developers.cloudflare.com/load-balancing/pools/
228 *
229 * @resource
230 * @product Load Balancers
231 * @category Performance & Reliability
232 */
233export const Pool = Resource<Pool>(TypeId);
234
235/**
236 * Returns true if the given value is a Pool resource.
237 */
238export const isPool = (value: unknown): value is Pool =>
239 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
240
241export const PoolProvider = () =>
242 Provider.succeed(Pool, {
243 stables: ["poolId", "accountId", "createdOn"],
244
245 diff: Effect.fn(function* ({ output }) {
246 const { accountId } = yield* yield* CloudflareEnvironment;
247 if ((output?.accountId ?? accountId) !== accountId) {
248 return { action: "replace" } as const;
249 }
250 return undefined;
251 }),
252
253 read: Effect.fn(function* ({ id, output, olds }) {
254 const { accountId } = yield* yield* CloudflareEnvironment;
255 const acct = output?.accountId ?? accountId;
256
257 if (output?.poolId) {
258 const observed = yield* getPool(acct, output.poolId);
259 return observed ? toAttributes(observed, acct) : undefined;
260 }
261 // Cold read — recover from lost state by matching the deterministic
262 // pool name. Pools carry no ownership marker, so the match is
263 // reported as Unowned and the engine gates takeover behind --adopt.
264 const name = yield* createPoolName(id, olds?.name);
265 const match = yield* findByName(acct, name);
266 if (match?.id) {
267 const observed = yield* getPool(acct, match.id);
268 if (observed) return Unowned(toAttributes(observed, acct));
269 }
270 return undefined;
271 }),
272
273 list: Effect.fn(function* () {
274 const { accountId } = yield* yield* CloudflareEnvironment;
275 return yield* loadBalancers.listPools.pages({ accountId }).pipe(
276 Stream.runCollect,
277 Effect.map((chunk) =>
278 Array.from(chunk).flatMap((page) =>
279 (page.result ?? []).map((pool): PoolAttributes => ({
280 poolId: pool.id ?? "",
281 accountId,
282 name: pool.name ?? "",
283 enabled: pool.enabled ?? true,
284 monitor: pool.monitor ?? undefined,
285 createdOn: pool.createdOn ?? undefined,
286 modifiedOn: pool.modifiedOn ?? undefined,
287 })),
288 ),
289 ),
290 );
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output }) {
294 const { accountId } = yield* yield* CloudflareEnvironment;
295 const name = yield* createPoolName(id, news.name);
296 const body = buildBody(news, name);
297
298 // 1. Observe — output.poolId is a cache hint; a 404 falls through
299 // to "missing" and we recreate.
300 const observed = output?.poolId
301 ? yield* getPool(output.accountId ?? accountId, output.poolId)
302 : undefined;
303
304 // 2. Ensure — missing: create with the full desired body.
305 if (!observed?.id) {
306 const created = yield* loadBalancers.createPool({
307 accountId,
308 ...body,
309 });
310 return toAttributes(created, accountId);
311 }
312
313 // 3. Sync — the update endpoint is a PUT requiring name + origins;
314 // diff observed against desired and skip the call on a no-op.
315 if (!poolDirty(observed, body)) {
316 return toAttributes(observed, accountId);
317 }
318 const updated = yield* loadBalancers.updatePool({
319 accountId,
320 poolId: observed.id,
321 ...body,
322 });
323 return toAttributes(updated, accountId);
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 // A pool still referenced by a Load Balancer cannot be deleted — the
328 // engine deletes the LB first, but eventual consistency may lag, so
329 // retry the typed PoolInUse tag with bounded backoff.
330 yield* loadBalancers
331 .deletePool({
332 accountId: output.accountId,
333 poolId: output.poolId,
334 })
335 .pipe(
336 Effect.retry({
337 while: (e) => e._tag === "PoolInUse",
338 schedule: Schedule.max([
339 Schedule.exponential("1 second"),
340 Schedule.recurs(6),
341 ]),
342 }),
343 Effect.catchTag("PoolNotFound", () => Effect.void),
344 );
345 }),
346 });
347
348type ObservedPool =
349 | loadBalancers.GetPoolResponse
350 | loadBalancers.CreatePoolResponse
351 | loadBalancers.UpdatePoolResponse;
352
353/**
354 * Read a pool by id, mapping "gone" (`PoolNotFound`, HTTP 404 code 1001)
355 * to `undefined`.
356 */
357const getPool = (accountId: string, poolId: string) =>
358 loadBalancers
359 .getPool({ accountId, poolId })
360 .pipe(Effect.catchTag("PoolNotFound", () => Effect.succeed(undefined)));
361
362/**
363 * Find a pool by exact name. If several pools carry the same name, pick
364 * the oldest for determinism.
365 */
366const findByName = (accountId: string, name: string) =>
367 loadBalancers.listPools.items({ accountId }).pipe(
368 Stream.filter((p) => p.name === name),
369 Stream.runCollect,
370 Effect.map((chunk) =>
371 Array.from(chunk)
372 .sort((a, b) => (a.createdOn ?? "").localeCompare(b.createdOn ?? ""))
373 .at(0),
374 ),
375 );
376
377const createPoolName = (id: string, name: string | undefined) =>
378 Effect.gen(function* () {
379 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
380 });
381
382const buildBody = (news: PoolProps, name: string) => ({
383 name,
384 origins: news.origins.map((o) => ({
385 address: o.address,
386 name: o.name,
387 enabled: o.enabled,
388 weight: o.weight,
389 port: o.port,
390 header:
391 o.header === undefined
392 ? undefined
393 : {
394 host:
395 o.header.host === undefined
396 ? undefined
397 : Array.from(o.header.host),
398 },
399 flattenCname: o.flattenCname,
400 // Inputs are resolved to concrete strings by Plan.
401 virtualNetworkId: o.virtualNetworkId as string | undefined,
402 })),
403 description: news.description,
404 enabled: news.enabled,
405 monitor: news.monitor as string | undefined,
406 monitorGroup: news.monitorGroup as string | undefined,
407 minimumOrigins: news.minimumOrigins,
408 latitude: news.latitude,
409 longitude: news.longitude,
410 loadShedding: news.loadShedding,
411 originSteering: news.originSteering,
412 notificationFilter: news.notificationFilter,
413 notificationEmail: news.notificationEmail,
414});
415
416/**
417 * Compare desired (explicitly set) fields against observed cloud state.
418 * Unset desired fields defer to whatever the cloud already has.
419 */
420const poolDirty = (
421 observed: ObservedPool,
422 body: ReturnType<typeof buildBody>,
423): boolean => {
424 const scalarDirty = (
425 desired: string | number | boolean | undefined,
426 actual: string | number | boolean | null | undefined,
427 ) => desired !== undefined && desired !== (actual ?? undefined);
428
429 const desiredOrigins = body.origins.map(normalizeOrigin);
430 const observedOrigins = (observed.origins ?? []).map((o) =>
431 normalizeOrigin({
432 address: o.address ?? "",
433 name: o.name ?? undefined,
434 enabled: o.enabled ?? undefined,
435 weight: o.weight ?? undefined,
436 port: o.port ?? undefined,
437 header:
438 o.header == null
439 ? undefined
440 : { host: o.header.host == null ? undefined : [...o.header.host] },
441 flattenCname: o.flattenCname ?? undefined,
442 virtualNetworkId: o.virtualNetworkId ?? undefined,
443 }),
444 );
445
446 return (
447 (observed.name ?? "") !== body.name ||
448 JSON.stringify(desiredOrigins) !== JSON.stringify(observedOrigins) ||
449 scalarDirty(body.description, observed.description) ||
450 scalarDirty(body.enabled, observed.enabled) ||
451 scalarDirty(body.monitor, observed.monitor) ||
452 scalarDirty(body.monitorGroup, observed.monitorGroup) ||
453 scalarDirty(body.minimumOrigins, observed.minimumOrigins) ||
454 scalarDirty(body.latitude, observed.latitude) ||
455 scalarDirty(body.longitude, observed.longitude) ||
456 scalarDirty(body.notificationEmail, observed.notificationEmail) ||
457 (body.loadShedding !== undefined &&
458 JSON.stringify(body.loadShedding) !==
459 JSON.stringify(observed.loadShedding ?? {})) ||
460 (body.originSteering !== undefined &&
461 JSON.stringify(body.originSteering) !==
462 JSON.stringify(observed.originSteering ?? {})) ||
463 (body.notificationFilter !== undefined &&
464 JSON.stringify(body.notificationFilter) !==
465 JSON.stringify(observed.notificationFilter ?? {}))
466 );
467};
468
469/**
470 * Normalize an origin to its API defaults so desired-vs-observed
471 * comparison is stable.
472 */
473const normalizeOrigin = (o: {
474 address: string;
475 name?: string;
476 enabled?: boolean;
477 weight?: number;
478 port?: number;
479 header?: { host?: string[] };
480 flattenCname?: boolean;
481 virtualNetworkId?: string;
482}) => ({
483 address: o.address,
484 name: o.name ?? "",
485 enabled: o.enabled ?? true,
486 weight: o.weight ?? 1,
487 port: o.port ?? 0,
488 host: o.header?.host ?? [],
489 flattenCname: o.flattenCname ?? false,
490 virtualNetworkId: o.virtualNetworkId ?? "",
491});
492
493const toAttributes = (
494 pool: ObservedPool,
495 accountId: string,
496): PoolAttributes => ({
497 poolId: pool.id ?? "",
498 accountId,
499 name: pool.name ?? "",
500 enabled: pool.enabled ?? true,
501 monitor: pool.monitor ?? undefined,
502 createdOn: pool.createdOn ?? undefined,
503 modifiedOn: pool.modifiedOn ?? undefined,
504});