@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.
- package/dist/api/read-client.d.ts +2 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +2 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +56 -11
- package/dist/describe-resources.d.ts +20 -6
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +40 -26
- 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 +14 -1
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +49 -8
- package/dist/op/activities/gcp-compute.d.ts +10 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -1
- package/dist/op/activities/gcp-compute.js +39 -3
- 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 +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +72 -16
- package/package.json +2 -2
- package/src/api/read-client.ts +2 -1
- package/src/deep-observe.test.ts +27 -1
- package/src/deep-observe.ts +52 -12
- package/src/describe-resources.test.ts +6 -5
- package/src/describe-resources.ts +43 -24
- package/src/health.ts +7 -10
- package/src/op/activities/gcp-apply.test.ts +48 -3
- package/src/op/activities/gcp-apply.ts +49 -9
- package/src/op/activities/gcp-compute.test.ts +94 -0
- package/src/op/activities/gcp-compute.ts +37 -2
- package/src/op/activities/gcp-iam.ts +2 -0
- package/src/project-config.test.ts +119 -0
- package/src/read-refs.test.ts +135 -0
- package/src/serializer.test.ts +26 -0
- 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("
|
|
262
|
-
expect(resolveGcpProject(BUCKET, { GOOGLE_CLOUD_PROJECT: "env-project" } as NodeJS.ProcessEnv))
|
|
263
|
-
.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");
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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 =
|
|
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
|
+
});
|
package/src/serializer.test.ts
CHANGED
|
@@ -293,3 +293,29 @@ describe("gcpSerializer", () => {
|
|
|
293
293
|
expect(result).toContain("ComputeInstance");
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
describe("a .name reference serializes as the target's metadata.name (#4242)", () => {
|
|
298
|
+
test("a declared metadata.name wins; without one, the kebab-cased logical id", async () => {
|
|
299
|
+
const { RegionNetworkEndpointGroup, BackendService, CloudRunService } = await import("./generated");
|
|
300
|
+
const web = new CloudRunService({ location: "us-central1", template: { containers: [{ image: "i" }] } } as any);
|
|
301
|
+
(web as any).name._setLogicalName("webService");
|
|
302
|
+
const neg = new RegionNetworkEndpointGroup({
|
|
303
|
+
metadata: { name: "three-cloud-neg" },
|
|
304
|
+
region: "us-central1",
|
|
305
|
+
networkEndpointType: "SERVERLESS",
|
|
306
|
+
cloudRun: { serviceRef: { name: (web as any).name } },
|
|
307
|
+
} as any);
|
|
308
|
+
(neg as any).name._setLogicalName("neg");
|
|
309
|
+
const backend = new BackendService({
|
|
310
|
+
metadata: { name: "three-cloud-backend" },
|
|
311
|
+
location: "global",
|
|
312
|
+
backend: [{ group: { networkEndpointGroupRef: { name: (neg as any).name } } }],
|
|
313
|
+
} as any);
|
|
314
|
+
const entities = new Map<string, any>([["webService", web], ["neg", neg], ["backend", backend]]);
|
|
315
|
+
const yaml = String(gcpSerializer.serialize(entities));
|
|
316
|
+
expect(yaml).toContain("name: three-cloud-neg");
|
|
317
|
+
expect(yaml).not.toMatch(/networkEndpointGroupRef:\s*\n\s*name: neg\b/);
|
|
318
|
+
expect(yaml).toMatch(/serviceRef:\s*\n\s*name: web-service/);
|
|
319
|
+
expect(yaml).toMatch(/networkEndpointGroupRef:\s*\n\s*name: three-cloud-neg/);
|
|
320
|
+
});
|
|
321
|
+
});
|