@microagi/alchemy-gcp 0.8.0 → 0.9.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 (55) hide show
  1. package/lib/Container/NodePool.d.ts +10 -0
  2. package/lib/Container/NodePool.d.ts.map +1 -1
  3. package/lib/Container/NodePool.js +4 -0
  4. package/lib/Container/NodePool.js.map +1 -1
  5. package/lib/Iam/ServiceAccount.d.ts.map +1 -1
  6. package/lib/Iam/ServiceAccount.js +21 -2
  7. package/lib/Iam/ServiceAccount.js.map +1 -1
  8. package/lib/Iam/ServiceAccountKey.d.ts +72 -0
  9. package/lib/Iam/ServiceAccountKey.d.ts.map +1 -0
  10. package/lib/Iam/ServiceAccountKey.js +164 -0
  11. package/lib/Iam/ServiceAccountKey.js.map +1 -0
  12. package/lib/Iam/index.d.ts +2 -0
  13. package/lib/Iam/index.d.ts.map +1 -1
  14. package/lib/Iam/index.js +1 -0
  15. package/lib/Iam/index.js.map +1 -1
  16. package/lib/Kubernetes/Helm.d.ts +132 -0
  17. package/lib/Kubernetes/Helm.d.ts.map +1 -0
  18. package/lib/Kubernetes/Helm.js +314 -0
  19. package/lib/Kubernetes/Helm.js.map +1 -0
  20. package/lib/Kubernetes/index.d.ts +1 -0
  21. package/lib/Kubernetes/index.d.ts.map +1 -1
  22. package/lib/Kubernetes/index.js +1 -0
  23. package/lib/Kubernetes/index.js.map +1 -1
  24. package/lib/Providers.d.ts +1 -1
  25. package/lib/Providers.d.ts.map +1 -1
  26. package/lib/Providers.js +7 -1
  27. package/lib/Providers.js.map +1 -1
  28. package/lib/Storage/Bucket.d.ts +117 -0
  29. package/lib/Storage/Bucket.d.ts.map +1 -0
  30. package/lib/Storage/Bucket.js +239 -0
  31. package/lib/Storage/Bucket.js.map +1 -0
  32. package/lib/Storage/BucketIamMember.d.ts +29 -0
  33. package/lib/Storage/BucketIamMember.d.ts.map +1 -0
  34. package/lib/Storage/BucketIamMember.js +26 -0
  35. package/lib/Storage/BucketIamMember.js.map +1 -0
  36. package/lib/Storage/index.d.ts +4 -0
  37. package/lib/Storage/index.d.ts.map +1 -0
  38. package/lib/Storage/index.js +3 -0
  39. package/lib/Storage/index.js.map +1 -0
  40. package/lib/index.d.ts +1 -0
  41. package/lib/index.d.ts.map +1 -1
  42. package/lib/index.js +1 -0
  43. package/lib/index.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/Container/NodePool.ts +12 -0
  46. package/src/Iam/ServiceAccount.ts +21 -2
  47. package/src/Iam/ServiceAccountKey.ts +284 -0
  48. package/src/Iam/index.ts +5 -0
  49. package/src/Kubernetes/Helm.ts +536 -0
  50. package/src/Kubernetes/index.ts +1 -0
  51. package/src/Providers.ts +18 -0
  52. package/src/Storage/Bucket.ts +439 -0
  53. package/src/Storage/BucketIamMember.ts +33 -0
  54. package/src/Storage/index.ts +10 -0
  55. package/src/index.ts +1 -0
@@ -0,0 +1,439 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as storage from "@distilled.cloud/gcp/storage-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 { diffTags } from "alchemy/Tags";
8
+ import * as Duration from "effect/Duration";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Schedule from "effect/Schedule";
11
+ import {
12
+ gcpInternalLabels,
13
+ hasAlchemyLabels,
14
+ } from "../Tags.ts";
15
+ import type * as GCP from "../Providers.ts";
16
+
17
+ /**
18
+ * A GCS bucket. Lives under a
19
+ * {@link import("../CloudResourceManager/Project.ts").Project} and serves
20
+ * as the target for {@link storageBucketIamMember} bindings (bucket-level
21
+ * IAM grants merged into a single `setIamPolicy` call).
22
+ *
23
+ * Adoption is gated on alchemy internal labels (`alchemy_app` /
24
+ * `alchemy_stage` / `alchemy_id`) — GCS buckets support labels, so the
25
+ * same label-based adoption gate as `Project` applies. The labels are
26
+ * merged into the user-supplied label set on create and never cleared.
27
+ *
28
+ * All lifecycle operations (`insertBuckets`, `getBuckets`, `patchBuckets`,
29
+ * `deleteBuckets`) are synchronous — no LRO polling required.
30
+ *
31
+ * @section Creating a StorageBucket
32
+ * @example Bucket with a 48-hour object TTL
33
+ * ```typescript
34
+ * const bucket = yield* GCP.StorageBucket("TempBucket", {
35
+ * project: project.projectId,
36
+ * name: "microagi-shared-temp",
37
+ * location: "europe-west4",
38
+ * storageClass: "STANDARD",
39
+ * lifecycle: {
40
+ * rule: [{
41
+ * action: { type: "Delete" },
42
+ * condition: { age: 2 }, // 2 days = 48 hours
43
+ * }],
44
+ * },
45
+ * });
46
+ * ```
47
+ */
48
+ export type StorageBucketProps = {
49
+ /**
50
+ * GCP project id (e.g. `micro-research-shared`) the bucket lives
51
+ * under. Immutable — changing this orphans the bucket and its IAM
52
+ * bindings, so `diff` triggers a replacement.
53
+ */
54
+ project: string;
55
+ /**
56
+ * The bucket name. Must be globally unique, 3-222 chars, lowercase
57
+ * letters, digits, hyphens, underscores, and dots. Immutable —
58
+ * replaces.
59
+ */
60
+ name: string;
61
+ /**
62
+ * Bucket location, e.g. `europe-west4` (single region) or `EU`
63
+ * (multi-region). Immutable — replaces.
64
+ */
65
+ location: string;
66
+ /**
67
+ * Storage class, e.g. `STANDARD`, `NEARLINE`, `COLDLINE`,
68
+ * `ARCHIVE`. Defaults to `STANDARD` server-side. Mutable.
69
+ */
70
+ storageClass?: string;
71
+ /**
72
+ * Bucket lifecycle configuration. Used for object TTL (e.g.
73
+ * `{ rule: [{ action: { type: "Delete" }, condition: { age: 2 } }] }`
74
+ * deletes objects older than 2 days). Mutable.
75
+ */
76
+ lifecycle?: storage.Bucket["lifecycle"];
77
+ /**
78
+ * User-supplied labels. The alchemy internal labels are merged
79
+ * automatically (see `gcpInternalLabels`). Alchemy keys are reserved
80
+ * and overwrite user-supplied collisions.
81
+ */
82
+ labels?: Record<string, string>;
83
+ /**
84
+ * Whether uniform bucket-level access is enabled. Defaults to
85
+ * `true` (recommended — ACLs are disabled, IAM is the sole authz
86
+ * path). Mutable.
87
+ */
88
+ uniformBucketLevelAccess?: boolean;
89
+ };
90
+
91
+ /**
92
+ * Single (role, members) entry on a bucket's IAM policy. Targets
93
+ * declare this contract so {@link storageBucketIamMember} can `.bind`
94
+ * IAM grants onto the bucket — the provider's `reconcile` merges all
95
+ * bound entries by role into a single `setIamPolicy` call.
96
+ */
97
+ export type StorageBucketIamBinding = {
98
+ /** IAM role, e.g. `"roles/storage.objectUser"`. */
99
+ role: string;
100
+ /** Principals, e.g. `["serviceAccount:sa@project.iam.gserviceaccount.com"]`. */
101
+ members: ReadonlyArray<string>;
102
+ };
103
+
104
+ export type StorageBucketBindingContract = {
105
+ iamBindings: ReadonlyArray<StorageBucketIamBinding>;
106
+ };
107
+
108
+ export type StorageBucket = Resource<
109
+ "GCP.StorageBucket",
110
+ StorageBucketProps,
111
+ {
112
+ /** Bucket name (same as the prop). */
113
+ name: string;
114
+ /** Bucket ID (server-assigned, same as name for buckets). */
115
+ id: string;
116
+ /** Self-link URL. */
117
+ selfLink: string;
118
+ /** Bucket location. */
119
+ location: string;
120
+ /** Storage class. */
121
+ storageClass: string | undefined;
122
+ /** Lifecycle configuration. */
123
+ lifecycle: storage.Bucket["lifecycle"];
124
+ /** Labels (including alchemy internal labels). */
125
+ labels: Record<string, string> | undefined;
126
+ /** Metageneration (changes on metadata updates). */
127
+ metageneration: string;
128
+ /** Creation time (RFC 3339). */
129
+ timeCreated: string;
130
+ /** Last update time (RFC 3339). */
131
+ updated: string;
132
+ },
133
+ StorageBucketBindingContract,
134
+ GCP.Providers
135
+ >;
136
+
137
+ export const StorageBucket = Resource<StorageBucket>("GCP.StorageBucket");
138
+
139
+ const toAttributes = (b: storage.Bucket): StorageBucket["Attributes"] => ({
140
+ name: b.name ?? "",
141
+ id: b.id ?? b.name ?? "",
142
+ selfLink: b.selfLink ?? "",
143
+ location: b.location ?? "",
144
+ storageClass: b.storageClass,
145
+ lifecycle: b.lifecycle,
146
+ labels: b.labels as Record<string, string> | undefined,
147
+ metageneration: b.metageneration ?? "",
148
+ timeCreated: b.timeCreated ?? "",
149
+ updated: b.updated ?? "",
150
+ });
151
+
152
+ export const StorageBucketProvider = () =>
153
+ Provider.effect(
154
+ StorageBucket,
155
+ Effect.gen(function* () {
156
+ const getBuckets = yield* storage.getBuckets;
157
+ const insertBuckets = yield* storage.insertBuckets;
158
+ const patchBuckets = yield* storage.patchBuckets;
159
+ const deleteBuckets = yield* storage.deleteBuckets;
160
+ const listBuckets = yield* storage.listBuckets;
161
+ const getIamPolicyBuckets = yield* storage.getIamPolicyBuckets;
162
+ const setIamPolicyBuckets = yield* storage.setIamPolicyBuckets;
163
+
164
+ const observeBucket = (name: string) =>
165
+ getBuckets({ bucket: name }).pipe(
166
+ Effect.catchTag("NotFound", () =>
167
+ Effect.succeed(undefined as storage.Bucket | undefined),
168
+ ),
169
+ Effect.catchTag("Forbidden", () =>
170
+ Effect.succeed(undefined as storage.Bucket | undefined),
171
+ ),
172
+ );
173
+
174
+ const requireBucket = (name: string, context: string) =>
175
+ observeBucket(name).pipe(
176
+ Effect.flatMap((b) =>
177
+ b
178
+ ? Effect.succeed(b)
179
+ : Effect.fail(
180
+ new ConfigError({
181
+ message: `Bucket ${name} ${context}.`,
182
+ }),
183
+ ),
184
+ ),
185
+ );
186
+
187
+ // List-scan for cold recovery. Alchemy labels on the bucket
188
+ // identify our resources; the prefix narrows the scan.
189
+ const findByAlchemyLabels = Effect.fn(function* (
190
+ id: string,
191
+ project: string,
192
+ name: string,
193
+ ) {
194
+ let pageToken: string | undefined;
195
+ for (let i = 0; i < 100; i++) {
196
+ const page = yield* listBuckets({
197
+ project,
198
+ prefix: name.slice(0, Math.min(name.length, 10)),
199
+ maxResults: 100,
200
+ ...(pageToken ? { pageToken } : {}),
201
+ });
202
+ for (const b of page.items ?? []) {
203
+ if (b.name === name && (yield* hasAlchemyLabels(id, b.labels as Record<string, string> | undefined))) {
204
+ return b;
205
+ }
206
+ }
207
+ pageToken = page.nextPageToken;
208
+ if (!pageToken) return undefined;
209
+ }
210
+ return undefined;
211
+ });
212
+
213
+ // Sync mutable fields: storageClass, lifecycle, labels,
214
+ // uniformBucketLevelAccess. Uses PATCH with the full bucket body
215
+ // (GCS patch semantics merge supplied fields). Diff against
216
+ // OBSERVED state, not `olds`, so adoption converges correctly.
217
+ const syncMutable = Effect.fn(function* (
218
+ observed: storage.Bucket,
219
+ desired: {
220
+ storageClass: string | undefined;
221
+ lifecycle: storage.Bucket["lifecycle"];
222
+ labels: Record<string, string>;
223
+ uniformBucketLevelAccess: boolean;
224
+ },
225
+ ) {
226
+ const needsUpdate =
227
+ desired.storageClass !== observed.storageClass ||
228
+ JSON.stringify(desired.lifecycle) !==
229
+ JSON.stringify(observed.lifecycle) ||
230
+ JSON.stringify(desired.labels) !==
231
+ JSON.stringify(observed.labels ?? {}) ||
232
+ desired.uniformBucketLevelAccess !==
233
+ (observed.iamConfiguration?.uniformBucketLevelAccess?.enabled ?? false);
234
+
235
+ if (!needsUpdate) return observed;
236
+
237
+ return yield* patchBuckets({
238
+ bucket: observed.name!,
239
+ body: {
240
+ storageClass: desired.storageClass,
241
+ lifecycle: desired.lifecycle,
242
+ labels: desired.labels,
243
+ iamConfiguration: {
244
+ uniformBucketLevelAccess: {
245
+ enabled: desired.uniformBucketLevelAccess,
246
+ },
247
+ },
248
+ },
249
+ });
250
+ });
251
+
252
+ // Apply merged IAM bindings as a single setIamPolicy. Same
253
+ // foreign-binding preservation + etag-retry pattern as
254
+ // ServiceAccount's `syncIam`.
255
+ const syncIam = (args: {
256
+ bucketName: string;
257
+ bindings: ReadonlyArray<
258
+ import("alchemy/Resource").ResourceBinding<StorageBucketBindingContract>
259
+ >;
260
+ }) =>
261
+ Effect.gen(function* () {
262
+ const desiredByRole = new Map<string, Set<string>>();
263
+ for (const b of args.bindings) {
264
+ for (const ib of b.data.iamBindings) {
265
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
266
+ for (const m of ib.members) set.add(m);
267
+ desiredByRole.set(ib.role, set);
268
+ }
269
+ }
270
+ if (desiredByRole.size === 0) return;
271
+
272
+ const current = yield* getIamPolicyBuckets({
273
+ bucket: args.bucketName,
274
+ optionsRequestedPolicyVersion: 3,
275
+ });
276
+
277
+ const existingBindings = (current.bindings ?? []).map((b) => ({
278
+ ...b,
279
+ members: [...(b.members ?? [])],
280
+ }));
281
+ let mutated = false;
282
+ for (const [role, members] of desiredByRole) {
283
+ let existing = existingBindings.find(
284
+ (b) => b.role === role && !b.condition,
285
+ );
286
+ if (!existing) {
287
+ existing = { role, members: [] };
288
+ existingBindings.push(existing);
289
+ }
290
+ const merged = new Set([...(existing.members ?? []), ...members]);
291
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
292
+ existing.members = [...merged];
293
+ }
294
+ if (!mutated) return;
295
+
296
+ yield* setIamPolicyBuckets({
297
+ bucket: args.bucketName,
298
+ body: {
299
+ ...current,
300
+ bindings: existingBindings,
301
+ version: 3,
302
+ },
303
+ });
304
+ }).pipe(
305
+ Effect.retry({
306
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
307
+ Schedule.both(Schedule.recurs(8)),
308
+ ),
309
+ }),
310
+ );
311
+
312
+ return {
313
+ stables: ["name", "id", "selfLink", "location", "timeCreated"],
314
+ diff: Effect.fn(function* ({ news, olds = {}, output }) {
315
+ if (!isResolved(news)) return undefined;
316
+ const priorName = output?.name || olds.name;
317
+ const priorProject = olds.project;
318
+ const priorLocation = olds.location;
319
+ if (
320
+ (priorName && news.name !== priorName) ||
321
+ (priorProject && news.project !== priorProject) ||
322
+ (priorLocation && news.location !== priorLocation)
323
+ ) {
324
+ return { action: "replace" } as const;
325
+ }
326
+ return undefined;
327
+ }),
328
+ reconcile: Effect.fn(function* ({ id, news, bindings, output }) {
329
+ const internalLabels = yield* gcpInternalLabels(id);
330
+ const mergedLabels = { ...(news.labels ?? {}), ...internalLabels };
331
+ let created = false;
332
+
333
+ // 1. Observe — cloud state is authoritative. Fall back to
334
+ // label-scan for cold recovery.
335
+ let observed = yield* observeBucket(news.name);
336
+ if (!observed) {
337
+ observed = yield* findByAlchemyLabels(id, news.project, news.name);
338
+ }
339
+
340
+ // 2. Ensure — create if missing. insertBuckets is
341
+ // synchronous (returns the bucket, not an LRO). Conflict
342
+ // covers a peer reconciler race — re-observe.
343
+ if (!observed) {
344
+ observed = yield* insertBuckets({
345
+ project: news.project,
346
+ body: {
347
+ name: news.name,
348
+ location: news.location,
349
+ storageClass: news.storageClass,
350
+ lifecycle: news.lifecycle,
351
+ labels: mergedLabels,
352
+ iamConfiguration: {
353
+ uniformBucketLevelAccess: {
354
+ enabled: news.uniformBucketLevelAccess ?? true,
355
+ },
356
+ },
357
+ },
358
+ }).pipe(
359
+ Effect.catchTag("Conflict", () =>
360
+ Effect.succeed(undefined as storage.Bucket | undefined),
361
+ ),
362
+ );
363
+ created = !!observed;
364
+
365
+ if (!observed) {
366
+ observed = yield* requireBucket(news.name, "did not appear after create");
367
+ }
368
+ }
369
+
370
+ // Refuse to mutate an existing unowned bucket on greenfield
371
+ // create. The `read` handler returns Unowned(attrs) for
372
+ // buckets without our labels, which gates adoption behind
373
+ // --adopt. But reconcile runs before read on a first deploy,
374
+ // so without this guard we'd sync mutable fields (labels,
375
+ // lifecycle, UBLA) on a foreign bucket — bypassing the
376
+ // adoption gate. Skip the check when we just created the
377
+ // bucket (it has our labels) or when the engine already
378
+ // adopted it (output is defined).
379
+ if (
380
+ !created &&
381
+ !output &&
382
+ !(yield* hasAlchemyLabels(
383
+ id,
384
+ observed.labels as Record<string, string> | undefined,
385
+ ))
386
+ ) {
387
+ return yield* Effect.fail(
388
+ new ConfigError({
389
+ message:
390
+ `Bucket ${news.name} already exists without matching alchemy labels; use --adopt to take it over.`,
391
+ }),
392
+ );
393
+ }
394
+
395
+ // 3. Sync mutable fields against OBSERVED state.
396
+ const synced = yield* syncMutable(observed, {
397
+ storageClass: news.storageClass ?? observed.storageClass,
398
+ lifecycle: news.lifecycle ?? observed.lifecycle,
399
+ labels: mergedLabels,
400
+ uniformBucketLevelAccess: news.uniformBucketLevelAccess ?? true,
401
+ });
402
+
403
+ // 4. Sync IAM bindings (storage.objectUser, etc.).
404
+ yield* syncIam({
405
+ bucketName: synced.name!,
406
+ bindings,
407
+ });
408
+
409
+ return toAttributes(synced);
410
+ }),
411
+ delete: Effect.fn(function* ({ output }) {
412
+ yield* deleteBuckets({
413
+ bucket: output.name,
414
+ }).pipe(
415
+ Effect.catchTag("NotFound", () => Effect.void),
416
+ Effect.catchTag("Forbidden", () => Effect.void),
417
+ );
418
+ }),
419
+ read: Effect.fn(function* ({ id, output, olds }) {
420
+ const name = output?.name ?? olds?.name;
421
+ const project = olds?.project as string | undefined;
422
+
423
+ let observed: storage.Bucket | undefined;
424
+ if (name) {
425
+ observed = yield* observeBucket(name);
426
+ }
427
+ if (!observed && project) {
428
+ observed = yield* findByAlchemyLabels(id, project, name ?? "");
429
+ }
430
+ if (!observed) return undefined;
431
+
432
+ const attrs = toAttributes(observed);
433
+ return (yield* hasAlchemyLabels(id, observed.labels as Record<string, string> | undefined))
434
+ ? attrs
435
+ : Unowned(attrs);
436
+ }),
437
+ };
438
+ }),
439
+ );
@@ -0,0 +1,33 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { StorageBucket } from "./Bucket.ts";
3
+
4
+ /**
5
+ * Bind a single `(role, member)` IAM grant onto a
6
+ * {@link StorageBucket}.
7
+ *
8
+ * Target-side binding — same pattern as
9
+ * {@link import("../CloudResourceManager/ProjectIamMember.ts").projectIamMember}
10
+ * / `serviceAccountIamMember`. The bucket's `reconcile` merges all
11
+ * bindings into a single `setIamPolicy` call on the bucket, preserving
12
+ * foreign roles + members.
13
+ *
14
+ * Identity for dedup: the rendered SID is `IamMember(<bucket>, <key>)`.
15
+ * The engine collapses binds by SID, so callers MUST supply a
16
+ * stack-unique `key` per (bucket, role, member) triple.
17
+ *
18
+ * @example Granting a service account read+write on a bucket
19
+ * ```typescript
20
+ * yield* GCP.storageBucketIamMember(bucket, "TempBucketRw", {
21
+ * role: "roles/storage.objectUser",
22
+ * member: `serviceAccount:${sa.email}`,
23
+ * });
24
+ * ```
25
+ */
26
+ export const storageBucketIamMember = (
27
+ bucket: StorageBucket,
28
+ key: string,
29
+ args: { role: string; member: string },
30
+ ): Effect.Effect<void> =>
31
+ bucket.bind`IamMember(${bucket}, ${key})`({
32
+ iamBindings: [{ role: args.role, members: [args.member] }],
33
+ }) as unknown as Effect.Effect<void>;
@@ -0,0 +1,10 @@
1
+ export {
2
+ StorageBucket,
3
+ StorageBucketProvider,
4
+ } from "./Bucket.ts";
5
+ export type {
6
+ StorageBucketProps,
7
+ StorageBucketIamBinding,
8
+ StorageBucketBindingContract,
9
+ } from "./Bucket.ts";
10
+ export * from "./BucketIamMember.ts";
package/src/index.ts CHANGED
@@ -18,4 +18,5 @@ export * from "./Run/index.ts";
18
18
  export * from "./ServiceNetworking/index.ts";
19
19
  export * from "./Sqladmin/index.ts";
20
20
  export * from "./ServiceUsage/index.ts";
21
+ export * from "./Storage/index.ts";
21
22
  export * from "./Providers.ts";