@intentius/chant-lexicon-gcp 0.124.38 → 0.124.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/read-client.d.ts +7 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +24 -9
- package/dist/components/capability-plugin.d.ts +14 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/capability-plugin.js +21 -0
- package/dist/components/gcp-apply.d.ts +104 -0
- package/dist/components/gcp-apply.d.ts.map +1 -0
- package/dist/components/gcp-apply.js +335 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/catalog.js +187 -0
- package/dist/composites/index.d.ts +4 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/index.js +2 -0
- package/dist/composites/private-egress-network.d.ts +42 -0
- package/dist/composites/private-egress-network.d.ts.map +1 -0
- package/dist/composites/private-egress-network.js +48 -0
- package/dist/composites/public-cloud-run.d.ts +85 -0
- package/dist/composites/public-cloud-run.d.ts.map +1 -0
- package/dist/composites/public-cloud-run.js +144 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +34 -2
- package/dist/health.d.ts +26 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +85 -6
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/gcp-apply.d.ts +61 -4
- package/dist/op/activities/gcp-apply.d.ts.map +1 -1
- package/dist/op/activities/gcp-apply.js +54 -22
- package/dist/op/activities/gcp-compute.d.ts +9 -0
- package/dist/op/activities/gcp-compute.d.ts.map +1 -1
- package/dist/op/activities/gcp-compute.js +107 -11
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -2
- package/dist/skills/chant-gcp.md +2 -0
- package/package.json +2 -2
- package/src/api/read-client.ts +23 -8
- package/src/components/capability-plugin.ts +25 -0
- package/src/components/gcp-apply-floci.e2e.test.ts +80 -0
- package/src/components/gcp-apply.test.ts +259 -0
- package/src/components/gcp-apply.ts +406 -0
- package/src/composites/catalog.ts +187 -0
- package/src/composites/cloud-run-lb.test.ts +93 -0
- package/src/composites/index.ts +4 -0
- package/src/composites/private-egress-network.ts +82 -0
- package/src/composites/public-cloud-run.ts +245 -0
- package/src/deep-observe.test.ts +46 -0
- package/src/deep-observe.ts +38 -2
- package/src/health.test.ts +65 -1
- package/src/health.ts +88 -11
- package/src/index.ts +13 -1
- package/src/op/activities/gcp-apply.ts +95 -24
- package/src/op/activities/gcp-compute-reconcile.test.ts +150 -0
- package/src/op/activities/gcp-compute.test.ts +4 -2
- package/src/op/activities/gcp-compute.ts +129 -11
- package/src/plugin.test.ts +10 -0
- package/src/plugin.ts +6 -2
- package/src/skills/chant-gcp.md +2 -0
|
@@ -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();
|
|
@@ -175,6 +175,12 @@ export const compositeCatalog: CompositeEntry[] = [
|
|
|
175
175
|
"required": false,
|
|
176
176
|
"description": "Environment variables."
|
|
177
177
|
},
|
|
178
|
+
{
|
|
179
|
+
"name": "serviceAccount",
|
|
180
|
+
"type": "string",
|
|
181
|
+
"required": false,
|
|
182
|
+
"description": "Service account the service runs as: an email, or the account id of an IAMServiceAccount in the project."
|
|
183
|
+
},
|
|
178
184
|
{
|
|
179
185
|
"name": "labels",
|
|
180
186
|
"type": "Record<string, string>",
|
|
@@ -955,6 +961,59 @@ export const compositeCatalog: CompositeEntry[] = [
|
|
|
955
961
|
}
|
|
956
962
|
]
|
|
957
963
|
},
|
|
964
|
+
{
|
|
965
|
+
"name": "PrivateEgressNetwork",
|
|
966
|
+
"lexicon": "gcp",
|
|
967
|
+
"description": "Create a PrivateEgressNetwork composite.",
|
|
968
|
+
"bundles": [
|
|
969
|
+
"Subnetwork",
|
|
970
|
+
"VPCNetwork"
|
|
971
|
+
],
|
|
972
|
+
"params": [
|
|
973
|
+
{
|
|
974
|
+
"name": "name",
|
|
975
|
+
"type": "string",
|
|
976
|
+
"required": true,
|
|
977
|
+
"description": "Name prefix: the network is `<name>-vpc`, its subnet `<name>-apps`."
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
"name": "region",
|
|
981
|
+
"type": "string",
|
|
982
|
+
"required": true,
|
|
983
|
+
"description": "GCP region of the subnet."
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
"name": "subnetCidr",
|
|
987
|
+
"type": "string",
|
|
988
|
+
"required": true,
|
|
989
|
+
"description": "IP CIDR range of the subnet."
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
"name": "privateIpGoogleAccess",
|
|
993
|
+
"type": "boolean",
|
|
994
|
+
"required": false,
|
|
995
|
+
"description": "Enable private Google access on the subnet (default: true)."
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
"name": "labels",
|
|
999
|
+
"type": "Record<string, string>",
|
|
1000
|
+
"required": false,
|
|
1001
|
+
"description": "Additional labels."
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
"name": "namespace",
|
|
1005
|
+
"type": "string",
|
|
1006
|
+
"required": false,
|
|
1007
|
+
"description": "Namespace for all resources."
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
"name": "defaults",
|
|
1011
|
+
"type": "{ vpc?: Partial<ConstructorParameters<typeof VPCNetwork>[0]>; subnet?: Partial<ConstructorParameters<typeof Subnetwor...",
|
|
1012
|
+
"required": false,
|
|
1013
|
+
"description": "Per-member defaults for customizing individual resources."
|
|
1014
|
+
}
|
|
1015
|
+
]
|
|
1016
|
+
},
|
|
958
1017
|
{
|
|
959
1018
|
"name": "PrivateService",
|
|
960
1019
|
"lexicon": "gcp",
|
|
@@ -1033,6 +1092,134 @@ export const compositeCatalog: CompositeEntry[] = [
|
|
|
1033
1092
|
}
|
|
1034
1093
|
]
|
|
1035
1094
|
},
|
|
1095
|
+
{
|
|
1096
|
+
"name": "PublicCloudRun",
|
|
1097
|
+
"lexicon": "gcp",
|
|
1098
|
+
"description": "Create a PublicCloudRun composite.",
|
|
1099
|
+
"bundles": [
|
|
1100
|
+
"BackendService",
|
|
1101
|
+
"CloudRunService",
|
|
1102
|
+
"ComputeAddress",
|
|
1103
|
+
"ForwardingRule",
|
|
1104
|
+
"GCPServiceAccount",
|
|
1105
|
+
"IAMPolicyMember",
|
|
1106
|
+
"ManagedSSLCertificate",
|
|
1107
|
+
"RegionNetworkEndpointGroup",
|
|
1108
|
+
"TargetHTTPProxy",
|
|
1109
|
+
"TargetHTTPSProxy",
|
|
1110
|
+
"URLMap"
|
|
1111
|
+
],
|
|
1112
|
+
"params": [
|
|
1113
|
+
{
|
|
1114
|
+
"name": "name",
|
|
1115
|
+
"type": "string",
|
|
1116
|
+
"required": true,
|
|
1117
|
+
"description": "Name prefix every resource is named with."
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
"name": "image",
|
|
1121
|
+
"type": "string",
|
|
1122
|
+
"required": true,
|
|
1123
|
+
"description": "Container image."
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
"name": "region",
|
|
1127
|
+
"type": "string",
|
|
1128
|
+
"required": true,
|
|
1129
|
+
"description": "GCP region of the service and its network endpoint group."
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
"name": "projectId",
|
|
1133
|
+
"type": "string",
|
|
1134
|
+
"required": false,
|
|
1135
|
+
"description": "GCP project, as `projectRef.external` (default: GCP.ProjectId, resolved from GCP_PROJECT_ID at build)."
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
"name": "vpc",
|
|
1139
|
+
"type": "{ name: string }",
|
|
1140
|
+
"required": true,
|
|
1141
|
+
"description": "VPC the service's egress goes into, such as a PrivateEgressNetwork's `vpc`."
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
"name": "subnet",
|
|
1145
|
+
"type": "{ name: string }",
|
|
1146
|
+
"required": true,
|
|
1147
|
+
"description": "Subnet the service's egress goes into, such as a PrivateEgressNetwork's `subnet`."
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
"name": "vpcEgress",
|
|
1151
|
+
"type": "string",
|
|
1152
|
+
"required": false,
|
|
1153
|
+
"description": "Egress sent through the VPC: \"PRIVATE_RANGES_ONLY\" or \"ALL_TRAFFIC\" (default: \"PRIVATE_RANGES_ONLY\")."
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
"name": "port",
|
|
1157
|
+
"type": "number",
|
|
1158
|
+
"required": false,
|
|
1159
|
+
"description": "Port the container listens on (default: 8080)."
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
"name": "frontendPort",
|
|
1163
|
+
"type": "number",
|
|
1164
|
+
"required": false,
|
|
1165
|
+
"description": "Port the HTTP forwarding rule listens on (default: 80)."
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
"name": "minInstanceCount",
|
|
1169
|
+
"type": "number",
|
|
1170
|
+
"required": false,
|
|
1171
|
+
"description": "Minimum instances (default: 0)."
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
"name": "maxInstanceCount",
|
|
1175
|
+
"type": "number",
|
|
1176
|
+
"required": false,
|
|
1177
|
+
"description": "Maximum instances (default: 100)."
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
"name": "env",
|
|
1181
|
+
"type": "Array<{ name: string; value: string }>",
|
|
1182
|
+
"required": false,
|
|
1183
|
+
"description": "Environment variables."
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
"name": "serviceAccount",
|
|
1187
|
+
"type": "string",
|
|
1188
|
+
"required": false,
|
|
1189
|
+
"description": "An existing service account to run as: an email, or the account id of an IAMServiceAccount in the project."
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
"name": "domains",
|
|
1193
|
+
"type": "string[]",
|
|
1194
|
+
"required": false,
|
|
1195
|
+
"description": "Domains for a Google-managed certificate."
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
"name": "securityPolicy",
|
|
1199
|
+
"type": "string",
|
|
1200
|
+
"required": false,
|
|
1201
|
+
"description": "Name of a ComputeSecurityPolicy (Cloud Armor) attached to the backend service."
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
"name": "labels",
|
|
1205
|
+
"type": "Record<string, string>",
|
|
1206
|
+
"required": false,
|
|
1207
|
+
"description": "Additional labels."
|
|
1208
|
+
},
|
|
1209
|
+
{
|
|
1210
|
+
"name": "namespace",
|
|
1211
|
+
"type": "string",
|
|
1212
|
+
"required": false,
|
|
1213
|
+
"description": "Namespace for all resources."
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
"name": "defaults",
|
|
1217
|
+
"type": "{ runtime?: Partial<ConstructorParameters<typeof GCPServiceAccount>[0]>; service?: Record<string, unknown>; publicAcc...",
|
|
1218
|
+
"required": false,
|
|
1219
|
+
"description": "Per-member defaults for customizing individual resources."
|
|
1220
|
+
}
|
|
1221
|
+
]
|
|
1222
|
+
},
|
|
1036
1223
|
{
|
|
1037
1224
|
"name": "PubSubPipeline",
|
|
1038
1225
|
"lexicon": "gcp",
|