| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { ResourceBinding } from "../Resource.ts"; |
| 3 | import { packEnvValue } from "../RuntimeContext.ts"; |
| 4 | import { Stack } from "../Stack.ts"; |
| 5 | import { |
| 6 | defaultComputeServiceAccount, |
| 7 | deleteHostServiceAccount, |
| 8 | ensureHostServiceAccount, |
| 9 | hostServiceAccountEmail, |
| 10 | hostServiceAccountId, |
| 11 | syncHostIam, |
| 12 | type AppliedIamGrant, |
| 13 | type GcpHostBinding, |
| 14 | } from "./Host.ts"; |
| 15 | |
| 16 | /** |
| 17 | * Shared deploy-time plumbing for the GCP runtime hosts (`Run.Service`, |
| 18 | * `Run.Job`, `Run.WorkerPool`, `CloudFunctions.Function`): runtime |
| 19 | * identity, IAM sync, container env, and the generated bootstrap entry. |
| 20 | * |
| 21 | * NOT exported from `index.ts`. |
| 22 | */ |
| 23 | |
| 24 | /** Attributes every GCP host records about its runtime identity. */ |
| 25 | export interface HostIdentityAttrs { |
| 26 | /** Runtime service account email. */ |
| 27 | serviceAccount: string | undefined; |
| 28 | /** True when Alchemy minted the per-host runtime service account. */ |
| 29 | managedServiceAccount: boolean; |
| 30 | /** |
| 31 | * IAM roles bindings granted to the runtime service account, by target |
| 32 | * resource. Used to revoke grants whose binding was removed. |
| 33 | */ |
| 34 | iamGrants: AppliedIamGrant[]; |
| 35 | } |
| 36 | |
| 37 | /** True when `serviceAccount` is the account Alchemy mints for this host. */ |
| 38 | export const isManagedServiceAccount = (options: { |
| 39 | project: string; |
| 40 | hostType: string; |
| 41 | resourceName: string; |
| 42 | serviceAccount: string | undefined; |
| 43 | }) => |
| 44 | (options.serviceAccount ?? "") === |
| 45 | hostServiceAccountEmail( |
| 46 | options.project, |
| 47 | hostServiceAccountId(options.hostType, options.resourceName), |
| 48 | ); |
| 49 | |
| 50 | /** |
| 51 | * Resolve the runtime identity for a host and converge its IAM. |
| 52 | * |
| 53 | * A user-supplied account is used as-is (grants are additive). Otherwise |
| 54 | * an Effect-native host, a host with IAM bindings, or a host that was |
| 55 | * already on a minted account gets its own minted account; a plain |
| 56 | * image-only host with no bindings keeps the project's default Compute |
| 57 | * account. |
| 58 | * |
| 59 | * The returned `cleanup` deletes an account minted by THIS call, for a |
| 60 | * create that fails before the host exists. |
| 61 | */ |
| 62 | export const resolveHostIdentity = Effect.fn(function* (options: { |
| 63 | project: string; |
| 64 | hostType: string; |
| 65 | resourceName: string; |
| 66 | userServiceAccount: string | undefined; |
| 67 | effectNative: boolean; |
| 68 | bindings: readonly ResourceBinding<GcpHostBinding>[]; |
| 69 | output: Partial<HostIdentityAttrs> | undefined; |
| 70 | }) { |
| 71 | const hasGrants = options.bindings.some( |
| 72 | (binding) => (binding.data?.iam?.length ?? 0) > 0, |
| 73 | ); |
| 74 | const userSa = |
| 75 | options.userServiceAccount !== undefined && |
| 76 | options.userServiceAccount.length > 0 |
| 77 | ? options.userServiceAccount |
| 78 | : undefined; |
| 79 | const managed = |
| 80 | userSa === undefined && |
| 81 | (options.effectNative || |
| 82 | hasGrants || |
| 83 | options.output?.managedServiceAccount === true); |
| 84 | |
| 85 | let serviceAccount: string; |
| 86 | let created = false; |
| 87 | if (userSa !== undefined) { |
| 88 | serviceAccount = userSa; |
| 89 | } else if (managed) { |
| 90 | const ensured = yield* ensureHostServiceAccount({ |
| 91 | project: options.project, |
| 92 | hostType: options.hostType, |
| 93 | resourceName: options.resourceName, |
| 94 | }); |
| 95 | serviceAccount = ensured.email; |
| 96 | created = ensured.created; |
| 97 | } else { |
| 98 | serviceAccount = yield* defaultComputeServiceAccount(options.project); |
| 99 | } |
| 100 | |
| 101 | // Grants recorded against a different account (the host switched |
| 102 | // identity) belong to that account; never replay them onto this one. |
| 103 | const previous = |
| 104 | options.output?.serviceAccount === serviceAccount |
| 105 | ? options.output.iamGrants |
| 106 | : undefined; |
| 107 | const synced = yield* syncHostIam({ |
| 108 | project: options.project, |
| 109 | serviceAccount, |
| 110 | managed, |
| 111 | bindings: options.bindings, |
| 112 | previous, |
| 113 | }).pipe( |
| 114 | Effect.tapError(() => |
| 115 | created |
| 116 | ? deleteHostServiceAccount({ |
| 117 | project: options.project, |
| 118 | email: serviceAccount, |
| 119 | }).pipe(Effect.ignore) |
| 120 | : Effect.void, |
| 121 | ), |
| 122 | ); |
| 123 | |
| 124 | return { |
| 125 | serviceAccount, |
| 126 | managed, |
| 127 | env: synced.env, |
| 128 | grants: synced.grants, |
| 129 | /** Undo a service account minted by this reconcile. */ |
| 130 | cleanup: created |
| 131 | ? deleteHostServiceAccount({ |
| 132 | project: options.project, |
| 133 | email: serviceAccount, |
| 134 | grants: synced.grants, |
| 135 | }).pipe(Effect.ignore) |
| 136 | : Effect.void, |
| 137 | }; |
| 138 | }); |
| 139 | |
| 140 | /** Release a host's runtime identity on delete. */ |
| 141 | export const releaseHostIdentity = (output: { |
| 142 | project: string; |
| 143 | serviceAccount: string | undefined; |
| 144 | managedServiceAccount: boolean; |
| 145 | iamGrants?: readonly AppliedIamGrant[]; |
| 146 | }) => |
| 147 | output.serviceAccount === undefined || !output.managedServiceAccount |
| 148 | ? Effect.void |
| 149 | : deleteHostServiceAccount({ |
| 150 | project: output.project, |
| 151 | email: output.serviceAccount, |
| 152 | grants: output.iamGrants ?? [], |
| 153 | }); |
| 154 | |
| 155 | /** |
| 156 | * Env every Effect-native container needs to rebuild its `Stack` at boot |
| 157 | * (`stackFromEnv` in the bootstrap) — the GCP counterpart of the |
| 158 | * `alchemyEnv` block on `AWS.ECS.Task` / `AWS.Lambda.Function`. |
| 159 | */ |
| 160 | export const alchemyRuntimeEnv = Effect.gen(function* () { |
| 161 | const stack = yield* Stack; |
| 162 | return { |
| 163 | ALCHEMY_STACK_NAME: stack.name, |
| 164 | ALCHEMY_STAGE: stack.stage, |
| 165 | ALCHEMY_PHASE: "runtime", |
| 166 | } as Record<string, string>; |
| 167 | }); |
| 168 | |
| 169 | export interface ContainerEnvVar { |
| 170 | name?: string; |
| 171 | value?: string; |
| 172 | valueSource?: unknown; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Merge env maps into a container's env list without mutating either. |
| 177 | * Later sources win by name; an explicit entry already on the container |
| 178 | * (the user's own `containers[].env`) wins over every injected value, and |
| 179 | * a name never appears twice (Cloud Run rejects duplicates). |
| 180 | */ |
| 181 | export const mergeContainerEnv = <V extends ContainerEnvVar>( |
| 182 | explicit: readonly V[] | undefined, |
| 183 | ...injected: ReadonlyArray<Record<string, unknown> | undefined> |
| 184 | ): V[] => { |
| 185 | const byName = new Map<string, V>(); |
| 186 | for (const source of injected) { |
| 187 | for (const [name, value] of Object.entries(source ?? {})) { |
| 188 | if (value === undefined) continue; |
| 189 | byName.set(name, { name, value: packEnvValue(value) } as V); |
| 190 | } |
| 191 | } |
| 192 | for (const entry of explicit ?? []) { |
| 193 | if (entry.name === undefined) continue; |
| 194 | byName.set(entry.name, entry); |
| 195 | } |
| 196 | return [...byName.values()].sort((left, right) => |
| 197 | (left.name ?? "").localeCompare(right.name ?? ""), |
| 198 | ); |
| 199 | }; |
| 200 | |
| 201 | /** Volume name and mount path Cloud Run uses for Cloud SQL Unix sockets. */ |
| 202 | export const CLOUD_SQL_VOLUME = "cloudsql"; |
| 203 | export const CLOUD_SQL_MOUNT_PATH = "/cloudsql"; |
| 204 | |
| 205 | type CloudSqlTemplate = { |
| 206 | volumes?: Array<{ |
| 207 | name?: string; |
| 208 | cloudSqlInstance?: { instances?: string[] }; |
| 209 | }>; |
| 210 | containers?: Array<{ |
| 211 | volumeMounts?: Array<{ name?: string; mountPath?: string }>; |
| 212 | }>; |
| 213 | }; |
| 214 | |
| 215 | /** |
| 216 | * Mount the Cloud SQL instances requested by bindings (`cloudSqlInstances`) |
| 217 | * as the `cloudsql` volume on the first container, so each instance's |
| 218 | * socket appears at `/cloudsql/{connectionName}`. Instances the user |
| 219 | * already declared on a `cloudsql` volume are kept. |
| 220 | */ |
| 221 | export const mountCloudSqlInstances = <T extends CloudSqlTemplate>( |
| 222 | template: T, |
| 223 | bindings: readonly ResourceBinding<GcpHostBinding>[], |
| 224 | ): T => { |
| 225 | const requested = bindings.flatMap( |
| 226 | (binding) => binding.data?.cloudSqlInstances ?? [], |
| 227 | ); |
| 228 | if (requested.length === 0) return template; |
| 229 | const existing = template.volumes?.find( |
| 230 | (volume) => volume.name === CLOUD_SQL_VOLUME, |
| 231 | ); |
| 232 | const instances = [ |
| 233 | ...new Set([ |
| 234 | ...(existing?.cloudSqlInstance?.instances ?? []), |
| 235 | ...requested, |
| 236 | ]), |
| 237 | ].sort(); |
| 238 | const volumes = [ |
| 239 | ...(template.volumes ?? []).filter( |
| 240 | (volume) => volume.name !== CLOUD_SQL_VOLUME, |
| 241 | ), |
| 242 | { name: CLOUD_SQL_VOLUME, cloudSqlInstance: { instances } }, |
| 243 | ]; |
| 244 | const containers = (template.containers ?? []).map((container, index) => |
| 245 | index === 0 && |
| 246 | !(container.volumeMounts ?? []).some( |
| 247 | (mount) => mount.name === CLOUD_SQL_VOLUME, |
| 248 | ) |
| 249 | ? { |
| 250 | ...container, |
| 251 | volumeMounts: [ |
| 252 | ...(container.volumeMounts ?? []), |
| 253 | { name: CLOUD_SQL_VOLUME, mountPath: CLOUD_SQL_MOUNT_PATH }, |
| 254 | ], |
| 255 | } |
| 256 | : container, |
| 257 | ); |
| 258 | return { ...template, volumes, containers }; |
| 259 | }; |
| 260 | |
| 261 | /** |
| 262 | * Generated entry for an Effect-native GCP container: imports only |
| 263 | * `alchemy/Runtime/Bootstrap/<module>` plus the user's `main`. The runtime |
| 264 | * flag is raised before the user's module evaluates (hence the dynamic |
| 265 | * import) so module-scope code sees it. |
| 266 | */ |
| 267 | export const makeGcpBootstrap = |
| 268 | (module: "CloudRun" | "CloudRunJob", handler: string) => |
| 269 | (importPath: string): string => |
| 270 | ` |
| 271 | import { bootstrap } from "alchemy/Runtime/Bootstrap/${module}"; |
| 272 | |
| 273 | globalThis.__ALCHEMY_RUNTIME__ = true; |
| 274 | const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)}); |
| 275 | |
| 276 | await bootstrap(entrypoint); |
| 277 | `; |