@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.
- package/lib/CloudResourceManager/Project.d.ts +16 -1
- package/lib/CloudResourceManager/Project.d.ts.map +1 -1
- package/lib/CloudResourceManager/Project.js +59 -1
- package/lib/CloudResourceManager/Project.js.map +1 -1
- package/lib/CloudResourceManager/ProjectIamMember.d.ts +25 -0
- package/lib/CloudResourceManager/ProjectIamMember.d.ts.map +1 -0
- package/lib/CloudResourceManager/ProjectIamMember.js +22 -0
- package/lib/CloudResourceManager/ProjectIamMember.js.map +1 -0
- package/lib/CloudResourceManager/index.d.ts +1 -0
- package/lib/CloudResourceManager/index.d.ts.map +1 -1
- package/lib/CloudResourceManager/index.js +1 -0
- package/lib/CloudResourceManager/index.js.map +1 -1
- package/lib/Compute/Network.d.ts +39 -2
- package/lib/Compute/Network.d.ts.map +1 -1
- package/lib/Compute/Network.js +12 -0
- package/lib/Compute/Network.js.map +1 -1
- package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -1
- package/lib/Compute/SharedVpcServiceProject.js +7 -1
- package/lib/Compute/SharedVpcServiceProject.js.map +1 -1
- package/lib/Compute/Subnetwork.d.ts +16 -1
- package/lib/Compute/Subnetwork.d.ts.map +1 -1
- package/lib/Compute/Subnetwork.js +74 -1
- package/lib/Compute/Subnetwork.js.map +1 -1
- package/lib/Compute/SubnetworkIamMember.d.ts +45 -0
- package/lib/Compute/SubnetworkIamMember.d.ts.map +1 -0
- package/lib/Compute/SubnetworkIamMember.js +46 -0
- package/lib/Compute/SubnetworkIamMember.js.map +1 -0
- package/lib/Compute/index.d.ts +1 -0
- package/lib/Compute/index.d.ts.map +1 -1
- package/lib/Compute/index.js +1 -0
- package/lib/Compute/index.js.map +1 -1
- package/lib/ManagedLustre/Instance.d.ts +173 -0
- package/lib/ManagedLustre/Instance.d.ts.map +1 -0
- package/lib/{Parallelstore → ManagedLustre}/Instance.js +39 -35
- package/lib/ManagedLustre/Instance.js.map +1 -0
- package/lib/ManagedLustre/index.d.ts.map +1 -0
- package/lib/ManagedLustre/index.js.map +1 -0
- package/lib/Providers.js +3 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +4 -4
- package/src/CloudResourceManager/Project.ts +90 -2
- package/src/CloudResourceManager/ProjectIamMember.ts +29 -0
- package/src/CloudResourceManager/index.ts +1 -0
- package/src/Compute/Network.ts +58 -5
- package/src/Compute/SharedVpcServiceProject.ts +6 -1
- package/src/Compute/Subnetwork.ts +105 -2
- package/src/Compute/SubnetworkIamMember.ts +52 -0
- package/src/Compute/index.ts +1 -0
- package/src/{Parallelstore → ManagedLustre}/Instance.ts +163 -110
- package/src/Providers.ts +5 -5
- package/src/index.ts +1 -1
- package/lib/Parallelstore/Instance.d.ts +0 -119
- package/lib/Parallelstore/Instance.d.ts.map +0 -1
- package/lib/Parallelstore/Instance.js.map +0 -1
- package/lib/Parallelstore/index.d.ts.map +0 -1
- package/lib/Parallelstore/index.js.map +0 -1
- /package/lib/{Parallelstore → ManagedLustre}/index.d.ts +0 -0
- /package/lib/{Parallelstore → ManagedLustre}/index.js +0 -0
- /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
|
-
|
|
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>;
|
package/src/Compute/Network.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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>;
|
package/src/Compute/index.ts
CHANGED