@intentius/chant-lexicon-gcp 0.124.36 → 0.124.38

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,195 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { describeResources } from "./describe-resources";
3
+ import { CloudRunServiceComposite } from "./composites/cloud-run-service";
4
+ import {
5
+ BackendService,
6
+ CloudRunService,
7
+ ComputeAddress,
8
+ ForwardingRule,
9
+ IAMPolicyMember,
10
+ RegionNetworkEndpointGroup,
11
+ Subnetwork,
12
+ TargetHTTPProxy,
13
+ URLMap,
14
+ VPCNetwork,
15
+ } from "./generated";
16
+
17
+ /**
18
+ * #4251: behold's three-cloud gcp member, read back. Every resource here names
19
+ * another by `.name`, an attribute reference the serializer resolves to the
20
+ * target's metadata.name (#4245). The read path has to resolve it the same way
21
+ * before a mapper builds a URL from the declared props.
22
+ */
23
+ const fetchMock = vi.fn();
24
+
25
+ function reply(status: number, body: unknown) {
26
+ return { status, text: async () => JSON.stringify(body) };
27
+ }
28
+
29
+ function estate() {
30
+ const web = new CloudRunService({ metadata: { name: "three-cloud-site" }, location: "us-central1", template: {} } as never);
31
+ const vpc = new VPCNetwork({ metadata: { name: "three-cloud-vpc" }, autoCreateSubnetworks: false } as never);
32
+ const subnet = new Subnetwork({
33
+ metadata: { name: "three-cloud-subnet" },
34
+ region: "us-central1",
35
+ ipCidrRange: "10.8.0.0/24",
36
+ networkRef: { name: vpc.name },
37
+ } as never);
38
+ (web as unknown as { props: Record<string, unknown> }).props.template = {
39
+ containers: [{ image: "gcr.io/p/site:1" }],
40
+ vpcAccess: { egress: "ALL_TRAFFIC", networkInterfaces: [{ networkRef: { name: vpc.name }, subnetworkRef: { name: subnet.name } }] },
41
+ };
42
+ const neg = new RegionNetworkEndpointGroup({
43
+ metadata: { name: "three-cloud-neg" },
44
+ region: "us-central1",
45
+ networkEndpointType: "SERVERLESS",
46
+ cloudRun: { serviceRef: { name: web.name } },
47
+ } as never);
48
+ const backend = new BackendService({
49
+ metadata: { name: "three-cloud-backend" },
50
+ location: "global",
51
+ loadBalancingScheme: "EXTERNAL_MANAGED",
52
+ backend: [{ group: { networkEndpointGroupRef: { name: neg.name } } }],
53
+ } as never);
54
+ const urlMap = new URLMap({
55
+ metadata: { name: "three-cloud-urlmap" },
56
+ location: "global",
57
+ defaultService: { backendServiceRef: { name: backend.name } },
58
+ } as never);
59
+ const proxy = new TargetHTTPProxy({ metadata: { name: "three-cloud-proxy" }, location: "global", urlMapRef: { name: urlMap.name } } as never);
60
+ const address = new ComputeAddress({ metadata: { name: "three-cloud-ip" }, location: "global" } as never);
61
+ const rule = new ForwardingRule({
62
+ metadata: { name: "three-cloud-rule" },
63
+ location: "global",
64
+ loadBalancingScheme: "EXTERNAL_MANAGED",
65
+ portRange: "80",
66
+ target: { targetHTTPProxyRef: { name: proxy.name } },
67
+ ipAddress: { addressRef: { name: address.name } },
68
+ } as never);
69
+ const publicIam = new IAMPolicyMember({
70
+ metadata: { name: "three-cloud-site-public" },
71
+ member: "allUsers",
72
+ role: "roles/run.invoker",
73
+ resourceRef: { apiVersion: "run.cnrm.cloud.google.com/v1beta1", kind: "RunService", name: "three-cloud-site" },
74
+ } as never);
75
+ const all = { web, vpc, subnet, neg, backend, urlMap, proxy, address, rule, publicIam };
76
+ return new Map(
77
+ Object.entries(all).map(([logical, e]) => {
78
+ const entity = e as unknown as { entityType: string; props: Record<string, unknown> };
79
+ return [logical, { entityType: entity.entityType, props: entity.props }];
80
+ }),
81
+ );
82
+ }
83
+
84
+ describe("gcp live read resolves .name references as the build does (#4251)", () => {
85
+ beforeEach(() => {
86
+ fetchMock.mockReset();
87
+ vi.stubGlobal("fetch", fetchMock);
88
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
89
+ process.env.GOOGLE_CLOUD_PROJECT = "three-cloud";
90
+ fetchMock.mockImplementation(async (url: string) => {
91
+ if (url.includes(":getIamPolicy")) {
92
+ return reply(200, { etag: "e1", bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] });
93
+ }
94
+ return reply(200, { id: url, name: url.split("/").pop() });
95
+ });
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.unstubAllGlobals();
100
+ delete process.env.GCP_ENDPOINT_URL;
101
+ delete process.env.GOOGLE_CLOUD_PROJECT;
102
+ });
103
+
104
+ test("every resource reads, by the names the build writes, and none fails on a reference", async () => {
105
+ const entities = estate();
106
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
107
+
108
+ expect(out.unobserved ?? {}).toEqual({});
109
+ for (const logical of entities.keys()) {
110
+ expect(out.resources[logical]?.physicalId, logical).toBeTruthy();
111
+ }
112
+ const urls = fetchMock.mock.calls.map((c) => String(c[0]));
113
+ for (const name of ["three-cloud-neg", "three-cloud-backend", "three-cloud-urlmap", "three-cloud-proxy", "three-cloud-rule", "three-cloud-subnet", "three-cloud-site"]) {
114
+ expect(urls.some((u) => u.includes(name)), name).toBe(true);
115
+ }
116
+ expect(urls.every((u) => !u.includes("[object"))).toBe(true);
117
+ });
118
+
119
+ test("the public IAMPolicyMember reads as the binding in the service's policy, with a physical id", async () => {
120
+ const entities = estate();
121
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
122
+ expect(out.resources.publicIam?.physicalId).toBe(
123
+ "projects/three-cloud/locations/us-central1/services/three-cloud-site#roles/run.invoker#allUsers",
124
+ );
125
+ });
126
+
127
+ test("a binding missing from the policy reads as absent, not observed", async () => {
128
+ fetchMock.mockImplementation(async (url: string) =>
129
+ url.includes(":getIamPolicy") ? reply(200, { etag: "e1", bindings: [] }) : reply(200, { id: url }),
130
+ );
131
+ const entities = estate();
132
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
133
+ expect(out.resources.publicIam).toBeUndefined();
134
+ expect(out.unobserved?.publicIam).toBeUndefined();
135
+ });
136
+ });
137
+
138
+ /**
139
+ * #4251, the IAM binding behold saw as pending: the public IAMPolicyMember of a
140
+ * CloudRunServiceComposite whose service runs outside us-central1. The binding
141
+ * names its service by `name`, so the read has to take the service's region from
142
+ * the declared service, as apply does, or it asks a region where the service
143
+ * isn't, gets a 404 and reports the binding absent.
144
+ */
145
+ describe("the public binding of a service outside us-central1 reads as observed (#4251)", () => {
146
+ const policyUrl = "/v2/projects/three-cloud/locations/asia-northeast1/services/three-cloud-site:getIamPolicy";
147
+
148
+ function composite() {
149
+ const { service, publicIam } = CloudRunServiceComposite({
150
+ name: "three-cloud-site",
151
+ image: "gcr.io/p/site:1",
152
+ projectId: "three-cloud",
153
+ location: "asia-northeast1",
154
+ publicAccess: true,
155
+ }) as unknown as Record<string, { entityType: string; props: Record<string, unknown> }>;
156
+ return [
157
+ ["webService", { entityType: service.entityType, props: service.props }],
158
+ ["webPublicIam", { entityType: publicIam.entityType, props: publicIam.props }],
159
+ ] as Array<[string, { entityType: string; props: Record<string, unknown> }]>;
160
+ }
161
+
162
+ beforeEach(() => {
163
+ fetchMock.mockReset();
164
+ vi.stubGlobal("fetch", fetchMock);
165
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
166
+ process.env.GCP_PROJECT_ID = "three-cloud";
167
+ delete process.env.GCP_REGION;
168
+ fetchMock.mockImplementation(async (url: string) => {
169
+ if (url.includes(":getIamPolicy")) {
170
+ return url.includes(policyUrl)
171
+ ? reply(200, { bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] })
172
+ : reply(404, { error: { code: 404 } });
173
+ }
174
+ return reply(200, { name: url.split("?")[0].split("/").slice(-6).join("/") });
175
+ });
176
+ });
177
+
178
+ afterEach(() => {
179
+ vi.unstubAllGlobals();
180
+ delete process.env.GCP_ENDPOINT_URL;
181
+ delete process.env.GCP_PROJECT_ID;
182
+ });
183
+
184
+ for (const order of ["service first", "binding first"]) {
185
+ test(`webPublicIam is observed with a physical id, ${order}`, async () => {
186
+ const pairs = composite();
187
+ const entities = new Map(order === "service first" ? pairs : [...pairs].reverse());
188
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
189
+ expect(out.unobserved?.webPublicIam).toBeUndefined();
190
+ expect(out.resources.webPublicIam?.physicalId).toBe(
191
+ "projects/three-cloud/locations/asia-northeast1/services/three-cloud-site#roles/run.invoker#allUsers",
192
+ );
193
+ });
194
+ }
195
+ });
package/src/serializer.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { createRequire } from "module";
9
9
  import { moduleSourceUrl } from "@intentius/chant/module-source";
10
10
  import type { Declarable } from "@intentius/chant/declarable";
11
- import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
11
+ import { isDeclarable, isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
12
+ import { AttrRef } from "@intentius/chant/attrref";
12
13
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
13
14
  import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
14
15
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
@@ -113,11 +114,42 @@ function gcpVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
113
114
  * props, else its logical id in kebab case.
114
115
  */
115
116
  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;
117
+ return cnrmNameOf(logical, isResourceDeclarable(entity) ? entity.props : undefined);
118
+ }
119
+
120
+ /** {@link cnrmName} from a resource's props, for callers holding props rather than the declarable. */
121
+ export function cnrmNameOf(logical: string, props: unknown): string {
122
+ const declared = (props as { metadata?: { name?: unknown } } | undefined)?.metadata?.name;
118
123
  return typeof declared === "string" && declared.length > 0 ? declared : kebabName(logical);
119
124
  }
120
125
 
126
+ /**
127
+ * Declared props with each reference to another resource replaced as the
128
+ * serializer writes it (#4251): an attribute reference (`neg.name`) becomes the
129
+ * target's metadata.name, and a resource placed whole becomes `{ name }`.
130
+ * `logicalOf` maps a target's props object to its logical id, for a reference
131
+ * that does not carry one. Other values, pseudo-parameters included, are kept.
132
+ */
133
+ export function resolveCnrmRefs(value: unknown, logicalOf: (props: unknown) => string | undefined): unknown {
134
+ if (value instanceof AttrRef) {
135
+ const parent = value.parent.deref() as Declarable | undefined;
136
+ const props = parent && isResourceDeclarable(parent) ? parent.props : undefined;
137
+ const logical = value.getLogicalName() ?? logicalOf(props);
138
+ return logical === undefined ? value : cnrmNameOf(logical, props);
139
+ }
140
+ if (Array.isArray(value)) return value.map((v) => resolveCnrmRefs(v, logicalOf));
141
+ if (value === null || typeof value !== "object") return value;
142
+ if (isDeclarable(value)) {
143
+ const props = isResourceDeclarable(value) ? value.props : undefined;
144
+ const logical = logicalOf(props);
145
+ return logical === undefined ? value : { name: cnrmNameOf(logical, props) };
146
+ }
147
+ if (INTRINSIC_MARKER in (value as object)) return value;
148
+ const out: Record<string, unknown> = {};
149
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = resolveCnrmRefs(v, logicalOf);
150
+ return out;
151
+ }
152
+
121
153
  function kebabName(logical: string): string {
122
154
  return logical.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
123
155
  }
@@ -163,7 +195,11 @@ export const gcpSerializer: Serializer = {
163
195
  let defaultLabelEntries: Record<string, unknown> = context?.ownership
164
196
  ? { ...ownershipEntries(LABEL_OWNERSHIP_KEYS, context.ownership) }
165
197
  : {};
166
- let defaultAnnotationEntries: Record<string, unknown> = {};
198
+ // #4252: a member's project, once, in chant.config's `gcp.project`. It
199
+ // resolves GCP.ProjectId and is written as each resource's project-id
200
+ // annotation, which apply and the readers take ahead of the env vars.
201
+ const configProject = gcpConfigProject(context?.config);
202
+ let defaultAnnotationEntries: Record<string, unknown> = configProject ? { [PROJECT_ID_ANNOTATION]: configProject } : {};
167
203
 
168
204
  for (const [, entity] of entities) {
169
205
  if (isDefaultLabels(entity)) {
@@ -189,6 +225,7 @@ export const gcpSerializer: Serializer = {
189
225
  toYAMLValue(isResourceDeclarable(entity) ? entity.props : undefined, entityNames),
190
226
  name,
191
227
  unresolved,
228
+ configProject,
192
229
  ) as Record<string, unknown> | undefined;
193
230
 
194
231
  if (!props) continue;
@@ -246,6 +283,14 @@ export const gcpSerializer: Serializer = {
246
283
  },
247
284
  };
248
285
 
286
+ const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
287
+
288
+ /** The `gcp.project` a chant.config declares, if any (#4252). */
289
+ export function gcpConfigProject(config: Record<string, unknown> | undefined): string | undefined {
290
+ const project = (config?.gcp as { project?: unknown } | undefined)?.project;
291
+ return typeof project === "string" && project.length > 0 ? project : undefined;
292
+ }
293
+
249
294
  /**
250
295
  * Pseudo-parameter → environment variable mapping.
251
296
  * Config Connector annotations must be plain strings, so PseudoParameters
@@ -270,20 +315,20 @@ interface UnresolvedPseudo {
270
315
  * the API as an object. A variable that isn't set is recorded, and the
271
316
  * build refuses with every one named.
272
317
  */
273
- function resolvePseudoParameters(value: unknown, entity: string, unresolved: UnresolvedPseudo[]): unknown {
274
- if (Array.isArray(value)) return value.map((v) => resolvePseudoParameters(v, entity, unresolved));
318
+ function resolvePseudoParameters(value: unknown, entity: string, unresolved: UnresolvedPseudo[], project?: string): unknown {
319
+ if (Array.isArray(value)) return value.map((v) => resolvePseudoParameters(v, entity, unresolved, project));
275
320
  if (value === null || typeof value !== "object") return value;
276
321
  const obj = value as Record<string, unknown>;
277
322
  const keys = Object.keys(obj);
278
323
  if (keys.length === 1 && keys[0] === "Ref" && typeof obj.Ref === "string" && obj.Ref in PSEUDO_ENV_MAP) {
279
324
  const { envVar } = PSEUDO_ENV_MAP[obj.Ref];
280
- const resolved = process.env[envVar];
325
+ const resolved = (obj.Ref === "GCP::ProjectId" ? project : undefined) ?? process.env[envVar];
281
326
  if (resolved) return resolved;
282
327
  unresolved.push({ entity, ref: obj.Ref, envVar });
283
328
  return value;
284
329
  }
285
330
  const out: Record<string, unknown> = {};
286
- for (const [k, v] of Object.entries(obj)) out[k] = resolvePseudoParameters(v, entity, unresolved);
331
+ for (const [k, v] of Object.entries(obj)) out[k] = resolvePseudoParameters(v, entity, unresolved, project);
287
332
  return out;
288
333
  }
289
334