@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/dist/api/read-client.d.ts +7 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +1 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +6 -11
- package/dist/describe-resources.d.ts +33 -6
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +58 -25
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +6 -9
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +7 -1
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +12 -6
- package/dist/op/activities/gcp-iam.d.ts.map +1 -1
- package/dist/op/activities/gcp-iam.js +2 -0
- package/dist/serializer.d.ts +12 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +52 -8
- package/package.json +2 -2
- package/src/api/read-client.ts +7 -1
- package/src/deep-observe.ts +7 -12
- package/src/describe-resources.test.ts +5 -4
- package/src/describe-resources.ts +65 -24
- package/src/health.ts +7 -10
- package/src/op/activities/gcp-apply.test.ts +10 -3
- package/src/op/activities/gcp-apply.ts +13 -8
- package/src/op/activities/gcp-iam.ts +2 -0
- package/src/project-config.test.ts +166 -0
- package/src/read-refs.test.ts +195 -0
- package/src/serializer.ts +53 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gcp",
|
|
3
|
-
"version": "0.124.
|
|
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.
|
|
79
|
+
"@intentius/chant": "^0.124.38"
|
|
80
80
|
}
|
|
81
81
|
}
|
package/src/api/read-client.ts
CHANGED
|
@@ -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
|
}
|
package/src/deep-observe.ts
CHANGED
|
@@ -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
|
|
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
|
|
405
|
-
const
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(
|
|
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 {
|
|
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
|
|
123
|
-
* `
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
222
|
-
const
|
|
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
|
|
81
|
+
[...options.entities].map(async ([entityName, { entityType }]) => {
|
|
81
82
|
const gvk = deriveGVK(entityType);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
if (!gvk
|
|
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("
|
|
300
|
-
expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv))
|
|
301
|
-
.toBe("
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
+
});
|