| 1 | import * as mcn from "@distilled.cloud/cloudflare/magic-cloud-networking"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | const TypeId = "Cloudflare.MagicCloudNetworking.CloudIntegration" as const; |
| 15 | type TypeId = typeof TypeId; |
| 16 | |
| 17 | /** |
| 18 | * The cloud provider an integration discovers resources from. |
| 19 | */ |
| 20 | export type CloudIntegrationCloudType = |
| 21 | | "AWS" |
| 22 | | "AZURE" |
| 23 | | "GOOGLE" |
| 24 | | "CLOUDFLARE"; |
| 25 | |
| 26 | /** |
| 27 | * Lifecycle state of a cloud integration. |
| 28 | */ |
| 29 | export type CloudIntegrationLifecycleState = |
| 30 | | "ACTIVE" |
| 31 | | "PENDING_SETUP" |
| 32 | | "RETIRED"; |
| 33 | |
| 34 | /** |
| 35 | * Discovery state of a cloud integration. |
| 36 | */ |
| 37 | export type CloudIntegrationState = |
| 38 | | "UNSPECIFIED" |
| 39 | | "PENDING" |
| 40 | | "DISCOVERING" |
| 41 | | "FAILED" |
| 42 | | "SUCCEEDED"; |
| 43 | |
| 44 | export 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 | |
| 89 | export 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 | |
| 118 | export 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 | */ |
| 173 | export const CloudIntegration = Resource<CloudIntegration>(TypeId); |
| 174 | |
| 175 | /** |
| 176 | * Returns true if the given value is a CloudIntegration resource. |
| 177 | */ |
| 178 | export const isCloudIntegration = (value: unknown): value is CloudIntegration => |
| 179 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 180 | |
| 181 | export 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 | |
| 307 | const credentialKeys = [ |
| 308 | "awsArn", |
| 309 | "azureSubscriptionId", |
| 310 | "azureTenantId", |
| 311 | "gcpProjectId", |
| 312 | "gcpServiceAccountEmail", |
| 313 | ] as const; |
| 314 | |
| 315 | type 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 | */ |
| 335 | const 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 | */ |
| 348 | const 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 | |
| 359 | const integrationName = (id: string, name: string | undefined) => |
| 360 | Effect.gen(function* () { |
| 361 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 362 | }); |
| 363 | |
| 364 | const 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 | }); |