1import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { GcpEnvironment } from "../Environment.ts";
7import {
8 ALCHEMY_HOST_SA_DISPLAY_NAME,
9 deleteHostServiceAccount,
10 hostServiceAccountEmail,
11} from "../Host.ts";
12import type { Providers } from "../Providers.ts";
13
14/**
15 * Nuke-only enumeration of Alchemy-minted Cloud Run / Functions runtime
16 * service accounts (`alch-*` with display name {@link ALCHEMY_HOST_SA_DISPLAY_NAME}).
17 * Not a user-facing resource.
18 *
19 * @internal
20 */
21export type HostServiceAccount = Resource<
22 "GCP.IAM.HostServiceAccount",
23 object,
24 {
25 name: string;
26 email: string;
27 project: string;
28 accountId: string;
29 },
30 never,
31 Providers
32>;
33
34export const HostServiceAccount = Resource<HostServiceAccount>(
35 "GCP.IAM.HostServiceAccount",
36);
37
38const toAttrs = (
39 account: iam.ServiceAccount,
40 project: string,
41): HostServiceAccount["Attributes"] => {
42 const email = account.email ?? "";
43 const accountId = email.split("@")[0] ?? "";
44 return {
45 name: account.name ?? "",
46 email,
47 project,
48 accountId,
49 };
50};
51
52const isManaged = (account: iam.ServiceAccount, project: string) => {
53 const email = account.email ?? "";
54 return (
55 account.displayName === ALCHEMY_HOST_SA_DISPLAY_NAME &&
56 email.startsWith("alch-") &&
57 email.endsWith(`@${project}.iam.gserviceaccount.com`)
58 );
59};
60
61export const HostServiceAccountProvider = () =>
62 Provider.succeed(HostServiceAccount, {
63 stables: ["name", "email", "project", "accountId"],
64
65 read: Effect.fn(function* ({ output }) {
66 if (output === undefined) return undefined;
67 const env = yield* GcpEnvironment.current;
68 const accountId = output.accountId;
69 const email = hostServiceAccountEmail(env.project, accountId);
70 const name = `projects/${env.project}/serviceAccounts/${email}`;
71 const existing = yield* iam
72 .getProjectsServiceAccounts({ name })
73 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
74 if (existing === undefined || !isManaged(existing, env.project)) {
75 return undefined;
76 }
77 return toAttrs(existing, env.project);
78 }),
79
80 // Host accounts are minted by their host (`GCP.Run.Service`, …); this
81 // resource only exists so `nuke` can list and delete leaked ones.
82 reconcile: Effect.fn(function* ({ id, output }) {
83 const env = yield* GcpEnvironment.current;
84 const accountId =
85 output?.accountId ?? `alch-${id.toLowerCase()}`.slice(0, 30);
86 const email = hostServiceAccountEmail(env.project, accountId);
87 const name = `projects/${env.project}/serviceAccounts/${email}`;
88 const existing = yield* iam
89 .getProjectsServiceAccounts({ name })
90 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
91 if (existing === undefined) {
92 return {
93 name,
94 email,
95 project: env.project,
96 accountId,
97 };
98 }
99 return toAttrs(existing, env.project);
100 }),
101
102 list: () =>
103 Effect.gen(function* () {
104 const env = yield* GcpEnvironment.current;
105 return yield* iam.listProjectsServiceAccounts
106 .pages({
107 name: `projects/${env.project}`,
108 pageSize: 100,
109 })
110 .pipe(
111 Stream.flatMap((page) => Stream.fromIterable(page.accounts ?? [])),
112 Stream.filter((account) => isManaged(account, env.project)),
113 Stream.map((account) => toAttrs(account, env.project)),
114 Stream.runCollect,
115 Effect.map((chunk) => Array.from(chunk)),
116 Effect.catchTag("NotFound", () =>
117 Effect.succeed([] as HostServiceAccount["Attributes"][]),
118 ),
119 );
120 }),
121
122 delete: Effect.fn(function* ({ output }) {
123 yield* deleteHostServiceAccount({
124 project: output.project,
125 email: hostServiceAccountEmail(output.project, output.accountId),
126 });
127 }),
128 });