@intentius/chant-lexicon-gcp 0.124.39 → 0.124.41

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.
@@ -4,7 +4,7 @@ import { sleep } from "@intentius/chant/op";
4
4
  import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
5
  import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership.js";
6
6
  import { iamPolicyMemberMapper } from "./gcp-iam.js";
7
- import { backendServiceMapper, computeAddressMapper, forwardingRuleMapper, networkMapper, subnetworkMapper, regionNetworkEndpointGroupMapper, targetHttpProxyMapper, urlMapMapper, } from "./gcp-compute.js";
7
+ import { changedFields, backendServiceMapper, computeAddressMapper, forwardingRuleMapper, networkMapper, subnetworkMapper, regionNetworkEndpointGroupMapper, targetHttpProxyMapper, urlMapMapper, } from "./gcp-compute.js";
8
8
  import { applyResult, } from "@intentius/chant/apply";
9
9
  const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
10
10
  /**
@@ -471,11 +471,36 @@ const defaultHttp = async (method, url, body, signal) => {
471
471
  });
472
472
  return { status: res.status, text: await res.text() };
473
473
  };
474
+ export async function decideResource(mapper, resource, ctx, http = defaultHttp, signal) {
475
+ if (mapper.custom) {
476
+ const read = await mapper.custom.read(resource, ctx, http, signal);
477
+ return read.status === 200 ? { action: "unchanged", live: parseJson(read.text) } : { action: "create", readStatus: read.status };
478
+ }
479
+ const plan = mapper.plan(resource, ctx);
480
+ const get = await http("GET", plan.getUrl, undefined, signal);
481
+ if (get.status !== 200)
482
+ return { action: "create", readStatus: get.status };
483
+ const live = parseJson(get.text);
484
+ const liveObj = live !== null && typeof live === "object" && !Array.isArray(live) ? live : {};
485
+ const desired = plan.create.body !== null && typeof plan.create.body === "object" ? plan.create.body : {};
486
+ const fields = changedFields(desired, liveObj);
487
+ if (plan.reconcile) {
488
+ const step = await plan.reconcile(live, http, signal);
489
+ if ("replace" in step)
490
+ return { action: "replace-required", reason: step.replace, live };
491
+ return step.requests.length > 0 ? { action: "update", fields, requests: step.requests, live } : { action: "unchanged", live };
492
+ }
493
+ // A kind with an in-place update sends it only when a desired field differs:
494
+ // a PATCH that changes nothing still costs a Cloud Run revision.
495
+ if (!plan.update || fields.length === 0)
496
+ return { action: "unchanged", live };
497
+ return { action: "update", fields, requests: [plan.update], live };
498
+ }
474
499
  /**
475
- * Reconcile one resource via its mapper: `GET` it; if present, PATCH it to the
476
- * desired state (when the mapper supports `update`, else leave it); if absent,
477
- * create it. Async create/update return an operation that is polled to
478
- * completion. `http` is injectable for tests.
500
+ * Reconcile one resource via its mapper: decide from a GET what it needs
501
+ * ({@link decideResource}), then create it, send its updates, or leave it.
502
+ * Async create/update return an operation that is polled to completion.
503
+ * `http` is injectable for tests.
479
504
  */
480
505
  export async function applyResource(mapper, resource, ctx, http = defaultHttp, signal) {
481
506
  const name = resource.metadata?.name ?? "?";
@@ -483,42 +508,33 @@ export async function applyResource(mapper, resource, ctx, http = defaultHttp, s
483
508
  const result = await mapper.custom.apply(resource, ctx, http, signal);
484
509
  return { kind: mapper.kind, name, ...result };
485
510
  }
486
- const plan = mapper.plan(resource, ctx);
487
- const get = await http("GET", plan.getUrl, undefined, signal);
488
- if (get.status === 200 && plan.reconcile) {
489
- const step = await plan.reconcile(parseJson(get.text), http, signal);
490
- if ("replace" in step) {
511
+ const decision = await decideResource(mapper, resource, ctx, http, signal);
512
+ switch (decision.action) {
513
+ case "replace-required":
491
514
  // Reported, not attempted: replacing deletes the resource, which apply never does on its own.
492
- console.log(`replace required: ${mapper.kind}/${name}: ${step.replace}`);
493
- return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: step.replace };
494
- }
495
- for (const req of step.requests) {
496
- const res = await http(req.method, req.url, req.body, signal);
515
+ console.log(`replace required: ${mapper.kind}/${name}: ${decision.reason}`);
516
+ return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: decision.reason };
517
+ case "unchanged":
518
+ return { kind: mapper.kind, name, created: false, updated: false };
519
+ case "update":
520
+ for (const req of decision.requests) {
521
+ const res = await http(req.method, req.url, req.body, signal);
522
+ if (res.status >= 300) {
523
+ throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
524
+ }
525
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
526
+ }
527
+ return { kind: mapper.kind, name, created: false, updated: true };
528
+ case "create": {
529
+ const plan = mapper.plan(resource, ctx);
530
+ const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
497
531
  if (res.status >= 300) {
498
- throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
532
+ throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
499
533
  }
500
534
  await pollIfAsync(mapper, res.text, ctx, http, signal);
535
+ return { kind: mapper.kind, name, created: true, updated: false };
501
536
  }
502
- return { kind: mapper.kind, name, created: false, updated: step.requests.length > 0 };
503
- }
504
- if (get.status === 200) {
505
- // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
506
- if (!plan.update) {
507
- return { kind: mapper.kind, name, created: false, updated: false };
508
- }
509
- const res = await http(plan.update.method, plan.update.url, plan.update.body, signal);
510
- if (res.status >= 300) {
511
- throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
512
- }
513
- await pollIfAsync(mapper, res.text, ctx, http, signal);
514
- return { kind: mapper.kind, name, created: false, updated: true };
515
- }
516
- const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
517
- if (res.status >= 300) {
518
- throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
519
537
  }
520
- await pollIfAsync(mapper, res.text, ctx, http, signal);
521
- return { kind: mapper.kind, name, created: true, updated: false };
522
538
  }
523
539
  /**
524
540
  * When a mapper is async, extract the long-running operation from a create/update
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.124.39",
3
+ "version": "0.124.41",
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.39"
79
+ "@intentius/chant": "^0.124.41"
80
80
  }
81
81
  }
@@ -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
+ });