@intentius/chant 0.124.17 → 0.124.18
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/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/dist/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/dist/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/dist/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/dist/__golden__/wave-loop/fan-out-only.json +514 -0
- package/dist/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/dist/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/dist/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/dist/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/dist/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/dist/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/dist/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/dist/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/dist/__golden__/wave-loop/op-cost-member.json +191 -0
- package/dist/__golden__/wave-loop/op-denied-source.json +407 -0
- package/dist/__golden__/wave-loop/op-denied.json +399 -0
- package/dist/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/dist/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/dist/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/dist/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/dist/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/dist/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/dist/__golden__/wave-loop/op-held-on.json +1455 -0
- package/dist/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/dist/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/dist/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/dist/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/dist/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/dist/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/dist/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/dist/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/dist/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/dist/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/dist/__golden__/wave-loop/op-overridden.json +508 -0
- package/dist/__golden__/wave-loop/op-per-run.json +518 -0
- package/dist/__golden__/wave-loop/op-point.json +495 -0
- package/dist/__golden__/wave-loop/op-pr-review.json +397 -0
- package/dist/__golden__/wave-loop/op-sealed.json +125 -0
- package/dist/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/dist/__golden__/wave-loop/op-spend.json +328 -0
- package/dist/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/dist/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/dist/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/dist/__golden__/wave-loop/op-wide.json +440 -0
- package/dist/components/fan-out-run.d.ts +4 -3
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +138 -123
- package/dist/op/op-waves-run.d.ts +7 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +129 -72
- package/dist/wave-engine.d.ts +74 -5
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +47 -5
- package/dist/wave-runtime.mjs +202 -202
- package/package.json +1 -1
- package/src/__golden__/wave-loop/fan-out-applied.json +511 -0
- package/src/__golden__/wave-loop/fan-out-apply-fails.json +406 -0
- package/src/__golden__/wave-loop/fan-out-gated.json +707 -0
- package/src/__golden__/wave-loop/fan-out-no-members.json +898 -0
- package/src/__golden__/wave-loop/fan-out-only.json +514 -0
- package/src/__golden__/wave-loop/fan-out-plan-fails.json +359 -0
- package/src/__golden__/wave-loop/fan-out-same-root.json +311 -0
- package/src/__golden__/wave-loop/op-by-apply-after-fails.json +401 -0
- package/src/__golden__/wave-loop/op-by-apply-hold.json +756 -0
- package/src/__golden__/wave-loop/op-by-apply-ignored.json +340 -0
- package/src/__golden__/wave-loop/op-by-apply-pass.json +400 -0
- package/src/__golden__/wave-loop/op-by-apply-skip.json +404 -0
- package/src/__golden__/wave-loop/op-cost-member-clash.json +62 -0
- package/src/__golden__/wave-loop/op-cost-member.json +191 -0
- package/src/__golden__/wave-loop/op-denied-source.json +407 -0
- package/src/__golden__/wave-loop/op-denied.json +399 -0
- package/src/__golden__/wave-loop/op-discover-rest-short.json +92 -0
- package/src/__golden__/wave-loop/op-discover-rest-stops.json +340 -0
- package/src/__golden__/wave-loop/op-discover-rest.json +659 -0
- package/src/__golden__/wave-loop/op-discover-wide.json +290 -0
- package/src/__golden__/wave-loop/op-held-on-again.json +176 -0
- package/src/__golden__/wave-loop/op-held-on-before-apply.json +689 -0
- package/src/__golden__/wave-loop/op-held-on.json +1455 -0
- package/src/__golden__/wave-loop/op-hooks-apply-fails.json +673 -0
- package/src/__golden__/wave-loop/op-hooks-fail-after-apply.json +737 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-apply.json +450 -0
- package/src/__golden__/wave-loop/op-hooks-fail-before-plan.json +97 -0
- package/src/__golden__/wave-loop/op-hooks-held.json +1059 -0
- package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +693 -0
- package/src/__golden__/wave-loop/op-hooks-pass.json +735 -0
- package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +148 -0
- package/src/__golden__/wave-loop/op-hooks-share.json +1225 -0
- package/src/__golden__/wave-loop/op-outcome-held.json +202 -0
- package/src/__golden__/wave-loop/op-outcome-replan-approved.json +286 -0
- package/src/__golden__/wave-loop/op-outcome-replan-forever.json +823 -0
- package/src/__golden__/wave-loop/op-outcome-replan.json +259 -0
- package/src/__golden__/wave-loop/op-outcome-stood-down.json +196 -0
- package/src/__golden__/wave-loop/op-outcome-unreadable.json +193 -0
- package/src/__golden__/wave-loop/op-overridden.json +508 -0
- package/src/__golden__/wave-loop/op-per-run.json +518 -0
- package/src/__golden__/wave-loop/op-point.json +495 -0
- package/src/__golden__/wave-loop/op-pr-review.json +397 -0
- package/src/__golden__/wave-loop/op-sealed.json +125 -0
- package/src/__golden__/wave-loop/op-spend-change-only.json +168 -0
- package/src/__golden__/wave-loop/op-spend.json +328 -0
- package/src/__golden__/wave-loop/op-wave-commands.json +1808 -0
- package/src/__golden__/wave-loop/op-wide-refused.json +291 -0
- package/src/__golden__/wave-loop/op-wide-waiting.json +231 -0
- package/src/__golden__/wave-loop/op-wide.json +440 -0
- package/src/components/fan-out-run.ts +156 -128
- package/src/meta/peer-deps.test.ts +39 -0
- package/src/op/op-waves-run.ts +184 -68
- package/src/wave-engine.test.ts +38 -1
- package/src/wave-engine.ts +101 -5
- package/src/wave-loop-golden.test.ts +627 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden transcripts of both wave runners (#3766): `chant run wave` (an Op
|
|
3
|
+
* waves spec, `runOpWave`) and `chant components fan-out --wave-gate`
|
|
4
|
+
* (`runFanOut`). Each scenario records everything a job outside chant can
|
|
5
|
+
* see: every command chant runs with its argv and the environment it adds,
|
|
6
|
+
* the files those variables name as they stood when the command ran
|
|
7
|
+
* (`CHANT_HOOKS`, `CHANT_RUNS`, `CHANT_DECISION`), every file chant left
|
|
8
|
+
* under `.chant/`, every fact on the gate ledger, the result with its exit
|
|
9
|
+
* code, and the log. For fan-out: the progress events, each wave record as
|
|
10
|
+
* it settled, and the result.
|
|
11
|
+
*
|
|
12
|
+
* The transcripts are pinned in `__golden__/wave-loop/`. A change to how the
|
|
13
|
+
* runners are built must leave them byte for byte as they are: terragucci
|
|
14
|
+
* runs every wave through `chant run wave`, and approvals in flight are
|
|
15
|
+
* bound to these gate names and digests.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
19
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { memoryGateLedgerPort } from "./op/gate";
|
|
23
|
+
import { runOpWave, runOpWaveApplyHooks, readOpWaveHooksFile, type OpWaveExec, type OpWaveRunsFile, type RunOpWaveOptions } from "./op/op-waves-run";
|
|
24
|
+
import type { OpWavesSpec } from "./op/op-waves";
|
|
25
|
+
import type { GatePointAsker, GatePointQuestion, GatePointRequest } from "./point-decision";
|
|
26
|
+
import type { GateResolutionRecord } from "./lifecycle/gate-ledger";
|
|
27
|
+
import type { AppliedGateRecord } from "./lifecycle/gate-applied";
|
|
28
|
+
import { waveSetDigest } from "./gated-waves";
|
|
29
|
+
import { CapabilityRegistry, type DeployContext } from "./components/capability";
|
|
30
|
+
import { planFanOut } from "./components/fan-out";
|
|
31
|
+
import { runFanOut, type FanOutRunOptions } from "./components/fan-out-run";
|
|
32
|
+
import { planWaveComponent } from "./components/wave-plan";
|
|
33
|
+
import type { DriverComponent } from "./components/driver";
|
|
34
|
+
import type { RunProgressEvent } from "./components/run-progress";
|
|
35
|
+
import type { WaveRecord } from "./gated-waves";
|
|
36
|
+
|
|
37
|
+
const digest = (n: string) => `sha256:${n.repeat(64)}`;
|
|
38
|
+
const NOW = "2026-10-11T00:00:00.000Z";
|
|
39
|
+
const BEFORE = "2026-10-10T00:00:00Z";
|
|
40
|
+
const GOLDEN = "./__golden__/wave-loop";
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
// A CI job's locator would land on every pending fact.
|
|
44
|
+
vi.stubEnv("GITHUB_ACTIONS", "");
|
|
45
|
+
vi.stubEnv("GITLAB_CI", "");
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
48
|
+
|
|
49
|
+
type PlanEntry = { planDigest: string; destructive?: boolean; empty?: boolean; denied?: { rules: string[]; reason?: string } };
|
|
50
|
+
|
|
51
|
+
interface Script {
|
|
52
|
+
/** Each target's plan, for `tool plan` and `tg plan`. */
|
|
53
|
+
plans?: Record<string, PlanEntry>;
|
|
54
|
+
/** What wave k's plan finds, for a discovering wave. */
|
|
55
|
+
layers?: Array<Record<string, PlanEntry>>;
|
|
56
|
+
/** Fields a `tg plan` adds to its plans file, by wave number. */
|
|
57
|
+
extra?: (k: number) => Record<string, unknown>;
|
|
58
|
+
/** A hook (any argv not `tool`/`tg` plan or apply). Default exit 0. */
|
|
59
|
+
hook?: (argv: string[], env: Record<string, string>, dir: string) => number;
|
|
60
|
+
/** An apply call; `n` counts apply calls. Default exit 0. */
|
|
61
|
+
apply?: (argv: string[], env: Record<string, string>, dir: string, n: number) => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Call {
|
|
65
|
+
argv: string[];
|
|
66
|
+
env?: Record<string, string>;
|
|
67
|
+
/** The files the call's environment and argv name, as they stood when it ran. */
|
|
68
|
+
read?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readMaybeJson(path: string): unknown {
|
|
72
|
+
const text = readFileSync(path, "utf-8");
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
} catch {
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every file under `dir`, by path relative to `root`. */
|
|
81
|
+
function tree(root: string, dir = root, out: Record<string, unknown> = {}): Record<string, unknown> {
|
|
82
|
+
if (!existsSync(dir)) return out;
|
|
83
|
+
for (const name of readdirSync(dir).sort()) {
|
|
84
|
+
const at = join(dir, name);
|
|
85
|
+
if (statSync(at).isDirectory()) tree(root, at, out);
|
|
86
|
+
else out[relative(root, at)] = readMaybeJson(at);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function opHarness(script: Script) {
|
|
92
|
+
const cwd = mkdtempSync(join(tmpdir(), "wave-golden-"));
|
|
93
|
+
const calls: Call[] = [];
|
|
94
|
+
const logs: string[] = [];
|
|
95
|
+
let applies = 0;
|
|
96
|
+
const exec: OpWaveExec = (argv, dir, env) => {
|
|
97
|
+
const read: Record<string, unknown> = {};
|
|
98
|
+
for (const key of ["CHANT_HOOKS", "CHANT_RUNS", "CHANT_DECISION"] as const) {
|
|
99
|
+
const p = env?.[key];
|
|
100
|
+
if (p && existsSync(isAbsolute(p) ? p : join(dir, p))) read[key] = readMaybeJson(isAbsolute(p) ? p : join(dir, p));
|
|
101
|
+
}
|
|
102
|
+
for (const part of argv) if (part.endsWith("runs.json") && existsSync(join(dir, part))) read[part] = readMaybeJson(join(dir, part));
|
|
103
|
+
calls.push({ argv: [...argv], ...(env ? { env: { ...env } } : {}), ...(Object.keys(read).length ? { read } : {}) });
|
|
104
|
+
if (argv[0] === "tool" && argv[1] === "plan") {
|
|
105
|
+
const target = argv[2]!;
|
|
106
|
+
writeFileSync(join(dir, argv[argv.length - 1]!), JSON.stringify(script.plans?.[target] ?? { planDigest: digest(target.slice(0, 1)) }));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (argv[0] === "tg" && argv[1] === "plan") {
|
|
110
|
+
const runs = JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile;
|
|
111
|
+
const found = script.layers?.[runs.number - 1] ?? script.plans ?? {};
|
|
112
|
+
const entries = runs.discover
|
|
113
|
+
? Object.entries(found).map(([target, p]) => ({ target, ...p }))
|
|
114
|
+
: runs.runs.map((r) => ({ target: r.target, ...(found[r.target] ?? { planDigest: digest(r.target.slice(0, 1)) }) }));
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(dir, argv[4]!),
|
|
117
|
+
JSON.stringify({ runs: entries, ...(script.layers ? { waves: script.layers.length } : {}), ...(script.extra?.(runs.number) ?? {}) }),
|
|
118
|
+
);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
if ((argv[0] === "tool" || argv[0] === "tg") && argv[1] === "apply") return script.apply?.(argv, env ?? {}, dir, ++applies) ?? 0;
|
|
122
|
+
return script.hook?.(argv, env ?? {}, dir) ?? 0;
|
|
123
|
+
};
|
|
124
|
+
const scrub = (value: unknown): unknown => JSON.parse(JSON.stringify(value ?? null).replaceAll(cwd, "<cwd>"));
|
|
125
|
+
return {
|
|
126
|
+
cwd,
|
|
127
|
+
calls,
|
|
128
|
+
logs,
|
|
129
|
+
exec,
|
|
130
|
+
log: (line: string) => logs.push(line),
|
|
131
|
+
show: (atBase: OpWavesSpec | null) => () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) }),
|
|
132
|
+
transcript: (extra: Record<string, unknown>) => scrub({ ...extra, calls, logs, files: tree(cwd) }),
|
|
133
|
+
done: () => rmSync(cwd, { recursive: true, force: true }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type Gates = ReturnType<typeof memoryGateLedgerPort>;
|
|
138
|
+
const ledgerOf = (gates: Gates) => ({ appended: gates.appended, resolved: gates.resolved, applied: gates.applied });
|
|
139
|
+
|
|
140
|
+
const resolution = (op: string, gate: string, planDigest: string, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
|
|
141
|
+
version: 1,
|
|
142
|
+
op,
|
|
143
|
+
gate,
|
|
144
|
+
resolvedBy: "alice",
|
|
145
|
+
timestamp: BEFORE,
|
|
146
|
+
planDigest,
|
|
147
|
+
...extra,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** Run one or more jobs of one spec in one working tree; each job's result goes into the transcript. */
|
|
151
|
+
async function opScenario(
|
|
152
|
+
name: string,
|
|
153
|
+
spec: OpWavesSpec,
|
|
154
|
+
script: Script,
|
|
155
|
+
jobs: Array<Partial<RunOpWaveOptions> & { wave: number; gates?: Gates; atBase?: OpWavesSpec | null; label?: string; before?: () => void }>,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const h = opHarness(script);
|
|
158
|
+
try {
|
|
159
|
+
const out: Array<Record<string, unknown>> = [];
|
|
160
|
+
for (const job of jobs) {
|
|
161
|
+
const { gates = memoryGateLedgerPort(), atBase = spec, label, before, ...rest } = job;
|
|
162
|
+
before?.();
|
|
163
|
+
const from = h.calls.length;
|
|
164
|
+
const logFrom = h.logs.length;
|
|
165
|
+
let result: unknown;
|
|
166
|
+
try {
|
|
167
|
+
result = await runOpWave({ spec, specFile: "waves.json", cwd: h.cwd, exec: h.exec, show: h.show(atBase), gates, now: NOW, log: h.log, ...rest });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
result = { threw: err instanceof Error ? err.message : String(err) };
|
|
170
|
+
}
|
|
171
|
+
out.push({ ...(label ? { label } : {}), job: { wave: rest.wave, decide: rest.decide, share: rest.share }, result, ledger: ledgerOf(gates), calls: h.calls.slice(from).length, logs: h.logs.slice(logFrom).length });
|
|
172
|
+
}
|
|
173
|
+
await expect(JSON.stringify(h.transcript({ jobs: out }), null, 2) + "\n").toMatchFileSnapshot(`${GOLDEN}/op-${name}.json`);
|
|
174
|
+
} finally {
|
|
175
|
+
h.done();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const perRun: OpWavesSpec = {
|
|
180
|
+
name: "migrations",
|
|
181
|
+
op: "migrate",
|
|
182
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
183
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
184
|
+
waves: [
|
|
185
|
+
{ name: "dev", runs: [{ target: "dev" }], gate: "never" },
|
|
186
|
+
{ name: "staging", runs: [{ target: "staging" }], gate: "on-destructive" },
|
|
187
|
+
{ name: "prod", runs: [{ target: "t3" }, { target: "t1" }, { target: "t2" }], shares: 2 },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const tg: OpWavesSpec = {
|
|
192
|
+
name: "tf-apply",
|
|
193
|
+
op: "tf-apply",
|
|
194
|
+
gateName: "wave-{k}",
|
|
195
|
+
digest: { over: "changing" },
|
|
196
|
+
planWave: ["tg", "plan", "{wave}", "{runs}", "{plans}"],
|
|
197
|
+
applyWave: ["tg", "apply", "{wave}", "{runs}", "{plans}"],
|
|
198
|
+
hooks: { after: { gate: [{ name: "report", command: ["hook", "report", "{runs}"] }] } },
|
|
199
|
+
waves: [
|
|
200
|
+
{ name: "net", runs: [{ target: "vpc" }, { target: "dns" }, { target: "nat" }], gate: "always" },
|
|
201
|
+
{ name: "apps", runs: [{ target: "a1" }, { target: "a2" }, { target: "a3" }], gate: "never", shares: 2 },
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const stagingSet = waveSetDigest([{ member: "staging", planDigest: digest("a") }]);
|
|
206
|
+
|
|
207
|
+
describe("Op waves golden transcripts (#3766)", () => {
|
|
208
|
+
test("per-run plans: never, on-destructive waiting then approved, not-required", async () => {
|
|
209
|
+
const script: Script = { plans: { dev: { planDigest: digest("d") }, staging: { planDigest: digest("a"), destructive: true } } };
|
|
210
|
+
await opScenario("per-run", perRun, script, [
|
|
211
|
+
{ wave: 1, label: "never" },
|
|
212
|
+
{ wave: 2, label: "waits", atBase: perRun },
|
|
213
|
+
{ wave: 2, label: "loosened in the change, still waits", atBase: perRun, spec: { ...perRun, waves: perRun.waves.map((w) => ({ ...w, gate: "never" as const })) } },
|
|
214
|
+
{ wave: 2, label: "approved", gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-2", stagingSet)] }) },
|
|
215
|
+
{ wave: 2, label: "absent at base", atBase: null },
|
|
216
|
+
]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a wide wave: the deciding job, its shares, a moved plan", async () => {
|
|
220
|
+
const plans: Record<string, PlanEntry> = { t1: { planDigest: digest("1") }, t2: { planDigest: digest("2") }, t3: { planDigest: digest("3") } };
|
|
221
|
+
const set = waveSetDigest(Object.entries(plans).map(([member, p]) => ({ member, planDigest: p.planDigest })));
|
|
222
|
+
const gates = memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-3", set)] });
|
|
223
|
+
await opScenario("wide", { ...perRun, approvals: { spend: true } }, { plans }, [
|
|
224
|
+
{ wave: 3, decide: true, gates, label: "decide, approved and spent" },
|
|
225
|
+
{ wave: 3, share: 2, gates, label: "share 2" },
|
|
226
|
+
{ wave: 3, share: 1, gates, label: "share 1, t2 moved", before: () => (plans.t2 = { planDigest: digest("9") }) },
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a share whose decision waits, and one whose decision was refused", async () => {
|
|
231
|
+
const plans: Record<string, PlanEntry> = { t1: { planDigest: digest("1") }, t2: { planDigest: digest("2") }, t3: { planDigest: digest("3") } };
|
|
232
|
+
await opScenario("wide-waiting", perRun, { plans }, [
|
|
233
|
+
{ wave: 3, decide: true, label: "decide, waits" },
|
|
234
|
+
{ wave: 3, share: 1, label: "share 1 of a waiting decision" },
|
|
235
|
+
]);
|
|
236
|
+
const denied: Record<string, PlanEntry> = { ...plans, t2: { planDigest: digest("2"), denied: { rules: ["r1"], reason: "no" } } };
|
|
237
|
+
const wide: OpWavesSpec = { ...tg, hooks: undefined, waves: [{ name: "apps", runs: [{ target: "t1" }, { target: "t2" }, { target: "t3" }], gate: "never", shares: 2 }] };
|
|
238
|
+
await opScenario("wide-refused", wide, { plans: denied }, [
|
|
239
|
+
{ wave: 1, decide: true, label: "decide, refused" },
|
|
240
|
+
{ wave: 1, share: 2, label: "share 2 of a refused decision" },
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("planWave, applyWave, a product's gate name, changing digest, pending fields, extras and neverOverMcp", async () => {
|
|
245
|
+
const plans: Record<string, PlanEntry> = { vpc: { planDigest: digest("1"), destructive: true }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3") } };
|
|
246
|
+
const extra = () => ({ pending: { description: "net layer", commit: "c0ffee" }, members: [{ member: "cost", planDigest: "usd:12.00" }] });
|
|
247
|
+
const spec: OpWavesSpec = { ...tg, neverOverMcp: true };
|
|
248
|
+
const set = waveSetDigest([{ member: "vpc", planDigest: digest("1") }, { member: "nat", planDigest: digest("3") }, { member: "cost", planDigest: "usd:12.00" }]);
|
|
249
|
+
await opScenario("wave-commands", spec, { plans, extra }, [
|
|
250
|
+
{ wave: 1, label: "waits" },
|
|
251
|
+
{ wave: 1, label: "approved", gates: memoryGateLedgerPort({ resolutions: [resolution("tf-apply", "wave-1", set)] }) },
|
|
252
|
+
{ wave: 1, label: "another digest approved", gates: memoryGateLedgerPort({ resolutions: [resolution("tf-apply", "wave-1", digest("9"))] }) },
|
|
253
|
+
{ wave: 2, decide: true, label: "decide a wide never wave" },
|
|
254
|
+
{ wave: 2, share: 1, label: "share 1" },
|
|
255
|
+
{ wave: 2, share: 2, label: "share 2" },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("hooks around plan and apply, perRun, holds, failures", async () => {
|
|
260
|
+
const hooked: OpWavesSpec = {
|
|
261
|
+
name: "migrations",
|
|
262
|
+
op: "migrate",
|
|
263
|
+
plan: ["tool", "plan", "{target}", "{plan}"],
|
|
264
|
+
apply: ["tool", "apply", "{target}", "{plan}"],
|
|
265
|
+
hooks: { before: { plan: [{ name: "lint", command: ["hook", "lint", "{runs}"] }] } },
|
|
266
|
+
waves: [
|
|
267
|
+
{
|
|
268
|
+
name: "prod",
|
|
269
|
+
runs: [{ target: "t1" }, { target: "t2" }],
|
|
270
|
+
gate: "never",
|
|
271
|
+
hooks: {
|
|
272
|
+
after: { plan: [{ name: "check", command: ["hook", "check", "{target}", "{plan}"], perRun: true, onFailure: "approve" }], apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
|
|
273
|
+
before: { apply: [{ name: "backup", command: ["hook", "backup", "{runs}"] }] },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
const set = waveSetDigest([{ member: "t1", planDigest: digest("t") }, { member: "t2", planDigest: digest("t") }]);
|
|
279
|
+
await opScenario("hooks-pass", hooked, {}, [{ wave: 1 }]);
|
|
280
|
+
await opScenario("hooks-held", hooked, { hook: (argv) => (argv[1] === "check" && argv[2] === "t2" ? 1 : 0) }, [
|
|
281
|
+
{ wave: 1, label: "held, waits" },
|
|
282
|
+
{ wave: 1, label: "held, approved", gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-1", set)] }) },
|
|
283
|
+
]);
|
|
284
|
+
await opScenario("hooks-fail-before-plan", hooked, { hook: (argv) => (argv[1] === "lint" ? 1 : 0) }, [{ wave: 1 }]);
|
|
285
|
+
await opScenario("hooks-fail-after-apply", hooked, { hook: (argv) => (argv[1] === "smoke" ? 1 : 0) }, [{ wave: 1 }]);
|
|
286
|
+
await opScenario("hooks-fail-before-apply", hooked, { hook: (argv) => (argv[1] === "backup" ? 1 : 0) }, [{ wave: 1 }]);
|
|
287
|
+
await opScenario("hooks-apply-fails", hooked, { apply: (argv) => (argv[2] === "t1" ? 2 : 0) }, [{ wave: 1 }]);
|
|
288
|
+
const holdBefore: OpWavesSpec = { ...hooked, waves: [{ ...hooked.waves[0]!, hooks: { before: { apply: [{ name: "lag", command: ["hook", "lag"], onFailure: "approve" }] } } }] };
|
|
289
|
+
await opScenario("hooks-hold-before-apply", holdBefore, { hook: (argv) => (argv[1] === "lag" ? 1 : 0) }, [
|
|
290
|
+
{ wave: 1, label: "waits" },
|
|
291
|
+
{ wave: 1, label: "approved", gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-1", set)] }) },
|
|
292
|
+
]);
|
|
293
|
+
await opScenario("hooks-refused-at-base", hooked, {}, [
|
|
294
|
+
{ wave: 1, atBase: { ...hooked, hooks: { before: { plan: [{ command: [] as unknown as string[] }] } } } as OpWavesSpec },
|
|
295
|
+
]);
|
|
296
|
+
await opScenario("hooks-share", { ...hooked, waves: [{ ...hooked.waves[0]!, shares: 2 }] }, { hook: (argv) => (argv[1] === "check" && argv[2] === "t2" ? 1 : 0) }, [
|
|
297
|
+
{ wave: 1, decide: true, label: "decide, held" },
|
|
298
|
+
{ wave: 1, share: 1, label: "share 1, waiting decision" },
|
|
299
|
+
{ wave: 1, decide: true, label: "decide, approved", gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-1", set)] }) },
|
|
300
|
+
{ wave: 1, share: 2, label: "share 2, approved" },
|
|
301
|
+
]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("apply-stage hooks run by the apply (CHANT_HOOKS, CHANT_HOOKS_RESULT)", async () => {
|
|
305
|
+
const spec: OpWavesSpec = {
|
|
306
|
+
name: "migrations",
|
|
307
|
+
op: "migrate",
|
|
308
|
+
plan: ["tool", "plan", "{target}", "{plan}"],
|
|
309
|
+
apply: ["tool", "apply", "{target}", "{plan}"],
|
|
310
|
+
hooks: { apply: { runBy: "apply" }, before: { plan: [{ name: "lint", command: ["hook", "lint"] }] } },
|
|
311
|
+
waves: [
|
|
312
|
+
{
|
|
313
|
+
name: "prod",
|
|
314
|
+
runs: [{ target: "t1" }],
|
|
315
|
+
gate: "never",
|
|
316
|
+
hooks: {
|
|
317
|
+
before: { apply: [{ name: "lag", command: ["hook", "lag", "{target}"], perRun: true, onFailure: "approve" }] },
|
|
318
|
+
after: { apply: [{ name: "smoke", command: ["hook", "smoke", "{wave}"] }] },
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
const byApply = (fails: string[], obey = true): Script => ({
|
|
324
|
+
apply: (_argv, env, dir) => {
|
|
325
|
+
const file = readOpWaveHooksFile(env.CHANT_HOOKS, dir)!;
|
|
326
|
+
if (!obey) return 0;
|
|
327
|
+
const inner: OpWaveExec = (a) => (fails.includes(a[1]!) ? 1 : 0);
|
|
328
|
+
const before = runOpWaveApplyHooks(file, "before-apply", { cwd: dir, exec: inner, log: () => {} });
|
|
329
|
+
if (!before.proceed) return before.held.length > 0 ? 3 : 4;
|
|
330
|
+
const after = runOpWaveApplyHooks(file, "after-apply", { cwd: dir, exec: inner, log: () => {} });
|
|
331
|
+
return after.proceed ? 0 : 1;
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const set = waveSetDigest([{ member: "t1", planDigest: digest("t") }]);
|
|
335
|
+
await opScenario("by-apply-pass", spec, byApply([]), [{ wave: 1 }]);
|
|
336
|
+
await opScenario("by-apply-hold", spec, byApply(["lag"]), [
|
|
337
|
+
{ wave: 1, label: "waits" },
|
|
338
|
+
{ wave: 1, label: "approved", gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-1", set)] }) },
|
|
339
|
+
]);
|
|
340
|
+
await opScenario("by-apply-after-fails", spec, byApply(["smoke"]), [{ wave: 1 }]);
|
|
341
|
+
await opScenario("by-apply-ignored", spec, byApply([], false), [{ wave: 1 }]);
|
|
342
|
+
await opScenario("by-apply-skip", spec, {
|
|
343
|
+
apply: (_argv, env, dir) => {
|
|
344
|
+
const file = readOpWaveHooksFile(env.CHANT_HOOKS, dir)!;
|
|
345
|
+
runOpWaveApplyHooks(file, "before-apply", { cwd: dir, skip: "nothing to apply", log: () => {} });
|
|
346
|
+
runOpWaveApplyHooks(file, "after-apply", { cwd: dir, skip: "nothing to apply", log: () => {} });
|
|
347
|
+
return 0;
|
|
348
|
+
},
|
|
349
|
+
}, [{ wave: 1 }]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("spend: approved once, then spent", async () => {
|
|
353
|
+
const spec: OpWavesSpec = { ...perRun, approvals: { spend: true } };
|
|
354
|
+
const gates = memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-2", stagingSet)] });
|
|
355
|
+
await opScenario("spend", spec, { plans: { staging: { planDigest: digest("a"), destructive: true } } }, [
|
|
356
|
+
{ wave: 2, gates, label: "applies under the approval and spends it" },
|
|
357
|
+
{ wave: 2, gates, label: "the same approval is spent" },
|
|
358
|
+
{ wave: 2, label: "no appendApplied", gates: { ...memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-2", stagingSet)] }), appendApplied: undefined } as unknown as Gates },
|
|
359
|
+
]);
|
|
360
|
+
await opScenario("spend-change-only", perRun, { plans: { staging: { planDigest: digest("a"), destructive: true } } }, [
|
|
361
|
+
{ wave: 2, spec, gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-2", stagingSet)] }) },
|
|
362
|
+
]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("denials and overrides (exit 7)", async () => {
|
|
366
|
+
const { overrideDigest } = await import("./override-gate");
|
|
367
|
+
const od = overrideDigest("vpc", digest("1"), ["no-public-s3"]);
|
|
368
|
+
const plans: Record<string, PlanEntry> = { vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") }, nat: { planDigest: digest("3"), empty: true } };
|
|
369
|
+
await opScenario("denied", tg, { plans }, [{ wave: 1 }]);
|
|
370
|
+
await opScenario("denied-source", tg, { plans, extra: () => ({ overriders: ["github:dave"], overridersSource: "policy.override in terragucci.yml" }) }, [
|
|
371
|
+
{ wave: 1, gates: memoryGateLedgerPort({ resolutions: [resolution("policy-override", "vpc", od, { resolvedBy: "github:carol", note: "ok" })] }) },
|
|
372
|
+
]);
|
|
373
|
+
const never: OpWavesSpec = { ...tg, waves: [{ ...tg.waves[0]!, gate: "never" }] };
|
|
374
|
+
await opScenario("overridden", never, { plans, extra: () => ({ overriders: ["github:carol"] }) }, [
|
|
375
|
+
{ wave: 1, gates: memoryGateLedgerPort({ resolutions: [resolution("policy-override", "vpc", od, { resolvedBy: "github:carol", note: "the bucket is the docs site" })] }) },
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("a hook holds the wave on other gates (CHANT_HOLD, CHANT_HOLD_APPROVED, CHANT_HOLDS_APPROVED)", async () => {
|
|
380
|
+
const migrate = { name: "migrate", command: ["hook", "migrate", "{runs}"], onFailure: "approve" as const };
|
|
381
|
+
const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
|
|
382
|
+
const hook = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
383
|
+
if (argv[1] !== "migrate") return 0;
|
|
384
|
+
const approved = env.CHANT_HOLDS_APPROVED ? (JSON.parse(env.CHANT_HOLDS_APPROVED) as unknown[]) : [];
|
|
385
|
+
if (approved.length >= 2) return 0;
|
|
386
|
+
const gate = approved.length === 0 ? "split-queues" : "split-topics";
|
|
387
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate, digest: digest(gate.slice(6, 7)), description: `move the ${gate.slice(6)}`, pending: { members: [] } }));
|
|
388
|
+
return 3;
|
|
389
|
+
};
|
|
390
|
+
const plans: Record<string, PlanEntry> = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true }, nat: { planDigest: digest("3"), empty: true } };
|
|
391
|
+
const first = resolution("tf-migrate", "split-queues", digest("q"));
|
|
392
|
+
await opScenario("held-on", spec, { plans, hook }, [
|
|
393
|
+
{ wave: 1, label: "waits on the first" },
|
|
394
|
+
{ wave: 1, label: "waits on the second", gates: memoryGateLedgerPort({ resolutions: [first] }) },
|
|
395
|
+
{ wave: 1, label: "both approved", gates: memoryGateLedgerPort({ resolutions: [first, resolution("tf-migrate", "split-topics", digest("t"))] }) },
|
|
396
|
+
]);
|
|
397
|
+
const again = (argv: string[], env: Record<string, string>, dir: string) => {
|
|
398
|
+
if (argv[1] !== "migrate") return 0;
|
|
399
|
+
writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("q") }));
|
|
400
|
+
return 3;
|
|
401
|
+
};
|
|
402
|
+
await opScenario("held-on-again", spec, { plans, hook: again }, [{ wave: 1, gates: memoryGateLedgerPort({ resolutions: [first] }) }]);
|
|
403
|
+
const applyHold: OpWavesSpec = { ...tg, hooks: { before: { apply: [migrate] }, after: tg.hooks!.after } };
|
|
404
|
+
await opScenario("held-on-before-apply", { ...applyHold, waves: [{ ...tg.waves[0]!, gate: "never" }] }, { plans, hook }, [{ wave: 1 }]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("discovered waves, rest, a cost member, applies that apply nothing, replans", async () => {
|
|
408
|
+
const disc: OpWavesSpec = {
|
|
409
|
+
...tg,
|
|
410
|
+
hooks: undefined,
|
|
411
|
+
waves: [
|
|
412
|
+
{ name: "layer", runs: [], discover: true, gate: "never" },
|
|
413
|
+
{ name: "rest", runs: [], discover: true, rest: true, gate: "never" },
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
const layers: Array<Record<string, PlanEntry>> = [{ "a/vpc": { planDigest: digest("1") } }, { "b/db": { planDigest: digest("2") } }, { "c/app": { planDigest: digest("3") } }, { "d/dns": { planDigest: digest("4") } }];
|
|
417
|
+
await opScenario("discover-rest", disc, { layers }, [{ wave: 1 }, { wave: 2 }]);
|
|
418
|
+
const failsOn = (target: string): Script["apply"] => (argv, _env, dir) =>
|
|
419
|
+
(JSON.parse(readFileSync(join(dir, argv[3]!), "utf-8")) as OpWaveRunsFile).runs.some((r) => r.target === target) ? 1 : 0;
|
|
420
|
+
await opScenario("discover-rest-stops", disc, { layers, apply: failsOn("c/app") }, [{ wave: 2 }]);
|
|
421
|
+
await opScenario("discover-rest-short", disc, { layers: [layers[0]!] }, [{ wave: 2 }]);
|
|
422
|
+
const wide: OpWavesSpec = { ...disc, waves: [{ name: "layer", runs: [], discover: true, gate: "never", shares: 2 }] };
|
|
423
|
+
await opScenario("discover-wide", wide, { layers: [{ "a/1": { planDigest: digest("1") }, "a/2": { planDigest: digest("2") }, "a/3": { planDigest: digest("3") } }] }, [
|
|
424
|
+
{ wave: 1, decide: true },
|
|
425
|
+
{ wave: 1, share: 2 },
|
|
426
|
+
]);
|
|
427
|
+
const outcome = (said: (n: number) => unknown): Script["apply"] => (_argv, env, dir, n) => {
|
|
428
|
+
const s = said(n);
|
|
429
|
+
if (s) writeFileSync(join(dir, env.CHANT_APPLY_OUTCOME!), JSON.stringify(s));
|
|
430
|
+
return 0;
|
|
431
|
+
};
|
|
432
|
+
const one = [{ "a/vpc": { planDigest: digest("1") } }];
|
|
433
|
+
await opScenario("outcome-stood-down", disc, { layers: one, apply: outcome(() => ({ outcome: "stood-down", reason: "a newer push is on main" })) }, [{ wave: 1 }]);
|
|
434
|
+
await opScenario("outcome-held", disc, { layers: one, apply: outcome(() => ({ outcome: "held", holder: { run: "github:123", url: "https://example.test/run/123" } })) }, [{ wave: 1 }]);
|
|
435
|
+
await opScenario("outcome-unreadable", disc, { layers: one, apply: outcome(() => ({ outcome: "maybe" })) }, [{ wave: 1 }]);
|
|
436
|
+
await opScenario("outcome-replan", disc, { layers: one, apply: outcome((n) => (n === 1 ? { outcome: "replan", reason: "run 123 let go" } : undefined)) }, [{ wave: 1 }]);
|
|
437
|
+
await opScenario("outcome-replan-forever", disc, { layers: one, apply: outcome(() => ({ outcome: "replan" })) }, [{ wave: 1 }]);
|
|
438
|
+
const spend: OpWavesSpec = { ...disc, approvals: { spend: true }, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
|
|
439
|
+
const set = waveSetDigest([{ member: "a/vpc", planDigest: digest("1") }]);
|
|
440
|
+
await opScenario("outcome-replan-approved", spend, { layers: one, apply: outcome((n) => (n === 1 ? { outcome: "replan" } : undefined)) }, [
|
|
441
|
+
{ wave: 1, gates: memoryGateLedgerPort({ resolutions: [resolution("tf-apply", "wave-1", set)] }) },
|
|
442
|
+
]);
|
|
443
|
+
const priced: OpWavesSpec = { ...disc, waves: [{ name: "layer", runs: [], discover: true, gate: "always" }] };
|
|
444
|
+
await opScenario("cost-member", priced, { layers: [{ "a/vpc": { planDigest: digest("1") }, "a/idle": { planDigest: digest("2"), empty: true } }], extra: () => ({ members: [{ member: "cost", planDigest: "usd:120.00" }] }) }, [{ wave: 1 }]);
|
|
445
|
+
await opScenario("cost-member-clash", priced, { layers: [{ cost: { planDigest: digest("1") } }], extra: () => ({ members: [{ member: "cost", planDigest: "usd:1" }] }) }, [{ wave: 1 }]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a gate that asks a decision point (exit 3, 8, approved) and a point refused at base", async () => {
|
|
449
|
+
const spec: OpWavesSpec = {
|
|
450
|
+
name: "tf-apply",
|
|
451
|
+
op: "apply",
|
|
452
|
+
plan: ["tool", "plan", "{target}", "--out", "{plan}"],
|
|
453
|
+
apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
|
|
454
|
+
point: { name: "prod-apply", timeout: "48h" },
|
|
455
|
+
waves: [{ name: "prod", runs: [{ target: "db" }, { target: "net" }], gate: "always" }],
|
|
456
|
+
};
|
|
457
|
+
const asker = (answer?: Partial<GatePointQuestion>): GatePointAsker & { asked: GatePointRequest[] } => {
|
|
458
|
+
const asked: GatePointRequest[] = [];
|
|
459
|
+
return {
|
|
460
|
+
asked,
|
|
461
|
+
async ask(request) {
|
|
462
|
+
asked.push(request);
|
|
463
|
+
const id = `${request.point}-${(request.facts.planDigest ?? "none").slice(7, 19)}`;
|
|
464
|
+
return { id, point: request.point, path: `.chant/answers/${id}.md`, state: "escalated", answer: null, decider: "quorum", answeredBy: [], subject: request.subject, steward: null, ...(answer ?? {}) };
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
await opScenario("point", spec, {}, [
|
|
469
|
+
{ wave: 1, label: "open question", points: asker() },
|
|
470
|
+
{ wave: 1, label: "answered no", points: asker({ state: "answered", answer: false, answeredBy: ["bob"] }) },
|
|
471
|
+
{ wave: 1, label: "answered yes", points: asker({ state: "answered", answer: true, answeredBy: ["alice"] }) },
|
|
472
|
+
{ wave: 1, label: "point refused at base", points: asker(), atBase: { ...spec, point: { name: "" } } as OpWavesSpec },
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("pr-review and sealed approvals", async () => {
|
|
477
|
+
const spec: OpWavesSpec = { ...perRun, waves: perRun.waves.map((w) => (w.name === "staging" ? { ...w, approval: "pr-review" as const } : w)) };
|
|
478
|
+
const head = "h".repeat(40);
|
|
479
|
+
const reviews = (approvers: string[]) => ({
|
|
480
|
+
kind: "github" as const,
|
|
481
|
+
mergedReview: async () => ({ pr: 7, url: "https://example.test/pr/7", author: "github:dan", head, approvers, refused: [] }),
|
|
482
|
+
});
|
|
483
|
+
const headPlans = async (name: string) => ({ version: 1 as const, name, head, timestamp: BEFORE, waves: [{ wave: 2, name: "staging", digest: stagingSet }] });
|
|
484
|
+
const plans = { staging: { planDigest: digest("a"), destructive: true } };
|
|
485
|
+
await opScenario("pr-review", spec, { plans }, [
|
|
486
|
+
{ wave: 2, label: "approved by the review", head, reviews: reviews(["github:erin"]), headPlans },
|
|
487
|
+
{ wave: 2, label: "no approver", head, reviews: reviews([]), headPlans },
|
|
488
|
+
{ wave: 2, label: "spent review", spec: { ...spec, approvals: { spend: true } }, atBase: { ...spec, approvals: { spend: true } }, head, reviews: reviews(["github:erin"]), headPlans, gates: memoryGateLedgerPort({ applied: [{ version: 1, kind: "applied", op: "migrations", gate: "migrations-wave-2", planDigest: stagingSet, approvedAt: BEFORE, approvedBy: "github:erin", timestamp: BEFORE, review: { pr: 7, head } } as AppliedGateRecord] }) },
|
|
489
|
+
]);
|
|
490
|
+
const sealed: OpWavesSpec = { ...perRun, waves: perRun.waves.map((w) => (w.name === "staging" ? { ...w, approval: "sealed" as const } : w)) };
|
|
491
|
+
await opScenario("sealed", sealed, { plans }, [
|
|
492
|
+
{ wave: 2, label: "an unsealed approval does not count", trust: () => ({ base: "b".repeat(40), signersPath: ".chant/signers.json", active: true, signers: [], excluded: [], roles: {}, adopted: [], runners: [], excludedRunners: [], admitted: {}, problems: [] }), gates: memoryGateLedgerPort({ resolutions: [resolution("migrations", "migrations-wave-2", stagingSet)] }) },
|
|
493
|
+
]);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
/* ---------------------------------------------------------------- fan-out */
|
|
498
|
+
|
|
499
|
+
const fanRoot = (name: string, dependsOn?: string[], vars?: Record<string, unknown>): DriverComponent => ({
|
|
500
|
+
name,
|
|
501
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name, ...(vars ? { vars } : {}) }] }],
|
|
502
|
+
...(dependsOn ? { dependsOn } : {}),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/** net, then a and b; app reads a's output. */
|
|
506
|
+
const ESTATE: DriverComponent[] = [fanRoot("net"), fanRoot("a", ["net"]), fanRoot("b", ["net"]), fanRoot("app", ["a"], { upstream: { stackOutput: { stack: "a", name: "id" } } })];
|
|
507
|
+
|
|
508
|
+
type FanWorld = { failing?: string[]; failPlan?: string[]; sameRoot?: Record<string, string>; noMembers?: string[] };
|
|
509
|
+
|
|
510
|
+
function fanWorld(opts: FanWorld = {}) {
|
|
511
|
+
const registry = new CapabilityRegistry();
|
|
512
|
+
const applied: string[] = [];
|
|
513
|
+
registry.register({
|
|
514
|
+
kind: "fake-root",
|
|
515
|
+
async plan(_ctx: DeployContext, input: { root: string; vars?: Record<string, unknown> }) {
|
|
516
|
+
if (opts.failPlan?.includes(input.root)) throw new Error(`${input.root} cannot plan`);
|
|
517
|
+
const member = opts.sameRoot?.[input.root] ?? input.root;
|
|
518
|
+
const planDigest = `sha256:${Buffer.from(JSON.stringify(input)).toString("hex").slice(0, 64).padEnd(64, "0")}`;
|
|
519
|
+
return { member, planDigest, artifact: { planDigest } };
|
|
520
|
+
},
|
|
521
|
+
async run(_ctx: DeployContext, input: { root: string }) {
|
|
522
|
+
if (opts.failing?.includes(input.root)) throw new Error(`${input.root} failed`);
|
|
523
|
+
applied.push(input.root);
|
|
524
|
+
return { outputs: { id: `${input.root}-id` } };
|
|
525
|
+
},
|
|
526
|
+
} as never);
|
|
527
|
+
// A component that plans to no member at all (#3049: its wave's digest is empty).
|
|
528
|
+
const planComponent = opts.noMembers
|
|
529
|
+
? async (component: DriverComponent, ctx: DeployContext, outputs: Record<string, Record<string, unknown>>) =>
|
|
530
|
+
opts.noMembers!.includes(component.name) ? { component: component.name, members: [], plans: {}, parts: [] } : planWaveComponent(component, registry, ctx, outputs)
|
|
531
|
+
: undefined;
|
|
532
|
+
return { registry, applied, planComponent };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function fanRun(world: FanWorld, gates: Gates, options: Partial<FanOutRunOptions> = {}, waveGate: Partial<NonNullable<FanOutRunOptions["waveGate"]>> = {}, changed = ["net"]) {
|
|
536
|
+
const w = fanWorld(world);
|
|
537
|
+
const plan = planFanOut({ components: ESTATE, changed });
|
|
538
|
+
const run = runFanOut(plan, ESTATE, w.registry, {
|
|
539
|
+
env: "test",
|
|
540
|
+
gates,
|
|
541
|
+
now: NOW,
|
|
542
|
+
...options,
|
|
543
|
+
waveGate: { op: "fan-out", gate: "release", ...(w.planComponent ? { planComponent: w.planComponent } : {}), ...waveGate },
|
|
544
|
+
});
|
|
545
|
+
return { w, plan, run };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const scrubDurations = (value: unknown): unknown =>
|
|
549
|
+
JSON.parse(JSON.stringify(value ?? null, (key, v) => (key === "durationMs" || key === "startedAt" || key === "finishedAt" ? 0 : v)));
|
|
550
|
+
|
|
551
|
+
async function fanScenario(
|
|
552
|
+
name: string,
|
|
553
|
+
jobs: Array<{ label?: string; world?: FanWorld; changed?: string[]; gates?: Gates; options?: Partial<FanOutRunOptions>; waveGate?: Partial<NonNullable<FanOutRunOptions["waveGate"]>> }>,
|
|
554
|
+
): Promise<void> {
|
|
555
|
+
const out: unknown[] = [];
|
|
556
|
+
for (const job of jobs) {
|
|
557
|
+
const gates = job.gates ?? memoryGateLedgerPort();
|
|
558
|
+
const events: RunProgressEvent[] = [];
|
|
559
|
+
const settled: WaveRecord[] = [];
|
|
560
|
+
const components: Array<{ component: string; outputs: unknown }> = [];
|
|
561
|
+
const { w, plan, run } = fanRun(
|
|
562
|
+
job.world ?? {},
|
|
563
|
+
gates,
|
|
564
|
+
{
|
|
565
|
+
onProgress: (e) => events.push(e),
|
|
566
|
+
onWaveSettled: (r) => settled.push(r),
|
|
567
|
+
onComponentSettled: (r, outputs) => components.push({ component: r.component, outputs }),
|
|
568
|
+
...job.options,
|
|
569
|
+
},
|
|
570
|
+
job.waveGate,
|
|
571
|
+
job.changed,
|
|
572
|
+
);
|
|
573
|
+
let result: unknown;
|
|
574
|
+
try {
|
|
575
|
+
result = await run;
|
|
576
|
+
} catch (err) {
|
|
577
|
+
result = { threw: err instanceof Error ? `${err.name}: ${err.message}` : String(err) };
|
|
578
|
+
}
|
|
579
|
+
out.push({ ...(job.label ? { label: job.label } : {}), plan, result, events, settled, components, applied: w.applied, ledger: ledgerOf(gates) });
|
|
580
|
+
}
|
|
581
|
+
await expect(JSON.stringify(scrubDurations(out), null, 2) + "\n").toMatchFileSnapshot(`${GOLDEN}/fan-out-${name}.json`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
describe("fan-out --wave-gate golden transcripts (#3766)", () => {
|
|
585
|
+
const fanResolution = (gate: string, planDigest: string): GateResolutionRecord => ({ version: 1, op: "fan-out", gate, resolvedBy: "ana", timestamp: BEFORE, planDigest });
|
|
586
|
+
|
|
587
|
+
test("waves gate in turn; an approval lets one through; another digest's approval does not", async () => {
|
|
588
|
+
const first = (await fanRun({}, memoryGateLedgerPort()).run).waves![0]!.digest;
|
|
589
|
+
await fanScenario("gated", [
|
|
590
|
+
{ label: "wave 1 waits" },
|
|
591
|
+
{ label: "wave 1 approved, wave 2 waits", gates: memoryGateLedgerPort({ resolutions: [fanResolution("release-wave-1", first)] }) },
|
|
592
|
+
{ label: "another digest approved", gates: memoryGateLedgerPort({ resolutions: [fanResolution("release-wave-1", digest("9"))] }) },
|
|
593
|
+
{ label: "with a description and timeout", waveGate: { description: "the release", timeout: "2h" } },
|
|
594
|
+
]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("every wave approved; a failure blocks its dependents; a plan failure; two components planning one root", async () => {
|
|
598
|
+
const approveAll = async (world: FanWorld = {}) => {
|
|
599
|
+
// Approve whatever each wave asks for, one wave at a time.
|
|
600
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
601
|
+
for (let i = 0; i < 4; i++) {
|
|
602
|
+
const r = await fanRun(world, memoryGateLedgerPort({ resolutions })).run;
|
|
603
|
+
if (!r.gate) break;
|
|
604
|
+
resolutions.push(fanResolution(r.gate.gate, r.gate.planDigest!));
|
|
605
|
+
}
|
|
606
|
+
return memoryGateLedgerPort({ resolutions });
|
|
607
|
+
};
|
|
608
|
+
await fanScenario("applied", [{ label: "all approved", gates: await approveAll() }]);
|
|
609
|
+
await fanScenario("apply-fails", [{ label: "a fails", world: { failing: ["a"] }, gates: await approveAll({ failing: ["a"] }) }]);
|
|
610
|
+
await fanScenario("plan-fails", [{ label: "a cannot plan", world: { failPlan: ["a"] }, gates: await approveAll({ failPlan: ["a"] }) }]);
|
|
611
|
+
await fanScenario("same-root", [{ label: "a and b plan one root", world: { sameRoot: { a: "shared", b: "shared" } }, gates: await approveAll({ sameRoot: { a: "shared", b: "shared" } }) }]);
|
|
612
|
+
await fanScenario("no-members", [
|
|
613
|
+
{ label: "b plans nothing", world: { noMembers: ["b"] }, gates: await approveAll({ noMembers: ["b"] }) },
|
|
614
|
+
{ label: "a wave where nothing planned asks its gate with an empty digest", world: { noMembers: ["net"] } },
|
|
615
|
+
{ label: "approved at the empty digest", world: { noMembers: ["net"] }, gates: memoryGateLedgerPort({ resolutions: [fanResolution("release-wave-1", "")] }) },
|
|
616
|
+
]);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("one job per wave (only) and a resumed attempt", async () => {
|
|
620
|
+
await fanScenario("only", [
|
|
621
|
+
{ label: "only wave 2 with wave 1 left", waveGate: { only: 2 } },
|
|
622
|
+
{ label: "only wave 1", waveGate: { only: 1 } },
|
|
623
|
+
{ label: "only wave 2 after wave 1 completed", waveGate: { only: 2 }, options: { progress: { completed: ["net"] } } },
|
|
624
|
+
{ label: "resume past a failure", options: { progress: { completed: ["net"], failed: ["a"] } } },
|
|
625
|
+
]);
|
|
626
|
+
});
|
|
627
|
+
});
|