@intentius/chant-lexicon-gcp 0.124.34 → 0.124.35
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 -3
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +36 -15
- package/dist/composites/cloud-run-service.d.ts +2 -0
- package/dist/composites/cloud-run-service.d.ts.map +1 -1
- package/dist/composites/cloud-run-service.js +4 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +124 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +55 -18
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +93 -16
- package/dist/op/activities/gcp-compute.d.ts +56 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -0
- package/dist/op/activities/gcp-compute.js +313 -0
- package/dist/op/activities/gcp-iam.d.ts +82 -0
- package/dist/op/activities/gcp-iam.d.ts.map +1 -0
- package/dist/op/activities/gcp-iam.js +215 -0
- package/package.json +2 -2
- package/src/api/read-client.ts +35 -16
- package/src/composites/cloud-run-service.ts +6 -0
- package/src/composites/composites.test.ts +7 -0
- package/src/deep-observe.test.ts +16 -1
- package/src/deep-observe.ts +124 -0
- package/src/describe-resources.test.ts +1 -1
- package/src/op/activities/gcp-apply.test.ts +37 -0
- package/src/op/activities/gcp-apply.ts +132 -22
- package/src/op/activities/gcp-compute.test.ts +212 -0
- package/src/op/activities/gcp-compute.ts +319 -0
- package/src/op/activities/gcp-iam.test.ts +152 -0
- package/src/op/activities/gcp-iam.ts +284 -0
|
@@ -3,6 +3,15 @@ import { parseYAML } from "@intentius/chant/yaml";
|
|
|
3
3
|
import { sleep } from "@intentius/chant/op";
|
|
4
4
|
import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
5
5
|
import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
|
|
6
|
+
import { iamPolicyMemberMapper } from "./gcp-iam";
|
|
7
|
+
import {
|
|
8
|
+
backendServiceMapper,
|
|
9
|
+
computeAddressMapper,
|
|
10
|
+
forwardingRuleMapper,
|
|
11
|
+
regionNetworkEndpointGroupMapper,
|
|
12
|
+
targetHttpProxyMapper,
|
|
13
|
+
urlMapMapper,
|
|
14
|
+
} from "./gcp-compute";
|
|
6
15
|
import {
|
|
7
16
|
applyResult,
|
|
8
17
|
type ApplyResult,
|
|
@@ -120,6 +129,29 @@ export interface ListSpec {
|
|
|
120
129
|
items(responseBody: unknown): Array<{ name: string; labels?: Record<string, string> | null }>;
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Where a mapper works: the API base, the project, and the rest of the applied
|
|
134
|
+
* manifest, so a `*Ref` to a resource declared alongside can take that
|
|
135
|
+
* resource's scope. The read path has no manifest.
|
|
136
|
+
*/
|
|
137
|
+
export interface MapperCtx {
|
|
138
|
+
base: string;
|
|
139
|
+
project: string;
|
|
140
|
+
manifest?: readonly GcpResource[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A kind whose apply is not GET-then-create (#4233). IAMPolicyMember is a
|
|
145
|
+
* member inside another resource's policy, so it reads, merges and writes that
|
|
146
|
+
* policy instead. `read` answers like a GET: 200 with a body when present, 404
|
|
147
|
+
* when not.
|
|
148
|
+
*/
|
|
149
|
+
export interface CustomOps {
|
|
150
|
+
apply(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ created: boolean; updated: boolean }>;
|
|
151
|
+
delete(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<boolean>;
|
|
152
|
+
read(resource: GcpResource, ctx: MapperCtx, http: GcpHttp, signal?: AbortSignal): Promise<{ status: number; text: string }>;
|
|
153
|
+
}
|
|
154
|
+
|
|
123
155
|
/** Maps one CNRM kind to its GCP REST operations. The unit of the dispatch table. */
|
|
124
156
|
export interface ResourceMapper {
|
|
125
157
|
/** CNRM kind this handles. */
|
|
@@ -127,7 +159,11 @@ export interface ResourceMapper {
|
|
|
127
159
|
/** Real-GCP API host, used when no `endpoint` override is given. */
|
|
128
160
|
defaultHost: string;
|
|
129
161
|
/** Build the GET + create plan for one resource against `base`. */
|
|
130
|
-
plan(resource: GcpResource, ctx:
|
|
162
|
+
plan(resource: GcpResource, ctx: MapperCtx): ApplyPlan;
|
|
163
|
+
/** False for a resource of this kind chant cannot act on (an IAM binding on a target with no policy endpoint): reported, not attempted. */
|
|
164
|
+
accepts?(resource: GcpResource, ctx: MapperCtx): boolean;
|
|
165
|
+
/** Present when apply, delete and read are not the plan's GET-then-create. */
|
|
166
|
+
custom?: CustomOps;
|
|
131
167
|
/** Present for async resources whose create returns a long-running operation. */
|
|
132
168
|
operation?: OperationSpec;
|
|
133
169
|
/** Present when the kind can be pruned (list live → delete chant-owned orphans). */
|
|
@@ -245,14 +281,37 @@ export const pubSubTopicMapper: ResourceMapper = {
|
|
|
245
281
|
},
|
|
246
282
|
};
|
|
247
283
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Map a CNRM RunService to a Cloud Run v2 Service body. Pure. CNRM names the
|
|
286
|
+
* service account with `template.serviceAccountRef`; v2 takes
|
|
287
|
+
* `template.serviceAccount` as an email (#4240).
|
|
288
|
+
*/
|
|
289
|
+
export function cloudRunServiceBody(resource: GcpResource, project?: string): { template?: unknown } {
|
|
290
|
+
const spec = (resource.spec ?? {}) as { template?: Record<string, unknown> };
|
|
251
291
|
const body: { template?: unknown } = {};
|
|
252
|
-
if (spec.template) body
|
|
292
|
+
if (!spec.template) return body;
|
|
293
|
+
const { serviceAccountRef, ...template } = spec.template as { serviceAccountRef?: { name?: string; external?: string } };
|
|
294
|
+
const email = serviceAccountEmail(serviceAccountRef, project);
|
|
295
|
+
body.template = email ? { ...template, serviceAccount: email } : template;
|
|
253
296
|
return body;
|
|
254
297
|
}
|
|
255
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The email a CNRM `serviceAccountRef` names: `external` as given (an email,
|
|
301
|
+
* or a `projects/<p>/serviceAccounts/<email>` path), `name` as an account id
|
|
302
|
+
* in the project.
|
|
303
|
+
*/
|
|
304
|
+
export function serviceAccountEmail(ref: { name?: string; external?: string } | undefined, project?: string): string | undefined {
|
|
305
|
+
if (!ref) return undefined;
|
|
306
|
+
if (ref.external) return ref.external.split("/").pop();
|
|
307
|
+
if (ref.name) {
|
|
308
|
+
if (ref.name.includes("@")) return ref.name;
|
|
309
|
+
if (!project) throw new Error(`serviceAccountRef { name: "${ref.name}" } needs a project to form the account's email`);
|
|
310
|
+
return `${ref.name}@${project}.iam.gserviceaccount.com`;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
256
315
|
export const cloudRunServiceMapper: ResourceMapper = {
|
|
257
316
|
kind: "RunService",
|
|
258
317
|
defaultHost: "https://run.googleapis.com",
|
|
@@ -269,11 +328,11 @@ export const cloudRunServiceMapper: ResourceMapper = {
|
|
|
269
328
|
create: {
|
|
270
329
|
method: "POST",
|
|
271
330
|
url: `${services}?serviceId=${encodeURIComponent(name)}`,
|
|
272
|
-
body: cloudRunServiceBody(resource),
|
|
331
|
+
body: cloudRunServiceBody(resource, project),
|
|
273
332
|
},
|
|
274
333
|
// Cloud Run reconciles with a PATCH of the service; like create it returns
|
|
275
334
|
// a long-running operation, polled via the mapper's `operation`.
|
|
276
|
-
update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
|
|
335
|
+
update: { method: "PATCH", url, body: cloudRunServiceBody(resource, project) },
|
|
277
336
|
};
|
|
278
337
|
},
|
|
279
338
|
};
|
|
@@ -368,6 +427,14 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
368
427
|
SecretManagerSecret: secretManagerSecretMapper,
|
|
369
428
|
IAMServiceAccount: gcpServiceAccountMapper,
|
|
370
429
|
RunService: cloudRunServiceMapper,
|
|
430
|
+
// #4233: a binding member, and the Cloud Run load balancer.
|
|
431
|
+
IAMPolicyMember: iamPolicyMemberMapper,
|
|
432
|
+
ComputeAddress: computeAddressMapper,
|
|
433
|
+
ComputeRegionNetworkEndpointGroup: regionNetworkEndpointGroupMapper,
|
|
434
|
+
ComputeBackendService: backendServiceMapper,
|
|
435
|
+
ComputeURLMap: urlMapMapper,
|
|
436
|
+
ComputeTargetHTTPProxy: targetHttpProxyMapper,
|
|
437
|
+
ComputeForwardingRule: forwardingRuleMapper,
|
|
371
438
|
};
|
|
372
439
|
|
|
373
440
|
/**
|
|
@@ -376,7 +443,29 @@ export const MAPPERS: Record<string, ResourceMapper> = {
|
|
|
376
443
|
* refs point outside the manifest and are ignored for ordering. Pure.
|
|
377
444
|
*/
|
|
378
445
|
export function referencedNames(resource: GcpResource): string[] {
|
|
379
|
-
|
|
446
|
+
return [...new Set(referencedResources(resource).map((r) => r.name))];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The CNRM kind a `*Ref` key points at, where the key alone says. A ref that
|
|
451
|
+
* carries its own `kind` (`resourceRef`) uses that. Two resources of different
|
|
452
|
+
* kinds may share a name (a RunService `web` and its NEG `web`), so ordering
|
|
453
|
+
* matches on kind when it can.
|
|
454
|
+
*/
|
|
455
|
+
const REF_KINDS: Record<string, string> = {
|
|
456
|
+
topicRef: "PubSubTopic",
|
|
457
|
+
serviceRef: "RunService",
|
|
458
|
+
serviceAccountRef: "IAMServiceAccount",
|
|
459
|
+
addressRef: "ComputeAddress",
|
|
460
|
+
networkEndpointGroupRef: "ComputeRegionNetworkEndpointGroup",
|
|
461
|
+
backendServiceRef: "ComputeBackendService",
|
|
462
|
+
urlMapRef: "ComputeURLMap",
|
|
463
|
+
targetHTTPProxyRef: "ComputeTargetHTTPProxy",
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/** The local resources this resource references via `*Ref` fields, each with its kind when known. Pure. */
|
|
467
|
+
export function referencedResources(resource: GcpResource): Array<{ name: string; kind?: string }> {
|
|
468
|
+
const refs: Array<{ name: string; kind?: string }> = [];
|
|
380
469
|
const visit = (v: unknown, key?: string): void => {
|
|
381
470
|
if (Array.isArray(v)) {
|
|
382
471
|
for (const x of v) visit(x, key);
|
|
@@ -385,13 +474,14 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
385
474
|
if (v && typeof v === "object") {
|
|
386
475
|
const obj = v as Record<string, unknown>;
|
|
387
476
|
if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
|
|
388
|
-
|
|
477
|
+
const kind = typeof obj.kind === "string" ? obj.kind : REF_KINDS[key];
|
|
478
|
+
refs.push(kind ? { name: obj.name, kind } : { name: obj.name });
|
|
389
479
|
}
|
|
390
480
|
for (const [k, val] of Object.entries(obj)) visit(val, k);
|
|
391
481
|
}
|
|
392
482
|
};
|
|
393
483
|
visit(resource.spec);
|
|
394
|
-
return
|
|
484
|
+
return refs;
|
|
395
485
|
}
|
|
396
486
|
|
|
397
487
|
/**
|
|
@@ -401,10 +491,10 @@ export function referencedNames(resource: GcpResource): string[] {
|
|
|
401
491
|
* Pure — a DFS post-order.
|
|
402
492
|
*/
|
|
403
493
|
export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
404
|
-
const byName = new Map<string, GcpResource>();
|
|
494
|
+
const byName = new Map<string, GcpResource[]>();
|
|
405
495
|
for (const r of resources) {
|
|
406
496
|
const n = r.metadata?.name;
|
|
407
|
-
if (n) byName.set(n, r);
|
|
497
|
+
if (n) byName.set(n, [...(byName.get(n) ?? []), r]);
|
|
408
498
|
}
|
|
409
499
|
const ordered: GcpResource[] = [];
|
|
410
500
|
const done = new Set<GcpResource>();
|
|
@@ -413,9 +503,10 @@ export function orderByReferences(resources: GcpResource[]): GcpResource[] {
|
|
|
413
503
|
if (done.has(r)) return;
|
|
414
504
|
if (active.has(r)) throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
|
|
415
505
|
active.add(r);
|
|
416
|
-
for (const ref of
|
|
417
|
-
const
|
|
418
|
-
|
|
506
|
+
for (const ref of referencedResources(r)) {
|
|
507
|
+
const named = (byName.get(ref.name) ?? []).filter((d) => d !== r);
|
|
508
|
+
const deps = ref.kind ? named.filter((d) => d.kind === ref.kind) : named;
|
|
509
|
+
for (const dep of deps) visit(dep);
|
|
419
510
|
}
|
|
420
511
|
active.delete(r);
|
|
421
512
|
done.add(r);
|
|
@@ -503,12 +594,16 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
|
|
|
503
594
|
export async function applyResource(
|
|
504
595
|
mapper: ResourceMapper,
|
|
505
596
|
resource: GcpResource,
|
|
506
|
-
ctx:
|
|
597
|
+
ctx: MapperCtx,
|
|
507
598
|
http: GcpHttp = defaultHttp,
|
|
508
599
|
signal?: AbortSignal,
|
|
509
600
|
): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
|
|
510
|
-
const plan = mapper.plan(resource, ctx);
|
|
511
601
|
const name = resource.metadata?.name ?? "?";
|
|
602
|
+
if (mapper.custom) {
|
|
603
|
+
const result = await mapper.custom.apply(resource, ctx, http, signal);
|
|
604
|
+
return { kind: mapper.kind, name, ...result };
|
|
605
|
+
}
|
|
606
|
+
const plan = mapper.plan(resource, ctx);
|
|
512
607
|
const get = await http("GET", plan.getUrl, undefined, signal);
|
|
513
608
|
|
|
514
609
|
if (get.status === 200) {
|
|
@@ -712,7 +807,7 @@ export interface GcpNotPrunable {
|
|
|
712
807
|
*/
|
|
713
808
|
export async function pruneOrphans(
|
|
714
809
|
desired: GcpResource[],
|
|
715
|
-
resolve: (mapper: ResourceMapper, resource?: GcpResource) =>
|
|
810
|
+
resolve: (mapper: ResourceMapper, resource?: GcpResource) => MapperCtx,
|
|
716
811
|
http: GcpHttp = defaultHttp,
|
|
717
812
|
signal?: AbortSignal,
|
|
718
813
|
): Promise<{
|
|
@@ -780,9 +875,10 @@ export async function gcpApply(
|
|
|
780
875
|
}> {
|
|
781
876
|
const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
|
|
782
877
|
const endpoint = resolveGcpEndpoint(args);
|
|
783
|
-
const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
|
|
878
|
+
const resolve = (mapper: ResourceMapper, resource?: GcpResource): MapperCtx => ({
|
|
784
879
|
base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
|
|
785
880
|
project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
|
|
881
|
+
manifest: resources,
|
|
786
882
|
});
|
|
787
883
|
|
|
788
884
|
const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
|
|
@@ -796,6 +892,11 @@ export async function gcpApply(
|
|
|
796
892
|
continue;
|
|
797
893
|
}
|
|
798
894
|
const ctx = resolve(mapper, r);
|
|
895
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
896
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can apply it to`);
|
|
897
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
799
900
|
const result = await applyResource(mapper, r, ctx, http, signal);
|
|
800
901
|
const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
|
|
801
902
|
console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
|
|
@@ -816,12 +917,15 @@ export async function gcpApply(
|
|
|
816
917
|
export async function deleteResource(
|
|
817
918
|
mapper: ResourceMapper,
|
|
818
919
|
resource: GcpResource,
|
|
819
|
-
ctx:
|
|
920
|
+
ctx: MapperCtx,
|
|
820
921
|
http: GcpHttp = defaultHttp,
|
|
821
922
|
signal?: AbortSignal,
|
|
822
923
|
): Promise<{ kind: string; name: string; deleted: boolean }> {
|
|
823
|
-
const plan = mapper.plan(resource, ctx);
|
|
824
924
|
const name = resource.metadata?.name ?? "?";
|
|
925
|
+
if (mapper.custom) {
|
|
926
|
+
return { kind: mapper.kind, name, deleted: await mapper.custom.delete(resource, ctx, http, signal) };
|
|
927
|
+
}
|
|
928
|
+
const plan = mapper.plan(resource, ctx);
|
|
825
929
|
const res = await http("DELETE", plan.getUrl, undefined, signal);
|
|
826
930
|
if (res.status === 404) {
|
|
827
931
|
return { kind: mapper.kind, name, deleted: false };
|
|
@@ -870,7 +974,13 @@ export async function gcpDelete(
|
|
|
870
974
|
}
|
|
871
975
|
const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
|
|
872
976
|
const project = args.project ?? resolveGcpProject(r);
|
|
873
|
-
const
|
|
977
|
+
const ctx: MapperCtx = { base, project, manifest: resources };
|
|
978
|
+
if (mapper.accepts && !mapper.accepts(r, ctx)) {
|
|
979
|
+
console.log(`skip: ${r.kind}/${r.metadata?.name ?? "?"} has nothing chant can delete it from`);
|
|
980
|
+
notAttempted.push({ kind: r.kind as string, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const result = await deleteResource(mapper, r, ctx, http, signal);
|
|
874
984
|
console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
|
|
875
985
|
deleted.push(result);
|
|
876
986
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { applyResource, deleteResource, MAPPERS, orderByReferences, type GcpHttp, type GcpResource } from "./gcp-apply";
|
|
3
|
+
import { computeLabels, computeOperation } from "./gcp-compute";
|
|
4
|
+
import { getResource } from "../../api/read-client";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.GCP_REGION;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** The serverless load balancer behold's three-cloud demo declares, in the shape the serializer writes. */
|
|
11
|
+
const lb: GcpResource[] = [
|
|
12
|
+
{
|
|
13
|
+
kind: "ComputeForwardingRule",
|
|
14
|
+
metadata: { name: "web-fr", labels: { "app.kubernetes.io/component": "lb", tier: "edge" } },
|
|
15
|
+
spec: {
|
|
16
|
+
location: "global",
|
|
17
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
18
|
+
portRange: "80",
|
|
19
|
+
ipProtocol: "TCP",
|
|
20
|
+
ipAddress: { addressRef: { name: "web-ip" } },
|
|
21
|
+
target: { targetHTTPProxyRef: { name: "web-proxy" } },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{ kind: "ComputeTargetHTTPProxy", metadata: { name: "web-proxy" }, spec: { location: "global", urlMapRef: { name: "web-map" } } },
|
|
25
|
+
{ kind: "ComputeURLMap", metadata: { name: "web-map" }, spec: { location: "global", defaultService: { backendServiceRef: { name: "web" } } } },
|
|
26
|
+
{
|
|
27
|
+
kind: "ComputeBackendService",
|
|
28
|
+
metadata: { name: "web" },
|
|
29
|
+
spec: {
|
|
30
|
+
location: "global",
|
|
31
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
32
|
+
protocol: "HTTPS",
|
|
33
|
+
enableCdn: false,
|
|
34
|
+
backend: [{ group: { networkEndpointGroupRef: { name: "web" } } }],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: "ComputeRegionNetworkEndpointGroup",
|
|
39
|
+
metadata: { name: "web" },
|
|
40
|
+
spec: { region: "europe-west1", networkEndpointType: "SERVERLESS", cloudRun: { serviceRef: { name: "web" } } },
|
|
41
|
+
},
|
|
42
|
+
{ kind: "ComputeAddress", metadata: { name: "web-ip" }, spec: { location: "global" } },
|
|
43
|
+
{ kind: "RunService", metadata: { name: "web" }, spec: { location: "europe-west1" } },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const ctx = { base: "https://compute.googleapis.com", project: "p1", manifest: lb };
|
|
47
|
+
const byKind = (kind: string) => lb.find((r) => r.kind === kind)!;
|
|
48
|
+
const plan = (kind: string) => MAPPERS[kind].plan(byKind(kind), ctx);
|
|
49
|
+
|
|
50
|
+
describe("compute load balancer mappers (#4233)", () => {
|
|
51
|
+
test("apply order: service, NEG and address before the backend, map, proxy and forwarding rule", () => {
|
|
52
|
+
const order = orderByReferences(lb).map((r) => r.kind);
|
|
53
|
+
const at = (k: string) => order.indexOf(k);
|
|
54
|
+
expect(at("RunService")).toBeLessThan(at("ComputeRegionNetworkEndpointGroup"));
|
|
55
|
+
expect(at("ComputeRegionNetworkEndpointGroup")).toBeLessThan(at("ComputeBackendService"));
|
|
56
|
+
expect(at("ComputeBackendService")).toBeLessThan(at("ComputeURLMap"));
|
|
57
|
+
expect(at("ComputeURLMap")).toBeLessThan(at("ComputeTargetHTTPProxy"));
|
|
58
|
+
expect(at("ComputeTargetHTTPProxy")).toBeLessThan(at("ComputeForwardingRule"));
|
|
59
|
+
expect(at("ComputeAddress")).toBeLessThan(at("ComputeForwardingRule"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a serverless NEG is regional and names its Cloud Run service", () => {
|
|
63
|
+
expect(plan("ComputeRegionNetworkEndpointGroup")).toEqual({
|
|
64
|
+
getUrl: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web",
|
|
65
|
+
create: {
|
|
66
|
+
method: "POST",
|
|
67
|
+
url: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups",
|
|
68
|
+
body: { name: "web", networkEndpointType: "SERVERLESS", cloudRun: { service: "web" } },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a global backend service points at the regional NEG by the NEG's own region", () => {
|
|
74
|
+
expect(plan("ComputeBackendService").create).toEqual({
|
|
75
|
+
method: "POST",
|
|
76
|
+
url: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices",
|
|
77
|
+
body: {
|
|
78
|
+
name: "web",
|
|
79
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
80
|
+
protocol: "HTTPS",
|
|
81
|
+
enableCDN: false,
|
|
82
|
+
backends: [{ group: "projects/p1/regions/europe-west1/networkEndpointGroups/web" }],
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("URL map, proxy and forwarding rule resolve their refs to partial URLs", () => {
|
|
88
|
+
expect(plan("ComputeURLMap").create.body).toEqual({ name: "web-map", defaultService: "projects/p1/global/backendServices/web" });
|
|
89
|
+
expect(plan("ComputeTargetHTTPProxy").create.body).toEqual({ name: "web-proxy", urlMap: "projects/p1/global/urlMaps/web-map" });
|
|
90
|
+
expect(plan("ComputeForwardingRule").create.body).toEqual({
|
|
91
|
+
name: "web-fr",
|
|
92
|
+
loadBalancingScheme: "EXTERNAL_MANAGED",
|
|
93
|
+
portRange: "80",
|
|
94
|
+
IPProtocol: "TCP",
|
|
95
|
+
IPAddress: "projects/p1/global/addresses/web-ip",
|
|
96
|
+
target: "projects/p1/global/targetHttpProxies/web-proxy",
|
|
97
|
+
labels: { tier: "edge", "managed-by": "chant" },
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a global address goes to the global collection, with the ownership label", () => {
|
|
102
|
+
expect(plan("ComputeAddress")).toMatchObject({
|
|
103
|
+
getUrl: "https://compute.googleapis.com/compute/v1/projects/p1/global/addresses/web-ip",
|
|
104
|
+
create: { body: { name: "web-ip", labels: { "managed-by": "chant" } } },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("labels GCP refuses are left out", () => {
|
|
109
|
+
expect(computeLabels({ "app.kubernetes.io/name": "x", Bad: "y", ok: "Value", good: "v-1" })).toEqual({ good: "v-1", "managed-by": "chant" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("an external ref is used as given", () => {
|
|
113
|
+
const proxy: GcpResource = {
|
|
114
|
+
kind: "ComputeTargetHTTPProxy",
|
|
115
|
+
metadata: { name: "p" },
|
|
116
|
+
spec: { location: "global", urlMapRef: { external: "projects/shared/global/urlMaps/m" } },
|
|
117
|
+
};
|
|
118
|
+
expect(MAPPERS.ComputeTargetHTTPProxy.plan(proxy, { base: "b", project: "p1" }).create.body).toEqual({
|
|
119
|
+
name: "p",
|
|
120
|
+
urlMap: "projects/shared/global/urlMaps/m",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("compute operations (#4233)", () => {
|
|
126
|
+
test("the selfLink is rebased onto an endpoint override, and DONE ends the poll", () => {
|
|
127
|
+
const op = { kind: "compute#operation", selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1", status: "RUNNING" };
|
|
128
|
+
expect(computeOperation.pollUrl(op, { base: "http://localhost:4588", project: "p1" })).toBe(
|
|
129
|
+
"http://localhost:4588/compute/v1/projects/p1/global/operations/op-1",
|
|
130
|
+
);
|
|
131
|
+
expect(computeOperation.isDone({ status: "DONE" })).toBe(true);
|
|
132
|
+
expect(computeOperation.error({ status: "DONE", error: { errors: [{ message: "quota" }] } })).toBe("quota");
|
|
133
|
+
expect(computeOperation.pollUrl({ name: "web" }, { base: "b", project: "p1" })).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("apply inserts, polls the operation, and leaves an existing resource alone", async () => {
|
|
137
|
+
const calls: string[] = [];
|
|
138
|
+
let exists = false;
|
|
139
|
+
const http: GcpHttp = async (method, url) => {
|
|
140
|
+
calls.push(`${method} ${url}`);
|
|
141
|
+
if (method === "GET" && url.endsWith("/addresses/web-ip")) return exists ? { status: 200, text: "{}" } : { status: 404, text: "" };
|
|
142
|
+
if (method === "POST") {
|
|
143
|
+
exists = true;
|
|
144
|
+
return { status: 200, text: JSON.stringify({ kind: "compute#operation", selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1", status: "PENDING" }) };
|
|
145
|
+
}
|
|
146
|
+
if (url.includes("/operations/")) return { status: 200, text: JSON.stringify({ status: "DONE" }) };
|
|
147
|
+
return { status: 500, text: "" };
|
|
148
|
+
};
|
|
149
|
+
const created = await applyResource(MAPPERS.ComputeAddress, byKind("ComputeAddress"), ctx, http);
|
|
150
|
+
expect(created.created).toBe(true);
|
|
151
|
+
expect(calls).toContain("GET https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1");
|
|
152
|
+
const again = await applyResource(MAPPERS.ComputeAddress, byKind("ComputeAddress"), ctx, http);
|
|
153
|
+
expect(again).toMatchObject({ created: false, updated: false });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("delete removes the resource at its scoped URL", async () => {
|
|
157
|
+
const calls: string[] = [];
|
|
158
|
+
const http: GcpHttp = async (method, url) => {
|
|
159
|
+
calls.push(`${method} ${url}`);
|
|
160
|
+
return { status: 200, text: JSON.stringify({ status: "DONE" }) };
|
|
161
|
+
};
|
|
162
|
+
await deleteResource(MAPPERS.ComputeRegionNetworkEndpointGroup, byKind("ComputeRegionNetworkEndpointGroup"), ctx, http);
|
|
163
|
+
expect(calls[0]).toBe("DELETE https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the reader reads a load balancer kind through the same URL", async () => {
|
|
167
|
+
const http = async (_m: string, url: string) =>
|
|
168
|
+
url.endsWith("/global/urlMaps/web-map") ? { status: 200, text: JSON.stringify({ name: "web-map" }) } : { status: 404, text: "" };
|
|
169
|
+
const body = await getResource({ project: "p1", http }, "ComputeURLMap", "web-map", byKind("ComputeURLMap").spec);
|
|
170
|
+
expect(body).toEqual({ name: "web-map" });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("deep observe reads a load balancer back in the declared vocabulary (#4233)", () => {
|
|
175
|
+
test("refs, renamed fields and the scope invert the body builders", async () => {
|
|
176
|
+
const { toCnrmTree } = await import("../../deep-observe");
|
|
177
|
+
const c = { project: "p1" };
|
|
178
|
+
expect(
|
|
179
|
+
toCnrmTree(
|
|
180
|
+
"ComputeBackendService",
|
|
181
|
+
{
|
|
182
|
+
name: "web",
|
|
183
|
+
selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices/web",
|
|
184
|
+
enableCDN: false,
|
|
185
|
+
protocol: "HTTPS",
|
|
186
|
+
backends: [{ group: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web" }],
|
|
187
|
+
},
|
|
188
|
+
c,
|
|
189
|
+
),
|
|
190
|
+
).toEqual({
|
|
191
|
+
metadata: { name: "web" },
|
|
192
|
+
selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/backendServices/web",
|
|
193
|
+
enableCdn: false,
|
|
194
|
+
protocol: "HTTPS",
|
|
195
|
+
location: "global",
|
|
196
|
+
backend: [{ group: { networkEndpointGroupRef: { name: "web" } } }],
|
|
197
|
+
});
|
|
198
|
+
expect(
|
|
199
|
+
toCnrmTree(
|
|
200
|
+
"ComputeRegionNetworkEndpointGroup",
|
|
201
|
+
{ name: "web", region: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1", cloudRun: { service: "web" } },
|
|
202
|
+
c,
|
|
203
|
+
),
|
|
204
|
+
).toMatchObject({ region: "europe-west1", cloudRun: { serviceRef: { name: "web" } } });
|
|
205
|
+
expect(
|
|
206
|
+
toCnrmTree("ComputeForwardingRule", { name: "fr", IPProtocol: "TCP", target: "projects/p1/global/targetHttpProxies/web-proxy" }, c),
|
|
207
|
+
).toMatchObject({ ipProtocol: "TCP", location: "global", target: { targetHTTPProxyRef: { name: "web-proxy" } } });
|
|
208
|
+
expect(toCnrmTree("ComputeURLMap", { name: "m", defaultService: "projects/p1/global/backendServices/web" }, c)).toMatchObject({
|
|
209
|
+
defaultService: { backendServiceRef: { name: "web" } },
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|