@intentius/chant 0.118.0 → 0.118.1
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.
|
@@ -19,7 +19,7 @@ export interface LifecycleDiffArgs {
|
|
|
19
19
|
export interface LifecycleDiffResult {
|
|
20
20
|
/** Combined stdout + stderr from the chant command. */
|
|
21
21
|
output: string;
|
|
22
|
-
/** Process exit code
|
|
22
|
+
/** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
|
|
23
23
|
exitCode: number;
|
|
24
24
|
/**
|
|
25
25
|
* True when the diff output contains any drift indicators
|
|
@@ -73,6 +73,11 @@ export declare function lifecycleDiffDigest(args: LifecycleDiffArgs, output: str
|
|
|
73
73
|
* MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
|
|
74
74
|
* cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
|
|
75
75
|
* on a WatchOp activity step to surface drift as the run's `Drift` outcome.
|
|
76
|
+
*
|
|
77
|
+
* A diff that exits non-zero (#3712) fails the step and yields no digest.
|
|
78
|
+
* Its output is an error, not a change set: every failed run printed the
|
|
79
|
+
* same lines, so a digest over them was one an approval matched whatever
|
|
80
|
+
* the declarations had become since.
|
|
76
81
|
*/
|
|
77
82
|
export declare function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult>;
|
|
78
83
|
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/op/activities/lifecycle.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAIxG;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/op/activities/lifecycle.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAIxG;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;OASG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM/D;AAmBD,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAMnF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiB/G"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3712: a `chant lifecycle diff` that fails yields no digest, so an approval
|
|
3
|
+
* cannot pass a gate for a plan nobody read.
|
|
4
|
+
*
|
|
5
|
+
* Before the fix the activity caught the diff's non-zero exit and digested
|
|
6
|
+
* its error output. Every run whose build failed printed the same lines, so
|
|
7
|
+
* an approval of one such run's digest matched the next, whatever the
|
|
8
|
+
* declarations had become, and the Apply phase (which builds on its own)
|
|
9
|
+
* applied the change.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
/** What the next `chant lifecycle diff` prints and how it exits. */
|
|
15
|
+
const next = { stdout: "", stderr: "", code: 0 };
|
|
16
|
+
|
|
17
|
+
vi.mock(import("node:child_process"), async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal()),
|
|
19
|
+
exec: ((_command: string, _options: unknown, callback: (err: unknown, out?: { stdout: string; stderr: string }) => void) => {
|
|
20
|
+
if (next.code === 0) callback(null, { stdout: next.stdout, stderr: next.stderr });
|
|
21
|
+
else callback(Object.assign(new Error("exit"), { code: next.code, stdout: next.stdout, stderr: next.stderr }));
|
|
22
|
+
}) as never,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { lifecycleDiff, lifecycleDiffDigest } = await import("./lifecycle");
|
|
26
|
+
const { OpRunFailure, runOpLocally } = await import("../local-executor");
|
|
27
|
+
const { stepOutput } = await import("../step-output-ref");
|
|
28
|
+
type ActivityFn = import("../activity-registry").ActivityFn;
|
|
29
|
+
type ActivityProfile = import("../activity-registry").ActivityProfile;
|
|
30
|
+
type GateLedgerPort = import("../gate").GateLedgerPort;
|
|
31
|
+
type GateResolutionRecord = import("../../lifecycle/gate-ledger").GateResolutionRecord;
|
|
32
|
+
type PendingGateRecord = import("../../lifecycle/gate-ledger").PendingGateRecord;
|
|
33
|
+
type OpConfig = import("../types").OpConfig;
|
|
34
|
+
|
|
35
|
+
const PROFILES: Record<string, ActivityProfile> = {
|
|
36
|
+
fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const BUILD_FAILED = "error: Build failed for project — fix errors before diffing";
|
|
40
|
+
const ARGS = { env: "dev", live: true };
|
|
41
|
+
|
|
42
|
+
function failDiff(): void {
|
|
43
|
+
Object.assign(next, { stdout: "", stderr: BUILD_FAILED, code: 1 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function diffPrints(render: string): void {
|
|
47
|
+
Object.assign(next, { stdout: render, stderr: "", code: 0 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
diffPrints("");
|
|
52
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
53
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("lifecycleDiff on a failed diff (#3712)", () => {
|
|
57
|
+
test("a build error rejects, naming the command and the error, with no digest", async () => {
|
|
58
|
+
failDiff();
|
|
59
|
+
await expect(lifecycleDiff(ARGS)).rejects.toThrow(/chant lifecycle diff dev --live failed \(exit 1\); no plan to digest:\n.*Build failed for project/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a diff that succeeds still returns its digest", async () => {
|
|
63
|
+
diffPrints("MISSING (declared, provider reports not in cloud):\n - events");
|
|
64
|
+
const result = await lifecycleDiff(ARGS);
|
|
65
|
+
expect(result.exitCode).toBe(0);
|
|
66
|
+
expect(result.drifted).toBe(true);
|
|
67
|
+
expect(result.planDigest).toBe(lifecycleDiffDigest(ARGS, "MISSING (declared, provider reports not in cloud):\n - events"));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("ApplyOp's gate over a diff whose build fails (#3712)", () => {
|
|
72
|
+
function ledger(): GateLedgerPort & { approve(planDigest: string): void } {
|
|
73
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
74
|
+
const pending: PendingGateRecord[] = [];
|
|
75
|
+
return {
|
|
76
|
+
approve(planDigest) {
|
|
77
|
+
resolutions.push({ version: 1, op: "apply-dev", gate: "approve-apply-dev", resolvedBy: "alex", timestamp: "2026-10-10T12:05:00.000Z", planDigest });
|
|
78
|
+
},
|
|
79
|
+
async read() {
|
|
80
|
+
return { resolutions: [...resolutions], pending: [...pending] };
|
|
81
|
+
},
|
|
82
|
+
async appendPending(input) {
|
|
83
|
+
const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
|
|
84
|
+
pending.push(record);
|
|
85
|
+
return { record, pushed: true };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const statuses = (failure: unknown) =>
|
|
91
|
+
(failure as InstanceType<typeof OpRunFailure>).result.records.map((r) => [r.fn, r.status]);
|
|
92
|
+
|
|
93
|
+
/** ApplyOp's Plan, Approve and Apply phases, with the real `lifecycleDiff` and an apply that records what it applied. */
|
|
94
|
+
function harness() {
|
|
95
|
+
const declared = { schema: "A" };
|
|
96
|
+
const applied: string[] = [];
|
|
97
|
+
const activities = new Map<string, ActivityFn>([
|
|
98
|
+
["lifecycleDiff", (args, signal) => lifecycleDiff(args as { env: string; live?: boolean }, signal)],
|
|
99
|
+
["nativeApply", async () => {
|
|
100
|
+
applied.push(declared.schema);
|
|
101
|
+
}],
|
|
102
|
+
]);
|
|
103
|
+
const config: OpConfig = {
|
|
104
|
+
name: "apply-dev",
|
|
105
|
+
overview: "",
|
|
106
|
+
phases: [
|
|
107
|
+
{ name: "Plan", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "plan", args: ARGS }] },
|
|
108
|
+
{ name: "Approve", steps: [{ kind: "gate", gate: "approve-apply-dev", plan: stepOutput("plan", "planDigest") }] },
|
|
109
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply" }] },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
return { declared, applied, activities, config };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("an approval of plan A does not pass for plan B when the diff's build fails both times", async () => {
|
|
116
|
+
const { declared, applied, activities, config } = harness();
|
|
117
|
+
const gates = ledger();
|
|
118
|
+
|
|
119
|
+
// Plan A: the diff's build fails. The run stops at Plan: nothing reaches the gate.
|
|
120
|
+
failDiff();
|
|
121
|
+
const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" }).catch((e: unknown) => e);
|
|
122
|
+
expect(first).toBeInstanceOf(OpRunFailure);
|
|
123
|
+
expect(statuses(first)).toEqual([["lifecycleDiff", "fail"]]);
|
|
124
|
+
expect(applied).toEqual([]);
|
|
125
|
+
|
|
126
|
+
// The digest the diff's error output used to give: someone approves it.
|
|
127
|
+
gates.approve(lifecycleDiffDigest(ARGS, BUILD_FAILED));
|
|
128
|
+
|
|
129
|
+
// Plan B: the declarations change, and the diff's build still fails.
|
|
130
|
+
declared.schema = "B";
|
|
131
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:10:00.000Z" }).catch((e: unknown) => e);
|
|
132
|
+
expect(second).toBeInstanceOf(OpRunFailure);
|
|
133
|
+
expect(statuses(second)).toEqual([["lifecycleDiff", "fail"]]);
|
|
134
|
+
expect(applied).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("with the build fixed, the run stops at the gate for the plan it read", async () => {
|
|
138
|
+
const { applied, activities, config } = harness();
|
|
139
|
+
const gates = ledger();
|
|
140
|
+
const render = "MISSING (declared, provider reports not in cloud):\n - events";
|
|
141
|
+
diffPrints(render);
|
|
142
|
+
const run = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" });
|
|
143
|
+
expect(run.status).toBe("gated");
|
|
144
|
+
expect(run.gate?.planDigest).toBe(lifecycleDiffDigest(ARGS, render));
|
|
145
|
+
expect(applied).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -32,7 +32,7 @@ export interface LifecycleDiffArgs {
|
|
|
32
32
|
export interface LifecycleDiffResult {
|
|
33
33
|
/** Combined stdout + stderr from the chant command. */
|
|
34
34
|
output: string;
|
|
35
|
-
/** Process exit code
|
|
35
|
+
/** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
|
|
36
36
|
exitCode: number;
|
|
37
37
|
/**
|
|
38
38
|
* True when the diff output contains any drift indicators
|
|
@@ -121,18 +121,27 @@ export function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): st
|
|
|
121
121
|
* MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
|
|
122
122
|
* cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
|
|
123
123
|
* on a WatchOp activity step to surface drift as the run's `Drift` outcome.
|
|
124
|
+
*
|
|
125
|
+
* A diff that exits non-zero (#3712) fails the step and yields no digest.
|
|
126
|
+
* Its output is an error, not a change set: every failed run printed the
|
|
127
|
+
* same lines, so a digest over them was one an approval matched whatever
|
|
128
|
+
* the declarations had become since.
|
|
124
129
|
*/
|
|
125
130
|
export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult> {
|
|
126
131
|
const liveFlag = args.live ? " --live" : "";
|
|
132
|
+
const command = `chant lifecycle diff ${args.env}${liveFlag}`;
|
|
133
|
+
let stdout: string;
|
|
134
|
+
let stderr: string;
|
|
127
135
|
try {
|
|
128
|
-
|
|
129
|
-
const output = `${stdout}${stderr}`.trim();
|
|
130
|
-
if (output) console.log(output);
|
|
131
|
-
return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
136
|
+
({ stdout, stderr } = await execAsync(command, { signal }));
|
|
132
137
|
} catch (err) {
|
|
133
138
|
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
134
139
|
const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
|
135
140
|
if (output) console.error(output);
|
|
136
|
-
|
|
141
|
+
const message = `${command} failed (exit ${String(e.code ?? 1)}); no plan to digest${output ? `:\n${output}` : ""}`;
|
|
142
|
+
throw new Error(message);
|
|
137
143
|
}
|
|
144
|
+
const output = `${stdout}${stderr}`.trim();
|
|
145
|
+
if (output) console.log(output);
|
|
146
|
+
return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
138
147
|
}
|