@intentius/chant-lexicon-gcp 0.124.34 → 0.124.36
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/dist/api/read-client.d.ts +8 -3
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +37 -15
- package/dist/composites/cloud-run-service.d.ts +2 -0
- package/dist/composites/cloud-run-service.d.ts.map +1 -1
- package/dist/composites/cloud-run-service.js +4 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +176 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +62 -18
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +128 -16
- package/dist/op/activities/gcp-compute.d.ts +66 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -0
- package/dist/op/activities/gcp-compute.js +349 -0
- package/dist/op/activities/gcp-iam.d.ts +82 -0
- package/dist/op/activities/gcp-iam.d.ts.map +1 -0
- package/dist/op/activities/gcp-iam.js +215 -0
- package/dist/serializer.d.ts +6 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +21 -9
- package/package.json +2 -2
- package/src/api/read-client.ts +36 -16
- package/src/composites/cloud-run-service.ts +6 -0
- package/src/composites/composites.test.ts +7 -0
- package/src/deep-observe.test.ts +42 -1
- package/src/deep-observe.ts +171 -0
- package/src/describe-resources.test.ts +1 -1
- package/src/op/activities/gcp-apply.test.ts +75 -0
- package/src/op/activities/gcp-apply.ts +167 -22
- package/src/op/activities/gcp-compute.test.ts +306 -0
- package/src/op/activities/gcp-compute.ts +354 -0
- package/src/op/activities/gcp-iam.test.ts +152 -0
- package/src/op/activities/gcp-iam.ts +284 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +22 -9
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IAMPolicyMember (#4233): one member in one role on one resource's IAM policy.
|
|
3
|
+
*
|
|
4
|
+
* GCP has no REST resource for a single binding member. The member lives inside
|
|
5
|
+
* the target's policy, read with `getIamPolicy` and written whole with
|
|
6
|
+
* `setIamPolicy`. So this mapper does not fit the GET-then-create plan the other
|
|
7
|
+
* kinds use: apply reads the policy, adds the member to the role's binding when
|
|
8
|
+
* it is missing and writes the policy back with its `etag`; delete removes it
|
|
9
|
+
* the same way. Other members and bindings are kept as read (a merge, never an
|
|
10
|
+
* overwrite), and a write that loses an `etag` race is retried.
|
|
11
|
+
*
|
|
12
|
+
* Targets are the kinds `resourceRef` can name that have an IAM policy chant can
|
|
13
|
+
* reach: Project, RunService, IAMServiceAccount, StorageBucket, PubSubTopic,
|
|
14
|
+
* PubSubSubscription and SecretManagerSecret. Any other target is reported as
|
|
15
|
+
* not attempted, the way a kind with no mapper is.
|
|
16
|
+
*/
|
|
17
|
+
import type { GcpHttp, GcpResource, ResourceMapper } from "./gcp-apply";
|
|
18
|
+
|
|
19
|
+
/** The CNRM IAMPolicyMember spec chant reads. */
|
|
20
|
+
export interface IamPolicyMemberSpec {
|
|
21
|
+
member?: string;
|
|
22
|
+
role?: string;
|
|
23
|
+
resourceRef?: { kind?: string; name?: string; external?: string; apiVersion?: string };
|
|
24
|
+
condition?: { title?: string; description?: string; expression?: string };
|
|
25
|
+
location?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Binding {
|
|
29
|
+
role: string;
|
|
30
|
+
members?: string[];
|
|
31
|
+
condition?: { title?: string; description?: string; expression?: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Policy {
|
|
35
|
+
version?: number;
|
|
36
|
+
etag?: string;
|
|
37
|
+
bindings?: Binding[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Where a target's policy is read and written. */
|
|
41
|
+
export interface IamTarget {
|
|
42
|
+
/** Real-GCP host for the target's service. */
|
|
43
|
+
host: string;
|
|
44
|
+
/** The target's resource path, used in messages and in the read body. */
|
|
45
|
+
path: string;
|
|
46
|
+
get: (base: string) => { method: "GET" | "POST"; url: string; body?: unknown };
|
|
47
|
+
set: (base: string, policy: Policy) => { method: "POST" | "PUT"; url: string; body: unknown };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The default host this mapper reports; a target's own host replaces it when there is no endpoint override. */
|
|
51
|
+
export const IAM_DEFAULT_HOST = "https://iam.googleapis.com";
|
|
52
|
+
|
|
53
|
+
const enc = encodeURIComponent;
|
|
54
|
+
|
|
55
|
+
function region(env: NodeJS.ProcessEnv = process.env): string {
|
|
56
|
+
return env.GCP_REGION || "us-central1";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A `:getIamPolicy` / `:setIamPolicy` pair on a REST resource path (the Google API convention). */
|
|
60
|
+
function verbTarget(host: string, version: string, path: string, getMethod: "GET" | "POST"): IamTarget {
|
|
61
|
+
return {
|
|
62
|
+
host,
|
|
63
|
+
path,
|
|
64
|
+
get: (base) =>
|
|
65
|
+
getMethod === "GET"
|
|
66
|
+
? { method: "GET", url: `${base}/${version}/${path}:getIamPolicy?options.requestedPolicyVersion=3` }
|
|
67
|
+
: { method: "POST", url: `${base}/${version}/${path}:getIamPolicy`, body: { options: { requestedPolicyVersion: 3 } } },
|
|
68
|
+
set: (base, policy) => ({ method: "POST", url: `${base}/${version}/${path}:setIamPolicy`, body: { policy } }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `resourceRef` to the target's policy endpoints, or undefined for a
|
|
74
|
+
* target chant cannot reach. `manifest` is the rest of the applied manifest, so
|
|
75
|
+
* a RunService named by `name` takes its region from its own spec. Pure.
|
|
76
|
+
*/
|
|
77
|
+
export function iamTarget(
|
|
78
|
+
spec: IamPolicyMemberSpec,
|
|
79
|
+
project: string,
|
|
80
|
+
manifest: readonly GcpResource[] = [],
|
|
81
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
82
|
+
): IamTarget | undefined {
|
|
83
|
+
const ref = spec.resourceRef ?? {};
|
|
84
|
+
const external = typeof ref.external === "string" && ref.external !== "" ? ref.external : undefined;
|
|
85
|
+
const name = typeof ref.name === "string" && ref.name !== "" ? ref.name : undefined;
|
|
86
|
+
switch (ref.kind) {
|
|
87
|
+
case "Project": {
|
|
88
|
+
const id = external ? external.replace(/^projects\//, "") : (name ?? project);
|
|
89
|
+
return verbTarget("https://cloudresourcemanager.googleapis.com", "v1", `projects/${enc(id)}`, "POST");
|
|
90
|
+
}
|
|
91
|
+
case "RunService": {
|
|
92
|
+
if (external) return verbTarget("https://run.googleapis.com", "v2", external, "GET");
|
|
93
|
+
if (!name) return undefined;
|
|
94
|
+
const declared = manifest.find((r) => r.kind === "RunService" && r.metadata?.name === name);
|
|
95
|
+
const location = (declared?.spec as { location?: unknown } | undefined)?.location;
|
|
96
|
+
const loc = typeof location === "string" ? location : (spec.location ?? region(env));
|
|
97
|
+
return verbTarget("https://run.googleapis.com", "v2", `projects/${enc(project)}/locations/${enc(loc)}/services/${enc(name)}`, "GET");
|
|
98
|
+
}
|
|
99
|
+
case "IAMServiceAccount": {
|
|
100
|
+
if (external?.startsWith("projects/")) return verbTarget(IAM_DEFAULT_HOST, "v1", external, "POST");
|
|
101
|
+
const email = external ?? (name ? `${name}@${project}.iam.gserviceaccount.com` : undefined);
|
|
102
|
+
if (!email) return undefined;
|
|
103
|
+
return verbTarget(IAM_DEFAULT_HOST, "v1", `projects/${enc(project)}/serviceAccounts/${email}`, "POST");
|
|
104
|
+
}
|
|
105
|
+
case "PubSubTopic":
|
|
106
|
+
case "PubSubSubscription": {
|
|
107
|
+
const collection = ref.kind === "PubSubTopic" ? "topics" : "subscriptions";
|
|
108
|
+
const path = external ?? (name ? `projects/${enc(project)}/${collection}/${enc(name)}` : undefined);
|
|
109
|
+
return path ? verbTarget("https://pubsub.googleapis.com", "v1", path, "GET") : undefined;
|
|
110
|
+
}
|
|
111
|
+
case "SecretManagerSecret": {
|
|
112
|
+
const path = external ?? (name ? `projects/${enc(project)}/secrets/${enc(name)}` : undefined);
|
|
113
|
+
return path ? verbTarget("https://secretmanager.googleapis.com", "v1", path, "GET") : undefined;
|
|
114
|
+
}
|
|
115
|
+
case "StorageBucket": {
|
|
116
|
+
const bucket = external ? external.replace(/^(gs:\/\/|projects\/_\/buckets\/)/, "") : name;
|
|
117
|
+
if (!bucket) return undefined;
|
|
118
|
+
// GCS has its own IAM shape: GET and PUT on `b/<bucket>/iam`, with the policy as the body.
|
|
119
|
+
const url = (base: string) => `${base}/storage/v1/b/${enc(bucket)}/iam`;
|
|
120
|
+
return {
|
|
121
|
+
host: "https://storage.googleapis.com",
|
|
122
|
+
path: `b/${bucket}`,
|
|
123
|
+
get: (base) => ({ method: "GET", url: `${url(base)}?optionsRequestedPolicyVersion=3` }),
|
|
124
|
+
set: (base, policy) => ({ method: "PUT", url: url(base), body: policy }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sameCondition(a: Binding["condition"], b: IamPolicyMemberSpec["condition"]): boolean {
|
|
133
|
+
if (!a && !b) return true;
|
|
134
|
+
if (!a || !b) return false;
|
|
135
|
+
return (a.expression ?? "") === (b.expression ?? "") && (a.title ?? "") === (b.title ?? "");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findBinding(policy: Policy, spec: IamPolicyMemberSpec): Binding | undefined {
|
|
139
|
+
return policy.bindings?.find((b) => b.role === spec.role && sameCondition(b.condition, spec.condition));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when the policy grants the spec's member in its role (and condition). Pure. */
|
|
143
|
+
export function policyHasMember(policy: Policy, spec: IamPolicyMemberSpec): boolean {
|
|
144
|
+
return findBinding(policy, spec)?.members?.includes(spec.member ?? "") ?? false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The policy with the spec's member added to its binding, every other binding kept. Pure. */
|
|
148
|
+
export function withMember(policy: Policy, spec: IamPolicyMemberSpec): Policy {
|
|
149
|
+
const member = spec.member as string;
|
|
150
|
+
const bindings = (policy.bindings ?? []).map((b) => ({ ...b, members: [...(b.members ?? [])] }));
|
|
151
|
+
const existing = bindings.find((b) => b.role === spec.role && sameCondition(b.condition, spec.condition));
|
|
152
|
+
if (existing) {
|
|
153
|
+
if (!existing.members.includes(member)) existing.members.push(member);
|
|
154
|
+
} else {
|
|
155
|
+
bindings.push({ role: spec.role as string, members: [member], ...(spec.condition ? { condition: spec.condition } : {}) });
|
|
156
|
+
}
|
|
157
|
+
// A conditional binding needs policy version 3; otherwise keep what was read.
|
|
158
|
+
const version = spec.condition || bindings.some((b) => b.condition) ? 3 : policy.version;
|
|
159
|
+
return { ...policy, bindings, ...(version !== undefined ? { version } : {}) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The policy with the spec's member removed; a binding left with no members is dropped. Pure. */
|
|
163
|
+
export function withoutMember(policy: Policy, spec: IamPolicyMemberSpec): Policy {
|
|
164
|
+
const bindings = (policy.bindings ?? [])
|
|
165
|
+
.map((b) =>
|
|
166
|
+
b.role === spec.role && sameCondition(b.condition, spec.condition)
|
|
167
|
+
? { ...b, members: (b.members ?? []).filter((m) => m !== spec.member) }
|
|
168
|
+
: b,
|
|
169
|
+
)
|
|
170
|
+
.filter((b) => (b.members ?? []).length > 0);
|
|
171
|
+
return { ...policy, bindings };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function specOf(resource: GcpResource): IamPolicyMemberSpec {
|
|
175
|
+
const spec = (resource.spec ?? {}) as IamPolicyMemberSpec;
|
|
176
|
+
const name = resource.metadata?.name ?? "?";
|
|
177
|
+
if (!spec.member) throw new Error(`IAMPolicyMember ${name} has no spec.member`);
|
|
178
|
+
if (!spec.role) throw new Error(`IAMPolicyMember ${name} has no spec.role`);
|
|
179
|
+
return spec;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function targetOf(resource: GcpResource, ctx: IamCtx, env?: NodeJS.ProcessEnv): { target: IamTarget; base: string } {
|
|
183
|
+
const target = iamTarget(specOf(resource), ctx.project, ctx.manifest, env);
|
|
184
|
+
if (!target) {
|
|
185
|
+
const ref = (resource.spec as IamPolicyMemberSpec | undefined)?.resourceRef;
|
|
186
|
+
throw new Error(`IAMPolicyMember ${resource.metadata?.name ?? "?"}: no IAM policy endpoint for resourceRef kind ${ref?.kind ?? "(none)"}`);
|
|
187
|
+
}
|
|
188
|
+
// No endpoint override: each target's own service host. An override (floci-gcp) serves every service.
|
|
189
|
+
const base = ctx.base === IAM_DEFAULT_HOST ? target.host : ctx.base;
|
|
190
|
+
return { target, base };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface IamCtx {
|
|
194
|
+
base: string;
|
|
195
|
+
project: string;
|
|
196
|
+
manifest?: readonly GcpResource[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function readPolicy(target: IamTarget, base: string, http: GcpHttp, signal?: AbortSignal): Promise<{ status: number; text: string; policy?: Policy }> {
|
|
200
|
+
const req = target.get(base);
|
|
201
|
+
const res = await http(req.method, req.url, req.body, signal);
|
|
202
|
+
if (res.status >= 300) return res;
|
|
203
|
+
let policy: Policy;
|
|
204
|
+
try {
|
|
205
|
+
policy = JSON.parse(res.text) as Policy;
|
|
206
|
+
} catch {
|
|
207
|
+
policy = {};
|
|
208
|
+
}
|
|
209
|
+
return { ...res, policy };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** How many times a write that loses an `etag` race (409, or 412 on GCS) is retried. */
|
|
213
|
+
const ETAG_ATTEMPTS = 3;
|
|
214
|
+
|
|
215
|
+
async function writeWith(
|
|
216
|
+
resource: GcpResource,
|
|
217
|
+
ctx: IamCtx,
|
|
218
|
+
http: GcpHttp,
|
|
219
|
+
signal: AbortSignal | undefined,
|
|
220
|
+
change: (policy: Policy, spec: IamPolicyMemberSpec) => Policy | undefined,
|
|
221
|
+
): Promise<boolean> {
|
|
222
|
+
const spec = specOf(resource);
|
|
223
|
+
const { target, base } = targetOf(resource, ctx);
|
|
224
|
+
const name = resource.metadata?.name ?? "?";
|
|
225
|
+
for (let attempt = 1; ; attempt++) {
|
|
226
|
+
const read = await readPolicy(target, base, http, signal);
|
|
227
|
+
if (!read.policy) throw new Error(`IAMPolicyMember ${name}: reading the policy of ${target.path} failed (${read.status}): ${read.text}`);
|
|
228
|
+
const next = change(read.policy, spec);
|
|
229
|
+
if (!next) return false;
|
|
230
|
+
const req = target.set(base, next);
|
|
231
|
+
const res = await http(req.method, req.url, req.body, signal);
|
|
232
|
+
if (res.status < 300) return true;
|
|
233
|
+
if ((res.status === 409 || res.status === 412) && attempt < ETAG_ATTEMPTS) continue;
|
|
234
|
+
throw new Error(`IAMPolicyMember ${name}: writing the policy of ${target.path} failed (${res.status}): ${res.text}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const iamPolicyMemberMapper: ResourceMapper = {
|
|
239
|
+
kind: "IAMPolicyMember",
|
|
240
|
+
defaultHost: IAM_DEFAULT_HOST,
|
|
241
|
+
accepts(resource, ctx) {
|
|
242
|
+
return iamTarget((resource.spec ?? {}) as IamPolicyMemberSpec, ctx.project, ctx.manifest) !== undefined;
|
|
243
|
+
},
|
|
244
|
+
plan(resource, ctx) {
|
|
245
|
+
// Only the read is a single URL here; apply and delete go through `custom`.
|
|
246
|
+
const { target, base } = targetOf(resource, ctx);
|
|
247
|
+
const get = target.get(base);
|
|
248
|
+
return { getUrl: get.url, create: { method: "POST", url: get.url, body: get.body ?? {} } };
|
|
249
|
+
},
|
|
250
|
+
custom: {
|
|
251
|
+
async apply(resource, ctx, http, signal) {
|
|
252
|
+
const created = await writeWith(resource, ctx, http, signal, (policy, spec) =>
|
|
253
|
+
policyHasMember(policy, spec) ? undefined : withMember(policy, spec),
|
|
254
|
+
);
|
|
255
|
+
return { created, updated: false };
|
|
256
|
+
},
|
|
257
|
+
async delete(resource, ctx, http, signal) {
|
|
258
|
+
return writeWith(resource, ctx, http, signal, (policy, spec) =>
|
|
259
|
+
policyHasMember(policy, spec) ? withoutMember(policy, spec) : undefined,
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
async read(resource, ctx, http, signal) {
|
|
263
|
+
const spec = specOf(resource);
|
|
264
|
+
const { target, base } = targetOf(resource, ctx);
|
|
265
|
+
const read = await readPolicy(target, base, http, signal);
|
|
266
|
+
if (!read.policy) return read;
|
|
267
|
+
// The binding member is the resource: absent from the policy reads as absent.
|
|
268
|
+
if (!policyHasMember(read.policy, spec)) return { status: 404, text: `${spec.member} has no ${spec.role} on ${target.path}` };
|
|
269
|
+
const binding = findBinding(read.policy, spec);
|
|
270
|
+
return {
|
|
271
|
+
status: 200,
|
|
272
|
+
// `resourceRef` is the declared one: the policy read is that target's, so the binding is on it.
|
|
273
|
+
text: JSON.stringify({
|
|
274
|
+
member: spec.member,
|
|
275
|
+
role: spec.role,
|
|
276
|
+
resource: target.path,
|
|
277
|
+
...(spec.resourceRef ? { resourceRef: spec.resourceRef } : {}),
|
|
278
|
+
...(binding?.condition ? { condition: binding.condition } : {}),
|
|
279
|
+
...(read.policy.etag ? { etag: read.policy.etag } : {}),
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
};
|
package/src/serializer.test.ts
CHANGED
|
@@ -293,3 +293,29 @@ describe("gcpSerializer", () => {
|
|
|
293
293
|
expect(result).toContain("ComputeInstance");
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
describe("a .name reference serializes as the target's metadata.name (#4242)", () => {
|
|
298
|
+
test("a declared metadata.name wins; without one, the kebab-cased logical id", async () => {
|
|
299
|
+
const { RegionNetworkEndpointGroup, BackendService, CloudRunService } = await import("./generated");
|
|
300
|
+
const web = new CloudRunService({ location: "us-central1", template: { containers: [{ image: "i" }] } } as any);
|
|
301
|
+
(web as any).name._setLogicalName("webService");
|
|
302
|
+
const neg = new RegionNetworkEndpointGroup({
|
|
303
|
+
metadata: { name: "three-cloud-neg" },
|
|
304
|
+
region: "us-central1",
|
|
305
|
+
networkEndpointType: "SERVERLESS",
|
|
306
|
+
cloudRun: { serviceRef: { name: (web as any).name } },
|
|
307
|
+
} as any);
|
|
308
|
+
(neg as any).name._setLogicalName("neg");
|
|
309
|
+
const backend = new BackendService({
|
|
310
|
+
metadata: { name: "three-cloud-backend" },
|
|
311
|
+
location: "global",
|
|
312
|
+
backend: [{ group: { networkEndpointGroupRef: { name: (neg as any).name } } }],
|
|
313
|
+
} as any);
|
|
314
|
+
const entities = new Map<string, any>([["webService", web], ["neg", neg], ["backend", backend]]);
|
|
315
|
+
const yaml = String(gcpSerializer.serialize(entities));
|
|
316
|
+
expect(yaml).toContain("name: three-cloud-neg");
|
|
317
|
+
expect(yaml).not.toMatch(/networkEndpointGroupRef:\s*\n\s*name: neg\b/);
|
|
318
|
+
expect(yaml).toMatch(/serviceRef:\s*\n\s*name: web-service/);
|
|
319
|
+
expect(yaml).toMatch(/networkEndpointGroupRef:\s*\n\s*name: three-cloud-neg/);
|
|
320
|
+
});
|
|
321
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -84,15 +84,14 @@ function deriveGVKFromType(entityType: string): { apiVersion: string; kind: stri
|
|
|
84
84
|
* GCP visitor for the generic serializer walker.
|
|
85
85
|
*/
|
|
86
86
|
function gcpVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
87
|
+
// Config Connector resolves a `*Ref: { name }` by the target's metadata.name,
|
|
88
|
+
// which is its declared name or else the kebab-cased logical id (#4242).
|
|
89
|
+
const cnrmNames = new Map<string, string>();
|
|
90
|
+
for (const [entity, logical] of entityNames) cnrmNames.set(logical, cnrmName(logical, entity));
|
|
91
|
+
const resolve = (logical: string) => cnrmNames.get(logical) ?? logical;
|
|
87
92
|
return {
|
|
88
|
-
attrRef: (name, _attr) =>
|
|
89
|
-
|
|
90
|
-
return name;
|
|
91
|
-
},
|
|
92
|
-
resourceRef: (name) => {
|
|
93
|
-
// Config Connector uses resourceRef pattern: { name: "resource-name" }
|
|
94
|
-
return { name };
|
|
95
|
-
},
|
|
93
|
+
attrRef: (name, _attr) => resolve(name),
|
|
94
|
+
resourceRef: (name) => ({ name: resolve(name) }),
|
|
96
95
|
propertyDeclarable: (entity, walk) => {
|
|
97
96
|
if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
98
97
|
return undefined;
|
|
@@ -109,6 +108,20 @@ function gcpVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
|
109
108
|
};
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The metadata.name a resource is written with: a string `metadata.name` in its
|
|
113
|
+
* props, else its logical id in kebab case.
|
|
114
|
+
*/
|
|
115
|
+
export function cnrmName(logical: string, entity: Declarable): string {
|
|
116
|
+
const props = isResourceDeclarable(entity) ? (entity.props as { metadata?: { name?: unknown } } | undefined) : undefined;
|
|
117
|
+
const declared = props?.metadata?.name;
|
|
118
|
+
return typeof declared === "string" && declared.length > 0 ? declared : kebabName(logical);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function kebabName(logical: string): string {
|
|
122
|
+
return logical.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
/**
|
|
113
126
|
* Convert a value to YAML-compatible form using the walker.
|
|
114
127
|
*/
|
|
@@ -189,7 +202,7 @@ export const gcpSerializer: Serializer = {
|
|
|
189
202
|
// Build metadata
|
|
190
203
|
const metadata: Record<string, unknown> = props.metadata as Record<string, unknown> ?? {};
|
|
191
204
|
if (!metadata.name) {
|
|
192
|
-
metadata.name = name
|
|
205
|
+
metadata.name = kebabName(name);
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
// Merge default labels
|