@intentius/chant-lexicon-gcp 0.124.39 → 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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * `gcp-apply`: the gcp lexicon's component deploy step (#4273). It applies a
3
+ * built Config Connector manifest through the `gcpApply` activity
4
+ * (../op/activities/gcp-apply.ts), so a component step, an Op and the
5
+ * floci-gcp path all write through the same `MAPPERS`.
6
+ *
7
+ * `plan` reads each declared resource on the same transport and decides what
8
+ * apply would do with `decideResource`, the function `applyResource` acts on.
9
+ * So a plan's create, update or no-op is the apply's created, updated or
10
+ * unchanged. Nothing is written: the plan's HTTP refuses any request that is
11
+ * not a read.
12
+ *
13
+ * `GCP_ENDPOINT_URL` (or the step's `endpoint`) points both at floci-gcp. The
14
+ * project follows the applier's rule: the step's `project`, else each
15
+ * resource's `projectRef.external`, its project-id annotation,
16
+ * `GCP_PROJECT_ID`, then `GOOGLE_CLOUD_PROJECT`.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { readFileSync } from "node:fs";
21
+ import type { Capability, CapabilityPlan, DeployContext, PlannedOutputs } from "@intentius/chant/components/capability";
22
+ import type { ChangeSetEntry, ChangeSetHole, ChangeSetPart } from "@intentius/chant/change-set";
23
+ import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
24
+ import {
25
+ MAPPERS,
26
+ decideResource,
27
+ gcpApply,
28
+ isChantOwned,
29
+ orderByReferences,
30
+ parseManifest,
31
+ resolveGcpEndpoint,
32
+ resolveGcpProject,
33
+ type GcpHttp,
34
+ type GcpNotAttempted,
35
+ type GcpNotPrunable,
36
+ type GcpResource,
37
+ type MapperCtx,
38
+ type ResourceMapper,
39
+ } from "../op/activities/gcp-apply";
40
+
41
+ export interface GcpApplyStepInput {
42
+ /** The member's name in a plan. Default: the component's name. */
43
+ stack?: string;
44
+ /** Path to the built Config Connector manifest (YAML multi-doc or JSON). */
45
+ manifest: string;
46
+ /** Project for every resource. Default: each resource's own, by the applier's rule. */
47
+ project?: string;
48
+ /** Endpoint for every kind, such as floci-gcp's. Default: `GCP_ENDPOINT_URL`, else each kind's real host. */
49
+ endpoint?: string;
50
+ /** Delete chant-owned resources of a manifested kind the manifest no longer declares. Default: false. */
51
+ prune?: boolean;
52
+ }
53
+
54
+ export interface GcpApplyStepOutput {
55
+ applied: Array<{ kind: string; name: string; created: boolean; updated: boolean; replaceRequired?: string }>;
56
+ pruned: Array<{ kind: string; name: string; deleted: boolean }>;
57
+ notAttempted: GcpNotAttempted[];
58
+ notPrunable: GcpNotPrunable[];
59
+ /** What the API reports for the applied resources, such as a Cloud Run URL ({@link GCP_OUTPUT_FIELDS}). */
60
+ outputs: Record<string, string>;
61
+ }
62
+
63
+ /**
64
+ * The outputs a resource kind has: the output's name is the resource's name
65
+ * plus `suffix`, and its value the live REST body's `field`.
66
+ */
67
+ export const GCP_OUTPUT_FIELDS: Record<string, { field: string; suffix: string }> = {
68
+ RunService: { field: "uri", suffix: "Uri" },
69
+ ComputeAddress: { field: "address", suffix: "Address" },
70
+ ComputeForwardingRule: { field: "IPAddress", suffix: "IpAddress" },
71
+ };
72
+
73
+ /** What a plan hands `run` through `DeployContext.plans[member]`. */
74
+ export interface GcpApplyPlanArtifact {
75
+ kind: "gcp-apply-plan";
76
+ member: string;
77
+ /** {@link gcpChangesDigest} of the planned entries. */
78
+ changesDigest: string;
79
+ }
80
+
81
+ function isGcpApplyPlanArtifact(value: unknown): value is GcpApplyPlanArtifact {
82
+ const v = value as Partial<GcpApplyPlanArtifact> | undefined;
83
+ return v?.kind === "gcp-apply-plan" && typeof v.member === "string" && typeof v.changesDigest === "string";
84
+ }
85
+
86
+ /**
87
+ * Thrown by `run` when a fresh plan of the manifest differs from the approved
88
+ * one: the manifest or the live resources moved since. Nothing is applied.
89
+ * `planMoved` is core's mark, so the command exits 3.
90
+ */
91
+ export class GcpPlanMovedError extends Error {
92
+ readonly planMoved = true;
93
+ constructor(
94
+ public readonly member: string,
95
+ public readonly planned: string,
96
+ public readonly now: string,
97
+ ) {
98
+ super(
99
+ `gcp-apply "${member}": refusing to apply. The resources need other changes than the plan that was approved ` +
100
+ `(planned ${planned}, now ${now}); the manifest or the live resources changed since. Plan it again.`,
101
+ );
102
+ this.name = "GcpPlanMovedError";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * A digest over the entries' addresses, actions, and changed fields with the
108
+ * value apply writes, in address order. The live value before is left out:
109
+ * apply writes the same thing whatever it was.
110
+ */
111
+ export function gcpChangesDigest(member: string, entries: readonly ChangeSetEntry[]): string {
112
+ const shape = entries
113
+ .map((e) => ({ address: e.address, action: e.action, fields: e.attributes.map((a) => ({ path: a.path, after: a.after ?? null })) }))
114
+ .sort((a, b) => a.address.localeCompare(b.address) || a.action.localeCompare(b.action));
115
+ return computePlanDigest("gcp-apply-changes", { member, entries: shape });
116
+ }
117
+
118
+ /** A plan only reads. A policy read is a POST to `:getIamPolicy`; anything else that writes is refused. */
119
+ export function readOnlyHttp(http: GcpHttp): GcpHttp {
120
+ return (method, url, body, signal) => {
121
+ if (method === "GET" || (method === "POST" && /:getIamPolicy(\?|$)/.test(url))) return http(method, url, body, signal);
122
+ return Promise.reject(new Error(`gcp-apply plan: refusing ${method} ${url}; a plan only reads`));
123
+ };
124
+ }
125
+
126
+ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
127
+ const res = await fetch(url, {
128
+ method,
129
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
130
+ body: body === undefined ? undefined : JSON.stringify(body),
131
+ signal,
132
+ });
133
+ return { status: res.status, text: await res.text() };
134
+ };
135
+
136
+ interface Loaded {
137
+ resources: GcpResource[];
138
+ digest: string;
139
+ }
140
+
141
+ function loadManifest(path: string): Loaded {
142
+ let content: string;
143
+ try {
144
+ content = readFileSync(path, "utf8");
145
+ } catch (err) {
146
+ throw new Error(`gcp-apply: cannot read the manifest ${path}: ${err instanceof Error ? err.message : String(err)}. Build it before planning.`);
147
+ }
148
+ return {
149
+ resources: orderByReferences(parseManifest(content, path)),
150
+ digest: `sha256:${createHash("sha256").update(content).digest("hex")}`,
151
+ };
152
+ }
153
+
154
+ /** Where a resource is read and written: the same base and project `gcpApply` gives it. */
155
+ function mapperCtx(input: GcpApplyStepInput, mapper: ResourceMapper, resources: GcpResource[], resource?: GcpResource): MapperCtx {
156
+ const endpoint = resolveGcpEndpoint(input);
157
+ return {
158
+ base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
159
+ project: input.project ?? (resource ? resolveGcpProject(resource) : ""),
160
+ manifest: resources,
161
+ };
162
+ }
163
+
164
+ const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
165
+
166
+ /** The entries, holes and planned outputs for one manifest. Reads only. */
167
+ export async function planGcpResources(
168
+ member: string,
169
+ input: GcpApplyStepInput,
170
+ resources: GcpResource[],
171
+ http: GcpHttp,
172
+ ): Promise<{ entries: ChangeSetEntry[]; holes: ChangeSetHole[]; outputs: PlannedOutputs }> {
173
+ const read = readOnlyHttp(http);
174
+ const entries: ChangeSetEntry[] = [];
175
+ const holes: ChangeSetHole[] = [];
176
+ const known: Record<string, unknown> = {};
177
+ const unknown: string[] = [];
178
+ const base = (kind: string, name: string, project: string) => ({
179
+ member,
180
+ lexicon: "gcp",
181
+ planner: "chant" as const,
182
+ address: `${kind}/${name}`,
183
+ type: kind,
184
+ name,
185
+ ...(project ? { scope: project } : {}),
186
+ });
187
+
188
+ for (const r of resources) {
189
+ if (!r.kind) throw new Error(`gcp-apply: ${input.manifest}: a manifest entry has no "kind"${r.metadata?.name ? ` (metadata.name: ${r.metadata.name})` : ""}`);
190
+ const kind = r.kind;
191
+ const name = r.metadata?.name ?? "?";
192
+ const address = `${kind}/${name}`;
193
+ const output = GCP_OUTPUT_FIELDS[kind];
194
+ const outputName = output ? `${name}${output.suffix}` : undefined;
195
+ const mapper = MAPPERS[kind];
196
+ if (!mapper) {
197
+ holes.push({ address, type: kind, reason: `chant has no REST mapper for ${kind}; apply skips it` });
198
+ continue;
199
+ }
200
+ const ctx = mapperCtx(input, mapper, resources, r);
201
+ if (mapper.accepts && !mapper.accepts(r, ctx)) {
202
+ holes.push({ address, type: kind, reason: `nothing chant can apply ${address} to; apply skips it` });
203
+ continue;
204
+ }
205
+ const decision = await decideResource(mapper, r, ctx, read);
206
+ switch (decision.action) {
207
+ case "create":
208
+ if (decision.readStatus !== 404) {
209
+ holes.push({ address, type: kind, reason: `the read answered ${decision.readStatus}, so the plan can't say whether it exists; apply would try a create` });
210
+ if (outputName) unknown.push(outputName);
211
+ continue;
212
+ }
213
+ entries.push({ ...base(kind, name, ctx.project), action: "create", attributes: [] });
214
+ if (outputName) unknown.push(outputName);
215
+ continue;
216
+ case "replace-required":
217
+ holes.push({ address, type: kind, reason: `replace required: ${decision.reason}; apply leaves it as it is` });
218
+ break;
219
+ case "unchanged":
220
+ entries.push({ ...base(kind, name, ctx.project), action: "no-op", attributes: [] });
221
+ break;
222
+ case "update": {
223
+ const desired = mapper.plan(r, ctx).create.body;
224
+ const live = isObj(decision.live) ? decision.live : {};
225
+ const attributes = [...decision.fields].sort().map((path) => ({
226
+ path,
227
+ ...(live[path] !== undefined ? { before: live[path] } : {}),
228
+ ...(isObj(desired) && desired[path] !== undefined ? { after: desired[path] } : {}),
229
+ }));
230
+ entries.push({ ...base(kind, name, ctx.project), action: "update", disruption: "in-place", attributes });
231
+ break;
232
+ }
233
+ }
234
+ // The resource stays, so an output it reports now is the one it has after apply.
235
+ if (outputName) {
236
+ const value = isObj(decision.live) ? decision.live[output!.field] : undefined;
237
+ if (typeof value === "string" && value !== "") known[outputName] = value;
238
+ else unknown.push(outputName);
239
+ }
240
+ }
241
+
242
+ if (input.prune) await planPrune(member, input, resources, read, entries, holes);
243
+ entries.sort((a, b) => a.address.localeCompare(b.address));
244
+ return { entries, holes, outputs: { known, unknown: [...new Set(unknown)].sort() } };
245
+ }
246
+
247
+ /** The deletes an owned-only prune would make, the way `pruneOrphans` finds them. */
248
+ async function planPrune(
249
+ member: string,
250
+ input: GcpApplyStepInput,
251
+ resources: GcpResource[],
252
+ http: GcpHttp,
253
+ entries: ChangeSetEntry[],
254
+ holes: ChangeSetHole[],
255
+ ): Promise<void> {
256
+ const desiredByKind = new Map<string, Set<string>>();
257
+ for (const r of resources) {
258
+ if (!r.kind) continue;
259
+ const names = desiredByKind.get(r.kind) ?? new Set<string>();
260
+ if (r.metadata?.name) names.add(r.metadata.name);
261
+ desiredByKind.set(r.kind, names);
262
+ }
263
+ for (const [kind, keep] of desiredByKind) {
264
+ const mapper = MAPPERS[kind];
265
+ if (!mapper?.list) {
266
+ holes.push({ address: `${kind}/*`, type: kind, reason: `chant can't list ${kind}, so owned orphans of it are not considered` });
267
+ continue;
268
+ }
269
+ const ctx = mapperCtx(input, mapper, resources, resources.find((r) => r.kind === kind));
270
+ const res = await http("GET", mapper.list.url(ctx));
271
+ if (res.status >= 300) {
272
+ holes.push({ address: `${kind}/*`, type: kind, reason: `listing ${kind} failed (${res.status}), so owned orphans of it are not considered` });
273
+ continue;
274
+ }
275
+ let body: unknown;
276
+ try {
277
+ body = JSON.parse(res.text);
278
+ } catch {
279
+ body = undefined;
280
+ }
281
+ for (const item of mapper.list.items(body)) {
282
+ if (!isChantOwned(item.labels) || keep.has(item.name)) continue;
283
+ entries.push({
284
+ member,
285
+ lexicon: "gcp",
286
+ planner: "chant",
287
+ address: `${kind}/${item.name}`,
288
+ type: kind,
289
+ name: item.name,
290
+ action: "delete",
291
+ disruption: "destroy",
292
+ ...(ctx.project ? { scope: ctx.project } : {}),
293
+ attributes: [],
294
+ });
295
+ }
296
+ }
297
+ }
298
+
299
+ /** The outputs the declared resources report now. A resource not there yet has none. */
300
+ export async function readGcpOutputs(input: GcpApplyStepInput, resources: GcpResource[], http: GcpHttp): Promise<Record<string, string>> {
301
+ const outputs: Record<string, string> = {};
302
+ for (const r of resources) {
303
+ const output = r.kind ? GCP_OUTPUT_FIELDS[r.kind] : undefined;
304
+ const mapper = r.kind ? MAPPERS[r.kind] : undefined;
305
+ const name = r.metadata?.name;
306
+ if (!output || !mapper || !name) continue;
307
+ const ctx = mapperCtx(input, mapper, resources, r);
308
+ const res = await http("GET", mapper.plan(r, ctx).getUrl);
309
+ if (res.status === 404) continue;
310
+ if (res.status >= 300) throw new Error(`gcp-apply: reading ${r.kind}/${name} for its outputs failed (${res.status}): ${res.text.slice(0, 200)}`);
311
+ let body: unknown;
312
+ try {
313
+ body = JSON.parse(res.text);
314
+ } catch {
315
+ continue;
316
+ }
317
+ const value = isObj(body) ? body[output.field] : undefined;
318
+ if (typeof value === "string" && value !== "") outputs[`${name}${output.suffix}`] = value;
319
+ }
320
+ return outputs;
321
+ }
322
+
323
+ /**
324
+ * Plan a `gcp-apply` step. The digest covers the environment, the member, the
325
+ * manifest's path and bytes, the endpoint, the project override, the prune
326
+ * mode and the components whose outputs were missing. A step whose inputs
327
+ * wait on another component reads nothing and gets a hole.
328
+ */
329
+ async function planGcpApply(http: GcpHttp, ctx: DeployContext, input: GcpApplyStepInput): Promise<CapabilityPlan> {
330
+ const member = input.stack ?? ctx.component;
331
+ const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
332
+ const loaded = unresolved.length > 0 ? undefined : loadManifest(input.manifest);
333
+ const planDigest = computePlanDigest("gcp-apply-plan", {
334
+ env: ctx.env,
335
+ member,
336
+ manifest: input.manifest,
337
+ manifestDigest: loaded?.digest ?? null,
338
+ endpoint: resolveGcpEndpoint(input) ?? null,
339
+ project: input.project ?? null,
340
+ prune: input.prune === true,
341
+ unresolved,
342
+ });
343
+ const memberOf = (holes: ChangeSetHole[]): ChangeSetPart["member"] => ({
344
+ member,
345
+ lexicon: "gcp",
346
+ planner: "chant",
347
+ status: "planned",
348
+ planDigest,
349
+ holes,
350
+ });
351
+ if (!loaded) {
352
+ return {
353
+ member,
354
+ planDigest,
355
+ changeSet: { member: memberOf([{ address: member, type: "gcp-apply", reason: `inputs from ${unresolved.join(", ")}; re-checked on apply` }]), entries: [] },
356
+ };
357
+ }
358
+ const { entries, holes, outputs } = await planGcpResources(member, input, loaded.resources, http);
359
+ const artifact: GcpApplyPlanArtifact = { kind: "gcp-apply-plan", member, changesDigest: gcpChangesDigest(member, entries) };
360
+ return { member, planDigest, artifact, changeSet: { member: memberOf(holes), entries }, outputs };
361
+ }
362
+
363
+ /**
364
+ * Apply a Config Connector manifest with `gcpApply`. Handed the approved plan
365
+ * (`ctx.plans[member]`), `run` plans again first and applies only when the
366
+ * changes have the same digest. It returns what `gcpApply` reports, plus the
367
+ * outputs the API reports for the applied resources. There is no rollback:
368
+ * apply never deletes on its own, so a component acknowledges the gap.
369
+ */
370
+ export function createGcpApplyCapability(http: GcpHttp = defaultHttp): Capability<GcpApplyStepInput, GcpApplyStepOutput> {
371
+ return {
372
+ kind: "gcp-apply",
373
+ rollbackPolicy: "needs-opt-out",
374
+ plan(ctx, input) {
375
+ return planGcpApply(http, ctx, input);
376
+ },
377
+ async outputs(_ctx, input) {
378
+ return readGcpOutputs(input, loadManifest(input.manifest).resources, http);
379
+ },
380
+ async run(ctx, input) {
381
+ const member = input.stack ?? ctx.component;
382
+ const { resources } = loadManifest(input.manifest);
383
+ const planned = ctx.plans?.[member];
384
+ if (isGcpApplyPlanArtifact(planned)) {
385
+ const { entries } = await planGcpResources(member, input, resources, http);
386
+ const now = gcpChangesDigest(member, entries);
387
+ if (now !== planned.changesDigest) throw new GcpPlanMovedError(member, planned.changesDigest, now);
388
+ }
389
+ const result = await gcpApply(
390
+ {
391
+ manifestPath: input.manifest,
392
+ ...(input.endpoint ? { endpoint: input.endpoint } : {}),
393
+ ...(input.project ? { project: input.project } : {}),
394
+ ...(input.prune ? { prune: true } : {}),
395
+ },
396
+ undefined,
397
+ http,
398
+ );
399
+ const outputs = await readGcpOutputs(input, resources, http);
400
+ return { ...result, outputs };
401
+ },
402
+ };
403
+ }
404
+
405
+ /** The default `gcp-apply` capability, over `fetch`. */
406
+ export const gcpApplyCapability: Capability<GcpApplyStepInput, GcpApplyStepOutput> = createGcpApplyCapability();
package/src/index.ts CHANGED
@@ -78,3 +78,14 @@ export type { PackageOptions, PackageResult } from "./codegen/package";
78
78
  // LSP providers
79
79
  export { gcpCompletions } from "./lsp/completions";
80
80
  export { gcpHover } from "./lsp/hover";
81
+
82
+ // Component capabilities (#4273): core loads `gcpCapabilityPlugin` when a
83
+ // project's chant.config lists this lexicon, which registers `gcp-apply`.
84
+ export { gcpCapabilityPlugin, GCP_VERB_FAMILIES } from "./components/capability-plugin";
85
+ export {
86
+ gcpApplyCapability,
87
+ createGcpApplyCapability,
88
+ GcpPlanMovedError,
89
+ type GcpApplyStepInput,
90
+ type GcpApplyStepOutput,
91
+ } from "./components/gcp-apply";
@@ -5,6 +5,7 @@ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant
5
5
  import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
6
6
  import { iamPolicyMemberMapper } from "./gcp-iam";
7
7
  import {
8
+ changedFields,
8
9
  backendServiceMapper,
9
10
  computeAddressMapper,
10
11
  forwardingRuleMapper,
@@ -639,10 +640,62 @@ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
639
640
  };
640
641
 
641
642
  /**
642
- * Reconcile one resource via its mapper: `GET` it; if present, PATCH it to the
643
- * desired state (when the mapper supports `update`, else leave it); if absent,
644
- * create it. Async create/update return an operation that is polled to
645
- * completion. `http` is injectable for tests.
643
+ * What apply does to one existing-or-absent resource, decided from a read
644
+ * (#4273). `applyResource` acts on it and a `gcp-apply` plan reports it, so
645
+ * the two cannot disagree:
646
+ *
647
+ * - `create`: the GET did not answer 200 (`readStatus` says what it did).
648
+ * - `update`: the resource exists and the desired body holds a value the live
649
+ * one does not. `fields` names those top-level fields, `requests` are what
650
+ * apply sends.
651
+ * - `unchanged`: the resource exists and holds every desired value, or its
652
+ * kind has no in-place update.
653
+ * - `replace-required`: a field differs that can't change in place. Apply
654
+ * reports it and leaves the resource alone.
655
+ *
656
+ * `http` is only read through, except by a mapper's `reconcile`, which may GET
657
+ * a referenced resource. A kind with `custom` ops decides from its `read`.
658
+ */
659
+ export type ResourceDecision =
660
+ | { action: "create"; readStatus: number }
661
+ | { action: "update"; fields: string[]; requests: Array<{ method: string; url: string; body: unknown }>; live: unknown }
662
+ | { action: "unchanged"; live: unknown }
663
+ | { action: "replace-required"; reason: string; live: unknown };
664
+
665
+ export async function decideResource(
666
+ mapper: ResourceMapper,
667
+ resource: GcpResource,
668
+ ctx: MapperCtx,
669
+ http: GcpHttp = defaultHttp,
670
+ signal?: AbortSignal,
671
+ ): Promise<ResourceDecision> {
672
+ if (mapper.custom) {
673
+ const read = await mapper.custom.read(resource, ctx, http, signal);
674
+ return read.status === 200 ? { action: "unchanged", live: parseJson(read.text) } : { action: "create", readStatus: read.status };
675
+ }
676
+ const plan = mapper.plan(resource, ctx);
677
+ const get = await http("GET", plan.getUrl, undefined, signal);
678
+ if (get.status !== 200) return { action: "create", readStatus: get.status };
679
+ const live = parseJson(get.text);
680
+ const liveObj = live !== null && typeof live === "object" && !Array.isArray(live) ? (live as Record<string, unknown>) : {};
681
+ const desired = plan.create.body !== null && typeof plan.create.body === "object" ? (plan.create.body as Record<string, unknown>) : {};
682
+ const fields = changedFields(desired, liveObj);
683
+ if (plan.reconcile) {
684
+ const step = await plan.reconcile(live, http, signal);
685
+ if ("replace" in step) return { action: "replace-required", reason: step.replace, live };
686
+ return step.requests.length > 0 ? { action: "update", fields, requests: step.requests, live } : { action: "unchanged", live };
687
+ }
688
+ // A kind with an in-place update sends it only when a desired field differs:
689
+ // a PATCH that changes nothing still costs a Cloud Run revision.
690
+ if (!plan.update || fields.length === 0) return { action: "unchanged", live };
691
+ return { action: "update", fields, requests: [plan.update], live };
692
+ }
693
+
694
+ /**
695
+ * Reconcile one resource via its mapper: decide from a GET what it needs
696
+ * ({@link decideResource}), then create it, send its updates, or leave it.
697
+ * Async create/update return an operation that is polled to completion.
698
+ * `http` is injectable for tests.
646
699
  */
647
700
  export async function applyResource(
648
701
  mapper: ResourceMapper,
@@ -656,45 +709,33 @@ export async function applyResource(
656
709
  const result = await mapper.custom.apply(resource, ctx, http, signal);
657
710
  return { kind: mapper.kind, name, ...result };
658
711
  }
659
- const plan = mapper.plan(resource, ctx);
660
- const get = await http("GET", plan.getUrl, undefined, signal);
661
-
662
- if (get.status === 200 && plan.reconcile) {
663
- const step = await plan.reconcile(parseJson(get.text), http, signal);
664
- if ("replace" in step) {
712
+ const decision = await decideResource(mapper, resource, ctx, http, signal);
713
+ switch (decision.action) {
714
+ case "replace-required":
665
715
  // Reported, not attempted: replacing deletes the resource, which apply never does on its own.
666
- console.log(`replace required: ${mapper.kind}/${name}: ${step.replace}`);
667
- return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: step.replace };
668
- }
669
- for (const req of step.requests) {
670
- const res = await http(req.method, req.url, req.body, signal);
716
+ console.log(`replace required: ${mapper.kind}/${name}: ${decision.reason}`);
717
+ return { kind: mapper.kind, name, created: false, updated: false, replaceRequired: decision.reason };
718
+ case "unchanged":
719
+ return { kind: mapper.kind, name, created: false, updated: false };
720
+ case "update":
721
+ for (const req of decision.requests) {
722
+ const res = await http(req.method, req.url, req.body, signal);
723
+ if (res.status >= 300) {
724
+ throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
725
+ }
726
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
727
+ }
728
+ return { kind: mapper.kind, name, created: false, updated: true };
729
+ case "create": {
730
+ const plan = mapper.plan(resource, ctx);
731
+ const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
671
732
  if (res.status >= 300) {
672
- throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
733
+ throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
673
734
  }
674
735
  await pollIfAsync(mapper, res.text, ctx, http, signal);
736
+ return { kind: mapper.kind, name, created: true, updated: false };
675
737
  }
676
- return { kind: mapper.kind, name, created: false, updated: step.requests.length > 0 };
677
- }
678
-
679
- if (get.status === 200) {
680
- // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
681
- if (!plan.update) {
682
- return { kind: mapper.kind, name, created: false, updated: false };
683
- }
684
- const res = await http(plan.update.method, plan.update.url, plan.update.body, signal);
685
- if (res.status >= 300) {
686
- throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
687
- }
688
- await pollIfAsync(mapper, res.text, ctx, http, signal);
689
- return { kind: mapper.kind, name, created: false, updated: true };
690
- }
691
-
692
- const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
693
- if (res.status >= 300) {
694
- throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
695
738
  }
696
- await pollIfAsync(mapper, res.text, ctx, http, signal);
697
- return { kind: mapper.kind, name, created: true, updated: false };
698
739
  }
699
740
 
700
741
  /**
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { gcpPlugin } from "./plugin";
3
3
  import { isLexiconPlugin } from "@intentius/chant/lexicon";
4
+ import { emulatorsOf, endpointEnvVars } from "@intentius/chant/op";
4
5
 
5
6
  describe("gcpPlugin", () => {
6
7
  // ── Basic interface ────────────────────────────────────────────────
@@ -254,3 +255,12 @@ describe("gcpPlugin", () => {
254
255
  });
255
256
  });
256
257
  });
258
+
259
+ // #4281: `chant components pr-plan`, `pr-apply` and `run --components` apply an
260
+ // environment's declared endpoint to the vars the plugin's emulator names. `gcp-apply`
261
+ // reads GCP_ENDPOINT_URL, so the plugin has to name it.
262
+ describe("gcpPlugin endpoint var (#4281)", () => {
263
+ test("the emulator names GCP_ENDPOINT_URL, the var gcp-apply reads", () => {
264
+ expect(emulatorsOf(gcpPlugin.emulator).flatMap((cap) => endpointEnvVars(cap))).toEqual(["GCP_ENDPOINT_URL"]);
265
+ });
266
+ });