1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.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.MagicTransit.SiteWan" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Static addressing configuration for a Magic WAN site WAN — omit to use
19 * DHCP. Submit `secondaryAddress` when the site is in high availability
20 * mode.
21 */
22export interface MagicSiteWanStaticAddressing {
23 /** A valid CIDR notation representing the WAN address. */
24 address: string;
25 /** A valid IPv4 address for the WAN gateway. */
26 gatewayAddress: string;
27 /** Secondary address, required when the site is in HA mode. */
28 secondaryAddress?: string;
29}
30
31export interface MagicSiteWanProps {
32 /**
33 * The site this WAN belongs to. Changing it triggers a replacement.
34 */
35 siteId: string;
36 /**
37 * The physical port number on the connector this WAN is attached to.
38 */
39 physport: number;
40 /**
41 * The name of the WAN. If omitted, a unique name is generated from the
42 * app, stage, and logical ID.
43 * @default ${app}-${stage}-${id}
44 */
45 name?: string;
46 /**
47 * Priority of the WAN for traffic load balancing. Lower is preferred.
48 */
49 priority?: number;
50 /**
51 * VLAN ID. Use zero for untagged.
52 * @default 0
53 */
54 vlanTag?: number;
55 /**
56 * Static addressing configuration; omit to use DHCP.
57 */
58 staticAddressing?: MagicSiteWanStaticAddressing;
59}
60
61export interface MagicSiteWanAttributes {
62 /** Cloudflare-assigned identifier of the WAN. */
63 wanId: string;
64 /** The site the WAN belongs to. */
65 siteId: string;
66 /** The Cloudflare account the WAN belongs to. */
67 accountId: string;
68 /** The name of the WAN. */
69 name: string;
70 /** The physical port number. */
71 physport: number | undefined;
72 /** Priority of the WAN for traffic load balancing. */
73 priority: number | undefined;
74 /** The VLAN ID (zero for untagged). */
75 vlanTag: number | undefined;
76 /** Magic WAN health-check rate for tunnels created on this link. */
77 healthCheckRate: string | undefined;
78}
79
80export type MagicSiteWan = Resource<
81 TypeId,
82 MagicSiteWanProps,
83 MagicSiteWanAttributes,
84 never,
85 Providers
86>;
87
88/**
89 * A WAN attached to a Magic WAN site — describes an uplink on a Magic WAN
90 * Connector port (addressing, VLAN, load-balancing priority). Cloudflare
91 * automatically creates IPsec tunnels over each WAN.
92 *
93 * Requires a Magic WAN subscription — accounts without it receive a typed
94 * `MagicWanUnauthorized` error (Cloudflare code 1025).
95 *
96 * `siteId` is create-only — changing it triggers a replacement. Everything
97 * else is updated in place.
98 * ### Creating a WAN
99 * **Example:** DHCP uplink
100 * ```typescript
101 * const wan = yield* Cloudflare.MagicTransit.MagicSiteWan("hq-wan", {
102 * siteId: site.siteId,
103 * physport: 1,
104 * });
105 * ```
106 *
107 * **Example:** Static uplink with priority
108 * ```typescript
109 * const wan = yield* Cloudflare.MagicTransit.MagicSiteWan("hq-wan", {
110 * siteId: site.siteId,
111 * physport: 1,
112 * priority: 10,
113 * staticAddressing: {
114 * address: "203.0.113.10/24",
115 * gatewayAddress: "203.0.113.1",
116 * },
117 * });
118 * ```
119 *
120 * @see https://developers.cloudflare.com/magic-wan/configuration/connector/
121 *
122 * @resource
123 * @product Magic Transit
124 * @category Network
125 */
126export const MagicSiteWan = Resource<MagicSiteWan>(TypeId);
127
128/**
129 * Returns true if the given value is a MagicSiteWan resource.
130 */
131export const isMagicSiteWan = (value: unknown): value is MagicSiteWan =>
132 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
133
134export const MagicSiteWanProvider = () =>
135 Provider.succeed(MagicSiteWan, {
136 stables: ["wanId", "siteId", "accountId"],
137
138 diff: Effect.fn(function* ({ olds, news }) {
139 if (!isResolved(news)) return undefined;
140 if (olds === undefined) return undefined;
141 // WANs cannot move between sites.
142 if (
143 typeof olds.siteId === "string" &&
144 typeof news.siteId === "string" &&
145 olds.siteId !== news.siteId
146 ) {
147 return { action: "replace" } as const;
148 }
149 return undefined;
150 }),
151
152 read: Effect.fn(function* ({ id, output, olds }) {
153 const { accountId } = yield* yield* CloudflareEnvironment;
154 const acct = output?.accountId ?? accountId;
155 const siteId =
156 output?.siteId ??
157 (typeof olds?.siteId === "string" ? olds.siteId : undefined);
158 if (!siteId) return undefined;
159
160 if (output?.wanId) {
161 const observed = yield* getWan(acct, siteId, output.wanId);
162 if (observed) return toAttributes(observed, siteId, acct);
163 }
164 // Cold read — match the deterministic physical name. WANs carry no
165 // ownership markers; report as Unowned so takeover is gated behind
166 // the adopt policy.
167 const name = yield* createWanName(id, olds?.name);
168 const observed = yield* findByName(acct, siteId, name);
169 if (observed) return Unowned(toAttributes(observed, siteId, acct));
170 return undefined;
171 }),
172
173 reconcile: Effect.fn(function* ({ id, news, output }) {
174 const { accountId } = yield* yield* CloudflareEnvironment;
175 // Inputs have been resolved to concrete strings by Plan.
176 const siteId = news.siteId as string;
177 const name = yield* createWanName(id, news.name);
178
179 // Observe — the id on `output` is a hint; fall back to a name scan.
180 let observed = output?.wanId
181 ? yield* getWan(accountId, siteId, output.wanId)
182 : undefined;
183 if (!observed) {
184 observed = yield* findByName(accountId, siteId, name);
185 }
186
187 // Ensure — create when missing. The create API returns the site's
188 // WAN list; pick the created WAN by name.
189 if (!observed) {
190 const created = yield* magicTransit.createSiteWan({
191 accountId,
192 siteId,
193 name,
194 physport: news.physport,
195 priority: news.priority,
196 vlanTag: news.vlanTag,
197 staticAddressing: news.staticAddressing,
198 });
199 observed =
200 created.result.find((wan) => wan.name === name) ??
201 created.result.at(0) ??
202 (yield* findByName(accountId, siteId, name));
203 if (observed) return toAttributes(observed, siteId, accountId);
204 return yield* Effect.fail(
205 new Error(`Magic WAN site WAN ${name} not visible after create`),
206 );
207 }
208
209 // Sync — the update API is a PUT; send the full desired state, but
210 // skip the call entirely on a no-op of the observable fields.
211 const dirty =
212 (observed.name ?? undefined) !== name ||
213 (observed.physport ?? undefined) !== news.physport ||
214 (news.priority !== undefined &&
215 (observed.priority ?? undefined) !== news.priority) ||
216 (news.vlanTag !== undefined &&
217 (observed.vlanTag ?? undefined) !== news.vlanTag) ||
218 staticAddressingDirty(observed.staticAddressing, news.staticAddressing);
219 if (dirty) {
220 const updated = yield* magicTransit.updateSiteWan({
221 accountId,
222 siteId,
223 wanId: observed.id!,
224 name,
225 physport: news.physport,
226 priority: news.priority,
227 vlanTag: news.vlanTag,
228 staticAddressing: news.staticAddressing,
229 });
230 observed = { ...updated, id: updated.id ?? observed.id };
231 }
232
233 return toAttributes(observed, siteId, accountId);
234 }),
235
236 delete: Effect.fn(function* ({ output }) {
237 yield* magicTransit
238 .deleteSiteWan({
239 accountId: output.accountId,
240 siteId: output.siteId,
241 wanId: output.wanId,
242 })
243 .pipe(Effect.catchTag("SiteWanNotFound", () => Effect.void));
244 }),
245
246 // Parent fan-out: WANs are sub-resources keyed by site, and there is no
247 // account-wide WAN enumeration API. Enumerate every Magic site (account
248 // scope), then list WANs per site with bounded concurrency, paginating
249 // each list exhaustively. Magic WAN-gated accounts (and partial-scope
250 // tokens) reject these routes with the typed `MagicWanUnauthorized`
251 // (code 1025) / `Forbidden` tags — treat those as "nothing to list".
252 list: Effect.fn(function* () {
253 const { accountId } = yield* yield* CloudflareEnvironment;
254
255 const siteIds = yield* magicTransit.listSites.pages({ accountId }).pipe(
256 Stream.runCollect,
257 Effect.map((chunk) =>
258 Array.from(chunk).flatMap((page) =>
259 (page.result ?? []).flatMap((site) => (site.id ? [site.id] : [])),
260 ),
261 ),
262 Effect.catchTag(["MagicWanUnauthorized", "Forbidden"], () =>
263 Effect.succeed([] as string[]),
264 ),
265 );
266
267 const rows = yield* Effect.forEach(
268 siteIds,
269 (siteId) =>
270 magicTransit.listSiteWans.pages({ accountId, siteId }).pipe(
271 Stream.runCollect,
272 Effect.map((chunk) =>
273 Array.from(chunk).flatMap((page) =>
274 (page.result ?? []).map((wan) =>
275 toAttributes(wan, siteId, accountId),
276 ),
277 ),
278 ),
279 // Site vanished or became inaccessible mid-enumeration — skip it.
280 Effect.catchTag(["MagicWanUnauthorized", "Forbidden"], () =>
281 Effect.succeed([] as MagicSiteWanAttributes[]),
282 ),
283 ),
284 { concurrency: 10 },
285 );
286
287 return rows.flat();
288 }),
289 });
290
291interface ObservedWan {
292 id?: string | null;
293 name?: string | null;
294 physport?: number | null;
295 priority?: number | null;
296 vlanTag?: number | null;
297 healthCheckRate?: string | null;
298 staticAddressing?: {
299 address: string;
300 gatewayAddress: string;
301 secondaryAddress?: string | null;
302 } | null;
303}
304
305/**
306 * Read a WAN by id, mapping "gone" (`SiteWanNotFound`, HTTP 404) to
307 * `undefined`.
308 */
309const getWan = (accountId: string, siteId: string, wanId: string) =>
310 magicTransit.getSiteWan({ accountId, siteId, wanId }).pipe(
311 Effect.map((wan): ObservedWan | undefined => wan),
312 Effect.catchTag("SiteWanNotFound", () => Effect.succeed(undefined)),
313 );
314
315/**
316 * Find a WAN by exact name within a site. Names are not enforced unique;
317 * pick the first match deterministically by id.
318 */
319const findByName = (accountId: string, siteId: string, name: string) =>
320 magicTransit.listSiteWans({ accountId, siteId }).pipe(
321 Effect.map((r): ObservedWan | undefined =>
322 r.result
323 .filter((wan) => wan.name === name)
324 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
325 .at(0),
326 ),
327 );
328
329const createWanName = (id: string, name: string | undefined) =>
330 Effect.gen(function* () {
331 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
332 });
333
334const staticAddressingDirty = (
335 observed: ObservedWan["staticAddressing"],
336 desired: MagicSiteWanStaticAddressing | undefined,
337): boolean => {
338 if (desired === undefined) return false;
339 return (
340 (observed?.address ?? undefined) !== desired.address ||
341 (observed?.gatewayAddress ?? undefined) !== desired.gatewayAddress ||
342 (desired.secondaryAddress !== undefined &&
343 (observed?.secondaryAddress ?? undefined) !== desired.secondaryAddress)
344 );
345};
346
347const toAttributes = (
348 wan: ObservedWan,
349 siteId: string,
350 accountId: string,
351): MagicSiteWanAttributes => ({
352 wanId: wan.id ?? "",
353 siteId,
354 accountId,
355 name: wan.name ?? "",
356 physport: wan.physport ?? undefined,
357 priority: wan.priority ?? undefined,
358 vlanTag: wan.vlanTag ?? undefined,
359 healthCheckRate: wan.healthCheckRate ?? undefined,
360});