1import * as firebaseapphosting from "@distilled.cloud/gcp/firebaseapphosting_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 defaultServiceAccount,
19 fieldMask,
20 fingerprint,
21 listAtLocation,
22 listLabeledPages,
23 MAX_BACKEND_ID_LENGTH,
24 normalizeLocation,
25 normalizeServingLocality,
26 parentOf,
27 parseName,
28 replaceOnIdentity,
29 ResourceNotResolved,
30 sameBool,
31 sameText,
32 stringMap,
33 toPhysicalId,
34 userLabels,
35 waitForDeleteOperation,
36 waitForOperation,
37 waitUntilExists,
38 waitUntilGone,
39} from "./internal.ts";
40
41export type Codebase = {
42 /**
43 * Developer Connect gitRepositoryLink resource name
44 * `projects/{project}/locations/{location}/connections/{connection}/gitRepositoryLinks/{repositoryLink}`.
45 * The connection must use the Firebase App Hosting GitHub App.
46 */
47 repository?: string;
48 /**
49 * Directory relative to the repository root used as the web app root.
50 * Defaults to the repository root. For a monorepo this is the directory
51 * that contains `package.json` or `apphosting.yaml`.
52 */
53 rootDirectory?: string;
54};
55
56export type RunService = {
57 /**
58 * Cloud Run service name
59 * `projects/{project}/locations/{location}/services/{serviceId}`.
60 */
61 service?: string;
62};
63
64export type ManagedResource = {
65 /** Cloud Run service managed by App Hosting. */
66 runService?: RunService;
67};
68
69export type BackendProps = {
70 /**
71 * Backend id (the `{backendId}` segment of
72 * `projects/{project}/locations/{location}/backends/{backendId}`).
73 * Also used as the Cloud Run service id and as part of the default
74 * domain name. If omitted, a unique RFC1035 name is generated from the
75 * stack, stage, and logical id. Immutable — changing it replaces the
76 * backend. Must start with a letter, contain only lowercase letters,
77 * digits, and hyphens, and be at most 49 characters.
78 */
79 backendId?: string;
80 /**
81 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
82 * replaces the backend. `US-CENTRAL1` is accepted and normalized to
83 * `us-central1`.
84 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
85 */
86 location?: string;
87 /**
88 * Service account used for Cloud Build and Cloud Run. Should have
89 * `roles/firebaseapphosting.computeRunner` (or equivalent). Defaults
90 * to `firebase-app-hosting-compute@{project}.iam.gserviceaccount.com`.
91 */
92 serviceAccount?: string;
93 /**
94 * How App Hosting serves this backend. `REGIONAL_STRICT` keeps
95 * traffic in one region; `GLOBAL_ACCESS` uses App Hosting's
96 * global-replicated serving. Immutable — changing it replaces the
97 * backend.
98 * @default "GLOBAL_ACCESS"
99 */
100 servingLocality?:
101 | firebaseapphosting.BackendServingLocalityEnum
102 | (string & {});
103 /**
104 * Human-readable name. 63 character limit.
105 */
106 displayName?: string;
107 /**
108 * Git repository connection used for event-driven updates.
109 */
110 codebase?: Codebase;
111 /**
112 * Firebase Web App id associated with this backend.
113 */
114 appId?: string;
115 /**
116 * Environment name used to load environment-specific configuration.
117 */
118 environment?: string;
119 /**
120 * When true, incoming request logs are disabled. Logs are enabled by
121 * default.
122 * @default false
123 */
124 requestLogsDisabled?: boolean;
125 /**
126 * User annotations (preserved by external tools).
127 */
128 annotations?: Record<string, string>;
129 /**
130 * User labels. Alchemy ownership labels are merged in automatically.
131 */
132 labels?: Record<string, string>;
133};
134
135export type Backend = Resource<
136 "GCP.FirebaseAppHosting.Backend",
137 BackendProps,
138 {
139 /** Full resource name. */
140 name: string;
141 /** Backend id (last path segment). */
142 backendId: string;
143 /** Project id. */
144 project: string;
145 /** Location id. */
146 location: string;
147 /** Service account email. */
148 serviceAccount: string | undefined;
149 /** Serving locality. */
150 servingLocality: string | undefined;
151 /** Human-readable name. */
152 displayName: string | undefined;
153 /** Git repository connection. */
154 codebase: Codebase | undefined;
155 /** Associated Firebase Web App id. */
156 appId: string | undefined;
157 /** Environment name. */
158 environment: string | undefined;
159 /** Whether incoming request logs are disabled. */
160 requestLogsDisabled: boolean;
161 /** Primary URI used to reach the backend. */
162 uri: string | undefined;
163 /** Cloud resources managed by this backend. */
164 managedResources: ManagedResource[] | undefined;
165 /** True while App Hosting is applying an LRO. */
166 reconciling: boolean;
167 /** User annotations. */
168 annotations: Record<string, string>;
169 /** User labels (Alchemy ownership labels stripped). */
170 labels: Record<string, string>;
171 /** Server-generated resource uid. */
172 uid: string | undefined;
173 /** RFC3339 creation timestamp. */
174 createTime: string | undefined;
175 /** RFC3339 last-update timestamp. */
176 updateTime: string | undefined;
177 /** Server-computed etag. */
178 etag: string | undefined;
179 },
180 never,
181 Providers
182>;
183
184/**
185 * A Firebase App Hosting backend — the primary resource that provisions
186 * a Cloud Run service and default domain for a web app.
187 *
188 * Changing `backendId`, `location`, or `servingLocality` replaces the
189 * backend. Display name, codebase, app id, environment, request logs,
190 * service account, labels, and annotations update in place.
191 *
192 * ### Creating a Backend
193 * **Example:** Generated name
194 * ```typescript
195 * const backend = yield* GCP.FirebaseAppHosting.Backend("Web", {
196 * labels: { env: "test" },
197 * });
198 * ```
199 *
200 * **Example:** Explicit id and regional serving
201 * ```typescript
202 * const backend = yield* GCP.FirebaseAppHosting.Backend("Web", {
203 * backendId: "web-prod",
204 * servingLocality: "REGIONAL_STRICT",
205 * serviceAccount: "firebase-app-hosting-compute@my-project.iam.gserviceaccount.com",
206 * displayName: "production web",
207 * labels: { env: "prod" },
208 * });
209 * ```
210 *
211 * ### Updating a Backend
212 * **Example:** Display name and labels
213 * ```typescript
214 * const backend = yield* GCP.FirebaseAppHosting.Backend("Web", {
215 * displayName: "production web v2",
216 * labels: { env: "prod", team: "web" },
217 * });
218 * ```
219 *
220 * @resource
221 * @category FirebaseAppHosting
222 */
223export const Backend = Resource<Backend>("GCP.FirebaseAppHosting.Backend");
224
225const resourceName = (project: string, location: string, backendId: string) =>
226 `projects/${project}/locations/${location}/backends/${backendId}`;
227
228const toCodebase = (
229 value: firebaseapphosting.Codebase | undefined,
230): Codebase | undefined =>
231 value === undefined
232 ? undefined
233 : {
234 repository: value.repository,
235 rootDirectory: value.rootDirectory,
236 };
237
238const toManagedResources = (
239 value: firebaseapphosting.ManagedResourceList | undefined,
240): ManagedResource[] | undefined =>
241 value === undefined
242 ? undefined
243 : value.map((item) => ({
244 runService: item.runService
245 ? { service: item.runService.service }
246 : undefined,
247 }));
248
249const toAttrs = (item: firebaseapphosting.Backend, project: string) => {
250 const name = item.name ?? "";
251 const parsed = parseName(name, "backends");
252 return {
253 name,
254 backendId: parsed.id,
255 project: parsed.project || project,
256 location: parsed.location,
257 serviceAccount: item.serviceAccount,
258 servingLocality: item.servingLocality,
259 displayName: item.displayName,
260 codebase: toCodebase(item.codebase),
261 appId: item.appId,
262 environment: item.environment,
263 requestLogsDisabled: item.requestLogsDisabled === true,
264 uri: item.uri,
265 managedResources: toManagedResources(item.managedResources),
266 reconciling: item.reconciling === true,
267 annotations: stringMap(item.annotations),
268 labels: userLabels(item.labels),
269 uid: item.uid,
270 createTime: item.createTime,
271 updateTime: item.updateTime,
272 etag: item.etag,
273 };
274};
275
276const getByName = (name: string) =>
277 firebaseapphosting
278 .getProjectsLocationsBackends({ name })
279 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
280
281const listOwned = (project: string) =>
282 listAtLocation(project, (parent) =>
283 listLabeledPages(
284 firebaseapphosting.listProjectsLocationsBackends.pages({
285 parent,
286 pageSize: 1000,
287 }),
288 (page) => page.backends,
289 (item) => item.labels,
290 ),
291 );
292
293export const BackendProvider = () =>
294 Provider.succeed(Backend, {
295 stables: [
296 "name",
297 "backendId",
298 "project",
299 "location",
300 "uid",
301 "createTime",
302 "servingLocality",
303 ],
304
305 diff: Effect.fn(function* ({ news, olds, output }) {
306 if (!isResolved(news)) return undefined;
307 const env = yield* GcpEnvironment.current;
308 const previousServing = normalizeServingLocality(
309 olds?.servingLocality ?? output?.servingLocality,
310 );
311 const nextServing = normalizeServingLocality(
312 news.servingLocality ??
313 olds?.servingLocality ??
314 output?.servingLocality,
315 );
316 return replaceOnIdentity({
317 previousId: olds?.backendId ?? output?.backendId,
318 nextId: news.backendId ?? olds?.backendId ?? output?.backendId,
319 previousLocation: normalizeLocation(
320 olds?.location ?? output?.location,
321 env.region,
322 ),
323 nextLocation: normalizeLocation(
324 news.location ?? olds?.location ?? output?.location,
325 env.region,
326 ),
327 extra:
328 (olds?.servingLocality ?? output?.servingLocality) !== undefined &&
329 previousServing !== nextServing,
330 });
331 }),
332
333 read: Effect.fn(function* ({ id, olds, output }) {
334 const env = yield* GcpEnvironment.current;
335 const backendId = yield* toPhysicalId(
336 id,
337 olds?.backendId,
338 output?.backendId,
339 "backend",
340 MAX_BACKEND_ID_LENGTH,
341 );
342 const location = normalizeLocation(
343 olds?.location ?? output?.location,
344 env.region,
345 );
346 const name =
347 output?.name ?? resourceName(env.project, location, backendId);
348 const existing = yield* getByName(name);
349 if (existing === undefined) return undefined;
350 const attrs = toAttrs(existing, env.project);
351 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
352 ? attrs
353 : Unowned(attrs);
354 }),
355
356 list: () =>
357 Effect.gen(function* () {
358 const env = yield* GcpEnvironment.current;
359 const items = yield* listOwned(env.project);
360 return items.map((item) => toAttrs(item, env.project));
361 }),
362
363 reconcile: Effect.fn(function* ({ id, news, output }) {
364 const env = yield* GcpEnvironment.current;
365 const backendId = yield* toPhysicalId(
366 id,
367 news.backendId,
368 output?.backendId,
369 "backend",
370 MAX_BACKEND_ID_LENGTH,
371 );
372 const location = normalizeLocation(
373 news.location ?? output?.location,
374 env.region,
375 );
376 const name = resourceName(env.project, location, backendId);
377 const desiredLabels = {
378 ...toLabels(news.labels),
379 ...(yield* createInternalLabels(id)),
380 };
381 const desiredAnnotations = news.annotations ?? {};
382 const desiredServing = normalizeServingLocality(news.servingLocality);
383 const desiredServiceAccount =
384 news.serviceAccount ?? defaultServiceAccount(env.project);
385 const desiredLogsDisabled = news.requestLogsDisabled === true;
386
387 let current = yield* getByName(output?.name ?? name);
388
389 if (current === undefined) {
390 const created = yield* firebaseapphosting
391 .createProjectsLocationsBackends({
392 parent: parentOf(env.project, location),
393 backendId,
394 body: {
395 servingLocality:
396 desiredServing as firebaseapphosting.BackendServingLocalityEnum,
397 serviceAccount: desiredServiceAccount,
398 displayName: news.displayName,
399 codebase: news.codebase,
400 appId: news.appId,
401 environment: news.environment,
402 requestLogsDisabled: desiredLogsDisabled,
403 annotations: desiredAnnotations,
404 labels: desiredLabels,
405 },
406 })
407 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
408 if (created !== undefined) {
409 yield* waitForOperation(created);
410 }
411 current = yield* waitUntilExists(getByName(name), name);
412 }
413
414 if (current === undefined) {
415 return yield* new ResourceNotResolved({ name });
416 }
417
418 const observedLabels = tagRecord(current.labels);
419 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
420 const mask = fieldMask([
421 (upsert.length > 0 || removed.length > 0) && "labels",
422 fingerprint(stringMap(current.annotations)) !==
423 fingerprint(desiredAnnotations) && "annotations",
424 !sameText(current.displayName, news.displayName) && "displayName",
425 !sameText(current.serviceAccount, desiredServiceAccount) &&
426 "serviceAccount",
427 !sameText(current.appId, news.appId) && "appId",
428 !sameText(current.environment, news.environment) && "environment",
429 !sameBool(current.requestLogsDisabled, desiredLogsDisabled) &&
430 "requestLogsDisabled",
431 fingerprint(toCodebase(current.codebase)) !==
432 fingerprint(news.codebase) && "codebase",
433 ]);
434
435 if (mask.length > 0) {
436 const operation =
437 yield* firebaseapphosting.patchProjectsLocationsBackends({
438 name: current.name ?? name,
439 updateMask: mask,
440 body: {
441 etag: current.etag,
442 displayName: news.displayName,
443 serviceAccount: desiredServiceAccount,
444 codebase: news.codebase,
445 appId: news.appId,
446 environment: news.environment,
447 requestLogsDisabled: desiredLogsDisabled,
448 annotations: desiredAnnotations,
449 labels: desiredLabels,
450 },
451 });
452 yield* waitForOperation(operation);
453 current = yield* waitUntilExists(
454 getByName(current.name ?? name),
455 current.name ?? name,
456 );
457 }
458
459 return toAttrs(current, env.project);
460 }),
461
462 delete: Effect.fn(function* ({ output }) {
463 const operation = yield* firebaseapphosting
464 .deleteProjectsLocationsBackends({
465 name: output.name,
466 force: true,
467 })
468 .pipe(
469 Effect.retry({
470 while: (error) => error._tag === "Conflict",
471 times: 8,
472 schedule: Schedule.spaced("2 seconds"),
473 }),
474 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
475 );
476 if (operation !== undefined) {
477 yield* waitForDeleteOperation(operation);
478 }
479 yield* waitUntilGone(getByName(output.name), output.name);
480 }),
481 });