| 1 | import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import type { Input } from "../../Input.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import { listAllZones } from "../Zone/lookup.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.LoadBalancer.LoadBalancer" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | /** |
| 19 | * Steering policy for a Load Balancer. |
| 20 | */ |
| 21 | export type SteeringPolicy = |
| 22 | | "off" |
| 23 | | "geo" |
| 24 | | "random" |
| 25 | | "dynamic_latency" |
| 26 | | "proximity" |
| 27 | | "least_outstanding_requests" |
| 28 | | "least_connections" |
| 29 | | ""; |
| 30 | |
| 31 | /** |
| 32 | * Session affinity mode for a Load Balancer. |
| 33 | */ |
| 34 | export type SessionAffinity = "none" | "cookie" | "ip_cookie" | "header"; |
| 35 | |
| 36 | /** |
| 37 | * Session affinity attributes for a Load Balancer. |
| 38 | */ |
| 39 | export interface SessionAffinityAttributes { |
| 40 | /** Seconds to drain an origin of affine sessions before removal. */ |
| 41 | drainDuration?: number; |
| 42 | /** Headers to base header-mode session affinity on. */ |
| 43 | headers?: ReadonlyArray<string>; |
| 44 | /** Whether all configured headers must match. */ |
| 45 | requireAllHeaders?: boolean; |
| 46 | /** SameSite attribute of the affinity cookie. */ |
| 47 | samesite?: "Auto" | "Lax" | "None" | "Strict"; |
| 48 | /** Secure attribute of the affinity cookie. */ |
| 49 | secure?: "Auto" | "Always" | "Never"; |
| 50 | /** Failover behavior when an affine origin becomes unavailable. */ |
| 51 | zeroDowntimeFailover?: "none" | "temporary" | "sticky"; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Location strategy for non-proxied (DNS-steered) Load Balancers. |
| 56 | */ |
| 57 | export interface LocationStrategy { |
| 58 | /** Resolution mode used to determine the client's location. */ |
| 59 | mode?: "pop" | "resolver_ip"; |
| 60 | /** When to prefer the EDNS Client Subnet over the resolver IP. */ |
| 61 | preferEcs?: "always" | "never" | "proximity" | "geo"; |
| 62 | } |
| 63 | |
| 64 | export interface Props { |
| 65 | /** |
| 66 | * Zone the load balancer lives in. Stable — changing the zone triggers |
| 67 | * replacement. |
| 68 | */ |
| 69 | zoneId: string; |
| 70 | /** |
| 71 | * The DNS hostname to associate with the Load Balancer (e.g. |
| 72 | * `lb.example.com`). If this hostname already exists as a DNS record, |
| 73 | * the Load Balancer takes precedence. Mutable in place. |
| 74 | */ |
| 75 | name: string; |
| 76 | /** |
| 77 | * Pool IDs ordered by their failover priority. Used by default, or when |
| 78 | * region/country/PoP pools are not configured for a request. |
| 79 | */ |
| 80 | defaultPools: ReadonlyArray<string>; |
| 81 | /** |
| 82 | * The pool ID to use when all other pools are detected as unhealthy. |
| 83 | */ |
| 84 | fallbackPool: string; |
| 85 | /** |
| 86 | * Object description. |
| 87 | */ |
| 88 | description?: string; |
| 89 | /** |
| 90 | * Whether the hostname is gray clouded (false) or orange clouded / |
| 91 | * proxied through Cloudflare (true). |
| 92 | * @default false |
| 93 | */ |
| 94 | proxied?: boolean; |
| 95 | /** |
| 96 | * TTL (seconds) of the DNS entry for the IP address returned by this |
| 97 | * load balancer. Only applies to unproxied load balancers — the API |
| 98 | * rejects it when `proxied: true`. |
| 99 | * @default 30 |
| 100 | */ |
| 101 | ttl?: number; |
| 102 | /** |
| 103 | * Steering policy for this load balancer. |
| 104 | * @default "" |
| 105 | */ |
| 106 | steeringPolicy?: SteeringPolicy; |
| 107 | /** |
| 108 | * Type of session affinity to use. |
| 109 | * @default "none" |
| 110 | */ |
| 111 | sessionAffinity?: SessionAffinity; |
| 112 | /** |
| 113 | * Time, in seconds, until a client's session expires after being |
| 114 | * created. |
| 115 | * @default 82800 |
| 116 | */ |
| 117 | sessionAffinityTtl?: number; |
| 118 | /** |
| 119 | * Attributes configuring session affinity behavior. |
| 120 | */ |
| 121 | sessionAffinityAttributes?: SessionAffinityAttributes; |
| 122 | /** |
| 123 | * Routing modifications in response to dynamic conditions (e.g. |
| 124 | * zero-downtime failover between health probes). |
| 125 | */ |
| 126 | adaptiveRouting?: { failoverAcrossPools?: boolean }; |
| 127 | /** |
| 128 | * Location-based steering behavior for non-proxied requests. |
| 129 | */ |
| 130 | locationStrategy?: LocationStrategy; |
| 131 | /** |
| 132 | * Pool weights for `random` / `least_outstanding_requests` / |
| 133 | * `least_connections` steering. |
| 134 | */ |
| 135 | randomSteering?: { |
| 136 | defaultWeight?: number; |
| 137 | poolWeights?: Record<string, number>; |
| 138 | }; |
| 139 | /** |
| 140 | * Region code → ordered pool IDs for that region. Regions not defined |
| 141 | * fall back to `defaultPools`. |
| 142 | */ |
| 143 | regionPools?: Record<string, ReadonlyArray<string>>; |
| 144 | /** |
| 145 | * Country code → ordered pool IDs for that country. Countries not |
| 146 | * defined fall back to the corresponding region pool mapping. |
| 147 | */ |
| 148 | countryPools?: Record<string, ReadonlyArray<string>>; |
| 149 | /** |
| 150 | * Enterprise only — Cloudflare PoP identifier → ordered pool IDs for |
| 151 | * that PoP. |
| 152 | */ |
| 153 | popPools?: Record<string, ReadonlyArray<string>>; |
| 154 | } |
| 155 | |
| 156 | export interface Attributes { |
| 157 | /** Cloudflare-assigned load balancer identifier. */ |
| 158 | loadBalancerId: string; |
| 159 | /** Zone that owns the load balancer. */ |
| 160 | zoneId: string; |
| 161 | /** DNS hostname of the load balancer. */ |
| 162 | name: string; |
| 163 | /** Whether the load balancer is enabled. */ |
| 164 | enabled: boolean; |
| 165 | /** Whether the hostname is proxied (orange clouded). */ |
| 166 | proxied: boolean; |
| 167 | /** Resolved steering policy. */ |
| 168 | steeringPolicy: string; |
| 169 | /** Resolved default pool ids. */ |
| 170 | defaultPools: ReadonlyArray<string>; |
| 171 | /** Resolved fallback pool id. */ |
| 172 | fallbackPool: string; |
| 173 | /** ISO8601 creation timestamp. */ |
| 174 | createdOn: string | undefined; |
| 175 | /** ISO8601 last-modified timestamp. */ |
| 176 | modifiedOn: string | undefined; |
| 177 | } |
| 178 | |
| 179 | export type LoadBalancer = Resource< |
| 180 | TypeId, |
| 181 | Props, |
| 182 | Attributes, |
| 183 | never, |
| 184 | Providers |
| 185 | >; |
| 186 | |
| 187 | /** |
| 188 | * A Cloudflare Load Balancer — a zone-level DNS hostname that distributes |
| 189 | * traffic across {@link Pool}s with health-based failover, |
| 190 | * geo/latency steering, and session affinity. |
| 191 | * |
| 192 | * Requires the Load Balancing subscription to be enabled for the zone; |
| 193 | * without it, creation fails with the typed `LoadBalancingNotEnabledForZone` |
| 194 | * error. |
| 195 | * ### Creating a Load Balancer |
| 196 | * **Example:** DNS-only (unproxied) load balancer |
| 197 | * ```typescript |
| 198 | * const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("ApiLb", { |
| 199 | * zoneId: zone.zoneId, |
| 200 | * name: "api.example.com", |
| 201 | * defaultPools: [pool.poolId], |
| 202 | * fallbackPool: pool.poolId, |
| 203 | * proxied: false, |
| 204 | * ttl: 30, |
| 205 | * }); |
| 206 | * ``` |
| 207 | * |
| 208 | * **Example:** Proxied load balancer with steering and affinity |
| 209 | * ```typescript |
| 210 | * const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("AppLb", { |
| 211 | * zoneId: zone.zoneId, |
| 212 | * name: "app.example.com", |
| 213 | * defaultPools: [primary.poolId, secondary.poolId], |
| 214 | * fallbackPool: secondary.poolId, |
| 215 | * proxied: true, |
| 216 | * steeringPolicy: "random", |
| 217 | * sessionAffinity: "cookie", |
| 218 | * }); |
| 219 | * ``` |
| 220 | * |
| 221 | * ### Geo steering |
| 222 | * **Example:** Region pools |
| 223 | * ```typescript |
| 224 | * yield* Cloudflare.LoadBalancer.LoadBalancer("GeoLb", { |
| 225 | * zoneId: zone.zoneId, |
| 226 | * name: "geo.example.com", |
| 227 | * defaultPools: [us.poolId], |
| 228 | * fallbackPool: us.poolId, |
| 229 | * steeringPolicy: "geo", |
| 230 | * regionPools: { |
| 231 | * WEU: [eu.poolId], |
| 232 | * ENAM: [us.poolId], |
| 233 | * }, |
| 234 | * }); |
| 235 | * ``` |
| 236 | * |
| 237 | * @see https://developers.cloudflare.com/load-balancing/ |
| 238 | * |
| 239 | * @resource |
| 240 | * @product Load Balancers |
| 241 | * @category Performance & Reliability |
| 242 | */ |
| 243 | export const LoadBalancer = Resource<LoadBalancer>(TypeId, { |
| 244 | aliases: ["Cloudflare.LoadBalancer"], |
| 245 | }); |
| 246 | |
| 247 | /** |
| 248 | * Returns true if the given value is a LoadBalancer resource. |
| 249 | */ |
| 250 | export const isLoadBalancer = (value: unknown): value is LoadBalancer => |
| 251 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 252 | |
| 253 | export const LoadBalancerProvider = () => |
| 254 | Provider.succeed(LoadBalancer, { |
| 255 | stables: ["loadBalancerId", "zoneId", "createdOn"], |
| 256 | |
| 257 | diff: Effect.fn(function* ({ olds, news }) { |
| 258 | if (!isResolved(news)) return undefined; |
| 259 | // zoneId is Input<string>; by diff time both sides are concrete |
| 260 | // strings when statically known. |
| 261 | if ( |
| 262 | typeof olds.zoneId === "string" && |
| 263 | typeof news.zoneId === "string" && |
| 264 | olds.zoneId !== news.zoneId |
| 265 | ) { |
| 266 | return { action: "replace" } as const; |
| 267 | } |
| 268 | }), |
| 269 | |
| 270 | read: Effect.fn(function* ({ output, olds }) { |
| 271 | if (output?.loadBalancerId) { |
| 272 | const observed = yield* getLoadBalancer( |
| 273 | output.zoneId, |
| 274 | output.loadBalancerId, |
| 275 | ); |
| 276 | return observed ? toAttributes(observed, output.zoneId) : undefined; |
| 277 | } |
| 278 | // Cold read — a load balancer's hostname is unique within its zone. |
| 279 | // Load balancers carry no ownership marker, so report the match as |
| 280 | // Unowned and let the engine gate takeover behind --adopt. |
| 281 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 282 | const name = output?.name ?? olds?.name; |
| 283 | if (zoneId && name) { |
| 284 | const match = yield* findByName(zoneId, name); |
| 285 | if (match?.id) { |
| 286 | const observed = yield* getLoadBalancer(zoneId, match.id); |
| 287 | if (observed) return Unowned(toAttributes(observed, zoneId)); |
| 288 | } |
| 289 | } |
| 290 | return undefined; |
| 291 | }), |
| 292 | |
| 293 | reconcile: Effect.fn(function* ({ news, output }) { |
| 294 | // Inputs have been resolved to concrete strings by Plan. |
| 295 | const zoneId = news.zoneId as string; |
| 296 | const body = buildBody(news); |
| 297 | |
| 298 | // 1. Observe — output.loadBalancerId is a cache hint; a 404 falls |
| 299 | // through to "missing" and we recreate. |
| 300 | const observed = output?.loadBalancerId |
| 301 | ? yield* getLoadBalancer(output.zoneId ?? zoneId, output.loadBalancerId) |
| 302 | : undefined; |
| 303 | |
| 304 | // 2. Ensure — missing: create with the full desired body. |
| 305 | if (!observed?.id) { |
| 306 | const created = yield* loadBalancers.createLoadBalancer({ |
| 307 | zoneId, |
| 308 | ...body, |
| 309 | }); |
| 310 | return toAttributes(created, zoneId); |
| 311 | } |
| 312 | |
| 313 | // 3. Sync — the update endpoint is a PUT requiring the full body; |
| 314 | // diff observed against desired and skip the call on a no-op. |
| 315 | if (!loadBalancerDirty(observed, body)) { |
| 316 | return toAttributes(observed, zoneId); |
| 317 | } |
| 318 | const updated = yield* loadBalancers.updateLoadBalancer({ |
| 319 | zoneId, |
| 320 | loadBalancerId: observed.id, |
| 321 | ...body, |
| 322 | }); |
| 323 | return toAttributes(updated, zoneId); |
| 324 | }), |
| 325 | |
| 326 | delete: Effect.fn(function* ({ output }) { |
| 327 | yield* loadBalancers |
| 328 | .deleteLoadBalancer({ |
| 329 | zoneId: output.zoneId, |
| 330 | loadBalancerId: output.loadBalancerId, |
| 331 | }) |
| 332 | .pipe(Effect.catchTag("LoadBalancerNotFound", () => Effect.void)); |
| 333 | }), |
| 334 | |
| 335 | // Load balancers are zone-scoped; enumerate every zone in the account and |
| 336 | // exhaustively paginate each zone's load balancers, hydrating each into the |
| 337 | // same Attributes shape `read` returns. Zones without the Load Balancing |
| 338 | // subscription reject the route (Forbidden) and are skipped. |
| 339 | list: Effect.fn(function* () { |
| 340 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 341 | const zones = yield* listAllZones(accountId); |
| 342 | const rows = yield* Effect.forEach( |
| 343 | zones, |
| 344 | (zone) => |
| 345 | loadBalancers.listLoadBalancers.pages({ zoneId: zone.id }).pipe( |
| 346 | Stream.runCollect, |
| 347 | Effect.map((chunk) => |
| 348 | Array.from(chunk).flatMap((page) => |
| 349 | (page.result ?? []).map((lb) => toAttributes(lb, zone.id)), |
| 350 | ), |
| 351 | ), |
| 352 | ), |
| 353 | { concurrency: 10 }, |
| 354 | ); |
| 355 | return rows.flat(); |
| 356 | }), |
| 357 | }); |
| 358 | |
| 359 | type ObservedLoadBalancer = |
| 360 | | loadBalancers.GetLoadBalancerResponse |
| 361 | | loadBalancers.CreateLoadBalancerResponse |
| 362 | | loadBalancers.UpdateLoadBalancerResponse |
| 363 | | loadBalancers.ListLoadBalancersResponse["result"][number]; |
| 364 | |
| 365 | /** |
| 366 | * Read a load balancer by id, mapping "gone" (`LoadBalancerNotFound`, |
| 367 | * HTTP 404 code 1001) to `undefined`. |
| 368 | */ |
| 369 | const getLoadBalancer = (zoneId: string, loadBalancerId: string) => |
| 370 | loadBalancers |
| 371 | .getLoadBalancer({ zoneId, loadBalancerId }) |
| 372 | .pipe( |
| 373 | Effect.catchTag("LoadBalancerNotFound", () => Effect.succeed(undefined)), |
| 374 | ); |
| 375 | |
| 376 | /** |
| 377 | * Find a load balancer by exact hostname within a zone. |
| 378 | */ |
| 379 | const findByName = (zoneId: string, name: string) => |
| 380 | loadBalancers.listLoadBalancers.items({ zoneId }).pipe( |
| 381 | Stream.filter((lb) => lb.name === name), |
| 382 | Stream.runCollect, |
| 383 | Effect.map((chunk) => Array.from(chunk).at(0)), |
| 384 | ); |
| 385 | |
| 386 | const resolvePools = ( |
| 387 | pools: Record<string, ReadonlyArray<Input<string>>> | undefined, |
| 388 | ): Record<string, string[]> | undefined => |
| 389 | pools === undefined |
| 390 | ? undefined |
| 391 | : Object.fromEntries( |
| 392 | Object.entries(pools).map(([k, v]) => [k, Array.from(v as string[])]), |
| 393 | ); |
| 394 | |
| 395 | const buildBody = (news: Props) => ({ |
| 396 | name: news.name, |
| 397 | defaultPools: news.defaultPools as string[], |
| 398 | fallbackPool: news.fallbackPool as string, |
| 399 | description: news.description, |
| 400 | proxied: news.proxied, |
| 401 | ttl: news.proxied === true ? undefined : news.ttl, |
| 402 | steeringPolicy: news.steeringPolicy, |
| 403 | sessionAffinity: news.sessionAffinity, |
| 404 | sessionAffinityTtl: news.sessionAffinityTtl, |
| 405 | sessionAffinityAttributes: |
| 406 | news.sessionAffinityAttributes === undefined |
| 407 | ? undefined |
| 408 | : { |
| 409 | ...news.sessionAffinityAttributes, |
| 410 | headers: |
| 411 | news.sessionAffinityAttributes.headers === undefined |
| 412 | ? undefined |
| 413 | : Array.from(news.sessionAffinityAttributes.headers), |
| 414 | }, |
| 415 | adaptiveRouting: news.adaptiveRouting, |
| 416 | locationStrategy: news.locationStrategy, |
| 417 | randomSteering: news.randomSteering, |
| 418 | regionPools: resolvePools(news.regionPools), |
| 419 | countryPools: resolvePools(news.countryPools), |
| 420 | popPools: resolvePools(news.popPools), |
| 421 | }); |
| 422 | |
| 423 | /** |
| 424 | * Compare desired (explicitly set) fields against observed cloud state. |
| 425 | * Unset desired fields defer to whatever the cloud already has. |
| 426 | */ |
| 427 | const loadBalancerDirty = ( |
| 428 | observed: ObservedLoadBalancer, |
| 429 | body: ReturnType<typeof buildBody>, |
| 430 | ): boolean => { |
| 431 | const scalarDirty = ( |
| 432 | desired: string | number | boolean | undefined, |
| 433 | actual: string | number | boolean | null | undefined, |
| 434 | ) => desired !== undefined && desired !== (actual ?? undefined); |
| 435 | const structDirty = (desired: unknown, actual: unknown) => |
| 436 | desired !== undefined && |
| 437 | JSON.stringify(desired) !== JSON.stringify(actual ?? {}); |
| 438 | |
| 439 | return ( |
| 440 | (observed.name ?? "") !== body.name || |
| 441 | JSON.stringify(observed.defaultPools ?? []) !== |
| 442 | JSON.stringify(body.defaultPools) || |
| 443 | (observed.fallbackPool ?? "") !== body.fallbackPool || |
| 444 | scalarDirty(body.description, observed.description) || |
| 445 | scalarDirty(body.proxied, observed.proxied) || |
| 446 | scalarDirty(body.ttl, observed.ttl) || |
| 447 | scalarDirty(body.steeringPolicy, observed.steeringPolicy) || |
| 448 | scalarDirty(body.sessionAffinity, observed.sessionAffinity) || |
| 449 | scalarDirty(body.sessionAffinityTtl, observed.sessionAffinityTtl) || |
| 450 | structDirty( |
| 451 | body.sessionAffinityAttributes, |
| 452 | observed.sessionAffinityAttributes, |
| 453 | ) || |
| 454 | structDirty(body.adaptiveRouting, observed.adaptiveRouting) || |
| 455 | structDirty(body.locationStrategy, observed.locationStrategy) || |
| 456 | structDirty(body.randomSteering, observed.randomSteering) || |
| 457 | structDirty(body.regionPools, observed.regionPools) || |
| 458 | structDirty(body.countryPools, observed.countryPools) || |
| 459 | structDirty(body.popPools, observed.popPools) |
| 460 | ); |
| 461 | }; |
| 462 | |
| 463 | const toAttributes = ( |
| 464 | lb: ObservedLoadBalancer, |
| 465 | zoneId: string, |
| 466 | ): Attributes => ({ |
| 467 | loadBalancerId: lb.id ?? "", |
| 468 | zoneId, |
| 469 | name: lb.name ?? "", |
| 470 | enabled: lb.enabled ?? true, |
| 471 | proxied: lb.proxied ?? false, |
| 472 | steeringPolicy: lb.steeringPolicy ?? "", |
| 473 | defaultPools: [...(lb.defaultPools ?? [])], |
| 474 | fallbackPool: lb.fallbackPool ?? "", |
| 475 | createdOn: lb.createdOn ?? undefined, |
| 476 | modifiedOn: lb.modifiedOn ?? undefined, |
| 477 | }); |