@microagi/alchemy-gcp 0.1.4 → 0.2.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 (60) hide show
  1. package/lib/CloudResourceManager/Project.d.ts +16 -1
  2. package/lib/CloudResourceManager/Project.d.ts.map +1 -1
  3. package/lib/CloudResourceManager/Project.js +59 -1
  4. package/lib/CloudResourceManager/Project.js.map +1 -1
  5. package/lib/CloudResourceManager/ProjectIamMember.d.ts +25 -0
  6. package/lib/CloudResourceManager/ProjectIamMember.d.ts.map +1 -0
  7. package/lib/CloudResourceManager/ProjectIamMember.js +22 -0
  8. package/lib/CloudResourceManager/ProjectIamMember.js.map +1 -0
  9. package/lib/CloudResourceManager/index.d.ts +1 -0
  10. package/lib/CloudResourceManager/index.d.ts.map +1 -1
  11. package/lib/CloudResourceManager/index.js +1 -0
  12. package/lib/CloudResourceManager/index.js.map +1 -1
  13. package/lib/Compute/Network.d.ts +39 -2
  14. package/lib/Compute/Network.d.ts.map +1 -1
  15. package/lib/Compute/Network.js +12 -0
  16. package/lib/Compute/Network.js.map +1 -1
  17. package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -1
  18. package/lib/Compute/SharedVpcServiceProject.js +7 -1
  19. package/lib/Compute/SharedVpcServiceProject.js.map +1 -1
  20. package/lib/Compute/Subnetwork.d.ts +16 -1
  21. package/lib/Compute/Subnetwork.d.ts.map +1 -1
  22. package/lib/Compute/Subnetwork.js +74 -1
  23. package/lib/Compute/Subnetwork.js.map +1 -1
  24. package/lib/Compute/SubnetworkIamMember.d.ts +45 -0
  25. package/lib/Compute/SubnetworkIamMember.d.ts.map +1 -0
  26. package/lib/Compute/SubnetworkIamMember.js +46 -0
  27. package/lib/Compute/SubnetworkIamMember.js.map +1 -0
  28. package/lib/Compute/index.d.ts +1 -0
  29. package/lib/Compute/index.d.ts.map +1 -1
  30. package/lib/Compute/index.js +1 -0
  31. package/lib/Compute/index.js.map +1 -1
  32. package/lib/ManagedLustre/Instance.d.ts +173 -0
  33. package/lib/ManagedLustre/Instance.d.ts.map +1 -0
  34. package/lib/{Parallelstore → ManagedLustre}/Instance.js +39 -35
  35. package/lib/ManagedLustre/Instance.js.map +1 -0
  36. package/lib/ManagedLustre/index.d.ts.map +1 -0
  37. package/lib/ManagedLustre/index.js.map +1 -0
  38. package/lib/Providers.js +3 -3
  39. package/lib/index.d.ts +1 -1
  40. package/lib/index.js +1 -1
  41. package/package.json +4 -4
  42. package/src/CloudResourceManager/Project.ts +90 -2
  43. package/src/CloudResourceManager/ProjectIamMember.ts +29 -0
  44. package/src/CloudResourceManager/index.ts +1 -0
  45. package/src/Compute/Network.ts +58 -5
  46. package/src/Compute/SharedVpcServiceProject.ts +6 -1
  47. package/src/Compute/Subnetwork.ts +105 -2
  48. package/src/Compute/SubnetworkIamMember.ts +52 -0
  49. package/src/Compute/index.ts +1 -0
  50. package/src/{Parallelstore → ManagedLustre}/Instance.ts +163 -110
  51. package/src/Providers.ts +5 -5
  52. package/src/index.ts +1 -1
  53. package/lib/Parallelstore/Instance.d.ts +0 -119
  54. package/lib/Parallelstore/Instance.d.ts.map +0 -1
  55. package/lib/Parallelstore/Instance.js.map +0 -1
  56. package/lib/Parallelstore/index.d.ts.map +0 -1
  57. package/lib/Parallelstore/index.js.map +0 -1
  58. /package/lib/{Parallelstore → ManagedLustre}/index.d.ts +0 -0
  59. /package/lib/{Parallelstore → ManagedLustre}/index.js +0 -0
  60. /package/src/{Parallelstore → ManagedLustre}/index.ts +0 -0
@@ -21,6 +21,23 @@ export type ProjectParent =
21
21
  | { type: "folder"; id: string }
22
22
  | { type: "organization"; id: string };
23
23
 
24
+ /**
25
+ * Single (role, members) entry on a Project's IAM policy. Targets
26
+ * declare this contract so capabilities can `.bind` IAM grants onto
27
+ * the project — the provider's `reconcile` merges all bound entries
28
+ * by role into a single `setIamPolicy` call.
29
+ */
30
+ export type ProjectIamBinding = {
31
+ /** IAM role, e.g. `"roles/container.hostServiceAgentUser"`. */
32
+ role: string;
33
+ /** Principals, e.g. `["serviceAccount:foo@bar.iam.gserviceaccount.com"]`. */
34
+ members: ReadonlyArray<string>;
35
+ };
36
+
37
+ export type ProjectBindingContract = {
38
+ iamBindings: ReadonlyArray<ProjectIamBinding>;
39
+ };
40
+
24
41
  export type ProjectProps = {
25
42
  /**
26
43
  * Globally-unique project ID. 6-30 lowercase letters/digits/hyphens,
@@ -153,7 +170,7 @@ export type Project = Resource<
153
170
  */
154
171
  billingEnabled: boolean;
155
172
  },
156
- never,
173
+ ProjectBindingContract,
157
174
  GCP.Providers
158
175
  >;
159
176
 
@@ -208,6 +225,8 @@ export const ProjectProvider = () =>
208
225
  const patchProjects = yield* crm.patchProjects;
209
226
  const deleteProjects = yield* crm.deleteProjects;
210
227
  const getOperations = yield* crm.getOperations;
228
+ const getIamPolicy = yield* crm.getIamPolicyProjects;
229
+ const setIamPolicy = yield* crm.setIamPolicyProjects;
211
230
  const getBillingInfo = yield* billing.getBillingInfoProjects;
212
231
  const updateBillingInfo = yield* billing.updateBillingInfoProjects;
213
232
 
@@ -393,6 +412,67 @@ export const ProjectProvider = () =>
393
412
  );
394
413
  });
395
414
 
415
+ // Apply merged IAM bindings as a single setIamPolicy. Bindings
416
+ // are unioned per role (multiple `.bind` callers sharing a role
417
+ // get their members combined). Foreign bindings on the policy
418
+ // (non-alchemy roles, or members we don't manage on the same
419
+ // role) are preserved verbatim. Etag round-trip handles
420
+ // concurrent edits; Conflict triggers a re-read + retry.
421
+ const syncIam = (args: {
422
+ projectId: string;
423
+ bindings: ReadonlyArray<
424
+ import("alchemy/Resource").ResourceBinding<ProjectBindingContract>
425
+ >;
426
+ }) =>
427
+ Effect.gen(function* () {
428
+ const desiredByRole = new Map<string, Set<string>>();
429
+ for (const b of args.bindings) {
430
+ for (const ib of b.data.iamBindings) {
431
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
432
+ for (const m of ib.members) set.add(m);
433
+ desiredByRole.set(ib.role, set);
434
+ }
435
+ }
436
+ if (desiredByRole.size === 0) return;
437
+
438
+ const current = yield* getIamPolicy({
439
+ resource: `projects/${args.projectId}`,
440
+ body: { options: { requestedPolicyVersion: 3 } },
441
+ });
442
+
443
+ const existingBindings = (current.bindings ?? []).map((b) => ({
444
+ ...b,
445
+ members: [...(b.members ?? [])],
446
+ }));
447
+ let mutated = false;
448
+ for (const [role, members] of desiredByRole) {
449
+ let existing = existingBindings.find(
450
+ (b) => b.role === role && !b.condition,
451
+ );
452
+ if (!existing) {
453
+ existing = { role, members: [] };
454
+ existingBindings.push(existing);
455
+ }
456
+ const merged = new Set([...(existing.members ?? []), ...members]);
457
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
458
+ existing.members = [...merged];
459
+ }
460
+ if (!mutated) return;
461
+
462
+ yield* setIamPolicy({
463
+ resource: `projects/${args.projectId}`,
464
+ body: {
465
+ policy: { ...current, bindings: existingBindings, version: 3 },
466
+ },
467
+ });
468
+ }).pipe(
469
+ Effect.retry({
470
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
471
+ Schedule.both(Schedule.recurs(8)),
472
+ ),
473
+ }),
474
+ );
475
+
396
476
  return {
397
477
  stables: ["projectId", "projectNumber", "name"],
398
478
  diff: Effect.fn(function* ({ id, news, olds = {}, output }) {
@@ -410,7 +490,7 @@ export const ProjectProvider = () =>
410
490
  return { action: "replace" } as const;
411
491
  }
412
492
  }),
413
- reconcile: Effect.fn(function* ({ id, news, session }) {
493
+ reconcile: Effect.fn(function* ({ id, news, session, bindings }) {
414
494
  const projectId = yield* createProjectId(id, news.projectId);
415
495
  const parent = parentToString(news.parent);
416
496
  const internalLabels = yield* gcpInternalLabels(id);
@@ -471,6 +551,14 @@ export const ProjectProvider = () =>
471
551
  news.billingAccount,
472
552
  );
473
553
 
554
+ // 5. Sync IAM bindings (host-side IAM grants etc). Single
555
+ // setIamPolicy with merged bindings; foreign roles
556
+ // preserved. Etag-retried internally.
557
+ yield* syncIam({
558
+ projectId: synced.projectId!,
559
+ bindings,
560
+ });
561
+
474
562
  return toAttributes(synced, finalBilling);
475
563
  }),
476
564
  delete: Effect.fn(function* ({ output, session }) {
@@ -0,0 +1,29 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Project } from "./Project.ts";
3
+
4
+ /**
5
+ * Bind a single `(role, member)` IAM grant onto a {@link Project}.
6
+ *
7
+ * Target-side binding — see
8
+ * {@link import("../Compute/SubnetworkIamMember.ts").subnetworkIamMember}
9
+ * for the pattern, the SID-collision pitfall, and why `key` exists.
10
+ * Project's `reconcile` merges all bindings into a single
11
+ * `setIamPolicy` against the project, preserving foreign roles +
12
+ * members.
13
+ *
14
+ * @example Granting GKE service agent host-service-agent-user on a Shared VPC host
15
+ * ```typescript
16
+ * yield* GCP.projectIamMember(hostProject, "ClusterA-HostSvcAgent", {
17
+ * role: "roles/container.hostServiceAgentUser",
18
+ * member: gkeAgentMember,
19
+ * });
20
+ * ```
21
+ */
22
+ export const projectIamMember = (
23
+ project: Project,
24
+ key: string,
25
+ args: { role: string; member: string },
26
+ ): Effect.Effect<void> =>
27
+ project.bind`IamMember(${project}, ${key})`({
28
+ iamBindings: [{ role: args.role, members: [args.member] }],
29
+ }) as unknown as Effect.Effect<void>;
@@ -1,2 +1,3 @@
1
1
  export { Project, ProjectProvider } from "./Project.ts";
2
2
  export type { ProjectParent, ProjectProps } from "./Project.ts";
3
+ export * from "./ProjectIamMember.ts";
@@ -15,6 +15,15 @@ import {
15
15
  import type * as GCP from "../Providers.ts";
16
16
  import { makeAwaitGlobalOperation } from "./Operations.ts";
17
17
 
18
+ // Distinct nominal brands keep `NetworkRef` and `NetworkRefById`
19
+ // mutually unassignable even though their underlying template-literal
20
+ // shapes overlap. The brand symbols are `declare`-only — never present
21
+ // at runtime — and only the constructor functions below mint values
22
+ // carrying them, so a caller cannot bypass the type by hand-crafting a
23
+ // raw string and asserting it.
24
+ declare const _networkRefByNumberBrand: unique symbol;
25
+ declare const _networkRefByIdBrand: unique symbol;
26
+
18
27
  /**
19
28
  * Fully-qualified VPC reference in **project-number** form:
20
29
  * `projects/{projectNumber}/global/networks/{name}`.
@@ -33,9 +42,32 @@ import { makeAwaitGlobalOperation } from "./Operations.ts";
33
42
  *
34
43
  * `Project.projectNumber` is typed as `` `${number}` `` precisely so
35
44
  * this template literal type composes without a runtime coercion at
36
- * the call site.
45
+ * the call site. The nominal brand prevents a `NetworkRefById` from
46
+ * being passed where this is expected and vice versa — see
47
+ * {@link NetworkRefById}.
37
48
  */
38
- export type NetworkRef = `projects/${number}/global/networks/${string}`;
49
+ export type NetworkRef = `projects/${number}/global/networks/${string}` & {
50
+ readonly [_networkRefByNumberBrand]: true;
51
+ };
52
+
53
+ /**
54
+ * Fully-qualified VPC reference in **project-ID** form:
55
+ * `projects/{projectId}/global/networks/{name}`.
56
+ *
57
+ * Required by Managed Lustre's `instances.create` (and any future GCP
58
+ * API that explicitly rejects the project-number form). Build with
59
+ * {@link networkRefById}.
60
+ *
61
+ * Note: project-ID form is in some places interchangeable with
62
+ * project-number form (the SDK accepts either for compute / GKE), but
63
+ * Managed Lustre's API validates the path segment shape and refuses
64
+ * numeric ids — without this distinct branded type, a stack would
65
+ * compile fine and only fail at deploy time with `BadRequest: Valid
66
+ * format: projects/{project_id}/global/networks/{network_id}`.
67
+ */
68
+ export type NetworkRefById = `projects/${string}/global/networks/${string}` & {
69
+ readonly [_networkRefByIdBrand]: true;
70
+ };
39
71
 
40
72
  /**
41
73
  * Build a project-number-form `NetworkRef`. Lifted through `Output`
@@ -58,10 +90,31 @@ export const networkRef = (
58
90
  Output.asOutput(networkName),
59
91
  ).pipe(
60
92
  Output.map(
61
- ([num, name]) =>
62
- `projects/${num}/global/networks/${name}` as NetworkRef,
93
+ ([num, name]) => `projects/${num}/global/networks/${name}`,
63
94
  ),
64
- );
95
+ ) as unknown as Output.Output<NetworkRef>;
96
+
97
+ /**
98
+ * Build a project-ID-form `NetworkRefById`. Use for APIs that require
99
+ * the project-ID path segment — Managed Lustre is the current example.
100
+ *
101
+ * Pass `Project.projectId` (an `Output<string>`) plus `Network.name`.
102
+ * Plain literal arguments work too: `networkRefById("research-shared",
103
+ * "main")`. The branded return type prevents the value from being
104
+ * confused with a project-number-form {@link NetworkRef} at any call
105
+ * site downstream — even though their string shapes happen to coincide
106
+ * for numeric ids.
107
+ */
108
+ export const networkRefById = (
109
+ projectId: string | Output.Output<string>,
110
+ networkName: string | Output.Output<string>,
111
+ ): Output.Output<NetworkRefById> =>
112
+ Output.all(
113
+ Output.asOutput(projectId),
114
+ Output.asOutput(networkName),
115
+ ).pipe(
116
+ Output.map(([id, name]) => `projects/${id}/global/networks/${name}`),
117
+ ) as unknown as Output.Output<NetworkRefById>;
65
118
 
66
119
  /**
67
120
  * A VPC Network in custom-mode (`autoCreateSubnetworks=false`). Auto-mode
@@ -76,7 +76,12 @@ export const SharedVpcServiceProjectProvider = () =>
76
76
  // empty fields instead, so we coalesce both.
77
77
  const observeHost = (serviceProject: string) =>
78
78
  getXpnHostProjects({ project: serviceProject }).pipe(
79
- Effect.map((p) => p.name ?? p.id ?? undefined),
79
+ // Three "not attached" shapes observed:
80
+ // - 404 NotFound (catchTag below)
81
+ // - 200 with a `Project` whose name/id are empty
82
+ // - 200 with an undefined body (no Project payload at all)
83
+ // Coalesce all three to `undefined`.
84
+ Effect.map((p) => p?.name ?? p?.id ?? undefined),
80
85
  Effect.catchTag("NotFound", () =>
81
86
  Effect.succeed(undefined as string | undefined),
82
87
  ),
@@ -5,7 +5,10 @@ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
5
5
  import { deepEqual, isResolved, somePropsAreDifferent } from "alchemy/Diff";
6
6
  import { createPhysicalName } from "alchemy/PhysicalName";
7
7
  import * as Provider from "alchemy/Provider";
8
+ import type { ResourceBinding } from "alchemy/Resource";
9
+ import * as Duration from "effect/Duration";
8
10
  import * as Effect from "effect/Effect";
11
+ import * as Schedule from "effect/Schedule";
9
12
  import {
10
13
  descriptionHasAlchemyMarker,
11
14
  gcpAlchemyDescription,
@@ -118,11 +121,28 @@ export type SubnetworkAttributes = {
118
121
  gatewayAddress: string | undefined;
119
122
  };
120
123
 
124
+ /**
125
+ * Single (role, members) entry on a Subnetwork's IAM policy. Targets
126
+ * declare this contract so capabilities can `.bind` IAM grants onto
127
+ * the subnet — the provider's `reconcile` merges all bound entries
128
+ * by role into a single `setIamPolicy` call.
129
+ */
130
+ export type SubnetworkIamBinding = {
131
+ /** IAM role, e.g. `"roles/compute.networkUser"`. */
132
+ role: string;
133
+ /** Principals, e.g. `["serviceAccount:foo@bar.iam.gserviceaccount.com"]`. */
134
+ members: ReadonlyArray<string>;
135
+ };
136
+
137
+ export type SubnetworkBindingContract = {
138
+ iamBindings: ReadonlyArray<SubnetworkIamBinding>;
139
+ };
140
+
121
141
  export type Subnetwork = Resource<
122
142
  "GCP.Subnetwork",
123
143
  SubnetworkProps,
124
144
  SubnetworkAttributes,
125
- never,
145
+ SubnetworkBindingContract,
126
146
  GCP.Providers
127
147
  >;
128
148
  export const Subnetwork = Resource<Subnetwork>("GCP.Subnetwork");
@@ -164,8 +184,82 @@ export const SubnetworkProvider = () =>
164
184
  const deleteSubnetworks = yield* compute.deleteSubnetworks;
165
185
  const expandIpCidrRange = yield* compute.expandIpCidrRangeSubnetworks;
166
186
  const getRegionOperations = yield* compute.getRegionOperations;
187
+ const getIamPolicy = yield* compute.getIamPolicySubnetworks;
188
+ const setIamPolicy = yield* compute.setIamPolicySubnetworks;
167
189
  const awaitOp = makeAwaitRegionOperation(getRegionOperations);
168
190
 
191
+ // Apply merged IAM bindings as a single setIamPolicy. Bindings
192
+ // are unioned per role (multiple `.bind` callers sharing a role
193
+ // get their members combined). Foreign bindings on the policy
194
+ // (non-alchemy roles, or members we don't manage in this role)
195
+ // are preserved verbatim. Etag round-trip handles concurrent
196
+ // edits; Conflict triggers a re-read + retry.
197
+ const syncIam = (args: {
198
+ project: string;
199
+ region: string;
200
+ name: string;
201
+ bindings: ReadonlyArray<ResourceBinding<SubnetworkBindingContract>>;
202
+ }) =>
203
+ Effect.gen(function* () {
204
+ // Union all incoming binds by role.
205
+ const desiredByRole = new Map<string, Set<string>>();
206
+ for (const b of args.bindings) {
207
+ for (const ib of b.data.iamBindings) {
208
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
209
+ for (const m of ib.members) set.add(m);
210
+ desiredByRole.set(ib.role, set);
211
+ }
212
+ }
213
+ if (desiredByRole.size === 0) return;
214
+
215
+ const current = yield* getIamPolicy({
216
+ project: args.project,
217
+ region: args.region,
218
+ resource: args.name,
219
+ optionsRequestedPolicyVersion: 3,
220
+ });
221
+
222
+ // Take the current policy verbatim, then for each role we
223
+ // manage, union desired members into the existing binding (or
224
+ // create one). This preserves foreign roles and any extra
225
+ // members on the same role that we don't know about.
226
+ const bindings = (current.bindings ?? []).map((b) => ({
227
+ ...b,
228
+ members: [...(b.members ?? [])],
229
+ }));
230
+ let mutated = false;
231
+ for (const [role, members] of desiredByRole) {
232
+ let existing = bindings.find(
233
+ (b) => b.role === role && !b.condition,
234
+ );
235
+ if (!existing) {
236
+ existing = { role, members: [] };
237
+ bindings.push(existing);
238
+ }
239
+ const merged = new Set([...(existing.members ?? []), ...members]);
240
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
241
+ existing.members = [...merged];
242
+ }
243
+ if (!mutated) return;
244
+
245
+ yield* setIamPolicy({
246
+ project: args.project,
247
+ region: args.region,
248
+ resource: args.name,
249
+ body: { policy: { ...current, bindings, version: 3 } },
250
+ });
251
+ }).pipe(
252
+ // setIamPolicy returns Conflict on etag race. Re-read + retry.
253
+ // With multiple subnets/projects in a stack each receiving
254
+ // multiple bindings in parallel, races are common. Cap at
255
+ // ~1.5 min total to surface real failures.
256
+ Effect.retry({
257
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
258
+ Schedule.both(Schedule.recurs(8)),
259
+ ),
260
+ }),
261
+ );
262
+
169
263
  const observe = (project: string, region: string, name: string) =>
170
264
  getSubnetworks({ project, region, subnetwork: name }).pipe(
171
265
  Effect.catchTag("NotFound", () =>
@@ -265,7 +359,7 @@ export const SubnetworkProvider = () =>
265
359
  }
266
360
  return undefined;
267
361
  }),
268
- reconcile: Effect.fn(function* ({ id, news, session }) {
362
+ reconcile: Effect.fn(function* ({ id, news, session, bindings }) {
269
363
  const desiredName =
270
364
  news.name ??
271
365
  (yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
@@ -331,6 +425,15 @@ export const SubnetworkProvider = () =>
331
425
  session,
332
426
  });
333
427
 
428
+ // Apply IAM bindings AFTER the subnet exists. Single
429
+ // setIamPolicy call covers all bindings for this subnet.
430
+ yield* syncIam({
431
+ project: news.project,
432
+ region: news.region,
433
+ name: desiredName,
434
+ bindings,
435
+ });
436
+
334
437
  const final = yield* getSubnetworks({
335
438
  project: news.project,
336
439
  region: news.region,
@@ -0,0 +1,52 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Subnetwork } from "./Subnetwork.ts";
3
+
4
+ /**
5
+ * Bind a single `(role, member)` IAM grant onto a {@link Subnetwork}.
6
+ *
7
+ * This is a **target-side binding** — it records the grant onto the
8
+ * subnet's binding bag (`stack.bindings[subnet]`); the Subnetwork
9
+ * provider's `reconcile` later merges all such bindings (across all
10
+ * callers, including parallel ones from different cluster projects)
11
+ * into a single `setIamPolicy` call on the subnet.
12
+ *
13
+ * Identity for dedup: the rendered SID is `IamMember(<subnet>, <key>)`.
14
+ * The engine collapses binds by SID, so callers MUST supply a
15
+ * stack-unique `key` per (subnet, role, member) triple — otherwise a
16
+ * later bind silently overwrites an earlier one with the same key.
17
+ *
18
+ * The reason `key` exists: `member` is usually an `Output<string>`
19
+ * (e.g. `Output.interpolate\`...${proj.projectNumber}...\``), and
20
+ * `Output` expressions stringify by their AST shape, not by the
21
+ * eventual value. Two interpolations of the same shape but different
22
+ * literal templates render the same SID. `key` sidesteps that by
23
+ * forcing a static, caller-supplied label into the SID.
24
+ *
25
+ * Use this for the "Pulumi `gcp.compute.SubnetworkIAMMember`" pattern:
26
+ * authoritative-for-(role, member)-tuple, additive (does not displace
27
+ * other members on the same role).
28
+ *
29
+ * @example Granting GKE Standard cluster networkUser on a Shared VPC subnet
30
+ * ```typescript
31
+ * yield* GCP.subnetworkIamMember(subnet, "ClusterA-GkeAgent", {
32
+ * role: "roles/compute.networkUser",
33
+ * member: gkeAgentMember,
34
+ * });
35
+ * yield* GCP.subnetworkIamMember(subnet, "ClusterA-CloudSvc", {
36
+ * role: "roles/compute.networkUser",
37
+ * member: cloudServicesMember,
38
+ * });
39
+ * // → one setIamPolicy on `subnet` with both members under networkUser.
40
+ * ```
41
+ */
42
+ export const subnetworkIamMember = (
43
+ subnet: Subnetwork,
44
+ key: string,
45
+ args: { role: string; member: string },
46
+ ): Effect.Effect<void> =>
47
+ // Tagged-template `.bind` records into the target's binding bag.
48
+ // Subnetwork's reconcile sees `bindings: ResourceBinding<…>[]` and
49
+ // merges all entries by role into one setIamPolicy call.
50
+ subnet.bind`IamMember(${subnet}, ${key})`({
51
+ iamBindings: [{ role: args.role, members: [args.member] }],
52
+ }) as unknown as Effect.Effect<void>;
@@ -3,3 +3,4 @@ export * from "./Network.ts";
3
3
  export * from "./SharedVpcHost.ts";
4
4
  export * from "./SharedVpcServiceProject.ts";
5
5
  export * from "./Subnetwork.ts";
6
+ export * from "./SubnetworkIamMember.ts";