1import { Credentials } from "@distilled.cloud/gcp/Credentials";
2import * as cloudrun from "@distilled.cloud/gcp/run_v2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import type * as Bundle from "../../Bundle/Bundle.ts";
9import type { PackageInstall } from "../../Bundle/InstalledPackages.ts";
10import type { ExtraFile } from "../../Util/extraFiles.ts";
11import { isResolved } from "../../Diff.ts";
12import { createPhysicalName } from "../../PhysicalName.ts";
13import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
14import * as Provider from "../../Provider.ts";
15import { Resource, type ResourceBinding } from "../../Resource.ts";
16import { type ServerHost } from "../../Server/Process.ts";
17import { tagRecord } from "../../Tags.ts";
18import {
19 destroyHostImageRepository,
20 makeImageSource,
21} from "../ArtifactRegistry/ImageSource.ts";
22import { GcpEnvironment } from "../Environment.ts";
23import {
24 type LongRunningOperation,
25 waitForOperation as waitForLongRunningOperation,
26} from "../Operation.ts";
27import {
28 createGcpHostRuntimeContext,
29 type GcpHostRuntimeContext,
30} from "../HostContext.ts";
31import {
32 retryActAs,
33 type AppliedIamGrant,
34 type GcpHostBinding,
35} from "../Host.ts";
36import {
37 alchemyRuntimeEnv,
38 isManagedServiceAccount,
39 makeGcpBootstrap,
40 mergeContainerEnv,
41 mountCloudSqlInstances,
42 releaseHostIdentity,
43 resolveHostIdentity,
44} from "../HostRuntime.ts";
45import {
46 createInternalLabels,
47 diffLabels,
48 hasAlchemyLabels,
49 stripInternalLabels,
50 toLabels,
51} from "../Labels.ts";
52import type { Providers } from "../Providers.ts";
53
54const DEFAULT_IMAGE = "us-docker.pkg.dev/cloudrun/container/hello";
55const MAX_NAME_LENGTH = 49;
56
57export type EnvVar = {
58 /** Environment variable name. */
59 name?: string;
60 /** Literal value. Mutually exclusive with `valueSource`. */
61 value?: string;
62 /** Secret Manager source for the value. */
63 valueSource?: {
64 secretKeyRef?: {
65 secret?: string;
66 version?: string;
67 };
68 };
69};
70
71export type ContainerPort = {
72 /** Protocol name (`http1` or `h2c`). */
73 name?: string;
74 /** TCP port the container listens on. */
75 containerPort?: number;
76};
77
78export type ResourceRequirements = {
79 /** Resource limits. Keys: `cpu`, `memory`, `nvidia.com/gpu`. */
80 limits?: Record<string, string>;
81 /** Allocate CPU only during requests. */
82 cpuIdle?: boolean;
83 /** Boost CPU on startup to reduce cold starts. */
84 startupCpuBoost?: boolean;
85};
86
87export type Container = {
88 /** DNS_LABEL container name. */
89 name?: string;
90 /**
91 * Container image (Artifact Registry, GCR, or Docker Hub). Required
92 * unless `template` is omitted, in which case the public Cloud Run hello
93 * image is used.
94 */
95 image?: string;
96 /** Entrypoint. */
97 command?: string[];
98 /** Arguments to the entrypoint. */
99 args?: string[];
100 /** Environment variables. */
101 env?: EnvVar[];
102 /** Exposed ports. Only one port may be set. */
103 ports?: ContainerPort[];
104 /** CPU / memory / GPU requirements. */
105 resources?: ResourceRequirements;
106 /** Working directory. */
107 workingDir?: string;
108 /** Volume mounts. Names must match the template's `volumes`. */
109 volumeMounts?: cloudrun.GoogleCloudRunV2VolumeMountList;
110};
111
112export type RevisionScaling = {
113 /** Minimum serving instances. */
114 minInstanceCount?: number;
115 /** Maximum serving instances. Server default is 100. */
116 maxInstanceCount?: number;
117 /** CPU utilization target in `[0.1, 0.95]`, or `0` to disable. */
118 cpuUtilization?: number;
119 /** Concurrency utilization target in `[0.1, 0.95]`, or `0` to disable. */
120 concurrencyUtilization?: number;
121};
122
123export type RevisionTemplate = {
124 /** Containers that make up the revision. */
125 containers?: Container[];
126 /** Request timeout (e.g. `"300s"`). */
127 timeout?: string;
128 /** Max concurrent requests per instance. `0` uses the server default. */
129 maxInstanceRequestConcurrency?: number;
130 /** Runtime service account email. Defaults to the project Compute SA. */
131 serviceAccount?: string;
132 /** Sandbox (`EXECUTION_ENVIRONMENT_GEN1` or `EXECUTION_ENVIRONMENT_GEN2`). */
133 executionEnvironment?: string;
134 /** Enable session affinity. */
135 sessionAffinity?: boolean;
136 /** Revision-level scaling. */
137 scaling?: RevisionScaling;
138 /** Revision labels. */
139 labels?: Record<string, string>;
140 /** Revision annotations. */
141 annotations?: Record<string, string>;
142 /** Direct VPC egress / connector. */
143 vpcAccess?: cloudrun.GoogleCloudRunV2VpcAccess;
144 /** Volumes available to containers. */
145 volumes?: cloudrun.GoogleCloudRunV2VolumeList;
146 /** CMEK used to encrypt the container image. */
147 encryptionKey?: string;
148 /** Unique revision name. Generated from the service name if omitted. */
149 revision?: string;
150};
151
152export type TrafficTarget = {
153 /** Allocation type (`TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST` or `_REVISION`). */
154 type?: string;
155 /** Revision to send traffic to when allocating by revision. */
156 revision?: string;
157 /** Percent of traffic (0–100). */
158 percent?: number;
159 /** URI tag for this target. */
160 tag?: string;
161};
162
163export type ServiceScaling = {
164 /** Minimum instances across all revisions. */
165 minInstanceCount?: number;
166 /** Maximum instances across all revisions. */
167 maxInstanceCount?: number;
168 /** `AUTOMATIC` or `MANUAL`. */
169 scalingMode?: string;
170 /** Instance count in manual scaling mode. */
171 manualInstanceCount?: number;
172};
173
174export type ServiceProps = PlatformProps & {
175 /**
176 * Service id (the `{service}` segment of
177 * `projects/{project}/locations/{location}/services/{service}`). If
178 * omitted, a unique name is generated from the stack, stage, and logical
179 * id. Must begin with a letter, not end with a hyphen, and be fewer than
180 * 50 characters. Immutable — changing it replaces the service.
181 */
182 serviceId?: string;
183 /**
184 * Region (`us-central1`, `europe-west1`, …). Immutable — changing it
185 * replaces the service. `US-CENTRAL1` is accepted and normalized to
186 * `us-central1`.
187 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
188 */
189 location?: string;
190 /**
191 * User labels. Alchemy ownership labels are merged in automatically.
192 */
193 labels?: Record<string, string>;
194 /**
195 * Unstructured annotations. Cloud Run rejects `run.googleapis.com` /
196 * `cloud.googleapis.com` / Knative namespaces.
197 */
198 annotations?: Record<string, string>;
199 /**
200 * Human-readable description (max 512 characters).
201 */
202 description?: string;
203 /**
204 * Ingress (`INGRESS_TRAFFIC_ALL`, `INGRESS_TRAFFIC_INTERNAL_ONLY`,
205 * `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER`, `INGRESS_TRAFFIC_NONE`).
206 */
207 ingress?: cloudrun.GoogleCloudRunV2ServiceIngressEnum | (string & {});
208 /**
209 * Disable the IAM invoker check (`run.routes.invoke`).
210 * @default false
211 */
212 invokerIamDisabled?: boolean;
213 /**
214 * Disable public resolution of the default `*.run.app` URI.
215 * @default false
216 */
217 defaultUriDisabled?: boolean;
218 /**
219 * Enable Identity-Aware Proxy on the service.
220 */
221 iapEnabled?: boolean;
222 /**
223 * Custom audiences encoded into ID tokens.
224 */
225 customAudiences?: string[];
226 /**
227 * Service-level scaling.
228 */
229 scaling?: ServiceScaling;
230 /**
231 * Traffic split. Empty / omitted sends 100% to the latest Ready
232 * revision.
233 */
234 traffic?: TrafficTarget[];
235 /**
236 * Revision template. If omitted, a single container runs the public
237 * Cloud Run hello image. The Effect-native `main` form fills `image`
238 * for you.
239 */
240 template?: RevisionTemplate;
241 /**
242 * Module entrypoint for an Effect-native service (typically
243 * `import.meta.url`). Alchemy bundles the program, builds a container
244 * image, pushes it to Artifact Registry, and deploys Cloud Run.
245 * Bindings attach env + IAM onto the runtime service account.
246 */
247 main?: string;
248 /**
249 * Named export to load from `main`.
250 * @default "default"
251 */
252 handler?: string;
253 /**
254 * HTTP port the Effect-native program listens on (Cloud Run injects
255 * `PORT`). Only used with `main`.
256 * @default 8080
257 */
258 port?: number;
259 /**
260 * Additional environment variables for the Effect-native container.
261 * Merged after binding-injected `env`.
262 */
263 env?: Record<string, any>;
264 /**
265 * Bundler configuration for `main`. `install` lists packages
266 * `npm install`ed into an unbundled Node program image (see
267 * {@link extraFiles}); Next.js needs `next`.
268 */
269 build?: Bundle.BundleConfig & { install?: PackageInstall };
270 /**
271 * Host files or directories baked into the image next to `main`
272 * (`dest` is relative to `/app`; `"."` merges into `/app`). When set on
273 * an external (non-Effect) `main`, the program is not bundled: `main`
274 * is copied as-is and run by `node` on `node:26-slim`. Content-hashed,
275 * so a changed file rolls out a new revision. `GCP.Website.*` uses this
276 * to ship framework build output.
277 */
278 extraFiles?: ReadonlyArray<ExtraFile>;
279};
280
281export type Service = Resource<
282 "GCP.Run.Service",
283 ServiceProps,
284 {
285 /** Full resource name `projects/{project}/locations/{location}/services/{service}`. */
286 name: string;
287 /** Service id (last path segment). */
288 serviceId: string;
289 /** Project id. */
290 project: string;
291 /** Region (`us-central1`, …). */
292 location: string;
293 /** User labels (Alchemy ownership labels stripped). */
294 labels: Record<string, string>;
295 /** User annotations. */
296 annotations: Record<string, string>;
297 /** Description. */
298 description: string | undefined;
299 /** Main serving URI. */
300 uri: string | undefined;
301 /** All serving URLs. */
302 urls: ReadonlyArray<string>;
303 /** Server-assigned UUID. */
304 uid: string | undefined;
305 /** Ingress setting. */
306 ingress: string | undefined;
307 /** Whether the IAM invoker check is disabled. */
308 invokerIamDisabled: boolean;
309 /** Latest created revision name. */
310 latestCreatedRevision: string | undefined;
311 /** Latest Ready revision name. */
312 latestReadyRevision: string | undefined;
313 /** Terminal condition state (`CONDITION_SUCCEEDED`, …). */
314 terminalConditionState: string | undefined;
315 /** True while Cloud Run is still reconciling the desired state. */
316 reconciling: boolean;
317 /** Observed generation. */
318 generation: string | undefined;
319 /** RFC3339 creation timestamp. */
320 createTime: string | undefined;
321 /** RFC3339 last-update timestamp. */
322 updateTime: string | undefined;
323 /** Runtime service account email. */
324 serviceAccount: string | undefined;
325 /** True when Alchemy minted the per-host runtime service account. */
326 managedServiceAccount: boolean;
327 /** IAM roles bindings granted to the runtime service account. */
328 iamGrants: AppliedIamGrant[];
329 /** Hash of the bundled `main` program and its bootstrap (Effect-native only). */
330 codeHash: string | undefined;
331 },
332 GcpHostBinding,
333 Providers
334>;
335
336export type ServiceRuntimeContext = GcpHostRuntimeContext;
337export type ServiceServices = Credentials | GcpEnvironment | ServerHost;
338export type ServiceShape = Main<ServiceServices>;
339
340/**
341 * A Cloud Run service (Knative serving revision + traffic).
342 *
343 * Changing `serviceId` or `location` replaces the service. Updates to
344 * `template` create a new revision.
345 *
346 * This is GCP's effectful HTTP platform — the analog of
347 * `AWS.Lambda.Function` / `Cloudflare.Worker`. Pass `main` plus an
348 * Effect implementation; bindings grant IAM onto the runtime service
349 * account and inject env, the way AWS `policyStatements` land on the
350 * execution role.
351 *
352 * ### Creating a Service
353 * **Example:** Generated name, default hello image
354 * ```typescript
355 * const api = yield* GCP.Run.Service("api", {});
356 * ```
357 *
358 * **Example:** Explicit id, image, env, and labels
359 * ```typescript
360 * const api = yield* GCP.Run.Service("api", {
361 * serviceId: "order-api",
362 * location: "us-central1",
363 * description: "order HTTP API",
364 * labels: { env: "prod" },
365 * ingress: "INGRESS_TRAFFIC_ALL",
366 * template: {
367 * timeout: "60s",
368 * containers: [
369 * {
370 * image: "us-docker.pkg.dev/cloudrun/container/hello",
371 * env: [{ name: "ENV", value: "prod" }],
372 * },
373 * ],
374 * },
375 * });
376 * ```
377 *
378 * ### Effect-native Function with bindings
379 * **Example:** Publish tweets to Pub/Sub, cache in Memorystore
380 * ```typescript
381 * const tweets = yield* GCP.PubSub.Topic("tweets", {});
382 * const cache = yield* GCP.Redis.Instance("Cache", { memorySizeGb: 1 });
383 *
384 * export class TweetBot extends GCP.Run.Service<TweetBot>()(
385 * "TweetBot",
386 * { main: import.meta.url },
387 * Effect.gen(function* () {
388 * const publish = yield* GCP.PubSub.Publish(tweets);
389 * const redis = yield* GCP.Redis.ReadWriteRedis(cache);
390 * return {
391 * fetch: Effect.gen(function* () {
392 * const text = (yield* redis.get("last-tweet")) ?? "hello twitter";
393 * yield* publish({
394 * body: { messages: [{ data: btoa(text) }] },
395 * });
396 * return HttpServerResponse.text("queued");
397 * }),
398 * };
399 * }).pipe(
400 * Effect.provide([
401 * GCP.PubSub.PublishHttp,
402 * GCP.Redis.ReadWriteRedisHttp,
403 * ]),
404 * ),
405 * ) {}
406 * ```
407 *
408 * ### Updating a Service
409 * **Example:** New revision
410 * ```typescript
411 * const api = yield* GCP.Run.Service("api", {
412 * template: {
413 * timeout: "120s",
414 * containers: [{ image: "us-docker.pkg.dev/cloudrun/container/hello" }],
415 * },
416 * });
417 * ```
418 * Template changes create a new revision. The runtime service account is kept.
419 *
420 * ### Destroying a Service
421 * **Example:** `alchemy destroy` deletes the service and the minted `alch-*` service account.
422 *
423 * ### Reading a Service
424 * **Example:** Get the live service
425 * ```typescript
426 * const getService = yield* GCP.Run.GetService(api);
427 * const live = yield* getService();
428 * ```
429 *
430 * @resource
431 * @category Run
432 */
433export const Service: Platform<
434 Service,
435 ServiceServices,
436 ServiceShape,
437 ServiceRuntimeContext
438> = Platform("GCP.Run.Service", {
439 createRuntimeContext: createGcpHostRuntimeContext("GCP.Run.Service") as (
440 id: string,
441 ) => ServiceRuntimeContext,
442});
443
444export class ServiceNotResolved extends Data.TaggedError(
445 "GCP.Run.ServiceNotResolved",
446)<{
447 name: string;
448}> {}
449
450export class ServiceNotReady extends Data.TaggedError(
451 "GCP.Run.ServiceNotReady",
452)<{
453 name: string;
454 state: string;
455 message: string;
456}> {}
457
458export class ServiceReconciling extends Data.TaggedError(
459 "GCP.Run.ServiceReconciling",
460)<{
461 name: string;
462 state: string;
463}> {}
464
465export class ServiceStillExists extends Data.TaggedError(
466 "GCP.Run.ServiceStillExists",
467)<{
468 name: string;
469}> {}
470
471const lastSegment = (value: string) => {
472 const trimmed = value.replace(/\/+$/, "");
473 const parts = trimmed.split("/");
474 return parts[parts.length - 1] || trimmed;
475};
476
477const normalizeLocation = (
478 location: string | undefined,
479 defaultLocation: string,
480) => lastSegment(location ?? defaultLocation).toLowerCase();
481
482const resourceName = (project: string, location: string, serviceId: string) =>
483 `projects/${project}/locations/${location}/services/${serviceId}`;
484
485const parseName = (name: string, defaultLocation: string) => {
486 const parts = name.split("/").filter((part) => part.length > 0);
487 const servicesAt = parts.lastIndexOf("services");
488 const locationsAt = parts.lastIndexOf("locations");
489 const projectsAt = parts.lastIndexOf("projects");
490 return {
491 project:
492 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
493 location:
494 locationsAt >= 0 && parts[locationsAt + 1]
495 ? parts[locationsAt + 1]!
496 : defaultLocation,
497 serviceId:
498 servicesAt >= 0 && parts[servicesAt + 1]
499 ? parts[servicesAt + 1]!
500 : lastSegment(name),
501 };
502};
503
504const userLabels = (
505 labels: Record<string, string | undefined> | null | undefined,
506): Record<string, string> => stripInternalLabels(tagRecord(labels));
507
508const userAnnotations = (
509 annotations: Record<string, string | undefined> | null | undefined,
510): Record<string, string> => tagRecord(annotations);
511
512const recordsEqual = (
513 left: Record<string, string>,
514 right: Record<string, string>,
515) => {
516 const leftKeys = Object.keys(left).sort();
517 const rightKeys = Object.keys(right).sort();
518 return (
519 leftKeys.length === rightKeys.length &&
520 leftKeys.every(
521 (key, index) => key === rightKeys[index] && left[key] === right[key],
522 )
523 );
524};
525
526const rfc1035 = (name: string): string => {
527 let next = name
528 .toLowerCase()
529 .replace(/[^a-z0-9-]/g, "-")
530 .replace(/-+/g, "-")
531 .replace(/^-+|-+$/g, "");
532 if (!/^[a-z]/.test(next)) next = `s${next}`;
533 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
534 return next.length > 0 ? next : "service";
535};
536
537const toId = (id: string, serviceId: string | undefined, existing?: string) =>
538 Effect.gen(function* () {
539 if (serviceId !== undefined) return serviceId;
540 if (existing !== undefined) return existing;
541 return rfc1035(
542 yield* createPhysicalName({
543 id,
544 maxLength: MAX_NAME_LENGTH,
545 lowercase: true,
546 }),
547 );
548 });
549
550const desiredTemplate = (
551 news: ServiceProps,
552): cloudrun.GoogleCloudRunV2RevisionTemplate => {
553 const template = news.template ?? {};
554 return {
555 ...template,
556 containers: template.containers ?? [{ image: DEFAULT_IMAGE }],
557 };
558};
559
560const normalizeDuration = (value: string | undefined) => {
561 if (value === undefined) return undefined;
562 const match = /^([0-9]+(?:\.[0-9]+)?)s$/.exec(value.trim());
563 if (!match) return value;
564 return `${Number(match[1])}s`;
565};
566
567const envFingerprint = (env: cloudrun.GoogleCloudRunV2EnvVarList | undefined) =>
568 JSON.stringify(
569 [...(env ?? [])]
570 .map((item) => ({
571 name: item.name ?? "",
572 value: item.value ?? "",
573 secret: item.valueSource?.secretKeyRef?.secret ?? "",
574 version: item.valueSource?.secretKeyRef?.version ?? "",
575 }))
576 .sort((left, right) => left.name.localeCompare(right.name)),
577 );
578
579const containerNeedsSync = (
580 desired: cloudrun.GoogleCloudRunV2ContainerList | undefined,
581 observed: cloudrun.GoogleCloudRunV2ContainerList | undefined,
582) => {
583 const want = desired ?? [];
584 const have = observed ?? [];
585 if (want.length !== have.length) return true;
586 return want.some((container, index) => {
587 const current = have[index];
588 if (current === undefined) return true;
589 if ((container.image ?? "") !== (current.image ?? "")) return true;
590 if (
591 container.command !== undefined &&
592 JSON.stringify(container.command) !==
593 JSON.stringify(current.command ?? [])
594 ) {
595 return true;
596 }
597 if (
598 container.args !== undefined &&
599 JSON.stringify(container.args) !== JSON.stringify(current.args ?? [])
600 ) {
601 return true;
602 }
603 if (
604 container.workingDir !== undefined &&
605 container.workingDir !== (current.workingDir ?? "")
606 ) {
607 return true;
608 }
609 if (
610 container.env !== undefined &&
611 envFingerprint(container.env) !== envFingerprint(current.env)
612 ) {
613 return true;
614 }
615 if (
616 container.ports !== undefined &&
617 JSON.stringify(
618 container.ports.map((port) => ({
619 name: port.name ?? "",
620 containerPort: port.containerPort ?? 0,
621 })),
622 ) !==
623 JSON.stringify(
624 (current.ports ?? []).map((port) => ({
625 name: port.name ?? "",
626 containerPort: port.containerPort ?? 0,
627 })),
628 )
629 ) {
630 return true;
631 }
632 if (container.resources?.limits !== undefined) {
633 const wantLimits = tagRecord(container.resources.limits);
634 const haveLimits = tagRecord(current.resources?.limits);
635 for (const [key, value] of Object.entries(wantLimits)) {
636 if (haveLimits[key] !== value) return true;
637 }
638 }
639 if (
640 container.resources?.cpuIdle !== undefined &&
641 container.resources.cpuIdle !== current.resources?.cpuIdle
642 ) {
643 return true;
644 }
645 if (
646 container.resources?.startupCpuBoost !== undefined &&
647 container.resources.startupCpuBoost !== current.resources?.startupCpuBoost
648 ) {
649 return true;
650 }
651 if (
652 container.name !== undefined &&
653 container.name !== (current.name ?? "")
654 ) {
655 return true;
656 }
657 if (
658 container.volumeMounts !== undefined &&
659 JSON.stringify(
660 container.volumeMounts.map((mount) => ({
661 name: mount.name ?? "",
662 mountPath: mount.mountPath ?? "",
663 })),
664 ) !==
665 JSON.stringify(
666 (current.volumeMounts ?? []).map((mount) => ({
667 name: mount.name ?? "",
668 mountPath: mount.mountPath ?? "",
669 })),
670 )
671 ) {
672 return true;
673 }
674 return false;
675 });
676};
677
678const scalingFingerprint = (scaling: ServiceScaling | undefined) =>
679 JSON.stringify({
680 min: scaling?.minInstanceCount ?? null,
681 max: scaling?.maxInstanceCount ?? null,
682 mode: scaling?.scalingMode ?? "",
683 manual: scaling?.manualInstanceCount ?? null,
684 });
685
686const trafficFingerprint = (traffic: TrafficTarget[] | undefined) =>
687 JSON.stringify(
688 (traffic ?? []).map((target) => ({
689 type: target.type ?? "",
690 revision: target.revision ?? "",
691 percent: target.percent ?? 0,
692 tag: target.tag ?? "",
693 })),
694 );
695
696const audiencesFingerprint = (audiences: string[] | undefined) =>
697 JSON.stringify([...(audiences ?? [])].sort());
698
699const templateNeedsSync = (
700 desired: cloudrun.GoogleCloudRunV2RevisionTemplate,
701 observed: cloudrun.GoogleCloudRunV2RevisionTemplate | undefined,
702) => {
703 const current = observed ?? {};
704 if (containerNeedsSync(desired.containers, current.containers)) {
705 return true;
706 }
707 if (
708 desired.timeout !== undefined &&
709 normalizeDuration(desired.timeout) !== normalizeDuration(current.timeout)
710 ) {
711 return true;
712 }
713 if (
714 desired.maxInstanceRequestConcurrency !== undefined &&
715 desired.maxInstanceRequestConcurrency !==
716 (current.maxInstanceRequestConcurrency ?? 0)
717 ) {
718 return true;
719 }
720 if (
721 desired.serviceAccount !== undefined &&
722 desired.serviceAccount !== (current.serviceAccount ?? "")
723 ) {
724 return true;
725 }
726 if (
727 desired.executionEnvironment !== undefined &&
728 desired.executionEnvironment !== (current.executionEnvironment ?? "")
729 ) {
730 return true;
731 }
732 if (
733 desired.sessionAffinity !== undefined &&
734 desired.sessionAffinity !== (current.sessionAffinity === true)
735 ) {
736 return true;
737 }
738 if (
739 desired.encryptionKey !== undefined &&
740 desired.encryptionKey !== (current.encryptionKey ?? "")
741 ) {
742 return true;
743 }
744 if (
745 desired.revision !== undefined &&
746 desired.revision !== (current.revision ?? "")
747 ) {
748 return true;
749 }
750 if (
751 desired.scaling !== undefined &&
752 scalingFingerprint(desired.scaling) !== scalingFingerprint(current.scaling)
753 ) {
754 return true;
755 }
756 if (
757 desired.vpcAccess !== undefined &&
758 JSON.stringify(desired.vpcAccess) !==
759 JSON.stringify(current.vpcAccess ?? {})
760 ) {
761 return true;
762 }
763 if (
764 desired.volumes !== undefined &&
765 JSON.stringify(desired.volumes) !== JSON.stringify(current.volumes ?? [])
766 ) {
767 return true;
768 }
769 if (
770 desired.labels !== undefined &&
771 !recordsEqual(
772 userAnnotations(desired.labels),
773 userAnnotations(current.labels),
774 )
775 ) {
776 return true;
777 }
778 if (
779 desired.annotations !== undefined &&
780 !recordsEqual(
781 userAnnotations(desired.annotations),
782 userAnnotations(current.annotations),
783 )
784 ) {
785 return true;
786 }
787 return false;
788};
789
790const HOST_TYPE = "GCP.Run.Service";
791
792const toAttrs = (
793 service: cloudrun.GoogleCloudRunV2Service,
794 project: string,
795 region: string,
796 extras: { iamGrants?: AppliedIamGrant[]; codeHash?: string } = {},
797): Service["Attributes"] => {
798 const name = service.name ?? "";
799 const parsed = parseName(name, region);
800 return {
801 name,
802 serviceId: parsed.serviceId,
803 project: parsed.project || project,
804 location: parsed.location,
805 labels: userLabels(service.labels),
806 annotations: userAnnotations(service.annotations),
807 description: service.description,
808 uri: service.uri,
809 urls: service.urls ?? [],
810 uid: service.uid,
811 ingress: service.ingress,
812 invokerIamDisabled: service.invokerIamDisabled === true,
813 latestCreatedRevision: service.latestCreatedRevision,
814 latestReadyRevision: service.latestReadyRevision,
815 terminalConditionState: service.terminalCondition?.state,
816 reconciling: service.reconciling === true,
817 generation: service.generation,
818 createTime: service.createTime,
819 updateTime: service.updateTime,
820 serviceAccount: service.template?.serviceAccount,
821 managedServiceAccount: isManagedServiceAccount({
822 project: parsed.project || project,
823 hostType: HOST_TYPE,
824 resourceName: name,
825 serviceAccount: service.template?.serviceAccount,
826 }),
827 iamGrants: extras.iamGrants ?? [],
828 codeHash: extras.codeHash,
829 };
830};
831
832const bootstrapFor = (news: ServiceProps) =>
833 makeGcpBootstrap("CloudRun", news.handler ?? "default");
834
835const sourceOf = (news: ServiceProps, main: string) => ({
836 main,
837 handler: news.handler ?? "default",
838 build: news.build,
839 extraFiles: news.extraFiles,
840 install: news.build?.install,
841});
842
843const getByName = (name: string) =>
844 cloudrun
845 .getProjectsLocationsServices({ name })
846 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
847
848/** Waits on a Cloud Run long-running operation (revisions roll out in minutes). */
849const waitForOperation = (
850 operation: cloudrun.GoogleLongrunningOperation,
851 options?: { notFoundOk?: boolean },
852) =>
853 waitForLongRunningOperation(
854 operation,
855 (name) => {
856 const get = cloudrun.getProjectsLocationsOperations({ name });
857 return options?.notFoundOk === true
858 ? get.pipe(
859 Effect.catchTag("NotFound", () =>
860 Effect.succeed<LongRunningOperation>({ name, done: true }),
861 ),
862 )
863 : get.pipe(
864 // A just-returned operation can briefly 404 on read.
865 Effect.retry({
866 while: (error) => error._tag === "NotFound",
867 times: 5,
868 schedule: Schedule.exponential("250 millis"),
869 }),
870 );
871 },
872 { budget: "10 minutes" },
873 ).pipe(
874 // google.rpc.Code NOT_FOUND: the resource was already gone.
875 Effect.catchTag("GCP.OperationFailed", (error) =>
876 options?.notFoundOk === true && error.code === 5
877 ? Effect.succeed<LongRunningOperation>(operation)
878 : Effect.fail(error),
879 ),
880 );
881
882const isPendingService = (service: cloudrun.GoogleCloudRunV2Service) => {
883 const state = service.terminalCondition?.state ?? "";
884 return (
885 service.reconciling === true ||
886 state === "CONDITION_PENDING" ||
887 state === "CONDITION_RECONCILING" ||
888 state === "" ||
889 state === "STATE_UNSPECIFIED"
890 );
891};
892
893const waitUntilReady = (name: string) =>
894 getByName(name).pipe(
895 Effect.filterOrFail(
896 (service): service is cloudrun.GoogleCloudRunV2Service =>
897 service !== undefined && service.deleteTime === undefined,
898 () => new ServiceNotResolved({ name }),
899 ),
900 Effect.filterOrFail(
901 (service) =>
902 (service.terminalCondition?.state ?? "") !== "CONDITION_FAILED",
903 (service) =>
904 new ServiceNotReady({
905 name,
906 state: service.terminalCondition?.state ?? "",
907 message: service.terminalCondition?.message ?? "revision failed",
908 }),
909 ),
910 Effect.filterOrFail(
911 (service) => !isPendingService(service),
912 (service) =>
913 new ServiceReconciling({
914 name,
915 state: service.terminalCondition?.state || "reconciling",
916 }),
917 ),
918 Effect.retry({
919 while: (error) =>
920 error._tag === "GCP.Run.ServiceReconciling" ||
921 error._tag === "GCP.Run.ServiceNotResolved",
922 times: 10,
923 schedule: Schedule.spaced("4 seconds"),
924 }),
925 );
926
927const waitUntilGone = (name: string) =>
928 getByName(name).pipe(
929 Effect.flatMap((service) =>
930 service === undefined
931 ? Effect.void
932 : Effect.fail(new ServiceStillExists({ name })),
933 ),
934 Effect.retry({
935 while: (error) => error._tag === "GCP.Run.ServiceStillExists",
936 times: 10,
937 schedule: Schedule.spaced("2 seconds"),
938 }),
939 );
940
941const toCreateBody = (
942 news: ServiceProps,
943 labels: Record<string, string>,
944 template: cloudrun.GoogleCloudRunV2RevisionTemplate,
945): cloudrun.GoogleCloudRunV2Service => ({
946 labels,
947 annotations: news.annotations,
948 description: news.description,
949 ingress: news.ingress,
950 invokerIamDisabled: news.invokerIamDisabled,
951 defaultUriDisabled: news.defaultUriDisabled,
952 iapEnabled: news.iapEnabled,
953 customAudiences: news.customAudiences,
954 scaling: news.scaling,
955 traffic: news.traffic,
956 template,
957});
958
959export const ServiceProvider = () =>
960 Provider.succeed(Service, {
961 stables: ["name", "serviceId", "project", "location", "uid", "createTime"],
962
963 diff: Effect.fn(function* ({ news, olds, output }) {
964 if (!isResolved(news)) return undefined;
965 const env = yield* GcpEnvironment.current;
966 const previousId = olds?.serviceId ?? output?.serviceId;
967 const nextId = news.serviceId ?? previousId;
968 const previousLocation = normalizeLocation(
969 olds?.location ?? output?.location,
970 env.region,
971 );
972 const nextLocation = normalizeLocation(
973 news.location ?? output?.location,
974 env.region,
975 );
976 const idChanged =
977 previousId !== undefined &&
978 nextId !== undefined &&
979 nextId !== previousId;
980 const locationChanged = previousLocation !== nextLocation;
981 if (idChanged || locationChanged) {
982 return { action: "replace" as const, deleteFirst: false };
983 }
984 // A code-only change leaves the props untouched; hash the bundled
985 // program (and bootstrap) so it still surfaces as an update.
986 if (output !== undefined && news.main !== undefined) {
987 const images = yield* makeImageSource;
988 const hash = yield* images.hash({
989 source: sourceOf(news, news.main),
990 port: news.port ?? 8080,
991 isExternal: news.isExternal,
992 bootstrap: bootstrapFor(news),
993 });
994 if (hash !== undefined && hash !== output.codeHash) {
995 return { action: "update" as const };
996 }
997 }
998 return undefined;
999 }),
1000
1001 read: Effect.fn(function* ({ id, olds, output }) {
1002 const env = yield* GcpEnvironment.current;
1003 const serviceId = yield* toId(id, olds?.serviceId, output?.serviceId);
1004 const location = normalizeLocation(
1005 olds?.location ?? output?.location,
1006 env.region,
1007 );
1008 const name =
1009 output?.name ?? resourceName(env.project, location, serviceId);
1010 const existing = yield* getByName(name);
1011 if (existing === undefined || existing.deleteTime !== undefined) {
1012 return undefined;
1013 }
1014 const attrs = toAttrs(existing, env.project, env.region, {
1015 iamGrants: output?.iamGrants,
1016 codeHash: output?.codeHash,
1017 });
1018 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
1019 ? attrs
1020 : Unowned(attrs);
1021 }),
1022
1023 list: () =>
1024 Effect.gen(function* () {
1025 const env = yield* GcpEnvironment.current;
1026 return yield* cloudrun.listProjectsLocationsServices
1027 .pages({
1028 parent: `projects/${env.project}/locations/-`,
1029 pageSize: 1000,
1030 })
1031 .pipe(
1032 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
1033 Stream.filter(
1034 (service) =>
1035 service.deleteTime === undefined &&
1036 Object.keys(service.labels ?? {}).some((key) =>
1037 key.startsWith("alchemy-"),
1038 ),
1039 ),
1040 Stream.map((service) => toAttrs(service, env.project, env.region)),
1041 Stream.runCollect,
1042 Effect.map((chunk) => Array.from(chunk)),
1043 );
1044 }),
1045
1046 reconcile: Effect.fn(function* ({ id, news, output, bindings, session }) {
1047 const env = yield* GcpEnvironment.current;
1048 const serviceId = yield* toId(id, news.serviceId, output?.serviceId);
1049 const location = normalizeLocation(
1050 news.location ?? output?.location,
1051 env.region,
1052 );
1053 const name = resourceName(env.project, location, serviceId);
1054 const parent = `projects/${env.project}/locations/${location}`;
1055 const desiredLabels = {
1056 ...toLabels(news.labels),
1057 ...(yield* createInternalLabels(id)),
1058 };
1059 const desiredAnnotations = news.annotations;
1060 const template = desiredTemplate(news);
1061 const identity = yield* resolveHostIdentity({
1062 project: env.project,
1063 hostType: HOST_TYPE,
1064 resourceName: name,
1065 userServiceAccount: template.serviceAccount,
1066 effectNative: news.main !== undefined,
1067 bindings: bindings as ResourceBinding<GcpHostBinding>[],
1068 output,
1069 });
1070 template.serviceAccount = identity.serviceAccount;
1071 let codeHash: string | undefined;
1072 if (news.main !== undefined) {
1073 const images = yield* makeImageSource;
1074 const port = news.port ?? 8080;
1075 const image = yield* images
1076 .resolve({
1077 id,
1078 source: sourceOf(news, news.main),
1079 repositoryName: rfc1035(`${serviceId}-src`),
1080 location,
1081 port,
1082 isExternal: news.isExternal,
1083 bootstrap: bootstrapFor(news),
1084 session,
1085 })
1086 .pipe(Effect.onError(() => identity.cleanup));
1087 codeHash = image.codeHash;
1088 const container = template.containers?.[0] ?? {};
1089 template.containers = [
1090 {
1091 ...container,
1092 image: image.imageUri,
1093 ports: container.ports ?? [{ containerPort: port }],
1094 env: mergeContainerEnv(
1095 container.env,
1096 identity.env,
1097 yield* alchemyRuntimeEnv,
1098 news.env,
1099 ),
1100 },
1101 ];
1102 } else {
1103 const runtimeEnv = { ...identity.env, ...news.env };
1104 if (Object.keys(runtimeEnv).length > 0 && template.containers) {
1105 template.containers = template.containers.map((container, index) =>
1106 index === 0
1107 ? {
1108 ...container,
1109 env: mergeContainerEnv(container.env, runtimeEnv),
1110 }
1111 : container,
1112 );
1113 }
1114 }
1115
1116 const mounted = mountCloudSqlInstances(
1117 template,
1118 bindings as ResourceBinding<GcpHostBinding>[],
1119 );
1120 template.volumes = mounted.volumes;
1121 template.containers = mounted.containers;
1122
1123 let current = yield* getByName(name);
1124 if (current?.deleteTime !== undefined) {
1125 yield* waitUntilGone(name);
1126 current = undefined;
1127 }
1128
1129 if (current === undefined) {
1130 const created = yield* cloudrun
1131 .createProjectsLocationsServices({
1132 parent,
1133 serviceId,
1134 body: toCreateBody(news, desiredLabels, template),
1135 })
1136 .pipe(
1137 retryActAs,
1138 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
1139 Effect.onError(() => identity.cleanup),
1140 );
1141 if (created !== undefined) {
1142 yield* waitForOperation(created);
1143 }
1144 current = yield* waitUntilReady(name);
1145 }
1146
1147 if (current === undefined) {
1148 return yield* new ServiceNotResolved({ name });
1149 }
1150
1151 const observedLabels = tagRecord(current.labels);
1152 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
1153 const labelsChanged = upsert.length > 0 || removed.length > 0;
1154 const descriptionChanged =
1155 (current.description ?? "") !== (news.description ?? "");
1156 const annotationsChanged =
1157 desiredAnnotations !== undefined &&
1158 !recordsEqual(
1159 userAnnotations(current.annotations),
1160 userAnnotations(desiredAnnotations),
1161 );
1162 const ingressChanged =
1163 news.ingress !== undefined && (current.ingress ?? "") !== news.ingress;
1164 const invokerChanged =
1165 news.invokerIamDisabled !== undefined &&
1166 (current.invokerIamDisabled === true) !== news.invokerIamDisabled;
1167 const defaultUriChanged =
1168 news.defaultUriDisabled !== undefined &&
1169 (current.defaultUriDisabled === true) !== news.defaultUriDisabled;
1170 const iapChanged =
1171 news.iapEnabled !== undefined &&
1172 (current.iapEnabled === true) !== news.iapEnabled;
1173 const audiencesChanged =
1174 news.customAudiences !== undefined &&
1175 audiencesFingerprint(current.customAudiences) !==
1176 audiencesFingerprint(news.customAudiences);
1177 const scalingChanged =
1178 news.scaling !== undefined &&
1179 scalingFingerprint(current.scaling) !==
1180 scalingFingerprint(news.scaling);
1181 const trafficChanged =
1182 news.traffic !== undefined &&
1183 trafficFingerprint(current.traffic) !==
1184 trafficFingerprint(news.traffic);
1185 const templateChanged = templateNeedsSync(template, current.template);
1186
1187 if (
1188 labelsChanged ||
1189 descriptionChanged ||
1190 annotationsChanged ||
1191 ingressChanged ||
1192 invokerChanged ||
1193 defaultUriChanged ||
1194 iapChanged ||
1195 audiencesChanged ||
1196 scalingChanged ||
1197 trafficChanged ||
1198 templateChanged
1199 ) {
1200 const updateMask = [
1201 labelsChanged ? "labels" : undefined,
1202 descriptionChanged ? "description" : undefined,
1203 annotationsChanged ? "annotations" : undefined,
1204 ingressChanged ? "ingress" : undefined,
1205 invokerChanged ? "invokerIamDisabled" : undefined,
1206 defaultUriChanged ? "defaultUriDisabled" : undefined,
1207 iapChanged ? "iapEnabled" : undefined,
1208 audiencesChanged ? "customAudiences" : undefined,
1209 scalingChanged ? "scaling" : undefined,
1210 trafficChanged ? "traffic" : undefined,
1211 templateChanged ? "template" : undefined,
1212 ].filter((field): field is string => field !== undefined);
1213
1214 const patched = yield* cloudrun.patchProjectsLocationsServices({
1215 name,
1216 updateMask: updateMask.join(","),
1217 body: {
1218 name,
1219 labels: desiredLabels,
1220 description: news.description,
1221 annotations: desiredAnnotations,
1222 ingress: news.ingress,
1223 invokerIamDisabled: news.invokerIamDisabled,
1224 defaultUriDisabled: news.defaultUriDisabled,
1225 iapEnabled: news.iapEnabled,
1226 customAudiences: news.customAudiences,
1227 scaling: news.scaling,
1228 traffic: news.traffic,
1229 template,
1230 },
1231 });
1232 yield* waitForOperation(patched);
1233 current = yield* waitUntilReady(name);
1234 }
1235
1236 if (current === undefined) {
1237 return yield* new ServiceNotResolved({ name });
1238 }
1239
1240 return toAttrs(current, env.project, env.region, {
1241 iamGrants: identity.grants,
1242 codeHash,
1243 });
1244 }),
1245
1246 delete: Effect.fn(function* ({ id, output }) {
1247 const operation = yield* cloudrun
1248 .deleteProjectsLocationsServices({ name: output.name })
1249 .pipe(
1250 Effect.retry({
1251 while: (error) => error._tag === "Conflict",
1252 times: 8,
1253 schedule: Schedule.spaced("2 seconds"),
1254 }),
1255 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1256 );
1257 if (operation !== undefined) {
1258 yield* waitForOperation(operation, { notFoundOk: true });
1259 }
1260 yield* waitUntilGone(output.name);
1261 // Effect-native hosts build into a per-host repository on reconcile.
1262 yield* destroyHostImageRepository(
1263 id,
1264 output,
1265 rfc1035(`${output.serviceId}-src`),
1266 );
1267 yield* releaseHostIdentity(output);
1268 }),
1269 });