@intentius/chant-lexicon-gcp 0.124.35 → 0.124.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/api/read-client.d.ts +2 -1
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +2 -1
  4. package/dist/deep-observe.d.ts.map +1 -1
  5. package/dist/deep-observe.js +56 -11
  6. package/dist/describe-resources.d.ts +20 -6
  7. package/dist/describe-resources.d.ts.map +1 -1
  8. package/dist/describe-resources.js +40 -26
  9. package/dist/health.d.ts.map +1 -1
  10. package/dist/health.js +6 -9
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/gcp-apply.d.ts +14 -1
  14. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  15. package/dist/op/activities/gcp-apply.js +49 -8
  16. package/dist/op/activities/gcp-compute.d.ts +10 -0
  17. package/dist/op/activities/gcp-compute.d.ts.map +1 -1
  18. package/dist/op/activities/gcp-compute.js +39 -3
  19. package/dist/op/activities/gcp-iam.d.ts.map +1 -1
  20. package/dist/op/activities/gcp-iam.js +2 -0
  21. package/dist/serializer.d.ts +18 -0
  22. package/dist/serializer.d.ts.map +1 -1
  23. package/dist/serializer.js +72 -16
  24. package/package.json +2 -2
  25. package/src/api/read-client.ts +2 -1
  26. package/src/deep-observe.test.ts +27 -1
  27. package/src/deep-observe.ts +52 -12
  28. package/src/describe-resources.test.ts +6 -5
  29. package/src/describe-resources.ts +43 -24
  30. package/src/health.ts +7 -10
  31. package/src/op/activities/gcp-apply.test.ts +48 -3
  32. package/src/op/activities/gcp-apply.ts +49 -9
  33. package/src/op/activities/gcp-compute.test.ts +94 -0
  34. package/src/op/activities/gcp-compute.ts +37 -2
  35. package/src/op/activities/gcp-iam.ts +2 -0
  36. package/src/project-config.test.ts +119 -0
  37. package/src/read-refs.test.ts +135 -0
  38. package/src/serializer.test.ts +26 -0
  39. package/src/serializer.ts +73 -15
@@ -5,6 +5,7 @@ import {
5
5
  bucketInsertBody,
6
6
  pubSubTopicBody,
7
7
  cloudRunServiceBody,
8
+ networkInterface,
8
9
  serviceAccountEmail,
9
10
  resolveGcpProject,
10
11
  resolveGcpEndpoint,
@@ -122,7 +123,9 @@ describe("mappers build correct plans (#706)", () => {
122
123
  "ComputeAddress",
123
124
  "ComputeBackendService",
124
125
  "ComputeForwardingRule",
126
+ "ComputeNetwork",
125
127
  "ComputeRegionNetworkEndpointGroup",
128
+ "ComputeSubnetwork",
126
129
  "ComputeTargetHTTPProxy",
127
130
  "ComputeURLMap",
128
131
  "IAMPolicyMember",
@@ -164,6 +167,41 @@ describe("Cloud Run mapper + LRO (#706)", () => {
164
167
  expect(() => serviceAccountEmail({ name: "run-sa" })).toThrow(/needs a project/);
165
168
  });
166
169
 
170
+ test("cloudRunServiceBody sends vpcAccess refs as network and subnetwork paths (#4249)", () => {
171
+ const resource = {
172
+ ...RUN_SERVICE,
173
+ spec: {
174
+ ...(RUN_SERVICE.spec as object),
175
+ location: "europe-west1",
176
+ template: {
177
+ containers: [{ image: "i" }],
178
+ vpcAccess: {
179
+ egress: "PRIVATE_RANGES_ONLY",
180
+ networkInterfaces: [{ networkRef: { name: "vpc" }, subnetworkRef: { name: "apps" }, tags: ["run"] }],
181
+ },
182
+ },
183
+ },
184
+ };
185
+ expect(cloudRunServiceBody(resource, "p")).toEqual({
186
+ template: {
187
+ containers: [{ image: "i" }],
188
+ vpcAccess: {
189
+ egress: "PRIVATE_RANGES_ONLY",
190
+ networkInterfaces: [
191
+ { tags: ["run"], network: "projects/p/global/networks/vpc", subnetwork: "projects/p/regions/europe-west1/subnetworks/apps" },
192
+ ],
193
+ },
194
+ },
195
+ });
196
+ });
197
+
198
+ test("networkInterface: external as given, name needs a project", () => {
199
+ expect(networkInterface({ networkRef: { external: "projects/o/global/networks/shared" } }, "p", "r")).toEqual({
200
+ network: "projects/o/global/networks/shared",
201
+ });
202
+ expect(() => networkInterface({ subnetworkRef: { name: "apps" } }, undefined, "r")).toThrow(/needs a project/);
203
+ });
204
+
167
205
  test("RunService plan sends the service account email on create and update", () => {
168
206
  const resource = {
169
207
  ...RUN_SERVICE,
@@ -258,9 +296,16 @@ describe("Cloud Run mapper + LRO (#706)", () => {
258
296
  });
259
297
 
260
298
  describe("resolveGcpProject (#711)", () => {
261
- test("env wins over the annotation", () => {
262
- expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv))
263
- .toBe("env-project");
299
+ test("the annotation (where chant.config's gcp.project lands) wins over the env vars (#4252)", () => {
300
+ expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project", GCP_PROJECT_ID: "id-project" } as NodeJS.ProcessEnv))
301
+ .toBe("annotated-project");
302
+ });
303
+
304
+ test("without an annotation, GCP_PROJECT_ID wins over GOOGLE_CLOUD_PROJECT, and projectRef over both (#4252)", () => {
305
+ const bare = { metadata: { name: "b" } };
306
+ expect(resolveGcpProject(bare, { GOOGLE_CLOUD_PROJECT: "g", GCP_PROJECT_ID: "p" } as NodeJS.ProcessEnv)).toBe("p");
307
+ expect(resolveGcpProject(bare, { GOOGLE_CLOUD_PROJECT: "g" } as NodeJS.ProcessEnv)).toBe("g");
308
+ expect(resolveGcpProject({ ...bare, spec: { projectRef: { external: "own" } } }, { GCP_PROJECT_ID: "p" } as NodeJS.ProcessEnv)).toBe("own");
264
309
  });
265
310
 
266
311
  test("falls back to the CNRM annotation", () => {
@@ -8,6 +8,8 @@ import {
8
8
  backendServiceMapper,
9
9
  computeAddressMapper,
10
10
  forwardingRuleMapper,
11
+ networkMapper,
12
+ subnetworkMapper,
11
13
  regionNetworkEndpointGroupMapper,
12
14
  targetHttpProxyMapper,
13
15
  urlMapMapper,
@@ -292,10 +294,39 @@ export function cloudRunServiceBody(resource: GcpResource, project?: string): {
292
294
  if (!spec.template) return body;
293
295
  const { serviceAccountRef, ...template } = spec.template as { serviceAccountRef?: { name?: string; external?: string } };
294
296
  const email = serviceAccountEmail(serviceAccountRef, project);
295
- body.template = email ? { ...template, serviceAccount: email } : template;
297
+ const out: Record<string, unknown> = email ? { ...template, serviceAccount: email } : { ...template };
298
+ const vpcAccess = out.vpcAccess as { networkInterfaces?: unknown } | undefined;
299
+ if (vpcAccess && Array.isArray(vpcAccess.networkInterfaces)) {
300
+ const region = ((resource.spec ?? {}) as { location?: string }).location ?? "us-central1";
301
+ out.vpcAccess = { ...vpcAccess, networkInterfaces: vpcAccess.networkInterfaces.map((i) => networkInterface(i, project, region)) };
302
+ }
303
+ body.template = out;
296
304
  return body;
297
305
  }
298
306
 
307
+ type NameRef = { name?: string; external?: string };
308
+
309
+ /**
310
+ * A CNRM Direct VPC egress interface as Cloud Run v2 takes it (#4249):
311
+ * `networkRef` and `subnetworkRef` become `network` and `subnetwork` paths,
312
+ * `name` in the project (the subnetwork in the service's region) and
313
+ * `external` as given. `tags` and anything else pass through. Pure.
314
+ */
315
+ export function networkInterface(iface: unknown, project: string | undefined, region: string): unknown {
316
+ if (!iface || typeof iface !== "object") return iface;
317
+ const { networkRef, subnetworkRef, ...rest } = iface as { networkRef?: NameRef; subnetworkRef?: NameRef };
318
+ const path = (ref: NameRef | undefined, scoped: (p: string, n: string) => string, key: string): string | undefined => {
319
+ if (!ref) return undefined;
320
+ if (ref.external) return ref.external;
321
+ if (!ref.name) return undefined;
322
+ if (!project) throw new Error(`${key} { name: "${ref.name}" } needs a project to form its path`);
323
+ return scoped(project, ref.name);
324
+ };
325
+ const network = path(networkRef, (p, n) => `projects/${p}/global/networks/${n}`, "networkRef");
326
+ const subnetwork = path(subnetworkRef, (p, n) => `projects/${p}/regions/${region}/subnetworks/${n}`, "subnetworkRef");
327
+ return { ...rest, ...(network ? { network } : {}), ...(subnetwork ? { subnetwork } : {}) };
328
+ }
329
+
299
330
  /**
300
331
  * The email a CNRM `serviceAccountRef` names: `external` as given (an email,
301
332
  * or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
@@ -435,6 +466,8 @@ export const MAPPERS: Record<string, ResourceMapper> = {
435
466
  ComputeURLMap: urlMapMapper,
436
467
  ComputeTargetHTTPProxy: targetHttpProxyMapper,
437
468
  ComputeForwardingRule: forwardingRuleMapper,
469
+ ComputeNetwork: networkMapper,
470
+ ComputeSubnetwork: subnetworkMapper,
438
471
  };
439
472
 
440
473
  /**
@@ -461,6 +494,8 @@ const REF_KINDS: Record<string, string> = {
461
494
  backendServiceRef: "ComputeBackendService",
462
495
  urlMapRef: "ComputeURLMap",
463
496
  targetHTTPProxyRef: "ComputeTargetHTTPProxy",
497
+ networkRef: "ComputeNetwork",
498
+ subnetworkRef: "ComputeSubnetwork",
464
499
  };
465
500
 
466
501
  /** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
@@ -533,7 +568,6 @@ export function resolveGcpEndpoint(
533
568
  return args.endpoint || env.GCP_ENDPOINT_URL || undefined;
534
569
  }
535
570
 
536
- /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
537
571
  /**
538
572
  * The project a resource names in `spec.projectRef.external` (#4201): a
539
573
  * project id or `projects/<id>`. A `GCP.ProjectId` pseudo-parameter there
@@ -554,19 +588,25 @@ export function projectRefProject(spec: Record<string, unknown> | undefined, env
554
588
  return external.startsWith("projects/") ? external.slice("projects/".length).split("/")[0] : external;
555
589
  }
556
590
 
591
+ /**
592
+ * The project a resource is applied in (#4252): its own `spec.projectRef.external`,
593
+ * else its project-id annotation (where chant.config's `gcp.project` is
594
+ * written at build), else `GCP_PROJECT_ID`, else `GOOGLE_CLOUD_PROJECT`. Pure.
595
+ */
557
596
  export function resolveGcpProject(resource: GcpResource, env: NodeJS.ProcessEnv = process.env): string {
558
597
  const project =
559
598
  projectRefProject(resource.spec, env) ||
560
- env.GOOGLE_CLOUD_PROJECT ||
561
- resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
562
- if (!project) {
563
- throw new Error(
564
- `no GCP project — set spec.projectRef.external, GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
565
- );
566
- }
599
+ resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION] ||
600
+ env.GCP_PROJECT_ID ||
601
+ env.GOOGLE_CLOUD_PROJECT;
602
+ if (!project) throw new Error(NO_PROJECT);
567
603
  return project;
568
604
  }
569
605
 
606
+ /** The error every stage gives when no project resolves. */
607
+ export const NO_PROJECT =
608
+ `no GCP project — set gcp.project in chant.config, GCP_PROJECT_ID or GOOGLE_CLOUD_PROJECT, or the resource's spec.projectRef.external`;
609
+
570
610
  /** Injectable HTTP client — mirrors argo's injectable fetcher so tests avoid the network. */
571
611
  export type GcpHttp = (
572
612
  method: string,
@@ -210,3 +210,97 @@ describe("deep observe reads a load balancer back in the declared vocabulary (#4
210
210
  });
211
211
  });
212
212
  });
213
+
214
+ describe("network and subnetwork mappers (#4248)", () => {
215
+ const net: GcpResource[] = [
216
+ {
217
+ kind: "ComputeSubnetwork",
218
+ metadata: { name: "apps" },
219
+ spec: {
220
+ region: "europe-west1",
221
+ ipCidrRange: "10.0.0.0/24",
222
+ networkRef: { name: "vpc" },
223
+ privateIpGoogleAccess: true,
224
+ secondaryIpRange: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
225
+ },
226
+ },
227
+ { kind: "ComputeNetwork", metadata: { name: "vpc" }, spec: { routingMode: "REGIONAL" } },
228
+ {
229
+ kind: "RunService",
230
+ metadata: { name: "web" },
231
+ spec: { location: "europe-west1", template: { vpcAccess: { networkInterfaces: [{ networkRef: { name: "vpc" }, subnetworkRef: { name: "apps" } }] } } },
232
+ },
233
+ ];
234
+ const nctx = { base: "https://compute.googleapis.com", project: "p1", manifest: net };
235
+ const find = (kind: string) => net.find((r) => r.kind === kind)!;
236
+
237
+ test("a network is global, with autoCreateSubnetworks false unless declared and the routing mode nested", () => {
238
+ process.env.GCP_REGION = "us-east1";
239
+ const plan = MAPPERS.ComputeNetwork.plan(find("ComputeNetwork"), nctx);
240
+ expect(plan.create.url).toBe("https://compute.googleapis.com/compute/v1/projects/p1/global/networks");
241
+ expect(plan.getUrl).toBe("https://compute.googleapis.com/compute/v1/projects/p1/global/networks/vpc");
242
+ expect(plan.create.body).toEqual({ name: "vpc", autoCreateSubnetworks: false, routingConfig: { routingMode: "REGIONAL" } });
243
+ });
244
+
245
+ test("a subnetwork is regional and names its network by partial URL", () => {
246
+ const plan = MAPPERS.ComputeSubnetwork.plan(find("ComputeSubnetwork"), nctx);
247
+ expect(plan.create.url).toBe("https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/subnetworks");
248
+ expect(plan.create.body).toEqual({
249
+ name: "apps",
250
+ ipCidrRange: "10.0.0.0/24",
251
+ privateIpGoogleAccess: true,
252
+ network: "projects/p1/global/networks/vpc",
253
+ secondaryIpRanges: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
254
+ });
255
+ });
256
+
257
+ test("apply order: network, then subnetwork, then the Cloud Run service that egresses into them", () => {
258
+ const order = orderByReferences(net).map((r) => r.kind);
259
+ expect(order.indexOf("ComputeNetwork")).toBeLessThan(order.indexOf("ComputeSubnetwork"));
260
+ expect(order.indexOf("ComputeSubnetwork")).toBeLessThan(order.indexOf("RunService"));
261
+ });
262
+
263
+ test("the reader reads a network at its global URL", async () => {
264
+ const http = async (_m: string, url: string) =>
265
+ url.endsWith("/global/networks/vpc") ? { status: 200, text: JSON.stringify({ name: "vpc" }) } : { status: 404, text: "" };
266
+ expect(await getResource({ project: "p1", http }, "ComputeNetwork", "vpc", {})).toEqual({ name: "vpc" });
267
+ });
268
+
269
+ test("deep observe inverts both body builders", async () => {
270
+ const { toCnrmTree } = await import("../../deep-observe");
271
+ const c = { project: "p1" };
272
+ expect(
273
+ toCnrmTree(
274
+ "ComputeNetwork",
275
+ {
276
+ name: "vpc",
277
+ autoCreateSubnetworks: false,
278
+ routingConfig: { routingMode: "REGIONAL" },
279
+ subnetworks: ["https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/subnetworks/apps"],
280
+ },
281
+ c,
282
+ ),
283
+ ).toEqual({ metadata: { name: "vpc" }, autoCreateSubnetworks: false, routingMode: "REGIONAL" });
284
+ expect(
285
+ toCnrmTree(
286
+ "ComputeSubnetwork",
287
+ {
288
+ name: "apps",
289
+ region: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1",
290
+ network: "https://compute.googleapis.com/compute/v1/projects/p1/global/networks/vpc",
291
+ ipCidrRange: "10.0.0.0/24",
292
+ gatewayAddress: "10.0.0.1",
293
+ fingerprint: "abc",
294
+ secondaryIpRanges: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
295
+ },
296
+ c,
297
+ ),
298
+ ).toEqual({
299
+ metadata: { name: "apps" },
300
+ region: "europe-west1",
301
+ networkRef: { name: "vpc" },
302
+ ipCidrRange: "10.0.0.0/24",
303
+ secondaryIpRange: [{ rangeName: "pods", ipCidrRange: "10.4.0.0/14" }],
304
+ });
305
+ });
306
+ });
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Compute Engine mappers for the Cloud Run load balancer (#4233): ComputeAddress,
3
3
  * ComputeRegionNetworkEndpointGroup (serverless NEGs), ComputeBackendService,
4
- * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule.
4
+ * ComputeURLMap, ComputeTargetHTTPProxy and ComputeForwardingRule; and for a
5
+ * declared network (#4248): ComputeNetwork (always global) and
6
+ * ComputeSubnetwork (regional, by its CNRM `region`).
5
7
  *
6
8
  * Each maps a CNRM spec onto the compute v1 insert body. A resource is global
7
9
  * or regional by its CNRM `location` (`"global"` or a region; a NEG has
@@ -168,6 +170,36 @@ export function negBody(resource: GcpResource, ctx: ComputeCtx): Obj {
168
170
  return body;
169
171
  }
170
172
 
173
+ /**
174
+ * ComputeNetwork → networks.insert body. A network is global. CNRM's
175
+ * `autoCreateSubnetworks` defaults to false, where compute's default is true,
176
+ * so it is always sent. Pure.
177
+ */
178
+ export function networkBody(resource: GcpResource): Obj {
179
+ const spec = (resource.spec ?? {}) as Obj;
180
+ const body: Obj = {
181
+ name: computeName(resource),
182
+ autoCreateSubnetworks: spec.autoCreateSubnetworks === true,
183
+ ...pick(spec, ["description", "mtu", "networkFirewallPolicyEnforcementOrder"]),
184
+ };
185
+ if (typeof spec.routingMode === "string") body.routingConfig = { routingMode: spec.routingMode };
186
+ return body;
187
+ }
188
+
189
+ /** ComputeSubnetwork → subnetworks.insert body. CNRM's `secondaryIpRange` and `logConfig` become compute's `secondaryIpRanges` and an enabled `logConfig`. Pure. */
190
+ export function subnetworkBody(resource: GcpResource, ctx: ComputeCtx): Obj {
191
+ const spec = (resource.spec ?? {}) as Obj;
192
+ const scope = scopeOf(spec);
193
+ const body: Obj = {
194
+ name: computeName(resource),
195
+ ...pick(spec, ["ipCidrRange", "description", "privateIpGoogleAccess", "purpose", "role", "stackType"]),
196
+ };
197
+ if (isObj(spec.networkRef)) body.network = refUrl("networkRef", spec.networkRef as Ref, ctx, scope);
198
+ if (Array.isArray(spec.secondaryIpRange)) body.secondaryIpRanges = spec.secondaryIpRange;
199
+ if (isObj(spec.logConfig)) body.logConfig = { enable: true, ...spec.logConfig };
200
+ return body;
201
+ }
202
+
171
203
  /** ComputeBackendService → backendServices.insert body. Pure. */
172
204
  export function backendServiceBody(resource: GcpResource, ctx: ComputeCtx): Obj {
173
205
  const spec = (resource.spec ?? {}) as Obj;
@@ -297,13 +329,14 @@ function computeMapper(
297
329
  collection: string,
298
330
  build: (resource: GcpResource, ctx: ComputeCtx) => Obj,
299
331
  regionalCollection = collection,
332
+ scopeFor: (spec: Obj | undefined) => string = (spec) => scopeOf(spec),
300
333
  ): ResourceMapper {
301
334
  return {
302
335
  kind,
303
336
  defaultHost: COMPUTE_HOST,
304
337
  operation: computeOperation,
305
338
  plan(resource, ctx) {
306
- const scope = scopeOf(resource.spec as Obj | undefined);
339
+ const scope = scopeFor(resource.spec as Obj | undefined);
307
340
  const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
308
341
  const name = computeName(resource);
309
342
  return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
@@ -317,3 +350,5 @@ export const backendServiceMapper = computeMapper("ComputeBackendService", "back
317
350
  export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
318
351
  export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
319
352
  export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);
353
+ export const networkMapper = computeMapper("ComputeNetwork", "networks", networkBody, "networks", () => "global");
354
+ export const subnetworkMapper = computeMapper("ComputeSubnetwork", "subnetworks", subnetworkBody);
@@ -271,6 +271,8 @@ export const iamPolicyMemberMapper: ResourceMapper = {
271
271
  status: 200,
272
272
  // `resourceRef` is the declared one: the policy read is that target's, so the binding is on it.
273
273
  text: JSON.stringify({
274
+ // The binding has no id of its own; this one names it, so a read reports a physical id (#4251).
275
+ selfLink: `${target.path}#${spec.role}#${spec.member}`,
274
276
  member: spec.member,
275
277
  role: spec.role,
276
278
  resource: target.path,
@@ -0,0 +1,119 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { Declarable } from "@intentius/chant/declarable";
6
+ import { loadChantConfig } from "@intentius/chant/config";
7
+ import { armSandboxConfigEvaluation, resetSandboxConfigEvaluationForTests } from "@intentius/chant/config-sandbox";
8
+ import { gcpSerializer } from "./serializer";
9
+ import { manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
10
+ import { parseManifest, resolveGcpProject } from "./op/activities/gcp-apply";
11
+ import { CloudRunService, StorageBucket } from "./generated";
12
+ import { defaultAnnotations } from "./default-labels";
13
+ import { GCP } from "./pseudo";
14
+
15
+ /** #4252: one GCP project for build, apply and read, set once in chant.config. */
16
+
17
+ const ANNOTATION = "cnrm.cloud.google.com/project-id";
18
+
19
+ function build(entities: Record<string, unknown>, config?: Record<string, unknown>): string {
20
+ const out = gcpSerializer.serialize(new Map(Object.entries(entities)) as Map<string, Declarable>, undefined, config ? { config } : undefined);
21
+ return typeof out === "string" ? out : out.primary;
22
+ }
23
+
24
+ describe("gcp.project in chant.config (#4252)", () => {
25
+ let saved: NodeJS.ProcessEnv;
26
+ beforeEach(() => {
27
+ saved = { ...process.env };
28
+ process.env.GCP_PROJECT_ID = "env-id";
29
+ process.env.GOOGLE_CLOUD_PROJECT = "env-google";
30
+ });
31
+ afterEach(() => {
32
+ process.env = saved;
33
+ });
34
+
35
+ test("the build writes it as each resource's project-id annotation and resolves GCP.ProjectId from it", () => {
36
+ const yaml = build(
37
+ {
38
+ assets: new StorageBucket({ metadata: { name: "assets" }, location: "US" } as never),
39
+ web: new CloudRunService({ metadata: { name: "web" }, location: "us-central1", projectRef: { external: GCP.ProjectId } } as never),
40
+ },
41
+ { gcp: { project: "cfg-project" } },
42
+ );
43
+ const docs = parseManifest(yaml, "test");
44
+ expect(docs.find((d) => d.metadata?.name === "assets")?.metadata?.annotations?.[ANNOTATION]).toBe("cfg-project");
45
+ expect((docs.find((d) => d.metadata?.name === "web")?.spec as { projectRef: unknown }).projectRef).toEqual({ external: "cfg-project" });
46
+ });
47
+
48
+ test("without it the build writes no annotation, as before", () => {
49
+ const yaml = build({ assets: new StorageBucket({ metadata: { name: "assets" }, location: "US" } as never) });
50
+ expect(parseManifest(yaml, "test")[0].metadata?.annotations?.[ANNOTATION]).toBeUndefined();
51
+ });
52
+
53
+ test("a defaultAnnotations project wins over the config's", () => {
54
+ const yaml = build(
55
+ {
56
+ assets: new StorageBucket({ metadata: { name: "assets" }, location: "US" } as never),
57
+ annotations: defaultAnnotations({ [ANNOTATION]: "declared-project" }),
58
+ },
59
+ { gcp: { project: "cfg-project" } },
60
+ );
61
+ expect(parseManifest(yaml, "test")[0].metadata?.annotations?.[ANNOTATION]).toBe("declared-project");
62
+ });
63
+
64
+ test("apply and read both take it ahead of GCP_PROJECT_ID and GOOGLE_CLOUD_PROJECT, and a projectRef ahead of it", () => {
65
+ const yaml = build(
66
+ {
67
+ assets: new StorageBucket({ metadata: { name: "assets" }, location: "US" } as never),
68
+ other: new StorageBucket({ metadata: { name: "other" }, location: "US", projectRef: { external: "own-project" } } as never),
69
+ },
70
+ { gcp: { project: "cfg-project" } },
71
+ );
72
+ const docs = parseManifest(yaml, "test");
73
+ expect(resolveGcpProject(docs.find((d) => d.metadata?.name === "assets")!)).toBe("cfg-project");
74
+ expect(resolveGcpProject(docs.find((d) => d.metadata?.name === "other")!)).toBe("own-project");
75
+
76
+ const manifest = manifestProjectAnnotations(yaml);
77
+ expect(resolveReadProject("StorageBucket", "assets", undefined, manifest, { metadata: { name: "assets" } })).toBe("cfg-project");
78
+ expect(resolveReadProject("StorageBucket", "other", undefined, manifest, { projectRef: { external: "own-project" } })).toBe("own-project");
79
+ });
80
+
81
+ test("with nothing declared, read falls back to GCP_PROJECT_ID, then GOOGLE_CLOUD_PROJECT", () => {
82
+ const none = new Map<string, string>();
83
+ expect(resolveReadProject("StorageBucket", "b", undefined, none, {})).toBe("env-id");
84
+ delete process.env.GCP_PROJECT_ID;
85
+ expect(resolveReadProject("StorageBucket", "b", undefined, none, {})).toBe("env-google");
86
+ delete process.env.GOOGLE_CLOUD_PROJECT;
87
+ expect(() => resolveReadProject("StorageBucket", "b", undefined, none, {})).toThrow(/gcp\.project in chant\.config/);
88
+ });
89
+ });
90
+
91
+ describe("gcp.project under --sandbox (#4252)", () => {
92
+ let dir: string;
93
+ beforeEach(async () => {
94
+ const d = join(tmpdir(), `chant-4252-${Date.now()}-${Math.random()}`);
95
+ await mkdir(d, { recursive: true });
96
+ dir = await realpath(d);
97
+ resetSandboxConfigEvaluationForTests();
98
+ });
99
+ afterEach(async () => {
100
+ resetSandboxConfigEvaluationForTests();
101
+ await rm(dir, { recursive: true, force: true });
102
+ });
103
+
104
+ test("the config crosses from the sandboxed child as data, and the build reads it from there, not from process.env", async () => {
105
+ await writeFile(join(dir, "chant.config.ts"), `export default { lexicons: ["gcp"], gcp: { project: "sandboxed-project" } };\n`);
106
+ armSandboxConfigEvaluation();
107
+ const { config } = await loadChantConfig(dir);
108
+ expect((config as { gcp?: { project?: string } }).gcp?.project).toBe("sandboxed-project");
109
+
110
+ const saved = process.env.GCP_PROJECT_ID;
111
+ delete process.env.GCP_PROJECT_ID;
112
+ try {
113
+ const yaml = build({ assets: new StorageBucket({ metadata: { name: "assets" }, location: "US" } as never) }, config as Record<string, unknown>);
114
+ expect(parseManifest(yaml, "test")[0].metadata?.annotations?.[ANNOTATION]).toBe("sandboxed-project");
115
+ } finally {
116
+ if (saved !== undefined) process.env.GCP_PROJECT_ID = saved;
117
+ }
118
+ });
119
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { describeResources } from "./describe-resources";
3
+ import {
4
+ BackendService,
5
+ CloudRunService,
6
+ ComputeAddress,
7
+ ForwardingRule,
8
+ IAMPolicyMember,
9
+ RegionNetworkEndpointGroup,
10
+ Subnetwork,
11
+ TargetHTTPProxy,
12
+ URLMap,
13
+ VPCNetwork,
14
+ } from "./generated";
15
+
16
+ /**
17
+ * #4251: behold's three-cloud gcp member, read back. Every resource here names
18
+ * another by `.name`, an attribute reference the serializer resolves to the
19
+ * target's metadata.name (#4245). The read path has to resolve it the same way
20
+ * before a mapper builds a URL from the declared props.
21
+ */
22
+ const fetchMock = vi.fn();
23
+
24
+ function reply(status: number, body: unknown) {
25
+ return { status, text: async () => JSON.stringify(body) };
26
+ }
27
+
28
+ function estate() {
29
+ const web = new CloudRunService({ metadata: { name: "three-cloud-site" }, location: "us-central1", template: {} } as never);
30
+ const vpc = new VPCNetwork({ metadata: { name: "three-cloud-vpc" }, autoCreateSubnetworks: false } as never);
31
+ const subnet = new Subnetwork({
32
+ metadata: { name: "three-cloud-subnet" },
33
+ region: "us-central1",
34
+ ipCidrRange: "10.8.0.0/24",
35
+ networkRef: { name: vpc.name },
36
+ } as never);
37
+ (web as unknown as { props: Record<string, unknown> }).props.template = {
38
+ containers: [{ image: "gcr.io/p/site:1" }],
39
+ vpcAccess: { egress: "ALL_TRAFFIC", networkInterfaces: [{ networkRef: { name: vpc.name }, subnetworkRef: { name: subnet.name } }] },
40
+ };
41
+ const neg = new RegionNetworkEndpointGroup({
42
+ metadata: { name: "three-cloud-neg" },
43
+ region: "us-central1",
44
+ networkEndpointType: "SERVERLESS",
45
+ cloudRun: { serviceRef: { name: web.name } },
46
+ } as never);
47
+ const backend = new BackendService({
48
+ metadata: { name: "three-cloud-backend" },
49
+ location: "global",
50
+ loadBalancingScheme: "EXTERNAL_MANAGED",
51
+ backend: [{ group: { networkEndpointGroupRef: { name: neg.name } } }],
52
+ } as never);
53
+ const urlMap = new URLMap({
54
+ metadata: { name: "three-cloud-urlmap" },
55
+ location: "global",
56
+ defaultService: { backendServiceRef: { name: backend.name } },
57
+ } as never);
58
+ const proxy = new TargetHTTPProxy({ metadata: { name: "three-cloud-proxy" }, location: "global", urlMapRef: { name: urlMap.name } } as never);
59
+ const address = new ComputeAddress({ metadata: { name: "three-cloud-ip" }, location: "global" } as never);
60
+ const rule = new ForwardingRule({
61
+ metadata: { name: "three-cloud-rule" },
62
+ location: "global",
63
+ loadBalancingScheme: "EXTERNAL_MANAGED",
64
+ portRange: "80",
65
+ target: { targetHTTPProxyRef: { name: proxy.name } },
66
+ ipAddress: { addressRef: { name: address.name } },
67
+ } as never);
68
+ const publicIam = new IAMPolicyMember({
69
+ metadata: { name: "three-cloud-site-public" },
70
+ member: "allUsers",
71
+ role: "roles/run.invoker",
72
+ resourceRef: { apiVersion: "run.cnrm.cloud.google.com/v1beta1", kind: "RunService", name: "three-cloud-site" },
73
+ } as never);
74
+ const all = { web, vpc, subnet, neg, backend, urlMap, proxy, address, rule, publicIam };
75
+ return new Map(
76
+ Object.entries(all).map(([logical, e]) => {
77
+ const entity = e as unknown as { entityType: string; props: Record<string, unknown> };
78
+ return [logical, { entityType: entity.entityType, props: entity.props }];
79
+ }),
80
+ );
81
+ }
82
+
83
+ describe("gcp live read resolves .name references as the build does (#4251)", () => {
84
+ beforeEach(() => {
85
+ fetchMock.mockReset();
86
+ vi.stubGlobal("fetch", fetchMock);
87
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
88
+ process.env.GOOGLE_CLOUD_PROJECT = "three-cloud";
89
+ fetchMock.mockImplementation(async (url: string) => {
90
+ if (url.includes(":getIamPolicy")) {
91
+ return reply(200, { etag: "e1", bindings: [{ role: "roles/run.invoker", members: ["allUsers"] }] });
92
+ }
93
+ return reply(200, { id: url, name: url.split("/").pop() });
94
+ });
95
+ });
96
+
97
+ afterEach(() => {
98
+ vi.unstubAllGlobals();
99
+ delete process.env.GCP_ENDPOINT_URL;
100
+ delete process.env.GOOGLE_CLOUD_PROJECT;
101
+ });
102
+
103
+ test("every resource reads, by the names the build writes, and none fails on a reference", async () => {
104
+ const entities = estate();
105
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
106
+
107
+ expect(out.unobserved ?? {}).toEqual({});
108
+ for (const logical of entities.keys()) {
109
+ expect(out.resources[logical]?.physicalId, logical).toBeTruthy();
110
+ }
111
+ const urls = fetchMock.mock.calls.map((c) => String(c[0]));
112
+ 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"]) {
113
+ expect(urls.some((u) => u.includes(name)), name).toBe(true);
114
+ }
115
+ expect(urls.every((u) => !u.includes("[object"))).toBe(true);
116
+ });
117
+
118
+ test("the public IAMPolicyMember reads as the binding in the service's policy, with a physical id", async () => {
119
+ const entities = estate();
120
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
121
+ expect(out.resources.publicIam?.physicalId).toBe(
122
+ "projects/three-cloud/locations/us-central1/services/three-cloud-site#roles/run.invoker#allUsers",
123
+ );
124
+ });
125
+
126
+ test("a binding missing from the policy reads as absent, not observed", async () => {
127
+ fetchMock.mockImplementation(async (url: string) =>
128
+ url.includes(":getIamPolicy") ? reply(200, { etag: "e1", bindings: [] }) : reply(200, { id: url }),
129
+ );
130
+ const entities = estate();
131
+ const out = await describeResources({ environment: "local", buildOutput: "", entityNames: [...entities.keys()], entities });
132
+ expect(out.resources.publicIam).toBeUndefined();
133
+ expect(out.unobserved?.publicIam).toBeUndefined();
134
+ });
135
+ });
@@ -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
+ });