@intentius/chant-lexicon-gcp 0.124.38 → 0.124.40
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 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +24 -9
- package/dist/components/capability-plugin.d.ts +14 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/capability-plugin.js +21 -0
- package/dist/components/gcp-apply.d.ts +104 -0
- package/dist/components/gcp-apply.d.ts.map +1 -0
- package/dist/components/gcp-apply.js +335 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/catalog.js +187 -0
- package/dist/composites/index.d.ts +4 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/index.js +2 -0
- package/dist/composites/private-egress-network.d.ts +42 -0
- package/dist/composites/private-egress-network.d.ts.map +1 -0
- package/dist/composites/private-egress-network.js +48 -0
- package/dist/composites/public-cloud-run.d.ts +85 -0
- package/dist/composites/public-cloud-run.d.ts.map +1 -0
- package/dist/composites/public-cloud-run.js +144 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +34 -2
- package/dist/health.d.ts +26 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +85 -6
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +61 -4
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +54 -22
- package/dist/op/activities/gcp-compute.d.ts +9 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -1
- package/dist/op/activities/gcp-compute.js +107 -11
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -2
- package/dist/skills/chant-gcp.md +2 -0
- package/package.json +2 -2
- package/src/api/read-client.ts +23 -8
- package/src/components/capability-plugin.ts +25 -0
- package/src/components/gcp-apply-floci.e2e.test.ts +80 -0
- package/src/components/gcp-apply.test.ts +259 -0
- package/src/components/gcp-apply.ts +406 -0
- package/src/composites/catalog.ts +187 -0
- package/src/composites/cloud-run-lb.test.ts +93 -0
- package/src/composites/index.ts +4 -0
- package/src/composites/private-egress-network.ts +82 -0
- package/src/composites/public-cloud-run.ts +245 -0
- package/src/deep-observe.test.ts +46 -0
- package/src/deep-observe.ts +38 -2
- package/src/health.test.ts +65 -1
- package/src/health.ts +88 -11
- package/src/index.ts +13 -1
- package/src/op/activities/gcp-apply.ts +95 -24
- package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
- package/src/op/activities/gcp-compute.test.ts +4 -2
- package/src/op/activities/gcp-compute.ts +129 -11
- package/src/plugin.test.ts +10 -0
- package/src/plugin.ts +6 -2
- package/src/skills/chant-gcp.md +2 -0
package/src/health.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime health for GCP (#4177): Cloud Run services
|
|
2
|
+
* Runtime health for GCP (#4177): Cloud Run services; and (#4239) backend
|
|
3
|
+
* services, from `getHealth` on each backend group, and IAM policy members,
|
|
4
|
+
* healthy when the member holds the role in the target's policy.
|
|
3
5
|
*
|
|
4
6
|
* A Cloud Run v2 service carries its `Ready` condition as `terminalCondition`
|
|
5
7
|
* (`CONDITION_SUCCEEDED`, `CONDITION_FAILED`, pending or reconciling). The v1
|
|
@@ -10,10 +12,10 @@
|
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
13
|
-
import { getResource, GcpReadError, isNotFound, type GcpReadHttp } from "./api/read-client";
|
|
14
|
-
import { deriveGVK, manifestProjectAnnotations, readTargets, resolveReadProject } from "./describe-resources";
|
|
15
|
+
import { getResource, GcpReadError, isNotFound, readUrl, type GcpReadHttp } from "./api/read-client";
|
|
16
|
+
import { deriveGVK, manifestProjectAnnotations, readManifest, readTargets, resolveReadProject } from "./describe-resources";
|
|
15
17
|
|
|
16
|
-
export const GCP_HEALTH_TYPES = ["GCP::Run::Service"] as const;
|
|
18
|
+
export const GCP_HEALTH_TYPES = ["GCP::Run::Service", "GCP::Compute::BackendService", "GCP::Iam::PolicyMember"] as const;
|
|
17
19
|
|
|
18
20
|
export interface GcpHealthOptions extends HealthReadOptions {
|
|
19
21
|
http?: GcpReadHttp;
|
|
@@ -68,6 +70,77 @@ export function cloudRunEntry(body: CloudRunServiceState, observedAt: string): H
|
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/** One backend group's `getHealth` answer: `healthStatus[].healthState` per endpoint. */
|
|
74
|
+
export interface BackendGroupHealth {
|
|
75
|
+
group: string;
|
|
76
|
+
healthStatus?: Array<{ healthState?: string }>;
|
|
77
|
+
/** Set when the call failed; the group then counts as unknown. */
|
|
78
|
+
error?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A backend service's verdict from its groups: any endpoint `UNHEALTHY` is
|
|
83
|
+
* unhealthy; every endpoint `HEALTHY`, with at least one, is healthy; anything
|
|
84
|
+
* else (no backends, a group that reports no status, as a serverless NEG
|
|
85
|
+
* does, a failed call) is unknown.
|
|
86
|
+
*/
|
|
87
|
+
export function backendServiceEntry(groups: readonly BackendGroupHealth[], observedAt: string): HealthEntry {
|
|
88
|
+
const type = "GCP::Compute::BackendService";
|
|
89
|
+
if (groups.length === 0) return { type, state: "unknown", detail: "no backends", observedAt };
|
|
90
|
+
let healthy = 0;
|
|
91
|
+
let unhealthy = 0;
|
|
92
|
+
let unknown = 0;
|
|
93
|
+
for (const g of groups) {
|
|
94
|
+
const states = g.error ? [] : (g.healthStatus ?? []).map((h) => (h.healthState ?? "").toUpperCase());
|
|
95
|
+
if (states.length === 0) unknown++;
|
|
96
|
+
else if (states.some((st) => st === "UNHEALTHY")) unhealthy++;
|
|
97
|
+
else if (states.every((st) => st === "HEALTHY")) healthy++;
|
|
98
|
+
else unknown++;
|
|
99
|
+
}
|
|
100
|
+
const state = unhealthy > 0 ? "unhealthy" : healthy === groups.length ? "healthy" : "unknown";
|
|
101
|
+
const parts = [`${healthy} of ${groups.length} backend${groups.length === 1 ? "" : "s"} healthy`];
|
|
102
|
+
if (unhealthy) parts.push(`${unhealthy} unhealthy`);
|
|
103
|
+
if (unknown) parts.push(`${unknown} with no health status`);
|
|
104
|
+
return { type, state, detail: parts.join(", "), observedAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A binding the read found in its target's policy. A missing one is `absent`, never here. */
|
|
108
|
+
export function policyMemberEntry(binding: { role?: string; member?: string; resource?: string }, observedAt: string): HealthEntry {
|
|
109
|
+
return {
|
|
110
|
+
type: "GCP::Iam::PolicyMember",
|
|
111
|
+
state: "healthy",
|
|
112
|
+
detail: `${binding.member ?? "member"} holds ${binding.role ?? "the role"}${binding.resource ? ` on ${binding.resource}` : ""}`,
|
|
113
|
+
observedAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const defaultReadHttp: GcpReadHttp = async (method, url, body) => {
|
|
118
|
+
const res = await fetch(url, {
|
|
119
|
+
method,
|
|
120
|
+
...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
|
|
121
|
+
});
|
|
122
|
+
return { status: res.status, text: await res.text() };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** `getHealth` for each of a backend service's groups, at the service's own read URL. */
|
|
126
|
+
async function backendGroupsHealth(url: string, body: Record<string, unknown>, http: GcpReadHttp): Promise<BackendGroupHealth[]> {
|
|
127
|
+
const backends = Array.isArray(body.backends) ? (body.backends as Array<{ group?: unknown }>) : [];
|
|
128
|
+
return Promise.all(
|
|
129
|
+
backends
|
|
130
|
+
.map((b) => b.group)
|
|
131
|
+
.filter((g): g is string => typeof g === "string")
|
|
132
|
+
.map(async (group) => {
|
|
133
|
+
try {
|
|
134
|
+
const res = await http("POST", `${url}/getHealth`, { group });
|
|
135
|
+
if (res.status < 200 || res.status >= 300) return { group, error: `getHealth ${res.status}` };
|
|
136
|
+
return { group, ...(JSON.parse(res.text) as { healthStatus?: Array<{ healthState?: string }> }) };
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { group, error: err instanceof Error ? err.message : String(err) };
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
71
144
|
export async function observeHealthGcp(options: GcpHealthOptions): Promise<HealthReadResult> {
|
|
72
145
|
const observedAt = (options.now?.() ?? new Date()).toISOString();
|
|
73
146
|
const endpoint = process.env.GCP_ENDPOINT_URL;
|
|
@@ -76,6 +149,7 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
|
|
|
76
149
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
77
150
|
const absent: string[] = [];
|
|
78
151
|
const targets = readTargets(options.entities);
|
|
152
|
+
const manifest = readManifest(options.entities, targets);
|
|
79
153
|
|
|
80
154
|
await Promise.all(
|
|
81
155
|
[...options.entities].map(async ([entityName, { entityType }]) => {
|
|
@@ -94,13 +168,16 @@ export async function observeHealthGcp(options: GcpHealthOptions): Promise<Healt
|
|
|
94
168
|
return;
|
|
95
169
|
}
|
|
96
170
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
name,
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
-
|
|
171
|
+
const client = { project, ...(endpoint ? { endpoint } : {}), ...(options.http ? { http: options.http } : {}), manifest };
|
|
172
|
+
const body = await getResource(client, gvk.kind, name, props);
|
|
173
|
+
if (gvk.kind === "ComputeBackendService") {
|
|
174
|
+
const groups = await backendGroupsHealth(readUrl(client, gvk.kind, name, props), body, options.http ?? defaultReadHttp);
|
|
175
|
+
entities[entityName] = backendServiceEntry(groups, observedAt);
|
|
176
|
+
} else if (gvk.kind === "IAMPolicyMember") {
|
|
177
|
+
entities[entityName] = policyMemberEntry(body as { role?: string; member?: string; resource?: string }, observedAt);
|
|
178
|
+
} else {
|
|
179
|
+
entities[entityName] = cloudRunEntry(body as CloudRunServiceState, observedAt);
|
|
180
|
+
}
|
|
104
181
|
} catch (err) {
|
|
105
182
|
if (isNotFound(err)) {
|
|
106
183
|
absent.push(entityName);
|
package/src/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export * from "./generated/index";
|
|
|
32
32
|
export {
|
|
33
33
|
GkeCluster, CloudRunServiceComposite, CloudSqlInstance, GcsBucket, VpcNetwork,
|
|
34
34
|
PubSubPipeline, CloudFunctionWithTrigger, PrivateService, ManagedCertificate, SecureProject,
|
|
35
|
-
MemorystoreRedis, MultiRegionVpc, GkeCrdbRegion, GpuNodePool,
|
|
35
|
+
MemorystoreRedis, MultiRegionVpc, GkeCrdbRegion, GpuNodePool, PrivateEgressNetwork, PublicCloudRun,
|
|
36
36
|
GovernanceFoundation, LocationRestriction, OrganizationAuditConfig,
|
|
37
37
|
} from "./composites/index";
|
|
38
38
|
export type {
|
|
@@ -50,6 +50,7 @@ export type {
|
|
|
50
50
|
MultiRegionVpcConfig, MultiRegionVpcRegion,
|
|
51
51
|
GkeCrdbRegionConfig, GkeCrdbRegionNodeConfig,
|
|
52
52
|
GpuNodePoolProps, GpuNodePoolTaint,
|
|
53
|
+
PrivateEgressNetworkProps, PublicCloudRunProps,
|
|
53
54
|
GovernanceFoundationProps, LocationRestrictionProps, OrganizationAuditConfigProps,
|
|
54
55
|
} from "./composites/index";
|
|
55
56
|
|
|
@@ -77,3 +78,14 @@ export type { PackageOptions, PackageResult } from "./codegen/package";
|
|
|
77
78
|
// LSP providers
|
|
78
79
|
export { gcpCompletions } from "./lsp/completions";
|
|
79
80
|
export { gcpHover } from "./lsp/hover";
|
|
81
|
+
|
|
82
|
+
// Component capabilities (#4273): core loads `gcpCapabilityPlugin` when a
|
|
83
|
+
// project's chant.config lists this lexicon, which registers `gcp-apply`.
|
|
84
|
+
export { gcpCapabilityPlugin, GCP_VERB_FAMILIES } from "./components/capability-plugin";
|
|
85
|
+
export {
|
|
86
|
+
gcpApplyCapability,
|
|
87
|
+
createGcpApplyCapability,
|
|
88
|
+
GcpPlanMovedError,
|
|
89
|
+
type GcpApplyStepInput,
|
|
90
|
+
type GcpApplyStepOutput,
|
|
91
|
+
} from "./components/gcp-apply";
|
|
@@ -5,6 +5,7 @@ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant
|
|
|
5
5
|
import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
|
|
6
6
|
import { iamPolicyMemberMapper } from "./gcp-iam";
|
|
7
7
|
import {
|
|
8
|
+
changedFields,
|
|
8
9
|
backendServiceMapper,
|
|
9
10
|
computeAddressMapper,
|
|
10
11
|
forwardingRuleMapper,
|
|
@@ -104,8 +105,21 @@ export interface ApplyPlan {
|
|
|
104
105
|
getUrl: string;
|
|
105
106
|
create: { method: "POST" | "PUT"; url: string; body: unknown };
|
|
106
107
|
update?: { method: "PATCH" | "PUT"; url: string; body: unknown };
|
|
108
|
+
/**
|
|
109
|
+
* In place of `update`, for a resource whose update depends on the live body
|
|
110
|
+
* (#4238): compute updates carry the live `fingerprint`, and some fields only
|
|
111
|
+
* change through a setter (`setTarget`, `setLabels`). Returns the requests to
|
|
112
|
+
* send, none when the live resource already matches, or `replace` naming what
|
|
113
|
+
* differs on a field that can't change in place.
|
|
114
|
+
*/
|
|
115
|
+
reconcile?(live: unknown, http: GcpHttp, signal?: AbortSignal): Promise<ReconcileStep>;
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
/** What {@link ApplyPlan.reconcile} decided for an existing resource. */
|
|
119
|
+
export type ReconcileStep =
|
|
120
|
+
| { requests: Array<{ method: "POST" | "PATCH" | "PUT"; url: string; body: unknown }> }
|
|
121
|
+
| { replace: string };
|
|
122
|
+
|
|
109
123
|
/**
|
|
110
124
|
* Handles an async create whose response is a long-running operation. Present
|
|
111
125
|
* only on mappers for resources that provision asynchronously (Cloud Run, GKE,
|
|
@@ -626,10 +640,62 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
|
|
|
626
640
|
};
|
|
627
641
|
|
|
628
642
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
643
|
+
* What apply does to one existing-or-absent resource, decided from a read
|
|
644
|
+
* (#4273). `applyResource` acts on it and a `gcp-apply` plan reports it, so
|
|
645
|
+
* the two cannot disagree:
|
|
646
|
+
*
|
|
647
|
+
* - `create`: the GET did not answer 200 (`readStatus` says what it did).
|
|
648
|
+
* - `update`: the resource exists and the desired body holds a value the live
|
|
649
|
+
* one does not. `fields` names those top-level fields, `requests` are what
|
|
650
|
+
* apply sends.
|
|
651
|
+
* - `unchanged`: the resource exists and holds every desired value, or its
|
|
652
|
+
* kind has no in-place update.
|
|
653
|
+
* - `replace-required`: a field differs that can't change in place. Apply
|
|
654
|
+
* reports it and leaves the resource alone.
|
|
655
|
+
*
|
|
656
|
+
* `http` is only read through, except by a mapper's `reconcile`, which may GET
|
|
657
|
+
* a referenced resource. A kind with `custom` ops decides from its `read`.
|
|
658
|
+
*/
|
|
659
|
+
export type ResourceDecision =
|
|
660
|
+
| { action: "create"; readStatus: number }
|
|
661
|
+
| { action: "update"; fields: string[]; requests: Array<{ method: string; url: string; body: unknown }>; live: unknown }
|
|
662
|
+
| { action: "unchanged"; live: unknown }
|
|
663
|
+
| { action: "replace-required"; reason: string; live: unknown };
|
|
664
|
+
|
|
665
|
+
export async function decideResource(
|
|
666
|
+
mapper: ResourceMapper,
|
|
667
|
+
resource: GcpResource,
|
|
668
|
+
ctx: MapperCtx,
|
|
669
|
+
http: GcpHttp = defaultHttp,
|
|
670
|
+
signal?: AbortSignal,
|
|
671
|
+
): Promise<ResourceDecision> {
|
|
672
|
+
if (mapper.custom) {
|
|
673
|
+
const read = await mapper.custom.read(resource, ctx, http, signal);
|
|
674
|
+
return read.status === 200 ? { action: "unchanged", live: parseJson(read.text) } : { action: "create", readStatus: read.status };
|
|
675
|
+
}
|
|
676
|
+
const plan = mapper.plan(resource, ctx);
|
|
677
|
+
const get = await http("GET", plan.getUrl, undefined, signal);
|
|
678
|
+
if (get.status !== 200) return { action: "create", readStatus: get.status };
|
|
679
|
+
const live = parseJson(get.text);
|
|
680
|
+
const liveObj = live !== null && typeof live === "object" && !Array.isArray(live) ? (live as Record<string, unknown>) : {};
|
|
681
|
+
const desired = plan.create.body !== null && typeof plan.create.body === "object" ? (plan.create.body as Record<string, unknown>) : {};
|
|
682
|
+
const fields = changedFields(desired, liveObj);
|
|
683
|
+
if (plan.reconcile) {
|
|
684
|
+
const step = await plan.reconcile(live, http, signal);
|
|
685
|
+
if ("replace" in step) return { action: "replace-required", reason: step.replace, live };
|
|
686
|
+
return step.requests.length > 0 ? { action: "update", fields, requests: step.requests, live } : { action: "unchanged", live };
|
|
687
|
+
}
|
|
688
|
+
// A kind with an in-place update sends it only when a desired field differs:
|
|
689
|
+
// a PATCH that changes nothing still costs a Cloud Run revision.
|
|
690
|
+
if (!plan.update || fields.length === 0) return { action: "unchanged", live };
|
|
691
|
+
return { action: "update", fields, requests: [plan.update], live };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Reconcile one resource via its mapper: decide from a GET what it needs
|
|
696
|
+
* ({@link decideResource}), then create it, send its updates, or leave it.
|
|
697
|
+
* Async create/update return an operation that is polled to completion.
|
|
698
|
+
* `http` is injectable for tests.
|
|
633
699
|
*/
|
|
634
700
|
export async function applyResource(
|
|
635
701
|
mapper: ResourceMapper,
|
|
@@ -637,34 +703,39 @@ export async function applyResource(
|
|
|
637
703
|
ctx: MapperCtx,
|
|
638
704
|
http: GcpHttp = defaultHttp,
|
|
639
705
|
signal?: AbortSignal,
|
|
640
|
-
): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
|
|
706
|
+
): Promise<{ kind: string; name: string; created: boolean; updated: boolean; replaceRequired?: string }> {
|
|
641
707
|
const name = resource.metadata?.name ?? "?";
|
|
642
708
|
if (mapper.custom) {
|
|
643
709
|
const result = await mapper.custom.apply(resource, ctx, http, signal);
|
|
644
710
|
return { kind: mapper.kind, name, ...result };
|
|
645
711
|
}
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
712
|
+
const decision = await decideResource(mapper, resource, ctx, http, signal);
|
|
713
|
+
switch (decision.action) {
|
|
714
|
+
case "replace-required":
|
|
715
|
+
// Reported, not attempted: replacing deletes the resource, which apply never does on its own.
|
|
716
|
+
console.log(`replace required: ${mapper.kind}/${name}: ${decision.reason}`);
|
|
717
|
+
return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: decision.reason };
|
|
718
|
+
case "unchanged":
|
|
652
719
|
return { kind: mapper.kind, name, created: false, updated: false };
|
|
720
|
+
case "update":
|
|
721
|
+
for (const req of decision.requests) {
|
|
722
|
+
const res = await http(req.method, req.url, req.body, signal);
|
|
723
|
+
if (res.status >= 300) {
|
|
724
|
+
throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
|
|
725
|
+
}
|
|
726
|
+
await pollIfAsync(mapper, res.text, ctx, http, signal);
|
|
727
|
+
}
|
|
728
|
+
return { kind: mapper.kind, name, created: false, updated: true };
|
|
729
|
+
case "create": {
|
|
730
|
+
const plan = mapper.plan(resource, ctx);
|
|
731
|
+
const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
|
|
732
|
+
if (res.status >= 300) {
|
|
733
|
+
throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
|
|
734
|
+
}
|
|
735
|
+
await pollIfAsync(mapper, res.text, ctx, http, signal);
|
|
736
|
+
return { kind: mapper.kind, name, created: true, updated: false };
|
|
653
737
|
}
|
|
654
|
-
const res = await http(plan.update.method, plan.update.url, plan.update.body, signal);
|
|
655
|
-
if (res.status >= 300) {
|
|
656
|
-
throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
|
|
657
|
-
}
|
|
658
|
-
await pollIfAsync(mapper, res.text, ctx, http, signal);
|
|
659
|
-
return { kind: mapper.kind, name, created: false, updated: true };
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
|
|
663
|
-
if (res.status >= 300) {
|
|
664
|
-
throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
|
|
665
738
|
}
|
|
666
|
-
await pollIfAsync(mapper, res.text, ctx, http, signal);
|
|
667
|
-
return { kind: mapper.kind, name, created: true, updated: false };
|
|
668
739
|
}
|
|
669
740
|
|
|
670
741
|
/**
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { applyResource, MAPPERS, type GcpHttp, type GcpResource } from "./gcp-apply";
|
|
3
|
+
import { changedFields, computeLabels, holds } from "./gcp-compute";
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
delete process.env.GCP_REGION;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const base = "https://compute.googleapis.com";
|
|
10
|
+
const lb: GcpResource[] = [
|
|
11
|
+
{
|
|
12
|
+
kind: "ComputeForwardingRule",
|
|
13
|
+
metadata: { name: "web-fr" },
|
|
14
|
+
spec: {
|
|
15
|
+
location: "global",
|
|
16
|
+
portRange: "80",
|
|
17
|
+
ipAddress: { addressRef: { name: "web-ip" } },
|
|
18
|
+
target: { targetHTTPProxyRef: { name: "web-proxy" } },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{ kind: "ComputeTargetHTTPProxy", metadata: { name: "web-proxy" }, spec: { location: "global", urlMapRef: { name: "web-map" } } },
|
|
22
|
+
{ kind: "ComputeURLMap", metadata: { name: "web-map" }, spec: { location: "global", defaultService: { backendServiceRef: { name: "web" } } } },
|
|
23
|
+
{
|
|
24
|
+
kind: "ComputeBackendService",
|
|
25
|
+
metadata: { name: "web" },
|
|
26
|
+
spec: { location: "global", protocol: "HTTPS", timeoutSec: 30, backend: [{ group: { networkEndpointGroupRef: { name: "web" } } }] },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
kind: "ComputeRegionNetworkEndpointGroup",
|
|
30
|
+
metadata: { name: "web" },
|
|
31
|
+
spec: { region: "europe-west1", networkEndpointType: "SERVERLESS", cloudRun: { serviceRef: { name: "web" } } },
|
|
32
|
+
},
|
|
33
|
+
{ kind: "ComputeAddress", metadata: { name: "web-ip" }, spec: { location: "global" } },
|
|
34
|
+
];
|
|
35
|
+
const ctx = { base, project: "p1", manifest: lb };
|
|
36
|
+
const byKind = (kind: string) => lb.find((r) => r.kind === kind)!;
|
|
37
|
+
const url = (path: string) => `${base}/compute/v1/projects/p1/${path}`;
|
|
38
|
+
const OP = JSON.stringify({ kind: "compute#operation", selfLink: url("global/operations/op-1"), status: "DONE" });
|
|
39
|
+
|
|
40
|
+
/** A fake compute API: GETs answer from `live`, every write is recorded and returns a finished operation. */
|
|
41
|
+
function fakeCompute(live: Record<string, unknown>) {
|
|
42
|
+
const writes: Array<{ method: string; url: string; body: unknown }> = [];
|
|
43
|
+
const http: GcpHttp = async (method, u, body) => {
|
|
44
|
+
if (method === "GET") {
|
|
45
|
+
const hit = live[u];
|
|
46
|
+
if (hit !== undefined) return { status: 200, text: JSON.stringify(hit) };
|
|
47
|
+
if (u.includes("/operations/")) return { status: 200, text: OP };
|
|
48
|
+
return { status: 404, text: "" };
|
|
49
|
+
}
|
|
50
|
+
writes.push({ method, url: u, body });
|
|
51
|
+
return { status: 200, text: OP };
|
|
52
|
+
};
|
|
53
|
+
return { http, writes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("compute reconcile against the live resource (#4238)", () => {
|
|
57
|
+
test("holds: a full compute URL holds its partial form, and live-only fields don't count", () => {
|
|
58
|
+
expect(holds("projects/p1/global/urlMaps/m", url("global/urlMaps/m"))).toBe(true);
|
|
59
|
+
expect(holds({ a: 1 }, { a: 1, b: 2 })).toBe(true);
|
|
60
|
+
expect(holds([{ group: "projects/p1/x" }], [{ group: `${base}/compute/v1/projects/p1/x`, balancingMode: "UTILIZATION" }])).toBe(true);
|
|
61
|
+
expect(holds([1], [1, 2])).toBe(false);
|
|
62
|
+
expect(changedFields({ name: "n", a: 1, b: 2 }, { name: "other", a: 1, b: 3 })).toEqual(["b"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a backend service that matches is left alone; a change is a PATCH carrying the live fingerprint", async () => {
|
|
66
|
+
const liveBody = {
|
|
67
|
+
name: "web",
|
|
68
|
+
fingerprint: "fp-1",
|
|
69
|
+
protocol: "HTTPS",
|
|
70
|
+
timeoutSec: 30,
|
|
71
|
+
backends: [{ group: url("regions/europe-west1/networkEndpointGroups/web"), balancingMode: "UTILIZATION" }],
|
|
72
|
+
};
|
|
73
|
+
const same = fakeCompute({ [url("global/backendServices/web")]: liveBody });
|
|
74
|
+
await expect(applyResource(MAPPERS.ComputeBackendService, byKind("ComputeBackendService"), ctx, same.http)).resolves.toMatchObject({ updated: false });
|
|
75
|
+
expect(same.writes).toEqual([]);
|
|
76
|
+
|
|
77
|
+
const drifted = fakeCompute({ [url("global/backendServices/web")]: { ...liveBody, timeoutSec: 10 } });
|
|
78
|
+
await expect(applyResource(MAPPERS.ComputeBackendService, byKind("ComputeBackendService"), ctx, drifted.http)).resolves.toMatchObject({ updated: true });
|
|
79
|
+
expect(drifted.writes).toHaveLength(1);
|
|
80
|
+
expect(drifted.writes[0]).toMatchObject({ method: "PATCH", url: url("global/backendServices/web"), body: { timeoutSec: 30, fingerprint: "fp-1" } });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a URL map's new default service is a PATCH with the fingerprint", async () => {
|
|
84
|
+
const f = fakeCompute({ [url("global/urlMaps/web-map")]: { name: "web-map", fingerprint: "fp-m", defaultService: url("global/backendServices/old") } });
|
|
85
|
+
await applyResource(MAPPERS.ComputeURLMap, byKind("ComputeURLMap"), ctx, f.http);
|
|
86
|
+
expect(f.writes).toEqual([
|
|
87
|
+
{ method: "PATCH", url: url("global/urlMaps/web-map"), body: expect.objectContaining({ defaultService: "projects/p1/global/backendServices/web", fingerprint: "fp-m" }) },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a proxy's URL map changes through setUrlMap", async () => {
|
|
92
|
+
const f = fakeCompute({ [url("global/targetHttpProxies/web-proxy")]: { name: "web-proxy", urlMap: url("global/urlMaps/old") } });
|
|
93
|
+
await applyResource(MAPPERS.ComputeTargetHTTPProxy, byKind("ComputeTargetHTTPProxy"), ctx, f.http);
|
|
94
|
+
expect(f.writes).toEqual([{ method: "POST", url: url("global/targetHttpProxies/web-proxy/setUrlMap"), body: { urlMap: "projects/p1/global/urlMaps/web-map" } }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a forwarding rule: the declared address's IP holds, the target changes by setTarget, labels by setLabels", async () => {
|
|
98
|
+
const f = fakeCompute({
|
|
99
|
+
[url("global/forwardingRules/web-fr")]: {
|
|
100
|
+
name: "web-fr",
|
|
101
|
+
portRange: "80",
|
|
102
|
+
IPAddress: "34.1.2.3",
|
|
103
|
+
target: url("global/targetHttpProxies/old"),
|
|
104
|
+
labels: {},
|
|
105
|
+
labelFingerprint: "lf-1",
|
|
106
|
+
},
|
|
107
|
+
[url("global/addresses/web-ip")]: { name: "web-ip", address: "34.1.2.3" },
|
|
108
|
+
});
|
|
109
|
+
await applyResource(MAPPERS.ComputeForwardingRule, byKind("ComputeForwardingRule"), ctx, f.http);
|
|
110
|
+
expect(f.writes).toEqual([
|
|
111
|
+
{ method: "POST", url: url("global/forwardingRules/web-fr/setTarget"), body: { target: "projects/p1/global/targetHttpProxies/web-proxy" } },
|
|
112
|
+
{ method: "POST", url: url("global/forwardingRules/web-fr/setLabels"), body: { labels: computeLabels(undefined), labelFingerprint: "lf-1" } },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a forwarding rule on another IP, a changed address and a changed NEG are reported for replacement, not written", async () => {
|
|
117
|
+
const fr = fakeCompute({
|
|
118
|
+
[url("global/forwardingRules/web-fr")]: {
|
|
119
|
+
name: "web-fr",
|
|
120
|
+
portRange: "80",
|
|
121
|
+
IPAddress: "10.0.0.9",
|
|
122
|
+
target: url("global/targetHttpProxies/web-proxy"),
|
|
123
|
+
labels: computeLabels(undefined),
|
|
124
|
+
},
|
|
125
|
+
[url("global/addresses/web-ip")]: { name: "web-ip", address: "34.1.2.3" },
|
|
126
|
+
});
|
|
127
|
+
const r = await applyResource(MAPPERS.ComputeForwardingRule, byKind("ComputeForwardingRule"), ctx, fr.http);
|
|
128
|
+
expect(r).toMatchObject({ updated: false, replaceRequired: expect.stringContaining("IPAddress") });
|
|
129
|
+
expect(fr.writes).toEqual([]);
|
|
130
|
+
|
|
131
|
+
const neg = fakeCompute({
|
|
132
|
+
[url("regions/europe-west1/networkEndpointGroups/web")]: { name: "web", networkEndpointType: "SERVERLESS", cloudRun: { service: "other" } },
|
|
133
|
+
});
|
|
134
|
+
const n = await applyResource(MAPPERS.ComputeRegionNetworkEndpointGroup, byKind("ComputeRegionNetworkEndpointGroup"), ctx, neg.http);
|
|
135
|
+
expect(n.replaceRequired).toMatch(/cloudRun/);
|
|
136
|
+
expect(neg.writes).toEqual([]);
|
|
137
|
+
|
|
138
|
+
const addr = fakeCompute({ [url("global/addresses/web-ip")]: { name: "web-ip", address: "34.1.2.3", labels: computeLabels(undefined), addressType: "INTERNAL" } });
|
|
139
|
+
const changed = { ...byKind("ComputeAddress"), spec: { location: "global", addressType: "EXTERNAL" } };
|
|
140
|
+
const a = await applyResource(MAPPERS.ComputeAddress, changed, ctx, addr.http);
|
|
141
|
+
expect(a.replaceRequired).toMatch(/addressType/);
|
|
142
|
+
expect(addr.writes).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("an address's labels change through setLabels", async () => {
|
|
146
|
+
const f = fakeCompute({ [url("global/addresses/web-ip")]: { name: "web-ip", address: "34.1.2.3", labels: {}, labelFingerprint: "lf-a" } });
|
|
147
|
+
await applyResource(MAPPERS.ComputeAddress, byKind("ComputeAddress"), ctx, f.http);
|
|
148
|
+
expect(f.writes).toEqual([{ method: "POST", url: url("global/addresses/web-ip/setLabels"), body: { labels: computeLabels(undefined), labelFingerprint: "lf-a" } }]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -60,7 +60,7 @@ describe("compute load balancer mappers (#4233)", () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("a serverless NEG is regional and names its Cloud Run service", () => {
|
|
63
|
-
expect(plan("ComputeRegionNetworkEndpointGroup")).
|
|
63
|
+
expect(plan("ComputeRegionNetworkEndpointGroup")).toMatchObject({
|
|
64
64
|
getUrl: "https://compute.googleapis.com/compute/v1/projects/p1/regions/europe-west1/networkEndpointGroups/web",
|
|
65
65
|
create: {
|
|
66
66
|
method: "POST",
|
|
@@ -138,7 +138,9 @@ describe("compute operations (#4233)", () => {
|
|
|
138
138
|
let exists = false;
|
|
139
139
|
const http: GcpHttp = async (method, url) => {
|
|
140
140
|
calls.push(`${method} ${url}`);
|
|
141
|
-
if (method === "GET" && url.endsWith("/addresses/web-ip"))
|
|
141
|
+
if (method === "GET" && url.endsWith("/addresses/web-ip")) {
|
|
142
|
+
return exists ? { status: 200, text: JSON.stringify({ name: "web-ip", labels: computeLabels(undefined) }) } : { status: 404, text: "" };
|
|
143
|
+
}
|
|
142
144
|
if (method === "POST") {
|
|
143
145
|
exists = true;
|
|
144
146
|
return { status: 200, text: JSON.stringify({ kind: "compute#operation", selfLink: "https://compute.googleapis.com/compute/v1/projects/p1/global/operations/op-1", status: "PENDING" }) };
|