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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 expandParent,
14 fingerprint,
15 listAtNested,
16 listLabeledPages,
17 MAX_BACKEND_ID_LENGTH,
18 normalizeLocation,
19 parseName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 stringMap,
23 toPhysicalId,
24 userLabels,
25 waitForDeleteOperation,
26 waitForOperation,
27 waitUntilExists,
28 waitUntilGone,
29} from "./internal.ts";
30
31export type UserMetadata = {
32 /** Git user name. */
33 displayName?: string;
34 /** Git user email. */
35 email?: string;
36 /** URI of a profile image. */
37 imageUri?: string;
38};
39
40export type CodebaseSource = {
41 /**
42 * Branch to build from (latest commit). Mutually exclusive with
43 * `commit`.
44 */
45 branch?: string;
46 /**
47 * Commit SHA to build from. Mutually exclusive with `branch`.
48 */
49 commit?: string;
50 /**
51 * Developer Connect gitRepositoryLink resource name used for this
52 * build.
53 */
54 repository?: string;
55};
56
57export type ArchiveSource = {
58 /** Signed URL of an archive in a storage bucket. */
59 externalSignedUri?: string;
60 /**
61 * Cloud Storage URI of a `.zip` or `.tar.gz` archive
62 * (`gs://bucket/object`).
63 */
64 userStorageUri?: string;
65 /**
66 * Directory relative to the archive root used as the web app root.
67 */
68 rootDirectory?: string;
69 /** Optional message describing this uploaded source. */
70 description?: string;
71};
72
73export type ContainerSource = {
74 /**
75 * Artifact Registry container image URI used as the build source.
76 */
77 image?: string;
78};
79
80export type BuildSource = {
81 /** Git codebase at a branch or commit. */
82 codebase?: CodebaseSource;
83 /** Storage archive. */
84 archive?: ArchiveSource;
85 /** Artifact Registry container image. */
86 container?: ContainerSource;
87};
88
89export type RunConfig = {
90 /** Maximum Cloud Run instances per revision. */
91 maxInstances?: number;
92 /** Memory per instance in MiB (`128`–`32768`). */
93 memoryMib?: number;
94 /** Maximum concurrent requests per instance (`1`–`1000`). */
95 concurrency?: number;
96 /** Minimum Cloud Run instances. */
97 minInstances?: number;
98 /** CPUs per instance (`0.08`–`8`). */
99 cpu?: number;
100};
101
102export type EnvironmentVariable = {
103 /** Variable name. Must not start with `X_FIREBASE_`. */
104 variable?: string;
105 /** Plaintext value. */
106 value?: string;
107 /**
108 * Secret Manager version resource name. The Cloud Build and Cloud Run
109 * service accounts need `secretmanager.versions.access`.
110 */
111 secret?: string;
112 /**
113 * Where the variable is available (`BUILD`, `RUNTIME`). Unspecified
114 * means both.
115 */
116 availability?: Array<
117 firebaseapphosting.EnvironmentVariableAvailabilityItemEnum | (string & {})
118 >;
119};
120
121export type BuildConfig = {
122 /** Cloud Run service configuration for this build. */
123 runConfig?: RunConfig;
124 /**
125 * Environment variables supplied at create time. Immutable. Only
126 * valid for container or archive sources.
127 */
128 env?: EnvironmentVariable[];
129};
130
131export type BackendsBuildProps = {
132 /**
133 * Parent backend. Full name
134 * `projects/{project}/locations/{location}/backends/{backend}` or the
135 * backend id (combined with `location`). Immutable — changing it
136 * replaces the build.
137 */
138 backend: string;
139 /**
140 * Region used when `backend` is a bare id.
141 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
142 */
143 location?: string;
144 /**
145 * Build id (the `{buildId}` segment). If omitted, a unique RFC1035
146 * name is generated from the stack, stage, and logical id. Immutable
147 * — changing it replaces the build.
148 */
149 buildId?: string;
150 /**
151 * Source for the build. Immutable — changing it replaces the build.
152 */
153 source: BuildSource;
154 /**
155 * Additional Cloud Run / environment configuration. Immutable —
156 * changing it replaces the build.
157 */
158 config?: BuildConfig;
159 /**
160 * Human-readable name. 63 character limit.
161 */
162 displayName?: string;
163 /**
164 * User annotations (preserved by external tools).
165 */
166 annotations?: Record<string, string>;
167 /**
168 * User labels. Alchemy ownership labels are merged in automatically.
169 */
170 labels?: Record<string, string>;
171};
172
173export type BackendsBuild = Resource<
174 "GCP.FirebaseAppHosting.BackendsBuild",
175 BackendsBuildProps,
176 {
177 /** Full resource name. */
178 name: string;
179 /** Build id (last path segment). */
180 buildId: string;
181 /** Parent backend name. */
182 backend: string;
183 /** Project id. */
184 project: string;
185 /** Location id. */
186 location: string;
187 /** Build source. */
188 source: BuildSource | undefined;
189 /** Additional configuration. */
190 config: BuildConfig | undefined;
191 /** Human-readable name. */
192 displayName: string | undefined;
193 /** Environment name of the backend when this build was created. */
194 environment: string | undefined;
195 /** Artifact Registry image URI used by the Cloud Run revision. */
196 image: string | undefined;
197 /** Cloud Build logs URI. */
198 buildLogsUri: string | undefined;
199 /** Server-reported build state. */
200 state: string | undefined;
201 /** True while the build has an ongoing LRO. */
202 reconciling: boolean;
203 /** User annotations. */
204 annotations: Record<string, string>;
205 /** User labels (Alchemy ownership labels stripped). */
206 labels: Record<string, string>;
207 /** Server-generated resource uid. */
208 uid: string | undefined;
209 /** RFC3339 creation timestamp. */
210 createTime: string | undefined;
211 /** RFC3339 last-update timestamp. */
212 updateTime: string | undefined;
213 /** Server-computed etag. */
214 etag: string | undefined;
215 },
216 never,
217 Providers
218>;
219
220/**
221 * A Firebase App Hosting build of a backend from a git commit, storage
222 * archive, or container image.
223 *
224 * Builds have no update API. Changing `buildId`, `backend`,
225 * `location`, `source`, or `config` replaces the build.
226 *
227 * ### Creating a Build
228 * **Example:** Container image
229 * ```typescript
230 * const build = yield* GCP.FirebaseAppHosting.BackendsBuild("Hello", {
231 * backend: backend.name,
232 * source: {
233 * container: { image: "us-docker.pkg.dev/cloudrun/container/hello" },
234 * },
235 * labels: { env: "test" },
236 * });
237 * ```
238 *
239 * **Example:** Archive source with Cloud Run limits
240 * ```typescript
241 * const build = yield* GCP.FirebaseAppHosting.BackendsBuild("Archive", {
242 * backend: backend.name,
243 * source: { archive: { userStorageUri: "gs://bucket/app.tar.gz" } },
244 * config: { runConfig: { cpu: 1, memoryMib: 512, maxInstances: 4 } },
245 * });
246 * ```
247 *
248 * @resource
249 * @category FirebaseAppHosting
250 */
251export const BackendsBuild = Resource<BackendsBuild>(
252 "GCP.FirebaseAppHosting.BackendsBuild",
253);
254
255const resourceName = (backend: string, buildId: string) =>
256 `${backend}/builds/${buildId}`;
257
258const toCodebaseSource = (
259 value: firebaseapphosting.CodebaseSource | undefined,
260): CodebaseSource | undefined =>
261 value === undefined
262 ? undefined
263 : {
264 branch: value.branch,
265 commit: value.commit,
266 repository: value.repository,
267 };
268
269const toArchiveSource = (
270 value: firebaseapphosting.ArchiveSource | undefined,
271): ArchiveSource | undefined =>
272 value === undefined
273 ? undefined
274 : {
275 externalSignedUri: value.externalSignedUri,
276 userStorageUri: value.userStorageUri,
277 rootDirectory: value.rootDirectory,
278 description: value.description,
279 };
280
281const toContainerSource = (
282 value: firebaseapphosting.ContainerSource | undefined,
283): ContainerSource | undefined =>
284 value === undefined ? undefined : { image: value.image };
285
286const toSource = (
287 value: firebaseapphosting.BuildSource | undefined,
288): BuildSource | undefined =>
289 value === undefined
290 ? undefined
291 : {
292 codebase: toCodebaseSource(value.codebase),
293 archive: toArchiveSource(value.archive),
294 container: toContainerSource(value.container),
295 };
296
297const toConfig = (
298 value: firebaseapphosting.Config | undefined,
299): BuildConfig | undefined =>
300 value === undefined
301 ? undefined
302 : {
303 runConfig: value.runConfig
304 ? {
305 maxInstances: value.runConfig.maxInstances,
306 memoryMib: value.runConfig.memoryMib,
307 concurrency: value.runConfig.concurrency,
308 minInstances: value.runConfig.minInstances,
309 cpu: value.runConfig.cpu,
310 }
311 : undefined,
312 env: value.env?.map((item) => ({
313 variable: item.variable,
314 value: item.value,
315 secret: item.secret,
316 availability: item.availability,
317 })),
318 };
319
320const toAttrs = (item: firebaseapphosting.Build, project: string) => {
321 const name = item.name ?? "";
322 const parsed = parseName(name, "builds");
323 return {
324 name,
325 buildId: parsed.id,
326 backend: parsed.parent,
327 project: parsed.project || project,
328 location: parsed.location,
329 source: toSource(item.source),
330 config: toConfig(item.config),
331 displayName: item.displayName,
332 environment: item.environment,
333 image: item.image,
334 buildLogsUri: item.buildLogsUri,
335 state: item.state,
336 reconciling: item.reconciling === true,
337 annotations: stringMap(item.annotations),
338 labels: userLabels(item.labels),
339 uid: item.uid,
340 createTime: item.createTime,
341 updateTime: item.updateTime,
342 etag: item.etag,
343 };
344};
345
346const getByName = (name: string) =>
347 firebaseapphosting
348 .getProjectsLocationsBackendsBuilds({ name })
349 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
350
351const listOwned = (project: string) =>
352 listAtNested(project, "backends/-", (parent) =>
353 listLabeledPages(
354 firebaseapphosting.listProjectsLocationsBackendsBuilds.pages({
355 parent,
356 pageSize: 1000,
357 }),
358 (page) => page.builds,
359 (item) => item.labels,
360 ),
361 );
362
363export const BackendsBuildProvider = () =>
364 Provider.succeed(BackendsBuild, {
365 stables: [
366 "name",
367 "buildId",
368 "backend",
369 "project",
370 "location",
371 "uid",
372 "createTime",
373 ],
374
375 diff: Effect.fn(function* ({ news, olds, output }) {
376 if (!isResolved(news)) return undefined;
377 const env = yield* GcpEnvironment.current;
378 const location = normalizeLocation(
379 news.location ?? olds?.location ?? output?.location,
380 env.region,
381 );
382 const previousLocation = normalizeLocation(
383 olds?.location ?? output?.location,
384 env.region,
385 );
386 const previousParent =
387 (olds?.backend ?? output?.backend)
388 ? expandParent(
389 olds?.backend ?? output?.backend ?? "",
390 env.project,
391 previousLocation,
392 "backends",
393 )
394 : undefined;
395 const nextParent = expandParent(
396 news.backend,
397 env.project,
398 location,
399 "backends",
400 );
401 return replaceOnIdentity({
402 previousId: olds?.buildId ?? output?.buildId,
403 nextId: news.buildId ?? olds?.buildId ?? output?.buildId,
404 previousLocation,
405 nextLocation: location,
406 previousParent,
407 nextParent,
408 extra:
409 fingerprint(olds?.source ?? output?.source) !==
410 fingerprint(news.source) ||
411 fingerprint(olds?.config ?? output?.config) !==
412 fingerprint(news.config),
413 });
414 }),
415
416 read: Effect.fn(function* ({ id, olds, output }) {
417 const env = yield* GcpEnvironment.current;
418 const buildId = yield* toPhysicalId(
419 id,
420 olds?.buildId,
421 output?.buildId,
422 "build",
423 MAX_BACKEND_ID_LENGTH,
424 );
425 const location = normalizeLocation(
426 olds?.location ?? output?.location,
427 env.region,
428 );
429 const backend =
430 output?.backend ??
431 (olds?.backend
432 ? expandParent(olds.backend, env.project, location, "backends")
433 : undefined);
434 const name =
435 output?.name ?? (backend ? resourceName(backend, buildId) : undefined);
436 if (name === undefined) return undefined;
437 const existing = yield* getByName(name);
438 if (existing === undefined) return undefined;
439 const attrs = toAttrs(existing, env.project);
440 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
441 ? attrs
442 : Unowned(attrs);
443 }),
444
445 list: () =>
446 Effect.gen(function* () {
447 const env = yield* GcpEnvironment.current;
448 const items = yield* listOwned(env.project);
449 return items.map((item) => toAttrs(item, env.project));
450 }),
451
452 reconcile: Effect.fn(function* ({ id, news, output }) {
453 const env = yield* GcpEnvironment.current;
454 const buildId = yield* toPhysicalId(
455 id,
456 news.buildId,
457 output?.buildId,
458 "build",
459 MAX_BACKEND_ID_LENGTH,
460 );
461 const location = normalizeLocation(
462 news.location ?? output?.location,
463 env.region,
464 );
465 const backend = expandParent(
466 news.backend,
467 env.project,
468 location,
469 "backends",
470 );
471 const name = resourceName(backend, buildId);
472 const desiredLabels = {
473 ...toLabels(news.labels),
474 ...(yield* createInternalLabels(id)),
475 };
476 const desiredAnnotations = news.annotations ?? {};
477
478 let current = yield* getByName(output?.name ?? name);
479
480 if (current === undefined) {
481 const created = yield* firebaseapphosting
482 .createProjectsLocationsBackendsBuilds({
483 parent: backend,
484 buildId,
485 body: {
486 source: news.source,
487 config: news.config,
488 displayName: news.displayName,
489 annotations: desiredAnnotations,
490 labels: desiredLabels,
491 },
492 })
493 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
494 if (created !== undefined) {
495 yield* waitForOperation(created);
496 }
497 current = yield* waitUntilExists(getByName(name), name);
498 }
499
500 if (current === undefined) {
501 return yield* new ResourceNotResolved({ name });
502 }
503
504 return toAttrs(current, env.project);
505 }),
506
507 delete: Effect.fn(function* ({ output }) {
508 const operation = yield* firebaseapphosting
509 .deleteProjectsLocationsBackendsBuilds({
510 name: output.name,
511 })
512 .pipe(
513 Effect.retry({
514 while: (error) => error._tag === "Conflict",
515 times: 8,
516 schedule: Schedule.spaced("2 seconds"),
517 }),
518 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
519 );
520 if (operation !== undefined) {
521 yield* waitForDeleteOperation(operation);
522 }
523 yield* waitUntilGone(getByName(output.name), output.name);
524 }),
525 });