1import * as Effect from "effect/Effect";
2import type { ResourceBinding } from "../Resource.ts";
3import { packEnvValue } from "../RuntimeContext.ts";
4import { Stack } from "../Stack.ts";
5import {
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. */
25export 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. */
38export 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 */
62export 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. */
141export 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 */
160export 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
169export 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 */
181export 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. */
202export const CLOUD_SQL_VOLUME = "cloudsql";
203export const CLOUD_SQL_MOUNT_PATH = "/cloudsql";
204
205type 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 */
221export 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 */
267export const makeGcpBootstrap =
268 (module: "CloudRun" | "CloudRunJob", handler: string) =>
269 (importPath: string): string =>
270 `
271import { bootstrap } from "alchemy/Runtime/Bootstrap/${module}";
272
273globalThis.__ALCHEMY_RUNTIME__ = true;
274const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)});
275
276await bootstrap(entrypoint);
277`;