@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.
Files changed (63) 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/components/capability-plugin.d.ts +14 -0
  5. package/dist/components/capability-plugin.d.ts.map +1 -0
  6. package/dist/components/capability-plugin.js +21 -0
  7. package/dist/components/gcp-apply.d.ts +104 -0
  8. package/dist/components/gcp-apply.d.ts.map +1 -0
  9. package/dist/components/gcp-apply.js +335 -0
  10. package/dist/composites/catalog.d.ts.map +1 -1
  11. package/dist/composites/catalog.js +187 -0
  12. package/dist/composites/index.d.ts +4 -0
  13. package/dist/composites/index.d.ts.map +1 -1
  14. package/dist/composites/index.js +2 -0
  15. package/dist/composites/private-egress-network.d.ts +42 -0
  16. package/dist/composites/private-egress-network.d.ts.map +1 -0
  17. package/dist/composites/private-egress-network.js +48 -0
  18. package/dist/composites/public-cloud-run.d.ts +85 -0
  19. package/dist/composites/public-cloud-run.d.ts.map +1 -0
  20. package/dist/composites/public-cloud-run.js +144 -0
  21. package/dist/deep-observe.d.ts +5 -0
  22. package/dist/deep-observe.d.ts.map +1 -1
  23. package/dist/deep-observe.js +34 -2
  24. package/dist/health.d.ts +26 -2
  25. package/dist/health.d.ts.map +1 -1
  26. package/dist/health.js +85 -6
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +5 -1
  30. package/dist/integrity.json +3 -3
  31. package/dist/manifest.json +1 -1
  32. package/dist/op/activities/gcp-apply.d.ts +61 -4
  33. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  34. package/dist/op/activities/gcp-apply.js +54 -22
  35. package/dist/op/activities/gcp-compute.d.ts +9 -0
  36. package/dist/op/activities/gcp-compute.d.ts.map +1 -1
  37. package/dist/op/activities/gcp-compute.js +107 -11
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +6 -2
  40. package/dist/skills/chant-gcp.md +2 -0
  41. package/package.json +2 -2
  42. package/src/api/read-client.ts +23 -8
  43. package/src/components/capability-plugin.ts +25 -0
  44. package/src/components/gcp-apply-floci.e2e.test.ts +80 -0
  45. package/src/components/gcp-apply.test.ts +259 -0
  46. package/src/components/gcp-apply.ts +406 -0
  47. package/src/composites/catalog.ts +187 -0
  48. package/src/composites/cloud-run-lb.test.ts +93 -0
  49. package/src/composites/index.ts +4 -0
  50. package/src/composites/private-egress-network.ts +82 -0
  51. package/src/composites/public-cloud-run.ts +245 -0
  52. package/src/deep-observe.test.ts +46 -0
  53. package/src/deep-observe.ts +38 -2
  54. package/src/health.test.ts +65 -1
  55. package/src/health.ts +88 -11
  56. package/src/index.ts +13 -1
  57. package/src/op/activities/gcp-apply.ts +95 -24
  58. package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
  59. package/src/op/activities/gcp-compute.test.ts +4 -2
  60. package/src/op/activities/gcp-compute.ts +129 -11
  61. package/src/plugin.test.ts +10 -0
  62. package/src/plugin.ts +6 -2
  63. 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 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
 
@@ -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
- * Reconcile one resource via its mapper: `GET` it; if present, PATCH it to the
630
- * desired state (when the mapper supports `update`, else leave it); if absent,
631
- * create it. Async create/update return an operation that is polled to
632
- * completion. `http` is injectable for tests.
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 plan = mapper.plan(resource, ctx);
647
- const get = await http("GET", plan.getUrl, undefined, signal);
648
-
649
- if (get.status === 200) {
650
- // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
651
- if (!plan.update) {
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")).toEqual({
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")) return exists ? { status: 200, text: "{}" } : { status: 404, text: "" };
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" }) };