@intentius/chant 0.60.0 → 0.62.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/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +0 -18
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/codegen/json-schema.d.ts +5 -2
- package/dist/codegen/json-schema.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +70 -2
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.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/observe.d.ts.map +1 -1
- package/dist/observation.d.ts +21 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +225 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +17 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/lifecycle.ts +1 -0
- package/src/cli/handlers/operator.test.ts +102 -1
- package/src/cli/handlers/operator.ts +75 -5
- package/src/codegen/json-schema.test.ts +159 -0
- package/src/codegen/json-schema.ts +106 -8
- package/src/graph-ir.test.ts +63 -0
- package/src/graph-ir.ts +125 -8
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.ts +1 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/observe.ts +3 -0
- package/src/observation.test.ts +21 -9
- package/src/observation.ts +31 -2
- package/src/op/activities/lexicon-upgrade.test.ts +122 -39
- package/src/op/activities/lexicon-upgrade.ts +55 -9
- package/src/op/activities/reconcile.test.ts +527 -1
- package/src/op/activities/reconcile.ts +446 -23
- package/src/op/gate.test.ts +504 -0
- package/src/op/gate.ts +9 -1
- package/src/op/local-executor.test.ts +115 -0
- package/src/op/local-executor.ts +130 -21
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtimes/local.ts +11 -0
- package/src/runtime-adapter.ts +17 -3
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/observe.ts
CHANGED
|
@@ -55,6 +55,8 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
|
|
|
55
55
|
resources: q(obs.resources),
|
|
56
56
|
unobserved: q(obs.unobserved),
|
|
57
57
|
queried: q(obs.queried),
|
|
58
|
+
// Which read answered (#2267) is per entity, so it re-keys with them.
|
|
59
|
+
sources: q(obs.sources),
|
|
58
60
|
notes: obs.notes,
|
|
59
61
|
// Exports are already keyed by stack (#1279); nothing to qualify.
|
|
60
62
|
...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
|
|
@@ -283,6 +285,7 @@ export async function observeResources(
|
|
|
283
285
|
resources: {},
|
|
284
286
|
unobserved: unobservedAll(entityNames, "read-failed", message, entities),
|
|
285
287
|
queried: {},
|
|
288
|
+
sources: {},
|
|
286
289
|
notes: [],
|
|
287
290
|
},
|
|
288
291
|
environment,
|
package/src/observation.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
|
29
29
|
|
|
30
30
|
describe("normalizeObservation", () => {
|
|
31
31
|
test("a bare map means 'I looked at everything'", () => {
|
|
32
|
-
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
|
|
32
|
+
expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("the envelope carries both halves", () => {
|
|
@@ -38,6 +38,7 @@ describe("normalizeObservation", () => {
|
|
|
38
38
|
resources: { a: meta() },
|
|
39
39
|
unobserved: { b: { reason: "read-failed" } },
|
|
40
40
|
queried: {},
|
|
41
|
+
sources: {},
|
|
41
42
|
notes: [],
|
|
42
43
|
});
|
|
43
44
|
});
|
|
@@ -50,7 +51,7 @@ describe("normalizeObservation", () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
test("undefined normalizes to empty maps", () => {
|
|
53
|
-
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
|
|
54
|
+
expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] });
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test("the envelope carries the queried addresses through normalization (#1620)", () => {
|
|
@@ -92,8 +93,8 @@ describe("unobservedAll", () => {
|
|
|
92
93
|
describe("mergeObservations (multi-stack)", () => {
|
|
93
94
|
test("present beats not-observed beats absent", () => {
|
|
94
95
|
const merged = mergeObservations([
|
|
95
|
-
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
|
|
96
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
96
|
+
{ resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, sources: {}, notes: [] },
|
|
97
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
97
98
|
]);
|
|
98
99
|
expect(Object.keys(merged.resources)).toEqual(["a"]);
|
|
99
100
|
expect(Object.keys(merged.unobserved)).toEqual(["b"]);
|
|
@@ -102,26 +103,37 @@ describe("mergeObservations (multi-stack)", () => {
|
|
|
102
103
|
test("the same note from four stacks is one note (#1265)", () => {
|
|
103
104
|
const note = "ownership filter unavailable";
|
|
104
105
|
const merged = mergeObservations(
|
|
105
|
-
["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
|
|
106
|
+
["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [note] })),
|
|
106
107
|
);
|
|
107
108
|
expect(merged.notes).toEqual([note]);
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
test("an entity nobody looked for in any stack stays absent", () => {
|
|
111
112
|
const merged = mergeObservations([
|
|
112
|
-
{ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
113
|
-
{ resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
|
|
113
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
114
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
|
|
114
115
|
]);
|
|
115
116
|
expect(merged.unobserved).toEqual({});
|
|
116
117
|
});
|
|
117
118
|
|
|
118
119
|
test("queried addresses union across stacks (#1620)", () => {
|
|
119
120
|
const merged = mergeObservations([
|
|
120
|
-
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
|
|
121
|
-
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
|
|
121
|
+
{ resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, sources: {}, notes: [] },
|
|
122
|
+
{ resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, sources: {}, notes: [] },
|
|
122
123
|
]);
|
|
123
124
|
expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
test("which read answered unions across parts (#2267)", () => {
|
|
128
|
+
// Parts read disjoint entity sets (one per stack, or per terraform root),
|
|
129
|
+
// so the union is the whole answer and there is nothing to reconcile. A
|
|
130
|
+
// project mixing a stock root and a live one gets both values here.
|
|
131
|
+
const merged = mergeObservations([
|
|
132
|
+
{ resources: { a: meta() }, unobserved: {}, queried: {}, sources: { a: "state" }, notes: [] },
|
|
133
|
+
{ resources: { b: meta() }, unobserved: {}, queried: {}, sources: { b: "live" }, notes: [] },
|
|
134
|
+
]);
|
|
135
|
+
expect(merged.sources).toEqual({ a: "state", b: "live" });
|
|
136
|
+
});
|
|
125
137
|
});
|
|
126
138
|
|
|
127
139
|
describe("reason totality", () => {
|
package/src/observation.ts
CHANGED
|
@@ -112,6 +112,24 @@ export interface ObservationResult {
|
|
|
112
112
|
* namespace, endpoint or region was read, not the one the resource lives in.
|
|
113
113
|
*/
|
|
114
114
|
queried?: Record<string, string>;
|
|
115
|
+
/**
|
|
116
|
+
* Which read answered for each declared entity (#2267), keyed by chant
|
|
117
|
+
* entity name. The value is a short token the lexicon defines and documents;
|
|
118
|
+
* core carries it and never interprets it, so nothing here switches on one.
|
|
119
|
+
*
|
|
120
|
+
* Additive metadata over the tri-state, exactly like {@link queried}, and
|
|
121
|
+
* present for every verdict including OBSERVED-ABSENT and NOT-OBSERVED,
|
|
122
|
+
* since which read was attempted is a fact whatever the read came back with.
|
|
123
|
+
*
|
|
124
|
+
* A lexicon with one read path has nothing to say here and omits it. This is
|
|
125
|
+
* for a lexicon with two, where the two answer different questions: a stock
|
|
126
|
+
* Terraform root is read with `terraform show -json` over its state, which
|
|
127
|
+
* says what the last apply recorded, and a live root is read with
|
|
128
|
+
* `choudoufu live-plan -json`, which says what the account holds. A renderer
|
|
129
|
+
* that paints an overlay as drift must not paint the first one green, and
|
|
130
|
+
* before this field an observation gave it no way to tell them apart.
|
|
131
|
+
*/
|
|
132
|
+
sources?: Record<string, string>;
|
|
115
133
|
/**
|
|
116
134
|
* Notices about the read as a whole, not about any one entity (#1265) —
|
|
117
135
|
* "the ownership filter could not be applied on this surface" is the
|
|
@@ -144,6 +162,8 @@ export interface NormalizedObservation {
|
|
|
144
162
|
unobserved: Record<string, UnobservedEntity>;
|
|
145
163
|
/** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
|
|
146
164
|
queried: Record<string, string>;
|
|
165
|
+
/** Which read answered, per entity name (#2267). Empty when the lexicon reported none. */
|
|
166
|
+
sources: Record<string, string>;
|
|
147
167
|
/** Run-level notices (#1265), distinct. Empty when the lexicon reported none. */
|
|
148
168
|
notes: string[];
|
|
149
169
|
/** Per-stack exports (#1279), keyed by stack name. Absent when the lexicon reported none. */
|
|
@@ -169,12 +189,14 @@ export function observation(
|
|
|
169
189
|
queried?: Record<string, string>,
|
|
170
190
|
notes?: string[],
|
|
171
191
|
stackExports?: Record<string, Record<string, unknown>>,
|
|
192
|
+
sources?: Record<string, string>,
|
|
172
193
|
): ObservationResult {
|
|
173
194
|
return {
|
|
174
195
|
observation: "v1",
|
|
175
196
|
resources,
|
|
176
197
|
...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
|
|
177
198
|
...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
|
|
199
|
+
...(sources && Object.keys(sources).length > 0 ? { sources } : {}),
|
|
178
200
|
...(notes && notes.length > 0 ? { notes } : {}),
|
|
179
201
|
...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
|
|
180
202
|
};
|
|
@@ -187,17 +209,18 @@ export function observation(
|
|
|
187
209
|
* {@link unobservedAll} rather than returning nothing.
|
|
188
210
|
*/
|
|
189
211
|
export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
|
|
190
|
-
if (!value) return { resources: {}, unobserved: {}, queried: {}, notes: [] };
|
|
212
|
+
if (!value) return { resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] };
|
|
191
213
|
if (isObservationResult(value)) {
|
|
192
214
|
return {
|
|
193
215
|
resources: value.resources ?? {},
|
|
194
216
|
unobserved: value.unobserved ?? {},
|
|
195
217
|
queried: value.queried ?? {},
|
|
218
|
+
sources: value.sources ?? {},
|
|
196
219
|
notes: [...new Set(value.notes ?? [])],
|
|
197
220
|
...(value.stackExports && Object.keys(value.stackExports).length > 0 ? { stackExports: value.stackExports } : {}),
|
|
198
221
|
};
|
|
199
222
|
}
|
|
200
|
-
return { resources: value, unobserved: {}, queried: {}, notes: [] };
|
|
223
|
+
return { resources: value, unobserved: {}, queried: {}, sources: {}, notes: [] };
|
|
201
224
|
}
|
|
202
225
|
|
|
203
226
|
/**
|
|
@@ -237,6 +260,10 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
237
260
|
const resources: Record<string, ResourceMetadata> = {};
|
|
238
261
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
239
262
|
const queried: Record<string, string> = {};
|
|
263
|
+
// Which read answered (#2267) merges the same way `queried` does: it is a
|
|
264
|
+
// fact about one entity's read, and the parts being merged are reads of
|
|
265
|
+
// disjoint entity sets (one per stack, or per terraform root).
|
|
266
|
+
const sources: Record<string, string> = {};
|
|
240
267
|
// A note is about the read, not a stack; four stacks saying the same thing
|
|
241
268
|
// is one note (#1265).
|
|
242
269
|
const notes = new Set<string>();
|
|
@@ -245,6 +272,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
245
272
|
Object.assign(resources, part.resources);
|
|
246
273
|
Object.assign(unobserved, part.unobserved);
|
|
247
274
|
Object.assign(queried, part.queried);
|
|
275
|
+
Object.assign(sources, part.sources);
|
|
248
276
|
for (const n of part.notes) notes.add(n);
|
|
249
277
|
Object.assign(stackExports, part.stackExports ?? {});
|
|
250
278
|
}
|
|
@@ -255,6 +283,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
|
|
|
255
283
|
resources,
|
|
256
284
|
unobserved,
|
|
257
285
|
queried,
|
|
286
|
+
sources,
|
|
258
287
|
notes: [...notes],
|
|
259
288
|
...(Object.keys(stackExports).length > 0 ? { stackExports } : {}),
|
|
260
289
|
};
|