1import * as mnm from "@distilled.cloud/cloudflare/magic-network-monitoring";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.MagicNetworkMonitoring.Rule" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * MNM rule type: `threshold` (static bits/packets-per-second limits),
20 * `zscore` (anomaly detection), or `advanced_ddos` (requires Magic Transit).
21 */
22export type RuleType = "threshold" | "zscore" | "advanced_ddos";
23
24/**
25 * How long a threshold must be exceeded before an alert fires.
26 */
27export type RuleDuration =
28 | "1m"
29 | "5m"
30 | "10m"
31 | "15m"
32 | "20m"
33 | "30m"
34 | "45m"
35 | "60m";
36
37export interface RuleProps {
38 /**
39 * The Cloudflare account the rule belongs to. Defaults to the profile's
40 * account. Pass the owning MNM Config's `accountId` output to sequence
41 * the rule after the configuration (rules cannot be created until the
42 * account's MNM configuration exists).
43 */
44 accountId?: string;
45 /**
46 * The name of the rule. Must be unique within the account. Supports
47 * `A-Z`, `a-z`, `0-9`, underscore, dash, period, and tilde — no spaces.
48 * Max 256 characters. If omitted, a unique name is generated from the
49 * app, stage, and logical ID. Mutable in place.
50 * @default ${app}-${stage}-${id}
51 */
52 name?: string;
53 /**
54 * MNM rule type. Immutable — the rule's alerting semantics differ
55 * entirely per type, so changing it triggers a replacement.
56 */
57 type: RuleType;
58 /**
59 * IPv4 CIDR prefixes the rule monitors. Mutable in place.
60 */
61 prefixes: string[];
62 /**
63 * Toggle on to have Cloudflare automatically advertise the rule's
64 * prefixes via Magic Transit when the rule triggers. Requires Magic
65 * Transit.
66 * @default false
67 */
68 automaticAdvertisement?: boolean;
69 /**
70 * Bits per second threshold (threshold rules). When exceeded for the
71 * set duration, an alert notification is sent. Minimum of 1.
72 */
73 bandwidthThreshold?: number;
74 /**
75 * Packets per second threshold (threshold rules). When exceeded for the
76 * set duration, an alert notification is sent. Minimum of 1.
77 */
78 packetThreshold?: number;
79 /**
80 * How long the threshold must be exceeded before an alert fires.
81 * @default "1m"
82 */
83 duration?: RuleDuration;
84 /**
85 * Sensitivity of the anomaly detection (zscore rules).
86 */
87 zscoreSensitivity?: "low" | "medium" | "high";
88 /**
89 * Target of the zscore rule analysis (zscore rules).
90 */
91 zscoreTarget?: "bits" | "packets";
92 /**
93 * Prefix match type applied for prefix auto-advertisement
94 * (advanced_ddos rules).
95 */
96 prefixMatch?: "exact" | "subnet" | "supernet";
97}
98
99export interface RuleAttributes {
100 /** Cloudflare-assigned identifier of the rule. */
101 ruleId: string;
102 /** The Cloudflare account the rule belongs to. */
103 accountId: string;
104 /** The rule's unique name. */
105 name: string;
106 /** MNM rule type. */
107 type: RuleType;
108 /** IPv4 CIDR prefixes the rule monitors. */
109 prefixes: string[];
110 /** Whether prefixes are auto-advertised via Magic Transit on trigger. */
111 automaticAdvertisement: boolean;
112 /** Bits per second threshold, if set. */
113 bandwidthThreshold: number | undefined;
114 /** Packets per second threshold, if set. */
115 packetThreshold: number | undefined;
116 /**
117 * Alert duration as reported by Cloudflare (normalized, e.g. `1m0s`).
118 */
119 duration: string | undefined;
120 /** Zscore sensitivity, if set. */
121 zscoreSensitivity: "low" | "medium" | "high" | (string & {}) | undefined;
122 /** Zscore target, if set. */
123 zscoreTarget: "bits" | "packets" | (string & {}) | undefined;
124 /** Prefix match type, if set. */
125 prefixMatch: "exact" | "subnet" | "supernet" | (string & {}) | undefined;
126}
127
128export type Rule = Resource<
129 TypeId,
130 RuleProps,
131 RuleAttributes,
132 never,
133 Providers
134>;
135
136/**
137 * A Magic Network Monitoring (MNM) rule — alerts when traffic to a set of
138 * IPv4 prefixes exceeds a static threshold (`threshold`), deviates from the
139 * learned baseline (`zscore`), or matches advanced DDoS criteria
140 * (`advanced_ddos`, Magic Transit only).
141 *
142 * Rules require the account's MNM configuration to exist first — pass the
143 * Config resource's `accountId` output as this rule's `accountId` to
144 * sequence the deployment. Rule names are unique per account; the rule
145 * `type` is immutable and changing it triggers a replacement.
146 * ### Threshold rules
147 * **Example:** Alert when bandwidth exceeds 1 Mbps for 5 minutes
148 * ```typescript
149 * const config = yield* Cloudflare.MagicNetworkMonitoring.Config("Mnm", {
150 * name: "my-network",
151 * defaultSampling: 1,
152 * });
153 * yield* Cloudflare.MagicNetworkMonitoring.Rule("BandwidthAlert", {
154 * accountId: config.accountId,
155 * type: "threshold",
156 * prefixes: ["10.0.0.0/24"],
157 * bandwidthThreshold: 1_000_000,
158 * duration: "5m",
159 * });
160 * ```
161 *
162 * **Example:** Packet-rate alert
163 * ```typescript
164 * yield* Cloudflare.MagicNetworkMonitoring.Rule("PacketAlert", {
165 * accountId: config.accountId,
166 * type: "threshold",
167 * prefixes: ["10.0.1.0/24"],
168 * packetThreshold: 10_000,
169 * });
170 * ```
171 *
172 * ### Anomaly detection
173 * **Example:** Zscore rule on bits
174 * ```typescript
175 * yield* Cloudflare.MagicNetworkMonitoring.Rule("AnomalyAlert", {
176 * accountId: config.accountId,
177 * type: "zscore",
178 * prefixes: ["10.0.2.0/24"],
179 * zscoreSensitivity: "medium",
180 * zscoreTarget: "bits",
181 * });
182 * ```
183 *
184 * @see https://developers.cloudflare.com/magic-network-monitoring/rules/
185 *
186 * @resource
187 * @product Magic Network Monitoring
188 * @category Network
189 */
190export const Rule = Resource<Rule>(TypeId);
191
192/**
193 * Returns true if the given value is a Rule resource.
194 */
195export const isRule = (value: unknown): value is Rule =>
196 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
197
198export const RuleProvider = () =>
199 Provider.succeed(Rule, {
200 stables: ["ruleId", "accountId"],
201
202 diff: Effect.fn(function* ({ olds, news, output }) {
203 if (!isResolved(news)) return undefined;
204 // The rule type is immutable — alerting semantics differ entirely.
205 const oldType =
206 output?.type ??
207 (olds !== undefined && isResolved(olds) ? olds.type : undefined);
208 if (oldType !== undefined && oldType !== news.type) {
209 return { action: "replace" } as const;
210 }
211 // accountId is Input<string>; compare only once both are concrete.
212 const oldAccount = output?.accountId;
213 if (
214 oldAccount !== undefined &&
215 typeof news.accountId === "string" &&
216 oldAccount !== news.accountId
217 ) {
218 return { action: "replace" } as const;
219 }
220 return undefined;
221 }),
222
223 read: Effect.fn(function* ({ id, output, olds }) {
224 const { accountId } = yield* yield* CloudflareEnvironment;
225 const acct =
226 output?.accountId ??
227 (typeof olds?.accountId === "string" ? olds.accountId : accountId);
228
229 // Owned path: refresh by our persisted rule id.
230 if (output?.ruleId) {
231 const observed = yield* getRule(acct, output.ruleId);
232 if (observed) return toAttributes(observed, acct);
233 }
234
235 // Cold read: rule names are unique per account, so an exact name
236 // match identifies the rule. We cannot prove we created it — brand
237 // it `Unowned` so takeover is gated behind the adopt policy.
238 const name = yield* createRuleName(id, olds?.name);
239 const match = yield* findByName(acct, name);
240 if (match) {
241 const attributes = toAttributes(match, acct);
242 return output === undefined ? Unowned(attributes) : attributes;
243 }
244 return undefined;
245 }),
246
247 reconcile: Effect.fn(function* ({ id, news, output }) {
248 const { accountId: defaultAccountId } =
249 yield* yield* CloudflareEnvironment;
250 // Inputs have been resolved to concrete strings by Plan.
251 const accountId =
252 (news.accountId as string | undefined) ?? defaultAccountId;
253 const name = yield* createRuleName(id, news.name);
254
255 // 1. Observe — the rule id cached on `output` is a hint, not a
256 // guarantee: a missing rule falls through to the name scan and
257 // then to create.
258 let observed = output?.ruleId
259 ? yield* getRule(accountId, output.ruleId)
260 : undefined;
261 if (!observed) {
262 observed = yield* findByName(accountId, name);
263 }
264
265 // 2. Ensure — create when missing. A concurrent create surfaces as
266 // `DuplicateMnmRuleName` (Cloudflare code 1008): converge by
267 // re-scanning for the rule that won the race.
268 if (!observed) {
269 observed = yield* mnm
270 .createRule({
271 accountId,
272 name,
273 type: news.type,
274 prefixes: news.prefixes,
275 automaticAdvertisement: news.automaticAdvertisement ?? false,
276 bandwidthThreshold: news.bandwidthThreshold,
277 packetThreshold: news.packetThreshold,
278 duration: news.duration,
279 zscoreSensitivity: news.zscoreSensitivity,
280 zscoreTarget: news.zscoreTarget,
281 prefixMatch: news.prefixMatch,
282 })
283 .pipe(
284 // The account MNM config is a freshly-created dependency (the test
285 // and real deployments create it immediately before the rule).
286 // Cloudflare returns `MnmConfigMissing` (code 1004) until the new
287 // config has propagated, so ride out that consistency window.
288 Effect.retry({
289 while: (e) => e._tag === "MnmConfigMissing",
290 schedule: Schedule.max([
291 Schedule.exponential("500 millis"),
292 Schedule.recurs(8),
293 ]),
294 }),
295 Effect.catchTag("DuplicateMnmRuleName", (error) =>
296 findByName(accountId, name).pipe(
297 Effect.flatMap((existing) =>
298 existing ? Effect.succeed(existing) : Effect.fail(error),
299 ),
300 ),
301 ),
302 );
303 return toAttributes(observed, accountId);
304 }
305
306 // 3. Sync — diff observed cloud state against desired; the PATCH
307 // takes a full body, so send everything but skip the call
308 // entirely on a no-op.
309 const dirty =
310 observed.name !== name ||
311 !samePrefixes(observed.prefixes, news.prefixes) ||
312 (observed.automaticAdvertisement ?? false) !==
313 (news.automaticAdvertisement ?? false) ||
314 (news.bandwidthThreshold !== undefined &&
315 observed.bandwidthThreshold !== news.bandwidthThreshold) ||
316 (news.packetThreshold !== undefined &&
317 observed.packetThreshold !== news.packetThreshold) ||
318 (news.duration !== undefined &&
319 normalizeDuration(observed.duration) !== news.duration) ||
320 (news.zscoreSensitivity !== undefined &&
321 observed.zscoreSensitivity !== news.zscoreSensitivity) ||
322 (news.zscoreTarget !== undefined &&
323 observed.zscoreTarget !== news.zscoreTarget) ||
324 (news.prefixMatch !== undefined &&
325 observed.prefixMatch !== news.prefixMatch);
326
327 if (!dirty) return toAttributes(observed, accountId);
328
329 const ruleId = observed.id;
330 const patched = yield* mnm.patchRule({
331 accountId,
332 ruleId,
333 name,
334 type: news.type,
335 prefixes: news.prefixes,
336 automaticAdvertisement: news.automaticAdvertisement ?? false,
337 bandwidthThreshold: news.bandwidthThreshold,
338 packetThreshold: news.packetThreshold,
339 duration: news.duration,
340 zscoreSensitivity: news.zscoreSensitivity,
341 zscoreTarget: news.zscoreTarget,
342 prefixMatch: news.prefixMatch,
343 });
344 // Cloudflare omits `id` from the PATCH response body — carry the
345 // observed id through.
346 return toAttributes({ ...patched, id: patched.id ?? ruleId }, accountId);
347 }),
348
349 delete: Effect.fn(function* ({ output }) {
350 yield* mnm
351 .deleteRule({ accountId: output.accountId, ruleId: output.ruleId })
352 .pipe(
353 // Already gone (`MnmRuleNotFound`, Cloudflare code 1009) — also
354 // covers Cloudflare cascading rule deletion when the account's
355 // MNM config is deleted first.
356 Effect.catchTag("MnmRuleNotFound", () => Effect.void),
357 );
358 }),
359
360 // MNM rules are account-scoped: enumerate every rule in the ambient
361 // account. `listRules` paginates in "single" mode, so collect every
362 // page and flatten its `result` array, hydrating each item into the
363 // exact `read` Attributes shape. Accounts not onboarded to Magic
364 // Network Monitoring reject the route with `Forbidden` (HTTP 403) —
365 // treat that as "nothing to list" rather than an error.
366 list: Effect.fn(function* () {
367 const { accountId } = yield* yield* CloudflareEnvironment;
368 return yield* mnm.listRules.pages({ accountId }).pipe(
369 Stream.runCollect,
370 Effect.map((chunk) =>
371 Array.from(chunk).flatMap((page) =>
372 (page.result ?? [])
373 .filter((rule): rule is NonNullable<typeof rule> => rule != null)
374 .map((rule) => toAttributes(rule, accountId)),
375 ),
376 ),
377 Effect.catchTag("Forbidden", () => Effect.succeed([])),
378 );
379 }),
380 });
381
382type ObservedRule = mnm.GetRuleResponse;
383
384/**
385 * Read a rule by id, mapping "gone" (`MnmRuleNotFound`, Cloudflare error
386 * code 1009) to `undefined`.
387 */
388const getRule = (accountId: string, ruleId: string) =>
389 mnm.getRule({ accountId, ruleId }).pipe(
390 Effect.map((rule): ObservedRule | undefined => rule),
391 Effect.catchTag("MnmRuleNotFound", () => Effect.succeed(undefined)),
392 );
393
394/**
395 * Find a rule by exact name. Names are unique per account, so at most one
396 * rule can match. An account with no rules answers `result: null`.
397 */
398const findByName = (accountId: string, name: string) =>
399 mnm
400 .listRules({ accountId })
401 .pipe(
402 Effect.map((response) =>
403 (response.result ?? []).find(
404 (rule): rule is NonNullable<typeof rule> => rule?.name === name,
405 ),
406 ),
407 );
408
409const createRuleName = (id: string, name: string | undefined) =>
410 Effect.gen(function* () {
411 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
412 });
413
414const samePrefixes = (
415 observed: readonly string[],
416 desired: readonly string[],
417) =>
418 observed.length === desired.length &&
419 [...observed].sort().join(",") === [...desired].sort().join(",");
420
421/**
422 * Cloudflare normalizes durations to Go notation on read (`1m` → `1m0s`,
423 * `60m` → `1h0m0s`) — map the observed value back to the prop vocabulary
424 * for diffing.
425 */
426const normalizeDuration = (
427 duration: string | null | undefined,
428): RuleDuration | undefined => {
429 switch (duration) {
430 case "1m0s":
431 case "1m":
432 return "1m";
433 case "5m0s":
434 case "5m":
435 return "5m";
436 case "10m0s":
437 case "10m":
438 return "10m";
439 case "15m0s":
440 case "15m":
441 return "15m";
442 case "20m0s":
443 case "20m":
444 return "20m";
445 case "30m0s":
446 case "30m":
447 return "30m";
448 case "45m0s":
449 case "45m":
450 return "45m";
451 case "1h0m0s":
452 case "60m":
453 return "60m";
454 default:
455 return undefined;
456 }
457};
458
459const toAttributes = (
460 rule: ObservedRule | (Omit<mnm.PatchRuleResponse, "id"> & { id: string }),
461 accountId: string,
462): RuleAttributes => ({
463 ruleId: rule.id,
464 accountId,
465 name: rule.name,
466 // Distilled widens generated string enums to open unions (`string & {}`).
467 type: rule.type as RuleType,
468 prefixes: [...rule.prefixes],
469 automaticAdvertisement: rule.automaticAdvertisement ?? false,
470 bandwidthThreshold: rule.bandwidthThreshold ?? undefined,
471 packetThreshold: rule.packetThreshold ?? undefined,
472 duration: rule.duration ?? undefined,
473 zscoreSensitivity: rule.zscoreSensitivity ?? undefined,
474 zscoreTarget: rule.zscoreTarget ?? undefined,
475 prefixMatch: rule.prefixMatch ?? undefined,
476});