@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,79 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Job } from "./Job.ts";
3
+ import type { Service } from "./Service.ts";
4
+
5
+ /**
6
+ * Bind a single `(role, member)` IAM grant onto a Cloud Run
7
+ * {@link Service}.
8
+ *
9
+ * Target-side binding — see
10
+ * {@link import("../Compute/SubnetworkIamMember.ts").subnetworkIamMember}
11
+ * for the pattern, the SID-collision pitfall, and why `key` exists.
12
+ * Service's `reconcile` merges all bindings into a single
13
+ * `setIamPolicy` call against the service, preserving foreign roles
14
+ * and members on the policy. To remove a binding, drop the call and
15
+ * re-deploy — the provider is additive within the bindings we
16
+ * declared, but does NOT prune.
17
+ *
18
+ * The most common usage:
19
+ *
20
+ * - **Public invoker:** `{ role: "roles/run.invoker", member: "allUsers" }`
21
+ * makes the service publicly reachable. Prefer this over
22
+ * `invokerIamDisabled: true` on the {@link Service} props — declarative
23
+ * IAM is auditable in `setIamPolicy` history; `invokerIamDisabled`
24
+ * is a flag with no audit trail.
25
+ * - **Service-to-service:** a downstream service's runtime SA bound
26
+ * to `roles/run.invoker` on the upstream service.
27
+ *
28
+ * @example Public Cloud Run service
29
+ * ```typescript
30
+ * const api = yield* GCP.Service("PublicApi", { ... });
31
+ * yield* GCP.serviceIamMember(api, "PublicInvoker", {
32
+ * role: "roles/run.invoker",
33
+ * member: "allUsers",
34
+ * });
35
+ * ```
36
+ *
37
+ * @example Service-to-service auth
38
+ * ```typescript
39
+ * const upstream = yield* GCP.Service("Upstream", { ... });
40
+ * yield* GCP.serviceIamMember(upstream, "DownstreamCaller", {
41
+ * role: "roles/run.invoker",
42
+ * member: `serviceAccount:${downstreamSa.email}`,
43
+ * });
44
+ * ```
45
+ */
46
+ export const serviceIamMember = (
47
+ service: Service,
48
+ key: string,
49
+ args: { role: string; member: string },
50
+ ): Effect.Effect<void> =>
51
+ service.bind`IamMember(${service}, ${key})`({
52
+ iamBindings: [{ role: args.role, members: [args.member] }],
53
+ }) as unknown as Effect.Effect<void>;
54
+
55
+ /**
56
+ * Bind a single `(role, member)` IAM grant onto a Cloud Run {@link Job}.
57
+ *
58
+ * Same target-side pattern as {@link serviceIamMember}. The most
59
+ * common Job binding is `roles/run.invoker` on the service account
60
+ * that triggers the job (Cloud Scheduler, Eventarc, Workflows, or a
61
+ * developer's user identity).
62
+ *
63
+ * @example Letting a Cloud Scheduler SA trigger the job
64
+ * ```typescript
65
+ * const nightly = yield* GCP.Job("Nightly", { ... });
66
+ * yield* GCP.jobIamMember(nightly, "SchedulerInvoker", {
67
+ * role: "roles/run.invoker",
68
+ * member: `serviceAccount:${schedulerSa.email}`,
69
+ * });
70
+ * ```
71
+ */
72
+ export const jobIamMember = (
73
+ job: Job,
74
+ key: string,
75
+ args: { role: string; member: string },
76
+ ): Effect.Effect<void> =>
77
+ job.bind`IamMember(${job}, ${key})`({
78
+ iamBindings: [{ role: args.role, members: [args.member] }],
79
+ }) as unknown as Effect.Effect<void>;
@@ -0,0 +1,134 @@
1
+ import type * as run from "@distilled.cloud/gcp/run-v2";
2
+ import type { ResourceBinding } from "alchemy/Resource";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Schedule from "effect/Schedule";
6
+
7
+ /**
8
+ * Common shape of Cloud Run's `getIamPolicy` / `setIamPolicy` request
9
+ * inputs — Service and Job declare nominally distinct request types,
10
+ * but both reduce to `{ resource, body? }` plus the version query for
11
+ * get. We model the operation surface generically so {@link makeSyncIam}
12
+ * can serve both targets without duplication.
13
+ *
14
+ * @internal
15
+ */
16
+ type GetIamPolicyOp = (input: {
17
+ resource: string;
18
+ "options.requestedPolicyVersion"?: number;
19
+ }) => Effect.Effect<run.GoogleIamV1Policy, unknown, never>;
20
+
21
+ type SetIamPolicyOp = (input: {
22
+ resource: string;
23
+ body?: run.GoogleIamV1SetIamPolicyRequest;
24
+ }) => Effect.Effect<run.GoogleIamV1Policy, unknown, never>;
25
+
26
+ /**
27
+ * Single `(role, members)` entry on a Cloud Run target's IAM policy.
28
+ * Identical shape between Service and Job — both Cloud Run resources
29
+ * speak the same `iam.v1.Policy`, so the binding contract is shared.
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * yield* GCP.serviceIamMember(svc, "PublicInvoker", {
34
+ * role: "roles/run.invoker",
35
+ * member: "allUsers",
36
+ * });
37
+ * ```
38
+ */
39
+ export type RunIamBinding = {
40
+ /** IAM role, e.g. `"roles/run.invoker"`. */
41
+ role: string;
42
+ /** Principals, e.g. `["allUsers"]` or `["serviceAccount:foo@bar.iam.gserviceaccount.com"]`. */
43
+ members: ReadonlyArray<string>;
44
+ };
45
+
46
+ /**
47
+ * Binding contract for a Cloud Run target — services and jobs both
48
+ * accept a list of `iamBindings` records via the alchemy `.bind`
49
+ * mechanism. The reconciler reads `bindings` from its arguments and
50
+ * routes them through {@link makeSyncIam}.
51
+ */
52
+ export type RunIamBindingContract = {
53
+ iamBindings: ReadonlyArray<RunIamBinding>;
54
+ };
55
+
56
+ /**
57
+ * Build a reusable `syncIam` step parametrised by the resolved
58
+ * `getIamPolicy` / `setIamPolicy` callables. Cloud Run's Service and
59
+ * Job IAM endpoints have identical request/response shapes (both
60
+ * `resource: "projects/{p}/locations/{l}/{kind}/{n}"`), so the same
61
+ * helper works for both — the caller just hands in the right pair of
62
+ * callables.
63
+ *
64
+ * Semantics, mirroring `Compute/Subnetwork.ts`:
65
+ *
66
+ * - **Union bindings per role across all `.bind` callers.** Multiple
67
+ * capabilities granting the same role get their members merged into
68
+ * a single binding.
69
+ * - **Preserve foreign roles and members verbatim.** We never displace
70
+ * bindings we didn't author. The provider is additive — to remove a
71
+ * binding, drop the `.bind` call and re-deploy (a future revision
72
+ * could narrow this; for now it matches the existing idiom).
73
+ * - **Etag round-trip.** `getIamPolicy` returns an etag; we pass it
74
+ * through `setIamPolicy` so a concurrent edit racing us surfaces as
75
+ * `Conflict`. We retry exponentially up to ~1.5 min — enough to
76
+ * absorb normal CI/human contention without masking real failures.
77
+ */
78
+ export const makeSyncIam =
79
+ (api: { getIamPolicy: GetIamPolicyOp; setIamPolicy: SetIamPolicyOp }) =>
80
+ (args: {
81
+ /** Fully-qualified resource name: `projects/{p}/locations/{l}/{kind}/{n}`. */
82
+ resource: string;
83
+ bindings: ReadonlyArray<ResourceBinding<RunIamBindingContract>>;
84
+ }) =>
85
+ Effect.gen(function* () {
86
+ const desiredByRole = new Map<string, Set<string>>();
87
+ for (const b of args.bindings) {
88
+ for (const ib of b.data.iamBindings) {
89
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
90
+ for (const m of ib.members) set.add(m);
91
+ desiredByRole.set(ib.role, set);
92
+ }
93
+ }
94
+ if (desiredByRole.size === 0) return;
95
+
96
+ const current = yield* api.getIamPolicy({
97
+ resource: args.resource,
98
+ "options.requestedPolicyVersion": 3,
99
+ });
100
+
101
+ const bindings = (current.bindings ?? []).map((b) => ({
102
+ ...b,
103
+ members: [...(b.members ?? [])],
104
+ }));
105
+ let mutated = false;
106
+ for (const [role, members] of desiredByRole) {
107
+ let existing = bindings.find((b) => b.role === role && !b.condition);
108
+ if (!existing) {
109
+ existing = { role, members: [] };
110
+ bindings.push(existing);
111
+ }
112
+ const merged = new Set([...(existing.members ?? []), ...members]);
113
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
114
+ existing.members = [...merged];
115
+ }
116
+ if (!mutated) return;
117
+
118
+ yield* api.setIamPolicy({
119
+ resource: args.resource,
120
+ body: {
121
+ policy: {
122
+ ...current,
123
+ bindings,
124
+ version: 3,
125
+ },
126
+ },
127
+ });
128
+ }).pipe(
129
+ Effect.retry({
130
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
131
+ Schedule.both(Schedule.recurs(8)),
132
+ ),
133
+ }),
134
+ );
package/src/Run/Job.ts ADDED
@@ -0,0 +1,453 @@
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 Job — a managed batch workload. A Job holds an
26
+ * `ExecutionTemplate` (parallelism + a `TaskTemplate` containing
27
+ * containers); invoking the Job via `runProjectsLocationsJobs` creates
28
+ * an immutable Execution that runs the configured number of tasks to
29
+ * completion.
30
+ *
31
+ * **Declarative vs imperative.** This resource models the Job
32
+ * *definition* — its template, parallelism, task count, IAM. The act
33
+ * of *running* the Job is event-shaped (does not fit the
34
+ * converge-to-desired-state model) and is left to user-side code:
35
+ *
36
+ * ```typescript
37
+ * import * as run from "@distilled.cloud/gcp/run-v2";
38
+ * const job = yield* GCP.Job("Nightly", { ... });
39
+ * const runJob = yield* run.runProjectsLocationsJobs;
40
+ * yield* runJob({
41
+ * name: `projects/${job.project}/locations/${job.location}/jobs/${job.name}`,
42
+ * });
43
+ * // returns an LRO; poll if you want to wait for completion
44
+ * ```
45
+ *
46
+ * **Lifecycle.** observe → ensure (create, retrying the API-enable
47
+ * race) → sync (patch full body if anything changed; Job's patch has
48
+ * no `updateMask` — server diffs the body itself) → sync IAM bindings
49
+ * → return.
50
+ *
51
+ * **Replace triggers.** Only identity fields (project, location, name)
52
+ * force replacement. Everything else is in-place via `patch`. New
53
+ * Executions are created independently by `runProjectsLocationsJobs`.
54
+ *
55
+ * **Optimistic concurrency.** Delete passes through the observed
56
+ * `etag`; patch passes etag in the body so concurrent edits surface
57
+ * as `Conflict` instead of silently overwriting.
58
+ *
59
+ * **Adoption.** Label-gated, same as {@link import("./Service.ts").Service}.
60
+ *
61
+ * **IAM.** Use {@link import("./IamMember.ts").jobIamMember} to bind
62
+ * `(role, member)` grants — typically `roles/run.invoker` on the
63
+ * service account that triggers the job.
64
+ *
65
+ * @section Creating a Cloud Run Job
66
+ * @example Single-shot batch job
67
+ * ```typescript
68
+ * const job = yield* GCP.Job("ProcessBatch", {
69
+ * project: project.projectId,
70
+ * location: "europe-west4",
71
+ * template: {
72
+ * taskCount: 1,
73
+ * template: {
74
+ * maxRetries: 3,
75
+ * containers: [{
76
+ * image: "europe-west4-docker.pkg.dev/proj/repo/worker:latest",
77
+ * }],
78
+ * },
79
+ * },
80
+ * });
81
+ * ```
82
+ *
83
+ * @example Parallel job with custom service account and GPU
84
+ * ```typescript
85
+ * const train = yield* GCP.Job("Train", {
86
+ * project: project.projectId,
87
+ * location: "europe-west4",
88
+ * launchStage: "BETA",
89
+ * template: {
90
+ * taskCount: 8,
91
+ * parallelism: 8,
92
+ * template: {
93
+ * serviceAccount: sa.email,
94
+ * maxRetries: 0,
95
+ * timeout: "21600s",
96
+ * containers: [{
97
+ * image: "europe-west4-docker.pkg.dev/proj/repo/train:v2",
98
+ * resources: { limits: { cpu: "4", memory: "16Gi", "nvidia.com/gpu": "1" } },
99
+ * }],
100
+ * nodeSelector: { accelerator: "nvidia-l4" },
101
+ * },
102
+ * },
103
+ * });
104
+ * ```
105
+ */
106
+ export type JobProps = {
107
+ /** GCP project ID hosting the Job. Immutable — replace if changed. */
108
+ project: string;
109
+ /** Cloud Run region. Immutable — replace if changed. */
110
+ location: string;
111
+ /**
112
+ * Job name. Defaults to `createPhysicalName({ id, lowercase: true,
113
+ * maxLength: 49 })`. Lowercase letters/digits/hyphens; must begin
114
+ * with a letter and not end with a hyphen; **fewer than 50 characters**.
115
+ * Immutable — replace if changed.
116
+ */
117
+ name?: string;
118
+ /** User-visible description. Mutable via `patch`. */
119
+ description?: string;
120
+ /**
121
+ * Resource labels. Alchemy internal labels are merged on top
122
+ * automatically. Cloud Run rejects reserved namespaces (same as
123
+ * Service). Mutable via `patch`.
124
+ */
125
+ labels?: Record<string, string>;
126
+ /** Free-form annotations. Mutable via `patch`. */
127
+ annotations?: Record<string, string>;
128
+ /**
129
+ * Launch stage — `BETA` (or higher) required for preview features
130
+ * (GPU node selectors, Direct VPC). Mutable via `patch`.
131
+ */
132
+ launchStage?:
133
+ | "ALPHA"
134
+ | "BETA"
135
+ | "GA"
136
+ | "EARLY_ACCESS"
137
+ | "PRELAUNCH"
138
+ | "DEPRECATED";
139
+ /** Binary Authorization policy. Mutable via `patch`. */
140
+ binaryAuthorization?: run.GoogleCloudRunV2BinaryAuthorization;
141
+ /**
142
+ * Token-suffix used to compose Execution names when the Job is
143
+ * started via the GCP UI or `gcloud run jobs execute`. Required to
144
+ * keep distinct from Job name + 63 chars. Mutable.
145
+ */
146
+ startExecutionToken?: string;
147
+ /** Same as `startExecutionToken` but used on run completion. Mutable. */
148
+ runExecutionToken?: string;
149
+ /**
150
+ * The Execution template — describes parallelism, task count, and
151
+ * the inner `TaskTemplate` (containers, volumes, retry policy).
152
+ * **Required at create.** Mutable via `patch`.
153
+ */
154
+ template: run.GoogleCloudRunV2ExecutionTemplate;
155
+ };
156
+
157
+ export type JobAttributes = {
158
+ /** Job name (bare). */
159
+ name: string;
160
+ /** Server-assigned UID. */
161
+ uid: string;
162
+ /** Fully-qualified resource name. */
163
+ resourceName: string;
164
+ /** GCP project ID. */
165
+ project: string;
166
+ /** Region. */
167
+ location: string;
168
+ /** Monotonically increasing generation, bumped on every patch. */
169
+ generation: string | undefined;
170
+ /** Generation reflected in the latest reconciled state. */
171
+ observedGeneration: string | undefined;
172
+ /** Overall readiness condition. */
173
+ terminalCondition: run.GoogleCloudRunV2Condition | undefined;
174
+ /** Number of Executions created for this Job. */
175
+ executionCount: number | undefined;
176
+ /** Reference to the most recently created Execution, if any. */
177
+ latestCreatedExecution: run.GoogleCloudRunV2ExecutionReference | undefined;
178
+ /** True while Cloud Run is reconciling toward the desired state. */
179
+ reconciling: boolean | undefined;
180
+ /** Optimistic-concurrency etag. */
181
+ etag: string | undefined;
182
+ /** Labels currently set, including internals. */
183
+ labels: Record<string, string>;
184
+ /** Creation time. */
185
+ createTime: string | undefined;
186
+ /** Last-modified time. */
187
+ updateTime: string | undefined;
188
+ };
189
+
190
+ export type Job = Resource<
191
+ "GCP.Job",
192
+ JobProps,
193
+ JobAttributes,
194
+ RunIamBindingContract,
195
+ GCP.Providers
196
+ >;
197
+ export const Job = Resource<Job>("GCP.Job");
198
+
199
+ const fqName = (project: string, location: string, name: string) =>
200
+ `projects/${project}/locations/${location}/jobs/${name}`;
201
+
202
+ const toJobBody = (
203
+ news: JobProps,
204
+ desiredLabels: Record<string, string>,
205
+ ): run.GoogleCloudRunV2Job => ({
206
+ labels: desiredLabels,
207
+ ...(news.annotations ? { annotations: news.annotations } : {}),
208
+ ...(news.launchStage ? { launchStage: news.launchStage } : {}),
209
+ ...(news.binaryAuthorization
210
+ ? { binaryAuthorization: news.binaryAuthorization }
211
+ : {}),
212
+ ...(news.startExecutionToken
213
+ ? { startExecutionToken: news.startExecutionToken }
214
+ : {}),
215
+ ...(news.runExecutionToken
216
+ ? { runExecutionToken: news.runExecutionToken }
217
+ : {}),
218
+ template: news.template,
219
+ });
220
+
221
+ const toAttributes = (
222
+ j: run.GoogleCloudRunV2Job,
223
+ parent: { project: string; location: string; name: string },
224
+ ): JobAttributes => ({
225
+ name: parent.name,
226
+ uid: j.uid ?? "",
227
+ resourceName: j.name ?? fqName(parent.project, parent.location, parent.name),
228
+ project: parent.project,
229
+ location: parent.location,
230
+ generation: j.generation,
231
+ observedGeneration: j.observedGeneration,
232
+ terminalCondition: j.terminalCondition,
233
+ executionCount: j.executionCount,
234
+ latestCreatedExecution: j.latestCreatedExecution,
235
+ reconciling: j.reconciling,
236
+ etag: j.etag,
237
+ labels: { ...(j.labels ?? {}) },
238
+ createTime: j.createTime,
239
+ updateTime: j.updateTime,
240
+ });
241
+
242
+ /**
243
+ * Decide whether anything mutable on the Job changed since the last
244
+ * reconcile. Job's `patch` has no `updateMask` parameter — the server
245
+ * diffs the full body — so we just emit a single "changed / unchanged"
246
+ * verdict and re-send the whole body if it differs.
247
+ *
248
+ * Labels go through `diffTags` (consistent with every other GCP
249
+ * resource here); everything else is a `deepEqual` on the relevant
250
+ * field. The `template` deep-equal is the load-bearing check —
251
+ * Cloud Run injects server-side defaults into `template.template`
252
+ * (e.g. `maxRetries: 3` when omitted), so the observed template may
253
+ * have keys we never sent. Deep-equal on the OBSERVED side ⊆ NEWS
254
+ * side is too strict; we rely on the user re-sending equivalent
255
+ * inputs across reconciles so observed==news after the first patch.
256
+ */
257
+ const jobMutated = (
258
+ observed: run.GoogleCloudRunV2Job,
259
+ news: JobProps,
260
+ desiredLabels: Record<string, string>,
261
+ ): boolean => {
262
+ const labelDiff = diffTags(
263
+ { ...(observed.labels ?? {}) },
264
+ desiredLabels,
265
+ );
266
+ if (labelDiff.removed.length > 0 || labelDiff.upsert.length > 0) return true;
267
+ if (!deepEqual(observed.annotations ?? {}, news.annotations ?? {})) return true;
268
+ if (news.launchStage !== undefined && observed.launchStage !== news.launchStage) {
269
+ return true;
270
+ }
271
+ if (
272
+ news.binaryAuthorization &&
273
+ !deepEqual(observed.binaryAuthorization, news.binaryAuthorization)
274
+ ) {
275
+ return true;
276
+ }
277
+ if (
278
+ news.startExecutionToken !== undefined &&
279
+ observed.startExecutionToken !== news.startExecutionToken
280
+ ) {
281
+ return true;
282
+ }
283
+ if (
284
+ news.runExecutionToken !== undefined &&
285
+ observed.runExecutionToken !== news.runExecutionToken
286
+ ) {
287
+ return true;
288
+ }
289
+ if (!deepEqual(observed.template, news.template)) return true;
290
+ return false;
291
+ };
292
+
293
+ export const JobProvider = () =>
294
+ Provider.effect(
295
+ Job,
296
+ Effect.gen(function* () {
297
+ const getJob = yield* run.getProjectsLocationsJobs;
298
+ const createJob = yield* run.createProjectsLocationsJobs;
299
+ const patchJob = yield* run.patchProjectsLocationsJobs;
300
+ const deleteJob = yield* run.deleteProjectsLocationsJobs;
301
+ const getOperation = yield* run.getProjectsLocationsOperations;
302
+ const getIamPolicy = yield* run.getIamPolicyProjectsLocationsJobs;
303
+ const setIamPolicy = yield* run.setIamPolicyProjectsLocationsJobs;
304
+ const awaitOperation = makeAwaitOperation(getOperation);
305
+ const syncIam = makeSyncIam({ getIamPolicy, setIamPolicy });
306
+
307
+ const observe = (project: string, location: string, name: string) =>
308
+ getJob({ name: fqName(project, location, name) }).pipe(
309
+ Effect.catchTag("NotFound", () =>
310
+ Effect.succeed(undefined as run.GoogleCloudRunV2Job | undefined),
311
+ ),
312
+ Effect.catchTag("Forbidden", () =>
313
+ Effect.succeed(undefined as run.GoogleCloudRunV2Job | undefined),
314
+ ),
315
+ );
316
+
317
+ const syncMutable = Effect.fn(function* (args: {
318
+ name: string;
319
+ observed: run.GoogleCloudRunV2Job;
320
+ news: JobProps;
321
+ desiredLabels: Record<string, string>;
322
+ session: ScopedPlanStatusSession;
323
+ }) {
324
+ if (!jobMutated(args.observed, args.news, args.desiredLabels)) return;
325
+ const bodyWithEtag: run.GoogleCloudRunV2Job = {
326
+ ...toJobBody(args.news, args.desiredLabels),
327
+ ...(args.observed.etag ? { etag: args.observed.etag } : {}),
328
+ };
329
+ const op = yield* patchJob({
330
+ name: args.name,
331
+ body: bodyWithEtag,
332
+ }).pipe(
333
+ Effect.catchTag("BadRequest", reshapeBadRequest("Job", "patch")),
334
+ );
335
+ if (op.name) yield* awaitOperation(op.name, args.session);
336
+ });
337
+
338
+ return {
339
+ stables: ["name", "uid", "resourceName", "project", "location"],
340
+ diff: Effect.fn(function* ({ news, olds = {} }) {
341
+ if (!isResolved(news)) return undefined;
342
+ if (
343
+ somePropsAreDifferent(olds as JobProps, news, [
344
+ "project",
345
+ "location",
346
+ "name",
347
+ ])
348
+ ) {
349
+ return { action: "replace" } as const;
350
+ }
351
+ return undefined;
352
+ }),
353
+ reconcile: Effect.fn(function* ({ id, news, session, bindings }) {
354
+ const internalLabels = yield* gcpInternalLabels(id);
355
+ const desiredName =
356
+ news.name ??
357
+ (yield* createPhysicalName({ id, maxLength: 49 })).toLowerCase();
358
+
359
+ yield* validateRunName("Job", desiredName);
360
+ yield* validateLabels(news.labels);
361
+ yield* validateAnnotations(news.annotations);
362
+ // Job container constraint lives one level deeper (inside the
363
+ // ExecutionTemplate's TaskTemplate).
364
+ yield* validateContainers("Job", news.template.template?.containers);
365
+
366
+ const parent = `projects/${news.project}/locations/${news.location}`;
367
+ const name = fqName(news.project, news.location, desiredName);
368
+ const desiredLabels: Record<string, string> = {
369
+ ...(news.labels ?? {}),
370
+ ...internalLabels,
371
+ };
372
+
373
+ let observed = yield* observe(news.project, news.location, desiredName);
374
+
375
+ if (!observed) {
376
+ const op = yield* createJob({
377
+ parent,
378
+ jobId: desiredName,
379
+ body: toJobBody(news, desiredLabels),
380
+ }).pipe(
381
+ Effect.retry({
382
+ while: (e: { _tag?: string; message?: string }) =>
383
+ e?._tag === "Forbidden" &&
384
+ /enabled this API recently|has not been used/i.test(
385
+ e.message ?? "",
386
+ ),
387
+ schedule: Schedule.spaced(Duration.seconds(15)).pipe(
388
+ Schedule.both(Schedule.recurs(20)),
389
+ Schedule.tapOutput(() =>
390
+ session.note(
391
+ "Waiting for Cloud Run API enablement to propagate…",
392
+ ),
393
+ ),
394
+ ),
395
+ }),
396
+ Effect.catchTag("Conflict", () =>
397
+ Effect.succeed(
398
+ undefined as run.GoogleLongrunningOperation | undefined,
399
+ ),
400
+ ),
401
+ Effect.catchTag("BadRequest", reshapeBadRequest("Job", "create")),
402
+ );
403
+ if (op?.name) yield* awaitOperation(op.name, session);
404
+ observed = yield* getJob({ name });
405
+ }
406
+
407
+ yield* syncMutable({
408
+ name,
409
+ observed,
410
+ news,
411
+ desiredLabels,
412
+ session,
413
+ });
414
+
415
+ yield* syncIam({ resource: name, bindings });
416
+
417
+ const final = yield* getJob({ name });
418
+ return toAttributes(final, {
419
+ project: news.project,
420
+ location: news.location,
421
+ name: desiredName,
422
+ });
423
+ }),
424
+ delete: Effect.fn(function* ({ output, session }) {
425
+ const name = fqName(output.project, output.location, output.name);
426
+ yield* deleteJob({
427
+ name,
428
+ ...(output.etag ? { etag: output.etag } : {}),
429
+ }).pipe(
430
+ Effect.flatMap((op) =>
431
+ op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
432
+ ),
433
+ Effect.catchTag("NotFound", () => Effect.void),
434
+ );
435
+ }),
436
+ read: Effect.fn(function* ({ id, output, olds }) {
437
+ const project = output?.project ?? olds?.project;
438
+ const location = output?.location ?? olds?.location;
439
+ if (!project || !location) return undefined;
440
+ const name =
441
+ output?.name ??
442
+ olds?.name ??
443
+ (yield* createPhysicalName({ id, maxLength: 49 })).toLowerCase();
444
+ const observed = yield* observe(project, location, name);
445
+ if (!observed) return undefined;
446
+ const attrs = toAttributes(observed, { project, location, name });
447
+ return (yield* hasAlchemyLabels(id, observed.labels))
448
+ ? attrs
449
+ : Unowned(attrs);
450
+ }),
451
+ };
452
+ }),
453
+ );