@microagi/alchemy-gcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +54 -2
  3. package/lib/Providers.d.ts.map +1 -1
  4. package/lib/Providers.js +5 -1
  5. package/lib/Providers.js.map +1 -1
  6. package/lib/Run/IamMember.d.ts +70 -0
  7. package/lib/Run/IamMember.d.ts.map +1 -0
  8. package/lib/Run/IamMember.js +65 -0
  9. package/lib/Run/IamMember.js.map +1 -0
  10. package/lib/Run/IamSync.d.ts +80 -0
  11. package/lib/Run/IamSync.d.ts.map +1 -0
  12. package/lib/Run/IamSync.js +73 -0
  13. package/lib/Run/IamSync.js.map +1 -0
  14. package/lib/Run/Job.d.ts +167 -0
  15. package/lib/Run/Job.d.ts.map +1 -0
  16. package/lib/Run/Job.js +199 -0
  17. package/lib/Run/Job.js.map +1 -0
  18. package/lib/Run/Operations.d.ts +47 -0
  19. package/lib/Run/Operations.d.ts.map +1 -0
  20. package/lib/Run/Operations.js +46 -0
  21. package/lib/Run/Operations.js.map +1 -0
  22. package/lib/Run/Service.d.ts +193 -0
  23. package/lib/Run/Service.d.ts.map +1 -0
  24. package/lib/Run/Service.js +247 -0
  25. package/lib/Run/Service.js.map +1 -0
  26. package/lib/Run/Validation.d.ts +54 -0
  27. package/lib/Run/Validation.d.ts.map +1 -0
  28. package/lib/Run/Validation.js +129 -0
  29. package/lib/Run/Validation.js.map +1 -0
  30. package/lib/Run/index.d.ts +51 -0
  31. package/lib/Run/index.d.ts.map +1 -0
  32. package/lib/Run/index.js +4 -0
  33. package/lib/Run/index.js.map +1 -0
  34. package/lib/index.d.ts +1 -0
  35. package/lib/index.d.ts.map +1 -1
  36. package/lib/index.js +1 -0
  37. package/lib/index.js.map +1 -1
  38. package/package.json +5 -4
  39. package/src/Providers.ts +6 -0
  40. package/src/Run/IamMember.ts +79 -0
  41. package/src/Run/IamSync.ts +134 -0
  42. package/src/Run/Job.ts +453 -0
  43. package/src/Run/Operations.ts +77 -0
  44. package/src/Run/Service.ts +541 -0
  45. package/src/Run/Validation.ts +153 -0
  46. package/src/Run/index.ts +52 -0
  47. package/src/index.ts +1 -0
@@ -0,0 +1,77 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as run from "@distilled.cloud/gcp/run-v2";
3
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
4
+ import * as Duration from "effect/Duration";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Schedule from "effect/Schedule";
7
+
8
+ /**
9
+ * Resolved callable signature of `run.getProjectsLocationsOperations`.
10
+ *
11
+ * `run.getProjectsLocationsOperations` is itself an
12
+ * `Effect<callable, never, Credentials | HttpClient>`, so `Effect.Success`
13
+ * extracts the callable type — what you get back from
14
+ * `yield* run.getProjectsLocationsOperations`. Using a derived alias
15
+ * keeps the helper aligned with the SDK without us hand-writing the
16
+ * input/output/error union, which would drift if the patch set changes.
17
+ */
18
+ type GetOperations = Effect.Success<typeof run.getProjectsLocationsOperations>;
19
+
20
+ /**
21
+ * Build the Cloud Run long-running-operation polling helper.
22
+ *
23
+ * Cloud Run `Operation`s use the standard `GoogleLongrunningOperation`
24
+ * shape — doneness is `.done === true` (NOT `.status === "DONE"` like
25
+ * the GKE Container API). Verified at
26
+ * `vendor/distilled/packages/gcp/src/services/run-v2.ts:2880–2900`.
27
+ *
28
+ * Operation names returned by run-v2 (`createProjectsLocationsServices`,
29
+ * `patchProjectsLocationsServices`, `deleteProjectsLocationsServices`)
30
+ * are already fully qualified
31
+ * (`projects/{p}/locations/{l}/operations/{id}`), so callers pass them
32
+ * straight through to `getProjectsLocationsOperations` — no `qualifyOp`
33
+ * dance needed (unlike Container, which returns bare ids).
34
+ *
35
+ * Schedule: exponential 1s → 1.5× growth, capped per-poll at 10s via
36
+ * `Schedule.either`, then a hard count cap of 60 retries via
37
+ * `Schedule.both(Schedule.recurs(60))` → ~10 min wall ceiling. Cloud
38
+ * Run create/patch typically completes in seconds to a couple minutes,
39
+ * with the long pole being container image pulls.
40
+ *
41
+ * The helper is a factory — the caller resolves
42
+ * `run.getProjectsLocationsOperations` once at provider construction
43
+ * (see `Container/Operations.ts` for the same idiom) and passes the
44
+ * resulting callable here, so we never re-resolve services on each
45
+ * await.
46
+ */
47
+ export const makeAwaitOperation = (getOperations: GetOperations) =>
48
+ Effect.fn(function* (
49
+ operationName: string,
50
+ session: ScopedPlanStatusSession,
51
+ ) {
52
+ const op = yield* getOperations({ name: operationName }).pipe(
53
+ Effect.flatMap((current) =>
54
+ current.done === true
55
+ ? Effect.succeed(current)
56
+ : Effect.fail({ _tag: "OperationPending" as const }),
57
+ ),
58
+ Effect.retry({
59
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
60
+ schedule: Schedule.exponential(Duration.seconds(1), 1.5).pipe(
61
+ Schedule.either(Schedule.spaced(Duration.seconds(10))),
62
+ Schedule.both(Schedule.recurs(60)),
63
+ Schedule.tapOutput(() =>
64
+ session.note(`Waiting for Cloud Run operation ${operationName}…`),
65
+ ),
66
+ ),
67
+ }),
68
+ );
69
+ if (op.error) {
70
+ return yield* new ConfigError({
71
+ message: `Cloud Run operation ${operationName} failed: ${
72
+ op.error.message ?? JSON.stringify(op.error)
73
+ }`,
74
+ });
75
+ }
76
+ return op;
77
+ });
@@ -0,0 +1,541 @@
1
+ import * as run from "@distilled.cloud/gcp/run-v2";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
+ import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
6
+ import { createPhysicalName } from "alchemy/PhysicalName";
7
+ import * as Provider from "alchemy/Provider";
8
+ import { diffTags } from "alchemy/Tags";
9
+ import * as Duration from "effect/Duration";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Schedule from "effect/Schedule";
12
+ import type * as GCP from "../Providers.ts";
13
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
14
+ import { makeSyncIam, type RunIamBindingContract } from "./IamSync.ts";
15
+ import { makeAwaitOperation } from "./Operations.ts";
16
+ import {
17
+ reshapeBadRequest,
18
+ validateAnnotations,
19
+ validateContainers,
20
+ validateLabels,
21
+ validateRunName,
22
+ } from "./Validation.ts";
23
+
24
+ /**
25
+ * A Cloud Run v2 Service — a managed serverless HTTP endpoint. Cloud
26
+ * Run pulls a container image, scales it from zero up to the
27
+ * configured maximum based on request rate, and fronts it with a
28
+ * Google-managed URL (`https://{service}-{hash}-{region}.run.app`).
29
+ * Per-Service IAM controls who can invoke it.
30
+ *
31
+ * **Resource model.** A Service holds a `template` (the desired
32
+ * `RevisionTemplate`) plus traffic routing. Every change to `template`
33
+ * server-side spawns a new immutable `Revision`; `traffic` (this resource)
34
+ * picks which Revisions receive request percentages. Revisions and
35
+ * Executions are side-effects and are NOT modeled by this resource —
36
+ * inspect them via `getProjectsLocationsServicesRevisions` if needed.
37
+ *
38
+ * **Lifecycle.** observe → ensure (create, retrying on the well-known
39
+ * "API not yet enabled" 403) → sync (patch with updateMask of changed
40
+ * top-level fields) → sync IAM bindings → return.
41
+ *
42
+ * **Replace triggers.** Only identity fields (project, location, name)
43
+ * force replacement. Everything else — image, env, scaling, traffic,
44
+ * ingress, IAM — is in-place via `patch`. New revisions are auto-created
45
+ * by Cloud Run on any `template.*` change.
46
+ *
47
+ * **Optimistic concurrency.** Patch and delete pass through the
48
+ * observed `etag` so concurrent edits (e.g. from `gcloud run services
49
+ * update`) surface as `Conflict` instead of silently overwriting.
50
+ *
51
+ * **Adoption.** Label-gated: a Service whose `labels` lack our
52
+ * `alchemy_*` keys is wrapped in `Unowned(attrs)` from `read`, forcing
53
+ * `--adopt` before takeover.
54
+ *
55
+ * **IAM.** Use {@link import("./IamMember.ts").serviceIamMember} to
56
+ * bind `(role, member)` grants — the provider unions all bindings and
57
+ * applies them via a single `setIamPolicy` round-trip, preserving
58
+ * foreign roles + members.
59
+ *
60
+ * @section Creating a Cloud Run Service
61
+ * @example Minimal "hello" service, public via IAM
62
+ * ```typescript
63
+ * const helloApi = yield* GCP.Service("HelloApi", {
64
+ * project: project.projectId,
65
+ * location: "europe-west4",
66
+ * template: {
67
+ * containers: [{ image: "gcr.io/cloudrun/hello" }],
68
+ * },
69
+ * });
70
+ * yield* GCP.serviceIamMember(helloApi, "PublicInvoker", {
71
+ * role: "roles/run.invoker",
72
+ * member: "allUsers",
73
+ * });
74
+ * // helloApi.uri → https://hello-api-XXXXX-ew.a.run.app
75
+ * ```
76
+ *
77
+ * @example Internal-only service with explicit service account and scaling
78
+ * ```typescript
79
+ * const internal = yield* GCP.Service("Internal", {
80
+ * project: project.projectId,
81
+ * location: "europe-west4",
82
+ * ingress: "INGRESS_TRAFFIC_INTERNAL_ONLY",
83
+ * scaling: { minInstanceCount: 1, maxInstanceCount: 10 },
84
+ * template: {
85
+ * serviceAccount: sa.email,
86
+ * containers: [{
87
+ * image: "europe-west4-docker.pkg.dev/proj/repo/app:latest",
88
+ * resources: { limits: { cpu: "1000m", memory: "512Mi" } },
89
+ * }],
90
+ * },
91
+ * });
92
+ * ```
93
+ */
94
+ export type ServiceProps = {
95
+ /** GCP project ID hosting the Service. Immutable — replace if changed. */
96
+ project: string;
97
+ /**
98
+ * Cloud Run region (e.g. `europe-west4`). Cloud Run is a regional
99
+ * product — there are no zonal Services. Immutable — replace if changed.
100
+ */
101
+ location: string;
102
+ /**
103
+ * Service name. Defaults to `createPhysicalName({ id, lowercase: true,
104
+ * maxLength: 49 })`. Lowercase letters/digits/hyphens; must begin
105
+ * with a letter and not end with a hyphen; **fewer than 50 characters**.
106
+ * Immutable — replace if changed.
107
+ */
108
+ name?: string;
109
+ /**
110
+ * User-visible description (≤512 chars). Mutable via `patch`.
111
+ */
112
+ description?: string;
113
+ /**
114
+ * Resource labels. Alchemy internal labels (`alchemy_app`,
115
+ * `alchemy_stage`, `alchemy_id`) are merged on top automatically.
116
+ * Cloud Run rejects labels in `run.googleapis.com`,
117
+ * `cloud.googleapis.com`, `serving.knative.dev`, or
118
+ * `autoscaling.knative.dev` namespaces. Mutable via `patch`.
119
+ */
120
+ labels?: Record<string, string>;
121
+ /**
122
+ * Free-form annotations for external tools. Cloud Run rejects the
123
+ * same reserved namespaces as `labels`. Mutable via `patch`.
124
+ */
125
+ annotations?: Record<string, string>;
126
+ /**
127
+ * Launch stage. `BETA` (or higher) is required to use preview
128
+ * fields like GPU `nodeSelector` and Direct VPC `networkInterfaces`;
129
+ * leaving this unset defaults to `GA`, which silently rejects
130
+ * preview fields in the body. Mutable via `patch`.
131
+ */
132
+ launchStage?:
133
+ | "ALPHA"
134
+ | "BETA"
135
+ | "GA"
136
+ | "EARLY_ACCESS"
137
+ | "PRELAUNCH"
138
+ | "DEPRECATED";
139
+ /** Ingress traffic policy. Mutable via `patch`. */
140
+ ingress?:
141
+ | "INGRESS_TRAFFIC_ALL"
142
+ | "INGRESS_TRAFFIC_INTERNAL_ONLY"
143
+ | "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
144
+ | "INGRESS_TRAFFIC_NONE";
145
+ /**
146
+ * Disable the IAM permission check for invokers. When `true`, the
147
+ * Service is publicly reachable regardless of IAM policy — a
148
+ * deliberate footgun, surface it consciously. Prefer
149
+ * `serviceIamMember(svc, "Public", { role: "roles/run.invoker", member: "allUsers" })`
150
+ * for declarative public access. Mutable via `patch`.
151
+ */
152
+ invokerIamDisabled?: boolean;
153
+ /**
154
+ * Disable public resolution of the default `*.run.app` URI. When
155
+ * `true`, the service is reachable only via custom domains / load
156
+ * balancers. Mutable via `patch`.
157
+ */
158
+ defaultUriDisabled?: boolean;
159
+ /** Service-level scaling. Mutable via `patch`. */
160
+ scaling?: run.GoogleCloudRunV2ServiceScaling;
161
+ /**
162
+ * Traffic routing across Revisions. Defaults server-side to 100% to
163
+ * the latest `Ready` revision. Mutable via `patch`.
164
+ */
165
+ traffic?: ReadonlyArray<run.GoogleCloudRunV2TrafficTarget>;
166
+ /**
167
+ * Audiences encoded into the auth token, for cross-service auth.
168
+ * Mutable via `patch`.
169
+ */
170
+ customAudiences?: ReadonlyArray<string>;
171
+ /**
172
+ * Binary Authorization policy. Mutable via `patch`.
173
+ */
174
+ binaryAuthorization?: run.GoogleCloudRunV2BinaryAuthorization;
175
+ /**
176
+ * The Revision template — describes the desired Pod-equivalent.
177
+ * **Required at create.** Mutable via `patch`; any change spawns a
178
+ * new server-managed Revision.
179
+ */
180
+ template: run.GoogleCloudRunV2RevisionTemplate;
181
+ };
182
+
183
+ export type ServiceAttributes = {
184
+ /** Service name (bare, without the `projects/.../services/` prefix). */
185
+ name: string;
186
+ /** Server-assigned UID — stable across rename-less mutations. */
187
+ uid: string;
188
+ /** Fully-qualified resource name `projects/{p}/locations/{l}/services/{n}`. */
189
+ resourceName: string;
190
+ /** GCP project ID, threaded through from props. */
191
+ project: string;
192
+ /** Region, threaded through from props. */
193
+ location: string;
194
+ /** Primary serving URI (`https://…run.app`). */
195
+ uri: string;
196
+ /** All URIs serving traffic (default + any tagged ones). */
197
+ urls: ReadonlyArray<string>;
198
+ /** Monotonically increasing generation, bumped on every patch. */
199
+ generation: string | undefined;
200
+ /** The generation currently serving traffic. */
201
+ observedGeneration: string | undefined;
202
+ /** Name of the latest Ready revision. */
203
+ latestReadyRevision: string | undefined;
204
+ /** Name of the latest created revision. */
205
+ latestCreatedRevision: string | undefined;
206
+ /** Overall readiness condition. */
207
+ terminalCondition: run.GoogleCloudRunV2Condition | undefined;
208
+ /** Per-traffic-target serving status. */
209
+ trafficStatuses: ReadonlyArray<run.GoogleCloudRunV2TrafficTargetStatus>;
210
+ /** Optimistic-concurrency etag. */
211
+ etag: string | undefined;
212
+ /** Labels currently set, including internals. */
213
+ labels: Record<string, string>;
214
+ /** Creation time. */
215
+ createTime: string | undefined;
216
+ /** Last-modified time. */
217
+ updateTime: string | undefined;
218
+ };
219
+
220
+ export type Service = Resource<
221
+ "GCP.Service",
222
+ ServiceProps,
223
+ ServiceAttributes,
224
+ RunIamBindingContract,
225
+ GCP.Providers
226
+ >;
227
+ export const Service = Resource<Service>("GCP.Service");
228
+
229
+ const fqName = (project: string, location: string, name: string) =>
230
+ `projects/${project}/locations/${location}/services/${name}`;
231
+
232
+ const toServiceBody = (
233
+ news: ServiceProps,
234
+ desiredLabels: Record<string, string>,
235
+ ): run.GoogleCloudRunV2Service => ({
236
+ ...(news.description !== undefined ? { description: news.description } : {}),
237
+ labels: desiredLabels,
238
+ ...(news.annotations ? { annotations: news.annotations } : {}),
239
+ ...(news.launchStage ? { launchStage: news.launchStage } : {}),
240
+ ...(news.ingress ? { ingress: news.ingress } : {}),
241
+ ...(news.invokerIamDisabled !== undefined
242
+ ? { invokerIamDisabled: news.invokerIamDisabled }
243
+ : {}),
244
+ ...(news.defaultUriDisabled !== undefined
245
+ ? { defaultUriDisabled: news.defaultUriDisabled }
246
+ : {}),
247
+ ...(news.scaling ? { scaling: news.scaling } : {}),
248
+ ...(news.traffic ? { traffic: news.traffic } : {}),
249
+ ...(news.customAudiences ? { customAudiences: news.customAudiences } : {}),
250
+ ...(news.binaryAuthorization
251
+ ? { binaryAuthorization: news.binaryAuthorization }
252
+ : {}),
253
+ template: news.template,
254
+ });
255
+
256
+ const toAttributes = (
257
+ s: run.GoogleCloudRunV2Service,
258
+ parent: { project: string; location: string; name: string },
259
+ ): ServiceAttributes => ({
260
+ name: parent.name,
261
+ uid: s.uid ?? "",
262
+ resourceName: s.name ?? fqName(parent.project, parent.location, parent.name),
263
+ project: parent.project,
264
+ location: parent.location,
265
+ uri: s.uri ?? "",
266
+ urls: s.urls ?? [],
267
+ generation: s.generation,
268
+ observedGeneration: s.observedGeneration,
269
+ latestReadyRevision: s.latestReadyRevision,
270
+ latestCreatedRevision: s.latestCreatedRevision,
271
+ terminalCondition: s.terminalCondition,
272
+ trafficStatuses: s.trafficStatuses ?? [],
273
+ etag: s.etag,
274
+ labels: { ...(s.labels ?? {}) },
275
+ createTime: s.createTime,
276
+ updateTime: s.updateTime,
277
+ });
278
+
279
+ export const ServiceProvider = () =>
280
+ Provider.effect(
281
+ Service,
282
+ Effect.gen(function* () {
283
+ const getService = yield* run.getProjectsLocationsServices;
284
+ const createService = yield* run.createProjectsLocationsServices;
285
+ const patchService = yield* run.patchProjectsLocationsServices;
286
+ const deleteService = yield* run.deleteProjectsLocationsServices;
287
+ const getOperation = yield* run.getProjectsLocationsOperations;
288
+ const getIamPolicy = yield* run.getIamPolicyProjectsLocationsServices;
289
+ const setIamPolicy = yield* run.setIamPolicyProjectsLocationsServices;
290
+ const awaitOperation = makeAwaitOperation(getOperation);
291
+ const syncIam = makeSyncIam({ getIamPolicy, setIamPolicy });
292
+
293
+ const observe = (project: string, location: string, name: string) =>
294
+ getService({ name: fqName(project, location, name) }).pipe(
295
+ Effect.catchTag("NotFound", () =>
296
+ Effect.succeed(undefined as run.GoogleCloudRunV2Service | undefined),
297
+ ),
298
+ Effect.catchTag("Forbidden", () =>
299
+ Effect.succeed(undefined as run.GoogleCloudRunV2Service | undefined),
300
+ ),
301
+ );
302
+
303
+ const syncMutable = Effect.fn(function* (args: {
304
+ name: string;
305
+ observed: run.GoogleCloudRunV2Service;
306
+ news: ServiceProps;
307
+ desiredLabels: Record<string, string>;
308
+ session: ScopedPlanStatusSession;
309
+ }) {
310
+ const desiredBody = toServiceBody(args.news, args.desiredLabels);
311
+ const updateMaskFields: string[] = [];
312
+
313
+ if ((args.observed.description ?? undefined) !== args.news.description) {
314
+ updateMaskFields.push("description");
315
+ }
316
+ const labelDiff = diffTags(
317
+ { ...(args.observed.labels ?? {}) },
318
+ args.desiredLabels,
319
+ );
320
+ if (labelDiff.removed.length > 0 || labelDiff.upsert.length > 0) {
321
+ updateMaskFields.push("labels");
322
+ }
323
+ if (!deepEqual(args.observed.annotations ?? {}, args.news.annotations ?? {})) {
324
+ updateMaskFields.push("annotations");
325
+ }
326
+ if (
327
+ args.news.launchStage !== undefined &&
328
+ args.observed.launchStage !== args.news.launchStage
329
+ ) {
330
+ updateMaskFields.push("launchStage");
331
+ }
332
+ if (
333
+ args.news.ingress !== undefined &&
334
+ args.observed.ingress !== args.news.ingress
335
+ ) {
336
+ updateMaskFields.push("ingress");
337
+ }
338
+ if (
339
+ args.news.invokerIamDisabled !== undefined &&
340
+ (args.observed.invokerIamDisabled ?? false) !==
341
+ args.news.invokerIamDisabled
342
+ ) {
343
+ updateMaskFields.push("invokerIamDisabled");
344
+ }
345
+ if (
346
+ args.news.defaultUriDisabled !== undefined &&
347
+ (args.observed.defaultUriDisabled ?? false) !==
348
+ args.news.defaultUriDisabled
349
+ ) {
350
+ updateMaskFields.push("defaultUriDisabled");
351
+ }
352
+ if (args.news.scaling && !deepEqual(args.observed.scaling, args.news.scaling)) {
353
+ updateMaskFields.push("scaling");
354
+ }
355
+ if (args.news.traffic && !deepEqual(args.observed.traffic ?? [], args.news.traffic)) {
356
+ updateMaskFields.push("traffic");
357
+ }
358
+ if (
359
+ args.news.customAudiences &&
360
+ !deepEqual(args.observed.customAudiences ?? [], args.news.customAudiences)
361
+ ) {
362
+ updateMaskFields.push("customAudiences");
363
+ }
364
+ if (
365
+ args.news.binaryAuthorization &&
366
+ !deepEqual(args.observed.binaryAuthorization, args.news.binaryAuthorization)
367
+ ) {
368
+ updateMaskFields.push("binaryAuthorization");
369
+ }
370
+ // The Revision template is a deeply nested struct; diffing field
371
+ // by field would be miles of code with no payoff. Deep-equal at
372
+ // the top — patch sends the whole desired template if anything
373
+ // inside differs. Cloud Run spawns a new Revision on patch only
374
+ // when the template's content (modulo server-injected defaults)
375
+ // actually changes, so resending an equivalent template after a
376
+ // no-op reconcile is cheap.
377
+ if (!deepEqual(args.observed.template, args.news.template)) {
378
+ updateMaskFields.push("template");
379
+ }
380
+
381
+ if (updateMaskFields.length === 0) return;
382
+
383
+ // Pass the observed etag through the body so a concurrent edit
384
+ // racing us surfaces as Conflict instead of silently
385
+ // overwriting. Cloud Run reads etag from the body (it's a
386
+ // field on GoogleCloudRunV2Service, not a request-level param).
387
+ const bodyWithEtag: run.GoogleCloudRunV2Service = {
388
+ ...desiredBody,
389
+ ...(args.observed.etag ? { etag: args.observed.etag } : {}),
390
+ };
391
+ const op = yield* patchService({
392
+ name: args.name,
393
+ updateMask: updateMaskFields.join(","),
394
+ body: bodyWithEtag,
395
+ }).pipe(
396
+ Effect.catchTag("BadRequest", reshapeBadRequest("Service", "patch")),
397
+ );
398
+ if (op.name) yield* awaitOperation(op.name, args.session);
399
+ });
400
+
401
+ return {
402
+ stables: ["name", "uid", "resourceName", "project", "location"],
403
+ diff: Effect.fn(function* ({ news, olds = {} }) {
404
+ if (!isResolved(news)) return undefined;
405
+ if (
406
+ somePropsAreDifferent(olds as ServiceProps, news, [
407
+ "project",
408
+ "location",
409
+ "name",
410
+ ])
411
+ ) {
412
+ return { action: "replace" } as const;
413
+ }
414
+ return undefined;
415
+ }),
416
+ reconcile: Effect.fn(function* ({ id, news, session, bindings }) {
417
+ const internalLabels = yield* gcpInternalLabels(id);
418
+ // Cloud Run service IDs must be <50 chars (server hard cap).
419
+ const desiredName =
420
+ news.name ??
421
+ (yield* createPhysicalName({ id, maxLength: 49 })).toLowerCase();
422
+
423
+ // Plan-time validation: surface common footguns as typed
424
+ // ConfigErrors before any state mutation. The server would
425
+ // reject these too, but a 30 s wait + half-built state is a
426
+ // worse experience than failing immediately.
427
+ yield* validateRunName("Service", desiredName);
428
+ yield* validateLabels(news.labels);
429
+ yield* validateAnnotations(news.annotations);
430
+ yield* validateContainers("Service", news.template.containers);
431
+
432
+ const parent = `projects/${news.project}/locations/${news.location}`;
433
+ const name = fqName(news.project, news.location, desiredName);
434
+ const desiredLabels: Record<string, string> = {
435
+ ...(news.labels ?? {}),
436
+ ...internalLabels,
437
+ };
438
+
439
+ // 1. Observe — collapse 403 to "missing" alongside 404 (GCP
440
+ // surfaces 403 for resources/projects we can't see).
441
+ let observed = yield* observe(news.project, news.location, desiredName);
442
+
443
+ // 2. Ensure — same API-enable race as Cluster: a create can
444
+ // fire faster than ApiEnable's effect propagates and the
445
+ // server returns 403 with "If you enabled this API
446
+ // recently, wait a few minutes …". Retry the create call
447
+ // on that specific 403 for ~5 min; other Forbiddens
448
+ // propagate.
449
+ if (!observed) {
450
+ const op = yield* createService({
451
+ parent,
452
+ serviceId: desiredName,
453
+ body: toServiceBody(news, desiredLabels),
454
+ }).pipe(
455
+ Effect.retry({
456
+ while: (e: { _tag?: string; message?: string }) =>
457
+ e?._tag === "Forbidden" &&
458
+ /enabled this API recently|has not been used/i.test(
459
+ e.message ?? "",
460
+ ),
461
+ schedule: Schedule.spaced(Duration.seconds(15)).pipe(
462
+ Schedule.both(Schedule.recurs(20)),
463
+ Schedule.tapOutput(() =>
464
+ session.note(
465
+ "Waiting for Cloud Run API enablement to propagate…",
466
+ ),
467
+ ),
468
+ ),
469
+ }),
470
+ Effect.catchTag("Conflict", () =>
471
+ Effect.succeed(
472
+ undefined as run.GoogleLongrunningOperation | undefined,
473
+ ),
474
+ ),
475
+ Effect.catchTag(
476
+ "BadRequest",
477
+ reshapeBadRequest("Service", "create"),
478
+ ),
479
+ );
480
+ if (op?.name) yield* awaitOperation(op.name, session);
481
+ observed = yield* getService({ name });
482
+ }
483
+
484
+ // 3. Sync — single patch with an updateMask of changed
485
+ // top-level fields. Template is diff'd shallowly (deep
486
+ // equality on the whole struct).
487
+ yield* syncMutable({
488
+ name,
489
+ observed,
490
+ news,
491
+ desiredLabels,
492
+ session,
493
+ });
494
+
495
+ // 4. Apply IAM bindings AFTER the service exists. Single
496
+ // setIamPolicy call covers all bindings for this service;
497
+ // foreign bindings are preserved verbatim. Etag round-trip
498
+ // handles concurrent edits.
499
+ yield* syncIam({ resource: name, bindings });
500
+
501
+ const final = yield* getService({ name });
502
+ return toAttributes(final, {
503
+ project: news.project,
504
+ location: news.location,
505
+ name: desiredName,
506
+ });
507
+ }),
508
+ delete: Effect.fn(function* ({ output, session }) {
509
+ const name = fqName(output.project, output.location, output.name);
510
+ // Pass the etag we stored at last reconcile so a concurrent
511
+ // edit racing this delete surfaces as Conflict. If we never
512
+ // captured one (legacy state, adoption path), omit the
513
+ // parameter — Cloud Run treats it as "don't check".
514
+ yield* deleteService({
515
+ name,
516
+ ...(output.etag ? { etag: output.etag } : {}),
517
+ }).pipe(
518
+ Effect.flatMap((op) =>
519
+ op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
520
+ ),
521
+ Effect.catchTag("NotFound", () => Effect.void),
522
+ );
523
+ }),
524
+ read: Effect.fn(function* ({ id, output, olds }) {
525
+ const project = output?.project ?? olds?.project;
526
+ const location = output?.location ?? olds?.location;
527
+ if (!project || !location) return undefined;
528
+ const name =
529
+ output?.name ??
530
+ olds?.name ??
531
+ (yield* createPhysicalName({ id, maxLength: 49 })).toLowerCase();
532
+ const observed = yield* observe(project, location, name);
533
+ if (!observed) return undefined;
534
+ const attrs = toAttributes(observed, { project, location, name });
535
+ return (yield* hasAlchemyLabels(id, observed.labels))
536
+ ? attrs
537
+ : Unowned(attrs);
538
+ }),
539
+ };
540
+ }),
541
+ );