@intentius/chant 0.61.0 → 0.63.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 -18
- 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/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.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/git.d.ts +117 -0
- package/dist/lifecycle/git.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 +33 -3
- 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 +394 -14
- 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 +34 -2
- 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/operator.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/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.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 +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- 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/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- 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 +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- 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 +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- 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/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- 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
package/src/lifecycle/git.ts
CHANGED
|
@@ -8,6 +8,69 @@ import { getRuntime } from "../runtime-adapter";
|
|
|
8
8
|
|
|
9
9
|
const STATE_BRANCH = "chant/lifecycle";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The identity chant's ledger commits fall back to when the checkout has none
|
|
13
|
+
* of its own (#2301).
|
|
14
|
+
*
|
|
15
|
+
* A CI checkout is the ordinary case here, not an exotic one: GitLab CI,
|
|
16
|
+
* GitHub Actions and Forgejo Actions all clone without `user.email` or
|
|
17
|
+
* `user.name`, and `git commit-tree` refuses outright rather than inventing
|
|
18
|
+
* one — `fatal: unable to auto-detect email address`. Every write in this
|
|
19
|
+
* module goes through `commit-tree`, so before #2301 a gate's pending fact,
|
|
20
|
+
* a run record, a snapshot and a release record all died the same way the
|
|
21
|
+
* moment they ran anywhere but a developer's laptop.
|
|
22
|
+
*
|
|
23
|
+
* This is a *fallback*, applied per-invocation via `git -c` and field by
|
|
24
|
+
* field: only the half the checkout cannot supply is filled in. A configured
|
|
25
|
+
* `user.name` or `user.email` — a developer's, or a CI job that sets one
|
|
26
|
+
* deliberately — is left exactly as it is and still authors these commits, and
|
|
27
|
+
* nothing is written to the repository's config either way. Override the
|
|
28
|
+
* fallback with `CHANT_LIFECYCLE_COMMITTER_NAME` / `_EMAIL` when a project
|
|
29
|
+
* wants its ledger commits attributed to a bot account it controls.
|
|
30
|
+
*/
|
|
31
|
+
const LEDGER_COMMITTER_NAME = "chant";
|
|
32
|
+
const LEDGER_COMMITTER_EMAIL = "chant@localhost";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `git -c` arguments that give {@link writeBlobToPath}'s `commit-tree` a
|
|
36
|
+
* committer when — and only when — the checkout cannot supply one itself.
|
|
37
|
+
* Empty for any checkout that has an identity, which keeps every existing
|
|
38
|
+
* caller's commits byte-identical to what they were before #2301.
|
|
39
|
+
*/
|
|
40
|
+
async function ledgerCommitIdentityArgs(cwd?: string): Promise<string[]> {
|
|
41
|
+
const rt = getRuntime();
|
|
42
|
+
const probe = await rt.spawn(["git", "var", "GIT_COMMITTER_IDENT"], { cwd, env: C_LOCALE_ENV });
|
|
43
|
+
if (probe.exitCode === 0) return [];
|
|
44
|
+
|
|
45
|
+
// Per-field, not all-or-nothing (#2309 review). `git var` fails if *either*
|
|
46
|
+
// half is missing, so filling in both would overwrite a configured
|
|
47
|
+
// `user.name` in the checkout that has a name but no email — which is not
|
|
48
|
+
// what "a configured identity is left exactly as it is" promises.
|
|
49
|
+
const args: string[] = [];
|
|
50
|
+
for (const [key, envVar, fallback] of [
|
|
51
|
+
["user.name", "CHANT_LIFECYCLE_COMMITTER_NAME", LEDGER_COMMITTER_NAME],
|
|
52
|
+
["user.email", "CHANT_LIFECYCLE_COMMITTER_EMAIL", LEDGER_COMMITTER_EMAIL],
|
|
53
|
+
] as const) {
|
|
54
|
+
const configured = await rt.spawn(["git", "config", "--get", key], { cwd, env: C_LOCALE_ENV });
|
|
55
|
+
if (configured.exitCode === 0 && configured.stdout.trim() !== "") continue;
|
|
56
|
+
args.push("-c", `${key}=${process.env[envVar]?.trim() || fallback}`);
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Prefix every failure out of the ledger write path with what was being
|
|
63
|
+
* written and where (#2301 deliverable 2). `git commit-tree failed: <stderr>`
|
|
64
|
+
* on its own does not say that the thing that failed was chant recording a
|
|
65
|
+
* fact on its own branch, and the caller that swallows it is one stack frame
|
|
66
|
+
* away from printing nothing at all.
|
|
67
|
+
*/
|
|
68
|
+
function ledgerWriteError(stage: string, path: string, stderr: string): Error {
|
|
69
|
+
return new Error(
|
|
70
|
+
`cannot write ${path} on the ${STATE_BRANCH} branch: ${stage} failed: ${(stderr ?? "").trim()}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
11
74
|
/**
|
|
12
75
|
* Write a blob to an arbitrary `<environment>/<filename>` path on the orphan
|
|
13
76
|
* branch, preserving every other env/file entry already on the branch.
|
|
@@ -56,13 +119,42 @@ export async function writeBlobToPath(
|
|
|
56
119
|
// annotation) and no shell-quoting dance around embedded single quotes.
|
|
57
120
|
// Content-addressed and idempotent, so this stays outside the retry loop
|
|
58
121
|
// below — nothing about a CAS conflict on the ref ever invalidates it.
|
|
122
|
+
//
|
|
123
|
+
// `path` is resolved first only so every failure below can name it (#2301).
|
|
124
|
+
const path = `${environment}/${filename}`;
|
|
125
|
+
|
|
59
126
|
const blobResult = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd, stdin: content });
|
|
60
127
|
if (blobResult.exitCode !== 0) {
|
|
61
|
-
throw
|
|
128
|
+
throw ledgerWriteError("git hash-object", path, blobResult.stderr);
|
|
62
129
|
}
|
|
63
130
|
const blobSha = blobResult.stdout.trim();
|
|
64
131
|
|
|
65
|
-
|
|
132
|
+
// Resolved once, outside the retry loop: whether the checkout can name a
|
|
133
|
+
// committer does not change between attempts (#2301).
|
|
134
|
+
const identityArgs = await ledgerCommitIdentityArgs(cwd);
|
|
135
|
+
|
|
136
|
+
// Creating the ledger branch is a different act from extending it, and only
|
|
137
|
+
// one of them is safe to do blind (#2309 review). With no local
|
|
138
|
+
// `chant/lifecycle`, everything below builds a tree from an empty read and
|
|
139
|
+
// commits it with no parent — a brand-new root commit that *replaces* the
|
|
140
|
+
// branch rather than appending to it, which `updateRefCAS` cannot catch
|
|
141
|
+
// because "the ref does not exist" is perfectly true locally.
|
|
142
|
+
//
|
|
143
|
+
// Until #2301 this was mostly hidden: `commit-tree` died for want of a
|
|
144
|
+
// committer before it could happen. Supplying an identity turned that loud
|
|
145
|
+
// failure into a silent branch fabrication on every writer that reaches
|
|
146
|
+
// here without a ledger — a run record, a snapshot, a release, a build
|
|
147
|
+
// manifest — and a fabricated branch is precisely the fork the guard above
|
|
148
|
+
// exists to refuse. So the guard runs here, at the one seam they all pass
|
|
149
|
+
// through, rather than at each of their call sites.
|
|
150
|
+
//
|
|
151
|
+
// Only when the branch is absent: an extend costs no fetch, and the check
|
|
152
|
+
// that matters is whether the history this is about to discard exists
|
|
153
|
+
// somewhere. `requireLifecycleLedger` allows `no-remote` and `absent`, so a
|
|
154
|
+
// genuinely first write still creates the branch.
|
|
155
|
+
if ((await getStateBranchTip(cwd)) === null) {
|
|
156
|
+
await requireLifecycleLedger({ ...(cwd ? { cwd } : {}) });
|
|
157
|
+
}
|
|
66
158
|
|
|
67
159
|
// 2-5. Read tree, build the commit, and CAS-update the branch ref — retried
|
|
68
160
|
// on a conflict (#1959 finding 1). `writeBlobToPath` had no retry of its
|
|
@@ -127,7 +219,7 @@ export async function writeBlobToPath(
|
|
|
127
219
|
|
|
128
220
|
const envTreeResult = await rt.spawn(["git", "mktree"], { cwd, stdin: `${envEntries}\n` });
|
|
129
221
|
if (envTreeResult.exitCode !== 0) {
|
|
130
|
-
throw
|
|
222
|
+
throw ledgerWriteError("git mktree (env)", path, envTreeResult.stderr);
|
|
131
223
|
}
|
|
132
224
|
const envTreeSha = envTreeResult.stdout.trim();
|
|
133
225
|
|
|
@@ -150,7 +242,7 @@ export async function writeBlobToPath(
|
|
|
150
242
|
stdin: `${rootEntries.join("\n")}\n`,
|
|
151
243
|
});
|
|
152
244
|
if (rootTreeResult.exitCode !== 0) {
|
|
153
|
-
throw
|
|
245
|
+
throw ledgerWriteError("git mktree (root)", path, rootTreeResult.stderr);
|
|
154
246
|
}
|
|
155
247
|
const rootTreeSha = rootTreeResult.stdout.trim();
|
|
156
248
|
|
|
@@ -160,11 +252,11 @@ export async function writeBlobToPath(
|
|
|
160
252
|
const parentRef = tip;
|
|
161
253
|
const parentArgs = parentRef ? ["-p", parentRef] : [];
|
|
162
254
|
const commitResult = await rt.spawn(
|
|
163
|
-
["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
|
|
255
|
+
["git", ...identityArgs, "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
|
|
164
256
|
{ cwd },
|
|
165
257
|
);
|
|
166
258
|
if (commitResult.exitCode !== 0) {
|
|
167
|
-
throw
|
|
259
|
+
throw ledgerWriteError("git commit-tree", path, commitResult.stderr);
|
|
168
260
|
}
|
|
169
261
|
const commitSha = commitResult.stdout.trim();
|
|
170
262
|
|
|
@@ -534,16 +626,225 @@ export async function pushLifecycle(opts?: { cwd?: string }): Promise<boolean> {
|
|
|
534
626
|
* Fetch the state branch from remote.
|
|
535
627
|
*/
|
|
536
628
|
export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean> {
|
|
629
|
+
return (await fetchLifecycleStatus(opts)).status === "fetched";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Why a fetch of the ledger branch did or did not produce local history
|
|
634
|
+
* (#2303).
|
|
635
|
+
*
|
|
636
|
+
* `fetchLifecycle`'s boolean cannot answer the question the gate actually
|
|
637
|
+
* needs answered. "No local `chant/lifecycle`" has two causes that a reader
|
|
638
|
+
* of an empty ledger cannot tell apart, and they call for opposite
|
|
639
|
+
* behaviour: on a project whose ledger has never been written, an empty
|
|
640
|
+
* ledger is the truth; in a CI checkout that fetched only the pipeline's own
|
|
641
|
+
* ref, an empty ledger is history the checkout simply cannot see. Reading the
|
|
642
|
+
* second as the first is what makes a second pending fact look correct, so
|
|
643
|
+
* these are separate outcomes:
|
|
644
|
+
*
|
|
645
|
+
* - `fetched` — the branch is now in this checkout (or already was, and is
|
|
646
|
+
* up to date). Whatever the ledger says is the whole truth.
|
|
647
|
+
* - `no-remote` — nothing to fetch from. A single-machine project; local is
|
|
648
|
+
* authoritative by construction, the same assumption `chant operator`'s
|
|
649
|
+
* lease already documents.
|
|
650
|
+
* - `absent` — the remote answered and has no `chant/lifecycle`. The ledger
|
|
651
|
+
* is genuinely empty; this is a project's first gate.
|
|
652
|
+
* - `diverged` — both sides have the branch and neither contains the other.
|
|
653
|
+
* Appending here would drop one side's facts.
|
|
654
|
+
* - `unreachable` — a remote is configured and did not answer (no network,
|
|
655
|
+
* no credentials, a shallow-clone refspec that cannot resolve). Whether
|
|
656
|
+
* anything is on the branch is unknown, which is exactly the state that
|
|
657
|
+
* must not be read as "nothing has been approved".
|
|
658
|
+
*/
|
|
659
|
+
export type LifecycleFetchStatus =
|
|
660
|
+
| { status: "fetched" }
|
|
661
|
+
| { status: "no-remote" }
|
|
662
|
+
| { status: "absent"; remote: string }
|
|
663
|
+
| { status: "ahead"; remote: string }
|
|
664
|
+
| { status: "diverged"; remote: string; stderr: string }
|
|
665
|
+
| { status: "unreachable"; remote: string; stderr: string };
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* git's wording when the remote answered and simply does not carry the ref.
|
|
669
|
+
*
|
|
670
|
+
* Matched against output forced to `LC_ALL=C` ({@link C_LOCALE_ENV}) — both of
|
|
671
|
+
* these strings are in git's translation catalogs, so on a distro git built
|
|
672
|
+
* with NLS and a non-C `LANG` an unpinned locale would fail to match and send
|
|
673
|
+
* a project's genuine first gate down the `unreachable` path, refusing it.
|
|
674
|
+
*/
|
|
675
|
+
const NO_SUCH_REMOTE_REF_RE = /couldn't find remote ref|no such ref was fetched/i;
|
|
676
|
+
/** git's wording when the refspec would not fast-forward the local branch. Same locale caveat. */
|
|
677
|
+
const NON_FAST_FORWARD_RE = /non-fast-forward|rejected/i;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* `git` invocations here are parsed by their output, so they are pinned to the
|
|
681
|
+
* C locale. `LC_ALL` alone is enough — it outranks `LC_MESSAGES` and `LANG` —
|
|
682
|
+
* but `LANGUAGE` is GNU gettext's own override and outranks all of them, so it
|
|
683
|
+
* is cleared too.
|
|
684
|
+
*/
|
|
685
|
+
const C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The remote the ledger lives on.
|
|
689
|
+
*
|
|
690
|
+
* `origin` when it exists, rather than whichever name sorts first — a repo
|
|
691
|
+
* with a `backup` remote alongside `origin` would otherwise fetch the ledger
|
|
692
|
+
* from `backup`. A non-zero exit is *not* read as "no remote": a git that
|
|
693
|
+
* failed to answer is not evidence that a project is single-machine, and
|
|
694
|
+
* treating it as one is what lets an unreadable ledger look empty.
|
|
695
|
+
*/
|
|
696
|
+
async function ledgerRemote(cwd?: string): Promise<{ remote: string } | { failed: true; stderr: string } | null> {
|
|
537
697
|
const rt = getRuntime();
|
|
538
|
-
const
|
|
539
|
-
if (
|
|
698
|
+
const result = await rt.spawn(["git", "remote"], { cwd, env: C_LOCALE_ENV });
|
|
699
|
+
if (result.exitCode !== 0) return { failed: true, stderr: (result.stderr ?? "").trim() };
|
|
700
|
+
const names = result.stdout.trim().split("\n").map((n) => n.trim()).filter(Boolean);
|
|
701
|
+
if (names.length === 0) return null;
|
|
702
|
+
return { remote: names.includes("origin") ? "origin" : names[0] };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** A scratch ref this module owns, used to resolve the remote tip without touching the ledger branch. */
|
|
706
|
+
const PROBE_REF = "refs/chant/lifecycle-probe";
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Fetch `chant/lifecycle` and say what happened, in the terms
|
|
710
|
+
* {@link LifecycleFetchStatus} defines.
|
|
711
|
+
*
|
|
712
|
+
* The refspec is deliberately un-forced (`chant/lifecycle:chant/lifecycle`,
|
|
713
|
+
* no leading `+`), so a local branch holding facts the remote does not have is
|
|
714
|
+
* never rewound by a read.
|
|
715
|
+
*
|
|
716
|
+
* git reports "non-fast-forward" for two states that could not be more
|
|
717
|
+
* different, which is why the rejection is not taken at face value (#2309
|
|
718
|
+
* review): a local branch *ahead* of the remote — the ordinary state right
|
|
719
|
+
* after an append whose push has not landed yet — and a local branch that has
|
|
720
|
+
* genuinely *forked*. Resolving the remote tip into a scratch ref and asking
|
|
721
|
+
* whether it is an ancestor of the local tip separates them. Reading a fork as
|
|
722
|
+
* the ledger is what destroys approvals; refusing an append that is merely
|
|
723
|
+
* ahead would break the retry that is supposed to repair a failed push.
|
|
724
|
+
*/
|
|
725
|
+
export async function fetchLifecycleStatus(opts?: { cwd?: string }): Promise<LifecycleFetchStatus> {
|
|
726
|
+
const rt = getRuntime();
|
|
727
|
+
const cwd = opts?.cwd;
|
|
728
|
+
const found = await ledgerRemote(cwd);
|
|
729
|
+
if (found === null) return { status: "no-remote" };
|
|
730
|
+
if ("failed" in found) {
|
|
731
|
+
return { status: "unreachable", remote: "(unknown)", stderr: found.stderr };
|
|
732
|
+
}
|
|
733
|
+
const { remote } = found;
|
|
540
734
|
|
|
541
|
-
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
542
735
|
const fetchResult = await rt.spawn(
|
|
543
736
|
["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
|
|
544
|
-
{ cwd:
|
|
737
|
+
{ cwd, env: C_LOCALE_ENV },
|
|
545
738
|
);
|
|
546
|
-
|
|
739
|
+
if (fetchResult.exitCode === 0) return { status: "fetched" };
|
|
740
|
+
|
|
741
|
+
const stderr = (fetchResult.stderr ?? "").trim();
|
|
742
|
+
if (NO_SUCH_REMOTE_REF_RE.test(stderr)) return { status: "absent", remote };
|
|
743
|
+
if (!NON_FAST_FORWARD_RE.test(stderr)) return { status: "unreachable", remote, stderr };
|
|
744
|
+
|
|
745
|
+
// Rejected. Resolve the remote tip into our own scratch ref (forced: the ref
|
|
746
|
+
// is this module's, and nothing reads it but the next two lines) and ask
|
|
747
|
+
// whether the local branch already contains it.
|
|
748
|
+
const probe = await rt.spawn(
|
|
749
|
+
["git", "fetch", "--force", remote, `${STATE_BRANCH}:${PROBE_REF}`],
|
|
750
|
+
{ cwd, env: C_LOCALE_ENV },
|
|
751
|
+
);
|
|
752
|
+
if (probe.exitCode !== 0) {
|
|
753
|
+
return { status: "unreachable", remote, stderr: (probe.stderr ?? "").trim() || stderr };
|
|
754
|
+
}
|
|
755
|
+
const contains = await rt.spawn(
|
|
756
|
+
["git", "merge-base", "--is-ancestor", PROBE_REF, `refs/heads/${STATE_BRANCH}`],
|
|
757
|
+
{ cwd, env: C_LOCALE_ENV },
|
|
758
|
+
);
|
|
759
|
+
await rt.spawn(["git", "update-ref", "-d", PROBE_REF], { cwd, env: C_LOCALE_ENV });
|
|
760
|
+
if (contains.exitCode === 0) return { status: "ahead", remote };
|
|
761
|
+
return { status: "diverged", remote, stderr };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Thrown when the ledger branch cannot be read, rather than being reported as
|
|
766
|
+
* empty (#2303).
|
|
767
|
+
*/
|
|
768
|
+
export class LifecycleLedgerUnreadableError extends Error {
|
|
769
|
+
constructor(
|
|
770
|
+
public readonly reason: "unreachable" | "diverged",
|
|
771
|
+
public readonly remote: string,
|
|
772
|
+
public readonly stderr: string,
|
|
773
|
+
detail: string,
|
|
774
|
+
) {
|
|
775
|
+
super(
|
|
776
|
+
`the ${STATE_BRANCH} ledger branch ${detail}, so what is on it cannot be read. ` +
|
|
777
|
+
`Refusing rather than treating an unreadable ledger as an empty one.` +
|
|
778
|
+
(reason === "diverged"
|
|
779
|
+
? `\n Two histories claim to be the ledger; appending to either one drops the other's facts. ` +
|
|
780
|
+
`Inspect both before doing anything else:\n` +
|
|
781
|
+
` git fetch ${remote} ${STATE_BRANCH}:refs/chant/remote-ledger\n` +
|
|
782
|
+
` git log --oneline ${STATE_BRANCH} refs/chant/remote-ledger`
|
|
783
|
+
: `\n Fetch it and retry: git fetch ${remote} ${STATE_BRANCH}:${STATE_BRANCH}`) +
|
|
784
|
+
(stderr ? `\n git said: ${stderr.split("\n")[0]}` : ""),
|
|
785
|
+
);
|
|
786
|
+
this.name = "LifecycleLedgerUnreadableError";
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Bring the ledger branch into this checkout before reading or appending to
|
|
792
|
+
* it, and refuse when it cannot be (#2303).
|
|
793
|
+
*
|
|
794
|
+
* The gate's read is the one that has to be right: `evaluateGate` decides
|
|
795
|
+
* whether to walk through or to record a *new* pending fact purely from what
|
|
796
|
+
* the ledger says, so a read that reports history it cannot see as an empty
|
|
797
|
+
* ledger makes the second run of an already-approved commit record a second
|
|
798
|
+
* pending fact and gate again. That is what a GitLab CI retry did on
|
|
799
|
+
* INTENTIUS/choudoufu#1026 — two pending records for one commit, with
|
|
800
|
+
* different expiries — because a GitLab checkout fetches the pipeline's own
|
|
801
|
+
* ref at depth 20 and nothing else. `GIT_DEPTH: 0` does not help: GitLab
|
|
802
|
+
* fetches refspecs, not all branches.
|
|
803
|
+
*
|
|
804
|
+
* There was a separate, laxer guard for the read until #2309's review, on the
|
|
805
|
+
* reasoning that a checkout which already has the branch "has real history to
|
|
806
|
+
* read". That reasoning is only sound when the local branch *is* the remote's
|
|
807
|
+
* history, and it let the worst case through: on a fork, the gate read a
|
|
808
|
+
* ledger missing every remote approval, recorded a fresh pending fact, and
|
|
809
|
+
* pushed. The push was not protected either — a rejected non-fast-forward
|
|
810
|
+
* fetch still updates `refs/remotes/<remote>/chant/lifecycle` opportunistically
|
|
811
|
+
* in a full clone, so `pushLifecycle`'s `--force-with-lease` matched the tip
|
|
812
|
+
* it had just learned and forced. Verified on git 2.50.1: a remote holding a
|
|
813
|
+
* pending fact and an approval ended up holding neither.
|
|
814
|
+
*
|
|
815
|
+
* So there is one rule now, for readers and writers alike:
|
|
816
|
+
*
|
|
817
|
+
* - `diverged` always refuses. A fork is not the ledger, and nothing good
|
|
818
|
+
* comes of reading one as if it were.
|
|
819
|
+
* - `unreachable` refuses only when the checkout has no local history at all,
|
|
820
|
+
* which is the case that would otherwise read as an empty ledger. With
|
|
821
|
+
* local history and no answer from the remote, the read proceeds and the
|
|
822
|
+
* push's lease fails safely on its own — refusing there would strand every
|
|
823
|
+
* offline run.
|
|
824
|
+
* - `fetched`, `no-remote`, `absent` and `ahead` all proceed. `ahead` is the
|
|
825
|
+
* ordinary state right after an append whose push has not landed, and is
|
|
826
|
+
* exactly what a retry needs to be able to repair.
|
|
827
|
+
*/
|
|
828
|
+
export async function requireLifecycleLedger(opts?: { cwd?: string }): Promise<void> {
|
|
829
|
+
const hadLocal = (await getStateBranchTip(opts?.cwd)) !== null;
|
|
830
|
+
const result = await fetchLifecycleStatus(opts);
|
|
831
|
+
|
|
832
|
+
if (result.status === "diverged") {
|
|
833
|
+
throw new LifecycleLedgerUnreadableError(
|
|
834
|
+
"diverged",
|
|
835
|
+
result.remote,
|
|
836
|
+
result.stderr,
|
|
837
|
+
`has diverged from the copy on "${result.remote}" — neither contains the other`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (result.status === "unreachable" && !hadLocal) {
|
|
841
|
+
throw new LifecycleLedgerUnreadableError(
|
|
842
|
+
"unreachable",
|
|
843
|
+
result.remote,
|
|
844
|
+
result.stderr,
|
|
845
|
+
`is not in this checkout and could not be fetched from "${result.remote}"`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
547
848
|
}
|
|
548
849
|
|
|
549
850
|
// ── Generic ref CAS (#1485) ──────────────────────────────────────────────────
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { computePlanDigest, isPlanDigest, describePlanDigest } from "./plan-digest";
|
|
3
|
+
|
|
4
|
+
describe("computePlanDigest", () => {
|
|
5
|
+
test("the same change set digests the same, whatever order its keys arrived in", () => {
|
|
6
|
+
const a = computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a", actions: ["create"] });
|
|
7
|
+
const b = computePlanDigest("terraform-plan", { actions: ["create"], address: "aws_s3_bucket.a" });
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a changed address changes it", () => {
|
|
12
|
+
expect(computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a" })).not.toBe(
|
|
13
|
+
computePlanDigest("terraform-plan", { address: "aws_s3_bucket.b" }),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The kind is hashed alongside the subject so a gate bound to a terraform
|
|
18
|
+
// plan cannot be satisfied by another kind of plan that happened to
|
|
19
|
+
// serialize identically.
|
|
20
|
+
test("two kinds of plan over identical data do not collide", () => {
|
|
21
|
+
expect(computePlanDigest("terraform-plan", { x: 1 })).not.toBe(
|
|
22
|
+
computePlanDigest("lifecycle-diff", { x: 1 }),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("it is a sha256 digest, in the shape isPlanDigest accepts", () => {
|
|
27
|
+
const digest = computePlanDigest("terraform-plan", {});
|
|
28
|
+
expect(digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
29
|
+
expect(isPlanDigest(digest)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isPlanDigest", () => {
|
|
34
|
+
test("refuses everything a copy-paste or a path could be", () => {
|
|
35
|
+
expect(isPlanDigest("chant.tfplan")).toBe(false);
|
|
36
|
+
expect(isPlanDigest(`sha256:${"a".repeat(63)}`)).toBe(false);
|
|
37
|
+
expect(isPlanDigest("a".repeat(64))).toBe(false);
|
|
38
|
+
expect(isPlanDigest(`sha256:${"A".repeat(64)}`)).toBe(false);
|
|
39
|
+
expect(isPlanDigest(undefined)).toBe(false);
|
|
40
|
+
expect(isPlanDigest(12)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("describePlanDigest", () => {
|
|
45
|
+
test("an absent digest reads as the pre-#2300 record it is, never as undefined", () => {
|
|
46
|
+
expect(describePlanDigest(undefined)).toBe("(none — recorded before plan-bound gates)");
|
|
47
|
+
expect(describePlanDigest("sha256:abc")).toBe("sha256:abc");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -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
|
}
|