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.CloudIntegration" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * The cloud provider an integration discovers resources from.
19 */
20export type CloudIntegrationCloudType =
21 | "AWS"
22 | "AZURE"
23 | "GOOGLE"
24 | "CLOUDFLARE";
25
26/**
27 * Lifecycle state of a cloud integration.
28 */
29export type CloudIntegrationLifecycleState =
30 | "ACTIVE"
31 | "PENDING_SETUP"
32 | "RETIRED";
33
34/**
35 * Discovery state of a cloud integration.
36 */
37export type CloudIntegrationState =
38 | "UNSPECIFIED"
39 | "PENDING"
40 | "DISCOVERING"
41 | "FAILED"
42 | "SUCCEEDED";
43
44export interface CloudIntegrationProps {
45 /**
46 * The cloud provider this integration connects to.
47 *
48 * Immutable — changing the cloud type triggers a replacement.
49 */
50 cloudType: CloudIntegrationCloudType;
51 /**
52 * Human readable name for the integration. Used as the integration's
53 * identity for cold-state recovery, so it should be unique within the
54 * account. If omitted, a unique name is generated from the app, stage,
55 * and logical ID.
56 * @default ${app}-${stage}-${id}
57 */
58 friendlyName?: string;
59 /**
60 * Free-form description of the integration. Mutable.
61 */
62 description?: string;
63 /**
64 * AWS IAM role ARN Cloudflare assumes to discover resources
65 * (`cloudType: "AWS"` only). Mutable — wired after creating the role
66 * from the integration's setup data.
67 */
68 awsArn?: string;
69 /**
70 * Azure subscription to discover (`cloudType: "AZURE"` only). Mutable.
71 */
72 azureSubscriptionId?: string;
73 /**
74 * Azure tenant the subscription belongs to (`cloudType: "AZURE"` only).
75 * Mutable.
76 */
77 azureTenantId?: string;
78 /**
79 * GCP project to discover (`cloudType: "GOOGLE"` only). Mutable.
80 */
81 gcpProjectId?: string;
82 /**
83 * GCP service account email Cloudflare impersonates
84 * (`cloudType: "GOOGLE"` only). Mutable.
85 */
86 gcpServiceAccountEmail?: string;
87}
88
89export interface CloudIntegrationAttributes {
90 /** Cloudflare-assigned identifier of the integration. */
91 integrationId: string;
92 /** The Cloudflare account the integration belongs to. */
93 accountId: string;
94 /** The cloud provider this integration connects to. */
95 cloudType: CloudIntegrationCloudType;
96 /** Human readable name of the integration. */
97 friendlyName: string;
98 /** Free-form description, if set. */
99 description: string | undefined;
100 /** Lifecycle state (`PENDING_SETUP` until credentials are wired). */
101 lifecycleState: CloudIntegrationLifecycleState;
102 /** State of the most recent discovery run. */
103 state: CloudIntegrationState;
104 /** AWS IAM role ARN used for discovery, if wired. */
105 awsArn: string | undefined;
106 /** Azure subscription being discovered, if wired. */
107 azureSubscriptionId: string | undefined;
108 /** Azure tenant of the subscription, if wired. */
109 azureTenantId: string | undefined;
110 /** GCP project being discovered, if wired. */
111 gcpProjectId: string | undefined;
112 /** GCP service account email used for discovery, if wired. */
113 gcpServiceAccountEmail: string | undefined;
114 /** ISO8601 timestamp of the last change to the integration. */
115 lastUpdated: string;
116}
117
118export type CloudIntegration = Resource<
119 TypeId,
120 CloudIntegrationProps,
121 CloudIntegrationAttributes,
122 never,
123 Providers
124>;
125
126/**
127 * A Magic Cloud Networking cloud integration — registers an AWS, Azure, or
128 * GCP account with Cloudflare so Magic Cloud Networking can discover its
129 * networking resources (VPCs, subnets, gateways, …).
130 *
131 * Creating an integration returns provider-side setup data; credential
132 * wiring (`awsArn`, `azureSubscriptionId`/`azureTenantId`,
133 * `gcpProjectId`/`gcpServiceAccountEmail`) is applied in place. Only
134 * `cloudType` forces a replacement.
135 *
136 * Magic Cloud Networking is an entitlement-gated add-on (Magic WAN family).
137 * On accounts without the entitlement every API call fails with the typed
138 * `FeatureNotEnabled` error (Cloudflare code 1012, "feature not enabled").
139 * ### Creating an integration
140 * **Example:** Register an AWS account
141 * ```typescript
142 * const aws = yield* Cloudflare.MagicCloudNetworking.CloudIntegration("Discovery", {
143 * cloudType: "AWS",
144 * description: "production AWS account",
145 * });
146 * // aws.lifecycleState === "PENDING_SETUP" until credentials are wired
147 * ```
148 *
149 * **Example:** Wire credentials after creating the IAM role
150 * ```typescript
151 * yield* Cloudflare.MagicCloudNetworking.CloudIntegration("Discovery", {
152 * cloudType: "AWS",
153 * awsArn: "arn:aws:iam::123456789012:role/cloudflare-mcn-discovery",
154 * });
155 * ```
156 *
157 * ### GCP
158 * **Example:** Register a GCP project
159 * ```typescript
160 * yield* Cloudflare.MagicCloudNetworking.CloudIntegration("GcpDiscovery", {
161 * cloudType: "GOOGLE",
162 * gcpProjectId: "my-project",
163 * gcpServiceAccountEmail: "mcn@my-project.iam.gserviceaccount.com",
164 * });
165 * ```
166 *
167 * @see https://developers.cloudflare.com/magic-cloud-networking/
168 *
169 * @resource
170 * @product Magic Cloud Networking
171 * @category Network
172 */
173export const CloudIntegration = Resource<CloudIntegration>(TypeId);
174
175/**
176 * Returns true if the given value is a CloudIntegration resource.
177 */
178export const isCloudIntegration = (value: unknown): value is CloudIntegration =>
179 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
180
181export const CloudIntegrationProvider = () =>
182 Provider.succeed(CloudIntegration, {
183 stables: ["integrationId", "accountId", "cloudType"],
184
185 diff: Effect.fn(function* ({ olds, news, output }) {
186 if (!isResolved(news)) return undefined;
187 const oldCloudType =
188 output?.cloudType ??
189 (olds !== undefined && isResolved(olds) ? olds.cloudType : undefined);
190 if (oldCloudType !== undefined && oldCloudType !== news.cloudType) {
191 return { action: "replace" } as const;
192 }
193 return undefined;
194 }),
195
196 read: Effect.fn(function* ({ id, output, olds }) {
197 const { accountId } = yield* yield* CloudflareEnvironment;
198 const acct = output?.accountId ?? accountId;
199
200 // Owned path: refresh by our persisted integration id.
201 if (output?.integrationId) {
202 const observed = yield* getIntegration(acct, output.integrationId);
203 if (observed) return toAttributes(observed, acct);
204 return undefined;
205 }
206
207 // Cold read — recover from lost state by matching the deterministic
208 // physical name. Integrations carry no ownership markers, so report
209 // a match as Unowned and let the engine gate adoption.
210 const name = yield* integrationName(id, olds?.friendlyName);
211 const match = yield* findByName(acct, name);
212 return match ? Unowned(toAttributes(match, acct)) : undefined;
213 }),
214
215 reconcile: Effect.fn(function* ({ id, news, output }) {
216 const { accountId } = yield* yield* CloudflareEnvironment;
217 const name = yield* integrationName(id, news.friendlyName);
218
219 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
220 // a missing integration falls through to the name scan and create.
221 let observed = output?.integrationId
222 ? yield* getIntegration(
223 output.accountId ?? accountId,
224 output.integrationId,
225 )
226 : undefined;
227 if (!observed) {
228 observed = yield* findByName(accountId, name);
229 }
230
231 // 2. Ensure — create when missing. Names are not unique on
232 // Cloudflare's side so there is no AlreadyExists race to tolerate.
233 if (!observed) {
234 const created = yield* mcn.createCloudIntegration({
235 accountId,
236 cloudType: news.cloudType,
237 friendlyName: name,
238 description: news.description,
239 });
240 observed = yield* getIntegration(accountId, created.id).pipe(
241 Effect.map((latest) => latest ?? created),
242 );
243 }
244
245 // 3. Sync — diff observed cloud state against desired; PATCH only the
246 // delta and skip the call entirely on a no-op.
247 const patch: mcn.PatchCloudIntegrationRequest = {
248 accountId,
249 providerId: observed.id,
250 };
251 let dirty = false;
252 if (observed.friendlyName !== name) {
253 patch.friendlyName = name;
254 dirty = true;
255 }
256 if (
257 news.description !== undefined &&
258 (observed.description ?? "") !== news.description
259 ) {
260 patch.description = news.description;
261 dirty = true;
262 }
263 for (const key of credentialKeys) {
264 const desired = news[key];
265 if (desired !== undefined && (observed[key] ?? "") !== desired) {
266 patch[key] = desired;
267 dirty = true;
268 }
269 }
270 if (dirty) {
271 observed = yield* mcn.patchCloudIntegration(patch);
272 }
273
274 return toAttributes(observed, accountId);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 yield* mcn
279 .deleteCloudIntegration({
280 accountId: output.accountId,
281 providerId: output.integrationId,
282 })
283 .pipe(Effect.catchTag("CloudIntegrationNotFound", () => Effect.void));
284 }),
285
286 list: Effect.fn(function* () {
287 const { accountId } = yield* yield* CloudflareEnvironment;
288 // Account-scoped collection: the list response already carries the
289 // full per-integration shape `read` returns, so no per-item hydrate
290 // is needed. Paginate exhaustively and map each row to Attributes.
291 return yield* mcn.listCloudIntegrations.pages({ accountId }).pipe(
292 Stream.runCollect,
293 Effect.map((chunk) =>
294 Array.from(chunk).flatMap((page) =>
295 (page.result ?? []).map((integration) =>
296 toAttributes(integration, accountId),
297 ),
298 ),
299 ),
300 // Magic Cloud Networking is an entitlement-gated add-on; accounts
301 // without it can't enumerate integrations — treat as empty.
302 Effect.catchTag("FeatureNotEnabled", () => Effect.succeed([])),
303 );
304 }),
305 });
306
307const credentialKeys = [
308 "awsArn",
309 "azureSubscriptionId",
310 "azureTenantId",
311 "gcpProjectId",
312 "gcpServiceAccountEmail",
313] as const;
314
315type ObservedIntegration = Pick<
316 mcn.GetCloudIntegrationResponse,
317 | "id"
318 | "cloudType"
319 | "friendlyName"
320 | "description"
321 | "lifecycleState"
322 | "state"
323 | "lastUpdated"
324 | "awsArn"
325 | "azureSubscriptionId"
326 | "azureTenantId"
327 | "gcpProjectId"
328 | "gcpServiceAccountEmail"
329>;
330
331/**
332 * Read an integration by id, mapping "gone" (`CloudIntegrationNotFound`)
333 * to `undefined`.
334 */
335const getIntegration = (accountId: string, providerId: string) =>
336 mcn.getCloudIntegration({ accountId, providerId }).pipe(
337 Effect.map((integration): ObservedIntegration | undefined => integration),
338 Effect.catchTag("CloudIntegrationNotFound", () =>
339 Effect.succeed(undefined),
340 ),
341 );
342
343/**
344 * Find an integration by exact friendly name. Names are not unique on
345 * Cloudflare's side; if several integrations carry the same name, pick the
346 * lexicographically-first id for determinism.
347 */
348const findByName = (accountId: string, friendlyName: string) =>
349 mcn.listCloudIntegrations.items({ accountId }).pipe(
350 Stream.runCollect,
351 Effect.map((chunk) =>
352 Array.from(chunk)
353 .filter((integration) => integration.friendlyName === friendlyName)
354 .sort((a, b) => a.id.localeCompare(b.id))
355 .at(0),
356 ),
357 );
358
359const integrationName = (id: string, name: string | undefined) =>
360 Effect.gen(function* () {
361 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
362 });
363
364const toAttributes = (
365 integration: ObservedIntegration,
366 accountId: string,
367): CloudIntegrationAttributes => ({
368 integrationId: integration.id,
369 accountId,
370 // Distilled widens generated string enums to open unions (`string & {}`).
371 cloudType: integration.cloudType as CloudIntegrationCloudType,
372 friendlyName: integration.friendlyName,
373 description: integration.description ?? undefined,
374 lifecycleState: integration.lifecycleState as CloudIntegrationLifecycleState,
375 state: integration.state as CloudIntegrationState,
376 awsArn: integration.awsArn ?? undefined,
377 azureSubscriptionId: integration.azureSubscriptionId ?? undefined,
378 azureTenantId: integration.azureTenantId ?? undefined,
379 gcpProjectId: integration.gcpProjectId ?? undefined,
380 gcpServiceAccountEmail: integration.gcpServiceAccountEmail ?? undefined,
381 lastUpdated: integration.lastUpdated,
382});