1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.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.Tunnel.VirtualNetwork" as const;
15type TypeId = typeof TypeId;
16
17export interface VirtualNetworkProps {
18 /**
19 * User-friendly name for the virtual network. Names are unique per
20 * account, which makes the name the resource's identity during adoption
21 * and state recovery. If omitted, a unique name is generated from the
22 * app, stage, and logical ID.
23 *
24 * @default ${app}-${stage}-${id}
25 */
26 name?: string;
27 /**
28 * Optional remark describing the virtual network. Mutable — patched
29 * in place.
30 *
31 * @default ""
32 */
33 comment?: string;
34 /**
35 * If `true`, this virtual network is the default for the account. Only
36 * one default exists per account — promoting a network demotes the
37 * previous default. Mutable — patched in place.
38 *
39 * @default false
40 */
41 isDefaultNetwork?: boolean;
42}
43
44export interface VirtualNetworkAttributes {
45 /** UUID of the virtual network, assigned by Cloudflare. */
46 virtualNetworkId: string;
47 /** Cloudflare account that owns the virtual network. */
48 accountId: string;
49 /** User-friendly name of the virtual network. */
50 name: string;
51 /** Remark describing the virtual network. */
52 comment: string;
53 /** Whether this virtual network is the account default. */
54 isDefaultNetwork: boolean;
55 /** RFC 3339 timestamp of when the virtual network was created. */
56 createdAt: string;
57}
58
59export type VirtualNetwork = Resource<
60 TypeId,
61 VirtualNetworkProps,
62 VirtualNetworkAttributes,
63 never,
64 Providers
65>;
66
67/**
68 * A Cloudflare Zero Trust Virtual Network — an isolated routing namespace
69 * for Cloudflare Tunnel private networks.
70 *
71 * Virtual networks let you run overlapping CIDR ranges side by side: each
72 * {@link Route} can target a `virtualNetworkId`, and WARP clients
73 * switch between virtual networks to choose which copy of `10.0.0.0/8`
74 * they see. Every account starts with a single `default` virtual network.
75 *
76 * Name and comment are mutable in place. Deleting a virtual network
77 * requires that no routes reference it — express that relationship by
78 * passing `vnet.virtualNetworkId` into your `Route`s so destroy
79 * ordering is correct.
80 * ### Creating a Virtual Network
81 * **Example:** Basic virtual network
82 * ```typescript
83 * const vnet = yield* Cloudflare.Tunnel.VirtualNetwork("Staging", {
84 * comment: "staging private network",
85 * });
86 * ```
87 *
88 * **Example:** Route a tunnel CIDR through the virtual network
89 * ```typescript
90 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
91 * yield* Cloudflare.Tunnel.Route("StagingNet", {
92 * tunnelId: tunnel.tunnelId,
93 * network: "10.4.0.0/16",
94 * virtualNetworkId: vnet.virtualNetworkId,
95 * });
96 * ```
97 *
98 * ### Default network
99 * **Example:** Promote a virtual network to the account default
100 * ```typescript
101 * // Only one default per account — promoting demotes the previous one.
102 * const vnet = yield* Cloudflare.Tunnel.VirtualNetwork("Primary", {
103 * isDefaultNetwork: true,
104 * });
105 * ```
106 *
107 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/cloudflared/tunnel-virtual-networks/
108 *
109 * @resource
110 * @product Tunnels
111 * @category Cloudflare One (Zero Trust)
112 */
113export const VirtualNetwork = Resource<VirtualNetwork>(TypeId);
114
115/**
116 * Returns true if the given value is a VirtualNetwork resource.
117 */
118export const isVirtualNetwork = (value: unknown): value is VirtualNetwork =>
119 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
120
121export const VirtualNetworkProvider = () =>
122 Provider.succeed(VirtualNetwork, {
123 stables: ["virtualNetworkId", "accountId", "createdAt"],
124
125 diff: Effect.fn(function* ({ output }) {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 if ((output?.accountId ?? accountId) !== accountId) {
128 return { action: "replace" } as const;
129 }
130 // name / comment / isDefaultNetwork all converge via PATCH.
131 return undefined;
132 }),
133
134 read: Effect.fn(function* ({ id, output, olds }) {
135 const { accountId } = yield* yield* CloudflareEnvironment;
136 const acct = output?.accountId ?? accountId;
137
138 // Owned path — refresh by the cached id.
139 if (output?.virtualNetworkId) {
140 const observed = yield* getVnet(acct, output.virtualNetworkId);
141 if (observed) return toAttributes(observed, acct);
142 }
143
144 // Cold read — names are unique per account, so an exact name match
145 // is the resource's identity. Virtual networks carry no ownership
146 // markers, so report the match as Unowned to gate adoption.
147 const name = yield* resolveName(id, olds?.name ?? output?.name);
148 const match = yield* findByName(acct, name);
149 if (match) return Unowned(toAttributes(match, acct));
150 return undefined;
151 }),
152
153 reconcile: Effect.fn(function* ({ id, news, output }) {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155 const name = yield* resolveName(id, news.name);
156
157 // 1. Observe — the cached id is a hint, not a guarantee.
158 let observed = output?.virtualNetworkId
159 ? yield* getVnet(accountId, output.virtualNetworkId)
160 : undefined;
161 if (!observed) {
162 observed = yield* findByName(accountId, name);
163 }
164
165 // 2. Ensure — create when missing. Names are unique per account, so
166 // a racing create surfaces as VirtualNetworkConflict (code 1014):
167 // converge by re-reading the network that won the race.
168 if (!observed) {
169 const created = yield* zeroTrust
170 .createNetworkVirtualNetwork({
171 accountId,
172 name,
173 comment: news.comment,
174 isDefaultNetwork: news.isDefaultNetwork,
175 })
176 .pipe(
177 Effect.catchTag("VirtualNetworkConflict", (error) =>
178 findByName(accountId, name).pipe(
179 Effect.flatMap((existing) =>
180 existing ? Effect.succeed(existing) : Effect.fail(error),
181 ),
182 ),
183 ),
184 );
185 observed = created;
186 }
187
188 // 3. Sync — diff observed cloud state against desired and PATCH only
189 // the delta; skip the call entirely on a no-op. `isDefaultNetwork`
190 // is never patched to false here — demotion happens implicitly
191 // when another network is promoted (Cloudflare rejects an explicit
192 // demote of the only default).
193 const desired = {
194 name,
195 comment: news.comment ?? observed.comment,
196 isDefaultNetwork: news.isDefaultNetwork ?? observed.isDefaultNetwork,
197 };
198 const dirty =
199 observed.name !== desired.name ||
200 observed.comment !== desired.comment ||
201 observed.isDefaultNetwork !== desired.isDefaultNetwork;
202 if (dirty) {
203 observed = yield* zeroTrust.patchNetworkVirtualNetwork({
204 accountId,
205 virtualNetworkId: observed.id,
206 name: desired.name,
207 comment: desired.comment,
208 // Only send the promote; never an explicit demote.
209 ...(desired.isDefaultNetwork && !observed.isDefaultNetwork
210 ? { isDefaultNetwork: true }
211 : {}),
212 });
213 }
214
215 return toAttributes(observed, accountId);
216 }),
217
218 delete: Effect.fn(function* ({ output }) {
219 // Deleting a vnet with routes still attached fails — routes that
220 // reference `virtualNetworkId` as an Input get correct destroy
221 // ordering, so by the time we run the routes are gone. A missing
222 // vnet (VirtualNetworkNotFound, code 1046) means we're done.
223 yield* zeroTrust
224 .deleteNetworkVirtualNetwork({
225 accountId: output.accountId,
226 virtualNetworkId: output.virtualNetworkId,
227 })
228 .pipe(Effect.catchTag("VirtualNetworkNotFound", () => Effect.void));
229 }),
230
231 // Account-scoped collection: virtual networks are listed per account.
232 // Exhaustively paginate and filter out soft-deleted networks to match
233 // `read`/`findByName`, hydrating each into the `read` Attributes shape.
234 list: Effect.fn(function* () {
235 const { accountId } = yield* yield* CloudflareEnvironment;
236 return yield* zeroTrust.listNetworkVirtualNetworks
237 .pages({ accountId, isDeleted: false })
238 .pipe(
239 Stream.runCollect,
240 Effect.map((chunk) =>
241 Array.from(chunk).flatMap((page) =>
242 (page.result ?? [])
243 .filter((v) => !v.deletedAt)
244 // The account's default virtual network can't be deleted
245 // ("it is the default virtual network"); never enumerate it
246 // for account-wide teardown.
247 .filter((v) => !v.isDefaultNetwork)
248 .map((v): VirtualNetworkAttributes => ({
249 virtualNetworkId: v.id,
250 accountId,
251 name: v.name,
252 comment: v.comment,
253 isDefaultNetwork: v.isDefaultNetwork,
254 createdAt: v.createdAt,
255 })),
256 ),
257 ),
258 );
259 }),
260 });
261
262type ObservedVnet = zeroTrust.GetNetworkVirtualNetworkResponse;
263
264/**
265 * Read a virtual network by id, mapping "gone" (`VirtualNetworkNotFound`,
266 * Cloudflare error code 1046) and soft-deleted networks to `undefined`.
267 */
268const getVnet = (accountId: string, virtualNetworkId: string) =>
269 zeroTrust.getNetworkVirtualNetwork({ accountId, virtualNetworkId }).pipe(
270 Effect.map((v): ObservedVnet | undefined => (v.deletedAt ? undefined : v)),
271 Effect.catchTag("VirtualNetworkNotFound", () => Effect.succeed(undefined)),
272 );
273
274/**
275 * Find a live virtual network by exact name. Names are unique per account
276 * so at most one live network can match.
277 */
278const findByName = (accountId: string, name: string) =>
279 zeroTrust.listNetworkVirtualNetworks
280 .items({ accountId, name, isDeleted: false })
281 .pipe(
282 Stream.filter((v): v is ObservedVnet => v.name === name && !v.deletedAt),
283 Stream.runHead,
284 Effect.map(Option.getOrUndefined),
285 );
286
287const resolveName = (id: string, name: string | undefined) =>
288 Effect.gen(function* () {
289 if (name) return name;
290 return yield* createPhysicalName({ id, lowercase: true });
291 });
292
293const toAttributes = (
294 vnet: ObservedVnet,
295 accountId: string,
296): VirtualNetworkAttributes => ({
297 virtualNetworkId: vnet.id,
298 accountId,
299 name: vnet.name,
300 comment: vnet.comment,
301 isDefaultNetwork: vnet.isDefaultNetwork,
302 createdAt: vnet.createdAt,
303});