@intentius/chant-lexicon-gcp 0.124.37 → 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.
- package/dist/api/read-client.d.ts +14 -1
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +25 -10
- 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 +37 -3
- package/dist/describe-resources.d.ts +13 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +19 -0
- 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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +19 -0
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +16 -0
- 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 +30 -9
- 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 +41 -3
- package/src/describe-resources.ts +23 -1
- package/src/health.test.ts +65 -1
- package/src/health.ts +88 -11
- package/src/index.ts +2 -1
- package/src/op/activities/gcp-apply.ts +31 -1
- 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.ts +6 -2
- package/src/project-config.test.ts +50 -3
- package/src/read-refs.test.ts +60 -0
- 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
|
|
|
@@ -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) {
|
|
@@ -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" }) };
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
* backend service can point at a regional NEG.
|
|
15
15
|
*
|
|
16
16
|
* Inserts return a compute `Operation`, polled at its `selfLink` (rebased onto
|
|
17
|
-
* the endpoint override, so floci-gcp answers).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* the endpoint override, so floci-gcp answers).
|
|
18
|
+
*
|
|
19
|
+
* An existing load balancer resource is compared with its desired body and
|
|
20
|
+
* updated in place where compute allows it (#4238): a backend service or URL
|
|
21
|
+
* map by PATCH with the live `fingerprint`, a proxy's URL map by `setUrlMap`,
|
|
22
|
+
* a forwarding rule's target by `setTarget`, labels by `setLabels` with the
|
|
23
|
+
* live `labelFingerprint`. A difference on a field compute can't change in
|
|
24
|
+
* place (anything on a serverless NEG, an address's IP) is reported as
|
|
25
|
+
* "replace required" and left alone. A network and a subnetwork still
|
|
26
|
+
* reconcile by existence only.
|
|
20
27
|
*/
|
|
21
28
|
import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
|
|
22
29
|
import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
23
|
-
import type { GcpResource, OperationSpec, ResourceMapper } from "./gcp-apply";
|
|
30
|
+
import type { GcpHttp, GcpResource, OperationSpec, ReconcileStep, ResourceMapper } from "./gcp-apply";
|
|
24
31
|
|
|
25
32
|
export const COMPUTE_HOST = "https://compute.googleapis.com";
|
|
26
33
|
|
|
@@ -324,12 +331,103 @@ export const computeOperation: OperationSpec = {
|
|
|
324
331
|
},
|
|
325
332
|
};
|
|
326
333
|
|
|
334
|
+
/** A compute reference with everything before `projects/` dropped, so a full URL and a partial one compare equal. */
|
|
335
|
+
function refTail(v: string): string {
|
|
336
|
+
const at = v.indexOf("projects/");
|
|
337
|
+
return at >= 0 ? v.slice(at) : v;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* True when the live value holds the desired one: every desired key is set
|
|
342
|
+
* live to an equal value, arrays element by element, and a compute URL
|
|
343
|
+
* equals its partial form. Fields only the live side has (defaults, server
|
|
344
|
+
* ids) don't count. Pure.
|
|
345
|
+
*/
|
|
346
|
+
export function holds(desired: unknown, live: unknown): boolean {
|
|
347
|
+
if (typeof desired === "string" && typeof live === "string") {
|
|
348
|
+
return desired === live || (desired.includes("projects/") && refTail(desired) === refTail(live));
|
|
349
|
+
}
|
|
350
|
+
if (Array.isArray(desired)) {
|
|
351
|
+
return Array.isArray(live) && desired.length === live.length && desired.every((d, i) => holds(d, live[i]));
|
|
352
|
+
}
|
|
353
|
+
if (isObj(desired)) {
|
|
354
|
+
return isObj(live) && Object.entries(desired).every(([k, v]) => holds(v, live[k]));
|
|
355
|
+
}
|
|
356
|
+
return desired === live;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The desired body's top-level fields the live resource doesn't hold, `name` aside. Pure. */
|
|
360
|
+
export function changedFields(desired: Obj, live: Obj): string[] {
|
|
361
|
+
return Object.keys(desired).filter((k) => k !== "name" && !holds(desired[k], live[k]));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type Reconcile = (args: {
|
|
365
|
+
url: string;
|
|
366
|
+
desired: Obj;
|
|
367
|
+
live: Obj;
|
|
368
|
+
changed: string[];
|
|
369
|
+
ctx: ComputeCtx;
|
|
370
|
+
http: GcpHttp;
|
|
371
|
+
signal?: AbortSignal;
|
|
372
|
+
}) => Promise<ReconcileStep>;
|
|
373
|
+
|
|
374
|
+
const replace = (kind: string, fields: readonly string[]): ReconcileStep => ({
|
|
375
|
+
replace: `${fields.join(", ")} differ${fields.length === 1 ? "s" : ""}, and compute can't change ${fields.length === 1 ? "it" : "them"} on a ${kind} in place`,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/** `labels` by `setLabels` with the live `labelFingerprint`; anything else changed is a replace. */
|
|
379
|
+
function labelsOnly(kind: string): Reconcile {
|
|
380
|
+
return async ({ url, desired, live, changed }) => {
|
|
381
|
+
const other = changed.filter((f) => f !== "labels");
|
|
382
|
+
if (other.length) return replace(kind, other);
|
|
383
|
+
if (!changed.length) return { requests: [] };
|
|
384
|
+
return { requests: [{ method: "POST", url: `${url}/setLabels`, body: { labels: desired.labels, labelFingerprint: live.labelFingerprint } }] };
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Every changed field by one PATCH carrying the live `fingerprint` (backend services, URL maps). */
|
|
389
|
+
const patchWithFingerprint: Reconcile = async ({ url, desired, live, changed }) =>
|
|
390
|
+
changed.length ? { requests: [{ method: "PATCH", url, body: { ...desired, fingerprint: live.fingerprint } }] } : { requests: [] };
|
|
391
|
+
|
|
392
|
+
const reconcileProxy: Reconcile = async ({ url, desired, changed }) => {
|
|
393
|
+
const other = changed.filter((f) => f !== "urlMap");
|
|
394
|
+
if (other.length) return replace("ComputeTargetHTTPProxy", other);
|
|
395
|
+
return changed.length ? { requests: [{ method: "POST", url: `${url}/setUrlMap`, body: { urlMap: desired.urlMap } }] } : { requests: [] };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A forwarding rule's `IPAddress` is a declared address's partial URL, where
|
|
400
|
+
* the live rule holds the IP itself, so the address is read and its IP
|
|
401
|
+
* compared. The target changes by `setTarget`, labels by `setLabels`; any
|
|
402
|
+
* other field is a replace.
|
|
403
|
+
*/
|
|
404
|
+
const reconcileForwardingRule: Reconcile = async ({ url, desired, live, changed, ctx, http, signal }) => {
|
|
405
|
+
let fields = changed;
|
|
406
|
+
const ip = desired.IPAddress;
|
|
407
|
+
if (fields.includes("IPAddress") && typeof ip === "string" && ip.includes("/addresses/")) {
|
|
408
|
+
const res = await http("GET", `${ctx.base}/compute/v1/${refTail(ip)}`, undefined, signal);
|
|
409
|
+
const address = res.status === 200 ? (JSON.parse(res.text) as Obj).address : undefined;
|
|
410
|
+
if (address !== undefined && address === live.IPAddress) fields = fields.filter((f) => f !== "IPAddress");
|
|
411
|
+
}
|
|
412
|
+
const other = fields.filter((f) => f !== "target" && f !== "labels");
|
|
413
|
+
if (other.length) return replace("ComputeForwardingRule", other);
|
|
414
|
+
const requests: Array<{ method: "POST"; url: string; body: unknown }> = [];
|
|
415
|
+
if (fields.includes("target")) requests.push({ method: "POST", url: `${url}/setTarget`, body: { target: desired.target } });
|
|
416
|
+
if (fields.includes("labels")) {
|
|
417
|
+
requests.push({ method: "POST", url: `${url}/setLabels`, body: { labels: desired.labels, labelFingerprint: live.labelFingerprint } });
|
|
418
|
+
}
|
|
419
|
+
return { requests };
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const replaceOnAnyChange = (kind: string): Reconcile => async ({ changed }) => (changed.length ? replace(kind, changed) : { requests: [] });
|
|
423
|
+
|
|
327
424
|
function computeMapper(
|
|
328
425
|
kind: string,
|
|
329
426
|
collection: string,
|
|
330
427
|
build: (resource: GcpResource, ctx: ComputeCtx) => Obj,
|
|
331
428
|
regionalCollection = collection,
|
|
332
429
|
scopeFor: (spec: Obj | undefined) => string = (spec) => scopeOf(spec),
|
|
430
|
+
reconcile?: Reconcile,
|
|
333
431
|
): ResourceMapper {
|
|
334
432
|
return {
|
|
335
433
|
kind,
|
|
@@ -339,16 +437,36 @@ function computeMapper(
|
|
|
339
437
|
const scope = scopeFor(resource.spec as Obj | undefined);
|
|
340
438
|
const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
|
|
341
439
|
const name = computeName(resource);
|
|
342
|
-
|
|
440
|
+
const url = `${coll}/${enc(name)}`;
|
|
441
|
+
const desired = build(resource, ctx);
|
|
442
|
+
return {
|
|
443
|
+
getUrl: url,
|
|
444
|
+
create: { method: "POST", url: coll, body: desired },
|
|
445
|
+
...(reconcile
|
|
446
|
+
? {
|
|
447
|
+
reconcile: (live: unknown, http: GcpHttp, signal?: AbortSignal) => {
|
|
448
|
+
const l = isObj(live) ? live : {};
|
|
449
|
+
return reconcile({ url, desired, live: l, changed: changedFields(desired, l), ctx, http, signal });
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
453
|
+
};
|
|
343
454
|
},
|
|
344
455
|
};
|
|
345
456
|
}
|
|
346
457
|
|
|
347
|
-
export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody);
|
|
348
|
-
export const regionNetworkEndpointGroupMapper = computeMapper(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
458
|
+
export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody, undefined, undefined, labelsOnly("ComputeAddress"));
|
|
459
|
+
export const regionNetworkEndpointGroupMapper = computeMapper(
|
|
460
|
+
"ComputeRegionNetworkEndpointGroup",
|
|
461
|
+
"networkEndpointGroups",
|
|
462
|
+
negBody,
|
|
463
|
+
undefined,
|
|
464
|
+
undefined,
|
|
465
|
+
replaceOnAnyChange("ComputeRegionNetworkEndpointGroup"),
|
|
466
|
+
);
|
|
467
|
+
export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody, undefined, undefined, patchWithFingerprint);
|
|
468
|
+
export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody, undefined, undefined, patchWithFingerprint);
|
|
469
|
+
export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody, undefined, undefined, reconcileProxy);
|
|
470
|
+
export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody, undefined, undefined, reconcileForwardingRule);
|
|
353
471
|
export const networkMapper = computeMapper("ComputeNetwork", "networks", networkBody, "networks", () => "global");
|
|
354
472
|
export const subnetworkMapper = computeMapper("ComputeSubnetwork", "subnetworks", subnetworkBody);
|
package/src/plugin.ts
CHANGED
|
@@ -411,8 +411,12 @@ export const bucket = new StorageBucket({
|
|
|
411
411
|
|
|
412
412
|
deepNormalizationHooks: gcpDeepNormalizationHooks,
|
|
413
413
|
|
|
414
|
-
/**
|
|
415
|
-
|
|
414
|
+
/**
|
|
415
|
+
* Runtime health (#4177, #4239): Cloud Run services, backend services and IAM
|
|
416
|
+
* policy members. Implementation in ./health.ts, whose GCP_HEALTH_TYPES this
|
|
417
|
+
* list matches; written out so the build never imports the reader.
|
|
418
|
+
*/
|
|
419
|
+
healthTypes: ["GCP::Run::Service", "GCP::Compute::BackendService", "GCP::Iam::PolicyMember"],
|
|
416
420
|
|
|
417
421
|
async observeHealth(options) {
|
|
418
422
|
const { observeHealthGcp } = await import("./health");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -6,9 +6,9 @@ import type { Declarable } from "@intentius/chant/declarable";
|
|
|
6
6
|
import { loadChantConfig } from "@intentius/chant/config";
|
|
7
7
|
import { armSandboxConfigEvaluation, resetSandboxConfigEvaluationForTests } from "@intentius/chant/config-sandbox";
|
|
8
8
|
import { gcpSerializer } from "./serializer";
|
|
9
|
-
import { manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
|
|
9
|
+
import { describeResources, manifestProjectAnnotations, resolveReadProject } from "./describe-resources";
|
|
10
10
|
import { parseManifest, resolveGcpProject } from "./op/activities/gcp-apply";
|
|
11
|
-
import { CloudRunService, StorageBucket } from "./generated";
|
|
11
|
+
import { CloudRunService, PubSubTopic, StorageBucket } from "./generated";
|
|
12
12
|
import { defaultAnnotations } from "./default-labels";
|
|
13
13
|
import { GCP } from "./pseudo";
|
|
14
14
|
|
|
@@ -117,3 +117,50 @@ describe("gcp.project under --sandbox (#4252)", () => {
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("the live read takes gcp.project with no project env var (#4252)", () => {
|
|
122
|
+
let saved: NodeJS.ProcessEnv;
|
|
123
|
+
const fetchMock = vi.fn();
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
saved = { ...process.env };
|
|
126
|
+
delete process.env.GCP_PROJECT_ID;
|
|
127
|
+
delete process.env.GOOGLE_CLOUD_PROJECT;
|
|
128
|
+
delete process.env.GCP_ENDPOINT_URL;
|
|
129
|
+
fetchMock.mockReset();
|
|
130
|
+
fetchMock.mockImplementation(async (url: string) => new Response(JSON.stringify({ name: "projects/p/topics/assets" }), { status: 200 }));
|
|
131
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
132
|
+
});
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
process.env = saved;
|
|
135
|
+
vi.unstubAllGlobals();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
async function read(config: Record<string, unknown> | undefined, props: Record<string, unknown>) {
|
|
139
|
+
const yaml = build({ assets: new PubSubTopic(props as never) }, config);
|
|
140
|
+
return describeResources({
|
|
141
|
+
environment: "local",
|
|
142
|
+
buildOutput: yaml,
|
|
143
|
+
entityNames: ["assets"],
|
|
144
|
+
entities: new Map([["assets", { entityType: "GCP::Pubsub::Topic", props }]]),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test("only gcp.project in config: the read targets that project", async () => {
|
|
149
|
+
const result = await read({ gcp: { project: "three-cloud" } }, { metadata: { name: "assets" } });
|
|
150
|
+
expect(result.unobserved?.assets).toBeUndefined();
|
|
151
|
+
expect(result.resources.assets).toBeDefined();
|
|
152
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("three-cloud");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("only a project-id annotation on the resource: the read targets that project", async () => {
|
|
156
|
+
const result = await read(undefined, { metadata: { name: "assets", annotations: { [ANNOTATION]: "annotated" } } });
|
|
157
|
+
expect(result.unobserved?.assets).toBeUndefined();
|
|
158
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("annotated");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("neither: no-binding, naming every place a project can come from", async () => {
|
|
162
|
+
const result = await read(undefined, { metadata: { name: "assets" } });
|
|
163
|
+
expect(result.unobserved?.assets?.reason).toBe("no-binding");
|
|
164
|
+
expect(result.unobserved?.assets?.detail).toMatch(/gcp\.project in chant\.config/);
|
|
165
|
+
});
|
|
166
|
+
});
|