| 1 | import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3"; |
| 2 | import * as iam from "@distilled.cloud/gcp/unstable/iam_v1"; |
| 3 | import * as Config from "effect/Config"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import * as Binding from "../Binding.ts"; |
| 10 | import type { Input } from "../Input.ts"; |
| 11 | import { |
| 12 | Resource, |
| 13 | type ResourceBinding, |
| 14 | type ResourceLike, |
| 15 | } from "../Resource.ts"; |
| 16 | import { |
| 17 | hasIamMembership, |
| 18 | projectRolesOf, |
| 19 | revokeIamMembership, |
| 20 | updateIamMembership, |
| 21 | type GcpIamResourceKind, |
| 22 | type IamCondition, |
| 23 | } from "./IamPolicy.ts"; |
| 24 | import { parseServiceAccountKey } from "./Token.ts"; |
| 25 | |
| 26 | export type { GcpIamResourceKind } from "./IamPolicy.ts"; |
| 27 | |
| 28 | /** |
| 29 | * IAM grant a binding attaches to a GCP runtime host (Cloud Run |
| 30 | * Service/Job/WorkerPool, Cloud Function, or a `Kubernetes.Deployment` / |
| 31 | * `Kubernetes.Job` on GKE) — the GCP analog of an AWS binding's |
| 32 | * `policyStatements`. The role is granted to the host's runtime identity: |
| 33 | * the runtime service account, or on GKE the Kubernetes ServiceAccount's |
| 34 | * Workload Identity Federation principal. |
| 35 | */ |
| 36 | export type GcpIamGrant = { |
| 37 | /** |
| 38 | * Predefined or custom role, e.g. `roles/pubsub.publisher`. Pick the |
| 39 | * narrowest role that covers the binding's operation. |
| 40 | */ |
| 41 | role: string; |
| 42 | /** |
| 43 | * Grant on this one resource's IAM policy (the analog of an IAM |
| 44 | * statement's `Resource: [arn]`). Omit only for services with no |
| 45 | * resource-level IAM, in which case the role is granted on the project. |
| 46 | */ |
| 47 | resource?: { |
| 48 | kind: Exclude<GcpIamResourceKind, "project">; |
| 49 | /** |
| 50 | * Full resource name, e.g. `projects/p/topics/t` (a bucket name for |
| 51 | * `storage.bucket`), or {@link HOST_SERVICE_ACCOUNT} with kind |
| 52 | * `iam.serviceAccount` for the host's own runtime service account. |
| 53 | */ |
| 54 | name: string; |
| 55 | }; |
| 56 | /** |
| 57 | * For a project-level grant on a service with no per-resource IAM |
| 58 | * policy: an IAM Condition narrowing the role to one resource, e.g. |
| 59 | * `resource.name == "projects/p/databases/d"`. |
| 60 | */ |
| 61 | condition?: IamCondition; |
| 62 | }; |
| 63 | |
| 64 | /** |
| 65 | * Binding contract for GCP effectful hosts. Capability implementations |
| 66 | * call `host.bind` with env (packed into the container/function) and IAM |
| 67 | * grants (applied to the runtime service account). |
| 68 | */ |
| 69 | export type GcpHostBinding = { |
| 70 | env?: Record<string, any>; |
| 71 | iam?: GcpIamGrant[]; |
| 72 | /** |
| 73 | * Cloud SQL connection names (`project:region:instance`) to mount as |
| 74 | * Unix sockets under `/cloudsql` (Cloud Run services only). |
| 75 | */ |
| 76 | cloudSqlInstances?: string[]; |
| 77 | }; |
| 78 | |
| 79 | /** |
| 80 | * One role granted to a host's runtime service account on one resource, |
| 81 | * recorded on the host's attributes so a later deploy can revoke it once |
| 82 | * the binding that asked for it is gone. |
| 83 | */ |
| 84 | export type AppliedIamGrant = { |
| 85 | kind: GcpIamResourceKind; |
| 86 | /** Full resource name, or the project id for `project`. */ |
| 87 | name: string; |
| 88 | role: string; |
| 89 | /** IAM Condition the role is bound under, if any. */ |
| 90 | condition?: IamCondition; |
| 91 | }; |
| 92 | |
| 93 | const GCP_HOST_TYPES = new Set([ |
| 94 | "GCP.Run.Service", |
| 95 | "GCP.Run.Job", |
| 96 | "GCP.Run.WorkerPool", |
| 97 | "GCP.CloudFunctions.Function", |
| 98 | // Kubernetes workloads accept `{ env, iam }` through the augmented |
| 99 | // `WorkloadBindingContract`; the GKE cluster adapter grants `iam` to the |
| 100 | // workload's Workload Identity principal. |
| 101 | "Kubernetes.Deployment", |
| 102 | "Kubernetes.Job", |
| 103 | ]); |
| 104 | |
| 105 | /** |
| 106 | * True for any Alchemy host that accepts {@link GcpHostBinding}: the Cloud |
| 107 | * Run and Cloud Functions hosts, and `Kubernetes.Deployment` / |
| 108 | * `Kubernetes.Job` (granted through the GKE cluster adapter). HTTP |
| 109 | * `Binding.Service` implementations guard `host.bind` with this before |
| 110 | * granting IAM / injecting env. |
| 111 | */ |
| 112 | export const isGcpHost = ( |
| 113 | value: ResourceLike | undefined, |
| 114 | ): value is Resource<string, object, object, GcpHostBinding> => |
| 115 | typeof value === "object" && |
| 116 | value !== null && |
| 117 | "Type" in value && |
| 118 | GCP_HOST_TYPES.has((value as { Type: string }).Type); |
| 119 | |
| 120 | /** Merge the env and IAM grants of every binding attached to a host. */ |
| 121 | export const collectHostBindings = ( |
| 122 | bindings: readonly ResourceBinding<GcpHostBinding>[], |
| 123 | ): { env: Record<string, any>; iam: GcpIamGrant[] } => ({ |
| 124 | env: bindings |
| 125 | .map((binding) => binding.data?.env) |
| 126 | .reduce<Record<string, any>>((acc, next) => ({ ...acc, ...next }), {}), |
| 127 | iam: bindings.flatMap((binding) => binding.data?.iam ?? []), |
| 128 | }); |
| 129 | |
| 130 | const lastSegment = (value: string) => { |
| 131 | const trimmed = value.replace(/\/+$/, ""); |
| 132 | const parts = trimmed.split("/"); |
| 133 | return parts[parts.length - 1] || trimmed; |
| 134 | }; |
| 135 | |
| 136 | /** |
| 137 | * Project number for the default Compute Engine service account |
| 138 | * (`{number}-compute@developer.gserviceaccount.com`). |
| 139 | */ |
| 140 | export const projectNumber = (project: string) => |
| 141 | resourcemanager |
| 142 | .getProjects({ name: `projects/${project}` }) |
| 143 | .pipe(Effect.map((resource) => lastSegment(resource.name ?? ""))); |
| 144 | |
| 145 | export const defaultComputeServiceAccount = (project: string) => |
| 146 | projectNumber(project).pipe( |
| 147 | Effect.map((number) => `${number}-compute@developer.gserviceaccount.com`), |
| 148 | ); |
| 149 | |
| 150 | /** 64-bit FNV-1a, hex. Deterministic and dependency-free. */ |
| 151 | const fnv1a64 = (input: string): string => { |
| 152 | let hash = 0xcbf29ce484222325n; |
| 153 | for (let index = 0; index < input.length; index++) { |
| 154 | hash ^= BigInt(input.charCodeAt(index)); |
| 155 | hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn; |
| 156 | } |
| 157 | return hash.toString(16).padStart(16, "0"); |
| 158 | }; |
| 159 | |
| 160 | /** |
| 161 | * RFC1035 account id (6–30 chars) for the runtime service account Alchemy |
| 162 | * mints for one physical host. The hash covers the host type and full |
| 163 | * resource name, so two hosts never share an account — not across |
| 164 | * stages, not a Service and a Job with the same id, and not the two |
| 165 | * generations of a create-before-delete replacement. |
| 166 | */ |
| 167 | export const hostServiceAccountId = ( |
| 168 | hostType: string, |
| 169 | resourceName: string, |
| 170 | ): string => { |
| 171 | const slug = lastSegment(resourceName) |
| 172 | .toLowerCase() |
| 173 | .replace(/[^a-z0-9-]/g, "-") |
| 174 | .replace(/-+/g, "-") |
| 175 | .replace(/^-+|-+$/g, "") |
| 176 | .slice(0, 12) |
| 177 | .replace(/-+$/g, ""); |
| 178 | const hash = fnv1a64(`${hostType}:${resourceName}`).slice(0, 10); |
| 179 | return /^[a-z]/.test(slug) ? `alch-${slug}-${hash}` : `alch-${hash}`; |
| 180 | }; |
| 181 | |
| 182 | export const hostServiceAccountEmail = (project: string, accountId: string) => |
| 183 | `${accountId}@${project}.iam.gserviceaccount.com`; |
| 184 | |
| 185 | /** Display name stamped on Alchemy-minted host SAs so nuke can list them. */ |
| 186 | export const ALCHEMY_HOST_SA_DISPLAY_NAME = "alchemy-host"; |
| 187 | |
| 188 | const serviceAccountName = (project: string, email: string) => |
| 189 | `projects/${project}/serviceAccounts/${email}`; |
| 190 | |
| 191 | /** |
| 192 | * Let the deploying principal and the serverless robots `actAs` the host |
| 193 | * service account so Cloud Run / Functions accept it as the runtime |
| 194 | * identity. |
| 195 | */ |
| 196 | const grantActAs = (project: string, email: string) => |
| 197 | Effect.gen(function* () { |
| 198 | const number = yield* projectNumber(project); |
| 199 | const members = [ |
| 200 | `serviceAccount:service-${number}@serverless-robot-prod.iam.gserviceaccount.com`, |
| 201 | `serviceAccount:service-${number}@gcf-admin-robot.iam.gserviceaccount.com`, |
| 202 | ]; |
| 203 | const keyFile = yield* Config.option( |
| 204 | Config.String("GOOGLE_APPLICATION_CREDENTIALS"), |
| 205 | ); |
| 206 | if (Option.isSome(keyFile)) { |
| 207 | const fs = yield* FileSystem.FileSystem; |
| 208 | const raw = yield* fs |
| 209 | .readFileString(keyFile.value) |
| 210 | .pipe( |
| 211 | Effect.catchReason("PlatformError", "NotFound", () => |
| 212 | Effect.succeed(""), |
| 213 | ), |
| 214 | ); |
| 215 | if (raw.length > 0) { |
| 216 | const parsed = yield* parseServiceAccountKey(raw).pipe( |
| 217 | Effect.catchTag("AuthError", () => Effect.succeed(undefined)), |
| 218 | ); |
| 219 | if (parsed?.client_email) { |
| 220 | members.push(`serviceAccount:${parsed.client_email}`); |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | for (const member of members) { |
| 225 | yield* updateIamMembership({ |
| 226 | kind: "iam.serviceAccount", |
| 227 | name: serviceAccountName(project, email), |
| 228 | member, |
| 229 | add: ["roles/iam.serviceAccountUser"], |
| 230 | }); |
| 231 | } |
| 232 | }); |
| 233 | |
| 234 | /** |
| 235 | * Create (or adopt) the runtime service account for one host and let |
| 236 | * Cloud Run / Functions `actAs` it. `created` reports whether this call |
| 237 | * minted it, so a failed first deploy can clean up only what it made. |
| 238 | */ |
| 239 | export const ensureHostServiceAccount = (options: { |
| 240 | project: string; |
| 241 | hostType: string; |
| 242 | resourceName: string; |
| 243 | }) => { |
| 244 | const accountId = hostServiceAccountId( |
| 245 | options.hostType, |
| 246 | options.resourceName, |
| 247 | ); |
| 248 | const email = hostServiceAccountEmail(options.project, accountId); |
| 249 | return Effect.gen(function* () { |
| 250 | const existing = yield* iam |
| 251 | .getProjectsServiceAccounts({ |
| 252 | name: serviceAccountName(options.project, email), |
| 253 | }) |
| 254 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 255 | let created = false; |
| 256 | if (existing?.email === undefined || existing.email.length === 0) { |
| 257 | created = yield* iam |
| 258 | .createProjectsServiceAccounts({ |
| 259 | name: `projects/${options.project}`, |
| 260 | body: { |
| 261 | accountId, |
| 262 | serviceAccount: { displayName: ALCHEMY_HOST_SA_DISPLAY_NAME }, |
| 263 | }, |
| 264 | }) |
| 265 | .pipe( |
| 266 | Effect.as(true), |
| 267 | Effect.retry({ |
| 268 | while: (error) => error._tag === "ServiceAccountQuotaExceeded", |
| 269 | times: 6, |
| 270 | schedule: Schedule.exponential("2 seconds"), |
| 271 | }), |
| 272 | Effect.catchTag("Conflict", () => Effect.succeed(false)), |
| 273 | ); |
| 274 | } |
| 275 | yield* grantActAs(options.project, email); |
| 276 | return { email, created }; |
| 277 | }); |
| 278 | }; |
| 279 | |
| 280 | /** Retry Cloud Run / Functions create while IAM `actAs` is propagating. */ |
| 281 | export const retryActAs = <A, E extends { _tag: string }, R>( |
| 282 | effect: Effect.Effect<A, E, R>, |
| 283 | ) => |
| 284 | effect.pipe( |
| 285 | Effect.retry({ |
| 286 | while: (error) => error._tag === "Forbidden", |
| 287 | times: 8, |
| 288 | schedule: Schedule.spaced("2 seconds"), |
| 289 | }), |
| 290 | ); |
| 291 | |
| 292 | const conditionKey = (condition: IamCondition | undefined) => |
| 293 | condition === undefined |
| 294 | ? "" |
| 295 | : `${condition.title ?? ""}\u0001${condition.expression ?? ""}`; |
| 296 | |
| 297 | const grantKey = (grant: AppliedIamGrant) => |
| 298 | `${grant.kind}\u0000${grant.name}\u0000${grant.role}\u0000${conditionKey(grant.condition)}`; |
| 299 | |
| 300 | const groupByTarget = (grants: readonly AppliedIamGrant[]) => { |
| 301 | const groups = new Map< |
| 302 | string, |
| 303 | { |
| 304 | kind: GcpIamResourceKind; |
| 305 | name: string; |
| 306 | condition: IamCondition | undefined; |
| 307 | roles: Set<string>; |
| 308 | } |
| 309 | >(); |
| 310 | for (const grant of grants) { |
| 311 | const key = `${grant.kind}\u0000${grant.name}\u0000${conditionKey(grant.condition)}`; |
| 312 | const group = groups.get(key) ?? { |
| 313 | kind: grant.kind, |
| 314 | name: grant.name, |
| 315 | condition: grant.condition, |
| 316 | roles: new Set<string>(), |
| 317 | }; |
| 318 | group.roles.add(grant.role); |
| 319 | groups.set(key, group); |
| 320 | } |
| 321 | return [...groups.values()]; |
| 322 | }; |
| 323 | |
| 324 | /** |
| 325 | * Placeholder resource name (kind `iam.serviceAccount`) for the host's |
| 326 | * own runtime service account, e.g. to let it sign blobs as itself. |
| 327 | * Resolved when the host syncs IAM; dropped for hosts whose identity is |
| 328 | * not a service account (GKE Workload Identity principals). |
| 329 | */ |
| 330 | export const HOST_SERVICE_ACCOUNT = "@host"; |
| 331 | |
| 332 | /** Desired grants for a host, deduplicated and resolved to concrete targets. */ |
| 333 | export const desiredHostGrants = ( |
| 334 | project: string, |
| 335 | grants: readonly GcpIamGrant[], |
| 336 | serviceAccount?: string, |
| 337 | ): AppliedIamGrant[] => { |
| 338 | const ownAccount = |
| 339 | serviceAccount !== undefined && !serviceAccount.includes(":") |
| 340 | ? `projects/-/serviceAccounts/${serviceAccount}` |
| 341 | : undefined; |
| 342 | const unique = new Map<string, AppliedIamGrant>(); |
| 343 | for (const grant of grants) { |
| 344 | if (grant.role.length === 0) continue; |
| 345 | const self = grant.resource?.name === HOST_SERVICE_ACCOUNT; |
| 346 | if (self && ownAccount === undefined) continue; |
| 347 | const applied: AppliedIamGrant = |
| 348 | grant.resource === undefined |
| 349 | ? { |
| 350 | kind: "project", |
| 351 | name: project, |
| 352 | role: grant.role, |
| 353 | ...(grant.condition ? { condition: grant.condition } : {}), |
| 354 | } |
| 355 | : { |
| 356 | kind: grant.resource.kind, |
| 357 | name: self ? ownAccount! : grant.resource.name, |
| 358 | role: grant.role, |
| 359 | }; |
| 360 | unique.set(grantKey(applied), applied); |
| 361 | } |
| 362 | return [...unique.values()].sort((left, right) => |
| 363 | grantKey(left).localeCompare(grantKey(right)), |
| 364 | ); |
| 365 | }; |
| 366 | |
| 367 | /** |
| 368 | * Converge the runtime service account's IAM onto the host's bindings. |
| 369 | * |
| 370 | * Every desired grant is added on its target resource. Grants recorded |
| 371 | * on a previous deploy that no binding asks for any more are revoked. |
| 372 | * For an Alchemy-minted account, the host project policy is additionally |
| 373 | * synced against the observed policy, so roles granted out of band (or |
| 374 | * lost from state) are revoked too; a user-supplied account is only ever |
| 375 | * stripped of grants Alchemy itself recorded. |
| 376 | */ |
| 377 | export const syncHostIam = Effect.fn(function* (options: { |
| 378 | project: string; |
| 379 | serviceAccount: string; |
| 380 | managed: boolean; |
| 381 | bindings: readonly ResourceBinding<GcpHostBinding>[]; |
| 382 | previous: readonly AppliedIamGrant[] | undefined; |
| 383 | }) { |
| 384 | const collected = collectHostBindings(options.bindings); |
| 385 | const desired = desiredHostGrants( |
| 386 | options.project, |
| 387 | collected.iam, |
| 388 | options.serviceAccount, |
| 389 | ); |
| 390 | const desiredKeys = new Set(desired.map(grantKey)); |
| 391 | const stale = (options.previous ?? []).filter( |
| 392 | (grant) => !desiredKeys.has(grantKey(grant)), |
| 393 | ); |
| 394 | |
| 395 | // A principal Alchemy doesn't own (user-supplied service account, GKE |
| 396 | // Workload Identity principal) may already hold a role for other |
| 397 | // reasons. Such a grant is used but never recorded as ours, so a later |
| 398 | // deploy or destroy never revokes it. |
| 399 | const previousKeys = new Set((options.previous ?? []).map(grantKey)); |
| 400 | const preexisting = new Set<string>(); |
| 401 | if (!options.managed) { |
| 402 | for (const grant of desired) { |
| 403 | if (previousKeys.has(grantKey(grant)) || grant.condition !== undefined) { |
| 404 | continue; |
| 405 | } |
| 406 | const held = yield* hasIamMembership({ |
| 407 | kind: grant.kind, |
| 408 | name: grant.name, |
| 409 | member: options.serviceAccount, |
| 410 | role: grant.role, |
| 411 | }); |
| 412 | if (held) preexisting.add(grantKey(grant)); |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | for (const group of groupByTarget(desired)) { |
| 417 | yield* updateIamMembership({ |
| 418 | kind: group.kind, |
| 419 | name: group.name, |
| 420 | member: options.serviceAccount, |
| 421 | add: group.roles, |
| 422 | condition: group.condition, |
| 423 | }); |
| 424 | } |
| 425 | |
| 426 | // Unconditional project grants of a minted account are synced against |
| 427 | // the observed policy below; conditional ones only via the record. |
| 428 | const isHostProject = (grant: { |
| 429 | kind: string; |
| 430 | name: string; |
| 431 | condition?: IamCondition; |
| 432 | }) => |
| 433 | grant.kind === "project" && |
| 434 | grant.name === options.project && |
| 435 | grant.condition === undefined; |
| 436 | |
| 437 | if (options.managed) { |
| 438 | const wanted = new Set( |
| 439 | desired.filter(isHostProject).map((grant) => grant.role), |
| 440 | ); |
| 441 | const held = yield* projectRolesOf(options.project, options.serviceAccount); |
| 442 | const extra = held.filter((role) => !wanted.has(role)); |
| 443 | if (extra.length > 0) { |
| 444 | yield* revokeIamMembership({ |
| 445 | kind: "project", |
| 446 | name: options.project, |
| 447 | member: options.serviceAccount, |
| 448 | roles: extra, |
| 449 | }); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | for (const group of groupByTarget(stale)) { |
| 454 | if (options.managed && isHostProject(group)) continue; |
| 455 | yield* revokeIamMembership({ |
| 456 | kind: group.kind, |
| 457 | name: group.name, |
| 458 | member: options.serviceAccount, |
| 459 | roles: group.roles, |
| 460 | condition: group.condition, |
| 461 | }); |
| 462 | } |
| 463 | |
| 464 | return { |
| 465 | env: collected.env, |
| 466 | grants: desired.filter((grant) => !preexisting.has(grantKey(grant))), |
| 467 | }; |
| 468 | }); |
| 469 | |
| 470 | /** Revoke every recorded grant from the host's runtime service account. */ |
| 471 | export const revokeHostIam = Effect.fn(function* (options: { |
| 472 | serviceAccount: string; |
| 473 | grants: readonly AppliedIamGrant[]; |
| 474 | }) { |
| 475 | for (const group of groupByTarget(options.grants)) { |
| 476 | yield* revokeIamMembership({ |
| 477 | kind: group.kind, |
| 478 | name: group.name, |
| 479 | member: options.serviceAccount, |
| 480 | roles: group.roles, |
| 481 | condition: group.condition, |
| 482 | }); |
| 483 | } |
| 484 | }); |
| 485 | |
| 486 | /** |
| 487 | * Delete an Alchemy-minted host service account (no-op if it is already |
| 488 | * gone) after revoking its project roles and every recorded grant, so no |
| 489 | * `deleted:serviceAccount:` members linger on other resources. |
| 490 | */ |
| 491 | export const deleteHostServiceAccount = Effect.fn(function* (options: { |
| 492 | project: string; |
| 493 | email: string; |
| 494 | grants?: readonly AppliedIamGrant[]; |
| 495 | }) { |
| 496 | yield* revokeHostIam({ |
| 497 | serviceAccount: options.email, |
| 498 | grants: options.grants ?? [], |
| 499 | }); |
| 500 | const held = yield* projectRolesOf(options.project, options.email); |
| 501 | if (held.length > 0) { |
| 502 | yield* revokeIamMembership({ |
| 503 | kind: "project", |
| 504 | name: options.project, |
| 505 | member: options.email, |
| 506 | roles: held, |
| 507 | }); |
| 508 | } |
| 509 | const name = serviceAccountName(options.project, options.email); |
| 510 | yield* iam |
| 511 | .deleteProjectsServiceAccounts({ name }) |
| 512 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 513 | // Block until IAM stops serving the account. |
| 514 | yield* iam.getProjectsServiceAccounts({ name }).pipe( |
| 515 | Effect.flatMap(() => |
| 516 | Effect.fail(new HostServiceAccountStillExists({ email: options.email })), |
| 517 | ), |
| 518 | Effect.catchTag("NotFound", () => Effect.void), |
| 519 | Effect.retry({ |
| 520 | while: (error) => error._tag === "GCP.HostServiceAccountStillExists", |
| 521 | times: 30, |
| 522 | schedule: Schedule.spaced("2 seconds"), |
| 523 | }), |
| 524 | ); |
| 525 | }); |
| 526 | |
| 527 | export class HostServiceAccountStillExists extends Data.TaggedError( |
| 528 | "GCP.HostServiceAccountStillExists", |
| 529 | )<{ email: string }> {} |
| 530 | |
| 531 | /** |
| 532 | * Bind IAM (+ optional env) onto the ambient GCP host at deploy time. |
| 533 | * No-op inside the deployed runtime (`__ALCHEMY_RUNTIME__`). |
| 534 | */ |
| 535 | export const bindGcpHost = (options: { |
| 536 | tag: string; |
| 537 | resource: { readonly LogicalId: string }; |
| 538 | iam: Input<GcpIamGrant>[]; |
| 539 | env?: Record<string, any>; |
| 540 | cloudSqlInstances?: Input<string>[]; |
| 541 | }) => |
| 542 | Effect.gen(function* () { |
| 543 | if (globalThis.__ALCHEMY_RUNTIME__) return; |
| 544 | const host = yield* Binding.Host; |
| 545 | if (!isGcpHost(host)) return; |
| 546 | yield* host.bind`Allow(${host}, ${options.tag}(${options.resource}))`({ |
| 547 | iam: options.iam, |
| 548 | env: options.env, |
| 549 | ...(options.cloudSqlInstances |
| 550 | ? { cloudSqlInstances: options.cloudSqlInstances } |
| 551 | : {}), |
| 552 | }); |
| 553 | }); |