@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.
- package/lib/Container/NodePool.d.ts +10 -0
- package/lib/Container/NodePool.d.ts.map +1 -1
- package/lib/Container/NodePool.js +4 -0
- package/lib/Container/NodePool.js.map +1 -1
- package/lib/Iam/ServiceAccount.d.ts.map +1 -1
- package/lib/Iam/ServiceAccount.js +21 -2
- package/lib/Iam/ServiceAccount.js.map +1 -1
- package/lib/Iam/ServiceAccountKey.d.ts +72 -0
- package/lib/Iam/ServiceAccountKey.d.ts.map +1 -0
- package/lib/Iam/ServiceAccountKey.js +164 -0
- package/lib/Iam/ServiceAccountKey.js.map +1 -0
- package/lib/Iam/index.d.ts +2 -0
- package/lib/Iam/index.d.ts.map +1 -1
- package/lib/Iam/index.js +1 -0
- package/lib/Iam/index.js.map +1 -1
- package/lib/Kubernetes/Helm.d.ts +132 -0
- package/lib/Kubernetes/Helm.d.ts.map +1 -0
- package/lib/Kubernetes/Helm.js +314 -0
- package/lib/Kubernetes/Helm.js.map +1 -0
- package/lib/Kubernetes/index.d.ts +1 -0
- package/lib/Kubernetes/index.d.ts.map +1 -1
- package/lib/Kubernetes/index.js +1 -0
- package/lib/Kubernetes/index.js.map +1 -1
- package/lib/Providers.d.ts +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js +7 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Storage/Bucket.d.ts +117 -0
- package/lib/Storage/Bucket.d.ts.map +1 -0
- package/lib/Storage/Bucket.js +239 -0
- package/lib/Storage/Bucket.js.map +1 -0
- package/lib/Storage/BucketIamMember.d.ts +29 -0
- package/lib/Storage/BucketIamMember.d.ts.map +1 -0
- package/lib/Storage/BucketIamMember.js +26 -0
- package/lib/Storage/BucketIamMember.js.map +1 -0
- package/lib/Storage/index.d.ts +4 -0
- package/lib/Storage/index.d.ts.map +1 -0
- package/lib/Storage/index.js +3 -0
- package/lib/Storage/index.js.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Container/NodePool.ts +12 -0
- package/src/Iam/ServiceAccount.ts +21 -2
- package/src/Iam/ServiceAccountKey.ts +284 -0
- package/src/Iam/index.ts +5 -0
- package/src/Kubernetes/Helm.ts +536 -0
- package/src/Kubernetes/index.ts +1 -0
- package/src/Providers.ts +18 -0
- package/src/Storage/Bucket.ts +439 -0
- package/src/Storage/BucketIamMember.ts +33 -0
- package/src/Storage/index.ts +10 -0
- 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>;
|