@intentius/chant-lexicon-gcp 0.124.38 → 0.124.39

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.
Files changed (52) hide show
  1. package/dist/api/read-client.d.ts +7 -0
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/api/read-client.js +24 -9
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/catalog.js +187 -0
  6. package/dist/composites/index.d.ts +4 -0
  7. package/dist/composites/index.d.ts.map +1 -1
  8. package/dist/composites/index.js +2 -0
  9. package/dist/composites/private-egress-network.d.ts +42 -0
  10. package/dist/composites/private-egress-network.d.ts.map +1 -0
  11. package/dist/composites/private-egress-network.js +48 -0
  12. package/dist/composites/public-cloud-run.d.ts +85 -0
  13. package/dist/composites/public-cloud-run.d.ts.map +1 -0
  14. package/dist/composites/public-cloud-run.js +144 -0
  15. package/dist/deep-observe.d.ts +5 -0
  16. package/dist/deep-observe.d.ts.map +1 -1
  17. package/dist/deep-observe.js +34 -2
  18. package/dist/health.d.ts +26 -2
  19. package/dist/health.d.ts.map +1 -1
  20. package/dist/health.js +85 -6
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/integrity.json +3 -3
  25. package/dist/manifest.json +1 -1
  26. package/dist/op/activities/gcp-apply.d.ts +19 -0
  27. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  28. package/dist/op/activities/gcp-apply.js +16 -0
  29. package/dist/op/activities/gcp-compute.d.ts +9 -0
  30. package/dist/op/activities/gcp-compute.d.ts.map +1 -1
  31. package/dist/op/activities/gcp-compute.js +107 -11
  32. package/dist/plugin.d.ts.map +1 -1
  33. package/dist/plugin.js +6 -2
  34. package/dist/skills/chant-gcp.md +2 -0
  35. package/package.json +2 -2
  36. package/src/api/read-client.ts +23 -8
  37. package/src/composites/catalog.ts +187 -0
  38. package/src/composites/cloud-run-lb.test.ts +93 -0
  39. package/src/composites/index.ts +4 -0
  40. package/src/composites/private-egress-network.ts +82 -0
  41. package/src/composites/public-cloud-run.ts +245 -0
  42. package/src/deep-observe.test.ts +46 -0
  43. package/src/deep-observe.ts +38 -2
  44. package/src/health.test.ts +65 -1
  45. package/src/health.ts +88 -11
  46. package/src/index.ts +2 -1
  47. package/src/op/activities/gcp-apply.ts +31 -1
  48. package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
  49. package/src/op/activities/gcp-compute.test.ts +4 -2
  50. package/src/op/activities/gcp-compute.ts +129 -11
  51. package/src/plugin.ts +6 -2
  52. package/src/skills/chant-gcp.md +2 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * PublicCloudRun composite: a Cloud Run service behind a global external
3
+ * Application Load Balancer, running as its own service account with Direct
4
+ * VPC egress into a subnet.
5
+ */
6
+
7
+ import { Composite, mergeDefaults } from "@intentius/chant";
8
+ import {
9
+ BackendService,
10
+ ComputeAddress,
11
+ ForwardingRule,
12
+ GCPServiceAccount,
13
+ ManagedSSLCertificate,
14
+ RegionNetworkEndpointGroup,
15
+ TargetHTTPProxy,
16
+ TargetHTTPSProxy,
17
+ URLMap,
18
+ } from "../generated";
19
+ import { GCP } from "../pseudo";
20
+ import { CloudRunServiceComposite } from "./cloud-run-service";
21
+
22
+ export interface PublicCloudRunProps {
23
+ /** Name prefix every resource is named with. */
24
+ name: string;
25
+ /** Container image. */
26
+ image: string;
27
+ /** GCP region of the service and its network endpoint group. */
28
+ region: string;
29
+ /** GCP project, as `projectRef.external` (default: GCP.ProjectId, resolved from GCP_PROJECT_ID at build). */
30
+ projectId?: string;
31
+ /** VPC the service's egress goes into, such as a PrivateEgressNetwork's `vpc`. */
32
+ vpc: { name: string };
33
+ /** Subnet the service's egress goes into, such as a PrivateEgressNetwork's `subnet`. */
34
+ subnet: { name: string };
35
+ /** Egress sent through the VPC: "PRIVATE_RANGES_ONLY" or "ALL_TRAFFIC" (default: "PRIVATE_RANGES_ONLY"). */
36
+ vpcEgress?: string;
37
+ /** Port the container listens on (default: 8080). */
38
+ port?: number;
39
+ /** Port the HTTP forwarding rule listens on (default: 80). */
40
+ frontendPort?: number;
41
+ /** Minimum instances (default: 0). */
42
+ minInstanceCount?: number;
43
+ /** Maximum instances (default: 100). */
44
+ maxInstanceCount?: number;
45
+ /** Environment variables. */
46
+ env?: Array<{ name: string; value: string }>;
47
+ /** An existing service account to run as: an email, or the account id of an IAMServiceAccount in the project. When unset, the composite creates `<name>-run`. */
48
+ serviceAccount?: string;
49
+ /** Domains for a Google-managed certificate. When set, the composite adds an HTTPS proxy and a rule on 443, and the HTTP rule redirects to HTTPS. */
50
+ domains?: string[];
51
+ /** Name of a ComputeSecurityPolicy (Cloud Armor) attached to the backend service. */
52
+ securityPolicy?: string;
53
+ /** Additional labels. */
54
+ labels?: Record<string, string>;
55
+ /** Namespace for all resources. */
56
+ namespace?: string;
57
+ /** Per-member defaults for customizing individual resources. */
58
+ defaults?: {
59
+ runtime?: Partial<ConstructorParameters<typeof GCPServiceAccount>[0]>;
60
+ service?: Record<string, unknown>;
61
+ publicAccess?: Record<string, unknown>;
62
+ neg?: Partial<ConstructorParameters<typeof RegionNetworkEndpointGroup>[0]>;
63
+ backend?: Partial<ConstructorParameters<typeof BackendService>[0]>;
64
+ urlMap?: Partial<ConstructorParameters<typeof URLMap>[0]>;
65
+ proxy?: Partial<ConstructorParameters<typeof TargetHTTPProxy>[0]>;
66
+ address?: Partial<ConstructorParameters<typeof ComputeAddress>[0]>;
67
+ rule?: Partial<ConstructorParameters<typeof ForwardingRule>[0]>;
68
+ certificate?: Partial<ConstructorParameters<typeof ManagedSSLCertificate>[0]>;
69
+ httpsProxy?: Partial<ConstructorParameters<typeof TargetHTTPSProxy>[0]>;
70
+ httpsRule?: Partial<ConstructorParameters<typeof ForwardingRule>[0]>;
71
+ redirectMap?: Partial<ConstructorParameters<typeof URLMap>[0]>;
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Create a PublicCloudRun composite.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { PrivateEgressNetwork, PublicCloudRun } from "@intentius/chant-lexicon-gcp";
81
+ *
82
+ * const network = PrivateEgressNetwork({ name: "web", region: "us-central1", subnetCidr: "10.10.0.0/24" });
83
+ * const web = PublicCloudRun({
84
+ * name: "web",
85
+ * projectId: "my-project",
86
+ * region: "us-central1",
87
+ * vpc: network.vpc,
88
+ * subnet: network.subnet,
89
+ * image: "us-docker.pkg.dev/my-project/web/site:1",
90
+ * });
91
+ * ```
92
+ */
93
+ export const PublicCloudRun = Composite((props: PublicCloudRunProps) => {
94
+ const {
95
+ name,
96
+ image,
97
+ region,
98
+ projectId,
99
+ vpc,
100
+ subnet,
101
+ vpcEgress = "PRIVATE_RANGES_ONLY",
102
+ port = 8080,
103
+ frontendPort = 80,
104
+ minInstanceCount = 0,
105
+ maxInstanceCount = 100,
106
+ env,
107
+ serviceAccount,
108
+ domains,
109
+ securityPolicy,
110
+ labels: extraLabels = {},
111
+ namespace,
112
+ defaults: defs,
113
+ } = props;
114
+
115
+ const commonLabels: Record<string, string> = {
116
+ "app.kubernetes.io/name": name,
117
+ "app.kubernetes.io/managed-by": "chant",
118
+ ...extraLabels,
119
+ };
120
+ const meta = (suffix: string, component: string) => ({
121
+ name: `${name}-${suffix}`,
122
+ ...(namespace && { namespace }),
123
+ labels: { ...commonLabels, "app.kubernetes.io/component": component },
124
+ });
125
+
126
+ const result: Record<string, any> = {};
127
+
128
+ if (!serviceAccount) {
129
+ result.runtime = new GCPServiceAccount(mergeDefaults({
130
+ metadata: meta("run", "service-account"),
131
+ displayName: `${name} Cloud Run runtime`,
132
+ } as Record<string, unknown>, defs?.runtime));
133
+ }
134
+
135
+ const run = CloudRunServiceComposite({
136
+ name: `${name}-site`,
137
+ image,
138
+ projectId,
139
+ location: region,
140
+ port,
141
+ minInstanceCount,
142
+ maxInstanceCount,
143
+ env,
144
+ publicAccess: true,
145
+ serviceAccount: serviceAccount ?? `${name}-run`,
146
+ labels: extraLabels,
147
+ namespace,
148
+ defaults: {
149
+ service: mergeDefaults({
150
+ template: {
151
+ vpcAccess: {
152
+ egress: vpcEgress,
153
+ networkInterfaces: [{ networkRef: { name: vpc.name }, subnetworkRef: { name: subnet.name } }],
154
+ },
155
+ },
156
+ } as Record<string, unknown>, defs?.service) as any,
157
+ publicIam: defs?.publicAccess as any,
158
+ },
159
+ });
160
+ result.service = run.service;
161
+ result.publicAccess = run.publicIam;
162
+
163
+ const neg = new RegionNetworkEndpointGroup(mergeDefaults({
164
+ metadata: meta("neg", "neg"),
165
+ region,
166
+ networkEndpointType: "SERVERLESS",
167
+ cloudRun: { serviceRef: { name: run.service.name } },
168
+ } as Record<string, unknown>, defs?.neg));
169
+
170
+ // A backend service over a serverless NEG takes no health check: Cloud Run
171
+ // reports its own readiness.
172
+ const backend = new BackendService(mergeDefaults({
173
+ metadata: meta("backend", "backend"),
174
+ location: "global",
175
+ loadBalancingScheme: "EXTERNAL_MANAGED",
176
+ protocol: "HTTP",
177
+ backend: [{ group: { networkEndpointGroupRef: { name: neg.name } } }],
178
+ ...(securityPolicy && { securityPolicyRef: { name: securityPolicy } }),
179
+ } as Record<string, unknown>, defs?.backend));
180
+
181
+ const urlMap = new URLMap(mergeDefaults({
182
+ metadata: meta("map", "url-map"),
183
+ location: "global",
184
+ defaultService: { backendServiceRef: { name: backend.name } },
185
+ } as Record<string, unknown>, defs?.urlMap));
186
+
187
+ const address = new ComputeAddress(mergeDefaults({
188
+ metadata: meta("ip", "address"),
189
+ location: "global",
190
+ } as Record<string, unknown>, defs?.address));
191
+
192
+ Object.assign(result, { neg, backend, urlMap, address });
193
+
194
+ // With domains, HTTPS serves the URL map and HTTP serves a redirect to it.
195
+ let httpMapName: string = urlMap.name;
196
+ if (domains && domains.length > 0) {
197
+ const certificate = new ManagedSSLCertificate(mergeDefaults({
198
+ metadata: meta("cert", "certificate"),
199
+ projectRef: { external: projectId ?? GCP.ProjectId },
200
+ managed: { domains },
201
+ } as Record<string, unknown>, defs?.certificate));
202
+
203
+ const httpsProxy = new TargetHTTPSProxy(mergeDefaults({
204
+ metadata: meta("https-proxy", "proxy"),
205
+ location: "global",
206
+ urlMapRef: { name: urlMap.name },
207
+ sslCertificates: [{ name: certificate.name }],
208
+ } as Record<string, unknown>, defs?.httpsProxy));
209
+
210
+ const httpsRule = new ForwardingRule(mergeDefaults({
211
+ metadata: meta("https-rule", "forwarding-rule"),
212
+ location: "global",
213
+ loadBalancingScheme: "EXTERNAL_MANAGED",
214
+ portRange: "443",
215
+ ipAddress: { addressRef: { name: address.name } },
216
+ target: { targetHTTPSProxyRef: { name: httpsProxy.name } },
217
+ } as Record<string, unknown>, defs?.httpsRule));
218
+
219
+ const redirectMap = new URLMap(mergeDefaults({
220
+ metadata: meta("redirect", "url-map"),
221
+ location: "global",
222
+ defaultUrlRedirect: { httpsRedirect: true, stripQuery: false },
223
+ } as Record<string, unknown>, defs?.redirectMap));
224
+ httpMapName = redirectMap.name;
225
+
226
+ Object.assign(result, { certificate, httpsProxy, httpsRule, redirectMap });
227
+ }
228
+
229
+ result.proxy = new TargetHTTPProxy(mergeDefaults({
230
+ metadata: meta("proxy", "proxy"),
231
+ location: "global",
232
+ urlMapRef: { name: httpMapName },
233
+ } as Record<string, unknown>, defs?.proxy));
234
+
235
+ result.rule = new ForwardingRule(mergeDefaults({
236
+ metadata: meta("rule", "forwarding-rule"),
237
+ location: "global",
238
+ loadBalancingScheme: "EXTERNAL_MANAGED",
239
+ portRange: String(frontendPort),
240
+ ipAddress: { addressRef: { name: address.name } },
241
+ target: { targetHTTPProxyRef: { name: result.proxy.name } },
242
+ } as Record<string, unknown>, defs?.rule));
243
+
244
+ return result;
245
+ }, "PublicCloudRun");
@@ -352,3 +352,49 @@ describe("observeResourcesDeepGcp — over REST (#1209)", () => {
352
352
  expect(fetchMock).not.toHaveBeenCalled();
353
353
  });
354
354
  });
355
+
356
+ describe("a forwarding rule's IP read back as its declared addressRef (#4238)", () => {
357
+ beforeEach(() => {
358
+ fetchMock.mockReset();
359
+ vi.stubGlobal("fetch", fetchMock);
360
+ process.env.GOOGLE_CLOUD_PROJECT = "my-project";
361
+ process.env.GCP_ENDPOINT_URL = "http://localhost:4588";
362
+ });
363
+ afterEach(() => {
364
+ vi.unstubAllGlobals();
365
+ delete process.env.GCP_ENDPOINT_URL;
366
+ delete process.env.GOOGLE_CLOUD_PROJECT;
367
+ });
368
+
369
+ test("toCnrmTree: the declared address's IP becomes the addressRef; any other IP stays an ip", () => {
370
+ const ref = { name: "web-ip" };
371
+ expect(toCnrmTree("ComputeForwardingRule", { name: "fr", IPAddress: "34.1.2.3" }, { project: "p", address: { ip: "34.1.2.3", ref } })).toMatchObject({
372
+ ipAddress: { addressRef: ref },
373
+ });
374
+ const other = toCnrmTree("ComputeForwardingRule", { name: "fr", IPAddress: "10.0.0.9" }, { project: "p", address: { ip: "34.1.2.3", ref } });
375
+ expect(other).toMatchObject({ ipAddress: { ip: "10.0.0.9" } });
376
+ expect(other).not.toHaveProperty("IPAddress");
377
+ });
378
+
379
+ test("observe reads the declared address and reports the rule's ipAddress as declared", async () => {
380
+ fetchMock.mockImplementation(async (url: string) => {
381
+ if (url.endsWith("/global/forwardingRules/web-fr")) {
382
+ return reply(200, { name: "web-fr", IPAddress: "34.1.2.3", portRange: "80", target: "projects/my-project/global/targetHttpProxies/web-proxy" });
383
+ }
384
+ if (url.endsWith("/global/addresses/web-ip")) return reply(200, { name: "web-ip", address: "34.1.2.3" });
385
+ return reply(404, {});
386
+ });
387
+ const result = await observe(
388
+ entities([
389
+ [
390
+ "rule",
391
+ "GCP::Compute::ForwardingRule",
392
+ { metadata: { name: "web-fr" }, location: "global", portRange: "80", ipAddress: { addressRef: { name: "web-ip" } }, target: { targetHTTPProxyRef: { name: "web-proxy" } } },
393
+ ],
394
+ ["ip", "GCP::Compute::Address", { metadata: { name: "web-ip" }, location: "global" }],
395
+ ]),
396
+ );
397
+ expect(result.resources.rule?.properties).toMatchObject({ ipAddress: { addressRef: { name: "web-ip" } } });
398
+ expect(result.resources.rule?.properties).not.toHaveProperty("IPAddress");
399
+ });
400
+ });
@@ -122,6 +122,8 @@ function propertiesTreeOf(obj: Record<string, unknown>): Record<string, unknown>
122
122
  interface RefineContext {
123
123
  project: string;
124
124
  restName?: string;
125
+ /** A forwarding rule's declared `ipAddress.addressRef` and that address's live IP (#4238). */
126
+ address?: { ip: string; ref: unknown };
125
127
  }
126
128
 
127
129
  type CnrmTree = Record<string, unknown> & { metadata?: Record<string, unknown> };
@@ -316,8 +318,14 @@ const CNRM_REFINERS: Record<string, (tree: CnrmTree, ctx: RefineContext) => void
316
318
  delete tree.gatewayAddress;
317
319
  delete tree.fingerprint;
318
320
  },
319
- ComputeForwardingRule(tree) {
321
+ ComputeForwardingRule(tree, ctx) {
320
322
  computeLocation(tree);
323
+ // Compute returns the IP; CNRM writes `ipAddress: { addressRef }` or `{ ip }`.
324
+ // The IP is the declared address's own when the read found it so (#4238).
325
+ if (typeof tree.IPAddress === "string") {
326
+ tree.ipAddress = ctx.address && ctx.address.ip === tree.IPAddress ? { addressRef: ctx.address.ref } : { ip: tree.IPAddress };
327
+ delete tree.IPAddress;
328
+ }
321
329
  if (tree.IPProtocol !== undefined) {
322
330
  tree.ipProtocol = tree.IPProtocol;
323
331
  delete tree.IPProtocol;
@@ -374,6 +382,33 @@ export function toCnrmTree(kind: string, body: Record<string, unknown>, ctx: Ref
374
382
  return tree as Record<string, unknown>;
375
383
  }
376
384
 
385
+ /**
386
+ * The live IP of the address a forwarding rule declares in
387
+ * `ipAddress.addressRef`, read with the address's own declaration when the
388
+ * manifest holds it, so the rule's `IPAddress` can be matched to the ref
389
+ * rather than read as drift (#4238). Undefined when there is no ref or the
390
+ * read fails; the rule then shows the IP as it is.
391
+ */
392
+ async function declaredAddressIp(
393
+ client: GcpReadClientOptions,
394
+ props: Record<string, unknown>,
395
+ manifest: readonly { kind?: string; metadata?: { name?: string }; spec?: Record<string, unknown> }[],
396
+ ): Promise<{ ip: string; ref: unknown } | undefined> {
397
+ const spec = (props.spec as Record<string, unknown> | undefined) ?? props;
398
+ const ref = (spec.ipAddress as { addressRef?: { name?: unknown; external?: unknown } } | undefined)?.addressRef;
399
+ if (!ref) return undefined;
400
+ const raw = typeof ref.name === "string" ? ref.name : typeof ref.external === "string" ? ref.external : undefined;
401
+ if (!raw) return undefined;
402
+ const name = raw.slice(raw.lastIndexOf("/") + 1);
403
+ const declared = manifest.find((r) => r.kind === "ComputeAddress" && r.metadata?.name === name);
404
+ try {
405
+ const body = await getResource(client, "ComputeAddress", name, declared?.spec ?? { location: spec.location });
406
+ return typeof body.address === "string" ? { ip: body.address, ref } : undefined;
407
+ } catch {
408
+ return undefined;
409
+ }
410
+ }
411
+
377
412
  /**
378
413
  * Read the live property tree for each declared entity over the applier's
379
414
  * REST transport, normalized into the declared CNRM shape (see the module
@@ -425,6 +460,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
425
460
  try {
426
461
  const obj = await getResource(client, gvk.kind, name, props);
427
462
  const labels = obj.labels as Record<string, string> | null | undefined;
463
+ const address = gvk.kind === "ComputeForwardingRule" ? await declaredAddressIp(client, props, manifest) : undefined;
428
464
 
429
465
  // owned filter: withhold what does not carry chant's marker. Withheld is
430
466
  // not absent (#1089). Where the payload has no labels at all there is
@@ -443,7 +479,7 @@ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): P
443
479
  type: entityType,
444
480
  physicalId:
445
481
  (obj.id as string | undefined) ?? (obj.selfLink as string | undefined) ?? (obj.name as string | undefined),
446
- properties: normalizeDeepProperties(toCnrmTree(gvk.kind, obj, { project: client.project }), {
482
+ properties: normalizeDeepProperties(toCnrmTree(gvk.kind, obj, { project: client.project, ...(address ? { address } : {}) }), {
447
483
  entityType,
448
484
  side: "live",
449
485
  // The static table is the whole prune here — there is no per-resource
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
- import { cloudRunEntry, observeHealthGcp } from "./health";
2
+ import { backendServiceEntry, cloudRunEntry, GCP_HEALTH_TYPES, observeHealthGcp } from "./health";
3
+ import { gcpPlugin } from "./plugin";
3
4
  import type { GcpReadHttp } from "./api/read-client";
4
5
 
5
6
  const AT = "2026-10-20T21:00:00.000Z";
@@ -56,4 +57,67 @@ describe("gcp health (#4177)", () => {
56
57
  const r = await observeHealthGcp({ environment: "dev", buildOutput: "", entities, http: denied });
57
58
  expect(r.unobserved.api?.reason).toBe("no-credentials");
58
59
  });
60
+
61
+ test("backend service: any unhealthy endpoint is unhealthy, all healthy is healthy, no status is unknown (#4239)", () => {
62
+ const g = (healthState: string) => ({ group: "g", healthStatus: [{ healthState }] });
63
+ expect(backendServiceEntry([g("HEALTHY"), g("HEALTHY")], AT)).toMatchObject({ state: "healthy", detail: "2 of 2 backends healthy" });
64
+ expect(backendServiceEntry([g("HEALTHY"), g("UNHEALTHY")], AT)).toMatchObject({ state: "unhealthy", detail: "1 of 2 backends healthy, 1 unhealthy" });
65
+ // A serverless NEG reports no health status; a failed call counts the same.
66
+ expect(backendServiceEntry([{ group: "neg", healthStatus: [] }], AT)).toMatchObject({ state: "unknown", detail: "0 of 1 backend healthy, 1 with no health status" });
67
+ expect(backendServiceEntry([{ group: "g", error: "getHealth 400" }], AT).state).toBe("unknown");
68
+ expect(backendServiceEntry([], AT)).toMatchObject({ state: "unknown", detail: "no backends" });
69
+ });
70
+
71
+ test("a backend service is read, then getHealth is called for each backend group at its URL (#4239)", async () => {
72
+ const calls: Array<{ method: string; url: string; body?: unknown }> = [];
73
+ const group = "https://compute.googleapis.com/compute/v1/projects/demo/zones/us-central1-a/instanceGroups/ig";
74
+ const http: GcpReadHttp = async (method, url, body) => {
75
+ calls.push({ method, url, ...(body === undefined ? {} : { body }) });
76
+ if (method === "GET") return { status: 200, text: JSON.stringify({ name: "web", backends: [{ group }] }) };
77
+ return { status: 200, text: JSON.stringify({ healthStatus: [{ healthState: "HEALTHY" }, { healthState: "HEALTHY" }] }) };
78
+ };
79
+ const lb = new Map([
80
+ [
81
+ "backend",
82
+ {
83
+ entityType: "GCP::Compute::BackendService",
84
+ props: { metadata: { name: "web", annotations: { "cnrm.cloud.google.com/project-id": "demo" } }, location: "global" },
85
+ },
86
+ ],
87
+ ]);
88
+ const r = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: lb, http, now });
89
+ expect(r.entities.backend).toMatchObject({ type: "GCP::Compute::BackendService", state: "healthy" });
90
+ expect(calls[1]).toEqual({ method: "POST", url: "http://localhost:4588/compute/v1/projects/demo/global/backendServices/web/getHealth", body: { group } });
91
+ });
92
+
93
+ test("an IAM policy member in the target's policy is healthy; one missing from it is absent (#4239)", async () => {
94
+ const iam = new Map<string, { entityType: string; props: Record<string, unknown> }>([
95
+ [
96
+ "publicIam",
97
+ {
98
+ entityType: "GCP::Iam::PolicyMember",
99
+ props: {
100
+ metadata: { name: "api-public", annotations: { "cnrm.cloud.google.com/project-id": "demo" } },
101
+ member: "allUsers",
102
+ role: "roles/run.invoker",
103
+ resourceRef: { kind: "RunService", name: "api" },
104
+ },
105
+ },
106
+ ],
107
+ ...entities,
108
+ ]);
109
+ const policy = (members: string[]): GcpReadHttp => async (_m, url) =>
110
+ url.includes(":getIamPolicy")
111
+ ? { status: 200, text: JSON.stringify({ bindings: [{ role: "roles/run.invoker", members }] }) }
112
+ : { status: 200, text: JSON.stringify({ terminalCondition: { type: "Ready", state: "CONDITION_SUCCEEDED" } }) };
113
+ const present = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: iam, http: policy(["allUsers"]), now });
114
+ expect(present.entities.publicIam).toMatchObject({ type: "GCP::Iam::PolicyMember", state: "healthy" });
115
+ expect(present.entities.publicIam?.detail).toMatch(/^allUsers holds roles\/run\.invoker on /);
116
+ const missing = await observeHealthGcp({ environment: "dev", buildOutput: "", entities: iam, http: policy(["user:x@y.z"]), now });
117
+ expect(missing.absent).toContain("publicIam");
118
+ });
119
+
120
+ test("the plugin's healthTypes match the reader's", () => {
121
+ expect([...(gcpPlugin.healthTypes ?? [])]).toEqual([...GCP_HEALTH_TYPES]);
122
+ });
59
123
  });
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 body = await getResource(
98
- { project, ...(endpoint ? { endpoint } : {}), ...(options.http ? { http: options.http } : {}) },
99
- gvk.kind,
100
- name,
101
- props,
102
- );
103
- entities[entityName] = cloudRunEntry(body as CloudRunServiceState, observedAt);
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
 
@@ -104,8 +104,21 @@ export interface ApplyPlan {
104
104
  getUrl: string;
105
105
  create: { method: "POST" | "PUT"; url: string; body: unknown };
106
106
  update?: { method: "PATCH" | "PUT"; url: string; body: unknown };
107
+ /**
108
+ * In place of `update`, for a resource whose update depends on the live body
109
+ * (#4238): compute updates carry the live `fingerprint`, and some fields only
110
+ * change through a setter (`setTarget`, `setLabels`). Returns the requests to
111
+ * send, none when the live resource already matches, or `replace` naming what
112
+ * differs on a field that can't change in place.
113
+ */
114
+ reconcile?(live: unknown, http: GcpHttp, signal?: AbortSignal): Promise<ReconcileStep>;
107
115
  }
108
116
 
117
+ /** What {@link ApplyPlan.reconcile} decided for an existing resource. */
118
+ export type ReconcileStep =
119
+ | { requests: Array<{ method: "POST" | "PATCH" | "PUT"; url: string; body: unknown }> }
120
+ | { replace: string };
121
+
109
122
  /**
110
123
  * Handles an async create whose response is a long-running operation. Present
111
124
  * only on mappers for resources that provision asynchronously (Cloud Run, GKE,
@@ -637,7 +650,7 @@ export async function applyResource(
637
650
  ctx: MapperCtx,
638
651
  http: GcpHttp = defaultHttp,
639
652
  signal?: AbortSignal,
640
- ): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
653
+ ): Promise<{ kind: string; name: string; created: boolean; updated: boolean; replaceRequired?: string }> {
641
654
  const name = resource.metadata?.name ?? "?";
642
655
  if (mapper.custom) {
643
656
  const result = await mapper.custom.apply(resource, ctx, http, signal);
@@ -646,6 +659,23 @@ export async function applyResource(
646
659
  const plan = mapper.plan(resource, ctx);
647
660
  const get = await http("GET", plan.getUrl, undefined, signal);
648
661
 
662
+ if (get.status === 200 && plan.reconcile) {
663
+ const step = await plan.reconcile(parseJson(get.text), http, signal);
664
+ if ("replace" in step) {
665
+ // Reported, not attempted: replacing deletes the resource, which apply never does on its own.
666
+ console.log(`replace required: ${mapper.kind}/${name}: ${step.replace}`);
667
+ return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: step.replace };
668
+ }
669
+ for (const req of step.requests) {
670
+ const res = await http(req.method, req.url, req.body, signal);
671
+ if (res.status >= 300) {
672
+ throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
673
+ }
674
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
675
+ }
676
+ return { kind: mapper.kind, name, created: false, updated: step.requests.length > 0 };
677
+ }
678
+
649
679
  if (get.status === 200) {
650
680
  // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
651
681
  if (!plan.update) {