@intentius/chant 0.79.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.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 +14 -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/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/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.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.ts +2 -1
- 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 +69 -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 +14 -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/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/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components promote --from <env> --to <env>` (#2530) and `chant
|
|
3
|
+
* components rollback <env>` (#2531): deploy digests a release ledger already
|
|
4
|
+
* records, without a build. A promote takes them from another environment's
|
|
5
|
+
* ledger; a rollback takes an earlier release from the environment's own. The
|
|
6
|
+
* mechanism lives in ../../components/promote.ts; these handlers read the
|
|
7
|
+
* ledger, print the plan, run it, and append the release records.
|
|
8
|
+
*
|
|
9
|
+
* Every component that deployed gets a record, even when a later component
|
|
10
|
+
* failed or stopped at a gate, so the ledger never misses a deploy that
|
|
11
|
+
* happened. Unlike an auto-recorded release, the record is what these
|
|
12
|
+
* commands are for, so a failed write is an error rather than a warning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { loadChantConfig, type ChantConfig } from "../../config";
|
|
18
|
+
import { fetchLifecycle, getHeadCommit, pushLifecycle } from "../../lifecycle/git";
|
|
19
|
+
import {
|
|
20
|
+
appendReleaseRecord,
|
|
21
|
+
readReleaseLedger,
|
|
22
|
+
resolveRunId,
|
|
23
|
+
type ReleaseRecord,
|
|
24
|
+
type ReleaseRecordInput,
|
|
25
|
+
} from "../../lifecycle/release-ledger";
|
|
26
|
+
import { resolveComponentTargets } from "../../components/cli-support";
|
|
27
|
+
import { applyConfigDefaults } from "../../components/config-defaults";
|
|
28
|
+
import { fanOutRegistry } from "../../components/fan-out-support";
|
|
29
|
+
import { renderDriverHuman } from "../../components/driver-output";
|
|
30
|
+
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
31
|
+
import {
|
|
32
|
+
planPromotion,
|
|
33
|
+
planRollback,
|
|
34
|
+
withoutBuildSteps,
|
|
35
|
+
runPromotion,
|
|
36
|
+
promotionRecord,
|
|
37
|
+
rollbackRecord,
|
|
38
|
+
gateApprover,
|
|
39
|
+
type DeployRunInfo,
|
|
40
|
+
type PromotionItem,
|
|
41
|
+
type PromotionPlan,
|
|
42
|
+
} from "../../components/promote";
|
|
43
|
+
import type { DriverComponent } from "../../components/driver";
|
|
44
|
+
import { writeGatedRunSummary } from "../../op/gate-summary";
|
|
45
|
+
import { approveCommand } from "../../op/gate";
|
|
46
|
+
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
47
|
+
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
48
|
+
import { GATED_EXIT_CODE } from "./run";
|
|
49
|
+
import type { CommandContext } from "../registry";
|
|
50
|
+
|
|
51
|
+
const PROMOTE_USAGE =
|
|
52
|
+
"chant components promote --from <env> --to <env> [--component <name> [--digest <sha256:...>]] [--dry-run] [--json]";
|
|
53
|
+
const ROLLBACK_USAGE =
|
|
54
|
+
"chant components rollback <env> --component <name> [--digest <sha256:...>] [--dry-run] [--json]";
|
|
55
|
+
|
|
56
|
+
type Verb = "promote" | "rollback";
|
|
57
|
+
|
|
58
|
+
function renderPlan(verb: Verb, plan: PromotionPlan, removed: Map<string, string[]>): void {
|
|
59
|
+
console.error(formatBold(verb === "promote" ? `promote ${plan.from} -> ${plan.to}` : `rollback ${plan.to}`));
|
|
60
|
+
for (const item of plan.items) {
|
|
61
|
+
console.error(` ${item.component} ${item.digest}`);
|
|
62
|
+
console.error(` released to ${plan.from} at ${item.source.timestamp} (run ${item.source.runId}, git ${item.source.gitSha.slice(0, 12)})`);
|
|
63
|
+
const kinds = removed.get(item.component) ?? [];
|
|
64
|
+
if (kinds.length > 0) console.error(` not run: ${kinds.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
for (const skipped of plan.notPromoted) {
|
|
67
|
+
console.error(` ${skipped.component} not promoted: ${skipped.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function planJson(plan: PromotionPlan, removed: Map<string, string[]>) {
|
|
72
|
+
return {
|
|
73
|
+
from: plan.from,
|
|
74
|
+
to: plan.to,
|
|
75
|
+
items: plan.items.map((i) => ({
|
|
76
|
+
component: i.component,
|
|
77
|
+
digest: i.digest,
|
|
78
|
+
source: i.source,
|
|
79
|
+
notRun: removed.get(i.component) ?? [],
|
|
80
|
+
})),
|
|
81
|
+
notPromoted: plan.notPromoted,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveActor(ctx: CommandContext): string | undefined {
|
|
86
|
+
return ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Config, params and the declared components, or the exit code of a refusal already printed. */
|
|
90
|
+
async function loadProject(ctx: CommandContext, selector: string) {
|
|
91
|
+
const { args } = ctx;
|
|
92
|
+
const projectPath = resolve(".");
|
|
93
|
+
const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
|
|
94
|
+
const paramsResolution = resolveCliBuildParams(config.buildParams, {
|
|
95
|
+
cli: parseParamFlags(args.param),
|
|
96
|
+
paramsFile: args.paramsFile,
|
|
97
|
+
verbose: args.verbose,
|
|
98
|
+
});
|
|
99
|
+
if (!paramsResolution.success) {
|
|
100
|
+
for (const message of paramsResolution.errors) console.error(message);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const targets = await resolveComponentTargets(projectPath, selector, args.sandbox, paramsResolution.provenance);
|
|
104
|
+
if (!targets.success) {
|
|
105
|
+
console.error(formatError({ message: targets.error ?? "Could not discover components" }));
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
return { projectPath, config, targets: targets.targets };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readLedger(env: string): Promise<ReleaseRecord[]> {
|
|
112
|
+
const ledger = await readReleaseLedger(env);
|
|
113
|
+
if (ledger.malformed > 0) {
|
|
114
|
+
console.error(formatWarning({ message: `${ledger.malformed} malformed line(s) in the "${env}" release ledger were skipped` }));
|
|
115
|
+
}
|
|
116
|
+
return ledger.records;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prepare, run and record a plan. Shared by promote and rollback, which differ
|
|
121
|
+
* only in how the plan was chosen, whether the publish step runs, and which
|
|
122
|
+
* field names the earlier release on the new record.
|
|
123
|
+
*/
|
|
124
|
+
async function deployPlan(
|
|
125
|
+
ctx: CommandContext,
|
|
126
|
+
verb: Verb,
|
|
127
|
+
plan: PromotionPlan,
|
|
128
|
+
project: { projectPath: string; config: ChantConfig; targets: DriverComponent[] },
|
|
129
|
+
actor: string | undefined,
|
|
130
|
+
record: (item: PromotionItem, env: string, run: DeployRunInfo) => ReleaseRecordInput,
|
|
131
|
+
): Promise<number> {
|
|
132
|
+
const { args } = ctx;
|
|
133
|
+
const byName = new Map(project.targets.map((c) => [c.name, applyConfigDefaults(c, project.config)]));
|
|
134
|
+
const prepared: DriverComponent[] = [];
|
|
135
|
+
const removed = new Map<string, string[]>();
|
|
136
|
+
const refusals: string[] = [];
|
|
137
|
+
for (const item of plan.items) {
|
|
138
|
+
const result = withoutBuildSteps(byName.get(item.component)!, verb === "rollback" ? { pinDigest: item.digest } : {});
|
|
139
|
+
if ("error" in result) refusals.push(result.error);
|
|
140
|
+
else {
|
|
141
|
+
prepared.push(result.component);
|
|
142
|
+
removed.set(item.component, result.removed);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (refusals.length > 0) {
|
|
146
|
+
for (const message of refusals) console.error(formatError({ message }));
|
|
147
|
+
console.error(formatError({ message: `nothing was deployed` }));
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The composition deployed is this checkout's. Say so when it is not the
|
|
152
|
+
// commit the artifact was built from.
|
|
153
|
+
const head = await getHeadCommit().catch(() => undefined);
|
|
154
|
+
for (const item of plan.items) {
|
|
155
|
+
if (head && head !== item.source.gitSha) {
|
|
156
|
+
console.error(formatWarning({
|
|
157
|
+
message: `${item.component}: the artifact was built from ${item.source.gitSha.slice(0, 12)}, ` +
|
|
158
|
+
`but its deploy steps come from this checkout at ${head.slice(0, 12)}`,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (args.dryRun) {
|
|
164
|
+
if (args.json) console.log(JSON.stringify(planJson(plan, removed), null, 2));
|
|
165
|
+
else renderPlan(verb, plan, removed);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
if (!args.json) renderPlan(verb, plan, removed);
|
|
169
|
+
|
|
170
|
+
const seededOutputs: Record<string, Record<string, unknown>> = {};
|
|
171
|
+
for (const file of args.seedOutputs ?? []) {
|
|
172
|
+
try {
|
|
173
|
+
Object.assign(seededOutputs, JSON.parse(readFileSync(resolve(file), "utf8")));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(formatError({ message: `--seed-outputs: could not read "${file}": ${err instanceof Error ? err.message : String(err)}` }));
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const registry = await fanOutRegistry(project.projectPath, project.config);
|
|
181
|
+
const run = await runPromotion({
|
|
182
|
+
plan,
|
|
183
|
+
components: prepared,
|
|
184
|
+
registry,
|
|
185
|
+
componentOutputs: seededOutputs,
|
|
186
|
+
...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
|
|
187
|
+
});
|
|
188
|
+
if (!args.json) renderDriverHuman(run);
|
|
189
|
+
|
|
190
|
+
// Record every component that deployed, whatever happened to the rest: a
|
|
191
|
+
// component that reached the environment and is missing from its ledger
|
|
192
|
+
// would be exactly the unrecorded deploy the ledger exists to rule out.
|
|
193
|
+
const deployed = plan.items.filter((item) => run.results.some((r) => r.component === item.component && r.status === "ok"));
|
|
194
|
+
const { runId, runOrigin } = resolveRunId(args.runId);
|
|
195
|
+
const timestamp = new Date().toISOString();
|
|
196
|
+
const recorded: ReleaseRecord[] = [];
|
|
197
|
+
let recordError: string | undefined;
|
|
198
|
+
if (deployed.length > 0) {
|
|
199
|
+
try {
|
|
200
|
+
for (const item of deployed) {
|
|
201
|
+
const approver = gateApprover(run.results.find((r) => r.component === item.component));
|
|
202
|
+
const { record: written } = await appendReleaseRecord(
|
|
203
|
+
record(item, plan.to, { runId, ...(runOrigin ? { runOrigin } : {}), actor: actor!, timestamp, ...(approver ? { approver } : {}) }),
|
|
204
|
+
);
|
|
205
|
+
recorded.push(written);
|
|
206
|
+
}
|
|
207
|
+
await pushLifecycle();
|
|
208
|
+
} catch (err) {
|
|
209
|
+
recordError = err instanceof Error ? err.message : String(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (args.json) {
|
|
214
|
+
console.log(JSON.stringify({ ...planJson(plan, removed), run, recorded }, null, 2));
|
|
215
|
+
} else {
|
|
216
|
+
for (const r of recorded) {
|
|
217
|
+
console.error(formatSuccess(
|
|
218
|
+
verb === "promote"
|
|
219
|
+
? `Promoted ${formatBold(r.component)} ${plan.from} -> ${plan.to}: ${r.digest}`
|
|
220
|
+
: `Rolled back ${formatBold(r.component)} in ${plan.to} to ${r.digest}`,
|
|
221
|
+
));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (recordError !== undefined) {
|
|
226
|
+
console.error(formatError({
|
|
227
|
+
message: `deployed to "${plan.to}", but the release record was not written: ${recordError}`,
|
|
228
|
+
hint: `Pull the chant/lifecycle branch and check \`chant components status ${plan.to}\` before running this again.`,
|
|
229
|
+
}));
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (run.status === "gated" && run.gate) {
|
|
234
|
+
const pending = run.gate;
|
|
235
|
+
console.error(formatWarning({ message: `component "${run.gatedComponent}" is gated on "${pending.gate}" — pending approval` }));
|
|
236
|
+
console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate)}`));
|
|
237
|
+
console.error(formatInfo(`then run the same ${verb} again`));
|
|
238
|
+
writeGatedRunSummary({
|
|
239
|
+
op: pending.op,
|
|
240
|
+
gate: pending.gate,
|
|
241
|
+
...(pending.description ? { description: pending.description } : {}),
|
|
242
|
+
expiresAt: pending.expiresAt,
|
|
243
|
+
...(pending.url ? { url: pending.url } : {}),
|
|
244
|
+
...(pending.planDigest ? { planDigest: pending.planDigest } : {}),
|
|
245
|
+
});
|
|
246
|
+
return GATED_EXIT_CODE;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (run.status !== "ok") {
|
|
250
|
+
console.error(formatError({ message: `${verb} failed at "${run.failedComponent ?? "unknown"}"` }));
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Refuse before anything deploys when nobody can be named on the record. */
|
|
257
|
+
function actorOrRefuse(ctx: CommandContext): { actor: string | undefined } | number {
|
|
258
|
+
const actor = resolveActor(ctx);
|
|
259
|
+
if (!actor && !ctx.args.dryRun) {
|
|
260
|
+
console.error(formatError({
|
|
261
|
+
message: "Could not resolve --actor from the environment",
|
|
262
|
+
hint: "Pass --actor explicitly, or set GITHUB_ACTOR / GITLAB_USER_LOGIN / USER.",
|
|
263
|
+
}));
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
return { actor };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function runComponentsPromote(ctx: CommandContext): Promise<number> {
|
|
270
|
+
const { args } = ctx;
|
|
271
|
+
const from = args.migrateFrom;
|
|
272
|
+
const to = args.migrateTo;
|
|
273
|
+
if (!from || !to) {
|
|
274
|
+
console.error(formatError({ message: "--from <env> and --to <env> are required", hint: PROMOTE_USAGE }));
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
const who = actorOrRefuse(ctx);
|
|
278
|
+
if (typeof who === "number") return who;
|
|
279
|
+
|
|
280
|
+
const project = await loadProject(ctx, args.component ?? "all");
|
|
281
|
+
if (typeof project === "number") return project;
|
|
282
|
+
|
|
283
|
+
await fetchLifecycle().catch(() => false);
|
|
284
|
+
const plan = planPromotion({
|
|
285
|
+
from,
|
|
286
|
+
to,
|
|
287
|
+
sourceRecords: await readLedger(from),
|
|
288
|
+
declared: project.targets.map((c) => c.name),
|
|
289
|
+
...(args.component ? { component: args.component } : {}),
|
|
290
|
+
...(args.digest ? { digest: args.digest } : {}),
|
|
291
|
+
});
|
|
292
|
+
if ("error" in plan) {
|
|
293
|
+
console.error(formatError({ message: plan.error, hint: PROMOTE_USAGE }));
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
return deployPlan(ctx, "promote", plan, project, who.actor, promotionRecord);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function runComponentsRollback(ctx: CommandContext): Promise<number> {
|
|
300
|
+
const { args } = ctx;
|
|
301
|
+
const env = args.extraPositional;
|
|
302
|
+
if (!env || !args.component) {
|
|
303
|
+
console.error(formatError({ message: "an environment and --component <name> are required", hint: ROLLBACK_USAGE }));
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
const who = actorOrRefuse(ctx);
|
|
307
|
+
if (typeof who === "number") return who;
|
|
308
|
+
|
|
309
|
+
const project = await loadProject(ctx, args.component);
|
|
310
|
+
if (typeof project === "number") return project;
|
|
311
|
+
|
|
312
|
+
await fetchLifecycle().catch(() => false);
|
|
313
|
+
const plan = planRollback({
|
|
314
|
+
env,
|
|
315
|
+
records: await readLedger(env),
|
|
316
|
+
declared: project.targets.map((c) => c.name),
|
|
317
|
+
component: args.component,
|
|
318
|
+
...(args.digest ? { digest: args.digest } : {}),
|
|
319
|
+
});
|
|
320
|
+
if ("error" in plan) {
|
|
321
|
+
console.error(formatError({ message: plan.error, hint: ROLLBACK_USAGE }));
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
return deployPlan(ctx, "rollback", plan, project, who.actor, rollbackRecord);
|
|
325
|
+
}
|