@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.
- 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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +42 -4
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +51 -35
- package/package.json +2 -2
- 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/index.ts +11 -0
- package/src/op/activities/gcp-apply.ts +78 -37
- package/src/plugin.test.ts +10 -0
|
@@ -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
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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
|
|
660
|
-
|
|
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}: ${
|
|
667
|
-
return { kind: mapper.kind, name, created: false, updated: false, replaceRequired:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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}
|
|
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
|
/**
|
package/src/plugin.test.ts
CHANGED
|
@@ -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
|
+
});
|