@intentius/chant-lexicon-gcp 0.124.38 → 0.124.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/read-client.d.ts +7 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +24 -9
- package/dist/components/capability-plugin.d.ts +14 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/capability-plugin.js +21 -0
- package/dist/components/gcp-apply.d.ts +104 -0
- package/dist/components/gcp-apply.d.ts.map +1 -0
- package/dist/components/gcp-apply.js +335 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/catalog.js +187 -0
- package/dist/composites/index.d.ts +4 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/index.js +2 -0
- package/dist/composites/private-egress-network.d.ts +42 -0
- package/dist/composites/private-egress-network.d.ts.map +1 -0
- package/dist/composites/private-egress-network.js +48 -0
- package/dist/composites/public-cloud-run.d.ts +85 -0
- package/dist/composites/public-cloud-run.d.ts.map +1 -0
- package/dist/composites/public-cloud-run.js +144 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +34 -2
- package/dist/health.d.ts +26 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +85 -6
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +61 -4
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +54 -22
- package/dist/op/activities/gcp-compute.d.ts +9 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -1
- package/dist/op/activities/gcp-compute.js +107 -11
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -2
- package/dist/skills/chant-gcp.md +2 -0
- package/package.json +2 -2
- package/src/api/read-client.ts +23 -8
- package/src/components/capability-plugin.ts +25 -0
- package/src/components/gcp-apply-floci.e2e.test.ts +80 -0
- package/src/components/gcp-apply.test.ts +259 -0
- package/src/components/gcp-apply.ts +406 -0
- package/src/composites/catalog.ts +187 -0
- package/src/composites/cloud-run-lb.test.ts +93 -0
- package/src/composites/index.ts +4 -0
- package/src/composites/private-egress-network.ts +82 -0
- package/src/composites/public-cloud-run.ts +245 -0
- package/src/deep-observe.test.ts +46 -0
- package/src/deep-observe.ts +38 -2
- package/src/health.test.ts +65 -1
- package/src/health.ts +88 -11
- package/src/index.ts +13 -1
- package/src/op/activities/gcp-apply.ts +95 -24
- package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
- package/src/op/activities/gcp-compute.test.ts +4 -2
- package/src/op/activities/gcp-compute.ts +129 -11
- package/src/plugin.test.ts +10 -0
- package/src/plugin.ts +6 -2
- package/src/skills/chant-gcp.md +2 -0
|
@@ -14,9 +14,16 @@
|
|
|
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.js";
|
|
22
29
|
import { OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
@@ -326,7 +333,83 @@ export const computeOperation = {
|
|
|
326
333
|
return errors?.length ? errors.map((e) => e.message ?? e.code ?? "error").join("; ") : undefined;
|
|
327
334
|
},
|
|
328
335
|
};
|
|
329
|
-
|
|
336
|
+
/** A compute reference with everything before `projects/` dropped, so a full URL and a partial one compare equal. */
|
|
337
|
+
function refTail(v) {
|
|
338
|
+
const at = v.indexOf("projects/");
|
|
339
|
+
return at >= 0 ? v.slice(at) : v;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* True when the live value holds the desired one: every desired key is set
|
|
343
|
+
* live to an equal value, arrays element by element, and a compute URL
|
|
344
|
+
* equals its partial form. Fields only the live side has (defaults, server
|
|
345
|
+
* ids) don't count. Pure.
|
|
346
|
+
*/
|
|
347
|
+
export function holds(desired, live) {
|
|
348
|
+
if (typeof desired === "string" && typeof live === "string") {
|
|
349
|
+
return desired === live || (desired.includes("projects/") && refTail(desired) === refTail(live));
|
|
350
|
+
}
|
|
351
|
+
if (Array.isArray(desired)) {
|
|
352
|
+
return Array.isArray(live) && desired.length === live.length && desired.every((d, i) => holds(d, live[i]));
|
|
353
|
+
}
|
|
354
|
+
if (isObj(desired)) {
|
|
355
|
+
return isObj(live) && Object.entries(desired).every(([k, v]) => holds(v, live[k]));
|
|
356
|
+
}
|
|
357
|
+
return desired === live;
|
|
358
|
+
}
|
|
359
|
+
/** The desired body's top-level fields the live resource doesn't hold, `name` aside. Pure. */
|
|
360
|
+
export function changedFields(desired, live) {
|
|
361
|
+
return Object.keys(desired).filter((k) => k !== "name" && !holds(desired[k], live[k]));
|
|
362
|
+
}
|
|
363
|
+
const replace = (kind, fields) => ({
|
|
364
|
+
replace: `${fields.join(", ")} differ${fields.length === 1 ? "s" : ""}, and compute can't change ${fields.length === 1 ? "it" : "them"} on a ${kind} in place`,
|
|
365
|
+
});
|
|
366
|
+
/** `labels` by `setLabels` with the live `labelFingerprint`; anything else changed is a replace. */
|
|
367
|
+
function labelsOnly(kind) {
|
|
368
|
+
return async ({ url, desired, live, changed }) => {
|
|
369
|
+
const other = changed.filter((f) => f !== "labels");
|
|
370
|
+
if (other.length)
|
|
371
|
+
return replace(kind, other);
|
|
372
|
+
if (!changed.length)
|
|
373
|
+
return { requests: [] };
|
|
374
|
+
return { requests: [{ method: "POST", url: `${url}/setLabels`, body: { labels: desired.labels, labelFingerprint: live.labelFingerprint } }] };
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** Every changed field by one PATCH carrying the live `fingerprint` (backend services, URL maps). */
|
|
378
|
+
const patchWithFingerprint = async ({ url, desired, live, changed }) => changed.length ? { requests: [{ method: "PATCH", url, body: { ...desired, fingerprint: live.fingerprint } }] } : { requests: [] };
|
|
379
|
+
const reconcileProxy = async ({ url, desired, changed }) => {
|
|
380
|
+
const other = changed.filter((f) => f !== "urlMap");
|
|
381
|
+
if (other.length)
|
|
382
|
+
return replace("ComputeTargetHTTPProxy", other);
|
|
383
|
+
return changed.length ? { requests: [{ method: "POST", url: `${url}/setUrlMap`, body: { urlMap: desired.urlMap } }] } : { requests: [] };
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* A forwarding rule's `IPAddress` is a declared address's partial URL, where
|
|
387
|
+
* the live rule holds the IP itself, so the address is read and its IP
|
|
388
|
+
* compared. The target changes by `setTarget`, labels by `setLabels`; any
|
|
389
|
+
* other field is a replace.
|
|
390
|
+
*/
|
|
391
|
+
const reconcileForwardingRule = async ({ url, desired, live, changed, ctx, http, signal }) => {
|
|
392
|
+
let fields = changed;
|
|
393
|
+
const ip = desired.IPAddress;
|
|
394
|
+
if (fields.includes("IPAddress") && typeof ip === "string" && ip.includes("/addresses/")) {
|
|
395
|
+
const res = await http("GET", `${ctx.base}/compute/v1/${refTail(ip)}`, undefined, signal);
|
|
396
|
+
const address = res.status === 200 ? JSON.parse(res.text).address : undefined;
|
|
397
|
+
if (address !== undefined && address === live.IPAddress)
|
|
398
|
+
fields = fields.filter((f) => f !== "IPAddress");
|
|
399
|
+
}
|
|
400
|
+
const other = fields.filter((f) => f !== "target" && f !== "labels");
|
|
401
|
+
if (other.length)
|
|
402
|
+
return replace("ComputeForwardingRule", other);
|
|
403
|
+
const requests = [];
|
|
404
|
+
if (fields.includes("target"))
|
|
405
|
+
requests.push({ method: "POST", url: `${url}/setTarget`, body: { target: desired.target } });
|
|
406
|
+
if (fields.includes("labels")) {
|
|
407
|
+
requests.push({ method: "POST", url: `${url}/setLabels`, body: { labels: desired.labels, labelFingerprint: live.labelFingerprint } });
|
|
408
|
+
}
|
|
409
|
+
return { requests };
|
|
410
|
+
};
|
|
411
|
+
const replaceOnAnyChange = (kind) => async ({ changed }) => (changed.length ? replace(kind, changed) : { requests: [] });
|
|
412
|
+
function computeMapper(kind, collection, build, regionalCollection = collection, scopeFor = (spec) => scopeOf(spec), reconcile) {
|
|
330
413
|
return {
|
|
331
414
|
kind,
|
|
332
415
|
defaultHost: COMPUTE_HOST,
|
|
@@ -335,15 +418,28 @@ function computeMapper(kind, collection, build, regionalCollection = collection,
|
|
|
335
418
|
const scope = scopeFor(resource.spec);
|
|
336
419
|
const coll = `${ctx.base}/compute/v1/${scopePath(ctx.project, scope)}/${scope === "global" ? collection : regionalCollection}`;
|
|
337
420
|
const name = computeName(resource);
|
|
338
|
-
|
|
421
|
+
const url = `${coll}/${enc(name)}`;
|
|
422
|
+
const desired = build(resource, ctx);
|
|
423
|
+
return {
|
|
424
|
+
getUrl: url,
|
|
425
|
+
create: { method: "POST", url: coll, body: desired },
|
|
426
|
+
...(reconcile
|
|
427
|
+
? {
|
|
428
|
+
reconcile: (live, http, signal) => {
|
|
429
|
+
const l = isObj(live) ? live : {};
|
|
430
|
+
return reconcile({ url, desired, live: l, changed: changedFields(desired, l), ctx, http, signal });
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
: {}),
|
|
434
|
+
};
|
|
339
435
|
},
|
|
340
436
|
};
|
|
341
437
|
}
|
|
342
|
-
export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody);
|
|
343
|
-
export const regionNetworkEndpointGroupMapper = computeMapper("ComputeRegionNetworkEndpointGroup", "networkEndpointGroups", negBody);
|
|
344
|
-
export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody);
|
|
345
|
-
export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody);
|
|
346
|
-
export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody);
|
|
347
|
-
export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody);
|
|
438
|
+
export const computeAddressMapper = computeMapper("ComputeAddress", "addresses", addressBody, undefined, undefined, labelsOnly("ComputeAddress"));
|
|
439
|
+
export const regionNetworkEndpointGroupMapper = computeMapper("ComputeRegionNetworkEndpointGroup", "networkEndpointGroups", negBody, undefined, undefined, replaceOnAnyChange("ComputeRegionNetworkEndpointGroup"));
|
|
440
|
+
export const backendServiceMapper = computeMapper("ComputeBackendService", "backendServices", backendServiceBody, undefined, undefined, patchWithFingerprint);
|
|
441
|
+
export const urlMapMapper = computeMapper("ComputeURLMap", "urlMaps", urlMapBody, undefined, undefined, patchWithFingerprint);
|
|
442
|
+
export const targetHttpProxyMapper = computeMapper("ComputeTargetHTTPProxy", "targetHttpProxies", targetHttpProxyBody, undefined, undefined, reconcileProxy);
|
|
443
|
+
export const forwardingRuleMapper = computeMapper("ComputeForwardingRule", "forwardingRules", forwardingRuleBody, undefined, undefined, reconcileForwardingRule);
|
|
348
444
|
export const networkMapper = computeMapper("ComputeNetwork", "networks", networkBody, "networks", () => "global");
|
|
349
445
|
export const subnetworkMapper = computeMapper("ComputeSubnetwork", "subnetworks", subnetworkBody);
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAuBjG,eAAO,MAAM,SAAS,EAAE,
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAuBjG,eAAO,MAAM,SAAS,EAAE,aAyYvB,CAAC"}
|
package/dist/plugin.js
CHANGED
|
@@ -368,8 +368,12 @@ export const bucket = new StorageBucket({
|
|
|
368
368
|
return observeResourcesDeepGcp(options);
|
|
369
369
|
},
|
|
370
370
|
deepNormalizationHooks: gcpDeepNormalizationHooks,
|
|
371
|
-
/**
|
|
372
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Runtime health (#4177, #4239): Cloud Run services, backend services and IAM
|
|
373
|
+
* policy members. Implementation in ./health.ts, whose GCP_HEALTH_TYPES this
|
|
374
|
+
* list matches; written out so the build never imports the reader.
|
|
375
|
+
*/
|
|
376
|
+
healthTypes: ["GCP::Run::Service", "GCP::Compute::BackendService", "GCP::Iam::PolicyMember"],
|
|
373
377
|
async observeHealth(options) {
|
|
374
378
|
const { observeHealthGcp } = await import("./health.js");
|
|
375
379
|
return observeHealthGcp(options);
|
package/dist/skills/chant-gcp.md
CHANGED
|
@@ -202,6 +202,8 @@ Composites group related Config Connector resources with secure defaults. Each r
|
|
|
202
202
|
| `ManagedCertificate` | ComputeManagedSslCertificate | Auto-renewal via Google-managed cert |
|
|
203
203
|
| `SecureProject` | Project + IAMAuditConfig + essential Service resources | Audit logging, required APIs enabled, org policy constraints |
|
|
204
204
|
| `GpuNodePool` | ContainerNodePool with GPU accelerators | Scale-to-zero, nvidia.com/gpu taint, GKE metadata workload identity |
|
|
205
|
+
| `PrivateEgressNetwork` | ComputeNetwork + ComputeSubnetwork | Custom mode, regional routing, Private Google Access |
|
|
206
|
+
| `PublicCloudRun` | IAMServiceAccount + RunService + serverless NEG + global external Application Load Balancer | Own service account, Direct VPC egress for private ranges, optional managed certificate with HTTP to HTTPS redirect, optional Cloud Armor policy |
|
|
205
207
|
|
|
206
208
|
### Example: GKE cluster with secure defaults
|
|
207
209
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gcp",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.40",
|
|
4
4
|
"description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -76,6 +76,6 @@
|
|
|
76
76
|
"typescript": "^5.9.3"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
|
-
"@intentius/chant": "^0.124.
|
|
79
|
+
"@intentius/chant": "^0.124.40"
|
|
80
80
|
}
|
|
81
81
|
}
|
package/src/api/read-client.ts
CHANGED
|
@@ -98,6 +98,28 @@ export function mapperForKind(kind: string): ResourceMapper | undefined {
|
|
|
98
98
|
* build a URL — the same input the applier hands it. Returns the parsed body,
|
|
99
99
|
* or throws {@link GcpReadError} carrying the status.
|
|
100
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* The URL a read of this resource GETs: the applier's own `plan().getUrl`, so
|
|
103
|
+
* a health read's sub-calls (`getHealth`) land on the same resource path.
|
|
104
|
+
* Throws a {@link GcpReadError} when the declared spec can't produce one,
|
|
105
|
+
* which is a hole, never an absence.
|
|
106
|
+
*/
|
|
107
|
+
export function readUrl(client: GcpReadClientOptions, kind: string, name: string, declared?: Record<string, unknown>): string {
|
|
108
|
+
const mapper = mapperForKind(kind);
|
|
109
|
+
if (!mapper) throw new GcpReadError(`no REST mapper for kind ${kind}`);
|
|
110
|
+
const base = (client.endpoint ?? mapper.defaultHost).replace(/\/$/, "");
|
|
111
|
+
const resource: GcpResource = {
|
|
112
|
+
kind,
|
|
113
|
+
metadata: { name },
|
|
114
|
+
spec: (declared?.spec as Record<string, unknown> | undefined) ?? declared ?? {},
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
return mapper.plan(resource, { base, project: client.project, ...(client.manifest ? { manifest: client.manifest } : {}) }).getUrl;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new GcpReadError(`could not build a read URL for ${kind}/${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
export async function getResource(
|
|
102
124
|
client: GcpReadClientOptions,
|
|
103
125
|
kind: string,
|
|
@@ -128,14 +150,7 @@ export async function getResource(
|
|
|
128
150
|
throw new GcpReadError(`could not read ${kind}/${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
151
|
}
|
|
130
152
|
} else {
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
getUrl = mapper.plan(resource, ctx).getUrl;
|
|
134
|
-
} catch (err) {
|
|
135
|
-
// A mapper that cannot plan from the declared spec cannot produce a URL, so
|
|
136
|
-
// there is nothing to read — a hole, never an absence.
|
|
137
|
-
throw new GcpReadError(`could not build a read URL for ${kind}/${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
-
}
|
|
153
|
+
const getUrl = readUrl(client, kind, name, declared);
|
|
139
154
|
try {
|
|
140
155
|
res = await http("GET", getUrl);
|
|
141
156
|
} catch (err) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gcpCapabilityPlugin`: the gcp lexicon's capability plugin (#4273). Core
|
|
3
|
+
* loads it when a project's `chant.config.ts` lists `lexicons: ["gcp"]`, so a
|
|
4
|
+
* component's `gcp-apply` step resolves, plans in `chant components pr-plan`
|
|
5
|
+
* and runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
9
|
+
import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
10
|
+
import { gcpApplyCapability } from "./gcp-apply";
|
|
11
|
+
|
|
12
|
+
/** The GCP verb families this plugin contributes. */
|
|
13
|
+
export const GCP_VERB_FAMILIES = {
|
|
14
|
+
apply: ["gcp-apply"],
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** The gcp lexicon's capability plugin. */
|
|
18
|
+
export const gcpCapabilityPlugin: CapabilityPlugin = {
|
|
19
|
+
name: "gcp",
|
|
20
|
+
get version(): string {
|
|
21
|
+
return ownPackageVersion(import.meta.url);
|
|
22
|
+
},
|
|
23
|
+
capabilities: () => [gcpApplyCapability] as unknown as Array<Capability<never, unknown>>,
|
|
24
|
+
families: () => GCP_VERB_FAMILIES,
|
|
25
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gcp-apply`'s plan and apply against floci-gcp (#4273), over real HTTP.
|
|
3
|
+
* Set CHANT_FLOCI_GCP_ENDPOINT to a running floci-gcp, for example
|
|
4
|
+
* `docker run -d -p 4688:4588 floci/floci-gcp:0.5.0` and
|
|
5
|
+
* `CHANT_FLOCI_GCP_ENDPOINT=http://localhost:4688`. Skipped without it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { gcpDelete } from "../op/activities/gcp-apply";
|
|
13
|
+
import { createGcpApplyCapability, GcpPlanMovedError } from "./gcp-apply";
|
|
14
|
+
|
|
15
|
+
const endpoint = process.env.CHANT_FLOCI_GCP_ENDPOINT;
|
|
16
|
+
|
|
17
|
+
describe.skipIf(!endpoint)("gcp-apply plan against floci-gcp (#4273)", () => {
|
|
18
|
+
const suffix = `${process.pid}-${Date.now()}`;
|
|
19
|
+
const ctx = { env: "local", component: "gcp-e2e" };
|
|
20
|
+
const saved = { ...process.env };
|
|
21
|
+
let dir: string;
|
|
22
|
+
let manifest: string;
|
|
23
|
+
const bucket = (storageClass: string) => ({
|
|
24
|
+
apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
|
|
25
|
+
kind: "StorageBucket",
|
|
26
|
+
metadata: { name: `chant-plan-${suffix}` },
|
|
27
|
+
spec: { location: "US", storageClass },
|
|
28
|
+
});
|
|
29
|
+
const topic = { apiVersion: "pubsub.cnrm.cloud.google.com/v1beta1", kind: "PubSubTopic", metadata: { name: `chant-plan-${suffix}` }, spec: {} };
|
|
30
|
+
const write = (resources: unknown[]): void => writeFileSync(manifest, JSON.stringify(resources));
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
Object.assign(process.env, { GCP_ENDPOINT_URL: endpoint, GCP_PROJECT_ID: "floci-local" });
|
|
34
|
+
dir = mkdtempSync(join(tmpdir(), "gcp-apply-floci-"));
|
|
35
|
+
manifest = join(dir, "manifest.json");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
try {
|
|
40
|
+
write([bucket("STANDARD"), topic]);
|
|
41
|
+
await gcpDelete({ manifestPath: manifest });
|
|
42
|
+
} finally {
|
|
43
|
+
process.env = saved;
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("plans a create, applies it, plans no change, plans an update and refuses a plan the manifest moved past", async () => {
|
|
49
|
+
const capability = createGcpApplyCapability();
|
|
50
|
+
|
|
51
|
+
write([bucket("STANDARD"), topic]);
|
|
52
|
+
const created = await capability.plan!(ctx, { manifest });
|
|
53
|
+
expect(created.changeSet!.entries.map((e) => [e.type, e.action])).toEqual([
|
|
54
|
+
["PubSubTopic", "create"],
|
|
55
|
+
["StorageBucket", "create"],
|
|
56
|
+
]);
|
|
57
|
+
const first = await capability.run({ ...ctx, plans: { [ctx.component]: created.artifact } }, { manifest });
|
|
58
|
+
expect(first.applied.map((a) => a.created)).toEqual([true, true]);
|
|
59
|
+
|
|
60
|
+
const same = await capability.plan!(ctx, { manifest });
|
|
61
|
+
expect(same.changeSet!.entries.map((e) => e.action)).toEqual(["no-op", "no-op"]);
|
|
62
|
+
expect(same.changeSet!.member.holes).toEqual([]);
|
|
63
|
+
|
|
64
|
+
write([bucket("NEARLINE"), topic]);
|
|
65
|
+
const updated = await capability.plan!(ctx, { manifest });
|
|
66
|
+
expect(updated.changeSet!.entries.filter((e) => e.action !== "no-op").map((e) => [e.type, e.action, e.attributes.map((a) => a.path)])).toEqual([
|
|
67
|
+
["StorageBucket", "update", ["storageClass"]],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
write([bucket("COLDLINE"), topic]);
|
|
71
|
+
await expect(capability.run({ ...ctx, plans: { [ctx.component]: updated.artifact } }, { manifest })).rejects.toBeInstanceOf(GcpPlanMovedError);
|
|
72
|
+
|
|
73
|
+
write([bucket("NEARLINE"), topic]);
|
|
74
|
+
const second = await capability.run({ ...ctx, plans: { [ctx.component]: updated.artifact } }, { manifest });
|
|
75
|
+
expect(second.applied.find((a) => a.kind === "StorageBucket")).toMatchObject({ created: false, updated: true });
|
|
76
|
+
|
|
77
|
+
const after = await capability.plan!(ctx, { manifest });
|
|
78
|
+
expect(after.changeSet!.entries.map((e) => e.action)).toEqual(["no-op", "no-op"]);
|
|
79
|
+
}, 120_000);
|
|
80
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
6
|
+
import { buildCapabilityRegistry } from "@intentius/chant/components/capability-plugin-loader";
|
|
7
|
+
import { isPlanMovedError } from "@intentius/chant/components/capability";
|
|
8
|
+
import type { GcpHttp } from "../op/activities/gcp-apply";
|
|
9
|
+
import { createGcpApplyCapability, GcpPlanMovedError, readOnlyHttp } from "./gcp-apply";
|
|
10
|
+
import { gcpCapabilityPlugin } from "./capability-plugin";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An in-memory GCP behind the applier's REST paths: buckets, topics and
|
|
14
|
+
* Cloud Run services, keyed by URL path. Records every request.
|
|
15
|
+
*/
|
|
16
|
+
function fakeGcp() {
|
|
17
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
18
|
+
const calls: Array<{ method: string; url: string }> = [];
|
|
19
|
+
const http: GcpHttp = async (method, url, body) => {
|
|
20
|
+
calls.push({ method, url });
|
|
21
|
+
const u = new URL(url);
|
|
22
|
+
const path = u.pathname;
|
|
23
|
+
const b = (body ?? {}) as Record<string, unknown>;
|
|
24
|
+
const found = (p: string) => (store.has(p) ? { status: 200, text: JSON.stringify(store.get(p)) } : { status: 404, text: "not found" });
|
|
25
|
+
if (method === "GET") {
|
|
26
|
+
if (path.endsWith("/storage/v1/b")) {
|
|
27
|
+
return { status: 200, text: JSON.stringify({ items: [...store.entries()].filter(([k]) => k.startsWith("/storage/v1/b/")).map(([, v]) => v) }) };
|
|
28
|
+
}
|
|
29
|
+
if (path.includes("/operations/")) return { status: 200, text: JSON.stringify({ done: true }) };
|
|
30
|
+
return found(path);
|
|
31
|
+
}
|
|
32
|
+
if (method === "POST" && path.endsWith("/storage/v1/b")) {
|
|
33
|
+
store.set(`${path}/${b.name as string}`, { ...b });
|
|
34
|
+
return { status: 200, text: JSON.stringify(b) };
|
|
35
|
+
}
|
|
36
|
+
if (method === "POST" && path.endsWith("/services")) {
|
|
37
|
+
const id = u.searchParams.get("serviceId")!;
|
|
38
|
+
store.set(`${path}/${id}`, { ...b, name: id, uri: `https://${id}-abc.a.run.app` });
|
|
39
|
+
return { status: 200, text: JSON.stringify({ name: "projects/p/locations/us-central1/operations/op1" }) };
|
|
40
|
+
}
|
|
41
|
+
if (method === "PUT") {
|
|
42
|
+
store.set(path, { ...b, name: path.split("/").pop() });
|
|
43
|
+
return { status: 200, text: JSON.stringify(b) };
|
|
44
|
+
}
|
|
45
|
+
if (method === "PATCH") {
|
|
46
|
+
const patch = (b.topic ?? b) as Record<string, unknown>;
|
|
47
|
+
store.set(path, { ...store.get(path), ...patch });
|
|
48
|
+
return { status: 200, text: JSON.stringify(store.get(path)) };
|
|
49
|
+
}
|
|
50
|
+
if (method === "DELETE") {
|
|
51
|
+
store.delete(path);
|
|
52
|
+
return { status: 200, text: "{}" };
|
|
53
|
+
}
|
|
54
|
+
return { status: 400, text: `unhandled ${method} ${url}` };
|
|
55
|
+
};
|
|
56
|
+
return { store, calls, http, writes: () => calls.filter((c) => c.method !== "GET") };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bucket = (name: string, storageClass = "STANDARD") => ({
|
|
60
|
+
apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
|
|
61
|
+
kind: "StorageBucket",
|
|
62
|
+
metadata: { name, annotations: { "cnrm.cloud.google.com/project-id": "test-project" } },
|
|
63
|
+
spec: { location: "us-central1", storageClass },
|
|
64
|
+
});
|
|
65
|
+
const topic = { apiVersion: "pubsub.cnrm.cloud.google.com/v1beta1", kind: "PubSubTopic", metadata: { name: "events" }, spec: {} };
|
|
66
|
+
const service = {
|
|
67
|
+
apiVersion: "run.cnrm.cloud.google.com/v1beta1",
|
|
68
|
+
kind: "RunService",
|
|
69
|
+
metadata: { name: "web" },
|
|
70
|
+
spec: { location: "us-central1", template: { containers: [{ image: "us-docker.pkg.dev/test/web:1" }] } },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const ctx = { env: "test", component: "gcp-app" };
|
|
74
|
+
const ENV_KEYS = ["GCP_ENDPOINT_URL", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT"] as const;
|
|
75
|
+
|
|
76
|
+
describe("gcp-apply (#4273)", () => {
|
|
77
|
+
let dir: string;
|
|
78
|
+
let manifest: string;
|
|
79
|
+
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
|
80
|
+
const write = (resources: unknown[]): void => writeFileSync(manifest, JSON.stringify(resources));
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
84
|
+
process.env.GCP_ENDPOINT_URL = "http://floci.test:4588";
|
|
85
|
+
process.env.GCP_PROJECT_ID = "test-project";
|
|
86
|
+
delete process.env.GOOGLE_CLOUD_PROJECT;
|
|
87
|
+
dir = mkdtempSync(join(tmpdir(), "gcp-apply-step-"));
|
|
88
|
+
manifest = join(dir, "manifest.json");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
for (const k of ENV_KEYS) {
|
|
93
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
94
|
+
else process.env[k] = saved[k];
|
|
95
|
+
}
|
|
96
|
+
rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("plans creates without writing, applies them, then plans no change with the Cloud Run URL known", async () => {
|
|
100
|
+
const gcp = fakeGcp();
|
|
101
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
102
|
+
write([bucket("assets"), topic, service]);
|
|
103
|
+
|
|
104
|
+
const first = await capability.plan!(ctx, { manifest });
|
|
105
|
+
expect(gcp.writes()).toEqual([]);
|
|
106
|
+
expect(first.member).toBe("gcp-app");
|
|
107
|
+
expect(first.changeSet!.entries.map((e) => [e.address, e.action])).toEqual([
|
|
108
|
+
["PubSubTopic/events", "create"],
|
|
109
|
+
["RunService/web", "create"],
|
|
110
|
+
["StorageBucket/assets", "create"],
|
|
111
|
+
]);
|
|
112
|
+
expect(first.changeSet!.entries.every((e) => e.lexicon === "gcp" && e.planner === "chant" && e.scope === "test-project")).toBe(true);
|
|
113
|
+
expect(first.outputs).toEqual({ known: {}, unknown: ["webUri"] });
|
|
114
|
+
// Every read went to the endpoint override.
|
|
115
|
+
expect(gcp.calls.every((c) => c.url.startsWith("http://floci.test:4588/"))).toBe(true);
|
|
116
|
+
|
|
117
|
+
const applied = await capability.run({ ...ctx, plans: { "gcp-app": first.artifact } }, { manifest });
|
|
118
|
+
expect(applied.applied.map((a) => [a.kind, a.created])).toEqual([
|
|
119
|
+
["StorageBucket", true],
|
|
120
|
+
["PubSubTopic", true],
|
|
121
|
+
["RunService", true],
|
|
122
|
+
]);
|
|
123
|
+
expect(applied.outputs).toEqual({ webUri: "https://web-abc.a.run.app" });
|
|
124
|
+
|
|
125
|
+
const second = await capability.plan!(ctx, { manifest });
|
|
126
|
+
expect(second.changeSet!.entries.map((e) => e.action)).toEqual(["no-op", "no-op", "no-op"]);
|
|
127
|
+
expect(second.outputs).toEqual({ known: { webUri: "https://web-abc.a.run.app" }, unknown: [] });
|
|
128
|
+
await expect(capability.outputs!(ctx, { manifest })).resolves.toEqual({ webUri: "https://web-abc.a.run.app" });
|
|
129
|
+
|
|
130
|
+
// Applying again agrees with the plan: nothing is sent.
|
|
131
|
+
const before = gcp.writes().length;
|
|
132
|
+
const again = await capability.run(ctx, { manifest });
|
|
133
|
+
expect(again.applied.every((a) => !a.created && !a.updated)).toBe(true);
|
|
134
|
+
expect(gcp.writes().length).toBe(before);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an update names each changed field with its value before and after, and run refuses a plan the manifest moved past", async () => {
|
|
138
|
+
const gcp = fakeGcp();
|
|
139
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
140
|
+
write([bucket("assets")]);
|
|
141
|
+
await capability.run(ctx, { manifest });
|
|
142
|
+
|
|
143
|
+
write([bucket("assets", "NEARLINE")]);
|
|
144
|
+
const plan = await capability.plan!(ctx, { manifest });
|
|
145
|
+
expect(plan.changeSet!.entries).toEqual([
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
address: "StorageBucket/assets",
|
|
148
|
+
action: "update",
|
|
149
|
+
disruption: "in-place",
|
|
150
|
+
attributes: [{ path: "storageClass", before: "STANDARD", after: "NEARLINE" }],
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
write([bucket("assets", "COLDLINE")]);
|
|
155
|
+
const moved = capability.run({ ...ctx, plans: { "gcp-app": plan.artifact } }, { manifest });
|
|
156
|
+
await expect(moved).rejects.toBeInstanceOf(GcpPlanMovedError);
|
|
157
|
+
await expect(moved).rejects.toSatisfy(isPlanMovedError);
|
|
158
|
+
expect(gcp.store.get("/storage/v1/b/assets")!.storageClass).toBe("STANDARD");
|
|
159
|
+
|
|
160
|
+
write([bucket("assets", "NEARLINE")]);
|
|
161
|
+
const done = await capability.run({ ...ctx, plans: { "gcp-app": plan.artifact } }, { manifest });
|
|
162
|
+
expect(done.applied).toEqual([{ kind: "StorageBucket", name: "assets", created: false, updated: true }]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("with prune, a chant-owned bucket the manifest dropped is a delete and a foreign one is left out", async () => {
|
|
166
|
+
const gcp = fakeGcp();
|
|
167
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
168
|
+
write([bucket("keep"), bucket("old")]);
|
|
169
|
+
await capability.run(ctx, { manifest });
|
|
170
|
+
gcp.store.set("/storage/v1/b/foreign", { name: "foreign", labels: {} });
|
|
171
|
+
|
|
172
|
+
write([bucket("keep")]);
|
|
173
|
+
const plan = await capability.plan!(ctx, { manifest, prune: true });
|
|
174
|
+
expect(plan.changeSet!.entries.map((e) => [e.address, e.action, e.disruption])).toEqual([
|
|
175
|
+
["StorageBucket/keep", "no-op", undefined],
|
|
176
|
+
["StorageBucket/old", "delete", "destroy"],
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a kind with no mapper, and a read that is neither 200 nor 404, are holes", async () => {
|
|
181
|
+
const http: GcpHttp = async () => ({ status: 403, text: "denied" });
|
|
182
|
+
const capability = createGcpApplyCapability(http);
|
|
183
|
+
write([bucket("assets"), { kind: "SQLInstance", metadata: { name: "db" }, spec: {} }]);
|
|
184
|
+
const plan = await capability.plan!(ctx, { manifest });
|
|
185
|
+
expect(plan.changeSet!.entries).toEqual([]);
|
|
186
|
+
expect(plan.changeSet!.member.holes.map((h) => h.address)).toEqual(["StorageBucket/assets", "SQLInstance/db"]);
|
|
187
|
+
expect(plan.changeSet!.member.holes[0]!.reason).toMatch(/answered 403/);
|
|
188
|
+
expect(plan.changeSet!.member.holes[1]!.reason).toMatch(/no REST mapper for SQLInstance/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("inputs waiting on another component read nothing and leave a hole", async () => {
|
|
192
|
+
const gcp = fakeGcp();
|
|
193
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
194
|
+
const plan = await capability.plan!({ ...ctx, unresolvedInputs: ["network"] }, { manifest: join(dir, "not-built.json") });
|
|
195
|
+
expect(gcp.calls).toEqual([]);
|
|
196
|
+
expect(plan.artifact).toBeUndefined();
|
|
197
|
+
expect(plan.changeSet!.member.holes).toEqual([{ address: "gcp-app", type: "gcp-apply", reason: "inputs from network; re-checked on apply" }]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("the project follows projectRef.external, then the annotation, then GCP_PROJECT_ID, then GOOGLE_CLOUD_PROJECT", async () => {
|
|
201
|
+
const gcp = fakeGcp();
|
|
202
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
203
|
+
const named = (name: string, extra: Record<string, unknown>) => ({ kind: "PubSubTopic", metadata: { name, ...extra }, spec: {} });
|
|
204
|
+
write([
|
|
205
|
+
{ kind: "PubSubTopic", metadata: { name: "a", annotations: { "cnrm.cloud.google.com/project-id": "annotated" } }, spec: { projectRef: { external: "projects/from-ref" } } },
|
|
206
|
+
named("b", { annotations: { "cnrm.cloud.google.com/project-id": "annotated" } }),
|
|
207
|
+
named("c", {}),
|
|
208
|
+
]);
|
|
209
|
+
await capability.plan!(ctx, { manifest });
|
|
210
|
+
delete process.env.GCP_PROJECT_ID;
|
|
211
|
+
process.env.GOOGLE_CLOUD_PROJECT = "cloud-project";
|
|
212
|
+
write([named("d", {})]);
|
|
213
|
+
await capability.plan!(ctx, { manifest });
|
|
214
|
+
expect(gcp.calls.map((c) => new URL(c.url).pathname)).toEqual([
|
|
215
|
+
"/v1/projects/from-ref/topics/a",
|
|
216
|
+
"/v1/projects/annotated/topics/b",
|
|
217
|
+
"/v1/projects/test-project/topics/c",
|
|
218
|
+
"/v1/projects/cloud-project/topics/d",
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("the step's endpoint wins over GCP_ENDPOINT_URL and enters the plan digest", async () => {
|
|
223
|
+
const gcp = fakeGcp();
|
|
224
|
+
const capability = createGcpApplyCapability(gcp.http);
|
|
225
|
+
write([topic]);
|
|
226
|
+
const viaEnv = await capability.plan!(ctx, { manifest });
|
|
227
|
+
const viaStep = await capability.plan!(ctx, { manifest, endpoint: "http://localhost:4599" });
|
|
228
|
+
expect(gcp.calls.map((c) => new URL(c.url).origin)).toEqual(["http://floci.test:4588", "http://localhost:4599"]);
|
|
229
|
+
expect(viaStep.planDigest).not.toBe(viaEnv.planDigest);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("the plan's HTTP refuses a write, and lets a policy read through", async () => {
|
|
233
|
+
const seen: string[] = [];
|
|
234
|
+
const read = readOnlyHttp(async (method, url) => {
|
|
235
|
+
seen.push(`${method} ${url}`);
|
|
236
|
+
return { status: 200, text: "{}" };
|
|
237
|
+
});
|
|
238
|
+
await expect(read("PATCH", "http://x/b/assets", {})).rejects.toThrow(/a plan only reads/);
|
|
239
|
+
await expect(read("POST", "http://x/v1/projects/p/serviceAccounts", {})).rejects.toThrow(/a plan only reads/);
|
|
240
|
+
await read("POST", "http://x/v1/projects/p/topics/t:getIamPolicy", {});
|
|
241
|
+
await read("GET", "http://x/b/assets");
|
|
242
|
+
expect(seen).toEqual(["POST http://x/v1/projects/p/topics/t:getIamPolicy", "GET http://x/b/assets"]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("gcpCapabilityPlugin (#4273)", () => {
|
|
247
|
+
test("satisfies the CapabilityPlugin contract and contributes gcp-apply with plan and outputs", () => {
|
|
248
|
+
expect(isCapabilityPlugin(gcpCapabilityPlugin)).toBe(true);
|
|
249
|
+
const [step] = gcpCapabilityPlugin.capabilities();
|
|
250
|
+
expect(step!.kind).toBe("gcp-apply");
|
|
251
|
+
expect(typeof step!.plan).toBe("function");
|
|
252
|
+
expect(typeof step!.outputs).toBe("function");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a project listing the gcp lexicon gets gcp-apply in its registry", async () => {
|
|
256
|
+
const registry = await buildCapabilityRegistry({ lexicons: ["gcp"] });
|
|
257
|
+
expect(registry.has("gcp-apply")).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|