@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.124.36",
3
+ "version": "0.124.38",
4
4
  "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,6 +76,6 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@intentius/chant": "^0.124.36"
79
+ "@intentius/chant": "^0.124.38"
80
80
  }
81
81
  }
@@ -47,6 +47,12 @@ export interface GcpReadClientOptions {
47
47
  project: string;
48
48
  /** Injectable HTTP, so tests never touch the network. */
49
49
  http?: GcpReadHttp;
50
+ /**
51
+ * The declared resources, refs resolved, as apply passes them (#4251): an
52
+ * IAMPolicyMember on a RunService named by `name` takes the service's region
53
+ * from its declaration, which a read otherwise can't know.
54
+ */
55
+ manifest?: readonly GcpResource[];
50
56
  }
51
57
 
52
58
  /** Injectable HTTP client — mirrors the applier's `GcpHttp`. A body is sent only for a POST read (`:getIamPolicy`). */
@@ -108,7 +114,7 @@ export async function getResource(
108
114
  spec: (declared?.spec as Record<string, unknown> | undefined) ?? declared ?? {},
109
115
  };
110
116
 
111
- const ctx = { base, project: client.project };
117
+ const ctx = { base, project: client.project, ...(client.manifest ? { manifest: client.manifest } : {}) };
112
118
  if (mapper.accepts && !mapper.accepts(resource, ctx)) {
113
119
  throw new GcpReadError(`no REST read for ${kind}/${name}: its target has no endpoint chant can read`);
114
120
  }
@@ -61,7 +61,7 @@ import type {
61
61
  } from "@intentius/chant/lexicon";
62
62
  import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
63
63
  import { hasOwnershipMarker, type ChannelKeys } from "@intentius/chant/ownership";
64
- import { deriveGVK, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
64
+ import { deriveGVK, manifestProjectAnnotations, readManifest, readTargets, resolveReadProject } from "./describe-resources";
65
65
  import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
66
66
  import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
67
67
 
@@ -387,8 +387,10 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
387
387
 
388
388
  const endpoint = process.env.GCP_ENDPOINT_URL;
389
389
  const manifestProjects = manifestProjectAnnotations(options.buildOutput);
390
+ const targets = readTargets(options.entities);
391
+ const manifest = readManifest(options.entities, targets);
390
392
 
391
- const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
393
+ const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
392
394
  const gvk = deriveGVK(entityType);
393
395
  if (!gvk || !mapperForKind(gvk.kind)) {
394
396
  unobserved[entityName] = {
@@ -401,22 +403,15 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
401
403
  return;
402
404
  }
403
405
 
404
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
405
- const name = metadata?.name;
406
- if (!name) {
407
- unobserved[entityName] = {
408
- type: entityType,
409
- reason: "read-failed",
410
- detail: "declared entity has no metadata.name to query by",
411
- };
412
- return;
413
- }
406
+ const { name, props } = targets.get(entityName)!;
407
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
414
408
 
415
409
  let client: GcpReadClientOptions;
416
410
  try {
417
411
  client = {
418
412
  project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
419
413
  ...(endpoint ? { endpoint } : {}),
414
+ manifest,
420
415
  };
421
416
  } catch (err) {
422
417
  unobserved[entityName] = {
@@ -86,10 +86,11 @@ describe("gcp describeResources — direct REST (#1209)", () => {
86
86
  expect(fetchMock).not.toHaveBeenCalled();
87
87
  });
88
88
 
89
- test("an entity with no metadata.name has nothing to query by", async () => {
90
- const out = await read(makeEntities([{ name: "e", entityType: "GCP::Storage::Bucket", props: {} }]));
91
- expect(out.unobserved?.e?.reason).toBe("read-failed");
92
- expect(fetchMock).not.toHaveBeenCalled();
89
+ test("an entity with no metadata.name is read by the name the build gives it, the kebab-cased logical id (#4251)", async () => {
90
+ fetchMock.mockResolvedValue(reply(200, { name: "web-assets" }));
91
+ const out = await read(makeEntities([{ name: "webAssets", entityType: "GCP::Storage::Bucket", props: {} }]));
92
+ expect(out.resources.webAssets).toBeDefined();
93
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/b/web-assets");
93
94
  });
94
95
 
95
96
  test("kinds resolve through the serializer's GVK map, not naive recasing", async () => {
@@ -40,8 +40,8 @@ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@int
40
40
  import { observation } from "@intentius/chant/observation";
41
41
  import { hasOwnershipMarker, classifyOwnership, readOwnership, type ChannelKeys } from "@intentius/chant/ownership";
42
42
  import { getResource, mapperForKind, GcpReadError, isNotFound, type GcpReadClientOptions } from "./api/read-client";
43
- import { resolveGcpProject, parseManifest, projectRefProject } from "./op/activities/gcp-apply";
44
- import { resolveGVK } from "./serializer";
43
+ import { NO_PROJECT, parseManifest, projectRefProject, type GcpResource } from "./op/activities/gcp-apply";
44
+ import { cnrmNameOf, resolveCnrmRefs, resolveGVK } from "./serializer";
45
45
 
46
46
  /**
47
47
  * chant's ownership marker as GCP labels.
@@ -119,11 +119,12 @@ export function manifestProjectAnnotations(buildOutput: string | undefined): Map
119
119
  }
120
120
 
121
121
  /**
122
- * The project a read should target: the entity's `projectRef.external`,
123
- * `GOOGLE_CLOUD_PROJECT`, the entity's own annotation (via
124
- * `resolveGcpProject`, the applier's rule), then the built
125
- * manifest's merged annotation. Throws the applier's own error when nothing
126
- * resolves, so the `no-binding` detail names both knobs.
122
+ * The project a read should target, in apply's order (#4252): the entity's
123
+ * own literal `projectRef.external`, then the built manifest's project for it
124
+ * (its resolved projectRef, or the project-id annotation chant.config's
125
+ * `gcp.project` writes), then the entity's own annotation, then
126
+ * `GCP_PROJECT_ID`, then `GOOGLE_CLOUD_PROJECT`. Throws apply's own error when
127
+ * nothing resolves, so the `no-binding` detail names every knob.
127
128
  */
128
129
  export function resolveReadProject(
129
130
  kind: string,
@@ -131,14 +132,59 @@ export function resolveReadProject(
131
132
  metadata: { annotations?: Record<string, string> } | undefined,
132
133
  manifestProjects: Map<string, string>,
133
134
  props?: Record<string, unknown>,
135
+ env: NodeJS.ProcessEnv = process.env,
134
136
  ): string {
135
- try {
136
- return resolveGcpProject({ kind, metadata, ...(props ? { spec: props } : {}) });
137
- } catch (err) {
138
- const fromManifest = manifestProjects.get(`${kind}/${name}`);
139
- if (fromManifest) return fromManifest;
140
- throw err;
137
+ const project =
138
+ projectRefProject(props, {}) ||
139
+ manifestProjects.get(`${kind}/${name}`) ||
140
+ metadata?.annotations?.["cnrm.cloud.google.com/project-id"] ||
141
+ projectRefProject(props, env) ||
142
+ env.GCP_PROJECT_ID ||
143
+ env.GOOGLE_CLOUD_PROJECT;
144
+ if (!project) throw new Error(NO_PROJECT);
145
+ return project;
146
+ }
147
+
148
+ /**
149
+ * Each declared entity as a read sees it (#4251): the name the build writes
150
+ * (`metadata.name`, else the kebab-cased logical id) and its props with every
151
+ * reference to another resource resolved as the serializer resolves it, so a
152
+ * mapper building a read URL never meets an attribute reference.
153
+ */
154
+ export function readTargets(
155
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
156
+ ): Map<string, { name: string; props: Record<string, unknown> }> {
157
+ const logicalByProps = new Map<unknown, string>();
158
+ for (const [logical, { props }] of entities) logicalByProps.set(props, logical);
159
+ const logicalOf = (props: unknown) => logicalByProps.get(props);
160
+ const out = new Map<string, { name: string; props: Record<string, unknown> }>();
161
+ for (const [logical, { props }] of entities) {
162
+ out.set(logical, {
163
+ name: cnrmNameOf(logical, props),
164
+ props: resolveCnrmRefs(props, logicalOf) as Record<string, unknown>,
165
+ });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * The declared resources as the applier's manifest holds them (kind, CNRM
172
+ * name, spec), for a read that needs another declared resource: an
173
+ * IAMPolicyMember on a RunService takes the service's region from it (#4251).
174
+ */
175
+ export function readManifest(
176
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
177
+ targets: Map<string, { name: string; props: Record<string, unknown> }>,
178
+ ): GcpResource[] {
179
+ const out: GcpResource[] = [];
180
+ for (const [logical, { entityType }] of entities) {
181
+ const gvk = deriveGVK(entityType);
182
+ const target = targets.get(logical);
183
+ if (!gvk || !target) continue;
184
+ const spec = (target.props.spec as Record<string, unknown> | undefined) ?? target.props;
185
+ out.push({ kind: gvk.kind, metadata: { name: target.name }, spec });
141
186
  }
187
+ return out;
142
188
  }
143
189
 
144
190
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
@@ -196,8 +242,10 @@ export async function describeResources(options: {
196
242
  let warnedOwnership = false;
197
243
 
198
244
  const manifestProjects = manifestProjectAnnotations(options.buildOutput);
245
+ const targets = readTargets(options.entities);
246
+ const manifest = readManifest(options.entities, targets);
199
247
 
200
- const reads = [...options.entities].map(async ([entityName, { entityType, props }]) => {
248
+ const reads = [...options.entities].map(async ([entityName, { entityType }]) => {
201
249
  const gvk = deriveGVK(entityType);
202
250
  if (!gvk) {
203
251
  unobserved[entityName] = {
@@ -218,22 +266,15 @@ export async function describeResources(options: {
218
266
  return;
219
267
  }
220
268
 
221
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
222
- const name = metadata?.name;
223
- if (!name) {
224
- unobserved[entityName] = {
225
- type: entityType,
226
- reason: "read-failed",
227
- detail: "declared entity has no metadata.name to query by",
228
- };
229
- return;
230
- }
269
+ const { name, props } = targets.get(entityName)!;
270
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
231
271
 
232
272
  let client: GcpReadClientOptions;
233
273
  try {
234
274
  client = {
235
275
  project: resolveReadProject(gvk.kind, name, metadata, manifestProjects, props),
236
276
  ...(endpoint ? { endpoint } : {}),
277
+ manifest,
237
278
  };
238
279
  } catch (err) {
239
280
  // No project resolvable — nothing was queried, so nothing is known.
package/src/health.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
13
13
  import { getResource, GcpReadError, isNotFound, type GcpReadHttp } from "./api/read-client";
14
- import { deriveGVK, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
14
+ import { deriveGVK, manifestProjectAnnotations, readTargets, resolveReadProject } from "./describe-resources";
15
15
 
16
16
  export const GCP_HEALTH_TYPES = ["GCP::Run::Service"] as const;
17
17
 
@@ -75,18 +75,15 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
75
75
  const entities: Record<string, HealthEntry> = {};
76
76
  const unobserved: Record<string, UnobservedEntity> = {};
77
77
  const absent: string[] = [];
78
+ const targets = readTargets(options.entities);
78
79
 
79
80
  await Promise.all(
80
- [...options.entities].map(async ([entityName, { entityType, props }]) => {
81
+ [...options.entities].map(async ([entityName, { entityType }]) => {
81
82
  const gvk = deriveGVK(entityType);
82
- const metadata = props.metadata as { name?: string; annotations?: Record<string, string> } | undefined;
83
- const name = metadata?.name;
84
- if (!gvk || !name) {
85
- unobserved[entityName] = {
86
- type: entityType,
87
- reason: "read-failed",
88
- detail: gvk ? "declared entity has no metadata.name to query by" : `cannot derive a GCP kind from ${entityType}`,
89
- };
83
+ const { name, props } = targets.get(entityName)!;
84
+ const metadata = props.metadata as { annotations?: Record<string, string> } | undefined;
85
+ if (!gvk) {
86
+ unobserved[entityName] = { type: entityType, reason: "read-failed", detail: `cannot derive a GCP kind from ${entityType}` };
90
87
  return;
91
88
  }
92
89
  let project: string;
@@ -296,9 +296,16 @@ describe("Cloud Run mapper + LRO (#706)", () => {
296
296
  });
297
297
 
298
298
  describe("resolveGcpProject (#711)", () => {
299
- test("env wins over the annotation", () => {
300
- expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv))
301
- .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");
302
309
  });
303
310
 
304
311
  test("falls back to the CNRM annotation", () => {
@@ -568,7 +568,6 @@ export function resolveGcpEndpoint(
568
568
  return args.endpoint || env.GCP_ENDPOINT_URL || undefined;
569
569
  }
570
570
 
571
- /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
572
571
  /**
573
572
  * The project a resource names in `spec.projectRef.external` (#4201): a
574
573
  * project id or `projects/<id>`. A `GCP.ProjectId` pseudo-parameter there
@@ -589,19 +588,25 @@ export function projectRefProject(spec: Record<string, unknown> | undefined, env
589
588
  return external.startsWith("projects/") ? external.slice("projects/".length).split("/")[0] : external;
590
589
  }
591
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
+ */
592
596
  export function resolveGcpProject(resource: GcpResource, env: NodeJS.ProcessEnv = process.env): string {
593
597
  const project =
594
598
  projectRefProject(resource.spec, env) ||
595
- env.GOOGLE_CLOUD_PROJECT ||
596
- resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
597
- if (!project) {
598
- throw new Error(
599
- `no GCP project — set spec.projectRef.external, GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
600
- );
601
- }
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);
602
603
  return project;
603
604
  }
604
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
+
605
610
  /** Injectable HTTP client — mirrors argo's injectable fetcher so tests avoid the network. */
606
611
  export type GcpHttp = (
607
612
  method: string,
@@ -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,166 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } 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 { describeResources, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
10
+ import { parseManifest, resolveGcpProject } from "./op/activities/gcp-apply";
11
+ import { CloudRunService, PubSubTopic, 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
+ });
120
+
121
+ describe("the live read takes gcp.project with no project env var (#4252)", () => {
122
+ let saved: NodeJS.ProcessEnv;
123
+ const fetchMock = vi.fn();
124
+ beforeEach(() => {
125
+ saved = { ...process.env };
126
+ delete process.env.GCP_PROJECT_ID;
127
+ delete process.env.GOOGLE_CLOUD_PROJECT;
128
+ delete process.env.GCP_ENDPOINT_URL;
129
+ fetchMock.mockReset();
130
+ fetchMock.mockImplementation(async (url: string) => new Response(JSON.stringify({ name: "projects/p/topics/assets" }), { status: 200 }));
131
+ vi.stubGlobal("fetch", fetchMock);
132
+ });
133
+ afterEach(() => {
134
+ process.env = saved;
135
+ vi.unstubAllGlobals();
136
+ });
137
+
138
+ async function read(config: Record<string, unknown> | undefined, props: Record<string, unknown>) {
139
+ const yaml = build({ assets: new PubSubTopic(props as never) }, config);
140
+ return describeResources({
141
+ environment: "local",
142
+ buildOutput: yaml,
143
+ entityNames: ["assets"],
144
+ entities: new Map([["assets", { entityType: "GCP::Pubsub::Topic", props }]]),
145
+ });
146
+ }
147
+
148
+ test("only gcp.project in config: the read targets that project", async () => {
149
+ const result = await read({ gcp: { project: "three-cloud" } }, { metadata: { name: "assets" } });
150
+ expect(result.unobserved?.assets).toBeUndefined();
151
+ expect(result.resources.assets).toBeDefined();
152
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain("three-cloud");
153
+ });
154
+
155
+ test("only a project-id annotation on the resource: the read targets that project", async () => {
156
+ const result = await read(undefined, { metadata: { name: "assets", annotations: { [ANNOTATION]: "annotated" } } });
157
+ expect(result.unobserved?.assets).toBeUndefined();
158
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain("annotated");
159
+ });
160
+
161
+ test("neither: no-binding, naming every place a project can come from", async () => {
162
+ const result = await read(undefined, { metadata: { name: "assets" } });
163
+ expect(result.unobserved?.assets?.reason).toBe("no-binding");
164
+ expect(result.unobserved?.assets?.detail).toMatch(/gcp\.project in chant\.config/);
165
+ });
166
+ });