1import * as addressing from "@distilled.cloud/cloudflare/addressing";
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 { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Addressing.BgpPrefix" as const;
14type TypeId = typeof TypeId;
15
16export 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
47export 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
79export 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 */
121export const BgpPrefix = Resource<BgpPrefix>(TypeId);
122
123/**
124 * Returns true if the given value is an BgpPrefix resource.
125 */
126export const isBgpPrefix = (value: unknown): value is BgpPrefix =>
127 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
128
129export 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
298type ObservedBgpPrefix = addressing.GetPrefixBgpPrefixResponse;
299
300/**
301 * Read a BGP prefix by id, mapping "gone" (`BgpPrefixNotFound` /
302 * `PrefixNotFound`) to `undefined`.
303 */
304const 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 */
321const 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
328const 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});