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.CatalogSync" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Where a catalog sync writes the discovered resources.
19 */
20export type CatalogSyncDestinationType = "NONE" | "ZERO_TRUST_LIST";
21
22/**
23 * Whether the destination updates automatically as discoveries change, or
24 * only when explicitly refreshed.
25 */
26export type CatalogSyncUpdateMode = "AUTO" | "MANUAL";
27
28export interface CatalogSyncProps {
29 /**
30 * Type of destination the sync materializes into. `ZERO_TRUST_LIST`
31 * provisions a Zero Trust list owned by the sync.
32 *
33 * Immutable — the destination is provisioned at create time, so changing
34 * it triggers a replacement.
35 */
36 destinationType: CatalogSyncDestinationType;
37 /**
38 * Whether the destination updates automatically (`AUTO`) or only on an
39 * explicit refresh (`MANUAL`). Mutable.
40 */
41 updateMode: CatalogSyncUpdateMode;
42 /**
43 * Human readable name. Used as the sync's identity for cold-state
44 * recovery, so it should be unique within the account. If omitted, a
45 * unique name is generated from the app, stage, and logical ID.
46 * @default ${app}-${stage}-${id}
47 */
48 name?: string;
49 /**
50 * Free-form description of the sync. Mutable.
51 */
52 description?: string;
53 /**
54 * Filter expression selecting which discovered resources are included.
55 * See the prebuilt policies API for ready-made expressions. Mutable.
56 */
57 policy?: string;
58 /**
59 * Whether destroying the sync also deletes the destination it provisioned
60 * (e.g. the Zero Trust list). Consumed only at delete time.
61 * @default true
62 */
63 deleteDestination?: boolean;
64}
65
66export interface CatalogSyncAttributes {
67 /** Cloudflare-assigned identifier of the catalog sync. */
68 syncId: string;
69 /** The Cloudflare account the sync belongs to. */
70 accountId: string;
71 /** Human readable name of the sync. */
72 name: string;
73 /** Type of destination the sync materializes into. */
74 destinationType: CatalogSyncDestinationType;
75 /** Identifier of the provisioned destination (e.g. Zero Trust list id). */
76 destinationId: string;
77 /** Whether the destination updates automatically or manually. */
78 updateMode: CatalogSyncUpdateMode;
79 /** Free-form description. */
80 description: string;
81 /** Filter expression selecting included resources. */
82 policy: string;
83 /** ISO8601 timestamp of the last user-initiated update. */
84 lastUserUpdateAt: string;
85 /** Discoveries up to this ISO8601 timestamp are included, if any. */
86 includesDiscoveriesUntil: string | undefined;
87 /** Whether delete also removes the provisioned destination. */
88 deleteDestination: boolean;
89}
90
91export type CatalogSync = Resource<
92 TypeId,
93 CatalogSyncProps,
94 CatalogSyncAttributes,
95 never,
96 Providers
97>;
98
99/**
100 * A Magic Cloud Networking catalog sync — continuously materializes the
101 * catalog of discovered cloud resources (filtered by a policy expression)
102 * into a destination such as a Zero Trust list.
103 *
104 * The destination is provisioned when the sync is created, so
105 * `destinationType` is immutable and forces a replacement; `name`,
106 * `description`, `policy`, and `updateMode` are all patched in place.
107 *
108 * Magic Cloud Networking is an entitlement-gated add-on (Magic WAN family).
109 * On accounts without the entitlement every API call fails with the typed
110 * `FeatureNotEnabled` error (Cloudflare code 1012, "feature not enabled").
111 * ### Creating a sync
112 * **Example:** Sync discovered VPC CIDRs into a Zero Trust list
113 * ```typescript
114 * const sync = yield* Cloudflare.MagicCloudNetworking.CatalogSync("VpcCidrs", {
115 * destinationType: "ZERO_TRUST_LIST",
116 * updateMode: "AUTO",
117 * policy: "kind in ('aws_vpc','azurerm_virtual_network','google_compute_network')",
118 * });
119 * // sync.destinationId is the provisioned Zero Trust list
120 * ```
121 *
122 * **Example:** Manual sync without a destination
123 * ```typescript
124 * yield* Cloudflare.MagicCloudNetworking.CatalogSync("DryRun", {
125 * destinationType: "NONE",
126 * updateMode: "MANUAL",
127 * });
128 * ```
129 *
130 * ### Destroy behavior
131 * **Example:** Keep the destination list on destroy
132 * ```typescript
133 * yield* Cloudflare.MagicCloudNetworking.CatalogSync("VpcCidrs", {
134 * destinationType: "ZERO_TRUST_LIST",
135 * updateMode: "AUTO",
136 * deleteDestination: false,
137 * });
138 * ```
139 *
140 * @see https://developers.cloudflare.com/magic-cloud-networking/
141 *
142 * @resource
143 * @product Magic Cloud Networking
144 * @category Network
145 */
146export const CatalogSync = Resource<CatalogSync>(TypeId);
147
148/**
149 * Returns true if the given value is a CatalogSync resource.
150 */
151export const isCatalogSync = (value: unknown): value is CatalogSync =>
152 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
153
154export const CatalogSyncProvider = () =>
155 Provider.succeed(CatalogSync, {
156 stables: ["syncId", "accountId", "destinationType", "destinationId"],
157
158 diff: Effect.fn(function* ({ olds, news, output }) {
159 if (!isResolved(news)) return undefined;
160 const oldDestination =
161 output?.destinationType ??
162 (olds !== undefined && isResolved(olds)
163 ? olds.destinationType
164 : undefined);
165 if (
166 oldDestination !== undefined &&
167 oldDestination !== news.destinationType
168 ) {
169 return { action: "replace" } as const;
170 }
171 return undefined;
172 }),
173
174 read: Effect.fn(function* ({ id, output, olds }) {
175 const { accountId } = yield* yield* CloudflareEnvironment;
176 const acct = output?.accountId ?? accountId;
177 const deleteDestination =
178 output?.deleteDestination ?? olds?.deleteDestination ?? true;
179
180 // Owned path: refresh by our persisted sync id.
181 if (output?.syncId) {
182 const observed = yield* getSync(acct, output.syncId);
183 if (observed) return toAttributes(observed, acct, deleteDestination);
184 return undefined;
185 }
186
187 // Cold read — recover from lost state by matching the deterministic
188 // physical name. Syncs carry no ownership markers, so report a match
189 // as Unowned and let the engine gate adoption.
190 const name = yield* syncName(id, olds?.name);
191 const match = yield* findByName(acct, name);
192 return match
193 ? Unowned(toAttributes(match, acct, deleteDestination))
194 : undefined;
195 }),
196
197 reconcile: Effect.fn(function* ({ id, news, output }) {
198 const { accountId } = yield* yield* CloudflareEnvironment;
199 const name = yield* syncName(id, news.name);
200 const deleteDestination = news.deleteDestination ?? true;
201
202 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
203 // a missing sync falls through to the name scan and create.
204 let observed = output?.syncId
205 ? yield* getSync(output.accountId ?? accountId, output.syncId)
206 : undefined;
207 if (!observed) {
208 observed = yield* findByName(accountId, name);
209 }
210
211 // 2. Ensure — create when missing. Names are not unique on
212 // Cloudflare's side so there is no AlreadyExists race to tolerate.
213 if (!observed) {
214 observed = yield* mcn.createCatalogSync({
215 accountId,
216 name,
217 destinationType: news.destinationType,
218 updateMode: news.updateMode,
219 description: news.description,
220 policy: news.policy,
221 });
222 return toAttributes(observed, accountId, deleteDestination);
223 }
224
225 // 3. Sync — diff observed cloud state against desired; PATCH only the
226 // delta and skip the call entirely on a no-op.
227 const patch: mcn.PatchCatalogSyncRequest = {
228 accountId,
229 syncId: observed.id,
230 };
231 let dirty = false;
232 if (observed.name !== name) {
233 patch.name = name;
234 dirty = true;
235 }
236 if (observed.updateMode !== news.updateMode) {
237 patch.updateMode = news.updateMode;
238 dirty = true;
239 }
240 if (
241 news.description !== undefined &&
242 observed.description !== news.description
243 ) {
244 patch.description = news.description;
245 dirty = true;
246 }
247 if (news.policy !== undefined && observed.policy !== news.policy) {
248 patch.policy = news.policy;
249 dirty = true;
250 }
251 if (dirty) {
252 observed = yield* mcn.patchCatalogSync(patch);
253 }
254
255 return toAttributes(observed, accountId, deleteDestination);
256 }),
257
258 delete: Effect.fn(function* ({ output }) {
259 yield* mcn
260 .deleteCatalogSync({
261 accountId: output.accountId,
262 syncId: output.syncId,
263 deleteDestination: output.deleteDestination,
264 })
265 .pipe(Effect.catchTag("CatalogSyncNotFound", () => Effect.void));
266 }),
267
268 // Account-scoped collection: exhaustively paginate the catalog-sync list
269 // API. `deleteDestination` is an alchemy-only prop with no cloud
270 // representation, so it defaults to `true`. Magic Cloud Networking is an
271 // entitlement-gated add-on; an unentitled account rejects the list with
272 // the typed `FeatureNotEnabled` error — treat that as "nothing to list".
273 list: Effect.fn(function* () {
274 const { accountId } = yield* yield* CloudflareEnvironment;
275 return yield* mcn.listCatalogSyncs.pages({ accountId }).pipe(
276 Stream.runCollect,
277 Effect.map((chunk) =>
278 Array.from(chunk).flatMap((page) =>
279 (page.result ?? []).map((sync) =>
280 toAttributes(sync, accountId, true),
281 ),
282 ),
283 ),
284 Effect.catchTag("FeatureNotEnabled", () => Effect.succeed([])),
285 );
286 }),
287 });
288
289type ObservedSync = mcn.GetCatalogSyncResponse | mcn.CreateCatalogSyncResponse;
290
291/**
292 * Read a sync by id, mapping "gone" (`CatalogSyncNotFound`) to `undefined`.
293 */
294const getSync = (accountId: string, syncId: string) =>
295 mcn.getCatalogSync({ accountId, syncId }).pipe(
296 Effect.map((sync): ObservedSync | undefined => sync),
297 Effect.catchTag("CatalogSyncNotFound", () => Effect.succeed(undefined)),
298 );
299
300/**
301 * Find a sync by exact name. Names are not unique on Cloudflare's side; if
302 * several syncs carry the same name, pick the lexicographically-first id
303 * for determinism.
304 */
305const findByName = (accountId: string, name: string) =>
306 mcn.listCatalogSyncs.items({ accountId }).pipe(
307 Stream.runCollect,
308 Effect.map((chunk) =>
309 Array.from(chunk)
310 .filter((sync) => sync.name === name)
311 .sort((a, b) => a.id.localeCompare(b.id))
312 .at(0),
313 ),
314 );
315
316const syncName = (id: string, name: string | undefined) =>
317 Effect.gen(function* () {
318 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
319 });
320
321const toAttributes = (
322 sync: ObservedSync,
323 accountId: string,
324 deleteDestination: boolean,
325): CatalogSyncAttributes => ({
326 syncId: sync.id,
327 accountId,
328 name: sync.name,
329 // Distilled widens generated string enums to open unions (`string & {}`).
330 destinationType: sync.destinationType as CatalogSyncDestinationType,
331 destinationId: sync.destinationId,
332 updateMode: sync.updateMode as CatalogSyncUpdateMode,
333 description: sync.description,
334 policy: sync.policy,
335 lastUserUpdateAt: sync.lastUserUpdateAt,
336 includesDiscoveriesUntil: sync.includesDiscoveriesUntil ?? undefined,
337 deleteDestination,
338});