@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
@@ -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). These kinds reconcile by
18
- * existence only: an existing resource is left as it is, since compute updates
19
- * need the live `fingerprint` and most of these fields are immutable.
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
- function computeMapper(kind, collection, build, regionalCollection = collection, scopeFor = (spec) => scopeOf(spec)) {
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
- return { getUrl: `${coll}/${enc(name)}`, create: { method: "POST", url: coll, body: build(resource, ctx) } };
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);
@@ -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,aAqYvB,CAAC"}
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
- /** Runtime health (#4177): Cloud Run services. Implementation in ./health.ts. */
372
- healthTypes: ["GCP::Run::Service"],
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);
@@ -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.38",
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.38"
79
+ "@intentius/chant": "^0.124.40"
80
80
  }
81
81
  }
@@ -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
- let getUrl: string;
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
+ });