@nimbus-dev/connectors 0.7.0 → 0.9.0
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/connectors/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/iac/nimbus.extension.json +1 -1
- package/connectors/iac/src/tools.ts +808 -105
- package/connectors/kubernetes/nimbus.extension.json +1 -1
- package/connectors/kubernetes/src/manifest-scan.ts +602 -0
- package/connectors/kubernetes/src/tools.ts +366 -17
- package/connectors/snowflake/src/server.ts +69 -2
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +156 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +30 -0
- package/shared/private-kubeconfig.ts +111 -0
- package/shared/private-scratch.ts +68 -0
- package/shared/run-cli-json.ts +17 -1
|
@@ -1,66 +1,687 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { awsCliArg, cliArg } from "../../../shared/cli-json-kit.ts";
|
|
6
6
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createPrepareToolRegistrar,
|
|
9
|
+
createWriteToolRegistrar,
|
|
10
|
+
} from "../../../shared/consent-kit.ts";
|
|
8
11
|
import {
|
|
9
12
|
createRegisterSimpleTool,
|
|
10
13
|
createZodToolRegistrar,
|
|
11
14
|
mcpJsonResult as jsonResult,
|
|
12
15
|
} from "../../../shared/mcp-tool-kit.ts";
|
|
13
|
-
import {
|
|
16
|
+
import { scratchDirBase, withScratchDir } from "../../../shared/private-scratch.ts";
|
|
17
|
+
import { runCliCode, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
|
-
* The most a template may be when it is
|
|
17
|
-
*
|
|
20
|
+
* The most a template may be when it is handed to `create-change-set` as `--template-body`, which
|
|
21
|
+
* is how this connector hands one over (no S3 bucket): CloudFormation refuses a larger one itself.
|
|
22
|
+
* https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_CreateChangeSet.html
|
|
18
23
|
*/
|
|
19
24
|
const TEMPLATE_MAX_BYTES = 51_200;
|
|
20
25
|
|
|
26
|
+
/** A saved plan file is read whole (to hash it), so it is bounded. */
|
|
27
|
+
const PLAN_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
/** `terraform show -json` output is returned whole, so it is bounded. */
|
|
29
|
+
const PLAN_JSON_MAX_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
/** Most resource changes one change-set reply carries. */
|
|
31
|
+
const CHANGES_MAX = 500;
|
|
32
|
+
/** Most bytes of one change-set reply (the prepare tool's whole answer). */
|
|
33
|
+
const CHANGESET_REPLY_MAX_BYTES = 256 * 1024;
|
|
34
|
+
/** Longest the change set is polled for, and how often. */
|
|
35
|
+
const CHANGESET_POLL_DEADLINE_MS = 120_000;
|
|
36
|
+
const CHANGESET_POLL_INTERVAL_MS = 2000;
|
|
37
|
+
/** Page guard for `NextToken` following, so a misbehaving CLI cannot loop forever. */
|
|
38
|
+
const CHANGESET_PAGES_MAX = 100;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Time seams for the change-set poll, exported so a test never waits two real seconds per poll.
|
|
42
|
+
* Production never replaces them.
|
|
43
|
+
*/
|
|
44
|
+
export const iacTimeHooks: {
|
|
45
|
+
sleep: (ms: number) => Promise<void>;
|
|
46
|
+
now: () => number;
|
|
47
|
+
} = {
|
|
48
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
49
|
+
now: () => Date.now(),
|
|
50
|
+
};
|
|
51
|
+
|
|
21
52
|
/**
|
|
22
53
|
* A CloudFormation template. It reaches the AWS CLI as a file, never as an argument (see
|
|
23
|
-
*
|
|
24
|
-
* start with `---`.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
54
|
+
* `withScratchDir` below), so none of the argument rules apply to it: a YAML template may well
|
|
55
|
+
* start with `---`. The schema carries only the character bound a client is shown; the UTF-8 byte
|
|
56
|
+
* bound is {@link templateProblem}, run by the handlers and by `refuseBefore` (a `.refine()` on the
|
|
57
|
+
* registered schema would lose its `.shape`).
|
|
27
58
|
*/
|
|
28
|
-
const templateDocument = z
|
|
59
|
+
const templateDocument = z.string().min(1).max(TEMPLATE_MAX_BYTES);
|
|
60
|
+
|
|
61
|
+
/** Why `template` may not be sent, or `undefined`. A fixed message, before any spawn. */
|
|
62
|
+
export function templateProblem(template: string): string | undefined {
|
|
63
|
+
if (new TextEncoder().encode(template).length > TEMPLATE_MAX_BYTES) {
|
|
64
|
+
return `templateBody must be at most ${String(TEMPLATE_MAX_BYTES)} bytes as UTF-8, the most create-change-set takes without an S3 bucket`;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CAPABILITY_VALUES = [
|
|
70
|
+
"CAPABILITY_IAM",
|
|
71
|
+
"CAPABILITY_NAMED_IAM",
|
|
72
|
+
"CAPABILITY_AUTO_EXPAND",
|
|
73
|
+
] as const;
|
|
74
|
+
const capabilitiesSchema = z.array(z.enum(CAPABILITY_VALUES)).max(3).optional();
|
|
75
|
+
|
|
76
|
+
/** Why `capabilities` may not be sent (a duplicate), or `undefined`. */
|
|
77
|
+
function capabilitiesProblem(capabilities: readonly string[] | undefined): string | undefined {
|
|
78
|
+
if (capabilities !== undefined && new Set(capabilities).size !== capabilities.length) {
|
|
79
|
+
return "capabilities must not repeat a value";
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A change set's ARN. The CLI's `--change-set-name` takes a name or an ARN; the ARN alone names
|
|
86
|
+
* one change set of one stack unambiguously, and starts with `arn:` so it can never read as a flag
|
|
87
|
+
* or a `file://` load.
|
|
88
|
+
*/
|
|
89
|
+
const changeSetArn = z
|
|
29
90
|
.string()
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
.regex(
|
|
92
|
+
/^arn:aws[a-z-]*:cloudformation:[a-z0-9-]+:\d{12}:changeSet\/[A-Za-z][-A-Za-z0-9]{0,127}\/[0-9a-f-]{36}$/,
|
|
93
|
+
"must be a CloudFormation change set ARN",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** The change set name inside an ARN that already matched {@link changeSetArn}. */
|
|
97
|
+
function changeSetNameOf(arn: string): string {
|
|
98
|
+
return arn.split("/")[1] ?? "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A saved plan file path. `cliArg` refuses a leading `-` and control characters, and caps the
|
|
103
|
+
* length at 1024 characters (the repo-wide CLI argument bound, which `cli-argument-guards.test.ts`
|
|
104
|
+
* pins), a little under the 4096 the plan first named: a plan path under NIMBUS_IAC_PLAN_DIR is
|
|
105
|
+
* far shorter.
|
|
106
|
+
*/
|
|
107
|
+
const planFileArg = cliArg;
|
|
108
|
+
|
|
109
|
+
const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "must be a lower-case SHA-256 hex digest");
|
|
110
|
+
|
|
111
|
+
function sha256Of(data: string | Uint8Array): string {
|
|
112
|
+
return createHash("sha256").update(data).digest("hex");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The file names a plan is saved under: the KIND of plan is its name (apply vs destroy). */
|
|
116
|
+
const PLAN_NAME = "plan.tfplan";
|
|
117
|
+
const DESTROY_PLAN_NAME = "destroy.tfplan";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Why a plan file path is refused before the disk is touched (shared with refuseBefore): not
|
|
121
|
+
* absolute, a Windows device or UNC form (a leading backslash, which covers the
|
|
122
|
+
* long-path, device and host forms; on win32 also two forward slashes), or not one of the file
|
|
123
|
+
* names allowed (apply takes only plan.tfplan, destroy only destroy.tfplan).
|
|
124
|
+
*/
|
|
125
|
+
function planFilePathProblem(planFile: string, names: readonly string[]): string | undefined {
|
|
126
|
+
if (planFile.startsWith("\\") || (process.platform === "win32" && planFile.startsWith("//"))) {
|
|
127
|
+
return "planFile must not be a device or network path";
|
|
128
|
+
}
|
|
129
|
+
if (!isAbsolute(planFile)) return "planFile must be an absolute path";
|
|
130
|
+
if (!names.includes(basename(planFile))) {
|
|
131
|
+
return `planFile must be named ${names.join(" or ")}`;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
35
135
|
|
|
36
136
|
/**
|
|
37
|
-
*
|
|
137
|
+
* Check a plan file and return its REAL path, which is what is hashed and handed to terraform.
|
|
38
138
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
139
|
+
* It must be a regular, non-linked file of a sane size that lies strictly inside the real
|
|
140
|
+
* NIMBUS_IAC_PLAN_DIR (a symlinked parent directory, or a sibling whose name merely starts with
|
|
141
|
+
* the plan dir's, is outside). NO copy is made. Making that directory read-only to everyone but the
|
|
142
|
+
* gateway between approval and apply is the gateway's job; this connector can only narrow the
|
|
143
|
+
* window, not close it (README: stated bound).
|
|
43
144
|
*/
|
|
44
|
-
async function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
145
|
+
async function resolvePlanFile(planFile: string, names: readonly string[]): Promise<string> {
|
|
146
|
+
const problem = planFilePathProblem(planFile, names);
|
|
147
|
+
if (problem !== undefined) throw new Error(problem);
|
|
148
|
+
const base = await planDirBase();
|
|
149
|
+
let st: Awaited<ReturnType<typeof lstat>>;
|
|
150
|
+
try {
|
|
151
|
+
st = await lstat(planFile);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error("planFile does not exist");
|
|
154
|
+
}
|
|
155
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
156
|
+
throw new Error("planFile must be a regular file, not a link");
|
|
157
|
+
}
|
|
158
|
+
if (st.size > PLAN_FILE_MAX_BYTES) {
|
|
159
|
+
throw new Error("planFile is too large");
|
|
160
|
+
}
|
|
161
|
+
const realBase = await realpath(base);
|
|
162
|
+
const real = await realpath(planFile);
|
|
163
|
+
const rel = relative(realBase, real);
|
|
164
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
165
|
+
throw new Error(`planFile must be inside ${IAC_PLAN_DIR_ENV}`);
|
|
166
|
+
}
|
|
167
|
+
return real;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Environment of every terraform child: automation mode, and never an interactive prompt. */
|
|
171
|
+
function terraformEnv(): Record<string, string | undefined> {
|
|
172
|
+
return { ...process.env, TF_IN_AUTOMATION: "1", TF_INPUT: "0" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The directory plan files live under: absolute, an existing real directory. */
|
|
176
|
+
export const IAC_PLAN_DIR_ENV = "NIMBUS_IAC_PLAN_DIR";
|
|
177
|
+
|
|
178
|
+
async function planDirBase(): Promise<string> {
|
|
179
|
+
const configured = process.env[IAC_PLAN_DIR_ENV]?.trim();
|
|
180
|
+
if (configured === undefined || configured === "") {
|
|
181
|
+
throw new Error(`${IAC_PLAN_DIR_ENV} is not set: a plan needs a directory to be saved in`);
|
|
182
|
+
}
|
|
183
|
+
if (!isAbsolute(configured)) {
|
|
184
|
+
throw new Error(`${IAC_PLAN_DIR_ENV} must be an absolute path`);
|
|
185
|
+
}
|
|
186
|
+
let st: Awaited<ReturnType<typeof lstat>>;
|
|
187
|
+
try {
|
|
188
|
+
st = await lstat(configured);
|
|
189
|
+
} catch {
|
|
190
|
+
throw new Error(`${IAC_PLAN_DIR_ENV} does not name an existing directory`);
|
|
191
|
+
}
|
|
192
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
193
|
+
throw new Error(`${IAC_PLAN_DIR_ENV} must be a real directory, not a link`);
|
|
194
|
+
}
|
|
195
|
+
return configured;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `terraform plan` / `plan -destroy`, saved under a fresh per-plan directory. */
|
|
199
|
+
async function terraformPlan(
|
|
200
|
+
workingDirectory: string,
|
|
201
|
+
destroy: boolean,
|
|
202
|
+
): Promise<ReturnType<typeof jsonResult>> {
|
|
203
|
+
const base = await planDirBase();
|
|
204
|
+
const planId = randomBytes(16).toString("hex");
|
|
205
|
+
const planDir = join(base, planId);
|
|
206
|
+
// Non-recursive: an existing directory of that name is an error, never silently reused.
|
|
207
|
+
await mkdir(planDir, { mode: 0o700 });
|
|
208
|
+
const planFile = join(planDir, destroy ? DESTROY_PLAN_NAME : PLAN_NAME);
|
|
209
|
+
try {
|
|
210
|
+
const r = await runCliCode(
|
|
211
|
+
[
|
|
212
|
+
"terraform",
|
|
213
|
+
`-chdir=${workingDirectory}`,
|
|
214
|
+
"plan",
|
|
215
|
+
...(destroy ? ["-destroy"] : []),
|
|
216
|
+
"-input=false",
|
|
217
|
+
"-no-color",
|
|
218
|
+
"-detailed-exitcode",
|
|
219
|
+
"-lock-timeout=30s",
|
|
220
|
+
`-out=${planFile}`,
|
|
221
|
+
],
|
|
222
|
+
terraformEnv(),
|
|
223
|
+
);
|
|
224
|
+
// -detailed-exitcode: 0 = empty diff, 1 = error, 2 = changes present.
|
|
225
|
+
// https://developer.hashicorp.com/terraform/cli/commands/plan
|
|
226
|
+
if (r.code !== 0 && r.code !== 2) {
|
|
227
|
+
throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await chmod(planFile, 0o600);
|
|
231
|
+
} catch {
|
|
232
|
+
// Best effort: Windows has no POSIX modes; the directory is already 0700 where it matters.
|
|
233
|
+
}
|
|
234
|
+
const planSha256 = sha256Of(await readFile(planFile));
|
|
235
|
+
return jsonResult({ planId, planFile, planSha256, hasChanges: r.code === 2 });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
await rm(planDir, { recursive: true, force: true });
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `terraform apply <planFile>` after the file's hash is proven to be the approved one. */
|
|
243
|
+
async function terraformApplyPlan(
|
|
244
|
+
p: { workingDirectory: string; planFile: string; planSha256: string },
|
|
245
|
+
name: string,
|
|
246
|
+
): Promise<ReturnType<typeof jsonResult>> {
|
|
247
|
+
const planFile = await resolvePlanFile(p.planFile, [name]);
|
|
248
|
+
// Read ONCE; the hash is of exactly these bytes.
|
|
249
|
+
const actual = Buffer.from(sha256Of(await readFile(planFile)), "hex");
|
|
250
|
+
const approved = Buffer.from(p.planSha256, "hex");
|
|
251
|
+
if (actual.length !== approved.length || !timingSafeEqual(actual, approved)) {
|
|
252
|
+
throw new Error("plan file does not match the approved hash");
|
|
253
|
+
}
|
|
254
|
+
// No -auto-approve and no -destroy: a saved plan applies as it was planned, and `apply <file>`
|
|
255
|
+
// runs without a prompt. https://developer.hashicorp.com/terraform/cli/commands/apply
|
|
256
|
+
const r = await runCliCode(
|
|
257
|
+
["terraform", `-chdir=${p.workingDirectory}`, "apply", "-input=false", "-no-color", planFile],
|
|
258
|
+
terraformEnv(),
|
|
259
|
+
);
|
|
260
|
+
if (r.code !== 0) {
|
|
261
|
+
throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
262
|
+
}
|
|
263
|
+
// A plan carries secrets and is stale once applied. Best effort: a directory the gateway made
|
|
264
|
+
// read-only makes this fail, which is ignored.
|
|
49
265
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
266
|
+
await rm(planFile, { force: true });
|
|
267
|
+
} catch {
|
|
268
|
+
// ignored, see above
|
|
269
|
+
}
|
|
270
|
+
return jsonResult({ ok: true, status: "applied" });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// CloudFormation
|
|
275
|
+
|
|
276
|
+
/** The aws CLI's environment: the operator's, plus UTF-8 for the template file it reads. */
|
|
277
|
+
function awsEnv(): Record<string, string | undefined> {
|
|
278
|
+
// The file is UTF-8. Left to itself the CLI reads it in the locale's encoding, and on a
|
|
279
|
+
// Windows machine using cp1252 a template holding "Á" fails to decode while one holding
|
|
280
|
+
// "é" deploys as "é". AWS CLI v2 reads AWS_CLI_FILE_ENCODING and ignores PYTHONUTF8; v1,
|
|
281
|
+
// plain Python, reads PYTHONUTF8 and ignores the other. Both are set.
|
|
282
|
+
return { ...process.env, AWS_CLI_FILE_ENCODING: "UTF-8", PYTHONUTF8: "1" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Run an aws command; any non-zero exit throws the one failure message. Resolves to stdout. */
|
|
286
|
+
async function awsOk(argv: readonly string[]): Promise<string> {
|
|
287
|
+
const r = await runCliCode(["aws", ...argv], awsEnv());
|
|
288
|
+
if (r.code !== 0) {
|
|
289
|
+
throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
290
|
+
}
|
|
291
|
+
return r.stdout;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseObject(text: string, what: string): Record<string, unknown> {
|
|
295
|
+
let v: unknown;
|
|
296
|
+
try {
|
|
297
|
+
v = JSON.parse(text);
|
|
298
|
+
} catch {
|
|
299
|
+
throw new Error(`invalid JSON from aws ${what}`);
|
|
300
|
+
}
|
|
301
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
302
|
+
throw new Error(`unexpected reply from aws ${what}`);
|
|
303
|
+
}
|
|
304
|
+
return v as Record<string, unknown>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function str(v: unknown): string | undefined {
|
|
308
|
+
return typeof v === "string" ? v : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type StackState =
|
|
312
|
+
| { exists: false }
|
|
313
|
+
| { exists: true; status: string; statusReason?: string; lastUpdatedTime?: string };
|
|
314
|
+
|
|
315
|
+
async function describeStack(stackName: string): Promise<StackState> {
|
|
316
|
+
const r = await runCliCode(
|
|
317
|
+
["aws", "cloudformation", "describe-stacks", "--stack-name", stackName, "--output", "json"],
|
|
318
|
+
awsEnv(),
|
|
319
|
+
);
|
|
320
|
+
if (r.code !== 0) {
|
|
321
|
+
// A missing stack is an ordinary answer (a first deploy), reported on stderr as
|
|
322
|
+
// "Stack with id X does not exist".
|
|
323
|
+
if (/does not exist/.test(r.stderr)) return { exists: false };
|
|
324
|
+
throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
325
|
+
}
|
|
326
|
+
const stacks = parseObject(r.stdout, "describe-stacks")["Stacks"];
|
|
327
|
+
const first: unknown = Array.isArray(stacks) ? stacks[0] : undefined;
|
|
328
|
+
if (typeof first !== "object" || first === null) return { exists: false };
|
|
329
|
+
const s = first as Record<string, unknown>;
|
|
330
|
+
const status = str(s["StackStatus"]);
|
|
331
|
+
if (status === undefined) throw new Error("unexpected reply from aws describe-stacks");
|
|
332
|
+
const out: StackState = { exists: true, status };
|
|
333
|
+
const reason = str(s["StackStatusReason"]);
|
|
334
|
+
if (reason !== undefined) out.statusReason = reason;
|
|
335
|
+
const updated = str(s["LastUpdatedTime"]) ?? str(s["CreationTime"]);
|
|
336
|
+
if (updated !== undefined) out.lastUpdatedTime = updated;
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Remove a change set, and the empty stack shell a CREATE-type change set leaves behind.
|
|
342
|
+
*
|
|
343
|
+
* A first-time deploy's change set puts the stack in REVIEW_IN_PROGRESS, a shell with no resources
|
|
344
|
+
* that lingers. It is deleted only when it is in that status AND holds no change set at all; a
|
|
345
|
+
* stack in any other status is never deleted here.
|
|
346
|
+
* https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-changesets.html
|
|
347
|
+
*/
|
|
348
|
+
async function cleanupChangeSet(
|
|
349
|
+
stackName: string,
|
|
350
|
+
changeSet: string,
|
|
351
|
+
skipChangeSetDelete: boolean,
|
|
352
|
+
): Promise<{ changeSetDeleted: boolean; stackDeleted: boolean }> {
|
|
353
|
+
if (!skipChangeSetDelete) {
|
|
354
|
+
await awsOk(["cloudformation", "delete-change-set", "--change-set-name", changeSet]);
|
|
355
|
+
}
|
|
356
|
+
let stackDeleted = false;
|
|
357
|
+
const stack = await describeStack(stackName);
|
|
358
|
+
if (stack.exists && stack.status === "REVIEW_IN_PROGRESS") {
|
|
359
|
+
const listed = await awsOk([
|
|
360
|
+
"cloudformation",
|
|
361
|
+
"list-change-sets",
|
|
362
|
+
"--stack-name",
|
|
363
|
+
stackName,
|
|
364
|
+
"--output",
|
|
365
|
+
"json",
|
|
366
|
+
]);
|
|
367
|
+
const summaries = parseObject(listed, "list-change-sets")["Summaries"];
|
|
368
|
+
if (!Array.isArray(summaries) || summaries.length === 0) {
|
|
369
|
+
await awsOk(["cloudformation", "delete-stack", "--stack-name", stackName]);
|
|
370
|
+
stackDeleted = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { changeSetDeleted: !skipChangeSetDelete, stackDeleted };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
type ChangeRow = {
|
|
377
|
+
action: string;
|
|
378
|
+
logicalId: string;
|
|
379
|
+
physicalId?: string;
|
|
380
|
+
resourceType: string;
|
|
381
|
+
replacement: string;
|
|
382
|
+
scope: string[];
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
function changeRowOf(raw: unknown): ChangeRow | undefined {
|
|
386
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
387
|
+
const rc = (raw as Record<string, unknown>)["ResourceChange"];
|
|
388
|
+
if (typeof rc !== "object" || rc === null) return undefined;
|
|
389
|
+
const c = rc as Record<string, unknown>;
|
|
390
|
+
const row: ChangeRow = {
|
|
391
|
+
action: str(c["Action"]) ?? "unknown",
|
|
392
|
+
logicalId: str(c["LogicalResourceId"]) ?? "",
|
|
393
|
+
resourceType: str(c["ResourceType"]) ?? "",
|
|
394
|
+
replacement: str(c["Replacement"]) ?? "unknown",
|
|
395
|
+
scope: stringList(c["Scope"]),
|
|
396
|
+
};
|
|
397
|
+
const physical = str(c["PhysicalResourceId"]);
|
|
398
|
+
if (physical !== undefined) row.physicalId = physical;
|
|
399
|
+
return row;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function stringList(v: unknown): string[] {
|
|
403
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
407
|
+
return new Set(a).size === a.length && a.length === b.length && a.every((x) => b.includes(x));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The name every change set of this connector carries: bound to the exact template it is for. */
|
|
411
|
+
function changeSetNameFor(templateSha256: string): string {
|
|
412
|
+
return `nimbus-${templateSha256}-${randomBytes(6).toString("hex")}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Sync refusals shared by the execute handler and `refuseBefore`. */
|
|
416
|
+
function deployProblem(p: {
|
|
417
|
+
templateBody: string;
|
|
418
|
+
capabilities?: readonly string[] | undefined;
|
|
419
|
+
changeSetId: string;
|
|
420
|
+
}): string | undefined {
|
|
421
|
+
const t = templateProblem(p.templateBody) ?? capabilitiesProblem(p.capabilities);
|
|
422
|
+
if (t !== undefined) return t;
|
|
423
|
+
const expected = new RegExp(`^nimbus-${sha256Of(p.templateBody)}-[0-9a-f]{12}$`);
|
|
424
|
+
if (!expected.test(changeSetNameOf(p.changeSetId))) {
|
|
425
|
+
return "the change set was not made for this template";
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Poll the change set until it is CREATE_COMPLETE; returns that reply. Throws otherwise. */
|
|
431
|
+
async function awaitChangeSet(
|
|
432
|
+
describe: (nextToken?: string) => Promise<string>,
|
|
433
|
+
): Promise<Record<string, unknown>> {
|
|
434
|
+
const deadline = iacTimeHooks.now() + CHANGESET_POLL_DEADLINE_MS;
|
|
435
|
+
for (;;) {
|
|
436
|
+
const d = parseObject(await describe(), "describe-change-set");
|
|
437
|
+
const status = str(d["Status"]);
|
|
438
|
+
if (status === "CREATE_COMPLETE") return d;
|
|
439
|
+
if (status !== "CREATE_PENDING" && status !== "CREATE_IN_PROGRESS") {
|
|
440
|
+
// FAILED (a template with no changes lands here too: its StatusReason says so) or any
|
|
441
|
+
// other terminal status.
|
|
442
|
+
// Capped like every stderr echo here: the reason is AWS's text and may quote the template.
|
|
443
|
+
throw new Error(
|
|
444
|
+
`change set ${status ?? "unknown"}: ${(str(d["StatusReason"]) ?? "no reason given").slice(0, 500)}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (iacTimeHooks.now() >= deadline) {
|
|
448
|
+
throw new Error("change set was not ready within 120 seconds");
|
|
449
|
+
}
|
|
450
|
+
await iacTimeHooks.sleep(CHANGESET_POLL_INTERVAL_MS);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Every resource change, following `NextToken` by hand. */
|
|
455
|
+
async function collectChanges(
|
|
456
|
+
first: Record<string, unknown>,
|
|
457
|
+
describe: (nextToken?: string) => Promise<string>,
|
|
458
|
+
): Promise<ChangeRow[]> {
|
|
459
|
+
const changes: ChangeRow[] = [];
|
|
460
|
+
let page = first;
|
|
461
|
+
for (let pages = 1; ; pages += 1) {
|
|
462
|
+
const raw = page["Changes"];
|
|
463
|
+
for (const item of Array.isArray(raw) ? raw : []) {
|
|
464
|
+
const row = changeRowOf(item);
|
|
465
|
+
if (row !== undefined) changes.push(row);
|
|
466
|
+
}
|
|
467
|
+
if (changes.length > CHANGES_MAX) throw new Error("change set too large");
|
|
468
|
+
const token = str(page["NextToken"]);
|
|
469
|
+
if (token === undefined || token === "") return changes;
|
|
470
|
+
if (pages >= CHANGESET_PAGES_MAX) throw new Error("change set too large");
|
|
471
|
+
// U: whether the CLI paginates describe-change-set itself is unconfirmed; see the README.
|
|
472
|
+
page = parseObject(await describe(token), "describe-change-set");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function changeSetCreate(p: {
|
|
477
|
+
stackName: string;
|
|
478
|
+
templateBody: string;
|
|
479
|
+
capabilities?: readonly string[] | undefined;
|
|
480
|
+
}): Promise<ReturnType<typeof jsonResult>> {
|
|
481
|
+
const bad = templateProblem(p.templateBody) ?? capabilitiesProblem(p.capabilities);
|
|
482
|
+
if (bad !== undefined) throw new Error(bad);
|
|
483
|
+
const capabilities = p.capabilities ?? [];
|
|
484
|
+
const templateSha256 = sha256Of(p.templateBody);
|
|
485
|
+
// Refuse a missing scratch grant (Windows under the gateway) before the first aws call, as the
|
|
486
|
+
// kubernetes plan does: the template file is written under it below.
|
|
487
|
+
scratchDirBase();
|
|
488
|
+
|
|
489
|
+
const stack = await describeStack(p.stackName);
|
|
490
|
+
// REVIEW_IN_PROGRESS: a shell left by an earlier CREATE change set, which only a CREATE change
|
|
491
|
+
// set may be added to. DELETE_COMPLETE: a deleted stack can be created again under its name.
|
|
492
|
+
const changeSetType =
|
|
493
|
+
!stack.exists || stack.status === "REVIEW_IN_PROGRESS" || stack.status === "DELETE_COMPLETE"
|
|
494
|
+
? "CREATE"
|
|
495
|
+
: "UPDATE";
|
|
496
|
+
const name = changeSetNameFor(templateSha256);
|
|
497
|
+
|
|
498
|
+
return withScratchDir("nimbus-cfn-", async (dir) => {
|
|
499
|
+
const templatePath = join(dir, "template");
|
|
500
|
+
await writeFile(templatePath, p.templateBody, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
501
|
+
const created = parseObject(
|
|
502
|
+
await awsOk([
|
|
503
|
+
"cloudformation",
|
|
504
|
+
"create-change-set",
|
|
505
|
+
"--stack-name",
|
|
506
|
+
p.stackName,
|
|
507
|
+
"--change-set-name",
|
|
508
|
+
name,
|
|
509
|
+
"--change-set-type",
|
|
510
|
+
changeSetType,
|
|
511
|
+
"--template-body",
|
|
512
|
+
`file://${templatePath}`,
|
|
513
|
+
...(capabilities.length > 0 ? ["--capabilities", ...capabilities] : []),
|
|
514
|
+
"--output",
|
|
515
|
+
"json",
|
|
516
|
+
]),
|
|
517
|
+
"create-change-set",
|
|
518
|
+
);
|
|
519
|
+
const changeSetId = str(created["Id"]);
|
|
520
|
+
if (changeSetId === undefined) {
|
|
521
|
+
// The call succeeded, so a change set (and, for CREATE, a REVIEW_IN_PROGRESS stack) may
|
|
522
|
+
// exist: delete it by name, best effort, rather than leave it behind with no Id to report.
|
|
523
|
+
await runCliCode(
|
|
524
|
+
[
|
|
525
|
+
"aws",
|
|
526
|
+
"cloudformation",
|
|
527
|
+
"delete-change-set",
|
|
528
|
+
"--change-set-name",
|
|
529
|
+
name,
|
|
530
|
+
"--stack-name",
|
|
531
|
+
p.stackName,
|
|
532
|
+
],
|
|
533
|
+
awsEnv(),
|
|
534
|
+
);
|
|
535
|
+
throw new Error("unexpected reply from aws create-change-set");
|
|
536
|
+
}
|
|
537
|
+
const stackId = str(created["StackId"]) ?? "";
|
|
538
|
+
|
|
539
|
+
const describe = (nextToken?: string): Promise<string> =>
|
|
540
|
+
awsOk([
|
|
541
|
+
"cloudformation",
|
|
542
|
+
"describe-change-set",
|
|
543
|
+
"--change-set-name",
|
|
544
|
+
changeSetId,
|
|
545
|
+
...(nextToken === undefined ? [] : ["--next-token", nextToken]),
|
|
546
|
+
"--output",
|
|
547
|
+
"json",
|
|
548
|
+
]);
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const changes = await collectChanges(await awaitChangeSet(describe), describe);
|
|
552
|
+
const reply = {
|
|
553
|
+
changeSetId,
|
|
554
|
+
changeSetName: name,
|
|
555
|
+
stackId,
|
|
556
|
+
stackName: p.stackName,
|
|
557
|
+
changeSetType,
|
|
558
|
+
templateSha256,
|
|
559
|
+
capabilities,
|
|
560
|
+
changes,
|
|
561
|
+
};
|
|
562
|
+
if (Buffer.byteLength(JSON.stringify(reply), "utf8") > CHANGESET_REPLY_MAX_BYTES) {
|
|
563
|
+
throw new Error("change set too large");
|
|
564
|
+
}
|
|
565
|
+
return jsonResult(reply);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
try {
|
|
568
|
+
await cleanupChangeSet(p.stackName, changeSetId, false);
|
|
569
|
+
} catch {
|
|
570
|
+
// Best effort: the failure being reported is the original one.
|
|
571
|
+
}
|
|
572
|
+
throw e;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function changeSetExecute(p: {
|
|
578
|
+
stackName: string;
|
|
579
|
+
templateBody: string;
|
|
580
|
+
capabilities?: readonly string[] | undefined;
|
|
581
|
+
changeSetId: string;
|
|
582
|
+
}): Promise<ReturnType<typeof jsonResult>> {
|
|
583
|
+
const bad = deployProblem(p);
|
|
584
|
+
if (bad !== undefined) throw new Error(bad);
|
|
585
|
+
const d = parseObject(
|
|
586
|
+
await awsOk([
|
|
587
|
+
"cloudformation",
|
|
588
|
+
"describe-change-set",
|
|
589
|
+
"--change-set-name",
|
|
590
|
+
p.changeSetId,
|
|
591
|
+
"--output",
|
|
592
|
+
"json",
|
|
593
|
+
]),
|
|
594
|
+
"describe-change-set",
|
|
595
|
+
);
|
|
596
|
+
if (str(d["Status"]) !== "CREATE_COMPLETE") {
|
|
597
|
+
throw new Error(`change set is ${str(d["Status"]) ?? "unknown"}, not CREATE_COMPLETE`);
|
|
598
|
+
}
|
|
599
|
+
if (str(d["ExecutionStatus"]) !== "AVAILABLE") {
|
|
600
|
+
throw new Error(`change set cannot be executed: ${str(d["ExecutionStatus"]) ?? "unknown"}`);
|
|
601
|
+
}
|
|
602
|
+
if (str(d["StackName"]) !== p.stackName) {
|
|
603
|
+
throw new Error("change set belongs to a different stack");
|
|
55
604
|
}
|
|
605
|
+
if (!setEqual(stringList(d["Capabilities"]), p.capabilities ?? [])) {
|
|
606
|
+
throw new Error("change set capabilities do not match the approved capabilities");
|
|
607
|
+
}
|
|
608
|
+
await awsOk(["cloudformation", "execute-change-set", "--change-set-name", p.changeSetId]);
|
|
609
|
+
return jsonResult({
|
|
610
|
+
ok: true,
|
|
611
|
+
status: "execution started",
|
|
612
|
+
changeSetId: p.changeSetId,
|
|
613
|
+
stackId: str(d["StackId"]) ?? "",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The exact shape of a change set name this connector makes (`nimbus-<template sha256>-<12 hex>`).
|
|
619
|
+
* Discard requires all of it, not a `nimbus-` prefix: a person's own `nimbus-prod-hotfix` change
|
|
620
|
+
* set must not be deletable through a tool that says it deletes only its own.
|
|
621
|
+
*/
|
|
622
|
+
const NIMBUS_CHANGE_SET_NAME = /^nimbus-[0-9a-f]{64}-[0-9a-f]{12}$/;
|
|
623
|
+
|
|
624
|
+
function discardProblem(changeSetId: string): string | undefined {
|
|
625
|
+
return NIMBUS_CHANGE_SET_NAME.test(changeSetNameOf(changeSetId))
|
|
626
|
+
? undefined
|
|
627
|
+
: "only a change set this connector made (nimbus-<template sha256>-<12 hex>) can be discarded";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function describeChangeSetOrMissing(
|
|
631
|
+
changeSetId: string,
|
|
632
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
633
|
+
const r = await runCliCode(
|
|
634
|
+
[
|
|
635
|
+
"aws",
|
|
636
|
+
"cloudformation",
|
|
637
|
+
"describe-change-set",
|
|
638
|
+
"--change-set-name",
|
|
639
|
+
changeSetId,
|
|
640
|
+
"--output",
|
|
641
|
+
"json",
|
|
642
|
+
],
|
|
643
|
+
awsEnv(),
|
|
644
|
+
);
|
|
645
|
+
if (r.code !== 0) {
|
|
646
|
+
if (/ChangeSetNotFound|does not exist/.test(r.stderr)) return undefined;
|
|
647
|
+
throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
648
|
+
}
|
|
649
|
+
return parseObject(r.stdout, "describe-change-set");
|
|
56
650
|
}
|
|
57
651
|
|
|
652
|
+
async function changeSetDiscard(p: {
|
|
653
|
+
stackName: string;
|
|
654
|
+
changeSetId: string;
|
|
655
|
+
}): Promise<ReturnType<typeof jsonResult>> {
|
|
656
|
+
const bad = discardProblem(p.changeSetId);
|
|
657
|
+
if (bad !== undefined) throw new Error(bad);
|
|
658
|
+
const d = await describeChangeSetOrMissing(p.changeSetId);
|
|
659
|
+
if (d !== undefined && str(d["StackName"]) !== p.stackName) {
|
|
660
|
+
throw new Error("change set belongs to a different stack");
|
|
661
|
+
}
|
|
662
|
+
// Not found: nothing of ours to remove, so the empty-shell deletion is NOT attempted either. It
|
|
663
|
+
// runs only after THIS call deleted a change set that belongs to the stack. (Bound: between
|
|
664
|
+
// list-change-sets and delete-stack another change set could be added; README.)
|
|
665
|
+
if (d === undefined)
|
|
666
|
+
return jsonResult({ ok: true, changeSetDeleted: false, stackDeleted: false });
|
|
667
|
+
const done = await cleanupChangeSet(p.stackName, p.changeSetId, false);
|
|
668
|
+
return jsonResult({ ok: true, ...done });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const PULUMI_UP_REFUSAL =
|
|
672
|
+
"iac_pulumi_up is refused: Pulumi saved update plans are documented as experimental, so no plan-bound apply exists (OR-W34-2)";
|
|
673
|
+
|
|
58
674
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
59
675
|
export const IAC_TOOL_NAMES = [
|
|
60
676
|
"iac_terraform_plan",
|
|
677
|
+
"iac_terraform_destroy_plan",
|
|
678
|
+
"iac_terraform_show_json",
|
|
61
679
|
"iac_terraform_apply",
|
|
62
680
|
"iac_terraform_destroy",
|
|
681
|
+
"iac_cloudformation_changeset_create",
|
|
682
|
+
"iac_cloudformation_changeset_discard",
|
|
63
683
|
"iac_cloudformation_deploy",
|
|
684
|
+
"iac_cloudformation_stack_get",
|
|
64
685
|
"iac_pulumi_preview",
|
|
65
686
|
"iac_pulumi_up",
|
|
66
687
|
] as const;
|
|
@@ -70,110 +691,195 @@ export function registerIacTools(
|
|
|
70
691
|
): void {
|
|
71
692
|
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
72
693
|
|
|
694
|
+
const cfg = {
|
|
695
|
+
connector: "iac",
|
|
696
|
+
scopeEnv: "NIMBUS_MCP_IAC_WRITE_SCOPE",
|
|
697
|
+
scopeKinds: ["dir", "stack"],
|
|
698
|
+
} as const;
|
|
699
|
+
|
|
73
700
|
/**
|
|
74
701
|
* Every MUTATING iac tool goes through here. Outside the gateway this adds the
|
|
75
702
|
* consent gate, the write-scope allow-list, the mutation budget and the audit record; inside
|
|
76
703
|
* the gateway it is a pass-through, because executor.ts (I2) is the gate there.
|
|
77
704
|
*/
|
|
78
|
-
const registerWriteTool = createWriteToolRegistrar(server,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
scopeKinds: ["dir", "stack"],
|
|
82
|
-
});
|
|
705
|
+
const registerWriteTool = createWriteToolRegistrar(server, cfg);
|
|
706
|
+
/** Tools the gateway calls before a write's prompt: they change nothing but a scratch area. */
|
|
707
|
+
const registerPrepareTool = createPrepareToolRegistrar(server, cfg);
|
|
83
708
|
|
|
84
|
-
const
|
|
709
|
+
const dirScope = (p: { workingDirectory: string }): { kind: string; value: string } => ({
|
|
710
|
+
kind: "dir",
|
|
711
|
+
value: p.workingDirectory,
|
|
712
|
+
});
|
|
713
|
+
const stackScope = (p: { stackName: string }): { kind: string; value: string } => ({
|
|
714
|
+
kind: "stack",
|
|
715
|
+
value: p.stackName,
|
|
716
|
+
});
|
|
85
717
|
|
|
86
|
-
|
|
718
|
+
registerPrepareTool(
|
|
87
719
|
"iac_terraform_plan",
|
|
88
|
-
|
|
720
|
+
{ scopeTargetOf: dirScope },
|
|
721
|
+
"Run terraform plan in a directory and save the plan under NIMBUS_IAC_PLAN_DIR. Returns the plan id, file, its SHA-256 and whether it has changes.",
|
|
722
|
+
z.object({ workingDirectory: cliArg }),
|
|
723
|
+
(p) => terraformPlan(p.workingDirectory, false),
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
registerPrepareTool(
|
|
727
|
+
"iac_terraform_destroy_plan",
|
|
728
|
+
{ scopeTargetOf: dirScope },
|
|
729
|
+
"Run terraform plan -destroy in a directory and save the plan under NIMBUS_IAC_PLAN_DIR.",
|
|
89
730
|
z.object({ workingDirectory: cliArg }),
|
|
731
|
+
(p) => terraformPlan(p.workingDirectory, true),
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
registerPrepareTool(
|
|
735
|
+
"iac_terraform_show_json",
|
|
736
|
+
{ scopeTargetOf: dirScope },
|
|
737
|
+
"Render a saved terraform plan file as JSON (terraform show -json).",
|
|
738
|
+
z.object({ workingDirectory: cliArg, planFile: planFileArg }),
|
|
90
739
|
async (p) => {
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
740
|
+
const planFile = await resolvePlanFile(p.planFile, [PLAN_NAME, DESTROY_PLAN_NAME]);
|
|
741
|
+
// Read once, immediately before the show: the hash is of the bytes the owner is shown.
|
|
742
|
+
const planSha256 = sha256Of(await readFile(planFile));
|
|
743
|
+
const r = await runCliCode(
|
|
744
|
+
["terraform", `-chdir=${p.workingDirectory}`, "show", "-json", "-no-color", planFile],
|
|
745
|
+
terraformEnv(),
|
|
96
746
|
);
|
|
97
|
-
|
|
747
|
+
if (r.code !== 0) {
|
|
748
|
+
throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
|
|
749
|
+
}
|
|
750
|
+
if (Buffer.byteLength(r.stdout, "utf8") > PLAN_JSON_MAX_BYTES) {
|
|
751
|
+
throw new Error("plan JSON too large");
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
JSON.parse(r.stdout);
|
|
755
|
+
} catch {
|
|
756
|
+
throw new Error("terraform show -json did not print JSON");
|
|
757
|
+
}
|
|
758
|
+
return jsonResult({ planJson: r.stdout, planSha256 });
|
|
98
759
|
},
|
|
99
760
|
);
|
|
100
761
|
|
|
762
|
+
const applyShape = {
|
|
763
|
+
workingDirectory: cliArg,
|
|
764
|
+
planFile: planFileArg,
|
|
765
|
+
planSha256: sha256Hex,
|
|
766
|
+
};
|
|
767
|
+
const applyPreState = (p: {
|
|
768
|
+
workingDirectory: string;
|
|
769
|
+
planFile: string;
|
|
770
|
+
planSha256: string;
|
|
771
|
+
}): Promise<Record<string, unknown>> =>
|
|
772
|
+
Promise.resolve({
|
|
773
|
+
workingDirectory: p.workingDirectory,
|
|
774
|
+
planFile: p.planFile,
|
|
775
|
+
planSha256: p.planSha256,
|
|
776
|
+
});
|
|
777
|
+
|
|
101
778
|
registerWriteTool(
|
|
102
779
|
"iac_terraform_apply",
|
|
103
780
|
{
|
|
104
781
|
mutates: "iac.terraform.apply",
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await runCliOkThrowing(
|
|
112
|
-
["terraform", `-chdir=${p.workingDirectory}`, "apply", "-auto-approve", "-input=false"],
|
|
113
|
-
{ ...processEnv },
|
|
114
|
-
);
|
|
115
|
-
return jsonResult({ ok: true });
|
|
782
|
+
// Applying infrastructure cannot be undone from its own result; the plan's identity is the
|
|
783
|
+
// record.
|
|
784
|
+
recoverable: false,
|
|
785
|
+
capturePreState: applyPreState,
|
|
786
|
+
scopeTargetOf: dirScope,
|
|
787
|
+
refuseBefore: (p) => planFilePathProblem(p.planFile, [PLAN_NAME]),
|
|
116
788
|
},
|
|
789
|
+
"Apply a saved terraform plan file (plan.tfplan) whose SHA-256 matches the approved one.",
|
|
790
|
+
z.object(applyShape),
|
|
791
|
+
(p) => terraformApplyPlan(p, PLAN_NAME),
|
|
117
792
|
);
|
|
118
793
|
|
|
119
794
|
registerWriteTool(
|
|
120
795
|
"iac_terraform_destroy",
|
|
121
796
|
{
|
|
122
797
|
mutates: "iac.terraform.destroy",
|
|
123
|
-
// Destroying infrastructure cannot be undone from its own result, and there is nothing
|
|
124
|
-
// queryable to snapshot, so the identifying parameters ARE the pre-state: a record naming
|
|
125
|
-
// WHICH directory was destroyed still beats silence.
|
|
126
798
|
recoverable: false,
|
|
127
|
-
capturePreState:
|
|
128
|
-
scopeTargetOf:
|
|
799
|
+
capturePreState: applyPreState,
|
|
800
|
+
scopeTargetOf: dirScope,
|
|
801
|
+
refuseBefore: (p) => planFilePathProblem(p.planFile, [DESTROY_PLAN_NAME]),
|
|
129
802
|
},
|
|
130
|
-
"
|
|
131
|
-
z.object(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
803
|
+
"Apply a saved terraform destroy plan file (destroy.tfplan) whose SHA-256 matches the approved one.",
|
|
804
|
+
z.object(applyShape),
|
|
805
|
+
(p) => terraformApplyPlan(p, DESTROY_PLAN_NAME),
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
registerPrepareTool(
|
|
809
|
+
"iac_cloudformation_changeset_create",
|
|
810
|
+
{ scopeTargetOf: stackScope },
|
|
811
|
+
"Create a CloudFormation change set for a template and list its resource changes. Nothing is executed.",
|
|
812
|
+
z.object({
|
|
813
|
+
stackName: awsCliArg,
|
|
814
|
+
templateBody: templateDocument,
|
|
815
|
+
capabilities: capabilitiesSchema,
|
|
816
|
+
}),
|
|
817
|
+
changeSetCreate,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
registerWriteTool(
|
|
821
|
+
"iac_cloudformation_changeset_discard",
|
|
822
|
+
{
|
|
823
|
+
mutates: "iac.cloudformation.changeset.discard",
|
|
824
|
+
recoverable: false,
|
|
825
|
+
capturePreState: async (p) => {
|
|
826
|
+
const d = await describeChangeSetOrMissing(p.changeSetId);
|
|
827
|
+
return d === undefined
|
|
828
|
+
? { exists: false }
|
|
829
|
+
: {
|
|
830
|
+
exists: true,
|
|
831
|
+
status: str(d["Status"]) ?? null,
|
|
832
|
+
executionStatus: str(d["ExecutionStatus"]) ?? null,
|
|
833
|
+
};
|
|
834
|
+
},
|
|
835
|
+
scopeTargetOf: stackScope,
|
|
836
|
+
refuseBefore: (p) => discardProblem(p.changeSetId),
|
|
138
837
|
},
|
|
838
|
+
"Delete a change set made by iac_cloudformation_changeset_create, and the empty stack shell it left.",
|
|
839
|
+
z.object({ stackName: awsCliArg, changeSetId: changeSetArn }),
|
|
840
|
+
changeSetDiscard,
|
|
139
841
|
);
|
|
140
842
|
|
|
141
843
|
registerWriteTool(
|
|
142
844
|
"iac_cloudformation_deploy",
|
|
143
845
|
{
|
|
144
846
|
mutates: "iac.cloudformation.deploy",
|
|
145
|
-
recoverable:
|
|
146
|
-
|
|
847
|
+
recoverable: false,
|
|
848
|
+
capturePreState: async (p) => {
|
|
849
|
+
const s = await describeStack(p.stackName);
|
|
850
|
+
return s.exists
|
|
851
|
+
? { exists: true, stackStatus: s.status, lastUpdatedTime: s.lastUpdatedTime ?? null }
|
|
852
|
+
: { exists: false, stackStatus: null, lastUpdatedTime: null };
|
|
853
|
+
},
|
|
854
|
+
scopeTargetOf: stackScope,
|
|
855
|
+
refuseBefore: deployProblem,
|
|
147
856
|
},
|
|
148
|
-
"
|
|
857
|
+
"Execute a reviewed CloudFormation change set (made by iac_cloudformation_changeset_create for exactly this template).",
|
|
149
858
|
z.object({
|
|
150
859
|
stackName: awsCliArg,
|
|
151
860
|
templateBody: templateDocument,
|
|
861
|
+
capabilities: capabilitiesSchema,
|
|
862
|
+
changeSetId: changeSetArn,
|
|
152
863
|
}),
|
|
864
|
+
changeSetExecute,
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
reg(
|
|
868
|
+
"iac_cloudformation_stack_get",
|
|
869
|
+
"Describe a CloudFormation stack's status.",
|
|
870
|
+
z.object({ stackName: awsCliArg }),
|
|
153
871
|
async (p) => {
|
|
154
|
-
await
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"--capabilities",
|
|
165
|
-
"CAPABILITY_IAM",
|
|
166
|
-
// A deploy of a template that changes nothing has done what was asked, not failed.
|
|
167
|
-
"--no-fail-on-empty-changeset",
|
|
168
|
-
],
|
|
169
|
-
// The file is UTF-8. Left to itself the CLI reads it in the locale's encoding, and on a
|
|
170
|
-
// Windows machine using cp1252 a template holding "Á" fails to decode while one holding
|
|
171
|
-
// "é" deploys as "é". AWS CLI v2 reads AWS_CLI_FILE_ENCODING and ignores PYTHONUTF8; v1,
|
|
172
|
-
// plain Python, reads PYTHONUTF8 and ignores the other. Both are set.
|
|
173
|
-
{ ...processEnv, AWS_CLI_FILE_ENCODING: "UTF-8", PYTHONUTF8: "1" },
|
|
174
|
-
),
|
|
872
|
+
const s = await describeStack(p.stackName);
|
|
873
|
+
return jsonResult(
|
|
874
|
+
s.exists
|
|
875
|
+
? {
|
|
876
|
+
exists: true,
|
|
877
|
+
status: s.status,
|
|
878
|
+
statusReason: s.statusReason ?? null,
|
|
879
|
+
lastUpdatedTime: s.lastUpdatedTime ?? null,
|
|
880
|
+
}
|
|
881
|
+
: { exists: false },
|
|
175
882
|
);
|
|
176
|
-
return jsonResult({ ok: true });
|
|
177
883
|
},
|
|
178
884
|
);
|
|
179
885
|
|
|
@@ -184,7 +890,7 @@ export function registerIacTools(
|
|
|
184
890
|
async (p) => {
|
|
185
891
|
await runCliOkThrowing(
|
|
186
892
|
["pulumi", "preview", "--cwd", p.workingDirectory, "--non-interactive"],
|
|
187
|
-
{ ...
|
|
893
|
+
{ ...process.env },
|
|
188
894
|
);
|
|
189
895
|
return jsonResult({ ok: true });
|
|
190
896
|
},
|
|
@@ -195,16 +901,13 @@ export function registerIacTools(
|
|
|
195
901
|
{
|
|
196
902
|
mutates: "iac.pulumi.up",
|
|
197
903
|
recoverable: true,
|
|
198
|
-
scopeTargetOf:
|
|
904
|
+
scopeTargetOf: dirScope,
|
|
905
|
+
refuseBefore: () => PULUMI_UP_REFUSAL,
|
|
199
906
|
},
|
|
200
|
-
"
|
|
907
|
+
"Refused: no plan-bound Pulumi apply exists. Kept registered so the refusal names its reason.",
|
|
201
908
|
z.object({ workingDirectory: cliArg }),
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
{ ...processEnv },
|
|
206
|
-
);
|
|
207
|
-
return jsonResult({ ok: true });
|
|
208
|
-
},
|
|
909
|
+
// Pulumi's saved update plans are experimental, so there is nothing the owner could approve
|
|
910
|
+
// that an apply is then bound to. Refused on every door, spawning nothing.
|
|
911
|
+
() => Promise.reject(new Error(PULUMI_UP_REFUSAL)),
|
|
209
912
|
);
|
|
210
913
|
}
|