1import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { deepEqual, isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource, type ResourceBinding } from "../../Resource.ts";
12import { type ServerHost } from "../../Server/Process.ts";
13import { packEnvValue } from "../../RuntimeContext.ts";
14import { tagRecord } from "../../Tags.ts";
15import { Credentials } from "@distilled.cloud/gcp/Credentials";
16import type * as Bundle from "../../Bundle/Bundle.ts";
17import {
18 DEFAULT_NODE_RUNTIME,
19 FUNCTION_ENTRY_POINT,
20 makeFunctionSource,
21} from "./FunctionSource.ts";
22import { GcpEnvironment } from "../Environment.ts";
23import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
24import {
25 createGcpHostRuntimeContext,
26 type GcpHostRuntimeContext,
27} from "../HostContext.ts";
28import {
29 retryActAs,
30 type AppliedIamGrant,
31 type GcpHostBinding,
32} from "../Host.ts";
33import {
34 alchemyRuntimeEnv,
35 isManagedServiceAccount,
36 releaseHostIdentity,
37 resolveHostIdentity,
38} from "../HostRuntime.ts";
39import {
40 createInternalLabels,
41 diffLabels,
42 hasAlchemyLabels,
43 stripInternalLabels,
44 toLabels,
45} from "../Labels.ts";
46import type { Providers } from "../Providers.ts";
47
48const DEFAULT_ENVIRONMENT = "GEN_2";
49const DEFAULT_RUNTIME = "nodejs20";
50const MAX_NAME_LENGTH = 63;
51
52export type StorageSource = {
53 /** GCS bucket that holds the source archive. */
54 bucket?: string;
55 /** Object name of the source archive (typically a `.zip`). */
56 object?: string;
57 /** Object generation. Omit to use the latest generation. */
58 generation?: string;
59 /** Signed upload URL returned by `generateUploadUrl` for 1st gen. */
60 sourceUploadUrl?: string;
61};
62
63export type RepoSource = {
64 /** Cloud Source Repository name. */
65 repoName?: string;
66 /** Branch regex (RE2). */
67 branchName?: string;
68 /** Tag regex (RE2). */
69 tagName?: string;
70 /** Commit SHA to build. */
71 commitSha?: string;
72 /** Directory relative to the repo root. */
73 dir?: string;
74 /** Project that owns the repository. Defaults to the function's project. */
75 projectId?: string;
76};
77
78export type Source = {
79 /** Source archive in Cloud Storage. */
80 storageSource?: StorageSource;
81 /** Source in Cloud Source Repositories. */
82 repoSource?: RepoSource;
83 /** GitHub URI. 1st gen only. */
84 gitUri?: string;
85};
86
87export type BuildConfig = {
88 /**
89 * Runtime to build and run (e.g. `nodejs20`, `python312`). Required on
90 * create.
91 * @default "nodejs20"
92 */
93 runtime?: string;
94 /**
95 * Exported function in the source to execute. Defaults to the function
96 * id.
97 */
98 entryPoint?: string;
99 /** Location of the function source. Required on create. */
100 source?: Source;
101 /** Build-time environment variables. */
102 environmentVariables?: Record<string, string>;
103 /**
104 * Cloud Build worker pool
105 * (`projects/{project}/locations/{location}/workerPools/{workerPool}`).
106 */
107 workerPool?: string;
108 /**
109 * Artifact Registry Docker repository
110 * (`projects/{project}/locations/{location}/repositories/{repository}`).
111 */
112 dockerRepository?: string;
113 /**
114 * Service account used for the build
115 * (`projects/{project}/serviceAccounts/{email}`).
116 */
117 serviceAccount?: string;
118};
119
120export type SecretEnvVar = {
121 /** Secret id (not the full resource name). */
122 secret?: string;
123 /** Environment variable name. */
124 key?: string;
125 /** Project that owns the secret. Defaults to the function's project. */
126 projectId?: string;
127 /** Secret version (`latest` or a number). */
128 version?: string;
129};
130
131export type SecretVolume = {
132 /** Secret id (not the full resource name). */
133 secret?: string;
134 /** Mount path inside the container (e.g. `/etc/secrets`). */
135 mountPath?: string;
136 /** Project that owns the secret. Defaults to the function's project. */
137 projectId?: string;
138 /** Versions to mount. Defaults to `latest` as a file named after the secret. */
139 versions?: Array<{ path?: string; version?: string }>;
140};
141
142export type DirectVpcNetworkInterface = {
143 /** VPC network name or URL. */
144 network?: string;
145 /** Subnetwork name or URL. */
146 subnetwork?: string;
147 /** Network tags applied to the function. */
148 tags?: string[];
149};
150
151export type ServiceConfig = {
152 /**
153 * Memory available to each instance (e.g. `"256Mi"`, `"512M"`).
154 * @default "256M"
155 */
156 availableMemory?: string;
157 /** vCPU per instance (e.g. `"1"`). Derived from memory when omitted. */
158 availableCpu?: string;
159 /**
160 * Request timeout in seconds.
161 * @default 60
162 */
163 timeoutSeconds?: number;
164 /** Minimum idle instances. */
165 minInstanceCount?: number;
166 /** Maximum concurrent instances. */
167 maxInstanceCount?: number;
168 /**
169 * Max concurrent requests per instance.
170 * @default 1
171 */
172 maxInstanceRequestConcurrency?: number;
173 /** Runtime environment variables. */
174 environmentVariables?: Record<string, string>;
175 /**
176 * Ingress (`ALLOW_ALL`, `ALLOW_INTERNAL_ONLY`,
177 * `ALLOW_INTERNAL_AND_GCLB`).
178 */
179 ingressSettings?:
180 | cloudfunctions.ServiceConfigIngressSettingsEnum
181 | (string & {});
182 /**
183 * Serverless VPC Access connector
184 * (`projects/{project}/locations/{location}/connectors/{connector}`).
185 */
186 vpcConnector?: string;
187 /** Traffic sent through the VPC connector. */
188 vpcConnectorEgressSettings?:
189 | cloudfunctions.ServiceConfigVpcConnectorEgressSettingsEnum
190 | (string & {});
191 /** Runtime service account email. */
192 serviceAccountEmail?: string;
193 /**
194 * Route 100% of traffic to the latest revision.
195 * @default true
196 */
197 allTrafficOnLatestRevision?: boolean;
198 /** Secret Manager values exposed as environment variables. */
199 secretEnvironmentVariables?: SecretEnvVar[];
200 /** Secret Manager values mounted as files. */
201 secretVolumes?: SecretVolume[];
202 /** Direct VPC egress setting. */
203 directVpcEgress?:
204 | cloudfunctions.ServiceConfigDirectVpcEgressEnum
205 | (string & {});
206 /** Direct VPC network interfaces. Mutually exclusive with `vpcConnector`. */
207 directVpcNetworkInterface?: DirectVpcNetworkInterface[];
208};
209
210export type EventFilter = {
211 /** CloudEvents attribute name. */
212 attribute?: string;
213 /** Value to match. */
214 value?: string;
215 /** Match operator. Only `match-path-pattern` is allowed. */
216 operator?: string;
217};
218
219export type EventTrigger = {
220 /**
221 * Event type (e.g. `google.cloud.pubsub.topic.v1.messagePublished`).
222 */
223 eventType?: string;
224 /**
225 * Pub/Sub topic used as the transport
226 * (`projects/{project}/topics/{topic}`).
227 */
228 pubsubTopic?: string;
229 /** Service account email used to invoke the function. */
230 serviceAccountEmail?: string;
231 /** Retry failed deliveries. */
232 retryPolicy?: cloudfunctions.EventTriggerRetryPolicyEnum | (string & {});
233 /** CloudEvents attribute filters. */
234 eventFilters?: EventFilter[];
235 /**
236 * Region that receives events. Defaults to the function location.
237 */
238 triggerRegion?: string;
239 /**
240 * Eventarc channel
241 * (`projects/{project}/locations/{location}/channels/{channel}`) for partner events.
242 */
243 channel?: string;
244 /** 1st gen hostname of the event service (e.g. `storage.googleapis.com`). */
245 service?: string;
246};
247
248export type FunctionProps = PlatformProps & {
249 /**
250 * Function id (the `{function}` segment of
251 * `projects/{project}/locations/{location}/functions/{function}`).
252 * If omitted, a unique name is generated from the stack, stage, and
253 * logical id. 4-63 characters, matching `[a-z][a-z0-9-]*`. Immutable — changing
254 * it replaces the function.
255 */
256 functionId?: string;
257 /**
258 * Region (`us-central1`, `europe-west1`, …). Immutable — changing it
259 * replaces the function. `US-CENTRAL1` is accepted and normalized to
260 * `us-central1`.
261 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
262 */
263 location?: string;
264 /**
265 * Generation (`GEN_1` or `GEN_2`). Immutable — changing it replaces the
266 * function.
267 * @default "GEN_2"
268 */
269 environment?:
270 | cloudfunctions.Cloudfunctions_FunctionEnvironmentEnum
271 | (string & {});
272 /** User-provided description. */
273 description?: string;
274 /**
275 * User labels. Alchemy ownership labels are merged in automatically.
276 */
277 labels?: Record<string, string>;
278 /**
279 * Cloud KMS key used to encrypt function resources
280 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
281 */
282 kmsKeyName?: string;
283 /** Build step that turns source into a container. */
284 buildConfig?: BuildConfig;
285 /** Cloud Run service settings for the deployed function. */
286 serviceConfig?: ServiceConfig;
287 /** Eventarc trigger. Omit for an HTTPS function. */
288 eventTrigger?: EventTrigger;
289 /**
290 * Entry module of an Effect-native function. Alchemy bundles it for
291 * Node.js, uploads the archive, and serves the impl's `fetch` (and any
292 * event-source listeners) through the Functions Framework. Bindings
293 * attach env + IAM onto the runtime service account. Replaces
294 * `buildConfig.source` / `entryPoint`; `buildConfig.runtime` defaults to
295 * `nodejs22`.
296 */
297 main?: string;
298 /**
299 * Named export to load from `main`.
300 * @default "default"
301 */
302 handler?: string;
303 /**
304 * Additional environment variables for the Effect-native function.
305 * Merged after binding-injected `env`.
306 */
307 env?: Record<string, any>;
308 /** Bundler configuration for `main`. */
309 build?: Bundle.BundleConfig;
310};
311
312export type Function = Resource<
313 "GCP.CloudFunctions.Function",
314 FunctionProps,
315 {
316 /** Full resource name `projects/{project}/locations/{location}/functions/{function}`. */
317 name: string;
318 /** Function id (last path segment). */
319 functionId: string;
320 /** Project id. */
321 project: string;
322 /** Location id (`us-central1`, …). */
323 location: string;
324 /** Generation (`GEN_1` or `GEN_2`). */
325 environment: string;
326 /** User-provided description. */
327 description: string | undefined;
328 /** User labels (Alchemy ownership labels stripped). */
329 labels: Record<string, string>;
330 /** HTTPS URL of the function. */
331 url: string | undefined;
332 /** Server-reported state (`ACTIVE`, `DEPLOYING`, `FAILED`, …). */
333 state: string | undefined;
334 /** KMS key used for encryption, if any. */
335 kmsKeyName: string | undefined;
336 /** Runtime (e.g. `nodejs20`). */
337 runtime: string | undefined;
338 /** Source entry point. */
339 entryPoint: string | undefined;
340 /** Cloud Run service resource name. */
341 service: string | undefined;
342 /** Cloud Run service URI. */
343 uri: string | undefined;
344 /** Memory limit. */
345 availableMemory: string | undefined;
346 /** Request timeout in seconds. */
347 timeoutSeconds: number | undefined;
348 /** Minimum instance count. */
349 minInstanceCount: number | undefined;
350 /** Maximum instance count. */
351 maxInstanceCount: number | undefined;
352 /** Runtime service account email. */
353 serviceAccountEmail: string | undefined;
354 /** Eventarc trigger resource name, if any. */
355 trigger: string | undefined;
356 /** RFC3339 creation timestamp. */
357 createTime: string | undefined;
358 /** RFC3339 last-update timestamp. */
359 updateTime: string | undefined;
360 /** True when Alchemy minted the per-host runtime service account. */
361 managedServiceAccount: boolean;
362 /** IAM roles bindings granted to the runtime service account. */
363 iamGrants: AppliedIamGrant[];
364 /** Hash of the bundled `main` program (Effect-native only). */
365 codeHash: string | undefined;
366 },
367 GcpHostBinding,
368 Providers
369>;
370
371export type FunctionRuntimeContext = GcpHostRuntimeContext;
372export type FunctionServices = Credentials | GcpEnvironment | ServerHost;
373export type FunctionShape = Main<FunctionServices>;
374
375/**
376 * A Google Cloud Function (2nd gen) that builds source into a Cloud Run
377 * service.
378 *
379 * Changing `functionId`, `location`, or `environment` replaces the function.
380 * Source, runtime, service settings, labels, and event triggers update
381 * in place.
382 *
383 * ### Creating a Function
384 * **Example:** Generated name
385 * ```typescript
386 * const hello = yield* GCP.CloudFunctions.Function("Hello", {
387 * buildConfig: {
388 * runtime: "nodejs20",
389 * entryPoint: "helloHttp",
390 * source: {
391 * storageSource: {
392 * bucket: "my-source",
393 * object: "hello.zip",
394 * },
395 * },
396 * },
397 * });
398 * ```
399 *
400 * **Example:** Explicit id, labels, and memory
401 * ```typescript
402 * const hello = yield* GCP.CloudFunctions.Function("Hello", {
403 * functionId: "order-http",
404 * location: "us-central1",
405 * description: "HTTP handler",
406 * labels: { env: "prod" },
407 * buildConfig: {
408 * runtime: "nodejs20",
409 * entryPoint: "helloHttp",
410 * source: {
411 * storageSource: { bucket: "my-source", object: "hello.zip" },
412 * },
413 * },
414 * serviceConfig: {
415 * availableMemory: "256Mi",
416 * timeoutSeconds: 60,
417 * maxInstanceCount: 10,
418 * },
419 * });
420 * ```
421 *
422 * ### Event Triggers
423 * **Example:** Pub/Sub trigger
424 * ```typescript
425 * const consumer = yield* GCP.CloudFunctions.Function("Consume", {
426 * buildConfig: {
427 * runtime: "nodejs20",
428 * entryPoint: "onMessage",
429 * source: {
430 * storageSource: { bucket: "my-source", object: "consumer.zip" },
431 * },
432 * },
433 * eventTrigger: {
434 * eventType: "google.cloud.pubsub.topic.v1.messagePublished",
435 * pubsubTopic: topic.name,
436 * },
437 * });
438 * ```
439 *
440 * ### Reading a Function
441 * **Example:** Get the live function
442 * ```typescript
443 * const getFunction = yield* GCP.CloudFunctions.GetFunction(hello);
444 * const live = yield* getFunction();
445 * ```
446 *
447 * ### Downloading Source
448 * **Example:** Signed download URL
449 * ```typescript
450 * const download = yield* GCP.CloudFunctions.GenerateDownloadUrl(hello);
451 * const { downloadUrl } = yield* download();
452 * ```
453 *
454 * ### Effect-native Functions
455 * **Example:** HTTP function with a Pub/Sub binding
456 * ```typescript
457 * export class Hello extends GCP.CloudFunctions.Function<Hello>()(
458 * "Hello",
459 * { main: import.meta.url },
460 * Effect.gen(function* () {
461 * const publish = yield* GCP.PubSub.Publish(Events);
462 * return {
463 * fetch: Effect.gen(function* () {
464 * yield* publish({ body: { messages: [{ data: btoa("hi") }] } }).pipe(
465 * Effect.orDie,
466 * );
467 * return HttpServerResponse.text("sent");
468 * }),
469 * };
470 * }).pipe(Effect.provide(GCP.PubSub.PublishHttp)),
471 * ) {}
472 * ```
473 *
474 * **Example:** Consume a topic
475 * ```typescript
476 * yield* GCP.PubSub.consumeTopicMessages(orders, (messages) =>
477 * messages.pipe(Stream.runForEach(({ message }) => Effect.log(message))),
478 * );
479 * // …provided with Effect.provide(GCP.CloudFunctions.TopicEventSource)
480 * ```
481 *
482 * With `main`, Alchemy bundles the program for Node.js (`nodejs22` by
483 * default), uploads it, and serves it through the Functions Framework;
484 * `buildConfig.source` and `entryPoint` are managed for you. The runtime
485 * authenticates to GCP as the function's service account.
486 *
487 * @resource
488 * @category CloudFunctions
489 */
490export const Function: Platform<
491 Function,
492 FunctionServices,
493 FunctionShape,
494 FunctionRuntimeContext
495> = Platform("GCP.CloudFunctions.Function", {
496 createRuntimeContext: createGcpHostRuntimeContext(
497 "GCP.CloudFunctions.Function",
498 ) as (id: string) => FunctionRuntimeContext,
499});
500
501export class FunctionNotResolved extends Data.TaggedError(
502 "GCP.CloudFunctions.FunctionNotResolved",
503)<{
504 name: string;
505}> {}
506
507export class FunctionStillExists extends Data.TaggedError(
508 "GCP.CloudFunctions.FunctionStillExists",
509)<{
510 name: string;
511}> {}
512
513export class FunctionDeployFailed extends Data.TaggedError(
514 "GCP.CloudFunctions.FunctionDeployFailed",
515)<{
516 name: string;
517 state: string;
518 message: string;
519}> {}
520
521const lastSegment = (value: string) => {
522 const trimmed = value.replace(/\/+$/, "");
523 const parts = trimmed.split("/");
524 return parts[parts.length - 1] || trimmed;
525};
526
527const normalizeLocation = (location: string | undefined, fallback: string) =>
528 lastSegment(location ?? fallback).toLowerCase();
529
530const normalizeEnvironment = (environment: string | undefined) => {
531 const value = (environment ?? DEFAULT_ENVIRONMENT).toUpperCase();
532 return value === "ENVIRONMENT_UNSPECIFIED" ? DEFAULT_ENVIRONMENT : value;
533};
534
535const resourceName = (project: string, location: string, functionId: string) =>
536 `projects/${project}/locations/${location}/functions/${functionId}`;
537
538const parseName = (name: string, fallbackLocation: string) => {
539 const parts = name.split("/").filter((part) => part.length > 0);
540 const functionsAt = parts.lastIndexOf("functions");
541 const locationsAt = parts.lastIndexOf("locations");
542 const projectsAt = parts.lastIndexOf("projects");
543 return {
544 project:
545 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
546 location:
547 locationsAt >= 0 && parts[locationsAt + 1]
548 ? parts[locationsAt + 1]!
549 : fallbackLocation,
550 functionId:
551 functionsAt >= 0 && parts[functionsAt + 1]
552 ? parts[functionsAt + 1]!
553 : lastSegment(name),
554 };
555};
556
557const userLabels = (
558 labels: Record<string, string | undefined> | null | undefined,
559): Record<string, string> => stripInternalLabels(tagRecord(labels));
560
561const toId = (id: string, functionId: string | undefined, existing?: string) =>
562 Effect.gen(function* () {
563 return (
564 functionId ??
565 existing ??
566 (yield* createPhysicalName({
567 id,
568 maxLength: MAX_NAME_LENGTH,
569 lowercase: true,
570 }))
571 );
572 });
573
574const stable = (value: unknown) => JSON.stringify(value ?? null);
575
576const envMap = (value: Record<string, string | undefined> | null | undefined) =>
577 tagRecord(value);
578
579const normalizeGeneration = (generation: string | undefined) =>
580 generation === undefined || generation === "0" ? "" : generation;
581
582const sanitizeSource = (source: Source | undefined): Source | undefined => {
583 if (source === undefined) return undefined;
584 const storage = source.storageSource;
585 if (storage === undefined) return source;
586 const generation = normalizeGeneration(storage.generation);
587 return {
588 ...source,
589 storageSource: {
590 bucket: storage.bucket,
591 object: storage.object,
592 sourceUploadUrl: storage.sourceUploadUrl,
593 generation: generation === "" ? undefined : generation,
594 },
595 };
596};
597
598const sameSource = (desired?: Source, observed?: Source) => {
599 const desiredStorage = desired?.storageSource;
600 const observedStorage = observed?.storageSource;
601 const desiredRepo = desired?.repoSource;
602 const observedRepo = observed?.repoSource;
603 const desiredGeneration = normalizeGeneration(desiredStorage?.generation);
604 const observedGeneration = normalizeGeneration(observedStorage?.generation);
605 const generationEqual =
606 desiredGeneration === "" || desiredGeneration === observedGeneration;
607 return (
608 (desired?.gitUri ?? "") === (observed?.gitUri ?? "") &&
609 (desiredStorage?.bucket ?? "") === (observedStorage?.bucket ?? "") &&
610 (desiredStorage?.object ?? "") === (observedStorage?.object ?? "") &&
611 (desiredStorage?.sourceUploadUrl ?? "") ===
612 (observedStorage?.sourceUploadUrl ?? "") &&
613 generationEqual &&
614 (desiredRepo?.repoName ?? "") === (observedRepo?.repoName ?? "") &&
615 (desiredRepo?.branchName ?? "") === (observedRepo?.branchName ?? "") &&
616 (desiredRepo?.tagName ?? "") === (observedRepo?.tagName ?? "") &&
617 (desiredRepo?.commitSha ?? "") === (observedRepo?.commitSha ?? "") &&
618 (desiredRepo?.dir ?? "") === (observedRepo?.dir ?? "") &&
619 (desiredRepo?.projectId ?? "") === (observedRepo?.projectId ?? "")
620 );
621};
622
623const HOST_TYPE = "GCP.CloudFunctions.Function";
624
625const toAttrs = (
626 fn: cloudfunctions.Cloudfunctions_Function,
627 project: string,
628 region: string,
629 extras: { iamGrants?: AppliedIamGrant[]; codeHash?: string } = {},
630): Function["Attributes"] => {
631 const name = fn.name ?? "";
632 const parsed = parseName(name, region);
633 return {
634 name,
635 functionId: parsed.functionId,
636 project: parsed.project || project,
637 location: parsed.location,
638 environment: normalizeEnvironment(fn.environment),
639 description: fn.description,
640 labels: userLabels(fn.labels),
641 url: fn.url,
642 state: fn.state,
643 kmsKeyName: fn.kmsKeyName,
644 runtime: fn.buildConfig?.runtime,
645 entryPoint: fn.buildConfig?.entryPoint,
646 service: fn.serviceConfig?.service,
647 uri: fn.serviceConfig?.uri,
648 availableMemory: fn.serviceConfig?.availableMemory,
649 timeoutSeconds: fn.serviceConfig?.timeoutSeconds,
650 minInstanceCount: fn.serviceConfig?.minInstanceCount,
651 maxInstanceCount: fn.serviceConfig?.maxInstanceCount,
652 serviceAccountEmail: fn.serviceConfig?.serviceAccountEmail,
653 trigger: fn.eventTrigger?.trigger,
654 createTime: fn.createTime,
655 updateTime: fn.updateTime,
656 managedServiceAccount: isManagedServiceAccount({
657 project: parsed.project || project,
658 hostType: HOST_TYPE,
659 resourceName: name,
660 serviceAccount: fn.serviceConfig?.serviceAccountEmail,
661 }),
662 iamGrants: extras.iamGrants ?? [],
663 codeHash: extras.codeHash,
664 };
665};
666
667const getByName = (name: string) =>
668 cloudfunctions
669 .getProjectsLocationsFunctions({ name })
670 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
671
672const failedMessage = (fn: cloudfunctions.Cloudfunctions_Function) =>
673 fn.stateMessages
674 ?.map((item) => item.message)
675 .filter((item): item is string => typeof item === "string")
676 .join("; ") || "function is in FAILED state";
677
678/**
679 * A gen2 create/update builds the source and rolls out a Cloud Run revision
680 * (2–4 minutes; slow builds up to ~10). ALREADY_EXISTS (code 6) with
681 * `alreadyExistsOk` and NOT_FOUND (code 5) with `notFoundOk` count as
682 * success (create/delete races).
683 */
684const waitForOperation = (
685 operation: cloudfunctions.Operation,
686 options?: { notFoundOk?: boolean; alreadyExistsOk?: boolean },
687) =>
688 waitForGcpOperation(
689 operation,
690 (name) => cloudfunctions.getProjectsLocationsOperations({ name }),
691 { budget: "15 minutes", interval: "10 seconds" },
692 ).pipe(
693 Effect.catchIf(
694 (error) =>
695 error._tag === "GCP.OperationFailed" &&
696 ((options?.alreadyExistsOk === true && error.code === 6) ||
697 (options?.notFoundOk === true && error.code === 5)),
698 () => Effect.succeed(operation),
699 ),
700 );
701
702const waitUntilReady = (name: string) =>
703 getByName(name).pipe(
704 Effect.filterOrFail(
705 (fn): fn is cloudfunctions.Cloudfunctions_Function => fn !== undefined,
706 () => new FunctionNotResolved({ name }),
707 ),
708 Effect.filterOrFail(
709 (fn) => fn.state !== "FAILED",
710 (fn) =>
711 new FunctionDeployFailed({
712 name,
713 state: fn.state ?? "FAILED",
714 message: failedMessage(fn),
715 }),
716 ),
717 Effect.filterOrFail(
718 (fn) => fn.state === "ACTIVE" || fn.state === undefined,
719 () => new FunctionNotResolved({ name }),
720 ),
721 Effect.retry({
722 while: (error) => error._tag === "GCP.CloudFunctions.FunctionNotResolved",
723 times: 8,
724 schedule: Schedule.spaced("2 seconds"),
725 }),
726 );
727
728const waitUntilGone = (name: string) =>
729 getByName(name).pipe(
730 Effect.flatMap((fn) =>
731 fn === undefined
732 ? Effect.void
733 : Effect.fail(new FunctionStillExists({ name })),
734 ),
735 Effect.retry({
736 while: (error) => error._tag === "GCP.CloudFunctions.FunctionStillExists",
737 times: 10,
738 schedule: Schedule.spaced("3 seconds"),
739 }),
740 );
741
742const mergeBuildConfig = (
743 news: BuildConfig | undefined,
744 observed: cloudfunctions.BuildConfig | undefined,
745): cloudfunctions.BuildConfig => ({
746 runtime: news?.runtime ?? observed?.runtime ?? DEFAULT_RUNTIME,
747 entryPoint: news?.entryPoint ?? observed?.entryPoint,
748 source: sanitizeSource(news?.source) ?? observed?.source,
749 environmentVariables:
750 news?.environmentVariables ?? envMap(observed?.environmentVariables),
751 workerPool: news?.workerPool ?? observed?.workerPool,
752 dockerRepository: news?.dockerRepository ?? observed?.dockerRepository,
753 serviceAccount: news?.serviceAccount ?? observed?.serviceAccount,
754});
755
756const sameBuildConfig = (
757 desired: cloudfunctions.BuildConfig,
758 observed: cloudfunctions.BuildConfig | undefined,
759) =>
760 (desired.runtime ?? "") === (observed?.runtime ?? "") &&
761 (desired.entryPoint ?? "") === (observed?.entryPoint ?? "") &&
762 (desired.workerPool ?? "") === (observed?.workerPool ?? "") &&
763 (desired.dockerRepository ?? "") === (observed?.dockerRepository ?? "") &&
764 (desired.serviceAccount ?? "") === (observed?.serviceAccount ?? "") &&
765 deepEqual(
766 envMap(desired.environmentVariables),
767 envMap(observed?.environmentVariables),
768 { stripNullish: true },
769 ) &&
770 sameSource(desired.source, observed?.source);
771
772const eventTriggerKey = (trigger: EventTrigger | undefined) => {
773 if (trigger === undefined) return "";
774 const filters = [...(trigger.eventFilters ?? [])]
775 .map((filter) => ({
776 attribute: filter.attribute ?? "",
777 value: filter.value ?? "",
778 operator: filter.operator ?? "",
779 }))
780 .sort((a, b) => a.attribute.localeCompare(b.attribute));
781 return stable({
782 eventType: trigger.eventType ?? "",
783 pubsubTopic: trigger.pubsubTopic ?? "",
784 serviceAccountEmail: trigger.serviceAccountEmail ?? "",
785 retryPolicy: trigger.retryPolicy ?? "",
786 triggerRegion: trigger.triggerRegion ?? "",
787 channel: trigger.channel ?? "",
788 service: trigger.service ?? "",
789 filters,
790 });
791};
792
793const observedEventTrigger = (
794 trigger: cloudfunctions.EventTrigger | undefined,
795): EventTrigger | undefined => {
796 if (trigger === undefined) return undefined;
797 return {
798 eventType: trigger.eventType,
799 pubsubTopic: trigger.pubsubTopic,
800 serviceAccountEmail: trigger.serviceAccountEmail,
801 retryPolicy: trigger.retryPolicy,
802 eventFilters: trigger.eventFilters,
803 triggerRegion: trigger.triggerRegion,
804 channel: trigger.channel,
805 service: trigger.service,
806 };
807};
808
809export const FunctionProvider = () =>
810 Provider.succeed(Function, {
811 stables: [
812 "name",
813 "functionId",
814 "project",
815 "location",
816 "environment",
817 "createTime",
818 ],
819
820 diff: Effect.fn(function* ({ news, olds, output }) {
821 if (!isResolved(news)) return undefined;
822 const env = yield* GcpEnvironment.current;
823
824 const previousId = olds?.functionId ?? output?.functionId;
825 const nextId = news.functionId ?? previousId;
826 const previousLocation = normalizeLocation(
827 olds?.location ?? output?.location,
828 env.region,
829 );
830 const nextLocation = normalizeLocation(
831 news.location ?? output?.location,
832 env.region,
833 );
834 const previousEnvironment = normalizeEnvironment(
835 olds?.environment ?? output?.environment,
836 );
837 const nextEnvironment = normalizeEnvironment(
838 news.environment ?? output?.environment,
839 );
840
841 const replace =
842 (previousId !== undefined &&
843 news.functionId !== undefined &&
844 news.functionId !== previousId) ||
845 previousLocation !== nextLocation ||
846 previousEnvironment !== nextEnvironment;
847
848 if (replace) {
849 return {
850 action: "replace" as const,
851 deleteFirst: previousId !== undefined && nextId === previousId,
852 };
853 }
854 // A code-only change leaves the props untouched; hash the bundle so
855 // it still surfaces as an update.
856 if (output !== undefined && news.main !== undefined) {
857 const source = yield* makeFunctionSource;
858 const { codeHash } = yield* source.bundle({
859 main: news.main,
860 handler: news.handler,
861 build: news.build,
862 isExternal: news.isExternal,
863 });
864 if (codeHash !== output.codeHash) {
865 return { action: "update" as const };
866 }
867 }
868 return undefined;
869 }),
870
871 read: Effect.fn(function* ({ id, olds, output }) {
872 const env = yield* GcpEnvironment.current;
873 const functionId = yield* toId(id, olds?.functionId, output?.functionId);
874 const location = normalizeLocation(
875 olds?.location ?? output?.location,
876 env.region,
877 );
878 const name =
879 output?.name ?? resourceName(env.project, location, functionId);
880 const existing = yield* getByName(name);
881 if (existing === undefined) return undefined;
882 const attrs = toAttrs(existing, env.project, env.region, {
883 iamGrants: output?.iamGrants,
884 codeHash: output?.codeHash,
885 });
886 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
887 ? attrs
888 : Unowned(attrs);
889 }),
890
891 list: () =>
892 Effect.gen(function* () {
893 const env = yield* GcpEnvironment.current;
894 return yield* cloudfunctions.listProjectsLocationsFunctions
895 .pages({
896 parent: `projects/${env.project}/locations/-`,
897 pageSize: 1000,
898 })
899 .pipe(
900 Stream.flatMap((page) => Stream.fromIterable(page.functions ?? [])),
901 Stream.filter((fn) =>
902 Object.keys(fn.labels ?? {}).some((key) =>
903 key.startsWith("alchemy-"),
904 ),
905 ),
906 Stream.map((fn) => toAttrs(fn, env.project, env.region)),
907 Stream.runCollect,
908 Effect.map((chunk) => Array.from(chunk)),
909 );
910 }),
911
912 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
913 const env = yield* GcpEnvironment.current;
914 const functionId = yield* toId(id, news.functionId, output?.functionId);
915 const location = normalizeLocation(
916 news.location ?? output?.location,
917 env.region,
918 );
919 const environment = normalizeEnvironment(
920 news.environment ?? output?.environment,
921 );
922 const name = resourceName(env.project, location, functionId);
923 const desiredLabels = {
924 ...toLabels(news.labels),
925 ...(yield* createInternalLabels(id)),
926 };
927 const identity = yield* resolveHostIdentity({
928 project: env.project,
929 hostType: HOST_TYPE,
930 resourceName: name,
931 userServiceAccount: news.serviceConfig?.serviceAccountEmail,
932 effectNative: news.main !== undefined,
933 bindings: bindings as ResourceBinding<GcpHostBinding>[],
934 output: output && {
935 ...output,
936 serviceAccount: output.serviceAccountEmail,
937 },
938 });
939 const serviceAccount = identity.serviceAccount;
940 const collected = identity;
941 const runtimeEnv: Record<string, unknown> = {
942 ...(news.main !== undefined ? yield* alchemyRuntimeEnv : {}),
943 ...collected.env,
944 ...news.env,
945 };
946 const serviceConfig = {
947 ...news.serviceConfig,
948 serviceAccountEmail: serviceAccount,
949 environmentVariables: {
950 ...Object.fromEntries(
951 Object.entries(runtimeEnv).map(([key, value]) => [
952 key,
953 packEnvValue(value),
954 ]),
955 ),
956 ...news.serviceConfig?.environmentVariables,
957 },
958 };
959
960 let current = yield* getByName(name);
961 if (current?.state === "DELETING") {
962 yield* waitUntilGone(name);
963 current = undefined;
964 }
965
966 // Effect-native: bundle `main`, and upload a new archive only when
967 // the bundle changed (reusing the deployed source otherwise).
968 let codeHash: string | undefined;
969 let buildConfig = news.buildConfig;
970 if (news.main !== undefined) {
971 const source = yield* makeFunctionSource;
972 const bundled = yield* source
973 .bundle({
974 main: news.main,
975 handler: news.handler,
976 build: news.build,
977 isExternal: news.isExternal,
978 })
979 .pipe(Effect.onError(() => identity.cleanup));
980 codeHash = bundled.codeHash;
981 const deployed = current?.buildConfig?.source?.storageSource;
982 const storageSource =
983 current !== undefined &&
984 output?.codeHash === codeHash &&
985 deployed !== undefined
986 ? deployed
987 : yield* source
988 .upload({
989 parent: `projects/${env.project}/locations/${location}`,
990 files: bundled.files,
991 kmsKeyName: news.kmsKeyName,
992 })
993 .pipe(Effect.onError(() => identity.cleanup));
994 buildConfig = {
995 ...news.buildConfig,
996 runtime: news.buildConfig?.runtime ?? DEFAULT_NODE_RUNTIME,
997 entryPoint: FUNCTION_ENTRY_POINT,
998 source: { storageSource },
999 };
1000 }
1001
1002 if (current === undefined) {
1003 const created = yield* cloudfunctions
1004 .createProjectsLocationsFunctions({
1005 parent: `projects/${env.project}/locations/${location}`,
1006 functionId,
1007 body: {
1008 name,
1009 description: news.description,
1010 labels: desiredLabels,
1011 kmsKeyName: news.kmsKeyName,
1012 environment,
1013 buildConfig: mergeBuildConfig(buildConfig, undefined),
1014 serviceConfig,
1015 eventTrigger: news.eventTrigger,
1016 },
1017 })
1018 .pipe(
1019 retryActAs,
1020 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
1021 Effect.onError(() => identity.cleanup),
1022 );
1023 if (created !== undefined) {
1024 yield* waitForOperation(created, { alreadyExistsOk: true });
1025 }
1026 current = yield* waitUntilReady(name);
1027 }
1028 if (current === undefined) {
1029 return yield* new FunctionNotResolved({ name });
1030 }
1031
1032 const observedLabels = tagRecord(current.labels);
1033 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
1034 const labelsChanged = upsert.length > 0 || removed.length > 0;
1035 const descriptionChanged =
1036 (current.description ?? "") !== (news.description ?? "");
1037 const kmsChanged =
1038 news.kmsKeyName !== undefined &&
1039 (current.kmsKeyName ?? "") !== news.kmsKeyName;
1040
1041 const desiredBuild =
1042 buildConfig !== undefined
1043 ? mergeBuildConfig(buildConfig, current.buildConfig)
1044 : undefined;
1045 const buildConfigChanged =
1046 desiredBuild !== undefined &&
1047 !sameBuildConfig(desiredBuild, current.buildConfig);
1048
1049 const desiredService = serviceConfig;
1050 const serviceConfigChanged =
1051 desiredService !== undefined &&
1052 ((desiredService.availableMemory !== undefined &&
1053 (current.serviceConfig?.availableMemory ?? "") !==
1054 desiredService.availableMemory) ||
1055 (desiredService.availableCpu !== undefined &&
1056 (current.serviceConfig?.availableCpu ?? "") !==
1057 desiredService.availableCpu) ||
1058 (desiredService.timeoutSeconds !== undefined &&
1059 (current.serviceConfig?.timeoutSeconds ?? 60) !==
1060 desiredService.timeoutSeconds) ||
1061 (desiredService.minInstanceCount !== undefined &&
1062 (current.serviceConfig?.minInstanceCount ?? 0) !==
1063 desiredService.minInstanceCount) ||
1064 (desiredService.maxInstanceCount !== undefined &&
1065 (current.serviceConfig?.maxInstanceCount ?? 0) !==
1066 desiredService.maxInstanceCount) ||
1067 (desiredService.maxInstanceRequestConcurrency !== undefined &&
1068 (current.serviceConfig?.maxInstanceRequestConcurrency ?? 1) !==
1069 desiredService.maxInstanceRequestConcurrency) ||
1070 (desiredService.environmentVariables !== undefined &&
1071 !deepEqual(
1072 envMap(desiredService.environmentVariables),
1073 envMap(current.serviceConfig?.environmentVariables),
1074 { stripNullish: true },
1075 )) ||
1076 (desiredService.ingressSettings !== undefined &&
1077 (current.serviceConfig?.ingressSettings ?? "") !==
1078 desiredService.ingressSettings) ||
1079 (desiredService.vpcConnector !== undefined &&
1080 (current.serviceConfig?.vpcConnector ?? "") !==
1081 desiredService.vpcConnector) ||
1082 (desiredService.vpcConnectorEgressSettings !== undefined &&
1083 (current.serviceConfig?.vpcConnectorEgressSettings ?? "") !==
1084 desiredService.vpcConnectorEgressSettings) ||
1085 (desiredService.serviceAccountEmail !== undefined &&
1086 (current.serviceConfig?.serviceAccountEmail ?? "") !==
1087 desiredService.serviceAccountEmail) ||
1088 (desiredService.allTrafficOnLatestRevision !== undefined &&
1089 (current.serviceConfig?.allTrafficOnLatestRevision ?? true) !==
1090 desiredService.allTrafficOnLatestRevision) ||
1091 (desiredService.secretEnvironmentVariables !== undefined &&
1092 stable(desiredService.secretEnvironmentVariables) !==
1093 stable(current.serviceConfig?.secretEnvironmentVariables)) ||
1094 (desiredService.secretVolumes !== undefined &&
1095 stable(desiredService.secretVolumes) !==
1096 stable(current.serviceConfig?.secretVolumes)) ||
1097 (desiredService.directVpcEgress !== undefined &&
1098 (current.serviceConfig?.directVpcEgress ?? "") !==
1099 desiredService.directVpcEgress) ||
1100 (desiredService.directVpcNetworkInterface !== undefined &&
1101 stable(desiredService.directVpcNetworkInterface) !==
1102 stable(current.serviceConfig?.directVpcNetworkInterface)));
1103
1104 const eventTriggerChanged =
1105 news.eventTrigger !== undefined &&
1106 eventTriggerKey(news.eventTrigger) !==
1107 eventTriggerKey(observedEventTrigger(current.eventTrigger));
1108
1109 if (
1110 labelsChanged ||
1111 descriptionChanged ||
1112 kmsChanged ||
1113 buildConfigChanged ||
1114 serviceConfigChanged ||
1115 eventTriggerChanged
1116 ) {
1117 const updateMask = [
1118 labelsChanged ? "labels" : undefined,
1119 descriptionChanged ? "description" : undefined,
1120 kmsChanged ? "kmsKeyName" : undefined,
1121 buildConfigChanged ? "buildConfig" : undefined,
1122 serviceConfigChanged ? "serviceConfig" : undefined,
1123 eventTriggerChanged ? "eventTrigger" : undefined,
1124 ].filter((field): field is string => field !== undefined);
1125
1126 const operation = yield* cloudfunctions.patchProjectsLocationsFunctions(
1127 {
1128 name,
1129 updateMask: updateMask.join(","),
1130 body: {
1131 name,
1132 labels: desiredLabels,
1133 description: news.description,
1134 kmsKeyName: news.kmsKeyName ?? current.kmsKeyName,
1135 buildConfig: desiredBuild ?? current.buildConfig,
1136 serviceConfig: {
1137 ...current.serviceConfig,
1138 ...desiredService,
1139 service: undefined,
1140 uri: undefined,
1141 revision: undefined,
1142 },
1143 eventTrigger: news.eventTrigger ?? current.eventTrigger,
1144 },
1145 },
1146 );
1147 yield* waitForOperation(operation);
1148 current = yield* waitUntilReady(name);
1149 }
1150 if (current === undefined) {
1151 return yield* new FunctionNotResolved({ name });
1152 }
1153
1154 return toAttrs(current, env.project, env.region, {
1155 iamGrants: identity.grants,
1156 codeHash,
1157 });
1158 }),
1159
1160 delete: Effect.fn(function* ({ output }) {
1161 const operation = yield* cloudfunctions
1162 .deleteProjectsLocationsFunctions({ name: output.name })
1163 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
1164 if (operation !== undefined) {
1165 yield* waitForOperation(operation, { notFoundOk: true });
1166 }
1167 yield* waitUntilGone(output.name);
1168 yield* releaseHostIdentity({
1169 ...output,
1170 serviceAccount: output.serviceAccountEmail,
1171 });
1172 }),
1173 });