1import * as mcn from "@distilled.cloud/cloudflare/magic-cloud-networking";
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.MagicCloudNetworking.OnRamp" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * The cloud provider an on-ramp connects to Magic WAN.
19 */
20export type OnRampCloudType = "AWS" | "AZURE" | "GOOGLE";
21
22/**
23 * Topology of an on-ramp: a single VPC/VNet, or a hub (e.g. AWS Transit
24 * Gateway) with attached VPCs.
25 */
26export type OnRampType = "OnrampTypeSingle" | "OnrampTypeHub";
27
28export interface OnRampProps {
29 /**
30 * The cloud provider this on-ramp connects to.
31 *
32 * Immutable — changing the cloud type triggers a replacement.
33 */
34 cloudType: OnRampCloudType;
35 /**
36 * Topology of the on-ramp: `OnrampTypeSingle` connects one VPC/VNet,
37 * `OnrampTypeHub` provisions a hub (e.g. AWS Transit Gateway) that VPCs
38 * attach to.
39 *
40 * Immutable — changing the topology triggers a replacement.
41 */
42 type: OnRampType;
43 /**
44 * Enables BGP routing. When enabled, set both `installRoutesInCloud` and
45 * `installRoutesInMagicWan` to `false`.
46 *
47 * Immutable — the API offers no way to change it, so a change triggers a
48 * replacement.
49 */
50 dynamicRouting: boolean;
51 /**
52 * Whether Cloudflare installs Magic WAN routes into the cloud route
53 * tables. Mutable.
54 */
55 installRoutesInCloud: boolean;
56 /**
57 * Whether routes to the cloud networks are installed in Magic WAN.
58 * Mutable.
59 */
60 installRoutesInMagicWan: boolean;
61 /**
62 * Human readable name. Used as the on-ramp's identity for cold-state
63 * recovery, so it should be unique within the account. If omitted, a
64 * unique name is generated from the app, stage, and logical ID.
65 * @default ${app}-${stage}-${id}
66 */
67 name?: string;
68 /**
69 * Cloud region the on-ramp is provisioned in (e.g. `us-east-1`).
70 *
71 * Immutable — changing the region triggers a replacement.
72 */
73 region?: string;
74 /**
75 * The discovered VPC/VNet resource id to connect
76 * (`type: "OnrampTypeSingle"`). Mutable.
77 */
78 vpc?: string;
79 /**
80 * Cloud-side ASN for BGP. If unset or zero, the cloud's default ASN
81 * takes effect.
82 *
83 * Immutable — the API offers no way to change it, so a change triggers a
84 * replacement.
85 */
86 cloudAsn?: number;
87 /**
88 * Free-form description of the on-ramp. Mutable.
89 */
90 description?: string;
91 /**
92 * Adopt an existing hub (e.g. an existing Transit Gateway) instead of
93 * provisioning one (`type: "OnrampTypeHub"`).
94 *
95 * Immutable — hub identity cannot change, so a change triggers a
96 * replacement.
97 */
98 adoptedHubId?: string;
99 /**
100 * Cloud integration that owns the hub when it lives in a different
101 * provider account.
102 *
103 * Immutable — hub identity cannot change, so a change triggers a
104 * replacement.
105 */
106 hubProviderId?: string;
107 /**
108 * Hubs attached to this on-ramp. Mutable.
109 */
110 attachedHubs?: string[];
111 /**
112 * VPCs attached to this hub on-ramp (`type: "OnrampTypeHub"`). Mutable.
113 */
114 attachedVpcs?: string[];
115 /**
116 * Whether Cloudflare manages hub-to-hub attachments. Mutable.
117 */
118 manageHubToHubAttachments?: boolean;
119 /**
120 * Whether Cloudflare manages VPC-to-hub attachments. Mutable.
121 */
122 manageVpcToHubAttachments?: boolean;
123 /**
124 * Whether deleting the on-ramp also destroys the cloud-side resources it
125 * provisioned (VPN gateways, Transit Gateway, …). Consumed only at
126 * delete time.
127 * @default false
128 */
129 destroyOnDelete?: boolean;
130}
131
132export interface OnRampAttributes {
133 /** Cloudflare-assigned identifier of the on-ramp. */
134 onRampId: string;
135 /** The Cloudflare account the on-ramp belongs to. */
136 accountId: string;
137 /** Human readable name of the on-ramp. */
138 name: string;
139 /** The cloud provider this on-ramp connects to. */
140 cloudType: OnRampCloudType;
141 /** Topology of the on-ramp. */
142 type: OnRampType;
143 /** Whether BGP routing is enabled. */
144 dynamicRouting: boolean;
145 /** Whether Magic WAN routes are installed into the cloud route tables. */
146 installRoutesInCloud: boolean;
147 /** Whether cloud routes are installed in Magic WAN. */
148 installRoutesInMagicWan: boolean;
149 /** The connected VPC/VNet resource id, if set. */
150 vpc: string | undefined;
151 /** Cloud-side ASN, if set. */
152 cloudAsn: number | undefined;
153 /** Free-form description, if set. */
154 description: string | undefined;
155 /** Hubs attached to this on-ramp. */
156 attachedHubs: string[];
157 /** VPCs attached to this hub on-ramp. */
158 attachedVpcs: string[];
159 /** Whether Cloudflare manages hub-to-hub attachments, if reported. */
160 manageHubToHubAttachments: boolean | undefined;
161 /** Whether Cloudflare manages VPC-to-hub attachments, if reported. */
162 manageVpcToHubAttachments: boolean | undefined;
163 /** ISO8601 timestamp of the last change to the on-ramp. */
164 updatedAt: string;
165 /** Whether delete also destroys provisioned cloud-side resources. */
166 destroyOnDelete: boolean;
167}
168
169export type OnRamp = Resource<
170 TypeId,
171 OnRampProps,
172 OnRampAttributes,
173 never,
174 Providers
175>;
176
177/**
178 * A Magic Cloud Networking on-ramp — connects cloud VPCs/VNets to Magic WAN
179 * by provisioning VPN/Transit-Gateway constructs inside the cloud account
180 * registered via a `CloudIntegration`.
181 *
182 * On-ramps are heavily eventually consistent: after create/update the
183 * on-ramp goes through plan/apply phases that provision real cloud
184 * infrastructure (minutes). This resource creates and patches the on-ramp
185 * configuration and returns immediately; the apply lifecycle is driven by
186 * Cloudflare.
187 *
188 * `name`, `description`, `vpc`, route-installation flags, and attachments
189 * are patched in place; `cloudType`, `type`, `dynamicRouting`, `region`,
190 * `cloudAsn`, and hub identity force a replacement.
191 *
192 * Magic Cloud Networking is an entitlement-gated add-on (Magic WAN family).
193 * On accounts without the entitlement every API call fails with the typed
194 * `FeatureNotEnabled` error (Cloudflare code 1012, "feature not enabled").
195 * ### Connecting a single VPC
196 * **Example:** AWS VPC on-ramp
197 * ```typescript
198 * const onramp = yield* Cloudflare.MagicCloudNetworking.OnRamp("ProdVpc", {
199 * cloudType: "AWS",
200 * type: "OnrampTypeSingle",
201 * region: "us-east-1",
202 * vpc: discoveredVpcId,
203 * dynamicRouting: false,
204 * installRoutesInCloud: true,
205 * installRoutesInMagicWan: true,
206 * });
207 * ```
208 *
209 * ### Hub topologies
210 * **Example:** Transit Gateway hub with attached VPCs
211 * ```typescript
212 * yield* Cloudflare.MagicCloudNetworking.OnRamp("TgwHub", {
213 * cloudType: "AWS",
214 * type: "OnrampTypeHub",
215 * region: "us-east-1",
216 * dynamicRouting: true,
217 * installRoutesInCloud: false,
218 * installRoutesInMagicWan: false,
219 * attachedVpcs: [vpcA, vpcB],
220 * manageVpcToHubAttachments: true,
221 * });
222 * ```
223 *
224 * ### Destroy behavior
225 * **Example:** Tear down cloud-side resources on destroy
226 * ```typescript
227 * yield* Cloudflare.MagicCloudNetworking.OnRamp("ProdVpc", {
228 * cloudType: "AWS",
229 * type: "OnrampTypeSingle",
230 * region: "us-east-1",
231 * vpc: discoveredVpcId,
232 * dynamicRouting: false,
233 * installRoutesInCloud: true,
234 * installRoutesInMagicWan: true,
235 * destroyOnDelete: true,
236 * });
237 * ```
238 *
239 * @see https://developers.cloudflare.com/magic-cloud-networking/
240 *
241 * @resource
242 * @product Magic Cloud Networking
243 * @category Network
244 */
245export const OnRamp = Resource<OnRamp>(TypeId);
246
247/**
248 * Returns true if the given value is an OnRamp resource.
249 */
250export const isOnRamp = (value: unknown): value is OnRamp =>
251 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
252
253export const OnRampProvider = () =>
254 Provider.succeed(OnRamp, {
255 stables: [
256 "onRampId",
257 "accountId",
258 "cloudType",
259 "type",
260 "dynamicRouting",
261 "cloudAsn",
262 ],
263
264 diff: Effect.fn(function* ({ olds, news, output }) {
265 if (!isResolved(news)) return undefined;
266 const old = olds !== undefined && isResolved(olds) ? olds : undefined;
267 // Immutable identity/topology properties — the PATCH body accepts
268 // none of these, so any change is a replacement.
269 const oldCloudType = output?.cloudType ?? old?.cloudType;
270 if (oldCloudType !== undefined && oldCloudType !== news.cloudType) {
271 return { action: "replace" } as const;
272 }
273 const oldType = output?.type ?? old?.type;
274 if (oldType !== undefined && oldType !== news.type) {
275 return { action: "replace" } as const;
276 }
277 const oldDynamicRouting = output?.dynamicRouting ?? old?.dynamicRouting;
278 if (
279 oldDynamicRouting !== undefined &&
280 oldDynamicRouting !== news.dynamicRouting
281 ) {
282 return { action: "replace" } as const;
283 }
284 if (old !== undefined) {
285 if (old.region !== news.region) {
286 return { action: "replace" } as const;
287 }
288 if (old.cloudAsn !== news.cloudAsn) {
289 return { action: "replace" } as const;
290 }
291 if (old.adoptedHubId !== news.adoptedHubId) {
292 return { action: "replace" } as const;
293 }
294 if (old.hubProviderId !== news.hubProviderId) {
295 return { action: "replace" } as const;
296 }
297 }
298 return undefined;
299 }),
300
301 read: Effect.fn(function* ({ id, output, olds }) {
302 const { accountId } = yield* yield* CloudflareEnvironment;
303 const acct = output?.accountId ?? accountId;
304 const destroyOnDelete =
305 output?.destroyOnDelete ?? olds?.destroyOnDelete ?? false;
306
307 // Owned path: refresh by our persisted on-ramp id.
308 if (output?.onRampId) {
309 const observed = yield* getOnRamp(acct, output.onRampId);
310 if (observed) return toAttributes(observed, acct, destroyOnDelete);
311 return undefined;
312 }
313
314 // Cold read — recover from lost state by matching the deterministic
315 // physical name. On-ramps carry no ownership markers, so report a
316 // match as Unowned and let the engine gate adoption.
317 const name = yield* onRampName(id, olds?.name);
318 const match = yield* findByName(acct, name);
319 return match
320 ? Unowned(toAttributes(match, acct, destroyOnDelete))
321 : undefined;
322 }),
323
324 reconcile: Effect.fn(function* ({ id, news, output }) {
325 const { accountId } = yield* yield* CloudflareEnvironment;
326 const name = yield* onRampName(id, news.name);
327 const destroyOnDelete = news.destroyOnDelete ?? false;
328
329 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
330 // a missing on-ramp falls through to the name scan and create.
331 let observed = output?.onRampId
332 ? yield* getOnRamp(output.accountId ?? accountId, output.onRampId)
333 : undefined;
334 if (!observed) {
335 observed = yield* findByName(accountId, name);
336 }
337
338 // 2. Ensure — create when missing. Names are not unique on
339 // Cloudflare's side so there is no AlreadyExists race to tolerate.
340 if (!observed) {
341 observed = yield* mcn.createOnRamp({
342 accountId,
343 name,
344 cloudType: news.cloudType,
345 type: news.type,
346 dynamicRouting: news.dynamicRouting,
347 installRoutesInCloud: news.installRoutesInCloud,
348 installRoutesInMagicWan: news.installRoutesInMagicWan,
349 region: news.region,
350 vpc: news.vpc,
351 cloudAsn: news.cloudAsn,
352 description: news.description,
353 adoptedHubId: news.adoptedHubId,
354 hubProviderId: news.hubProviderId,
355 attachedHubs: news.attachedHubs,
356 attachedVpcs: news.attachedVpcs,
357 manageHubToHubAttachments: news.manageHubToHubAttachments,
358 manageVpcToHubAttachments: news.manageVpcToHubAttachments,
359 });
360 return toAttributes(observed, accountId, destroyOnDelete);
361 }
362
363 // 3. Sync — diff observed cloud state against desired; PATCH only the
364 // delta and skip the call entirely on a no-op.
365 const patch: mcn.PatchOnRampRequest = {
366 accountId,
367 onrampId: observed.id,
368 };
369 let dirty = false;
370 if (observed.name !== name) {
371 patch.name = name;
372 dirty = true;
373 }
374 if (
375 news.description !== undefined &&
376 (observed.description ?? "") !== news.description
377 ) {
378 patch.description = news.description;
379 dirty = true;
380 }
381 if (news.vpc !== undefined && (observed.vpc ?? "") !== news.vpc) {
382 patch.vpc = news.vpc;
383 dirty = true;
384 }
385 if (observed.installRoutesInCloud !== news.installRoutesInCloud) {
386 patch.installRoutesInCloud = news.installRoutesInCloud;
387 dirty = true;
388 }
389 if (observed.installRoutesInMagicWan !== news.installRoutesInMagicWan) {
390 patch.installRoutesInMagicWan = news.installRoutesInMagicWan;
391 dirty = true;
392 }
393 if (
394 news.attachedHubs !== undefined &&
395 !sameIds(observed.attachedHubs ?? [], news.attachedHubs)
396 ) {
397 patch.attachedHubs = news.attachedHubs;
398 dirty = true;
399 }
400 if (
401 news.attachedVpcs !== undefined &&
402 !sameIds(observed.attachedVpcs ?? [], news.attachedVpcs)
403 ) {
404 patch.attachedVpcs = news.attachedVpcs;
405 dirty = true;
406 }
407 if (
408 news.manageHubToHubAttachments !== undefined &&
409 (observed.manageHubToHubAttachments ?? false) !==
410 news.manageHubToHubAttachments
411 ) {
412 patch.manageHubToHubAttachments = news.manageHubToHubAttachments;
413 dirty = true;
414 }
415 if (
416 news.manageVpcToHubAttachments !== undefined &&
417 (observed.manageVpcToHubAttachments ?? false) !==
418 news.manageVpcToHubAttachments
419 ) {
420 patch.manageVpcToHubAttachments = news.manageVpcToHubAttachments;
421 dirty = true;
422 }
423 if (dirty) {
424 observed = yield* mcn.patchOnRamp(patch);
425 }
426
427 return toAttributes(observed, accountId, destroyOnDelete);
428 }),
429
430 delete: Effect.fn(function* ({ output }) {
431 // Deleting an on-ramp tears down cloud-side infrastructure when
432 // `destroy` is set — slow and eventually consistent on Cloudflare's
433 // side, but the DELETE call itself returns promptly.
434 yield* mcn
435 .deleteOnRamp({
436 accountId: output.accountId,
437 onrampId: output.onRampId,
438 destroy: output.destroyOnDelete,
439 })
440 .pipe(Effect.catchTag("OnRampNotFound", () => Effect.void));
441 }),
442
443 list: Effect.fn(function* () {
444 const { accountId } = yield* yield* CloudflareEnvironment;
445 // Account-scoped collection: exhaustively paginate the on-ramp list,
446 // then hydrate each id into the exact `read` Attributes shape via
447 // `getOnRamp`. The list item omits some fields (`vpc`, `region`), so
448 // a per-item get is required for a fully-faithful Attributes object.
449 // Magic Cloud Networking is an entitlement-gated add-on; on accounts
450 // without it every call fails with `FeatureNotEnabled` — treat that
451 // as a non-listable account and return `[]`.
452 const ids = yield* mcn.listOnRamps.items({ accountId }).pipe(
453 Stream.map((onramp) => onramp.id),
454 Stream.runCollect,
455 Effect.map((chunk) => Array.from(chunk)),
456 Effect.catchTag("FeatureNotEnabled", () =>
457 Effect.succeed([] as string[]),
458 ),
459 );
460 const rows = yield* Effect.forEach(
461 ids,
462 (onRampId) =>
463 getOnRamp(accountId, onRampId).pipe(
464 Effect.map((observed) =>
465 observed ? toAttributes(observed, accountId, false) : undefined,
466 ),
467 ),
468 { concurrency: 10 },
469 );
470 return rows.filter((row): row is OnRampAttributes => row !== undefined);
471 }),
472 });
473
474type ObservedOnRamp = Pick<
475 mcn.GetOnRampResponse,
476 | "id"
477 | "name"
478 | "cloudType"
479 | "type"
480 | "dynamicRouting"
481 | "installRoutesInCloud"
482 | "installRoutesInMagicWan"
483 | "vpc"
484 | "cloudAsn"
485 | "description"
486 | "attachedHubs"
487 | "attachedVpcs"
488 | "manageHubToHubAttachments"
489 | "manageVpcToHubAttachments"
490 | "updatedAt"
491 | "region"
492>;
493
494/**
495 * Read an on-ramp by id, mapping "gone" (`OnRampNotFound`) to `undefined`.
496 */
497const getOnRamp = (accountId: string, onrampId: string) =>
498 mcn.getOnRamp({ accountId, onrampId }).pipe(
499 Effect.map((onramp): ObservedOnRamp | undefined => onramp),
500 Effect.catchTag("OnRampNotFound", () => Effect.succeed(undefined)),
501 );
502
503/**
504 * Find an on-ramp by exact name. Names are not unique on Cloudflare's side;
505 * if several on-ramps carry the same name, pick the lexicographically-first
506 * id for determinism.
507 */
508const findByName = (accountId: string, name: string) =>
509 mcn.listOnRamps.items({ accountId }).pipe(
510 Stream.runCollect,
511 Effect.map((chunk) =>
512 Array.from(chunk)
513 .filter((onramp) => onramp.name === name)
514 .sort((a, b) => a.id.localeCompare(b.id))
515 .at(0),
516 ),
517 );
518
519const onRampName = (id: string, name: string | undefined) =>
520 Effect.gen(function* () {
521 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
522 });
523
524const sameIds = (observed: readonly string[], desired: readonly string[]) =>
525 observed.length === desired.length &&
526 [...observed].sort().join(",") === [...desired].sort().join(",");
527
528const toAttributes = (
529 onramp: ObservedOnRamp,
530 accountId: string,
531 destroyOnDelete: boolean,
532): OnRampAttributes => ({
533 onRampId: onramp.id,
534 accountId,
535 name: onramp.name,
536 // Distilled widens generated string enums to open unions (`string & {}`).
537 cloudType: onramp.cloudType as OnRampCloudType,
538 type: onramp.type as OnRampType,
539 dynamicRouting: onramp.dynamicRouting,
540 installRoutesInCloud: onramp.installRoutesInCloud,
541 installRoutesInMagicWan: onramp.installRoutesInMagicWan,
542 vpc: onramp.vpc ?? undefined,
543 cloudAsn: onramp.cloudAsn ?? undefined,
544 description: onramp.description ?? undefined,
545 attachedHubs: [...(onramp.attachedHubs ?? [])],
546 attachedVpcs: [...(onramp.attachedVpcs ?? [])],
547 manageHubToHubAttachments: onramp.manageHubToHubAttachments ?? undefined,
548 manageVpcToHubAttachments: onramp.manageVpcToHubAttachments ?? undefined,
549 updatedAt: onramp.updatedAt,
550 destroyOnDelete,
551});