@intentius/chant-lexicon-gcp 0.124.34 → 0.124.35

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,319 @@
1
+ /**
2
+ * Compute Engine mappers for the Cloud Run load balancer (#4233): ComputeAddress,
3
+ * ComputeRegionNetworkEndpointGroup (serverless NEGs), ComputeBackendService,
4
+ * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule.
5
+ *
6
+ * Each maps a CNRM spec onto the compute v1 insert body. A resource is global
7
+ * or regional by its CNRM `location` (`"global"` or a region; a NEG has
8
+ * `region`), and that picks `projects/<p>/global/<collection>` or
9
+ * `projects/<p>/regions/<r>/<collection>`. `*Ref` fields become the referenced
10
+ * resource's partial URL, which compute accepts; a ref to a resource in the
11
+ * same manifest takes its scope from that resource's own spec, so a global
12
+ * backend service can point at a regional NEG.
13
+ *
14
+ * Inserts return a compute `Operation`, polled at its `selfLink` (rebased onto
15
+ * the endpoint override, so floci-gcp answers). These kinds reconcile by
16
+ * existence only: an existing resource is left as it is, since compute updates
17
+ * need the live `fingerprint` and most of these fields are immutable.
18
+ */
19
+ import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
20
+ import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
21
+ import type { GcpResource, OperationSpec, ResourceMapper } from "./gcp-apply";
22
+
23
+ export const COMPUTE_HOST = "https://compute.googleapis.com";
24
+
25
+ const enc = encodeURIComponent;
26
+
27
+ type Ref = { name?: string; external?: string; kind?: string };
28
+ type Obj = Record<string, unknown>;
29
+
30
+ function isObj(v: unknown): v is Obj {
31
+ return v !== null && typeof v === "object" && !Array.isArray(v);
32
+ }
33
+
34
+ /** What a `*Ref` key points at: the CNRM kind (for a manifest lookup), the collection, and whether it is global only. */
35
+ const REF_TARGETS: Record<string, { kind: string; collection: string; scope: "global" | "regional" | "either" }> = {
36
+ addressRef: { kind: "ComputeAddress", collection: "addresses", scope: "either" },
37
+ networkEndpointGroupRef: { kind: "ComputeRegionNetworkEndpointGroup", collection: "networkEndpointGroups", scope: "regional" },
38
+ backendServiceRef: { kind: "ComputeBackendService", collection: "backendServices", scope: "either" },
39
+ backendBucketRef: { kind: "ComputeBackendBucket", collection: "backendBuckets", scope: "global" },
40
+ urlMapRef: { kind: "ComputeURLMap", collection: "urlMaps", scope: "either" },
41
+ targetHTTPProxyRef: { kind: "ComputeTargetHTTPProxy", collection: "targetHttpProxies", scope: "either" },
42
+ targetHTTPSProxyRef: { kind: "ComputeTargetHTTPSProxy", collection: "targetHttpsProxies", scope: "either" },
43
+ healthCheckRef: { kind: "ComputeHealthCheck", collection: "healthChecks", scope: "either" },
44
+ securityPolicyRef: { kind: "ComputeSecurityPolicy", collection: "securityPolicies", scope: "global" },
45
+ networkRef: { kind: "ComputeNetwork", collection: "networks", scope: "global" },
46
+ subnetworkRef: { kind: "ComputeSubnetwork", collection: "subnetworks", scope: "regional" },
47
+ };
48
+
49
+ export interface ComputeCtx {
50
+ base: string;
51
+ project: string;
52
+ manifest?: readonly GcpResource[];
53
+ }
54
+
55
+ /** The region or `"global"` a CNRM compute spec declares. A NEG names `region`; the rest `location`. */
56
+ export function scopeOf(spec: Obj | undefined, env: NodeJS.ProcessEnv = process.env): string {
57
+ const loc = spec?.location ?? spec?.region;
58
+ return typeof loc === "string" && loc !== "" ? loc : (env.GCP_REGION || "us-central1");
59
+ }
60
+
61
+ function scopePath(project: string, scope: string): string {
62
+ return scope === "global" ? `projects/${enc(project)}/global` : `projects/${enc(project)}/regions/${enc(scope)}`;
63
+ }
64
+
65
+ /** The compute name: CNRM's `resourceID` when set, else `metadata.name`. */
66
+ export function computeName(resource: GcpResource): string {
67
+ const id = (resource.spec as Obj | undefined)?.resourceID;
68
+ const name = typeof id === "string" && id !== "" ? id : resource.metadata?.name;
69
+ if (!name) throw new Error(`${resource.kind ?? "compute resource"} has no metadata.name`);
70
+ return name;
71
+ }
72
+
73
+ /**
74
+ * The partial URL a `*Ref` resolves to. `external` is used as given when it is
75
+ * a path or URL; a bare name (or `name`) is scoped by the referenced resource's
76
+ * own spec when the manifest holds it, else by `fallbackScope`. Pure.
77
+ */
78
+ export function refUrl(key: string, ref: Ref, ctx: ComputeCtx, fallbackScope: string): string {
79
+ const target = REF_TARGETS[key];
80
+ if (!target) throw new Error(`no compute collection for ${key}`);
81
+ if (typeof ref.external === "string" && ref.external !== "") {
82
+ if (ref.external.startsWith("projects/") || ref.external.startsWith("https://")) return ref.external;
83
+ }
84
+ const name = ref.name ?? ref.external;
85
+ if (!name) throw new Error(`${key} has no name or external`);
86
+ const declared = ctx.manifest?.find((r) => r.kind === target.kind && (r.metadata?.name === name || (r.spec as Obj | undefined)?.resourceID === name));
87
+ const scope =
88
+ target.scope === "global"
89
+ ? "global"
90
+ : declared
91
+ ? scopeOf(declared.spec as Obj)
92
+ : target.scope === "regional" && fallbackScope === "global"
93
+ ? scopeOf(undefined)
94
+ : fallbackScope;
95
+ const resolvedName = declared ? computeName(declared) : name;
96
+ return `${scopePath(ctx.project, scope)}/${target.collection}/${resolvedName}`;
97
+ }
98
+
99
+ /** Resolve the one `*Ref` key an object holds (`{ backendServiceRef: {...} }` → URL), or undefined. */
100
+ function oneRef(value: unknown, ctx: ComputeCtx, scope: string): string | undefined {
101
+ if (!isObj(value)) return undefined;
102
+ for (const [k, v] of Object.entries(value)) {
103
+ if (REF_TARGETS[k] && isObj(v)) return refUrl(k, v as Ref, ctx, scope);
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /** Copy the listed fields that are set. */
109
+ function pick(spec: Obj, keys: readonly string[]): Obj {
110
+ const out: Obj = {};
111
+ for (const k of keys) if (spec[k] !== undefined) out[k] = spec[k];
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * The resource's labels GCP accepts, plus the ownership marker. Composites put
117
+ * Kubernetes-style keys (`app.kubernetes.io/component`) in `metadata.labels`,
118
+ * which a compute label key can't hold, so those are left out.
119
+ */
120
+ export function computeLabels(labels: Record<string, string> | undefined): Record<string, string> {
121
+ const out: Record<string, string> = {};
122
+ for (const [k, v] of Object.entries(labels ?? {})) {
123
+ if (/^[a-z][a-z0-9_-]{0,62}$/.test(k) && typeof v === "string" && /^[a-z0-9_-]{0,63}$/.test(v)) out[k] = v;
124
+ }
125
+ return { ...out, [GCP_RESOURCE_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
126
+ }
127
+
128
+ /** ComputeAddress → addresses.insert body. Pure. */
129
+ export function addressBody(resource: GcpResource, ctx: ComputeCtx): Obj {
130
+ const spec = (resource.spec ?? {}) as Obj;
131
+ const scope = scopeOf(spec);
132
+ const body: Obj = {
133
+ name: computeName(resource),
134
+ ...pick(spec, ["address", "addressType", "description", "ipVersion", "ipv6EndpointType", "networkTier", "prefixLength", "purpose"]),
135
+ labels: computeLabels(resource.metadata?.labels),
136
+ };
137
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
138
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
139
+ return body;
140
+ }
141
+
142
+ /** ComputeRegionNetworkEndpointGroup → regionNetworkEndpointGroups.insert body (serverless NEGs). Pure. */
143
+ export function negBody(resource: GcpResource, ctx: ComputeCtx): Obj {
144
+ const spec = (resource.spec ?? {}) as Obj;
145
+ const scope = scopeOf(spec);
146
+ const body: Obj = {
147
+ name: computeName(resource),
148
+ networkEndpointType: (spec.networkEndpointType as string | undefined) ?? "SERVERLESS",
149
+ ...pick(spec, ["description", "pscTargetService"]),
150
+ };
151
+ const serviceName = (ref: unknown): string | undefined => {
152
+ if (!isObj(ref)) return undefined;
153
+ const r = ref as Ref;
154
+ // A serverless NEG names the service, not its path.
155
+ return (r.external ?? r.name)?.split("/").pop();
156
+ };
157
+ if (isObj(spec.cloudRun)) {
158
+ const c = spec.cloudRun as Obj;
159
+ body.cloudRun = { ...pick(c, ["tag", "urlMask"]), ...(serviceName(c.serviceRef) ? { service: serviceName(c.serviceRef) } : {}) };
160
+ }
161
+ if (isObj(spec.cloudFunction)) {
162
+ const c = spec.cloudFunction as Obj;
163
+ body.cloudFunction = { ...pick(c, ["urlMask"]), ...(serviceName(c.functionRef) ? { function: serviceName(c.functionRef) } : {}) };
164
+ }
165
+ if (isObj(spec.appEngine)) body.appEngine = spec.appEngine;
166
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
167
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
168
+ return body;
169
+ }
170
+
171
+ /** ComputeBackendService → backendServices.insert body. Pure. */
172
+ export function backendServiceBody(resource: GcpResource, ctx: ComputeCtx): Obj {
173
+ const spec = (resource.spec ?? {}) as Obj;
174
+ const scope = scopeOf(spec);
175
+ const body: Obj = {
176
+ name: computeName(resource),
177
+ ...pick(spec, [
178
+ "affinityCookieTtlSec",
179
+ "cdnPolicy",
180
+ "compressionMode",
181
+ "customRequestHeaders",
182
+ "customResponseHeaders",
183
+ "description",
184
+ "loadBalancingScheme",
185
+ "localityLbPolicy",
186
+ "logConfig",
187
+ "portName",
188
+ "protocol",
189
+ "sessionAffinity",
190
+ "timeoutSec",
191
+ ]),
192
+ };
193
+ if (spec.enableCdn !== undefined) body.enableCDN = spec.enableCdn;
194
+ if (spec.connectionDrainingTimeoutSec !== undefined) body.connectionDraining = { drainingTimeoutSec: spec.connectionDrainingTimeoutSec };
195
+ if (Array.isArray(spec.backend)) {
196
+ body.backends = (spec.backend as Obj[]).map((b) => {
197
+ const { group, ...rest } = b;
198
+ const groupUrl = isObj(group) ? oneRef(group, ctx, scope) ?? ((group as Obj).instanceGroupRef as Ref | undefined)?.external : undefined;
199
+ return { ...rest, ...(groupUrl ? { group: groupUrl } : {}) };
200
+ });
201
+ }
202
+ if (Array.isArray(spec.healthChecks)) {
203
+ body.healthChecks = (spec.healthChecks as Obj[]).map((h) => oneRef(h, ctx, scope)).filter((u): u is string => !!u);
204
+ }
205
+ if (isObj(spec.securityPolicyRef)) body.securityPolicy = refUrl("securityPolicyRef", spec.securityPolicyRef as Ref, ctx, scope);
206
+ else if (typeof spec.securityPolicy === "string") body.securityPolicy = spec.securityPolicy;
207
+ return body;
208
+ }
209
+
210
+ /** ComputeURLMap → urlMaps.insert body. Pure. */
211
+ export function urlMapBody(resource: GcpResource, ctx: ComputeCtx): Obj {
212
+ const spec = (resource.spec ?? {}) as Obj;
213
+ const scope = scopeOf(spec);
214
+ const service = (v: unknown) => oneRef(v, ctx, scope);
215
+ const body: Obj = {
216
+ name: computeName(resource),
217
+ ...pick(spec, ["description", "defaultUrlRedirect", "headerAction"]),
218
+ };
219
+ const def = service(spec.defaultService);
220
+ if (def) body.defaultService = def;
221
+ if (Array.isArray(spec.hostRule)) body.hostRules = spec.hostRule;
222
+ if (Array.isArray(spec.pathMatcher)) {
223
+ body.pathMatchers = (spec.pathMatcher as Obj[]).map((m) => {
224
+ const { defaultService, pathRule, ...rest } = m;
225
+ const out: Obj = { ...rest };
226
+ const d = service(defaultService);
227
+ if (d) out.defaultService = d;
228
+ if (Array.isArray(pathRule)) {
229
+ out.pathRules = (pathRule as Obj[]).map((r) => {
230
+ const { service: s, ...rr } = r;
231
+ const u = service(s);
232
+ return { ...rr, ...(u ? { service: u } : {}) };
233
+ });
234
+ }
235
+ return out;
236
+ });
237
+ }
238
+ return body;
239
+ }
240
+
241
+ /** ComputeTargetHTTPProxy → targetHttpProxies.insert body. Pure. */
242
+ export function targetHttpProxyBody(resource: GcpResource, ctx: ComputeCtx): Obj {
243
+ const spec = (resource.spec ?? {}) as Obj;
244
+ const body: Obj = { name: computeName(resource), ...pick(spec, ["description", "proxyBind", "httpKeepAliveTimeoutSec"]) };
245
+ if (isObj(spec.urlMapRef)) body.urlMap = refUrl("urlMapRef", spec.urlMapRef as Ref, ctx, scopeOf(spec));
246
+ return body;
247
+ }
248
+
249
+ /** ComputeForwardingRule → forwardingRules.insert body. Pure. */
250
+ export function forwardingRuleBody(resource: GcpResource, ctx: ComputeCtx): Obj {
251
+ const spec = (resource.spec ?? {}) as Obj;
252
+ const scope = scopeOf(spec);
253
+ const body: Obj = {
254
+ name: computeName(resource),
255
+ ...pick(spec, ["allowGlobalAccess", "description", "ipVersion", "isMirroringCollector", "loadBalancingScheme", "networkTier", "portRange", "ports", "allPorts"]),
256
+ labels: computeLabels(resource.metadata?.labels),
257
+ };
258
+ if (spec.ipProtocol !== undefined) body.IPProtocol = spec.ipProtocol;
259
+ if (isObj(spec.ipAddress)) {
260
+ const ip = spec.ipAddress as Obj;
261
+ const addr = isObj(ip.addressRef) ? refUrl("addressRef", ip.addressRef as Ref, ctx, scope) : (ip.ip as string | undefined);
262
+ if (addr) body.IPAddress = addr;
263
+ }
264
+ const target = oneRef(spec.target, ctx, scope);
265
+ if (target) body.target = target;
266
+ if (isObj(spec.backendServiceRef)) body.backendService = refUrl("backendServiceRef", spec.backendServiceRef as Ref, ctx, scope);
267
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
268
+ if (isObj(spec.subnetworkRef)) body.subnetwork = refUrl("subnetworkRef", spec.subnetworkRef as Ref, ctx, scope);
269
+ return body;
270
+ }
271
+
272
+ /**
273
+ * A compute `Operation`: the insert or delete returns one with a `selfLink`,
274
+ * polled until `status: "DONE"`. The link is rebased onto `base`, so an
275
+ * endpoint override (floci-gcp) is polled too. A create that returned the
276
+ * resource itself has nothing to poll.
277
+ */
278
+ export const computeOperation: OperationSpec = {
279
+ pollUrl(body, { base }) {
280
+ const op = body as { kind?: string; selfLink?: string } | undefined;
281
+ const link = op?.selfLink;
282
+ if (!link || !link.includes("/operations/")) return undefined;
283
+ const at = link.indexOf("/compute/v1/");
284
+ return at >= 0 ? `${base}${link.slice(at)}` : link;
285
+ },
286
+ isDone(body) {
287
+ return (body as { status?: string })?.status === "DONE";
288
+ },
289
+ error(body) {
290
+ const errors = (body as { error?: { errors?: Array<{ message?: string; code?: string }> } })?.error?.errors;
291
+ return errors?.length ? errors.map((e) => e.message ?? e.code ?? "error").join("; ") : undefined;
292
+ },
293
+ };
294
+
295
+ function computeMapper(
296
+ kind: string,
297
+ collection: string,
298
+ build: (resource: GcpResource, ctx: ComputeCtx) => Obj,
299
+ regionalCollection = collection,
300
+ ): ResourceMapper {
301
+ return {
302
+ kind,
303
+ defaultHost: COMPUTE_HOST,
304
+ operation: computeOperation,
305
+ plan(resource, ctx) {
306
+ const scope = scopeOf(resource.spec as Obj | undefined);
307
+ const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
308
+ const name = computeName(resource);
309
+ return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
310
+ },
311
+ };
312
+ }
313
+
314
+ export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody);
315
+ export const regionNetworkEndpointGroupMapper = computeMapper("ComputeRegionNetworkEndpointGroup", "networkEndpointGroups", negBody);
316
+ export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody);
317
+ export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
318
+ export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
319
+ export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);
@@ -0,0 +1,152 @@
1
+ import { beforeEach, describe, expect, test } from "vitest";
2
+ import { applyResource, deleteResource, MAPPERS, type GcpHttp, type GcpResource } from "./gcp-apply";
3
+ import { iamPolicyMemberMapper, iamTarget, policyHasMember, withMember, withoutMember } from "./gcp-iam";
4
+ import { getResource } from "../../api/read-client";
5
+
6
+ const binding = (overrides: Record<string, unknown> = {}): GcpResource => ({
7
+ kind: "IAMPolicyMember",
8
+ metadata: { name: "web-public" },
9
+ spec: {
10
+ member: "allUsers",
11
+ role: "roles/run.invoker",
12
+ resourceRef: { apiVersion: "run.cnrm.cloud.google.com/v1beta1", kind: "RunService", name: "web" },
13
+ ...overrides,
14
+ },
15
+ });
16
+
17
+ /** A fake policy store: one policy per `:getIamPolicy` path, recording each call. */
18
+ function policyServer(initial: Record<string, unknown> = { etag: "e1", bindings: [{ role: "roles/viewer", members: ["user:a@x.io"] }] }) {
19
+ let policy = initial as { etag?: string; bindings?: Array<{ role: string; members: string[] }> };
20
+ const calls: Array<{ method: string; url: string; body?: unknown }> = [];
21
+ let conflicts = 0;
22
+ const http: GcpHttp = async (method, url, body) => {
23
+ calls.push({ method, url, ...(body !== undefined ? { body } : {}) });
24
+ if (url.includes(":getIamPolicy")) return { status: 200, text: JSON.stringify(policy) };
25
+ if (url.includes(":setIamPolicy")) {
26
+ if (conflicts > 0) {
27
+ conflicts--;
28
+ return { status: 409, text: "etag mismatch" };
29
+ }
30
+ policy = { ...(body as { policy: typeof policy }).policy, etag: "e2" };
31
+ return { status: 200, text: JSON.stringify(policy) };
32
+ }
33
+ return { status: 404, text: "" };
34
+ };
35
+ return { http, calls, policy: () => policy, conflict: (n: number) => (conflicts = n) };
36
+ }
37
+
38
+ const ctx = { base: "https://iam.googleapis.com", project: "p1" };
39
+
40
+ // The region default reads GCP_REGION; pin it so the URLs below hold.
41
+ beforeEach(() => {
42
+ delete process.env.GCP_REGION;
43
+ });
44
+
45
+ describe("IAMPolicyMember targets (#4233)", () => {
46
+ test("a RunService takes its region from the manifest, and its host when there is no override", () => {
47
+ const manifest: GcpResource[] = [{ kind: "RunService", metadata: { name: "web" }, spec: { location: "europe-west1" } }];
48
+ const t = iamTarget(binding().spec as never, "p1", manifest)!;
49
+ expect(t.get(t.host)).toEqual({
50
+ method: "GET",
51
+ url: "https://run.googleapis.com/v2/projects/p1/locations/europe-west1/services/web:getIamPolicy?options.requestedPolicyVersion=3",
52
+ });
53
+ });
54
+
55
+ test("Project, IAMServiceAccount, StorageBucket, PubSub and secrets resolve; other kinds do not", () => {
56
+ const of = (resourceRef: Record<string, unknown>) => iamTarget({ member: "m", role: "r", resourceRef } as never, "p1");
57
+ expect(of({ kind: "Project", external: "projects/other" })!.path).toBe("projects/other");
58
+ expect(of({ kind: "IAMServiceAccount", name: "sa" })!.path).toBe("projects/p1/serviceAccounts/sa@p1.iam.gserviceaccount.com");
59
+ expect(of({ kind: "StorageBucket", name: "b" })!.set("https://storage.googleapis.com", { bindings: [] })).toEqual({
60
+ method: "PUT",
61
+ url: "https://storage.googleapis.com/storage/v1/b/b/iam",
62
+ body: { bindings: [] },
63
+ });
64
+ expect(of({ kind: "PubSubSubscription", name: "s" })!.path).toBe("projects/p1/subscriptions/s");
65
+ expect(of({ kind: "SecretManagerSecret", name: "k" })!.path).toBe("projects/p1/secrets/k");
66
+ expect(of({ kind: "CloudFunctionsFunction", name: "f" })).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ describe("policy merge (#4233)", () => {
71
+ const spec = { member: "allUsers", role: "roles/run.invoker" };
72
+
73
+ test("adds the member to its role and keeps every other binding", () => {
74
+ const next = withMember({ etag: "e1", bindings: [{ role: "roles/viewer", members: ["user:a"] }] }, spec);
75
+ expect(next).toEqual({
76
+ etag: "e1",
77
+ bindings: [
78
+ { role: "roles/viewer", members: ["user:a"] },
79
+ { role: "roles/run.invoker", members: ["allUsers"] },
80
+ ],
81
+ });
82
+ expect(policyHasMember(next, spec)).toBe(true);
83
+ });
84
+
85
+ test("a conditional binding is its own binding, at policy version 3", () => {
86
+ const cond = { ...spec, condition: { title: "t", expression: "request.time < timestamp('2030-01-01T00:00:00Z')" } };
87
+ const next = withMember({ bindings: [{ role: "roles/run.invoker", members: ["user:b"] }] }, cond);
88
+ expect(next.version).toBe(3);
89
+ expect(next.bindings).toHaveLength(2);
90
+ expect(policyHasMember(next, spec)).toBe(false);
91
+ });
92
+
93
+ test("removing the last member drops the binding", () => {
94
+ expect(withoutMember({ bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] }, spec).bindings).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe("IAMPolicyMember apply, delete and read (#4233)", () => {
99
+ test("apply reads, merges and writes the policy with its etag, then leaves it alone", async () => {
100
+ const server = policyServer();
101
+ const first = await applyResource(iamPolicyMemberMapper, binding(), ctx, server.http);
102
+ expect(first).toEqual({ kind: "IAMPolicyMember", name: "web-public", created: true, updated: false });
103
+ const set = server.calls.find((c) => c.url.endsWith(":setIamPolicy"))!;
104
+ expect(set.url).toBe("https://run.googleapis.com/v2/projects/p1/locations/us-central1/services/web:setIamPolicy");
105
+ expect((set.body as { policy: { etag: string; bindings: unknown[] } }).policy).toEqual({
106
+ etag: "e1",
107
+ bindings: [
108
+ { role: "roles/viewer", members: ["user:a@x.io"] },
109
+ { role: "roles/run.invoker", members: ["allUsers"] },
110
+ ],
111
+ });
112
+ const again = await applyResource(iamPolicyMemberMapper, binding(), ctx, server.http);
113
+ expect(again.created).toBe(false);
114
+ expect(server.calls.filter((c) => c.url.endsWith(":setIamPolicy"))).toHaveLength(1);
115
+ });
116
+
117
+ test("an etag conflict is read again and retried", async () => {
118
+ const server = policyServer();
119
+ server.conflict(1);
120
+ await applyResource(iamPolicyMemberMapper, binding(), ctx, server.http);
121
+ expect(server.calls.filter((c) => c.url.endsWith(":setIamPolicy"))).toHaveLength(2);
122
+ expect(policyHasMember(server.policy(), { member: "allUsers", role: "roles/run.invoker" })).toBe(true);
123
+ });
124
+
125
+ test("an endpoint override serves every target", async () => {
126
+ const server = policyServer();
127
+ await applyResource(iamPolicyMemberMapper, binding(), { base: "http://localhost:4588", project: "p1" }, server.http);
128
+ expect(server.calls.every((c) => c.url.startsWith("http://localhost:4588/v2/"))).toBe(true);
129
+ });
130
+
131
+ test("delete removes only this member", async () => {
132
+ const server = policyServer({ etag: "e1", bindings: [{ role: "roles/run.invoker", members: ["allUsers", "user:b"] }] });
133
+ expect((await deleteResource(iamPolicyMemberMapper, binding(), ctx, server.http)).deleted).toBe(true);
134
+ expect(server.policy().bindings).toEqual([{ role: "roles/run.invoker", members: ["user:b"] }]);
135
+ expect((await deleteResource(iamPolicyMemberMapper, binding(), ctx, server.http)).deleted).toBe(false);
136
+ });
137
+
138
+ test("the reader answers present when the policy grants the member, absent when not", async () => {
139
+ const server = policyServer({ bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] });
140
+ const props = binding().spec as Record<string, unknown>;
141
+ const body = await getResource({ project: "p1", http: server.http }, "IAMPolicyMember", "web-public", props);
142
+ expect(body).toMatchObject({ member: "allUsers", role: "roles/run.invoker", resource: "projects/p1/locations/us-central1/services/web" });
143
+
144
+ const empty = policyServer({ bindings: [] });
145
+ await expect(getResource({ project: "p1", http: empty.http }, "IAMPolicyMember", "web-public", props)).rejects.toMatchObject({ status: 404 });
146
+ });
147
+
148
+ test("a binding on a target chant cannot reach is not accepted", () => {
149
+ const fn = binding({ resourceRef: { kind: "CloudFunctionsFunction", name: "f" } });
150
+ expect(MAPPERS.IAMPolicyMember.accepts!(fn, ctx)).toBe(false);
151
+ });
152
+ });