@intentius/chant 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/operator.d.ts +13 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +12 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +36 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +196 -27
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +130 -0
- package/src/cli/handlers/operator.ts +56 -2
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/discovery/fold-import.ts +12 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +112 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset-public-export.test.ts +27 -0
- package/src/fold/subset.ts +36 -2
- package/src/index.ts +5 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +24 -12
- package/src/op/activities/lexicon-upgrade.ts +19 -3
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +512 -26
- package/src/op/activities/reconcile.ts +307 -34
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +111 -1
- package/src/op/gate.ts +181 -23
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +226 -3
- package/src/op/local-executor.ts +61 -12
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/types.ts +19 -0
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan identity for a gate (#2300, measured on INTENTIUS/choudoufu#1026).
|
|
3
|
+
*
|
|
4
|
+
* A gate resolution used to carry the op, the gate, the approver and a
|
|
5
|
+
* timestamp, and nothing about what was approved. Approve, edit the root,
|
|
6
|
+
* re-run, and the second run re-planned and applied: the resolution had
|
|
7
|
+
* authorised the *next run* of that op rather than the plan the approver
|
|
8
|
+
* read. This module is the missing half — one string that identifies a plan,
|
|
9
|
+
* written onto the pending fact the run records, onto the resolution `chant
|
|
10
|
+
* approve` appends, and compared by {@link latestResolutionForPlan} when a
|
|
11
|
+
* later run decides the gate.
|
|
12
|
+
*
|
|
13
|
+
* ## What a digest covers
|
|
14
|
+
*
|
|
15
|
+
* The change set, and only the change set: what a run proposes to create,
|
|
16
|
+
* update, replace or destroy, at which addresses, with which values. Two
|
|
17
|
+
* plans share a digest exactly when applying either one would do the same
|
|
18
|
+
* thing to the estate.
|
|
19
|
+
*
|
|
20
|
+
* ## What it deliberately does not cover
|
|
21
|
+
*
|
|
22
|
+
* - **When the plan was taken.** A plan file's own `timestamp`, and the
|
|
23
|
+
* resolution's. Re-planning an unchanged root a minute later must produce
|
|
24
|
+
* the same digest, or every approval would expire on the clock rather than
|
|
25
|
+
* on the content.
|
|
26
|
+
* - **Which run took it.** `runId`, the CI job number, the workflow attempt.
|
|
27
|
+
* Approving a plan and re-running the workflow is the whole loop; binding
|
|
28
|
+
* the run id would make the approval unusable by the run that consumes it.
|
|
29
|
+
* - **The tool that produced it.** The terraform/choudoufu version, the plan
|
|
30
|
+
* file's binary bytes and its path on disk. The digest is taken over the
|
|
31
|
+
* `show -json` rendering rather than the file, so a plan-format bump does
|
|
32
|
+
* not read as a changed plan.
|
|
33
|
+
* - **Who approved it, or where.** `resolvedBy`, `note`, `url` — those
|
|
34
|
+
* describe the approval, not the plan.
|
|
35
|
+
*
|
|
36
|
+
* The identity is therefore a claim about consequence, not about provenance.
|
|
37
|
+
* That is the claim an approver is actually making.
|
|
38
|
+
*/
|
|
39
|
+
import { sortedJsonReplacer } from "../utils";
|
|
40
|
+
import { getRuntime } from "../runtime-adapter";
|
|
41
|
+
|
|
42
|
+
/** The hash a plan digest is taken with, and the prefix every digest carries. */
|
|
43
|
+
export const PLAN_DIGEST_ALGORITHM = "sha256";
|
|
44
|
+
|
|
45
|
+
/** Shape of a well-formed digest: `sha256:` and 64 lowercase hex characters. */
|
|
46
|
+
const PLAN_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hash a plan's change set into a stable identity.
|
|
50
|
+
*
|
|
51
|
+
* `kind` names the shape `subject` is in (`"terraform-plan"`,
|
|
52
|
+
* `"lifecycle-diff"`), and is hashed alongside it so two different kinds of
|
|
53
|
+
* plan can never collide into the same digest by coincidence — a gate bound
|
|
54
|
+
* to a terraform plan must not be satisfiable by a lifecycle diff that
|
|
55
|
+
* happened to serialize identically.
|
|
56
|
+
*
|
|
57
|
+
* `subject` is canonicalised by {@link sortedJsonReplacer}, so object key
|
|
58
|
+
* order — which neither terraform's JSON writer nor `JSON.parse` guarantees
|
|
59
|
+
* across versions — does not change the answer. It is the caller's job to
|
|
60
|
+
* hand in a projection that already excludes the volatile fields this
|
|
61
|
+
* module's doc comment lists.
|
|
62
|
+
*/
|
|
63
|
+
export function computePlanDigest(kind: string, subject: unknown): string {
|
|
64
|
+
const canonical = JSON.stringify({ kind, subject }, sortedJsonReplacer);
|
|
65
|
+
return `${PLAN_DIGEST_ALGORITHM}:${getRuntime().hash(canonical)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether `raw` is a digest this code produced. Used at the `chant approve
|
|
70
|
+
* --plan` boundary, so a typo, a truncated copy-paste or a plan *file* path
|
|
71
|
+
* is refused before it is written into an immutable resolution that would
|
|
72
|
+
* then never match anything.
|
|
73
|
+
*/
|
|
74
|
+
export function isPlanDigest(raw: unknown): raw is string {
|
|
75
|
+
return typeof raw === "string" && PLAN_DIGEST_PATTERN.test(raw);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A digest as it reads in a message, and the one place that decides how an
|
|
80
|
+
* absent one reads. Records written before #2300 carry no digest at all, and
|
|
81
|
+
* "(none — recorded before plan-bound gates)" is what a refusal has to say
|
|
82
|
+
* about them instead of printing `undefined`.
|
|
83
|
+
*/
|
|
84
|
+
export function describePlanDigest(digest: string | undefined): string {
|
|
85
|
+
return digest ?? "(none — recorded before plan-bound gates)";
|
|
86
|
+
}
|
|
@@ -105,6 +105,7 @@ export function buildRunRecord(
|
|
|
105
105
|
...(record.outcome ? { outcome: record.outcome } : {}),
|
|
106
106
|
...(record.approval ? { approval: record.approval } : {}),
|
|
107
107
|
...(record.error !== undefined ? { error: record.error } : {}),
|
|
108
|
+
...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
|
|
108
109
|
});
|
|
109
110
|
if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
|
|
110
111
|
}
|
|
@@ -257,9 +257,9 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
257
257
|
|
|
258
258
|
test("on a GitHub Actions job (GITHUB_REPOSITORY set), opens the sticky issue via gh api (#2297)", async () => {
|
|
259
259
|
const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
|
|
260
|
-
const calls: string
|
|
261
|
-
const gh = vi.fn(async (cmd: string) => {
|
|
262
|
-
calls.push(cmd);
|
|
260
|
+
const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
|
|
261
|
+
const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
|
|
262
|
+
calls.push({ cmd, env: opts?.env });
|
|
263
263
|
if (cmd.includes("--paginate")) return { stdout: "", stderr: "" }; // no owned issue yet
|
|
264
264
|
if (cmd.includes("--method POST")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
|
|
265
265
|
return { stdout: "", stderr: "" };
|
|
@@ -267,6 +267,8 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
267
267
|
|
|
268
268
|
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
269
269
|
vi.stubEnv("GITHUB_API_URL", "");
|
|
270
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
|
|
271
|
+
vi.stubEnv("GH_TOKEN", "ghs-workflow");
|
|
270
272
|
try {
|
|
271
273
|
const r = await lexiconUpgrade({
|
|
272
274
|
lexicon: "gcp",
|
|
@@ -276,10 +278,14 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
276
278
|
});
|
|
277
279
|
|
|
278
280
|
expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
|
|
279
|
-
expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
|
|
280
|
-
const post = calls.find((c) => c.includes("--method POST"));
|
|
281
|
-
expect(post).toContain("https://api.github.com/repos/acme/infra/issues");
|
|
282
|
-
expect(post).toContain("<!-- chant-lexicon-upgrade:gcp -->");
|
|
281
|
+
expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
|
|
282
|
+
const post = calls.find((c) => c.cmd.includes("--method POST"));
|
|
283
|
+
expect(post?.cmd).toContain("https://api.github.com/repos/acme/infra/issues");
|
|
284
|
+
expect(post?.cmd).toContain("<!-- chant-lexicon-upgrade:gcp -->");
|
|
285
|
+
// #2320: the shared `postOrUpdateGithubIssue` resolves the credential
|
|
286
|
+
// and hands it to the runner, so this caller gets the same forwarding
|
|
287
|
+
// reconcilePr's issue mode does rather than leaving `gh` to guess.
|
|
288
|
+
for (const call of calls) expect(call.env?.GH_TOKEN).toBe("ghs-workflow");
|
|
283
289
|
} finally {
|
|
284
290
|
vi.unstubAllEnvs();
|
|
285
291
|
}
|
|
@@ -287,9 +293,9 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
287
293
|
|
|
288
294
|
test("a re-run on a GitHub Actions job PATCHes the issue it already owns (#2297)", async () => {
|
|
289
295
|
const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
|
|
290
|
-
const calls: string
|
|
291
|
-
const gh = vi.fn(async (cmd: string) => {
|
|
292
|
-
calls.push(cmd);
|
|
296
|
+
const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
|
|
297
|
+
const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
|
|
298
|
+
calls.push({ cmd, env: opts?.env });
|
|
293
299
|
if (cmd.includes("--paginate")) return { stdout: "11\n", stderr: "" }; // marker search found issue 11
|
|
294
300
|
if (cmd.includes("--method PATCH")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
|
|
295
301
|
return { stdout: "", stderr: "" };
|
|
@@ -297,6 +303,10 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
297
303
|
|
|
298
304
|
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
299
305
|
vi.stubEnv("GITHUB_API_URL", "");
|
|
306
|
+
// The cross-instance case (#2320): a Forgejo token that must outrank the
|
|
307
|
+
// job's own `github.token`, on the path that used to forward neither.
|
|
308
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
|
|
309
|
+
vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
|
|
300
310
|
try {
|
|
301
311
|
const r = await lexiconUpgrade({
|
|
302
312
|
lexicon: "gcp",
|
|
@@ -306,8 +316,10 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
306
316
|
});
|
|
307
317
|
|
|
308
318
|
expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
|
|
309
|
-
expect(calls.some((c) => c.includes("--method POST"))).toBe(false);
|
|
310
|
-
expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
|
|
319
|
+
expect(calls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
|
|
320
|
+
expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
|
|
321
|
+
const patch = calls.find((c) => c.cmd.includes("--method PATCH"));
|
|
322
|
+
expect(patch?.env?.GH_TOKEN).toBe("forgejo-cross-instance");
|
|
311
323
|
} finally {
|
|
312
324
|
vi.unstubAllEnvs();
|
|
313
325
|
}
|
|
@@ -120,8 +120,21 @@ export type CheckRollingFn = (opts: {
|
|
|
120
120
|
verbose?: boolean;
|
|
121
121
|
}) => Promise<RollingUpgradeResult>;
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Minimal shell-exec interface for gh/git invocations.
|
|
125
|
+
*
|
|
126
|
+
* `opts` carries the environment `postOrUpdateGithubIssue` resolves the issue
|
|
127
|
+
* credential into (chant #2320) — the same `GhExec` shape reconcile.ts
|
|
128
|
+
* declares, so the two stay one signature and this can keep being passed
|
|
129
|
+
* straight through. Every other call site here forwards nothing and passes
|
|
130
|
+
* nothing. A mock runner is free to ignore the argument; the default one
|
|
131
|
+
* hands it to `execAsync`, which is what makes CHANT_FORGEJO_TOKEN actually
|
|
132
|
+
* reach `gh` on a cross-instance Forgejo run.
|
|
133
|
+
*/
|
|
134
|
+
export type GhRunner = (
|
|
135
|
+
cmd: string,
|
|
136
|
+
opts?: { env?: NodeJS.ProcessEnv },
|
|
137
|
+
) => Promise<{ stdout: string; stderr: string }>;
|
|
125
138
|
|
|
126
139
|
/** Applies a pinned version bump permanently (no revert). Async (core loads the lexicon's pin descriptor); a sync mock is also accepted. */
|
|
127
140
|
export type ApplyBumpFn = (
|
|
@@ -283,7 +296,7 @@ function shellQuote(s: string): string {
|
|
|
283
296
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
284
297
|
}
|
|
285
298
|
|
|
286
|
-
const defaultGh: GhRunner = async (cmd) => execAsync(cmd);
|
|
299
|
+
const defaultGh: GhRunner = async (cmd, opts) => execAsync(cmd, { ...opts });
|
|
287
300
|
|
|
288
301
|
/**
|
|
289
302
|
* The hidden marker that makes this lexicon's upgrade-status issue findable
|
|
@@ -320,6 +333,9 @@ async function postLexiconIssue(
|
|
|
320
333
|
const marker = lexiconUpgradeIssueMarker(lexicon);
|
|
321
334
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
322
335
|
if (repo) {
|
|
336
|
+
// `postOrUpdateGithubIssue` resolves the token itself and hands it back
|
|
337
|
+
// through `gh`'s second argument (#2320), so this runner has to forward
|
|
338
|
+
// what it is given rather than dropping it — see `GhRunner`.
|
|
323
339
|
return postOrUpdateGithubIssue(repo, marker, title, body, gh);
|
|
324
340
|
}
|
|
325
341
|
const { stdout } = await gh(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { computePlanDigest } from "../../lifecycle/plan-digest";
|
|
3
4
|
|
|
4
5
|
const execAsync = promisify(exec);
|
|
5
6
|
|
|
@@ -39,6 +40,40 @@ export interface LifecycleDiffResult {
|
|
|
39
40
|
* `chant lifecycle diff --live`).
|
|
40
41
|
*/
|
|
41
42
|
drifted: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* This diff's identity (#2300) — the change set `chant lifecycle diff`
|
|
45
|
+
* reported, hashed. `ApplyOp` hands it to its gate step's `plan`, so an
|
|
46
|
+
* approval binds to the change set the approver read rather than to the
|
|
47
|
+
* next run of the Op.
|
|
48
|
+
*
|
|
49
|
+
* Computed over {@link normalizeDiffChangeSet}'s canonical form of
|
|
50
|
+
* `output`, so incidental whitespace does not read as a changed plan, while
|
|
51
|
+
* any added, removed or reworded row does.
|
|
52
|
+
*/
|
|
53
|
+
planDigest: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The change set half of a `chant lifecycle diff` render, canonicalised for
|
|
58
|
+
* digesting (#2300).
|
|
59
|
+
*
|
|
60
|
+
* The diff render is line-oriented: section headers and the resource rows
|
|
61
|
+
* under them. Two runs over an unchanged environment print the same lines, so
|
|
62
|
+
* the lines are the change set. Normalising is deliberately minimal — CRLF to
|
|
63
|
+
* LF, trailing whitespace off each line, blank lines dropped — because
|
|
64
|
+
* anything more aggressive would start discarding rows, and a digest that
|
|
65
|
+
* discards rows is a digest that approves changes nobody saw.
|
|
66
|
+
*
|
|
67
|
+
* Unlike a terraform plan there is no timestamp to strip: the diff render
|
|
68
|
+
* carries none. If one is ever added it has to be dropped here, or every
|
|
69
|
+
* approval would be stale the moment it was written.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeDiffChangeSet(output: string): string[] {
|
|
72
|
+
return output
|
|
73
|
+
.replace(/\r\n/g, "\n")
|
|
74
|
+
.split("\n")
|
|
75
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
76
|
+
.filter((line) => line !== "");
|
|
42
77
|
}
|
|
43
78
|
|
|
44
79
|
/**
|
|
@@ -59,6 +94,20 @@ function detectDrift(output: string): boolean {
|
|
|
59
94
|
return DRIFT_HEADERS.some((h) => output.includes(`${h} (`) || output.includes(`\n${h}`));
|
|
60
95
|
}
|
|
61
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The identity of one `chant lifecycle diff` result (#2300). The environment
|
|
99
|
+
* and the `--live` flag are hashed alongside the change set because they say
|
|
100
|
+
* what the change set is *of*: the same rows against `staging` are not an
|
|
101
|
+
* approval to apply against `prod`.
|
|
102
|
+
*/
|
|
103
|
+
function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): string {
|
|
104
|
+
return computePlanDigest("lifecycle-diff", {
|
|
105
|
+
env: args.env,
|
|
106
|
+
live: args.live === true,
|
|
107
|
+
changeSet: normalizeDiffChangeSet(output),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
/**
|
|
63
112
|
* Run `chant lifecycle diff <env>` and return the output + structured drift
|
|
64
113
|
* flag. Read-only; intended for use inside watch/observation Ops.
|
|
@@ -75,11 +124,11 @@ export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSigna
|
|
|
75
124
|
const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
|
|
76
125
|
const output = `${stdout}${stderr}`.trim();
|
|
77
126
|
if (output) console.log(output);
|
|
78
|
-
return { output, exitCode: 0, drifted: detectDrift(output) };
|
|
127
|
+
return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
79
128
|
} catch (err) {
|
|
80
129
|
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
81
130
|
const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
|
82
131
|
if (output) console.error(output);
|
|
83
|
-
return { output, exitCode: e.code ?? 1, drifted: detectDrift(output) };
|
|
132
|
+
return { output, exitCode: e.code ?? 1, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
|
|
84
133
|
}
|
|
85
134
|
}
|