1import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
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 { 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.LoadBalancer.MonitorGroup" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * A monitor membership within a monitor group.
19 */
20export interface MonitorGroupMember {
21 /**
22 * The ID of the {@link Monitor} to include in the group.
23 */
24 monitorId: string;
25 /**
26 * Whether this member's health check is active.
27 * @default true
28 */
29 enabled?: boolean;
30 /**
31 * If true, the member's results are recorded but do not affect the
32 * group's aggregate health.
33 * @default false
34 */
35 monitoringOnly?: boolean;
36 /**
37 * Whether this member must report healthy for the group to be healthy.
38 * @default true
39 */
40 mustBeHealthy?: boolean;
41}
42
43export interface MonitorGroupProps {
44 /**
45 * A short description of the monitor group. Monitor groups have no name
46 * field, so the description doubles as the group's identity for state
47 * recovery — if omitted, a unique name is generated from the app, stage,
48 * and logical ID.
49 * @default ${app}-${stage}-${id}
50 */
51 description?: string;
52 /**
53 * List of monitors in this group.
54 */
55 members: ReadonlyArray<MonitorGroupMember>;
56}
57
58export interface MonitorGroupAttributes {
59 /** Cloudflare-assigned monitor group identifier. */
60 monitorGroupId: string;
61 /** The Cloudflare account the monitor group belongs to. */
62 accountId: string;
63 /** Monitor group description (carries the physical name when generated). */
64 description: string;
65 /** ISO8601 creation timestamp. */
66 createdOn: string | undefined;
67 /** ISO8601 last-modified timestamp. */
68 modifiedOn: string | undefined;
69}
70
71export type MonitorGroup = Resource<
72 TypeId,
73 MonitorGroupProps,
74 MonitorGroupAttributes,
75 never,
76 Providers
77>;
78
79/**
80 * A Cloudflare Load Balancing monitor group — aggregates several
81 * {@link Monitor}s into one health signal that a
82 * {@link Pool} can reference via `monitorGroup` (mutually
83 * exclusive with `monitor`).
84 *
85 * Monitor groups are an Enterprise-only feature; on non-entitled accounts
86 * creation fails with the typed `MonitorGroupsNotEnabled` error.
87 * ### Creating a Monitor Group
88 * **Example:** Group of two monitors
89 * ```typescript
90 * const group = yield* Cloudflare.LoadBalancer.MonitorGroup("ApiChecks", {
91 * members: [
92 * { monitorId: httpsMonitor.monitorId },
93 * { monitorId: tcpMonitor.monitorId, mustBeHealthy: false },
94 * ],
95 * });
96 * ```
97 *
98 * ### Using with a Pool
99 * **Example:** Attach the group to a pool
100 * ```typescript
101 * yield* Cloudflare.LoadBalancer.Pool("ApiPool", {
102 * origins: [{ name: "origin-1", address: "203.0.113.10" }],
103 * monitorGroup: group.monitorGroupId,
104 * });
105 * ```
106 *
107 * @see https://developers.cloudflare.com/load-balancing/monitors/
108 *
109 * @resource
110 * @product Load Balancers
111 * @category Performance & Reliability
112 */
113export const MonitorGroup = Resource<MonitorGroup>(TypeId);
114
115/**
116 * Returns true if the given value is a MonitorGroup resource.
117 */
118export const isMonitorGroup = (value: unknown): value is MonitorGroup =>
119 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
120
121export const MonitorGroupProvider = () =>
122 Provider.succeed(MonitorGroup, {
123 stables: ["monitorGroupId", "accountId", "createdOn"],
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 return undefined;
131 }),
132
133 // Monitor groups are an account-scoped collection — paginate the
134 // account-wide list and hydrate each into the exact `read` Attributes
135 // shape (pattern (b) in processes/list-support.md).
136 list: Effect.fn(function* () {
137 const { accountId } = yield* yield* CloudflareEnvironment;
138 return yield* loadBalancers.listMonitorGroups.pages({ accountId }).pipe(
139 Stream.runCollect,
140 Effect.map((chunk) =>
141 Array.from(chunk).flatMap((page) =>
142 (page.result ?? []).map((g) => toAttributes(g, accountId)),
143 ),
144 ),
145 );
146 }),
147
148 read: Effect.fn(function* ({ id, output, olds }) {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150 const acct = output?.accountId ?? accountId;
151
152 if (output?.monitorGroupId) {
153 const observed = yield* getMonitorGroup(acct, output.monitorGroupId);
154 return observed ? toAttributes(observed, acct) : undefined;
155 }
156 // Cold read — recover from lost state by matching the deterministic
157 // description (our physical name). Monitor groups carry no ownership
158 // marker, so report the match as Unowned.
159 const description = yield* createDescription(id, olds?.description);
160 const match = yield* findByDescription(acct, description);
161 if (match) return Unowned(toAttributes(match, acct));
162 return undefined;
163 }),
164
165 reconcile: Effect.fn(function* ({ id, news, output }) {
166 const { accountId } = yield* yield* CloudflareEnvironment;
167 const description = yield* createDescription(id, news.description);
168 const members = buildMembers(news);
169
170 // 1. Observe — output.monitorGroupId is a cache hint.
171 const observed = output?.monitorGroupId
172 ? yield* getMonitorGroup(
173 output.accountId ?? accountId,
174 output.monitorGroupId,
175 )
176 : undefined;
177
178 // 2. Ensure — missing: create with the full desired body.
179 if (!observed) {
180 const created = yield* loadBalancers.createMonitorGroup({
181 accountId,
182 description,
183 members,
184 });
185 return toAttributes(created, accountId);
186 }
187
188 // 3. Sync — PUT the full desired body when anything drifts; skip
189 // the call on a no-op.
190 const dirty =
191 observed.description !== description ||
192 JSON.stringify(
193 observed.members.map((m) => ({
194 monitorId: m.monitorId,
195 enabled: m.enabled,
196 monitoringOnly: m.monitoringOnly,
197 mustBeHealthy: m.mustBeHealthy,
198 })),
199 ) !== JSON.stringify(members);
200 if (!dirty) {
201 return toAttributes(observed, accountId);
202 }
203 const updated = yield* loadBalancers.updateMonitorGroup({
204 accountId,
205 monitorGroupId: observed.id,
206 description,
207 members,
208 });
209 return toAttributes(updated, accountId);
210 }),
211
212 delete: Effect.fn(function* ({ output }) {
213 // A group still referenced by a pool cannot be deleted — retry the
214 // typed MonitorGroupInUse tag with bounded backoff while the engine
215 // tears down dependents.
216 yield* loadBalancers
217 .deleteMonitorGroup({
218 accountId: output.accountId,
219 monitorGroupId: output.monitorGroupId,
220 })
221 .pipe(
222 Effect.retry({
223 while: (e) => e._tag === "MonitorGroupInUse",
224 schedule: Schedule.max([
225 Schedule.exponential("1 second"),
226 Schedule.recurs(6),
227 ]),
228 }),
229 Effect.catchTag("MonitorGroupNotFound", () => Effect.void),
230 );
231 }),
232 });
233
234type ObservedMonitorGroup =
235 | loadBalancers.GetMonitorGroupResponse
236 | loadBalancers.CreateMonitorGroupResponse
237 | loadBalancers.UpdateMonitorGroupResponse
238 | loadBalancers.ListMonitorGroupsResponse["result"][number];
239
240/**
241 * Read a monitor group by id, mapping "gone" (`MonitorGroupNotFound`,
242 * HTTP 404 code 1001) to `undefined`.
243 */
244const getMonitorGroup = (accountId: string, monitorGroupId: string) =>
245 loadBalancers
246 .getMonitorGroup({ accountId, monitorGroupId })
247 .pipe(
248 Effect.catchTag("MonitorGroupNotFound", () => Effect.succeed(undefined)),
249 );
250
251/**
252 * Find a monitor group by exact description. If several carry the same
253 * description, pick the oldest for determinism.
254 */
255const findByDescription = (accountId: string, description: string) =>
256 loadBalancers.listMonitorGroups.items({ accountId }).pipe(
257 Stream.filter((g) => g.description === description),
258 Stream.runCollect,
259 Effect.map((chunk) =>
260 Array.from(chunk)
261 .sort((a, b) => (a.createdOn ?? "").localeCompare(b.createdOn ?? ""))
262 .at(0),
263 ),
264 );
265
266const createDescription = (id: string, description: string | undefined) =>
267 Effect.gen(function* () {
268 return description ?? (yield* createPhysicalName({ id, lowercase: true }));
269 });
270
271const buildMembers = (news: MonitorGroupProps) =>
272 news.members.map((m) => ({
273 // Inputs are resolved to concrete strings by Plan.
274 monitorId: m.monitorId as string,
275 enabled: m.enabled ?? true,
276 monitoringOnly: m.monitoringOnly ?? false,
277 mustBeHealthy: m.mustBeHealthy ?? true,
278 }));
279
280const toAttributes = (
281 group: ObservedMonitorGroup,
282 accountId: string,
283): MonitorGroupAttributes => ({
284 monitorGroupId: group.id,
285 accountId,
286 description: group.description,
287 createdOn: group.createdOn ?? undefined,
288 modifiedOn: group.modifiedOn ?? undefined,
289});