@intentius/chant 0.26.0 → 0.28.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/dist/cli/commands/build.d.ts +6 -5
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config.d.ts +13 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +73 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- package/dist/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/stack-output.d.ts +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +206 -2
- package/src/cli/commands/build.ts +43 -10
- package/src/cli/commands/lint.ts +17 -25
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +10 -2
- package/src/config.test.ts +28 -1
- package/src/config.ts +16 -12
- package/src/discovery/sandbox/config-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- package/src/kubectl-context.test.ts +94 -0
- package/src/kubectl-context.ts +126 -0
- package/src/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- package/src/stack-output.test.ts +21 -3
- package/src/stack-output.ts +21 -9
|
@@ -11,6 +11,11 @@ import { fork } from "node:child_process";
|
|
|
11
11
|
* one function is the cheapest way to make "same profile" a fact rather than
|
|
12
12
|
* a claim.
|
|
13
13
|
*
|
|
14
|
+
* chant #1148 — this is also the one place chant forwards a sandboxed
|
|
15
|
+
* child's own stdout/stderr, so `./run.ts`, `./config-run.ts` and
|
|
16
|
+
* `./policy-run.ts` cannot drift on whether project output vanishes. See
|
|
17
|
+
* {@link SandboxForkOptions.outputPrefix}.
|
|
18
|
+
*
|
|
14
19
|
* Isolation mechanics (verified on Node v24.13.1 — see the chant#1045 PR
|
|
15
20
|
* description for the full write-up):
|
|
16
21
|
* - `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted
|
|
@@ -45,6 +50,26 @@ export interface SandboxForkOptions {
|
|
|
45
50
|
timeoutMs: number;
|
|
46
51
|
/** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
|
|
47
52
|
label: string;
|
|
53
|
+
/**
|
|
54
|
+
* chant #1148 — prepended to every line the child writes on EITHER stdout
|
|
55
|
+
* or stderr before it is relayed, line-buffered, to this process's own
|
|
56
|
+
* stderr (e.g. `"[sandbox:run]"`, `"[sandbox:config]"`,
|
|
57
|
+
* `"[policy:org.ts]"`).
|
|
58
|
+
*
|
|
59
|
+
* A sandboxed child's `console.log`/`console.error` used to go nowhere: its
|
|
60
|
+
* stdout was piped but never read, and its stderr was captured only into
|
|
61
|
+
* {@link stderrBuf}'s error-message use, never surfaced on a successful
|
|
62
|
+
* run. Diagnostics crossing as data (the whole point of the boundary) is
|
|
63
|
+
* not the same thing as incidental output being silently dropped — chant's
|
|
64
|
+
* stance is that nothing the project prints vanishes, sandboxed or not.
|
|
65
|
+
*
|
|
66
|
+
* This is forwarding, not a second capture: {@link stderrBuf} still
|
|
67
|
+
* accumulates the child's raw stderr for `classifyChildError`/the
|
|
68
|
+
* exited-before-reporting message exactly as before. Both read the same
|
|
69
|
+
* `data` events; one buffers for classification, this one relays for a
|
|
70
|
+
* human to see.
|
|
71
|
+
*/
|
|
72
|
+
outputPrefix: string;
|
|
48
73
|
/**
|
|
49
74
|
* chant #1131 — an optional payload sent to the child over the SAME IPC
|
|
50
75
|
* channel its response comes back on, immediately after the fork.
|
|
@@ -65,6 +90,36 @@ export interface SandboxForkOptions {
|
|
|
65
90
|
send?: Record<string, unknown>;
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
/**
|
|
94
|
+
* chant #1148 — buffers arbitrary chunks and calls `emit` once per complete
|
|
95
|
+
* line, never on a chunk boundary that happens to split a line in two (a
|
|
96
|
+
* pipe makes no promise that one `write()` on the child's side arrives as one
|
|
97
|
+
* `data` event on ours). `flush()` emits whatever partial line never got a
|
|
98
|
+
* trailing newline, so the last unterminated write doesn't silently vanish
|
|
99
|
+
* when the stream ends — the same no-dropping stance this whole feature
|
|
100
|
+
* exists for.
|
|
101
|
+
*/
|
|
102
|
+
function lineBuffered(emit: (line: string) => void) {
|
|
103
|
+
let pending = "";
|
|
104
|
+
return {
|
|
105
|
+
push(chunk: Buffer | string): void {
|
|
106
|
+
pending += chunk.toString();
|
|
107
|
+
let newlineAt = pending.indexOf("\n");
|
|
108
|
+
while (newlineAt !== -1) {
|
|
109
|
+
emit(pending.slice(0, newlineAt));
|
|
110
|
+
pending = pending.slice(newlineAt + 1);
|
|
111
|
+
newlineAt = pending.indexOf("\n");
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
flush(): void {
|
|
115
|
+
if (pending.length > 0) {
|
|
116
|
+
emit(pending);
|
|
117
|
+
pending = "";
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
68
123
|
/**
|
|
69
124
|
* Fork `bundlePath` under `--permission` with a scrubbed environment, and
|
|
70
125
|
* resolve with the first IPC message that satisfies `isResponse` (or reject
|
|
@@ -74,7 +129,8 @@ export function forkSandboxed<T>(
|
|
|
74
129
|
options: SandboxForkOptions,
|
|
75
130
|
isResponse: (value: unknown) => value is T,
|
|
76
131
|
): Promise<T> {
|
|
77
|
-
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } =
|
|
132
|
+
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send, outputPrefix } =
|
|
133
|
+
options;
|
|
78
134
|
|
|
79
135
|
return new Promise((resolvePromise, reject) => {
|
|
80
136
|
const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
|
|
@@ -106,9 +162,27 @@ export function forkSandboxed<T>(
|
|
|
106
162
|
});
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
// chant #1148 — forward, don't just capture. Both streams write to THIS
|
|
166
|
+
// process's stderr, prefixed and line-buffered, independent of
|
|
167
|
+
// `stderrBuf` below (which keeps accumulating raw stderr for
|
|
168
|
+
// `classifyChildError`'s use — forwarded and captured are not mutually
|
|
169
|
+
// exclusive, the same bytes feed both).
|
|
170
|
+
const forwardLine = (line: string): void => {
|
|
171
|
+
process.stderr.write(`${outputPrefix} ${line}\n`);
|
|
172
|
+
};
|
|
173
|
+
const stdoutForwarder = lineBuffered(forwardLine);
|
|
174
|
+
const stderrForwarder = lineBuffered(forwardLine);
|
|
175
|
+
|
|
176
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
177
|
+
stdoutForwarder.push(chunk);
|
|
178
|
+
});
|
|
179
|
+
child.stdout?.on("end", () => stdoutForwarder.flush());
|
|
180
|
+
|
|
109
181
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
110
182
|
stderrBuf += chunk.toString();
|
|
183
|
+
stderrForwarder.push(chunk);
|
|
111
184
|
});
|
|
185
|
+
child.stderr?.on("end", () => stderrForwarder.flush());
|
|
112
186
|
|
|
113
187
|
child.on("message", (msg: unknown) => {
|
|
114
188
|
if (settled || !isResponse(msg)) return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -281,6 +281,61 @@ describe("lint.policies execution under --sandbox (chant #1131)", () => {
|
|
|
281
281
|
expect(await run([])).toEqual([]);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* chant #1148 — a policy's own `console.log`/`console.error` used to go
|
|
286
|
+
* nowhere under `--sandbox` (noted as a residual when #1131 shipped). Same
|
|
287
|
+
* relay as the run-fallback and config children, keyed to the policy
|
|
288
|
+
* module's own basename rather than a generic tag.
|
|
289
|
+
*/
|
|
290
|
+
describe("console output forwarding (chant #1148)", () => {
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
vi.restoreAllMocks();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterEach(() => {
|
|
296
|
+
vi.restoreAllMocks();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** Spies on `process.stderr.write` and returns the lines captured so far — same shape as `../../cli/handlers/emulator.test.ts`'s `stdout()`/`stderr()` helpers. */
|
|
300
|
+
function captureStderr(): string[] {
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
303
|
+
lines.push(String(chunk));
|
|
304
|
+
return true;
|
|
305
|
+
});
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// See ./run.test.ts's identical helper doc: the IPC "message" the
|
|
310
|
+
// awaited call resolves on and the child's stdout/stderr pipe data are
|
|
311
|
+
// independent channels, so polling briefly avoids a flaky assertion.
|
|
312
|
+
async function waitFor(lines: string[], matcher: RegExp, timeoutMs = 5000): Promise<void> {
|
|
313
|
+
const start = Date.now();
|
|
314
|
+
while (Date.now() - start < timeoutMs) {
|
|
315
|
+
if (matcher.test(lines.join(""))) return;
|
|
316
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
317
|
+
}
|
|
318
|
+
throw new Error(`stderr never matched ${matcher}. Captured so far:\n${lines.join("")}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
test("a policy's console.log/console.error are forwarded, prefixed with [policy:<module-basename>]", async () => {
|
|
322
|
+
const stderr = captureStderr();
|
|
323
|
+
await writeMarkerPolicy(
|
|
324
|
+
"org.ts",
|
|
325
|
+
`console.log("hello from policy stdout");\n` +
|
|
326
|
+
`console.error("hello from policy stderr");\n` +
|
|
327
|
+
`export const c = { id: "X", description: "d", check: () => [] };\n`,
|
|
328
|
+
);
|
|
329
|
+
armSandboxPolicyExecution();
|
|
330
|
+
|
|
331
|
+
const diags = await run(["org.ts"]);
|
|
332
|
+
|
|
333
|
+
expect(diags).toEqual([]);
|
|
334
|
+
await waitFor(stderr, /^\[policy:org\.ts\] hello from policy stdout$/m);
|
|
335
|
+
await waitFor(stderr, /^\[policy:org\.ts\] hello from policy stderr$/m);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
284
339
|
test("a program that runs a policy child EXITS — the child is not left holding the event loop", async () => {
|
|
285
340
|
// Found the hard way while measuring #1131's cost: unlike the run and
|
|
286
341
|
// config drivers, the policy driver keeps a `message` listener registered
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, rmSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
3
|
import type { PostSynthDiagnostic } from "../../lint/post-synth";
|
|
4
4
|
import { bundleDriver } from "./bundle";
|
|
5
5
|
import { generatePolicyDriverSource } from "./driver";
|
|
@@ -79,6 +79,17 @@ interface PolicyChildResponse {
|
|
|
79
79
|
error?: { name: string; file: string; message: string; type: string };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* chant #1148 — `[policy:<module-basename>]`, joined when a project declares
|
|
84
|
+
* more than one policy module (all of them share the one child — see the
|
|
85
|
+
* module doc above). One policy module is the only shape this repo's own
|
|
86
|
+
* corpus exercises (`lexicons/k8s/examples/org-policy`), so this is the
|
|
87
|
+
* common case, not a hypothetical.
|
|
88
|
+
*/
|
|
89
|
+
function policyOutputPrefix(policyPaths: readonly string[]): string {
|
|
90
|
+
return `[policy:${policyPaths.map((p) => basename(p)).join(",")}]`;
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
function isPolicyChildResponse(value: unknown): value is PolicyChildResponse {
|
|
83
94
|
return (
|
|
84
95
|
typeof value === "object" &&
|
|
@@ -158,6 +169,9 @@ export async function runPoliciesSandboxed(options: SandboxPolicyOptions): Promi
|
|
|
158
169
|
timeoutMs: POLICY_CHILD_TIMEOUT_MS,
|
|
159
170
|
label: `sandboxed evaluation of lint.policies (${policyPaths.length} module(s))`,
|
|
160
171
|
send: payload,
|
|
172
|
+
// chant #1148 — a policy's own console.log/error no longer goes
|
|
173
|
+
// nowhere; see `./fork.ts`'s `outputPrefix` doc.
|
|
174
|
+
outputPrefix: policyOutputPrefix(policyPaths),
|
|
161
175
|
},
|
|
162
176
|
isPolicyChildResponse,
|
|
163
177
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join, dirname, resolve } from "node:path";
|
|
@@ -176,4 +176,88 @@ describe("runFallbackFilesSandboxed — isolation", () => {
|
|
|
176
176
|
expect(ref.getLogicalName?.()).toBe("dataBucket");
|
|
177
177
|
expect(ref.attribute).toBe("Arn");
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* chant #1148 — a run-fallback file's own `console.log`/`console.error`
|
|
182
|
+
* used to go nowhere: the child's stdout was piped but never read, and its
|
|
183
|
+
* stderr was captured only for `classifyChildError`'s use, never surfaced
|
|
184
|
+
* on a successful run. `./fork.ts` now relays both, line-buffered and
|
|
185
|
+
* prefixed, to THIS process's stderr — diagnostics crossing as data was
|
|
186
|
+
* never meant to mean incidental output vanishes.
|
|
187
|
+
*/
|
|
188
|
+
describe("console output forwarding (chant #1148)", () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
vi.restoreAllMocks();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
vi.restoreAllMocks();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Spies on `process.stderr.write` and returns the lines captured so far — same shape as `../../cli/handlers/emulator.test.ts`'s `stdout()`/`stderr()` helpers. */
|
|
198
|
+
function captureStderr(): string[] {
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
|
|
201
|
+
lines.push(String(chunk));
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The child's IPC "message" (what the awaited call resolves on) and its
|
|
208
|
+
// stdout/stderr pipe data are two independent channels — nothing orders
|
|
209
|
+
// one ahead of the other, and #1147 deliberately keeps it that way (see
|
|
210
|
+
// the hang-regression test in policy-boundary.test.ts). Polling briefly
|
|
211
|
+
// rather than asserting immediately avoids a flaky test without giving
|
|
212
|
+
// the production path anything to wait for.
|
|
213
|
+
async function waitFor(lines: string[], matcher: RegExp, timeoutMs = 5000): Promise<void> {
|
|
214
|
+
const start = Date.now();
|
|
215
|
+
while (Date.now() - start < timeoutMs) {
|
|
216
|
+
if (matcher.test(lines.join(""))) return;
|
|
217
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`stderr never matched ${matcher}. Captured so far:\n${lines.join("")}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("both console.log and console.error are forwarded, prefixed with [sandbox:run]", async () => {
|
|
223
|
+
const stderr = captureStderr();
|
|
224
|
+
const file = join(testDir, "noisy.ts");
|
|
225
|
+
await writeFile(
|
|
226
|
+
file,
|
|
227
|
+
`
|
|
228
|
+
console.log("hello from run-fallback stdout");
|
|
229
|
+
console.error("hello from run-fallback stderr");
|
|
230
|
+
export const value = "ok";
|
|
231
|
+
`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
235
|
+
|
|
236
|
+
expect(result.errors).toEqual([]);
|
|
237
|
+
await waitFor(stderr, /^\[sandbox:run\] hello from run-fallback stdout$/m);
|
|
238
|
+
await waitFor(stderr, /^\[sandbox:run\] hello from run-fallback stderr$/m);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a file that exits hard still has its stderr in the classified error (forwarding doesn't replace capture)", async () => {
|
|
242
|
+
const stderr = captureStderr();
|
|
243
|
+
const file = join(testDir, "crashes.ts");
|
|
244
|
+
await writeFile(
|
|
245
|
+
file,
|
|
246
|
+
`
|
|
247
|
+
console.error("about to exit hard");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
`,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
253
|
+
|
|
254
|
+
// A hard process.exit() takes the whole bundled child with it — this is
|
|
255
|
+
// fork.ts's pre-existing "child exited before reporting results" path,
|
|
256
|
+
// fed by the SAME stderrBuf that now also feeds forwarding.
|
|
257
|
+
expect(result.errors).toHaveLength(1);
|
|
258
|
+
expect(result.errors[0].message).toMatch(/about to exit hard/);
|
|
259
|
+
|
|
260
|
+
await waitFor(stderr, /^\[sandbox:run\] about to exit hard$/m);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
179
263
|
});
|
|
@@ -112,6 +112,9 @@ export async function runFallbackFilesSandboxed(
|
|
|
112
112
|
env: { PATH: process.env.PATH ?? "" },
|
|
113
113
|
timeoutMs: CHILD_TIMEOUT_MS,
|
|
114
114
|
label: "sandboxed run",
|
|
115
|
+
// chant #1148 — a run-fallback file's own console.log/error no
|
|
116
|
+
// longer goes nowhere; see `./fork.ts`'s `outputPrefix` doc.
|
|
117
|
+
outputPrefix: "[sandbox:run]",
|
|
115
118
|
},
|
|
116
119
|
isChildResponse,
|
|
117
120
|
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const execMock = vi.fn();
|
|
4
|
+
vi.mock("node:child_process", async () => {
|
|
5
|
+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
|
|
9
|
+
Promise.resolve(execMock(cmd)).then(
|
|
10
|
+
(out) => cb(null, out as { stdout: string; stderr: string }),
|
|
11
|
+
(err) => cb(err as Error, { stdout: "", stderr: "" }),
|
|
12
|
+
);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { resolveClusterTarget, ClusterBindingMismatchError } = await import("./kubectl-context");
|
|
18
|
+
|
|
19
|
+
describe("resolveClusterTarget (chant #1100)", () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
execMock.mockReset();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("no binding declared: returns ambient source, warns visibly, never probes kubectl", async () => {
|
|
25
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
26
|
+
|
|
27
|
+
const target = await resolveClusterTarget({}, "prod", "k8s");
|
|
28
|
+
|
|
29
|
+
expect(target).toEqual({ source: "ambient" });
|
|
30
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
31
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
32
|
+
expect.stringMatching(/\[k8s\].*environment "prod".*k8s\.profiles\.prod\.context/s),
|
|
33
|
+
);
|
|
34
|
+
warnSpy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("bound and ambient context matches: returns the bound context", async () => {
|
|
38
|
+
execMock.mockResolvedValue({ stdout: "prod-eks\n", stderr: "" });
|
|
39
|
+
|
|
40
|
+
const target = await resolveClusterTarget(
|
|
41
|
+
{ k8s: { profiles: { prod: { context: "prod-eks" } } } },
|
|
42
|
+
"prod",
|
|
43
|
+
"k8s",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(target).toEqual({ context: "prod-eks", source: "bound" });
|
|
47
|
+
expect(execMock).toHaveBeenCalledWith(expect.stringContaining("current-context"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("bound and ambient context cannot be determined: proceeds with the bound context anyway", async () => {
|
|
51
|
+
execMock.mockRejectedValue(new Error("no kubeconfig"));
|
|
52
|
+
|
|
53
|
+
const target = await resolveClusterTarget(
|
|
54
|
+
{ k8s: { profiles: { prod: { context: "prod-eks" } } } },
|
|
55
|
+
"prod",
|
|
56
|
+
"k8s",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(target).toEqual({ context: "prod-eks", source: "bound" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("bound and ambient context mismatches: refuses loudly, naming env/expected/ambient", async () => {
|
|
63
|
+
execMock.mockResolvedValue({ stdout: "staging-eks\n", stderr: "" });
|
|
64
|
+
|
|
65
|
+
const err: unknown = await resolveClusterTarget(
|
|
66
|
+
{ k8s: { profiles: { prod: { context: "prod-eks" } } } },
|
|
67
|
+
"prod",
|
|
68
|
+
"k8s",
|
|
69
|
+
).catch((e: unknown) => e);
|
|
70
|
+
|
|
71
|
+
expect(err).toBeInstanceOf(ClusterBindingMismatchError);
|
|
72
|
+
const mismatch = err as InstanceType<typeof ClusterBindingMismatchError>;
|
|
73
|
+
expect(mismatch.environment).toBe("prod");
|
|
74
|
+
expect(mismatch.expectedContext).toBe("prod-eks");
|
|
75
|
+
expect(mismatch.ambientContext).toBe("staging-eks");
|
|
76
|
+
expect(mismatch.message).toContain('environment "prod"');
|
|
77
|
+
expect(mismatch.message).toContain('"prod-eks"');
|
|
78
|
+
expect(mismatch.message).toContain('"staging-eks"');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("bound for a different environment than the one requested: treated as unbound for this environment", async () => {
|
|
82
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
83
|
+
|
|
84
|
+
const target = await resolveClusterTarget(
|
|
85
|
+
{ k8s: { profiles: { staging: { context: "staging-eks" } } } },
|
|
86
|
+
"prod",
|
|
87
|
+
"k8s",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(target).toEqual({ source: "ambient" });
|
|
91
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
92
|
+
warnSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K8s environment→cluster binding — chant #1100.
|
|
3
|
+
*
|
|
4
|
+
* Every cloud lexicon binds an environment to a scope: AWS resolves `<env>`
|
|
5
|
+
* to a CloudFormation stack, Azure treats `<env>` as the resource group,
|
|
6
|
+
* Temporal looks up `temporal.profiles.<env>` in `chant.config.ts`. K8s (and
|
|
7
|
+
* GCP-via-Config-Connector, which observes through the same kubectl path)
|
|
8
|
+
* bound nothing — `describeResources` shelled out to `kubectl get` with no
|
|
9
|
+
* `--context`, so it read whatever cluster `kubectl config current-context`
|
|
10
|
+
* happened to point at. Point `prod` at a dev cluster and every declared
|
|
11
|
+
* resource reads as missing — a wrong-cluster diff that looks like a
|
|
12
|
+
* confident list of deletions.
|
|
13
|
+
*
|
|
14
|
+
* This module is the shared resolver both the k8s and gcp lexicons'
|
|
15
|
+
* `describeResources` call, so they resolve a cluster identity the same way
|
|
16
|
+
* (see `lexicons/k8s/src/config.ts`'s `K8sChantConfig` for the declared
|
|
17
|
+
* shape). It is intentionally provider-agnostic and lives in core (like
|
|
18
|
+
* `./ownership.ts`) rather than in the k8s lexicon package, since gcp's
|
|
19
|
+
* Config Connector observation needs it too without taking a dependency on
|
|
20
|
+
* the k8s lexicon.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { exec } from "node:child_process";
|
|
24
|
+
import { promisify } from "node:util";
|
|
25
|
+
|
|
26
|
+
const execAsync = promisify(exec);
|
|
27
|
+
|
|
28
|
+
/** A single environment's cluster binding — see `K8sChantConfig` in the k8s lexicon. */
|
|
29
|
+
export interface K8sClusterProfile {
|
|
30
|
+
/** kubectl context name this environment is bound to. */
|
|
31
|
+
context: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Shape of the `k8s` passthrough key in `chant.config.ts` that this resolver reads. */
|
|
35
|
+
export interface K8sConfigShape {
|
|
36
|
+
profiles?: Record<string, K8sClusterProfile>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown when an environment declares a cluster binding but the ambient
|
|
41
|
+
* kubectl context disagrees with it. Refusing here — instead of silently
|
|
42
|
+
* observing whichever cluster is ambient — is the fix for #1100: a
|
|
43
|
+
* wrong-cluster read reports every declared resource as missing, which #1089
|
|
44
|
+
* then classifies as a confident (and wrong) list of `create` actions.
|
|
45
|
+
*/
|
|
46
|
+
export class ClusterBindingMismatchError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
public readonly environment: string,
|
|
49
|
+
public readonly expectedContext: string,
|
|
50
|
+
public readonly ambientContext: string,
|
|
51
|
+
) {
|
|
52
|
+
super(
|
|
53
|
+
`k8s: environment "${environment}" is bound to cluster context "${expectedContext}" ` +
|
|
54
|
+
`(k8s.profiles.${environment}.context), but the ambient kubectl context is ` +
|
|
55
|
+
`"${ambientContext}". Refusing to observe — reading the wrong cluster would misreport ` +
|
|
56
|
+
`every declared resource as missing. Run \`kubectl config use-context ${expectedContext}\` ` +
|
|
57
|
+
`to switch, or update the binding in chant.config.ts if "${ambientContext}" is actually correct.`,
|
|
58
|
+
);
|
|
59
|
+
this.name = "ClusterBindingMismatchError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolvedClusterTarget {
|
|
64
|
+
/**
|
|
65
|
+
* Explicit `--context` value to pass to every kubectl invocation. Present
|
|
66
|
+
* only when the environment has a declared binding — undefined means
|
|
67
|
+
* "no binding, keep today's ambient-context behavior".
|
|
68
|
+
*/
|
|
69
|
+
context?: string;
|
|
70
|
+
/** Where the target came from. */
|
|
71
|
+
source: "bound" | "ambient";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reads `kubectl config current-context`. Returns undefined if unset or kubectl fails. */
|
|
75
|
+
async function currentAmbientContext(): Promise<string | undefined> {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await execAsync("kubectl config current-context");
|
|
78
|
+
const trimmed = stdout.trim();
|
|
79
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the kubectl context an environment should be observed/applied
|
|
87
|
+
* against, reading `k8s.profiles.<environment>.context` from `chant.config.ts`
|
|
88
|
+
* (the `config` passed in is the passthrough `ChantConfig`, cast loosely since
|
|
89
|
+
* the `k8s` key isn't declared on the core schema — same pattern as
|
|
90
|
+
* `temporal.profiles`).
|
|
91
|
+
*
|
|
92
|
+
* - No binding declared: returns `{ source: "ambient" }` — unchanged
|
|
93
|
+
* behavior — but logs a visible warning identifying the caller and
|
|
94
|
+
* environment, so the fallback is never silent (#1100 acceptance).
|
|
95
|
+
* - Binding declared and the ambient context agrees (or ambient can't be
|
|
96
|
+
* determined): returns `{ context: bound, source: "bound" }`. Callers
|
|
97
|
+
* should pass this context explicitly on every kubectl invocation rather
|
|
98
|
+
* than relying on it also being ambient.
|
|
99
|
+
* - Binding declared and the ambient context disagrees: throws
|
|
100
|
+
* {@link ClusterBindingMismatchError} — a loud refusal instead of quietly
|
|
101
|
+
* reading the wrong cluster.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveClusterTarget(
|
|
104
|
+
config: Record<string, unknown>,
|
|
105
|
+
environment: string,
|
|
106
|
+
lexiconName: string,
|
|
107
|
+
): Promise<ResolvedClusterTarget> {
|
|
108
|
+
const k8sConfig = config.k8s as K8sConfigShape | undefined;
|
|
109
|
+
const bound = k8sConfig?.profiles?.[environment]?.context;
|
|
110
|
+
|
|
111
|
+
if (!bound) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`[${lexiconName}] no cluster binding for environment "${environment}" ` +
|
|
114
|
+
`(k8s.profiles.${environment}.context in chant.config.ts) — observing whatever kubectl ` +
|
|
115
|
+
`context is ambient. Add a binding to pin this environment to a specific cluster (chant #1100).`,
|
|
116
|
+
);
|
|
117
|
+
return { source: "ambient" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ambient = await currentAmbientContext();
|
|
121
|
+
if (ambient && ambient !== bound) {
|
|
122
|
+
throw new ClusterBindingMismatchError(environment, bound, ambient);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { context: bound, source: "bound" };
|
|
126
|
+
}
|
package/src/lint/config.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { loadConfig, DEFAULT_CONFIG, findProjectRoot } from "./config";
|
|
2
|
+
import { loadConfig, DEFAULT_CONFIG, findProjectRoot, resolveConfiguredSeverity, applyConfiguredSeverity } from "./config";
|
|
3
|
+
import type { PostSynthDiagnostic } from "./post-synth";
|
|
3
4
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
4
5
|
import { join, resolve } from "path";
|
|
5
6
|
|
|
@@ -717,3 +718,94 @@ describe("findProjectRoot", () => {
|
|
|
717
718
|
expect(findProjectRoot(sub)).toBe(packageRoot);
|
|
718
719
|
});
|
|
719
720
|
});
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* chant #1138 — the one severity-resolution path AST lint rules
|
|
724
|
+
* (`../cli/commands/lint.ts`'s `getDefaultRules`), COMP* checks
|
|
725
|
+
* (`runComponentCheckDiagnostics`), and post-synth checks/policies
|
|
726
|
+
* (`applyConfiguredSeverity`, below) all now call, keyed by whichever id the
|
|
727
|
+
* caller has (a `LintRule.id`, a `ComponentCheck.id`, or a
|
|
728
|
+
* `PostSynthDiagnostic.checkId`) — a rule id behaves the same regardless of
|
|
729
|
+
* which phase produced it.
|
|
730
|
+
*/
|
|
731
|
+
describe("resolveConfiguredSeverity", () => {
|
|
732
|
+
test("an id with no config entry falls back to the caller's default severity, with no options", () => {
|
|
733
|
+
expect(resolveConfiguredSeverity(undefined, "COR001", "error")).toEqual({ severity: "error" });
|
|
734
|
+
expect(resolveConfiguredSeverity({}, "COR001", "warning")).toEqual({ severity: "warning" });
|
|
735
|
+
expect(resolveConfiguredSeverity({ OTHER: "off" }, "COR001", "error")).toEqual({ severity: "error" });
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("a bare severity string overrides the default", () => {
|
|
739
|
+
expect(resolveConfiguredSeverity({ COR001: "warning" }, "COR001", "error")).toEqual({ severity: "warning" });
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('"off" suppresses regardless of the default severity', () => {
|
|
743
|
+
expect(resolveConfiguredSeverity({ WAW019: "off" }, "WAW019", "error")).toEqual({ severity: "off" });
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("a [severity, options] tuple carries options through", () => {
|
|
747
|
+
expect(resolveConfiguredSeverity({ COR009: ["warning", { max: 12 }] }, "COR009", "error")).toEqual({
|
|
748
|
+
severity: "warning",
|
|
749
|
+
options: { max: 12 },
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("an invalid severity in a [severity, options] tuple throws, naming the bad value", () => {
|
|
754
|
+
expect(() =>
|
|
755
|
+
resolveConfiguredSeverity({ COR009: ["fatal" as never, { max: 12 }] }, "COR009", "error"),
|
|
756
|
+
).toThrow(/severity "fatal"/);
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* chant #1138 — `lint.rules` severity overrides apply to a post-synth check
|
|
762
|
+
* id (`diag.checkId`) through the identical `resolveConfiguredSeverity` an
|
|
763
|
+
* AST rule id or a COMP* check id goes through, so
|
|
764
|
+
* `lint.rules: { WAW019: "off" }` suppresses a post-synth finding just like a
|
|
765
|
+
* pre-synth one — the bug this issue reports.
|
|
766
|
+
*/
|
|
767
|
+
describe("applyConfiguredSeverity", () => {
|
|
768
|
+
function diag(overrides: Partial<PostSynthDiagnostic> = {}): PostSynthDiagnostic {
|
|
769
|
+
return { checkId: "WAW019", severity: "error", message: "open ingress", ...overrides };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
test("an unconfigured check id passes through unchanged — no drift for the common case", () => {
|
|
773
|
+
const result = applyConfiguredSeverity([diag()], undefined);
|
|
774
|
+
expect(result.diagnostics).toEqual([diag()]);
|
|
775
|
+
expect(result.suppressed).toEqual([]);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('"off" suppresses the finding — moved to `suppressed`, not dropped, so it stays countable', () => {
|
|
779
|
+
const result = applyConfiguredSeverity([diag()], { WAW019: "off" });
|
|
780
|
+
expect(result.diagnostics).toEqual([]);
|
|
781
|
+
expect(result.suppressed).toEqual([diag()]);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('"warning" downgrades an error-severity finding', () => {
|
|
785
|
+
const result = applyConfiguredSeverity([diag({ severity: "error" })], { WAW019: "warning" });
|
|
786
|
+
expect(result.diagnostics).toEqual([diag({ severity: "warning" })]);
|
|
787
|
+
expect(result.suppressed).toEqual([]);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('"error" upgrades a warning-severity finding', () => {
|
|
791
|
+
const result = applyConfiguredSeverity([diag({ severity: "warning" })], { WAW019: "error" });
|
|
792
|
+
expect(result.diagnostics).toEqual([diag({ severity: "error" })]);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("resolves each diagnostic by its own checkId — one config, independent ids", () => {
|
|
796
|
+
const diags = [
|
|
797
|
+
diag({ checkId: "WAW019" }),
|
|
798
|
+
diag({ checkId: "WAW049", message: "no logging" }),
|
|
799
|
+
diag({ checkId: "WAW099", message: "untouched" }),
|
|
800
|
+
];
|
|
801
|
+
const result = applyConfiguredSeverity(diags, { WAW019: "off", WAW049: "warning" });
|
|
802
|
+
expect(result.suppressed.map((d) => d.checkId)).toEqual(["WAW019"]);
|
|
803
|
+
expect(result.diagnostics.map((d) => d.checkId)).toEqual(["WAW049", "WAW099"]);
|
|
804
|
+
expect(result.diagnostics.find((d) => d.checkId === "WAW049")?.severity).toBe("warning");
|
|
805
|
+
expect(result.diagnostics.find((d) => d.checkId === "WAW099")?.severity).toBe("error");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("an empty diagnostics list is a no-op", () => {
|
|
809
|
+
expect(applyConfiguredSeverity([], { WAW019: "off" })).toEqual({ diagnostics: [], suppressed: [] });
|
|
810
|
+
});
|
|
811
|
+
});
|