| 1 | import * as addressing from "@distilled.cloud/cloudflare/addressing"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | |
| 13 | const TypeId = "Cloudflare.Addressing.BgpPrefix" as const; |
| 14 | type TypeId = typeof TypeId; |
| 15 | |
| 16 | export interface BgpPrefixProps { |
| 17 | /** |
| 18 | * Identifier of the parent BYOIP prefix the BGP prefix belongs to. |
| 19 | * Changing it forces a replacement. |
| 20 | */ |
| 21 | prefixId: string; |
| 22 | /** |
| 23 | * IP Prefix in Classless Inter-Domain Routing format. Must be contained |
| 24 | * in the parent prefix. Changing it forces a replacement. |
| 25 | */ |
| 26 | cidr: string; |
| 27 | /** |
| 28 | * Whether the BGP prefix is advertised to the internet (maps to |
| 29 | * `on_demand.advertised`). Mutable — patched in place; BGP propagation |
| 30 | * is eventually consistent (minutes). |
| 31 | * @default false |
| 32 | */ |
| 33 | advertised?: boolean; |
| 34 | /** |
| 35 | * Number of times to prepend the Cloudflare ASN to the BGP AS-Path |
| 36 | * attribute. Mutable. |
| 37 | */ |
| 38 | asnPrependCount?: number; |
| 39 | /** |
| 40 | * If `true`, Cloudflare advertises the prefix only while a matching BGP |
| 41 | * prefix exists in the Magic routing table, automatically withdrawing it |
| 42 | * otherwise. Mutable. |
| 43 | */ |
| 44 | autoAdvertiseWithdraw?: boolean; |
| 45 | } |
| 46 | |
| 47 | export interface BgpPrefixAttributes { |
| 48 | /** Cloudflare-assigned identifier of the BGP prefix. */ |
| 49 | bgpPrefixId: string; |
| 50 | /** Identifier of the parent BYOIP prefix. */ |
| 51 | prefixId: string; |
| 52 | /** The Cloudflare account the prefix belongs to. */ |
| 53 | accountId: string; |
| 54 | /** IP Prefix in CIDR format. */ |
| 55 | cidr: string; |
| 56 | /** ASN the prefix is advertised under. */ |
| 57 | asn: number | undefined; |
| 58 | /** Number of Cloudflare ASN prepends on the AS-Path attribute. */ |
| 59 | asnPrependCount: number | undefined; |
| 60 | /** Whether Cloudflare auto-withdraws the prefix without a Magic route. */ |
| 61 | autoAdvertiseWithdraw: boolean | undefined; |
| 62 | /** On-demand advertisement state for the BGP prefix. */ |
| 63 | onDemand: { |
| 64 | /** Whether the prefix is currently advertised. */ |
| 65 | advertised: boolean; |
| 66 | /** When the advertisement state last changed. */ |
| 67 | advertisedModifiedAt: string | undefined; |
| 68 | /** Whether on-demand advertisement is enabled for the prefix. */ |
| 69 | onDemandEnabled: boolean; |
| 70 | /** `true` while an advertisement change is propagating. */ |
| 71 | onDemandLocked: boolean; |
| 72 | }; |
| 73 | /** ISO8601 creation timestamp. */ |
| 74 | createdAt: string | undefined; |
| 75 | /** ISO8601 last-modified timestamp. */ |
| 76 | modifiedAt: string | undefined; |
| 77 | } |
| 78 | |
| 79 | export type BgpPrefix = Resource< |
| 80 | TypeId, |
| 81 | BgpPrefixProps, |
| 82 | BgpPrefixAttributes, |
| 83 | never, |
| 84 | Providers |
| 85 | >; |
| 86 | |
| 87 | /** |
| 88 | * A BGP prefix controlling on-demand advertisement of a BYOIP prefix (or a |
| 89 | * subnet of it) to the internet. |
| 90 | * |
| 91 | * Cloudflare automatically creates BGP prefixes during BYOIP onboarding, so |
| 92 | * reconcile adopts an existing BGP prefix matching the CIDR before creating |
| 93 | * a new one. There is **no delete API** — destroying this resource only |
| 94 | * withdraws the advertisement (`advertised: false`) and drops the state. |
| 95 | * ### Advertising a Prefix |
| 96 | * **Example:** Advertise the whole BYOIP prefix |
| 97 | * ```typescript |
| 98 | * const bgp = yield* Cloudflare.Addressing.BgpPrefix("advertise", { |
| 99 | * prefixId: prefix.prefixId, |
| 100 | * cidr: prefix.cidr, |
| 101 | * advertised: true, |
| 102 | * }); |
| 103 | * ``` |
| 104 | * |
| 105 | * **Example:** Withdraw with AS-Path prepending configured |
| 106 | * ```typescript |
| 107 | * const bgp = yield* Cloudflare.Addressing.BgpPrefix("advertise", { |
| 108 | * prefixId: prefix.prefixId, |
| 109 | * cidr: prefix.cidr, |
| 110 | * advertised: false, |
| 111 | * asnPrependCount: 2, |
| 112 | * }); |
| 113 | * ``` |
| 114 | * |
| 115 | * @see https://developers.cloudflare.com/byoip/concepts/bgp-prefixes/ |
| 116 | * |
| 117 | * @resource |
| 118 | * @product Addressing |
| 119 | * @category Network |
| 120 | */ |
| 121 | export const BgpPrefix = Resource<BgpPrefix>(TypeId); |
| 122 | |
| 123 | /** |
| 124 | * Returns true if the given value is an BgpPrefix resource. |
| 125 | */ |
| 126 | export const isBgpPrefix = (value: unknown): value is BgpPrefix => |
| 127 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 128 | |
| 129 | export const BgpPrefixProvider = () => |
| 130 | Provider.succeed(BgpPrefix, { |
| 131 | stables: ["bgpPrefixId", "prefixId", "accountId", "cidr", "createdAt"], |
| 132 | |
| 133 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 134 | if (olds === undefined) return undefined; |
| 135 | if (!isResolved(news) || !isResolved(olds)) return undefined; |
| 136 | // prefixId is Input<string>; by diff time both sides are concrete. |
| 137 | const oldPrefixId = output?.prefixId ?? olds.prefixId; |
| 138 | if ( |
| 139 | typeof oldPrefixId === "string" && |
| 140 | typeof news.prefixId === "string" && |
| 141 | news.prefixId !== oldPrefixId |
| 142 | ) { |
| 143 | return { action: "replace" } as const; |
| 144 | } |
| 145 | if (news.cidr !== (output?.cidr ?? olds.cidr)) { |
| 146 | return { action: "replace" } as const; |
| 147 | } |
| 148 | return undefined; |
| 149 | }), |
| 150 | |
| 151 | read: Effect.fn(function* ({ output, olds }) { |
| 152 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 153 | const acct = output?.accountId ?? accountId; |
| 154 | const prefixId = |
| 155 | output?.prefixId ?? |
| 156 | (typeof olds?.prefixId === "string" ? olds.prefixId : undefined); |
| 157 | if (!prefixId) return undefined; |
| 158 | |
| 159 | if (output?.bgpPrefixId) { |
| 160 | const observed = yield* getBgpPrefix( |
| 161 | acct, |
| 162 | prefixId, |
| 163 | output.bgpPrefixId, |
| 164 | ); |
| 165 | return observed ? toAttributes(observed, prefixId, acct) : undefined; |
| 166 | } |
| 167 | // Cold read — match on CIDR, which is unique within the parent prefix. |
| 168 | const cidr = output?.cidr ?? olds?.cidr; |
| 169 | if (typeof cidr !== "string") return undefined; |
| 170 | const match = yield* findByCidr(acct, prefixId, cidr); |
| 171 | return match ? toAttributes(match, prefixId, acct) : undefined; |
| 172 | }), |
| 173 | |
| 174 | // BGP prefixes are children of BYOIP IP prefixes, which have no single |
| 175 | // account-wide enumeration endpoint. Fan out: list every account IP |
| 176 | // prefix, then exhaustively page the BGP prefixes under each. Accounts |
| 177 | // without BYOIP simply have no parent prefixes, so the result is |
| 178 | // naturally empty. |
| 179 | list: Effect.fn(function* () { |
| 180 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 181 | |
| 182 | const prefixIds = yield* addressing.listPrefixes |
| 183 | .items({ accountId }) |
| 184 | .pipe( |
| 185 | Stream.runCollect, |
| 186 | Effect.map((chunk) => |
| 187 | Array.from(chunk) |
| 188 | .map((p) => p.id) |
| 189 | .filter((id): id is string => typeof id === "string"), |
| 190 | ), |
| 191 | ); |
| 192 | |
| 193 | const rows = yield* Effect.forEach( |
| 194 | prefixIds, |
| 195 | (prefixId) => |
| 196 | addressing.listPrefixBgpPrefixes.items({ accountId, prefixId }).pipe( |
| 197 | Stream.runCollect, |
| 198 | Effect.map((chunk) => |
| 199 | Array.from(chunk).map((bgp) => |
| 200 | toAttributes(bgp, prefixId, accountId), |
| 201 | ), |
| 202 | ), |
| 203 | // Parent prefix removed mid-enumeration — skip it. |
| 204 | Effect.catchTag("PrefixNotFound", () => |
| 205 | Effect.succeed([] as BgpPrefixAttributes[]), |
| 206 | ), |
| 207 | ), |
| 208 | { concurrency: 10 }, |
| 209 | ); |
| 210 | return rows.flat(); |
| 211 | }), |
| 212 | |
| 213 | reconcile: Effect.fn(function* ({ news, output }) { |
| 214 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 215 | const acct = output?.accountId ?? accountId; |
| 216 | const prefixId = news.prefixId as string; |
| 217 | |
| 218 | // 1. Observe — by cached id first, then by CIDR: Cloudflare creates |
| 219 | // BGP prefixes itself during onboarding, so an existing one with |
| 220 | // our CIDR is adopted rather than duplicated. |
| 221 | let observed = output?.bgpPrefixId |
| 222 | ? yield* getBgpPrefix(acct, prefixId, output.bgpPrefixId) |
| 223 | : undefined; |
| 224 | if (!observed) { |
| 225 | observed = yield* findByCidr(acct, prefixId, news.cidr); |
| 226 | } |
| 227 | |
| 228 | // 2. Ensure — create if genuinely missing. |
| 229 | if (!observed) { |
| 230 | observed = yield* addressing.createPrefixBgpPrefix({ |
| 231 | accountId: acct, |
| 232 | prefixId, |
| 233 | cidr: news.cidr, |
| 234 | }); |
| 235 | } |
| 236 | const bgpPrefixId = observed.id ?? ""; |
| 237 | |
| 238 | // 3. Sync — diff observed advertisement settings against desired and |
| 239 | // patch only the delta. `onDemandLocked` means a change is already |
| 240 | // propagating; retry (bounded) while locked. |
| 241 | const desiredAdvertised = news.advertised ?? false; |
| 242 | const dirty = |
| 243 | (news.advertised !== undefined && |
| 244 | (observed.onDemand?.advertised ?? false) !== desiredAdvertised) || |
| 245 | (news.asnPrependCount !== undefined && |
| 246 | (observed.asnPrependCount ?? 0) !== news.asnPrependCount) || |
| 247 | (news.autoAdvertiseWithdraw !== undefined && |
| 248 | (observed.autoAdvertiseWithdraw ?? false) !== |
| 249 | news.autoAdvertiseWithdraw); |
| 250 | if (dirty) { |
| 251 | const patched = yield* addressing |
| 252 | .patchPrefixBgpPrefix({ |
| 253 | accountId: acct, |
| 254 | prefixId, |
| 255 | bgpPrefixId, |
| 256 | asnPrependCount: news.asnPrependCount, |
| 257 | autoAdvertiseWithdraw: news.autoAdvertiseWithdraw, |
| 258 | onDemand: |
| 259 | news.advertised !== undefined |
| 260 | ? { advertised: news.advertised } |
| 261 | : undefined, |
| 262 | }) |
| 263 | .pipe( |
| 264 | Effect.retry({ |
| 265 | while: (e) => e._tag === "BgpPrefixNotFound", |
| 266 | schedule: Schedule.exponential("2 seconds"), |
| 267 | times: 5, |
| 268 | }), |
| 269 | ); |
| 270 | return toAttributes(patched, prefixId, acct); |
| 271 | } |
| 272 | |
| 273 | return toAttributes(observed, prefixId, acct); |
| 274 | }), |
| 275 | |
| 276 | delete: Effect.fn(function* ({ output }) { |
| 277 | // No delete API exists — BGP prefixes live as long as the parent |
| 278 | // BYOIP prefix. Withdraw the advertisement (best effort) and drop |
| 279 | // the state. |
| 280 | if (output.onDemand.advertised) { |
| 281 | yield* addressing |
| 282 | .patchPrefixBgpPrefix({ |
| 283 | accountId: output.accountId, |
| 284 | prefixId: output.prefixId, |
| 285 | bgpPrefixId: output.bgpPrefixId, |
| 286 | onDemand: { advertised: false }, |
| 287 | }) |
| 288 | .pipe( |
| 289 | Effect.catchTag( |
| 290 | ["BgpPrefixNotFound", "PrefixNotFound"], |
| 291 | () => Effect.void, |
| 292 | ), |
| 293 | ); |
| 294 | } |
| 295 | }), |
| 296 | }); |
| 297 | |
| 298 | type ObservedBgpPrefix = addressing.GetPrefixBgpPrefixResponse; |
| 299 | |
| 300 | /** |
| 301 | * Read a BGP prefix by id, mapping "gone" (`BgpPrefixNotFound` / |
| 302 | * `PrefixNotFound`) to `undefined`. |
| 303 | */ |
| 304 | const getBgpPrefix = ( |
| 305 | accountId: string, |
| 306 | prefixId: string, |
| 307 | bgpPrefixId: string, |
| 308 | ) => |
| 309 | addressing |
| 310 | .getPrefixBgpPrefix({ accountId, prefixId, bgpPrefixId }) |
| 311 | .pipe( |
| 312 | Effect.catchTag(["BgpPrefixNotFound", "PrefixNotFound"], () => |
| 313 | Effect.succeed(undefined), |
| 314 | ), |
| 315 | ); |
| 316 | |
| 317 | /** |
| 318 | * Find a BGP prefix by exact CIDR — unique within a parent prefix. The |
| 319 | * parent prefix being gone reads as "no match". |
| 320 | */ |
| 321 | const findByCidr = (accountId: string, prefixId: string, cidr: string) => |
| 322 | addressing.listPrefixBgpPrefixes.items({ accountId, prefixId }).pipe( |
| 323 | Stream.runCollect, |
| 324 | Effect.map((chunk) => Array.from(chunk).find((p) => p.cidr === cidr)), |
| 325 | Effect.catchTag("PrefixNotFound", () => Effect.succeed(undefined)), |
| 326 | ); |
| 327 | |
| 328 | const toAttributes = ( |
| 329 | bgp: ObservedBgpPrefix, |
| 330 | prefixId: string, |
| 331 | accountId: string, |
| 332 | ): BgpPrefixAttributes => ({ |
| 333 | bgpPrefixId: bgp.id ?? "", |
| 334 | prefixId, |
| 335 | accountId, |
| 336 | cidr: bgp.cidr ?? "", |
| 337 | asn: bgp.asn ?? undefined, |
| 338 | asnPrependCount: bgp.asnPrependCount ?? undefined, |
| 339 | autoAdvertiseWithdraw: bgp.autoAdvertiseWithdraw ?? undefined, |
| 340 | onDemand: { |
| 341 | advertised: bgp.onDemand?.advertised ?? false, |
| 342 | advertisedModifiedAt: bgp.onDemand?.advertisedModifiedAt ?? undefined, |
| 343 | onDemandEnabled: bgp.onDemand?.onDemandEnabled ?? false, |
| 344 | onDemandLocked: bgp.onDemand?.onDemandLocked ?? false, |
| 345 | }, |
| 346 | createdAt: bgp.createdAt ?? undefined, |
| 347 | modifiedAt: bgp.modifiedAt ?? undefined, |
| 348 | }); |