1import * as mnm from "@distilled.cloud/cloudflare/magic-network-monitoring";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10
11const TypeId = "Cloudflare.MagicNetworkMonitoring.Config" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * A WARP device registered as a flow-data source on the MNM config.
16 */
17export interface WarpDevice {
18 /**
19 * Unique identifier of the WARP device.
20 */
21 id: string;
22 /**
23 * Display name of the WARP device.
24 */
25 name: string;
26 /**
27 * IPv4 address the WARP device sends flow data from.
28 */
29 routerIp: string;
30}
31
32export interface ConfigProps {
33 /**
34 * The account name label stored on the MNM configuration. Freeform —
35 * mutable in place.
36 */
37 name: string;
38 /**
39 * Fallback sampling rate of flow messages being sent in packets per
40 * second. This should match the packet sampling rate configured on the
41 * router. Minimum of 1. Mutable in place.
42 */
43 defaultSampling: number;
44 /**
45 * IPv4 CIDR addresses (`/32`) of the routers that send flow data to
46 * Cloudflare. Registering router flow sources requires the Magic Network
47 * Monitoring router-flow entitlement — accounts without it reject any
48 * non-empty value with the typed `InvalidMnmConfig` error (Cloudflare
49 * code 1003).
50 * @default []
51 */
52 routerIps?: string[];
53 /**
54 * WARP devices registered as flow-data sources.
55 * @default []
56 */
57 warpDevices?: WarpDevice[];
58}
59
60export interface ConfigAttributes {
61 /** The Cloudflare account the configuration belongs to. */
62 accountId: string;
63 /** The account name label stored on the configuration. */
64 name: string;
65 /** Sampling rate of flow messages in packets per second. */
66 defaultSampling: number;
67 /** Router IPs registered as flow-data sources. */
68 routerIps: string[];
69 /** WARP devices registered as flow-data sources. */
70 warpDevices: WarpDevice[];
71}
72
73export type Config = Resource<
74 TypeId,
75 ConfigProps,
76 ConfigAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * The Magic Network Monitoring (MNM) account configuration — the singleton
83 * that registers your network's routers (and optionally WARP devices) as
84 * flow-data sources and sets the fallback packet sampling rate.
85 *
86 * There is exactly one MNM configuration per Cloudflare account, and MNM
87 * rules cannot be created until it exists. Creating a second configuration
88 * fails (`MnmConfigAlreadyExists`), so reconcile tolerates the race by
89 * falling through to an update. When the engine has no prior state but a
90 * configuration already exists on the account, `read` reports it as
91 * `Unowned` and takeover is gated behind `--adopt`.
92 * ### Creating the configuration
93 * **Example:** Minimal configuration
94 * ```typescript
95 * const config = yield* Cloudflare.MagicNetworkMonitoring.Config("Mnm", {
96 * name: "my-network",
97 * defaultSampling: 1,
98 * });
99 * ```
100 *
101 * **Example:** Configuration with router IPs
102 * ```typescript
103 * const config = yield* Cloudflare.MagicNetworkMonitoring.Config("Mnm", {
104 * name: "my-network",
105 * defaultSampling: 100,
106 * routerIps: ["203.0.113.1/32"],
107 * });
108 * ```
109 *
110 * ### Rules depend on the configuration
111 * **Example:** Create the config before any rules
112 * ```typescript
113 * const config = yield* Cloudflare.MagicNetworkMonitoring.Config("Mnm", {
114 * name: "my-network",
115 * defaultSampling: 1,
116 * });
117 * // Reference an output attribute so the rule deploys after the config.
118 * yield* Cloudflare.MagicNetworkMonitoring.Rule("VolumetricAlert", {
119 * accountId: config.accountId,
120 * type: "threshold",
121 * prefixes: ["10.0.0.0/24"],
122 * bandwidthThreshold: 1_000_000,
123 * });
124 * ```
125 *
126 * @see https://developers.cloudflare.com/magic-network-monitoring/
127 *
128 * @resource
129 * @product Magic Network Monitoring
130 * @category Network
131 */
132export const Config = Resource<Config>(TypeId);
133
134/**
135 * Returns true if the given value is a Config resource.
136 */
137export const isConfig = (value: unknown): value is Config =>
138 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
139
140export const ConfigProvider = () =>
141 Provider.succeed(Config, {
142 stables: ["accountId"],
143
144 diff: Effect.fn(function* ({ output }) {
145 const { accountId } = yield* yield* CloudflareEnvironment;
146 // The config is an account singleton — moving accounts replaces it.
147 if (output !== undefined && output.accountId !== accountId) {
148 return { action: "replace" } as const;
149 }
150 return undefined;
151 }),
152
153 read: Effect.fn(function* ({ output }) {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155 const acct = output?.accountId ?? accountId;
156 // A missing config answers HTTP 200 with `result: null` rather than
157 // a 404 — `observe` maps that to `undefined`.
158 const observed = observe(yield* mnm.getConfig({ accountId: acct }));
159 if (observed === undefined) return undefined;
160 // The config carries no ownership markers. With no prior state we
161 // cannot prove we created it — report `Unowned` so the engine gates
162 // takeover behind the adopt policy.
163 const attributes = toAttributes(observed, acct);
164 return output === undefined ? Unowned(attributes) : attributes;
165 }),
166
167 reconcile: Effect.fn(function* ({ news }) {
168 const { accountId } = yield* yield* CloudflareEnvironment;
169 const desired = {
170 accountId,
171 name: news.name,
172 defaultSampling: news.defaultSampling,
173 // Inputs have been resolved to concrete strings by Plan.
174 routerIps: (news.routerIps ?? []) as string[],
175 warpDevices: news.warpDevices ?? [],
176 };
177
178 // 1. Observe — a missing singleton answers `result: null` and maps
179 // to `undefined`.
180 const observed = observe(yield* mnm.getConfig({ accountId }));
181
182 // 2. Ensure — create when missing, converging a concurrent create
183 // (`MnmConfigAlreadyExists`, Cloudflare code 1005) into the
184 // update below.
185 if (observed === undefined) {
186 const created = observe(
187 yield* mnm
188 .createConfig(desired)
189 .pipe(
190 Effect.catchTag("MnmConfigAlreadyExists", () =>
191 mnm.updateConfig(desired),
192 ),
193 ),
194 );
195 // `updateConfig` answers `result: null` if the config vanished
196 // again mid-flight — fall back to one fresh create.
197 if (created !== undefined) return toAttributes(created, accountId);
198 const recreated = yield* mnm.createConfig(desired);
199 return toAttributes(observe(recreated) ?? desired, accountId);
200 }
201
202 // 3. Sync — diff observed cloud state against desired; the update
203 // is a full-body PUT, so skip the call entirely on a no-op.
204 const dirty =
205 observed.name !== desired.name ||
206 observed.defaultSampling !== desired.defaultSampling ||
207 !sameStrings(observed.routerIps, desired.routerIps) ||
208 !sameWarpDevices(observed.warpDevices, desired.warpDevices);
209 if (!dirty) return toAttributes(observed, accountId);
210
211 const updated = observe(yield* mnm.updateConfig(desired));
212 if (updated !== undefined) return toAttributes(updated, accountId);
213 // Deleted out-of-band between observe and update — recreate.
214 const recreated = yield* mnm.createConfig(desired);
215 return toAttributes(observe(recreated) ?? desired, accountId);
216 }),
217
218 delete: Effect.fn(function* ({ output }) {
219 yield* mnm.deleteConfig({ accountId: output.accountId }).pipe(
220 // Already gone (`MnmConfigNotFound`, Cloudflare code 1004).
221 Effect.catchTag("MnmConfigNotFound", () => Effect.void),
222 );
223 }),
224
225 // Account singleton — there is exactly one MNM config per account and
226 // only a per-account `getConfig`. Mirror `read`: return the one-element
227 // array when the config exists, `[]` when it is unset (HTTP 200 with
228 // `result: null`, which `observe` maps to `undefined`).
229 list: Effect.fn(function* () {
230 const { accountId } = yield* yield* CloudflareEnvironment;
231 const observed = observe(yield* mnm.getConfig({ accountId }));
232 return observed === undefined ? [] : [toAttributes(observed, accountId)];
233 }),
234 });
235
236/**
237 * A normalized, definitely-present MNM configuration.
238 */
239interface ObservedConfig {
240 name: string;
241 defaultSampling: number;
242 routerIps: readonly string[];
243 warpDevices: readonly WarpDevice[];
244}
245
246/**
247 * Normalize a config response to a definitely-present shape, mapping
248 * "missing" to `undefined`. Cloudflare answers HTTP 200 with
249 * `result: null` when no config exists; the distilled core client decodes
250 * that as an empty object, so a config without a `name` (a required field
251 * on every real config) means there is no config.
252 */
253const observe = (
254 config: mnm.GetConfigResponse | mnm.UpdateConfigResponse,
255): ObservedConfig | undefined => {
256 if (config === null) return undefined;
257 const { name, defaultSampling } = config;
258 if (name == null || defaultSampling == null) return undefined;
259 return {
260 name,
261 defaultSampling,
262 routerIps: config.routerIps ?? [],
263 warpDevices: config.warpDevices ?? [],
264 };
265};
266
267const sameStrings = (observed: readonly string[], desired: readonly string[]) =>
268 observed.length === desired.length &&
269 [...observed].sort().join(",") === [...desired].sort().join(",");
270
271const sameWarpDevices = (
272 observed: readonly WarpDevice[],
273 desired: readonly WarpDevice[],
274) =>
275 observed.length === desired.length &&
276 serializeWarpDevices(observed) === serializeWarpDevices(desired);
277
278const serializeWarpDevices = (devices: readonly WarpDevice[]) =>
279 devices
280 .map((d) => `${d.id}|${d.name}|${d.routerIp}`)
281 .sort()
282 .join(",");
283
284const toAttributes = (
285 config: ObservedConfig,
286 accountId: string,
287): ConfigAttributes => ({
288 accountId,
289 name: config.name,
290 defaultSampling: config.defaultSampling,
291 routerIps: [...config.routerIps],
292 warpDevices: config.warpDevices.map((d) => ({
293 id: d.id,
294 name: d.name,
295 routerIp: d.routerIp,
296 })),
297});