@intentius/chant 0.78.0 → 0.80.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/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +18 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +118 -2
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +78 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +18 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/builders.ts +10 -1
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +260 -0
- package/src/op/gate.ts +148 -10
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- package/src/op/runtimes/local.ts +2 -1
- package/src/op/types.ts +7 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -17,6 +17,7 @@ const readGateLedgerMock = vi.fn();
|
|
|
17
17
|
const readRunLedgerMock = vi.fn();
|
|
18
18
|
const pushLifecycleMock = vi.fn();
|
|
19
19
|
const requireLifecycleLedgerMock = vi.fn();
|
|
20
|
+
const loadGatePolicyEvaluatorMock = vi.fn();
|
|
20
21
|
|
|
21
22
|
vi.mock("../../op/operator", async () => {
|
|
22
23
|
const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
|
|
@@ -69,6 +70,11 @@ vi.mock("../../lifecycle/git", async () => {
|
|
|
69
70
|
};
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
vi.mock("../../op/gate-approval", async () => {
|
|
74
|
+
const actual = await vi.importActual<typeof import("../../op/gate-approval")>("../../op/gate-approval");
|
|
75
|
+
return { ...actual, loadGatePolicyEvaluator: (...args: unknown[]) => loadGatePolicyEvaluatorMock(...args) };
|
|
76
|
+
});
|
|
77
|
+
|
|
72
78
|
// Imported after the mocks above are registered.
|
|
73
79
|
const { runOperator, runOperatorStatus, runOperatorLog, runApprove } = await import("./operator");
|
|
74
80
|
|
|
@@ -979,3 +985,82 @@ describe("runApprove — a push that does not land is reported (#2309 review)",
|
|
|
979
985
|
errSpy.mockRestore();
|
|
980
986
|
});
|
|
981
987
|
});
|
|
988
|
+
|
|
989
|
+
describe("runApprove — a gate with an approval policy (#2508)", () => {
|
|
990
|
+
const TEXT = "permit (principal is Chant::Agent, action, resource);\n";
|
|
991
|
+
const POLICY = { kind: "gate-policy" as const, lexicon: "cedar", name: "ship", version: "sha256:v1", text: TEXT };
|
|
992
|
+
|
|
993
|
+
function seedPolicyGate(mode: "log-only" | "enforce"): void {
|
|
994
|
+
readGateLedgerMock.mockResolvedValue({
|
|
995
|
+
resolutions: [],
|
|
996
|
+
pending: [{
|
|
997
|
+
version: 1, kind: "pending", op: "fountain-apply", gate: "rollout-gate",
|
|
998
|
+
timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", planDigest: PLAN_A,
|
|
999
|
+
approval: { quorum: { count: 2, roles: ["maintainer"] }, policy: POLICY, mode, context: { risk: "low" } },
|
|
1000
|
+
}],
|
|
1001
|
+
malformed: 0,
|
|
1002
|
+
});
|
|
1003
|
+
appendGateResolutionMock.mockImplementation(async (input: Record<string, unknown>) => ({
|
|
1004
|
+
commit: "sha",
|
|
1005
|
+
record: { version: 1, ...input },
|
|
1006
|
+
}));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
test("evaluates the policy for an agent's approval and records the decision next to it", async () => {
|
|
1010
|
+
seedPolicyGate("enforce");
|
|
1011
|
+
const evaluateGatePolicy = vi.fn().mockResolvedValue({ decision: "allow", determining: ["agent-low-risk"], errors: [] });
|
|
1012
|
+
loadGatePolicyEvaluatorMock.mockResolvedValue({ evaluateGatePolicy });
|
|
1013
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1014
|
+
|
|
1015
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "release-bot", agent: true }))).toBe(0);
|
|
1016
|
+
|
|
1017
|
+
expect(loadGatePolicyEvaluatorMock).toHaveBeenCalledWith("cedar");
|
|
1018
|
+
expect(evaluateGatePolicy).toHaveBeenCalledWith(POLICY, {
|
|
1019
|
+
principal: { kind: "agent", name: "release-bot", roles: [] },
|
|
1020
|
+
action: "PassGate",
|
|
1021
|
+
resource: { op: "fountain-apply", gate: "rollout-gate" },
|
|
1022
|
+
context: { risk: "low", planDigest: PLAN_A },
|
|
1023
|
+
});
|
|
1024
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
1025
|
+
approver: { kind: "agent" },
|
|
1026
|
+
policyDecision: {
|
|
1027
|
+
policy: "ship", version: "sha256:v1", mode: "enforce",
|
|
1028
|
+
decision: "allow", determining: ["agent-low-risk"], errors: [],
|
|
1029
|
+
},
|
|
1030
|
+
}));
|
|
1031
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1032
|
+
expect(out).toContain("In enforce mode this permit passes the gate on its own");
|
|
1033
|
+
expect(out).toContain("An agent's approval does not count toward the quorum");
|
|
1034
|
+
errSpy.mockRestore();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("records a human's roles, and reports quorum progress with a log-only decision", async () => {
|
|
1038
|
+
seedPolicyGate("log-only");
|
|
1039
|
+
loadGatePolicyEvaluatorMock.mockResolvedValue({
|
|
1040
|
+
evaluateGatePolicy: async () => ({ decision: "deny", determining: [], errors: [] }),
|
|
1041
|
+
});
|
|
1042
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1043
|
+
|
|
1044
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", roles: ["maintainer"] }))).toBe(0);
|
|
1045
|
+
|
|
1046
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
1047
|
+
approver: { kind: "human", roles: ["maintainer"] },
|
|
1048
|
+
}));
|
|
1049
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1050
|
+
expect(out).toContain("log-only mode, so the decision is recorded and does not change the outcome");
|
|
1051
|
+
expect(out).toContain("Quorum: 1 of 2 human approval(s) with role maintainer for this plan (alex)");
|
|
1052
|
+
errSpy.mockRestore();
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("refuses, and writes nothing, when the policy cannot be evaluated", async () => {
|
|
1056
|
+
seedPolicyGate("enforce");
|
|
1057
|
+
loadGatePolicyEvaluatorMock.mockRejectedValue(new Error("@intentius/chant-lexicon-cedar/gate-policy could not be loaded"));
|
|
1058
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1059
|
+
|
|
1060
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(1);
|
|
1061
|
+
|
|
1062
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
1063
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('declares policy "ship", which could not be evaluated');
|
|
1064
|
+
errSpy.mockRestore();
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `run.ts`'s `runOpLocal` shape (SIGINT → AbortController, `loadActivities`/
|
|
7
7
|
* `loadProfiles` from the project's configured lexicons).
|
|
8
8
|
*/
|
|
9
|
+
import { lexiconNames } from "../../lexicon-module";
|
|
9
10
|
import { loadChantConfig } from "../../config";
|
|
10
11
|
import { build } from "../../build";
|
|
11
12
|
import { isResourceDeclarable } from "../../declarable";
|
|
@@ -38,6 +39,11 @@ import {
|
|
|
38
39
|
resolveApprovalUrl, isApprovalUrl,
|
|
39
40
|
} from "../../lifecycle/gate-ledger";
|
|
40
41
|
import { isPlanDigest } from "../../lifecycle/plan-digest";
|
|
42
|
+
import {
|
|
43
|
+
gatePolicyRequest, loadGatePolicyEvaluator,
|
|
44
|
+
type GateApprover, type GatePolicyDecision, type GatePolicyEvaluator, type ResolvedGateApproval,
|
|
45
|
+
} from "../../op/gate-approval";
|
|
46
|
+
import { approverOf, tallyGateApprovals } from "../../op/gate";
|
|
41
47
|
import { FAN_OUT_GATE_OP } from "../../op/gate-name";
|
|
42
48
|
import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
|
|
43
49
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
@@ -47,7 +53,7 @@ import type { CommandContext } from "../registry";
|
|
|
47
53
|
async function loadOperatorActivities() {
|
|
48
54
|
let lexicons: string[] = [];
|
|
49
55
|
try {
|
|
50
|
-
lexicons = (await loadChantConfig(process.cwd())).config.lexicons ?? [];
|
|
56
|
+
lexicons = lexiconNames((await loadChantConfig(process.cwd())).config.lexicons ?? []);
|
|
51
57
|
} catch {
|
|
52
58
|
// No/invalid chant.config — fall back to base activities only.
|
|
53
59
|
}
|
|
@@ -698,6 +704,8 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
698
704
|
url: ctx.args.url,
|
|
699
705
|
plan: ctx.args.plan,
|
|
700
706
|
allowSameOrigin: ctx.args.allowSameOrigin,
|
|
707
|
+
...(ctx.args.roles ? { roles: ctx.args.roles } : {}),
|
|
708
|
+
...(ctx.args.agent ? { agent: true } : {}),
|
|
701
709
|
});
|
|
702
710
|
if (!outcome.ok) return 1;
|
|
703
711
|
|
|
@@ -739,6 +747,15 @@ export interface GateApprovalOptions {
|
|
|
739
747
|
* stays one command: run, read what it planned, approve it.
|
|
740
748
|
*/
|
|
741
749
|
plan?: string;
|
|
750
|
+
/** `--role` (#2508) — roles the approver claims. */
|
|
751
|
+
roles?: string[];
|
|
752
|
+
/**
|
|
753
|
+
* `--agent` (#2508) — record this as an agent's approval. On a
|
|
754
|
+
* model-authored channel it is one whether or not this is set.
|
|
755
|
+
*/
|
|
756
|
+
agent?: boolean;
|
|
757
|
+
/** Replaces the lexicon evaluator a gate policy is loaded from. For tests. */
|
|
758
|
+
evaluator?: GatePolicyEvaluator;
|
|
742
759
|
}
|
|
743
760
|
|
|
744
761
|
export type GateApprovalOutcome =
|
|
@@ -841,7 +858,8 @@ export async function recordGateApproval(
|
|
|
841
858
|
// resolved over the same one has no second party in it. Refused by default,
|
|
842
859
|
// the way #2300 refuses a plan nobody approved.
|
|
843
860
|
const origin = opts.origin ?? currentGateOrigin();
|
|
844
|
-
const
|
|
861
|
+
const ledger = await readGateLedger(opName);
|
|
862
|
+
const standingForOrigin = latestPendingGate(ledger.pending, gate);
|
|
845
863
|
const refusal = sameOriginRefusal(standingForOrigin?.origin, origin);
|
|
846
864
|
if (refusal && !opts.allowSameOrigin) {
|
|
847
865
|
console.error(formatError({
|
|
@@ -862,11 +880,58 @@ export async function recordGateApproval(
|
|
|
862
880
|
? UNATTESTED_APPROVER
|
|
863
881
|
: process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown");
|
|
864
882
|
|
|
883
|
+
// #2508: who this is, as the gate's quorum and policy read it. A model on
|
|
884
|
+
// MCP or ACP is an agent whatever it claims, the same reasoning that gives
|
|
885
|
+
// it `UNATTESTED_APPROVER` for a name.
|
|
886
|
+
const approver: GateApprover = {
|
|
887
|
+
kind: opts.agent || isModelAuthored(origin) ? "agent" : "human",
|
|
888
|
+
...(opts.roles && opts.roles.length > 0 ? { roles: [...new Set(opts.roles)] } : {}),
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// #2508: a gate with a policy has it evaluated for this approval now, while
|
|
892
|
+
// the approver is known, against the context the gated run recorded. The
|
|
893
|
+
// decision is written next to the approval, so a pass can be traced to the
|
|
894
|
+
// rule that allowed it.
|
|
895
|
+
const approval = standingForOrigin?.approval;
|
|
896
|
+
let policyDecision: GatePolicyDecision | undefined;
|
|
897
|
+
if (approval?.policy) {
|
|
898
|
+
try {
|
|
899
|
+
const evaluator = opts.evaluator ?? (await loadGatePolicyEvaluator(approval.policy.lexicon));
|
|
900
|
+
const answer = await evaluator.evaluateGatePolicy(
|
|
901
|
+
approval.policy,
|
|
902
|
+
gatePolicyRequest({
|
|
903
|
+
op: opName,
|
|
904
|
+
gate,
|
|
905
|
+
resolvedBy,
|
|
906
|
+
approver,
|
|
907
|
+
...(planDigest !== undefined ? { planDigest } : {}),
|
|
908
|
+
...(approval.context ? { context: approval.context } : {}),
|
|
909
|
+
}),
|
|
910
|
+
);
|
|
911
|
+
policyDecision = {
|
|
912
|
+
policy: approval.policy.name,
|
|
913
|
+
version: approval.policy.version,
|
|
914
|
+
mode: approval.mode,
|
|
915
|
+
decision: answer.decision,
|
|
916
|
+
determining: answer.determining,
|
|
917
|
+
errors: answer.errors,
|
|
918
|
+
};
|
|
919
|
+
} catch (err) {
|
|
920
|
+
console.error(formatError({
|
|
921
|
+
message: `Gate "${gate}" on "${opName}" declares policy "${approval.policy.name}", which could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
|
|
922
|
+
hint: `Install the ${approval.policy.lexicon} lexicon in this project, or approve from a checkout that has it.`,
|
|
923
|
+
}));
|
|
924
|
+
return { ok: false };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
865
928
|
const { record } = await appendGateResolution({
|
|
866
929
|
op: opName,
|
|
867
930
|
gate,
|
|
868
931
|
resolvedBy,
|
|
869
932
|
timestamp: new Date().toISOString(),
|
|
933
|
+
approver,
|
|
934
|
+
...(policyDecision ? { policyDecision } : {}),
|
|
870
935
|
...(opts.note ? { note: opts.note } : {}),
|
|
871
936
|
...(url ? { url } : {}),
|
|
872
937
|
...(planDigest !== undefined ? { planDigest } : {}),
|
|
@@ -889,5 +954,56 @@ export async function recordGateApproval(
|
|
|
889
954
|
"differs refuses rather than applying it.",
|
|
890
955
|
));
|
|
891
956
|
}
|
|
957
|
+
if (approval) {
|
|
958
|
+
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin)) {
|
|
959
|
+
console.error(formatInfo(line));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
892
962
|
return { ok: true, record };
|
|
893
963
|
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* What a just-recorded approval did to a gate with an `approval` block
|
|
967
|
+
* (#2508): the policy's decision and whether it binds, then the quorum count
|
|
968
|
+
* for this plan. The run decides the gate from the ledger; these lines only
|
|
969
|
+
* say what it will find.
|
|
970
|
+
*/
|
|
971
|
+
export function describeApprovalProgress(
|
|
972
|
+
approval: ResolvedGateApproval,
|
|
973
|
+
record: GateResolutionRecord,
|
|
974
|
+
prior: GateResolutionRecord[],
|
|
975
|
+
standing: PendingGateRecord | undefined,
|
|
976
|
+
): string[] {
|
|
977
|
+
const lines: string[] = [];
|
|
978
|
+
const decision = record.policyDecision;
|
|
979
|
+
if (decision) {
|
|
980
|
+
const rules = decision.determining.length > 0 ? ` (${decision.determining.join(", ")})` : "";
|
|
981
|
+
const binds =
|
|
982
|
+
approval.mode === "enforce"
|
|
983
|
+
? decision.decision === "allow"
|
|
984
|
+
? "In enforce mode this permit passes the gate on its own."
|
|
985
|
+
: "In enforce mode only a permit passes the gate on its own, so this approval counts only toward the quorum."
|
|
986
|
+
: "The gate is in log-only mode, so the decision is recorded and does not change the outcome.";
|
|
987
|
+
lines.push(`Policy "${decision.policy}" ${decision.version}: ${decision.decision}${rules}. ${binds}`);
|
|
988
|
+
for (const error of decision.errors) lines.push(`Policy evaluation error: ${error}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const tally = tallyGateApprovals(
|
|
992
|
+
[...prior, record],
|
|
993
|
+
record.gate,
|
|
994
|
+
standing?.timestamp ?? new Date(0).toISOString(),
|
|
995
|
+
record.planDigest,
|
|
996
|
+
approval,
|
|
997
|
+
);
|
|
998
|
+
const roles = approval.quorum?.roles ? ` with role ${approval.quorum.roles.join(" or ")}` : "";
|
|
999
|
+
const who = tally.counted.map((r) => r.resolvedBy).join(", ") || "none yet";
|
|
1000
|
+
lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
|
|
1001
|
+
if (!tally.counted.includes(record)) {
|
|
1002
|
+
lines.push(
|
|
1003
|
+
approverOf(record).kind === "agent"
|
|
1004
|
+
? "An agent's approval does not count toward the quorum."
|
|
1005
|
+
: `This approval does not count toward the quorum: the approver claims none of its roles (${approval.quorum?.roles?.join(", ")}).`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
return lines;
|
|
1009
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components promote` (#2530), with the ledger, discovery and the
|
|
3
|
+
* capability registry replaced by fakes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { CapabilityRegistry, type DeployContext } from "../../components/capability";
|
|
8
|
+
import type { DriverComponent } from "../../components/driver";
|
|
9
|
+
import type { ReleaseRecord } from "../../lifecycle/release-ledger";
|
|
10
|
+
import type { ParsedArgs } from "../registry";
|
|
11
|
+
|
|
12
|
+
const appendReleaseRecordMock = vi.fn();
|
|
13
|
+
const readReleaseLedgerMock = vi.fn();
|
|
14
|
+
const pushLifecycleMock = vi.fn();
|
|
15
|
+
const resolveTargetsMock = vi.fn();
|
|
16
|
+
const ran: string[] = [];
|
|
17
|
+
let publishedDigest = "sha256:api";
|
|
18
|
+
let failApplyFor: string | undefined;
|
|
19
|
+
|
|
20
|
+
vi.mock("../../lifecycle/git", () => ({
|
|
21
|
+
getHeadCommit: async () => "abc123",
|
|
22
|
+
fetchLifecycle: async () => true,
|
|
23
|
+
pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../../lifecycle/release-ledger", async () => {
|
|
27
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/release-ledger")>("../../lifecycle/release-ledger");
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
appendReleaseRecord: (...args: unknown[]) => appendReleaseRecordMock(...args),
|
|
31
|
+
readReleaseLedger: (...args: unknown[]) => readReleaseLedgerMock(...args),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock("../../config", async () => {
|
|
36
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
37
|
+
return { ...actual, loadChantConfig: async () => ({ config: {} }) };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock("../../components/cli-support", () => ({
|
|
41
|
+
resolveComponentTargets: (...args: unknown[]) => resolveTargetsMock(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("../../components/fan-out-support", () => ({
|
|
45
|
+
fanOutRegistry: async () => {
|
|
46
|
+
const registry = new CapabilityRegistry();
|
|
47
|
+
const step = (kind: string, output: (ctx: DeployContext) => unknown) =>
|
|
48
|
+
registry.register({ kind, async run(ctx: DeployContext) { ran.push(`${ctx.env}:${kind}`); return output(ctx); } } as never);
|
|
49
|
+
step("docker-build", () => ({ digest: "sha256:rebuilt" }));
|
|
50
|
+
step("publish-image", () => ({ digest: publishedDigest, uri: `reg/api@${publishedDigest}` }));
|
|
51
|
+
step("apply", (ctx) => {
|
|
52
|
+
if (ctx.component === failApplyFor) throw new Error("apply failed");
|
|
53
|
+
return {};
|
|
54
|
+
});
|
|
55
|
+
return registry;
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// Gates read an in-memory ledger rather than the git branch.
|
|
60
|
+
vi.mock("../../op/gate", async () => {
|
|
61
|
+
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
62
|
+
const port = actual.memoryGateLedgerPort();
|
|
63
|
+
return { ...actual, gitGateLedgerPort: () => port };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The run handler pulls in every runtime; the promote needs only its exit code.
|
|
67
|
+
vi.mock("./run", () => ({ GATED_EXIT_CODE: 3 }));
|
|
68
|
+
|
|
69
|
+
const { runComponentsPromote, runComponentsRollback } = await import("./promote");
|
|
70
|
+
|
|
71
|
+
const api: DriverComponent = {
|
|
72
|
+
name: "api",
|
|
73
|
+
deploy: [
|
|
74
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "api.tar" }] },
|
|
75
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:api.tar" }] },
|
|
76
|
+
{ phase: "Apply", steps: [{ kind: "apply", imageRef: "@Publish.digest" }] },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const staging: ReleaseRecord = {
|
|
81
|
+
version: 1,
|
|
82
|
+
component: "api",
|
|
83
|
+
env: "staging",
|
|
84
|
+
digest: "sha256:api",
|
|
85
|
+
gitSha: "abc123",
|
|
86
|
+
runId: "run-7",
|
|
87
|
+
timestamp: "2026-01-02T00:00:00.000Z",
|
|
88
|
+
actor: "ci",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function args(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
92
|
+
return {
|
|
93
|
+
command: "components",
|
|
94
|
+
path: "promote",
|
|
95
|
+
format: "",
|
|
96
|
+
fix: false,
|
|
97
|
+
watch: false,
|
|
98
|
+
verbose: false,
|
|
99
|
+
help: false,
|
|
100
|
+
live: false,
|
|
101
|
+
migrateFrom: "staging",
|
|
102
|
+
migrateTo: "prod",
|
|
103
|
+
actor: "bob",
|
|
104
|
+
runId: "run-9",
|
|
105
|
+
...overrides,
|
|
106
|
+
} as ParsedArgs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ctx = (a: Partial<ParsedArgs>) => ({ args: args(a), plugins: [] }) as never;
|
|
110
|
+
|
|
111
|
+
let errors: string[];
|
|
112
|
+
let logs: string[];
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
ran.length = 0;
|
|
116
|
+
publishedDigest = "sha256:api";
|
|
117
|
+
failApplyFor = undefined;
|
|
118
|
+
errors = [];
|
|
119
|
+
logs = [];
|
|
120
|
+
vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.join(" ")); });
|
|
121
|
+
vi.spyOn(console, "log").mockImplementation((...m: unknown[]) => { logs.push(m.join(" ")); });
|
|
122
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
123
|
+
appendReleaseRecordMock.mockReset().mockImplementation(async (input: object) => ({ commit: "c0ffee0", record: { version: 1, ...input } }));
|
|
124
|
+
readReleaseLedgerMock.mockReset().mockResolvedValue({ records: [staging], malformed: 0 });
|
|
125
|
+
pushLifecycleMock.mockReset().mockResolvedValue(undefined);
|
|
126
|
+
resolveTargetsMock.mockReset().mockResolvedValue({ success: true, targets: [api] });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
vi.restoreAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("chant components promote", () => {
|
|
134
|
+
test("--from and --to are required", async () => {
|
|
135
|
+
expect(await runComponentsPromote(ctx({ migrateTo: undefined }))).toBe(1);
|
|
136
|
+
expect(errors.join("\n")).toMatch(/--from <env> and --to <env> are required/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("deploys the staging digest to prod without building, and records the promotion", async () => {
|
|
140
|
+
expect(await runComponentsPromote(ctx({}))).toBe(0);
|
|
141
|
+
expect(readReleaseLedgerMock).toHaveBeenCalledWith("staging");
|
|
142
|
+
expect(ran).toEqual(["prod:publish-image", "prod:apply"]);
|
|
143
|
+
expect(appendReleaseRecordMock).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({
|
|
145
|
+
component: "api",
|
|
146
|
+
env: "prod",
|
|
147
|
+
digest: "sha256:api",
|
|
148
|
+
gitSha: "abc123",
|
|
149
|
+
runId: "run-9",
|
|
150
|
+
actor: "bob",
|
|
151
|
+
promotedFrom: { env: "staging", runId: "run-7", timestamp: "2026-01-02T00:00:00.000Z" },
|
|
152
|
+
});
|
|
153
|
+
expect(pushLifecycleMock).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a digest missing from the source ledger fails clearly and deploys nothing", async () => {
|
|
157
|
+
expect(await runComponentsPromote(ctx({ component: "api", digest: "sha256:unknown" }))).toBe(1);
|
|
158
|
+
expect(errors.join("\n")).toMatch(/sha256:unknown is not recorded for "api" in "staging"/);
|
|
159
|
+
expect(ran).toEqual([]);
|
|
160
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("--dry-run prints the plan and deploys nothing", async () => {
|
|
164
|
+
expect(await runComponentsPromote(ctx({ dryRun: true, json: true }))).toBe(0);
|
|
165
|
+
expect(ran).toEqual([]);
|
|
166
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
167
|
+
const plan = JSON.parse(logs.join("\n"));
|
|
168
|
+
expect(plan.items).toEqual([expect.objectContaining({ component: "api", digest: "sha256:api", notRun: ["docker-build"] })]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a publish that does not match the recorded digest fails and records nothing", async () => {
|
|
172
|
+
publishedDigest = "sha256:other";
|
|
173
|
+
expect(await runComponentsPromote(ctx({}))).toBe(1);
|
|
174
|
+
expect(ran).toEqual(["prod:publish-image"]);
|
|
175
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a gate in the composition stops the promote with exit 3 and records nothing", async () => {
|
|
179
|
+
const gated: DriverComponent = {
|
|
180
|
+
...api,
|
|
181
|
+
deploy: [...api.deploy.slice(0, 2), { phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "apply" }] }],
|
|
182
|
+
};
|
|
183
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [gated] });
|
|
184
|
+
expect(await runComponentsPromote(ctx({}))).toBe(3);
|
|
185
|
+
expect(ran).not.toContain("prod:apply");
|
|
186
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
187
|
+
expect(errors.join("\n")).toMatch(/chant approve api prod-release/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a component that deployed is recorded even when another one failed", async () => {
|
|
191
|
+
const web: DriverComponent = { ...api, name: "web" };
|
|
192
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [api, web] });
|
|
193
|
+
readReleaseLedgerMock.mockResolvedValue({ records: [staging, { ...staging, component: "web" }], malformed: 0 });
|
|
194
|
+
failApplyFor = "web";
|
|
195
|
+
expect(await runComponentsPromote(ctx({}))).toBe(1);
|
|
196
|
+
expect(appendReleaseRecordMock).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({ component: "api", env: "prod" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a component with no publish step is refused before anything runs", async () => {
|
|
201
|
+
resolveTargetsMock.mockResolvedValue({
|
|
202
|
+
success: true,
|
|
203
|
+
targets: [{ name: "api", deploy: [{ phase: "Apply", steps: [{ kind: "apply" }] }] }],
|
|
204
|
+
});
|
|
205
|
+
expect(await runComponentsPromote(ctx({}))).toBe(1);
|
|
206
|
+
expect(errors.join("\n")).toMatch(/no publish step/);
|
|
207
|
+
expect(ran).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("chant components rollback", () => {
|
|
212
|
+
const prod = (digest: string, timestamp: string, runId: string): ReleaseRecord =>
|
|
213
|
+
({ ...staging, env: "prod", digest, timestamp, runId });
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
readReleaseLedgerMock.mockResolvedValue({
|
|
217
|
+
records: [prod("sha256:old", "2026-01-01T00:00:00.000Z", "run-1"), prod("sha256:new", "2026-01-02T00:00:00.000Z", "run-2")],
|
|
218
|
+
malformed: 0,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const rb = (a: Partial<ParsedArgs>) => ctx({ migrateFrom: undefined, migrateTo: undefined, path: "rollback", extraPositional: "prod", component: "api", ...a });
|
|
223
|
+
|
|
224
|
+
test("an environment and --component are required", async () => {
|
|
225
|
+
expect(await runComponentsRollback(rb({ component: undefined }))).toBe(1);
|
|
226
|
+
expect(errors.join("\n")).toMatch(/--component <name> are required/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("redeploys the previous release from its recorded digest and records what it restored", async () => {
|
|
230
|
+
expect(await runComponentsRollback(rb({}))).toBe(0);
|
|
231
|
+
expect(readReleaseLedgerMock).toHaveBeenCalledWith("prod");
|
|
232
|
+
// Neither a build nor a publish: the environment already has the artifact.
|
|
233
|
+
expect(ran).toEqual(["prod:apply"]);
|
|
234
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({
|
|
235
|
+
component: "api",
|
|
236
|
+
env: "prod",
|
|
237
|
+
digest: "sha256:old",
|
|
238
|
+
restores: { env: "prod", runId: "run-1", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
239
|
+
});
|
|
240
|
+
expect(appendReleaseRecordMock.mock.calls[0][0]).not.toHaveProperty("promotedFrom");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a digest the environment never recorded fails clearly", async () => {
|
|
244
|
+
expect(await runComponentsRollback(rb({ digest: "sha256:elsewhere" }))).toBe(1);
|
|
245
|
+
expect(errors.join("\n")).toMatch(/sha256:elsewhere is not recorded for "api" in "prod"/);
|
|
246
|
+
expect(ran).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("the environment's gate applies to a rollback", async () => {
|
|
250
|
+
const gated: DriverComponent = {
|
|
251
|
+
...api,
|
|
252
|
+
name: "api",
|
|
253
|
+
deploy: [...api.deploy.slice(0, 2), { phase: "Apply", steps: [{ kind: "gate", gate: "rollback-ok" }, { kind: "apply" }] }],
|
|
254
|
+
};
|
|
255
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [gated] });
|
|
256
|
+
expect(await runComponentsRollback(rb({}))).toBe(3);
|
|
257
|
+
expect(ran).not.toContain("prod:apply");
|
|
258
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
259
|
+
expect(errors.join("\n")).toMatch(/chant approve api rollback-ok/);
|
|
260
|
+
});
|
|
261
|
+
});
|