@microagi/alchemy-gcp 0.11.6 → 0.11.7

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.
@@ -0,0 +1,309 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as iam from "@distilled.cloud/gcp/unstable/iam-v1";
3
+ import { Resource } from "alchemy";
4
+ import { Unowned } from "alchemy/AdoptPolicy";
5
+ import { isResolved } from "alchemy/Diff";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Effect from "effect/Effect";
8
+ import {
9
+ descriptionHasAlchemyMarker,
10
+ gcpAlchemyDescription,
11
+ stripAlchemyMarker,
12
+ } from "../Tags.ts";
13
+ import type * as GCP from "../Providers.ts";
14
+ import { makeAwaitOperation } from "./Operations.ts";
15
+
16
+ /**
17
+ * A Workload Identity **pool** — the trust boundary that lets identities from
18
+ * outside GCP (another cloud's OIDC issuer, a Kubernetes cluster, a CI system)
19
+ * impersonate a {@link import("./ServiceAccount.ts").ServiceAccount} without
20
+ * anyone ever creating a service-account key.
21
+ *
22
+ * A pool on its own trusts nothing; it is a namespace. The trust is declared
23
+ * by a {@link import("./WorkloadIdentityPoolProvider.ts").WorkloadIdentityPoolProvider}
24
+ * inside it, and the grant by an `iam.workloadIdentityUser` binding on the
25
+ * target GSA.
26
+ *
27
+ * ### Project number, not project ID
28
+ *
29
+ * The `principalSet://` member strings that reference this pool require the
30
+ * project **number**, not its ID:
31
+ *
32
+ * ```
33
+ * principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/POOL/*
34
+ * ```
35
+ *
36
+ * A member string built with the project ID is accepted by `setIamPolicy`
37
+ * and then silently never matches, which is a genuinely unpleasant thing to
38
+ * debug. Prefer passing the project number as `project` so `name` comes back
39
+ * in the form the bindings need.
40
+ *
41
+ * ### Deletion is soft
42
+ *
43
+ * `delete` puts the pool in state `DELETED` with an `expireTime` roughly 30
44
+ * days out; it is purged only after that. **The pool ID cannot be reused
45
+ * until it is purged.** Because of that, `reconcile` treats an existing
46
+ * soft-deleted pool as recoverable and undeletes it rather than failing —
47
+ * otherwise a destroy followed by a re-deploy inside the same month would be
48
+ * unrecoverable without renaming the pool.
49
+ *
50
+ * All mutating operations are long-running; the provider polls them to
51
+ * completion via {@link makeAwaitOperation}.
52
+ *
53
+ * @section Creating a WorkloadIdentityPool
54
+ * @example Trust an EKS cluster's OIDC issuer
55
+ * ```typescript
56
+ * const pool = yield* GCP.WorkloadIdentityPool("EksPool", {
57
+ * project: "123456789", // project NUMBER
58
+ * poolId: "eks-us-east-1",
59
+ * displayName: "EKS us-east-1",
60
+ * description: "Federates research-eks-us-east-1 service accounts.",
61
+ * });
62
+ * ```
63
+ */
64
+ export type WorkloadIdentityPoolProps = {
65
+ /**
66
+ * Project that owns the pool. Accepts the project ID or number; prefer the
67
+ * **number**, because `principalSet://` members require it (see above).
68
+ */
69
+ project: string;
70
+ /**
71
+ * Pool ID, 4–32 characters of `[a-z0-9-]`. Immutable — changing it
72
+ * replaces the pool. The `gcp-` prefix is reserved by Google.
73
+ */
74
+ poolId: string;
75
+ /** Display name, max 32 characters. */
76
+ displayName?: string;
77
+ /** Description, max 256 characters. */
78
+ description?: string;
79
+ /**
80
+ * Disable the pool. A disabled pool exchanges no tokens; existing tokens
81
+ * stop granting access and start working again if it is re-enabled.
82
+ */
83
+ disabled?: boolean;
84
+ };
85
+
86
+ export type WorkloadIdentityPoolAttributes = {
87
+ /** Full resource name, `projects/{p}/locations/global/workloadIdentityPools/{id}`. */
88
+ name: string;
89
+ project: string;
90
+ poolId: string;
91
+ displayName: string | undefined;
92
+ /** Description with the alchemy ownership marker stripped. */
93
+ description: string | undefined;
94
+ state: string | undefined;
95
+ disabled: boolean | undefined;
96
+ };
97
+
98
+ export interface WorkloadIdentityPool
99
+ extends Resource<
100
+ "GCP.WorkloadIdentityPool",
101
+ WorkloadIdentityPoolProps,
102
+ WorkloadIdentityPoolAttributes,
103
+ never,
104
+ GCP.Providers
105
+ > {}
106
+
107
+ export const WorkloadIdentityPool = Resource<WorkloadIdentityPool>(
108
+ "GCP.WorkloadIdentityPool",
109
+ );
110
+
111
+ /**
112
+ * Treat absent and empty-string as the same value.
113
+ *
114
+ * The patch below uses a FIXED `updateMask` while the body omits unset
115
+ * optional fields — deliberately, since under a field mask an omitted field
116
+ * means "clear it". The hazard is the comparison: if GCP echoes a cleared
117
+ * field back as `""` rather than omitting it, a raw `!==` against `undefined`
118
+ * would report drift on every deploy and churn a patch LRO forever.
119
+ */
120
+ const sameText = (a: string | undefined, b: string | undefined) =>
121
+ (a ?? "") === (b ?? "");
122
+
123
+ /** `projects/{project}/locations/global` — the only supported location. */
124
+ export const poolParent = (project: string) =>
125
+ `projects/${project}/locations/global`;
126
+
127
+ export const poolResourceName = (project: string, poolId: string) =>
128
+ `${poolParent(project)}/workloadIdentityPools/${poolId}`;
129
+
130
+ /**
131
+ * Alchemy provider factory for {@link WorkloadIdentityPool}.
132
+ *
133
+ * Deliberately NOT named `WorkloadIdentityPoolProvider`, which the repo's
134
+ * `X` + `XProvider` convention would suggest: GCP already uses that exact
135
+ * term for a different, user-facing resource — the OIDC provider inside a
136
+ * pool ({@link import("./WorkloadIdentityPoolProvider.ts").WorkloadIdentityPoolProvider}).
137
+ * The resource keeps GCP's name; this internal factory takes the awkward one.
138
+ */
139
+ export const WorkloadIdentityPoolResourceProvider = () =>
140
+ Provider.effect(
141
+ WorkloadIdentityPool,
142
+ Effect.gen(function* () {
143
+ const getPool = yield* iam.getProjectsLocationsWorkloadIdentityPools;
144
+ const createPool = yield* iam.createProjectsLocationsWorkloadIdentityPools;
145
+ const patchPool = yield* iam.patchProjectsLocationsWorkloadIdentityPools;
146
+ const deletePool = yield* iam.deleteProjectsLocationsWorkloadIdentityPools;
147
+ const undeletePool =
148
+ yield* iam.undeleteProjectsLocationsWorkloadIdentityPools;
149
+ const getOperations =
150
+ yield* iam.getProjectsLocationsWorkloadIdentityPoolsOperations;
151
+ const awaitOperation = makeAwaitOperation(
152
+ getOperations,
153
+ "Workload Identity pool",
154
+ );
155
+
156
+ // Absent and invisible collapse to the same thing, matching the
157
+ // ServiceAccount observer: a 403 on a resource we are about to create
158
+ // is indistinguishable from a 404 without extra permissions we may not
159
+ // have.
160
+ const observePool = (project: string, poolId: string) =>
161
+ getPool({ name: poolResourceName(project, poolId) }).pipe(
162
+ Effect.catchTag("NotFound", () =>
163
+ Effect.succeed(undefined as iam.WorkloadIdentityPool | undefined),
164
+ ),
165
+ Effect.catchTag("Forbidden", () =>
166
+ Effect.succeed(undefined as iam.WorkloadIdentityPool | undefined),
167
+ ),
168
+ );
169
+
170
+ const toAttrs = (
171
+ project: string,
172
+ poolId: string,
173
+ pool: iam.WorkloadIdentityPool,
174
+ ): WorkloadIdentityPoolAttributes => ({
175
+ name: pool.name ?? poolResourceName(project, poolId),
176
+ project,
177
+ poolId,
178
+ displayName: pool.displayName,
179
+ description: stripAlchemyMarker(pool.description),
180
+ state: pool.state,
181
+ disabled: pool.disabled,
182
+ });
183
+
184
+ return {
185
+ stables: ["name", "project", "poolId"],
186
+ diff: Effect.fn(function* ({ olds = {}, news, output }) {
187
+ if (!isResolved(news)) return undefined;
188
+ const oldProps = olds as Partial<WorkloadIdentityPoolProps>;
189
+ // Prefer live attributes over persisted props, and fall back to the
190
+ // DESIRED value when neither is known — mirroring Project.ts.
191
+ //
192
+ // Defaulting to `undefined` instead would make an adoption (output
193
+ // present, olds absent) compare `undefined !== news.project` and
194
+ // return `replace`, deleting and recreating a live pool that was
195
+ // already correct. Both fields are part of the resource name, so a
196
+ // genuine change here really is a replacement; the point is only to
197
+ // avoid inventing one.
198
+ const currentProject = output?.project || oldProps.project || news.project;
199
+ const currentPoolId = output?.poolId || oldProps.poolId || news.poolId;
200
+ if (currentProject !== news.project || currentPoolId !== news.poolId) {
201
+ return { action: "replace" } as const;
202
+ }
203
+ }),
204
+ read: Effect.fn(function* ({ id, olds, output }) {
205
+ const project = output?.project || olds?.project;
206
+ const poolId = output?.poolId || olds?.poolId;
207
+ if (!project || !poolId) return undefined;
208
+ const pool = yield* observePool(project, poolId);
209
+ if (!pool) return undefined;
210
+ const attrs = toAttrs(project, poolId, pool);
211
+ return (yield* descriptionHasAlchemyMarker(id, pool.description))
212
+ ? attrs
213
+ : Unowned(attrs);
214
+ }),
215
+ reconcile: Effect.fn(function* ({ id, news, session }) {
216
+ const { project, poolId } = news;
217
+ const description = yield* gcpAlchemyDescription(
218
+ id,
219
+ news.description,
220
+ );
221
+ // Body is TOTAL over the patch's updateMask below: every masked
222
+ // field is always present, with an explicit empty/default value
223
+ // when the prop is unset. Behaviourally identical to omitting them
224
+ // (a masked-but-absent field is cleared), but it states the intent
225
+ // in code rather than relying on that field-mask subtlety, so
226
+ // "clear displayName" cannot be misread as "leave it alone".
227
+ const body: iam.WorkloadIdentityPool = {
228
+ displayName: news.displayName ?? "",
229
+ description,
230
+ disabled: news.disabled ?? false,
231
+ };
232
+
233
+ let pool = yield* observePool(project, poolId);
234
+
235
+ // A soft-deleted pool holds its ID for ~30 days. Undelete rather
236
+ // than fail: otherwise destroy-then-redeploy is stuck until the
237
+ // purge, with no way out but renaming the pool.
238
+ if (pool?.state === "DELETED") {
239
+ yield* session.note(
240
+ `Undeleting soft-deleted Workload Identity pool ${poolId}…`,
241
+ );
242
+ const op = yield* undeletePool({
243
+ name: poolResourceName(project, poolId),
244
+ body: {},
245
+ });
246
+ if (op.name) yield* awaitOperation(op.name, session);
247
+ pool = yield* observePool(project, poolId);
248
+ }
249
+
250
+ if (!pool) {
251
+ const op = yield* createPool({
252
+ parent: poolParent(project),
253
+ workloadIdentityPoolId: poolId,
254
+ body,
255
+ });
256
+ if (op.name) yield* awaitOperation(op.name, session);
257
+ pool = yield* observePool(project, poolId);
258
+ if (!pool) {
259
+ return yield* new ConfigError({
260
+ message: `Workload Identity pool ${poolId} in project ${project} was not readable after create.`,
261
+ });
262
+ }
263
+ } else {
264
+ // Only the mutable fields, and only when they actually differ —
265
+ // an unconditional patch would churn an LRO on every deploy.
266
+ const needsPatch =
267
+ !sameText(pool.displayName, news.displayName) ||
268
+ !sameText(pool.description, description) ||
269
+ (pool.disabled ?? false) !== (news.disabled ?? false);
270
+ if (needsPatch) {
271
+ const op = yield* patchPool({
272
+ name: poolResourceName(project, poolId),
273
+ updateMask: "displayName,description,disabled",
274
+ body,
275
+ });
276
+ if (op.name) yield* awaitOperation(op.name, session);
277
+ pool = (yield* observePool(project, poolId)) ?? pool;
278
+ }
279
+ }
280
+
281
+ yield* session.note(poolResourceName(project, poolId));
282
+ return toAttrs(project, poolId, pool);
283
+ }),
284
+ delete: Effect.fn(function* ({ olds, output, session }) {
285
+ // Fall back to props when attributes are incomplete. A create whose
286
+ // LRO succeeded but whose follow-up read failed leaves state with
287
+ // props and no usable attributes; a no-op destroy would then LEAK
288
+ // the pool — and because deletion is soft, the leaked pool holds
289
+ // its ID for ~30 days, blocking a re-deploy under the same name.
290
+ // `||` not `??`: an empty string is never a valid identity here,
291
+ // and a persisted `""` must fall through to the next source rather
292
+ // than be taken as real. With `??` a corrupt/partial state entry
293
+ // would short-circuit the fallback and turn destroy into a silent
294
+ // no-op (or make diff replace a live resource).
295
+ const project = output?.project || olds?.project;
296
+ const poolId = output?.poolId || olds?.poolId;
297
+ if (!project || !poolId) return;
298
+ const op = yield* deletePool({
299
+ name: poolResourceName(project, poolId),
300
+ }).pipe(
301
+ Effect.catchTag("NotFound", () =>
302
+ Effect.succeed({ name: undefined } as iam.Operation),
303
+ ),
304
+ );
305
+ if (op.name) yield* awaitOperation(op.name, session);
306
+ }),
307
+ };
308
+ }),
309
+ );