1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.MagicTransit.StaticRoute" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Scope of an ECMP static route — restricts the route to specific
17 * Cloudflare colos or regions.
18 */
19export interface MagicStaticRouteScope {
20 /** List of colo names the route applies to. */
21 coloNames?: string[];
22 /** List of colo regions the route applies to. */
23 coloRegions?: string[];
24}
25
26export interface MagicStaticRouteProps {
27 /**
28 * IP prefix in CIDR notation that this route matches.
29 */
30 prefix: string;
31 /**
32 * The next-hop IP address for the static route — typically a Magic
33 * tunnel's customer-side interface address.
34 */
35 nexthop: string;
36 /**
37 * Priority of the static route. Lower values are preferred.
38 * @default 100
39 */
40 priority?: number;
41 /**
42 * An optional human-provided description of the static route.
43 */
44 description?: string;
45 /**
46 * Optional weight for ECMP routes.
47 */
48 weight?: number;
49 /**
50 * Used only for ECMP routes — restrict the route to colos/regions.
51 */
52 scope?: MagicStaticRouteScope;
53}
54
55export interface MagicStaticRouteAttributes {
56 /** Cloudflare-assigned identifier of the static route. */
57 routeId: string;
58 /** The Cloudflare account the route belongs to. */
59 accountId: string;
60 /** IP prefix in CIDR notation. */
61 prefix: string;
62 /** The next-hop IP address. */
63 nexthop: string;
64 /** Priority of the static route. */
65 priority: number;
66 /** The route description, if set. */
67 description: string | undefined;
68 /** ECMP weight, if set. */
69 weight: number | undefined;
70 /** ECMP scope, if set. */
71 scope: MagicStaticRouteScope | undefined;
72 /** ISO8601 creation timestamp. */
73 createdOn: string | undefined;
74 /** ISO8601 last-modified timestamp. */
75 modifiedOn: string | undefined;
76}
77
78export type MagicStaticRoute = Resource<
79 TypeId,
80 MagicStaticRouteProps,
81 MagicStaticRouteAttributes,
82 never,
83 Providers
84>;
85
86/**
87 * A Magic Transit / Magic WAN static route — steers traffic for a prefix
88 * to a next-hop (usually a Magic tunnel interface address).
89 *
90 * Requires a Magic Transit or Magic WAN subscription on the account —
91 * accounts that are not onboarded receive a typed
92 * `MagicTransitNotOnboarded` error (Cloudflare code 1012).
93 *
94 * All properties are mutable in place via PUT. A route's practical
95 * identity is the `(prefix, nexthop, priority)` triple — when state is
96 * lost, `read` scans for a matching route and reports it as `Unowned` so
97 * takeover is gated behind `--adopt`.
98 * ### Creating a static route
99 * **Example:** Route a prefix over a GRE tunnel
100 * ```typescript
101 * const tunnel = yield* Cloudflare.MagicTransit.GreTunnel("office", {
102 * name: "office-gre-1",
103 * cloudflareGreEndpoint: "203.0.113.1",
104 * customerGreEndpoint: "198.51.100.1",
105 * interfaceAddress: "10.213.0.8/31",
106 * });
107 *
108 * yield* Cloudflare.MagicTransit.MagicStaticRoute("office-route", {
109 * prefix: "10.100.0.0/24",
110 * nexthop: "10.213.0.9",
111 * priority: 100,
112 * });
113 * ```
114 *
115 * **Example:** ECMP route scoped to a region
116 * ```typescript
117 * yield* Cloudflare.MagicTransit.MagicStaticRoute("ecmp-route", {
118 * prefix: "10.100.0.0/24",
119 * nexthop: "10.213.0.9",
120 * priority: 100,
121 * weight: 50,
122 * scope: { coloRegions: ["ENAM"] },
123 * });
124 * ```
125 *
126 * @see https://developers.cloudflare.com/magic-transit/how-to/configure-static-routes/
127 *
128 * @resource
129 * @product Magic Transit
130 * @category Network
131 */
132export const MagicStaticRoute = Resource<MagicStaticRoute>(TypeId);
133
134/**
135 * Returns true if the given value is a MagicStaticRoute resource.
136 */
137export const isMagicStaticRoute = (value: unknown): value is MagicStaticRoute =>
138 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
139
140const DEFAULT_PRIORITY = 100;
141
142export const MagicStaticRouteProvider = () =>
143 Provider.succeed(MagicStaticRoute, {
144 stables: ["routeId", "accountId", "createdOn"],
145
146 read: Effect.fn(function* ({ output, olds }) {
147 const { accountId } = yield* yield* CloudflareEnvironment;
148 const acct = output?.accountId ?? accountId;
149
150 if (output?.routeId) {
151 const observed = yield* getRoute(acct, output.routeId);
152 if (observed) return toAttributes(observed, acct);
153 }
154 // Cold read — match on the (prefix, nexthop, priority) triple.
155 // Routes carry no ownership markers; report as Unowned so takeover
156 // is gated behind the adopt policy.
157 const prefix = output?.prefix ?? olds?.prefix;
158 const nexthop =
159 output?.nexthop ??
160 (olds?.nexthop !== undefined && isResolved(olds.nexthop)
161 ? olds.nexthop
162 : undefined);
163 if (prefix && nexthop) {
164 const priority = output?.priority ?? olds?.priority ?? DEFAULT_PRIORITY;
165 const observed = yield* findRoute(acct, prefix, nexthop, priority);
166 if (observed) return Unowned(toAttributes(observed, acct));
167 }
168 return undefined;
169 }),
170
171 reconcile: Effect.fn(function* ({ news, output }) {
172 const { accountId } = yield* yield* CloudflareEnvironment;
173 // Inputs have been resolved to concrete strings by Plan.
174 const nexthop = news.nexthop as string;
175 const priority = news.priority ?? DEFAULT_PRIORITY;
176
177 // Observe — the id on `output` is a hint; fall back to scanning for
178 // the identity triple when it is gone.
179 let observed = output?.routeId
180 ? yield* getRoute(accountId, output.routeId)
181 : undefined;
182 if (!observed) {
183 observed = yield* findRoute(accountId, news.prefix, nexthop, priority);
184 }
185
186 // Ensure — create when missing.
187 if (!observed) {
188 const created = yield* magicTransit.createRoute({
189 accountId,
190 prefix: news.prefix,
191 nexthop,
192 priority,
193 description: news.description,
194 weight: news.weight,
195 scope: news.scope,
196 });
197 return toAttributes(created, accountId);
198 }
199
200 // Sync — diff observed cloud state against desired; full PUT, skip
201 // on no-op.
202 const dirty =
203 observed.prefix !== news.prefix ||
204 observed.nexthop !== nexthop ||
205 observed.priority !== priority ||
206 (news.description !== undefined &&
207 (observed.description ?? undefined) !== news.description) ||
208 (news.weight !== undefined &&
209 (observed.weight ?? undefined) !== news.weight) ||
210 scopeDirty(observed.scope, news.scope);
211 if (dirty) {
212 const updated = yield* magicTransit.updateRoute({
213 accountId,
214 routeId: observed.id,
215 prefix: news.prefix,
216 nexthop,
217 priority,
218 description: news.description,
219 weight: news.weight,
220 scope: news.scope,
221 });
222 observed =
223 updated.modifiedRoute ??
224 (yield* getRoute(accountId, observed.id)) ??
225 observed;
226 }
227
228 return toAttributes(observed, accountId);
229 }),
230
231 delete: Effect.fn(function* ({ output }) {
232 yield* magicTransit
233 .deleteRoute({
234 accountId: output.accountId,
235 routeId: output.routeId,
236 })
237 .pipe(Effect.catchTag("RouteNotFound", () => Effect.void));
238 }),
239
240 list: Effect.fn(function* () {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 // Account-scoped collection — the list API already returns the full
243 // route shape, so each item maps directly to the `read` Attributes.
244 return yield* magicTransit.listRoutes({ accountId }).pipe(
245 Effect.map((response) =>
246 (response.routes ?? []).map((route) =>
247 toAttributes(route, accountId),
248 ),
249 ),
250 // Accounts without a Magic Transit / Magic WAN subscription can't
251 // enumerate routes — treat as empty rather than failing the list.
252 Effect.catchTag("MagicTransitNotOnboarded", () => Effect.succeed([])),
253 );
254 }),
255 });
256
257interface ObservedRoute {
258 id: string;
259 nexthop: string;
260 prefix: string;
261 priority: number;
262 description?: string | null;
263 weight?: number | null;
264 scope?: {
265 coloNames?: string[] | null;
266 coloRegions?: string[] | null;
267 } | null;
268 createdOn?: string | null;
269 modifiedOn?: string | null;
270}
271
272/**
273 * Read a route by id, mapping "gone" (`RouteNotFound`, Cloudflare error
274 * code 1020) to `undefined`.
275 */
276const getRoute = (accountId: string, routeId: string) =>
277 magicTransit.getRoute({ accountId, routeId }).pipe(
278 Effect.map((r): ObservedRoute | undefined => r.route ?? undefined),
279 Effect.catchTag("RouteNotFound", () => Effect.succeed(undefined)),
280 );
281
282/**
283 * Find a route by its (prefix, nexthop, priority) identity triple.
284 */
285const findRoute = (
286 accountId: string,
287 prefix: string,
288 nexthop: string,
289 priority: number,
290) =>
291 magicTransit
292 .listRoutes({ accountId })
293 .pipe(
294 Effect.map((r): ObservedRoute | undefined =>
295 (r.routes ?? []).find(
296 (route) =>
297 route.prefix === prefix &&
298 route.nexthop === nexthop &&
299 route.priority === priority,
300 ),
301 ),
302 );
303
304const sameList = (
305 a: string[] | null | undefined,
306 b: string[] | undefined,
307): boolean =>
308 [...(a ?? [])].sort().join(",") === [...(b ?? [])].sort().join(",");
309
310const scopeDirty = (
311 observed: ObservedRoute["scope"],
312 desired: MagicStaticRouteScope | undefined,
313): boolean => {
314 if (desired === undefined) return false;
315 return (
316 !sameList(observed?.coloNames, desired.coloNames) ||
317 !sameList(observed?.coloRegions, desired.coloRegions)
318 );
319};
320
321const toAttributes = (
322 route: ObservedRoute,
323 accountId: string,
324): MagicStaticRouteAttributes => ({
325 routeId: route.id,
326 accountId,
327 prefix: route.prefix,
328 nexthop: route.nexthop,
329 priority: route.priority,
330 description: route.description ?? undefined,
331 weight: route.weight ?? undefined,
332 scope: route.scope
333 ? {
334 coloNames: route.scope.coloNames ?? undefined,
335 coloRegions: route.scope.coloRegions ?? undefined,
336 }
337 : undefined,
338 createdOn: route.createdOn ?? undefined,
339 modifiedOn: route.modifiedOn ?? undefined,
340});