@intentius/chant 0.62.0 → 0.63.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/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -3,7 +3,7 @@ import * as ts from "typescript";
|
|
|
3
3
|
import { readFileSync } from "fs";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import { collectConsts } from "./fold";
|
|
6
|
+
import { collectConsts, fold, FoldError } from "./fold";
|
|
7
7
|
import { findSubsetViolation } from "./subset";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -50,6 +50,20 @@ import { findSubsetViolation } from "./subset";
|
|
|
50
50
|
* module boundary, or not at all (subset.ts's module doc, point 1) — a
|
|
51
51
|
* documented, intentional asymmetry, not something this guard can
|
|
52
52
|
* usefully narrow further without a binding resolver of its own.
|
|
53
|
+
*
|
|
54
|
+
* One doc claim below is checked against `fold()` instead of
|
|
55
|
+
* `findSubsetViolation` — chant #2306. "Unregistered tagged template
|
|
56
|
+
* intrinsics" is a claim `findSubsetViolation` structurally cannot decide:
|
|
57
|
+
* subset.ts treats every tagged template's interior as shape-valid
|
|
58
|
+
* regardless of tag registration (its own module doc explains why — EVL has
|
|
59
|
+
* no intrinsic registry at lint time), so registered vs. unregistered is
|
|
60
|
+
* indistinguishable at that layer no matter which heading the claim sits
|
|
61
|
+
* under. The registry check lives in `fold()` (`foldTaggedTemplate`), so
|
|
62
|
+
* that is what this one test calls instead. This is also why the doc bullet
|
|
63
|
+
* survived as long as it did in unfenced prose (chant #2306's own issue
|
|
64
|
+
* comment): moving it under a `###` heading with a fenced block, the shape
|
|
65
|
+
* every other case here uses, was necessary but not sufficient — the
|
|
66
|
+
* fixture still has to be run through the right function.
|
|
53
67
|
*/
|
|
54
68
|
|
|
55
69
|
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
@@ -208,3 +222,23 @@ describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx c
|
|
|
208
222
|
expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
|
|
209
223
|
});
|
|
210
224
|
});
|
|
225
|
+
|
|
226
|
+
describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (#2306)", () => {
|
|
227
|
+
test("Unregistered tagged template intrinsics", () => {
|
|
228
|
+
// findSubsetViolation cannot decide this one — see this file's module
|
|
229
|
+
// doc. Fold the doc's own example with an empty intrinsics list (no tag
|
|
230
|
+
// registered at all, the same as no lexicon opting `unknownTag` in) and
|
|
231
|
+
// check the real rejection, `foldTaggedTemplate` in ./fold.ts, fires.
|
|
232
|
+
const consts = parseConsts(extractFencedBlock("Unregistered tagged template intrinsics"));
|
|
233
|
+
const arg = resourceArg(consts, "store");
|
|
234
|
+
let error: unknown;
|
|
235
|
+
try {
|
|
236
|
+
fold(arg, consts, []);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
error = e;
|
|
239
|
+
}
|
|
240
|
+
expect(error).toBeInstanceOf(FoldError);
|
|
241
|
+
expect((error as FoldError).message).toContain("unregistered tagged template intrinsic");
|
|
242
|
+
expect((error as FoldError).message).toContain("unknownTag");
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -44,6 +44,16 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
44
44
|
* re-folding the initializer would construct a duplicate of a resource
|
|
45
45
|
* discovery has already registered. Shape cannot see the difference, and
|
|
46
46
|
* the rejection is a fall-back-to-run, never a wrong value.
|
|
47
|
+
*
|
|
48
|
+
* chant #2328 adds another, in the same direction and for the same
|
|
49
|
+
* reason: a property or element read whose OBJECT resolves to `null` or
|
|
50
|
+
* `undefined` (`cfg.nett.vpcId`, a typo in a nested path). Running it
|
|
51
|
+
* throws a `TypeError`, so `fold()` refuses rather than answering
|
|
52
|
+
* `undefined` and letting the build carry on with the property dropped.
|
|
53
|
+
* What the object resolves to is exactly the resolution this module
|
|
54
|
+
* does not do, so `cfg.nett.vpcId` stays shape-valid here — and a
|
|
55
|
+
* genuinely optional `cfg.net?.vpcId`, which folds to `undefined`
|
|
56
|
+
* because JavaScript defines it that way, is shape-valid in both.
|
|
47
57
|
* 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
|
|
48
58
|
* manifest, which isn't available to a syntax-only lint rule. `fold()`
|
|
49
59
|
* alone checks it; this module treats any tag name as shape-valid and
|
|
@@ -6,7 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
|
|
8
8
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
9
|
-
resolveApprovalUrl, isApprovalUrl,
|
|
9
|
+
resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan,
|
|
10
|
+
type PendingGateRecord, type GateResolutionRecord,
|
|
10
11
|
} from "./gate-ledger";
|
|
11
12
|
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
12
13
|
|
|
@@ -253,3 +254,134 @@ describe("lifecycle/gate-ledger — pending facts (#2119)", () => {
|
|
|
253
254
|
});
|
|
254
255
|
});
|
|
255
256
|
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* #2300. `latestResolutionSince` asks "is there a newer approval", which a run
|
|
260
|
+
* answers yes to however much has changed since it was written. That is the
|
|
261
|
+
* rule INTENTIUS/choudoufu#1026 measured applying a renamed resource without
|
|
262
|
+
* complaint. `latestResolutionForPlan` asks "is there an approval of *this*",
|
|
263
|
+
* with recency demoted from the criterion to the tiebreak.
|
|
264
|
+
*/
|
|
265
|
+
describe("latestResolutionForPlan (#2300)", () => {
|
|
266
|
+
const EPOCH = new Date(0).toISOString();
|
|
267
|
+
const PLAN_A = `sha256:${"a".repeat(64)}`;
|
|
268
|
+
const PLAN_B = `sha256:${"b".repeat(64)}`;
|
|
269
|
+
|
|
270
|
+
const resolution = (over: Partial<GateResolutionRecord>): GateResolutionRecord => ({
|
|
271
|
+
version: 1, op: "live-apply", gate: "approve-live-apply", resolvedBy: "alex",
|
|
272
|
+
timestamp: "2026-01-02T00:00:00.000Z", ...over,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a resolution for this plan answers the gate", () => {
|
|
276
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_A })], "approve-live-apply", EPOCH, PLAN_A);
|
|
277
|
+
expect(found.resolution?.resolvedBy).toBe("alex");
|
|
278
|
+
expect(found.mismatched).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a resolution for another plan does not, and comes back named", () => {
|
|
282
|
+
const found = latestResolutionForPlan([resolution({ planDigest: PLAN_B })], "approve-live-apply", EPOCH, PLAN_A);
|
|
283
|
+
expect(found.resolution).toBeUndefined();
|
|
284
|
+
expect(found.mismatched?.planDigest).toBe(PLAN_B);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The migration, and the safe reading of it: a record with no digest proves
|
|
288
|
+
// someone approved something, and nothing about what.
|
|
289
|
+
test("a resolution written before #2300 never matches a plan-bound gate", () => {
|
|
290
|
+
const found = latestResolutionForPlan([resolution({})], "approve-live-apply", EPOCH, PLAN_A);
|
|
291
|
+
expect(found.resolution).toBeUndefined();
|
|
292
|
+
expect(found.mismatched).toBeDefined();
|
|
293
|
+
expect(found.mismatched?.planDigest).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Recency is the tiebreak, not the criterion: a newer approval of the wrong
|
|
297
|
+
// plan does not shadow an older approval of the right one.
|
|
298
|
+
test("an older resolution for this plan beats a newer one for another", () => {
|
|
299
|
+
const found = latestResolutionForPlan(
|
|
300
|
+
[
|
|
301
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "right", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
302
|
+
resolution({ planDigest: PLAN_B, resolvedBy: "wrong", timestamp: "2026-01-09T00:00:00.000Z" }),
|
|
303
|
+
],
|
|
304
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
305
|
+
);
|
|
306
|
+
expect(found.resolution?.resolvedBy).toBe("right");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("among several for this plan, the newest wins", () => {
|
|
310
|
+
const found = latestResolutionForPlan(
|
|
311
|
+
[
|
|
312
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "first", timestamp: "2026-01-02T00:00:00.000Z" }),
|
|
313
|
+
resolution({ planDigest: PLAN_A, resolvedBy: "second", timestamp: "2026-01-03T00:00:00.000Z" }),
|
|
314
|
+
],
|
|
315
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
316
|
+
);
|
|
317
|
+
expect(found.resolution?.resolvedBy).toBe("second");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the staleness rule still applies — a resolution older than the pending fact is no answer to it", () => {
|
|
321
|
+
const found = latestResolutionForPlan(
|
|
322
|
+
[resolution({ planDigest: PLAN_A, timestamp: "2026-01-01T00:00:00.000Z" })],
|
|
323
|
+
"approve-live-apply", "2026-01-05T00:00:00.000Z", PLAN_A,
|
|
324
|
+
);
|
|
325
|
+
expect(found.resolution).toBeUndefined();
|
|
326
|
+
expect(found.mismatched).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a gate that binds no plan decides exactly as latestResolutionSince does", () => {
|
|
330
|
+
const records = [resolution({}), resolution({ resolvedBy: "newer", timestamp: "2026-01-04T00:00:00.000Z" })];
|
|
331
|
+
expect(latestResolutionForPlan(records, "approve-live-apply", EPOCH, undefined).resolution?.resolvedBy)
|
|
332
|
+
.toBe(latestResolutionSince(records, "approve-live-apply", EPOCH)?.resolvedBy);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("another gate's resolutions are not read as this one's", () => {
|
|
336
|
+
const found = latestResolutionForPlan(
|
|
337
|
+
[resolution({ gate: "approve-live-adopt", planDigest: PLAN_A })],
|
|
338
|
+
"approve-live-apply", EPOCH, PLAN_A,
|
|
339
|
+
);
|
|
340
|
+
expect(found.resolution).toBeUndefined();
|
|
341
|
+
expect(found.mismatched).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A `planDigest` that is present but not a string is a malformed line, not a
|
|
347
|
+
* line with a field to ignore (#2300) — ignoring it would demote a plan-bound
|
|
348
|
+
* record to a digest-less one, which is the shape a plan-bound gate refuses.
|
|
349
|
+
*/
|
|
350
|
+
describe("readGateLedger — a corrupted planDigest is malformed (#2300)", () => {
|
|
351
|
+
test("a non-string planDigest is counted, not read as an approval of nothing", async () => {
|
|
352
|
+
await withTestDir(async (dir) => {
|
|
353
|
+
await initRepo(dir);
|
|
354
|
+
await writeBlobToPath(
|
|
355
|
+
"_gates", "live-apply.jsonl",
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
version: 1, op: "live-apply", gate: "g", resolvedBy: "alex",
|
|
358
|
+
timestamp: "2026-01-01T00:00:00.000Z", planDigest: { sha: "…" },
|
|
359
|
+
}),
|
|
360
|
+
"hand-written",
|
|
361
|
+
{ cwd: dir },
|
|
362
|
+
);
|
|
363
|
+
const { resolutions, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
364
|
+
expect(malformed).toBe(1);
|
|
365
|
+
expect(resolutions).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a string planDigest round-trips onto both kinds of record", async () => {
|
|
370
|
+
await withTestDir(async (dir) => {
|
|
371
|
+
await initRepo(dir);
|
|
372
|
+
const digest = `sha256:${"a".repeat(64)}`;
|
|
373
|
+
await appendPendingGate(
|
|
374
|
+
{ op: "live-apply", gate: "g", timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-03T00:00:00.000Z", planDigest: digest },
|
|
375
|
+
{ cwd: dir },
|
|
376
|
+
);
|
|
377
|
+
await appendGateResolution(
|
|
378
|
+
{ op: "live-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z", planDigest: digest },
|
|
379
|
+
{ cwd: dir },
|
|
380
|
+
);
|
|
381
|
+
const { resolutions, pending, malformed } = await readGateLedger("live-apply", { cwd: dir });
|
|
382
|
+
expect(malformed).toBe(0);
|
|
383
|
+
expect(pending[0].planDigest).toBe(digest);
|
|
384
|
+
expect(resolutions[0].planDigest).toBe(digest);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* `chant approve` locally can write one, the same trust boundary a local
|
|
23
23
|
* commit already has.
|
|
24
24
|
*
|
|
25
|
+
* Since #2300 both halves also carry a plan identity (`./plan-digest.ts`): a
|
|
26
|
+
* run records the plan it reached the gate with, `chant approve` records the
|
|
27
|
+
* plan it approves, and {@link latestResolutionForPlan} matches on that
|
|
28
|
+
* rather than on recency alone. Before it, an approval authorised the next
|
|
29
|
+
* run of an op rather than the plan its approver had read
|
|
30
|
+
* (INTENTIUS/choudoufu#1026).
|
|
31
|
+
*
|
|
25
32
|
* Since #2119 the file carries both halves of the loop. A `gate` step the
|
|
26
33
|
* local executor reaches (`../op/local-executor.ts`) appends a
|
|
27
34
|
* {@link PendingGateRecord} and ends that run with status `gated`; `chant
|
|
@@ -119,6 +126,18 @@ export interface GateResolutionRecord {
|
|
|
119
126
|
* URL — see {@link isApprovalUrl}.
|
|
120
127
|
*/
|
|
121
128
|
url?: string;
|
|
129
|
+
/**
|
|
130
|
+
* The plan this approval is for (#2300) — `computePlanDigest`'s output
|
|
131
|
+
* (`./plan-digest.ts`), normally copied off the {@link PendingGateRecord}
|
|
132
|
+
* this resolution answers.
|
|
133
|
+
*
|
|
134
|
+
* Absent on every resolution written before #2300, and on one written for a
|
|
135
|
+
* gate that binds no plan. Absent is not a wildcard: {@link
|
|
136
|
+
* latestResolutionForPlan} refuses a digest-less resolution against a
|
|
137
|
+
* plan-bound gate rather than letting it through, because the only thing
|
|
138
|
+
* such a record proves is that somebody approved *something*.
|
|
139
|
+
*/
|
|
140
|
+
planDigest?: string;
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">;
|
|
@@ -154,6 +173,16 @@ export interface PendingGateRecord {
|
|
|
154
173
|
expiresAt: string;
|
|
155
174
|
/** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
|
|
156
175
|
url?: string;
|
|
176
|
+
/**
|
|
177
|
+
* The plan the run reached this gate with (#2300) — `computePlanDigest`'s
|
|
178
|
+
* output (`./plan-digest.ts`). This is what `chant approve` copies onto the
|
|
179
|
+
* resolution by default, so approving the standing fact approves the plan
|
|
180
|
+
* the approver was shown rather than the next run's.
|
|
181
|
+
*
|
|
182
|
+
* Absent when the gate binds no plan (a component gate, an authored `gate`
|
|
183
|
+
* step with no `plan`), which is the shape every gate had before #2300.
|
|
184
|
+
*/
|
|
185
|
+
planDigest?: string;
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
|
|
@@ -262,6 +291,16 @@ export async function readGateLedger(
|
|
|
262
291
|
malformed++;
|
|
263
292
|
continue;
|
|
264
293
|
}
|
|
294
|
+
// #2300: a `planDigest` that is present but not a string is a
|
|
295
|
+
// malformed line, not a line with a field to ignore. Ignoring it would
|
|
296
|
+
// silently demote a plan-bound record to a digest-less one, which is
|
|
297
|
+
// the shape `latestResolutionForPlan` refuses — a corrupted approval
|
|
298
|
+
// must not read as an approval of anything at all.
|
|
299
|
+
const rawDigest: unknown = (parsed as { planDigest?: unknown }).planDigest;
|
|
300
|
+
if (rawDigest !== undefined && typeof rawDigest !== "string") {
|
|
301
|
+
malformed++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
265
304
|
if (parsed.kind === "pending") {
|
|
266
305
|
if (typeof parsed.expiresAt !== "string") {
|
|
267
306
|
malformed++;
|
|
@@ -319,3 +358,72 @@ export function latestResolutionSince(
|
|
|
319
358
|
}
|
|
320
359
|
return latest;
|
|
321
360
|
}
|
|
361
|
+
|
|
362
|
+
/** What {@link latestResolutionForPlan} found for the plan a run is holding. */
|
|
363
|
+
export interface PlanBoundResolution {
|
|
364
|
+
/** The resolution that answers this gate for this plan. Absent when nothing does. */
|
|
365
|
+
resolution?: GateResolutionRecord;
|
|
366
|
+
/**
|
|
367
|
+
* Present instead of {@link PlanBoundResolution.resolution} when a
|
|
368
|
+
* resolution stands for this gate but for a different plan — the newest
|
|
369
|
+
* such record, so a refusal can name who approved what and when. Its
|
|
370
|
+
* `planDigest` is `undefined` for a record written before #2300.
|
|
371
|
+
*/
|
|
372
|
+
mismatched?: GateResolutionRecord;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The resolution that answers `gate` for the plan `planDigest` identifies
|
|
377
|
+
* (#2300).
|
|
378
|
+
*
|
|
379
|
+
* The criterion is the digest; recency is only the tiebreak between several
|
|
380
|
+
* resolutions that all match it. That inversion is the whole point of the
|
|
381
|
+
* issue: {@link latestResolutionSince} asks "is there a newer approval",
|
|
382
|
+
* which a run answers yes to no matter what has changed since, and this asks
|
|
383
|
+
* "is there an approval of *this*".
|
|
384
|
+
*
|
|
385
|
+
* - `planDigest` `undefined` — the gate binds no plan (a component gate, a
|
|
386
|
+
* `gate` step authored with no `plan`). Falls straight through to
|
|
387
|
+
* {@link latestResolutionSince}: gates that never claimed to bind a plan
|
|
388
|
+
* behave exactly as they did before #2300.
|
|
389
|
+
* - A resolution whose `planDigest` equals `planDigest` answers the gate.
|
|
390
|
+
* - A resolution for a different plan does not, and comes back as
|
|
391
|
+
* `mismatched` so the caller can name both digests.
|
|
392
|
+
* - A resolution with no `planDigest` at all — every record written before
|
|
393
|
+
* #2300 — does not either, and comes back as `mismatched` with an absent
|
|
394
|
+
* `planDigest`. This is the safe reading of the migration: such a record
|
|
395
|
+
* proves someone approved something, and nothing about what. Accepting it
|
|
396
|
+
* once would silently apply the very change this check exists to catch, on
|
|
397
|
+
* exactly the estates that have been running longest. The cost is one
|
|
398
|
+
* further `chant approve` per gate after the upgrade, which the refusal
|
|
399
|
+
* says out loud.
|
|
400
|
+
*/
|
|
401
|
+
export function latestResolutionForPlan(
|
|
402
|
+
records: GateResolutionRecord[],
|
|
403
|
+
gate: string,
|
|
404
|
+
sinceIso: string,
|
|
405
|
+
planDigest: string | undefined,
|
|
406
|
+
): PlanBoundResolution {
|
|
407
|
+
if (planDigest === undefined) {
|
|
408
|
+
const resolution = latestResolutionSince(records, gate, sinceIso);
|
|
409
|
+
return resolution ? { resolution } : {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const since = new Date(sinceIso).getTime();
|
|
413
|
+
let matched: GateResolutionRecord | undefined;
|
|
414
|
+
let mismatched: GateResolutionRecord | undefined;
|
|
415
|
+
for (const r of records) {
|
|
416
|
+
if (r.gate !== gate) continue;
|
|
417
|
+
if (new Date(r.timestamp).getTime() < since) continue;
|
|
418
|
+
const newest = (best: GateResolutionRecord | undefined) =>
|
|
419
|
+
!best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
|
|
420
|
+
if (r.planDigest === planDigest) {
|
|
421
|
+
if (newest(matched)) matched = r;
|
|
422
|
+
} else if (newest(mismatched)) {
|
|
423
|
+
mismatched = r;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (matched) return { resolution: matched };
|
|
428
|
+
return mismatched ? { mismatched } : {};
|
|
429
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { computePlanDigest, isPlanDigest, describePlanDigest } from "./plan-digest";
|
|
3
|
+
|
|
4
|
+
describe("computePlanDigest", () => {
|
|
5
|
+
test("the same change set digests the same, whatever order its keys arrived in", () => {
|
|
6
|
+
const a = computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a", actions: ["create"] });
|
|
7
|
+
const b = computePlanDigest("terraform-plan", { actions: ["create"], address: "aws_s3_bucket.a" });
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a changed address changes it", () => {
|
|
12
|
+
expect(computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a" })).not.toBe(
|
|
13
|
+
computePlanDigest("terraform-plan", { address: "aws_s3_bucket.b" }),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The kind is hashed alongside the subject so a gate bound to a terraform
|
|
18
|
+
// plan cannot be satisfied by another kind of plan that happened to
|
|
19
|
+
// serialize identically.
|
|
20
|
+
test("two kinds of plan over identical data do not collide", () => {
|
|
21
|
+
expect(computePlanDigest("terraform-plan", { x: 1 })).not.toBe(
|
|
22
|
+
computePlanDigest("lifecycle-diff", { x: 1 }),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("it is a sha256 digest, in the shape isPlanDigest accepts", () => {
|
|
27
|
+
const digest = computePlanDigest("terraform-plan", {});
|
|
28
|
+
expect(digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
29
|
+
expect(isPlanDigest(digest)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isPlanDigest", () => {
|
|
34
|
+
test("refuses everything a copy-paste or a path could be", () => {
|
|
35
|
+
expect(isPlanDigest("chant.tfplan")).toBe(false);
|
|
36
|
+
expect(isPlanDigest(`sha256:${"a".repeat(63)}`)).toBe(false);
|
|
37
|
+
expect(isPlanDigest("a".repeat(64))).toBe(false);
|
|
38
|
+
expect(isPlanDigest(`sha256:${"A".repeat(64)}`)).toBe(false);
|
|
39
|
+
expect(isPlanDigest(undefined)).toBe(false);
|
|
40
|
+
expect(isPlanDigest(12)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("describePlanDigest", () => {
|
|
45
|
+
test("an absent digest reads as the pre-#2300 record it is, never as undefined", () => {
|
|
46
|
+
expect(describePlanDigest(undefined)).toBe("(none — recorded before plan-bound gates)");
|
|
47
|
+
expect(describePlanDigest("sha256:abc")).toBe("sha256:abc");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan identity for a gate (#2300, measured on INTENTIUS/choudoufu#1026).
|
|
3
|
+
*
|
|
4
|
+
* A gate resolution used to carry the op, the gate, the approver and a
|
|
5
|
+
* timestamp, and nothing about what was approved. Approve, edit the root,
|
|
6
|
+
* re-run, and the second run re-planned and applied: the resolution had
|
|
7
|
+
* authorised the *next run* of that op rather than the plan the approver
|
|
8
|
+
* read. This module is the missing half — one string that identifies a plan,
|
|
9
|
+
* written onto the pending fact the run records, onto the resolution `chant
|
|
10
|
+
* approve` appends, and compared by {@link latestResolutionForPlan} when a
|
|
11
|
+
* later run decides the gate.
|
|
12
|
+
*
|
|
13
|
+
* ## What a digest covers
|
|
14
|
+
*
|
|
15
|
+
* The change set, and only the change set: what a run proposes to create,
|
|
16
|
+
* update, replace or destroy, at which addresses, with which values. Two
|
|
17
|
+
* plans share a digest exactly when applying either one would do the same
|
|
18
|
+
* thing to the estate.
|
|
19
|
+
*
|
|
20
|
+
* ## What it deliberately does not cover
|
|
21
|
+
*
|
|
22
|
+
* - **When the plan was taken.** A plan file's own `timestamp`, and the
|
|
23
|
+
* resolution's. Re-planning an unchanged root a minute later must produce
|
|
24
|
+
* the same digest, or every approval would expire on the clock rather than
|
|
25
|
+
* on the content.
|
|
26
|
+
* - **Which run took it.** `runId`, the CI job number, the workflow attempt.
|
|
27
|
+
* Approving a plan and re-running the workflow is the whole loop; binding
|
|
28
|
+
* the run id would make the approval unusable by the run that consumes it.
|
|
29
|
+
* - **The tool that produced it.** The terraform/choudoufu version, the plan
|
|
30
|
+
* file's binary bytes and its path on disk. The digest is taken over the
|
|
31
|
+
* `show -json` rendering rather than the file, so a plan-format bump does
|
|
32
|
+
* not read as a changed plan.
|
|
33
|
+
* - **Who approved it, or where.** `resolvedBy`, `note`, `url` — those
|
|
34
|
+
* describe the approval, not the plan.
|
|
35
|
+
*
|
|
36
|
+
* The identity is therefore a claim about consequence, not about provenance.
|
|
37
|
+
* That is the claim an approver is actually making.
|
|
38
|
+
*/
|
|
39
|
+
import { sortedJsonReplacer } from "../utils";
|
|
40
|
+
import { getRuntime } from "../runtime-adapter";
|
|
41
|
+
|
|
42
|
+
/** The hash a plan digest is taken with, and the prefix every digest carries. */
|
|
43
|
+
export const PLAN_DIGEST_ALGORITHM = "sha256";
|
|
44
|
+
|
|
45
|
+
/** Shape of a well-formed digest: `sha256:` and 64 lowercase hex characters. */
|
|
46
|
+
const PLAN_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hash a plan's change set into a stable identity.
|
|
50
|
+
*
|
|
51
|
+
* `kind` names the shape `subject` is in (`"terraform-plan"`,
|
|
52
|
+
* `"lifecycle-diff"`), and is hashed alongside it so two different kinds of
|
|
53
|
+
* plan can never collide into the same digest by coincidence — a gate bound
|
|
54
|
+
* to a terraform plan must not be satisfiable by a lifecycle diff that
|
|
55
|
+
* happened to serialize identically.
|
|
56
|
+
*
|
|
57
|
+
* `subject` is canonicalised by {@link sortedJsonReplacer}, so object key
|
|
58
|
+
* order — which neither terraform's JSON writer nor `JSON.parse` guarantees
|
|
59
|
+
* across versions — does not change the answer. It is the caller's job to
|
|
60
|
+
* hand in a projection that already excludes the volatile fields this
|
|
61
|
+
* module's doc comment lists.
|
|
62
|
+
*/
|
|
63
|
+
export function computePlanDigest(kind: string, subject: unknown): string {
|
|
64
|
+
const canonical = JSON.stringify({ kind, subject }, sortedJsonReplacer);
|
|
65
|
+
return `${PLAN_DIGEST_ALGORITHM}:${getRuntime().hash(canonical)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether `raw` is a digest this code produced. Used at the `chant approve
|
|
70
|
+
* --plan` boundary, so a typo, a truncated copy-paste or a plan *file* path
|
|
71
|
+
* is refused before it is written into an immutable resolution that would
|
|
72
|
+
* then never match anything.
|
|
73
|
+
*/
|
|
74
|
+
export function isPlanDigest(raw: unknown): raw is string {
|
|
75
|
+
return typeof raw === "string" && PLAN_DIGEST_PATTERN.test(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A digest as it reads in a message, and the one place that decides how an
|
|
80
|
+
* absent one reads. Records written before #2300 carry no digest at all, and
|
|
81
|
+
* "(none — recorded before plan-bound gates)" is what a refusal has to say
|
|
82
|
+
* about them instead of printing `undefined`.
|
|
83
|
+
*/
|
|
84
|
+
export function describePlanDigest(digest: string | undefined): string {
|
|
85
|
+
return digest ?? "(none — recorded before plan-bound gates)";
|
|
86
|
+
}
|
|
@@ -105,6 +105,7 @@ export function buildRunRecord(
|
|
|
105
105
|
...(record.outcome ? { outcome: record.outcome } : {}),
|
|
106
106
|
...(record.approval ? { approval: record.approval } : {}),
|
|
107
107
|
...(record.error !== undefined ? { error: record.error } : {}),
|
|
108
|
+
...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
|
|
108
109
|
});
|
|
109
110
|
if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
|
|
110
111
|
}
|
|
@@ -257,9 +257,9 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
257
257
|
|
|
258
258
|
test("on a GitHub Actions job (GITHUB_REPOSITORY set), opens the sticky issue via gh api (#2297)", async () => {
|
|
259
259
|
const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
|
|
260
|
-
const calls: string
|
|
261
|
-
const gh = vi.fn(async (cmd: string) => {
|
|
262
|
-
calls.push(cmd);
|
|
260
|
+
const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
|
|
261
|
+
const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
|
|
262
|
+
calls.push({ cmd, env: opts?.env });
|
|
263
263
|
if (cmd.includes("--paginate")) return { stdout: "", stderr: "" }; // no owned issue yet
|
|
264
264
|
if (cmd.includes("--method POST")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
|
|
265
265
|
return { stdout: "", stderr: "" };
|
|
@@ -267,6 +267,8 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
267
267
|
|
|
268
268
|
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
269
269
|
vi.stubEnv("GITHUB_API_URL", "");
|
|
270
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
|
|
271
|
+
vi.stubEnv("GH_TOKEN", "ghs-workflow");
|
|
270
272
|
try {
|
|
271
273
|
const r = await lexiconUpgrade({
|
|
272
274
|
lexicon: "gcp",
|
|
@@ -276,10 +278,14 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
276
278
|
});
|
|
277
279
|
|
|
278
280
|
expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
|
|
279
|
-
expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
|
|
280
|
-
const post = calls.find((c) => c.includes("--method POST"));
|
|
281
|
-
expect(post).toContain("https://api.github.com/repos/acme/infra/issues");
|
|
282
|
-
expect(post).toContain("<!-- chant-lexicon-upgrade:gcp -->");
|
|
281
|
+
expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
|
|
282
|
+
const post = calls.find((c) => c.cmd.includes("--method POST"));
|
|
283
|
+
expect(post?.cmd).toContain("https://api.github.com/repos/acme/infra/issues");
|
|
284
|
+
expect(post?.cmd).toContain("<!-- chant-lexicon-upgrade:gcp -->");
|
|
285
|
+
// #2320: the shared `postOrUpdateGithubIssue` resolves the credential
|
|
286
|
+
// and hands it to the runner, so this caller gets the same forwarding
|
|
287
|
+
// reconcilePr's issue mode does rather than leaving `gh` to guess.
|
|
288
|
+
for (const call of calls) expect(call.env?.GH_TOKEN).toBe("ghs-workflow");
|
|
283
289
|
} finally {
|
|
284
290
|
vi.unstubAllEnvs();
|
|
285
291
|
}
|
|
@@ -287,9 +293,9 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
287
293
|
|
|
288
294
|
test("a re-run on a GitHub Actions job PATCHes the issue it already owns (#2297)", async () => {
|
|
289
295
|
const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
|
|
290
|
-
const calls: string
|
|
291
|
-
const gh = vi.fn(async (cmd: string) => {
|
|
292
|
-
calls.push(cmd);
|
|
296
|
+
const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
|
|
297
|
+
const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
|
|
298
|
+
calls.push({ cmd, env: opts?.env });
|
|
293
299
|
if (cmd.includes("--paginate")) return { stdout: "11\n", stderr: "" }; // marker search found issue 11
|
|
294
300
|
if (cmd.includes("--method PATCH")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
|
|
295
301
|
return { stdout: "", stderr: "" };
|
|
@@ -297,6 +303,10 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
297
303
|
|
|
298
304
|
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
299
305
|
vi.stubEnv("GITHUB_API_URL", "");
|
|
306
|
+
// The cross-instance case (#2320): a Forgejo token that must outrank the
|
|
307
|
+
// job's own `github.token`, on the path that used to forward neither.
|
|
308
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
|
|
309
|
+
vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
|
|
300
310
|
try {
|
|
301
311
|
const r = await lexiconUpgrade({
|
|
302
312
|
lexicon: "gcp",
|
|
@@ -306,8 +316,10 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
306
316
|
});
|
|
307
317
|
|
|
308
318
|
expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
|
|
309
|
-
expect(calls.some((c) => c.includes("--method POST"))).toBe(false);
|
|
310
|
-
expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
|
|
319
|
+
expect(calls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
|
|
320
|
+
expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
|
|
321
|
+
const patch = calls.find((c) => c.cmd.includes("--method PATCH"));
|
|
322
|
+
expect(patch?.env?.GH_TOKEN).toBe("forgejo-cross-instance");
|
|
311
323
|
} finally {
|
|
312
324
|
vi.unstubAllEnvs();
|
|
313
325
|
}
|
|
@@ -120,8 +120,21 @@ export type CheckRollingFn = (opts: {
|
|
|
120
120
|
verbose?: boolean;
|
|
121
121
|
}) => Promise<RollingUpgradeResult>;
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Minimal shell-exec interface for gh/git invocations.
|
|
125
|
+
*
|
|
126
|
+
* `opts` carries the environment `postOrUpdateGithubIssue` resolves the issue
|
|
127
|
+
* credential into (chant #2320) — the same `GhExec` shape reconcile.ts
|
|
128
|
+
* declares, so the two stay one signature and this can keep being passed
|
|
129
|
+
* straight through. Every other call site here forwards nothing and passes
|
|
130
|
+
* nothing. A mock runner is free to ignore the argument; the default one
|
|
131
|
+
* hands it to `execAsync`, which is what makes CHANT_FORGEJO_TOKEN actually
|
|
132
|
+
* reach `gh` on a cross-instance Forgejo run.
|
|
133
|
+
*/
|
|
134
|
+
export type GhRunner = (
|
|
135
|
+
cmd: string,
|
|
136
|
+
opts?: { env?: NodeJS.ProcessEnv },
|
|
137
|
+
) => Promise<{ stdout: string; stderr: string }>;
|
|
125
138
|
|
|
126
139
|
/** Applies a pinned version bump permanently (no revert). Async (core loads the lexicon's pin descriptor); a sync mock is also accepted. */
|
|
127
140
|
export type ApplyBumpFn = (
|
|
@@ -283,7 +296,7 @@ function shellQuote(s: string): string {
|
|
|
283
296
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
284
297
|
}
|
|
285
298
|
|
|
286
|
-
const defaultGh: GhRunner = async (cmd) => execAsync(cmd);
|
|
299
|
+
const defaultGh: GhRunner = async (cmd, opts) => execAsync(cmd, { ...opts });
|
|
287
300
|
|
|
288
301
|
/**
|
|
289
302
|
* The hidden marker that makes this lexicon's upgrade-status issue findable
|
|
@@ -320,6 +333,9 @@ async function postLexiconIssue(
|
|
|
320
333
|
const marker = lexiconUpgradeIssueMarker(lexicon);
|
|
321
334
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
322
335
|
if (repo) {
|
|
336
|
+
// `postOrUpdateGithubIssue` resolves the token itself and hands it back
|
|
337
|
+
// through `gh`'s second argument (#2320), so this runner has to forward
|
|
338
|
+
// what it is given rather than dropping it — see `GhRunner`.
|
|
323
339
|
return postOrUpdateGithubIssue(repo, marker, title, body, gh);
|
|
324
340
|
}
|
|
325
341
|
const { stdout } = await gh(
|