@intentius/chant 0.34.1 → 0.37.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/commands/check-lexicon-docs.d.ts +42 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.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/codegen/docs.d.ts +16 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +1 -1
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +0 -10
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lifecycle/rollback.d.ts +18 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +53 -0
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
- package/src/cli/commands/check-lexicon-docs.ts +71 -0
- package/src/cli/commands/check-lexicon.ts +15 -0
- package/src/cli/handlers/components.ts +54 -3
- package/src/cli/handlers/graph.test.ts +61 -0
- package/src/cli/handlers/lifecycle.ts +8 -1
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs.ts +122 -5
- package/src/codegen/fetch.test.ts +24 -5
- package/src/codegen/fetch.ts +19 -1
- package/src/deep-observation.test.ts +151 -0
- package/src/deep-observation.ts +66 -2
- package/src/lifecycle/rollback.test.ts +78 -1
- package/src/lifecycle/rollback.ts +41 -2
- package/src/lifecycle/status.test.ts +90 -5
- package/src/lifecycle/status.ts +85 -2
- package/src/yaml.test.ts +89 -0
- package/src/yaml.ts +66 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { rollbackBranchName, rollbackTitle, rollbackBody } from "./rollback";
|
|
2
|
+
import { rollbackBranchName, rollbackTitle, rollbackBody, rollbackToRevision } from "./rollback";
|
|
3
3
|
|
|
4
4
|
describe("rollback helpers (#873)", () => {
|
|
5
5
|
test("branch name includes env and short ref", () => {
|
|
@@ -23,3 +23,80 @@ describe("rollback helpers (#873)", () => {
|
|
|
23
23
|
expect(body).toMatch(/approval gate|Sync|apply/i);
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
// A dry run has to work where the PR path cannot: no remote, no `gh`, and the
|
|
28
|
+
// repository left exactly as it was found. That is what makes chant#1208's
|
|
29
|
+
// round-trip demonstrable offline instead of only asserting the noop case.
|
|
30
|
+
describe("rollbackToRevision --dry-run", () => {
|
|
31
|
+
const git = async (args: string[], cwd: string) => {
|
|
32
|
+
const { promisify } = await import("node:util");
|
|
33
|
+
const { execFile } = await import("node:child_process");
|
|
34
|
+
return promisify(execFile)("git", args, { cwd });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A throwaway repo with two commits and NO remote configured. */
|
|
38
|
+
async function repoWithTwoCommits(): Promise<{ dir: string; base: string }> {
|
|
39
|
+
const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
|
|
40
|
+
const { tmpdir } = await import("node:os");
|
|
41
|
+
const { join } = await import("node:path");
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-rollback-test-"));
|
|
43
|
+
mkdirSync(join(dir, "src"));
|
|
44
|
+
await git(["init", "-q"], dir);
|
|
45
|
+
await git(["config", "user.email", "t@example.com"], dir);
|
|
46
|
+
await git(["config", "user.name", "t"], dir);
|
|
47
|
+
writeFileSync(join(dir, "src", "main.ts"), "export const a = 1;\n");
|
|
48
|
+
await git(["add", "-A"], dir);
|
|
49
|
+
await git(["commit", "-qm", "v1"], dir);
|
|
50
|
+
const base = (await git(["rev-parse", "HEAD"], dir)).stdout.trim();
|
|
51
|
+
writeFileSync(join(dir, "src", "main.ts"), "export const a = 2;\n");
|
|
52
|
+
await git(["add", "-A"], dir);
|
|
53
|
+
await git(["commit", "-qm", "v2"], dir);
|
|
54
|
+
return { dir, base };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("returns the delta without a remote, and leaves no branch behind", async () => {
|
|
58
|
+
const { dir, base } = await repoWithTwoCommits();
|
|
59
|
+
const before = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
|
|
60
|
+
|
|
61
|
+
const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
62
|
+
|
|
63
|
+
expect(result.noop).toBe(false);
|
|
64
|
+
expect(result.prUrl).toBeUndefined();
|
|
65
|
+
expect(result.diff).toContain("-export const a = 2;");
|
|
66
|
+
expect(result.diff).toContain("+export const a = 1;");
|
|
67
|
+
|
|
68
|
+
// Nothing persisted: same branches as before, and the working tree still
|
|
69
|
+
// holds the NEW content — a dry run reports, it does not roll back.
|
|
70
|
+
const after = (await git(["branch", "--format=%(refname:short)"], dir)).stdout.trim();
|
|
71
|
+
expect(after).toBe(before);
|
|
72
|
+
const { readFileSync } = await import("node:fs");
|
|
73
|
+
const { join } = await import("node:path");
|
|
74
|
+
expect(readFileSync(join(dir, "src", "main.ts"), "utf8")).toContain("a = 2");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("removes a file added since the ref — a per-path checkout leaves it, and that read as noop (#1327)", async () => {
|
|
78
|
+
// The shape a reconcile produces: `chant import --from <env>` writes NEW
|
|
79
|
+
// files rather than editing the authored ones, so the whole difference from
|
|
80
|
+
// the pre-reconcile revision is additions. `git checkout <ref> -- <dir>`
|
|
81
|
+
// never touches those, so rollback used to report "nothing to roll back".
|
|
82
|
+
const { dir, base } = await repoWithTwoCommits();
|
|
83
|
+
const { writeFileSync } = await import("node:fs");
|
|
84
|
+
const { join } = await import("node:path");
|
|
85
|
+
writeFileSync(join(dir, "src", "generated.ts"), "export const added = true;\n");
|
|
86
|
+
await git(["add", "-A"], dir);
|
|
87
|
+
await git(["commit", "-qm", "reconciled: a new file"], dir);
|
|
88
|
+
|
|
89
|
+
const result = await rollbackToRevision({ ref: base, env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
90
|
+
|
|
91
|
+
expect(result.noop).toBe(false);
|
|
92
|
+
expect(result.diff).toContain("src/generated.ts");
|
|
93
|
+
expect(result.diff).toContain("deleted file");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("still reports noop when the source already matches the ref", async () => {
|
|
97
|
+
const { dir } = await repoWithTwoCommits();
|
|
98
|
+
const result = await rollbackToRevision({ ref: "HEAD", env: "local", sourceDir: "src", cwd: dir, dryRun: true });
|
|
99
|
+
expect(result.noop).toBe(true);
|
|
100
|
+
expect(result.diff).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -40,19 +40,37 @@ export interface RollbackResult {
|
|
|
40
40
|
noop: boolean;
|
|
41
41
|
branch?: string;
|
|
42
42
|
prUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The rollback delta as a unified diff — present only for a `dryRun`, where
|
|
45
|
+
* it is the whole point: the PR body's diff is what a reviewer would act on,
|
|
46
|
+
* so producing it without a PR is what makes the delta inspectable offline.
|
|
47
|
+
*/
|
|
48
|
+
diff?: string;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Open a rollback PR restoring `sourceDir` to `ref`. Isolated worktree; the
|
|
47
53
|
* caller's branch is untouched. Throws on an unknown ref or a git/gh failure.
|
|
54
|
+
*
|
|
55
|
+
* `dryRun` computes the same delta and returns it as a diff without pushing a
|
|
56
|
+
* branch or opening a PR, leaving the repository exactly as it found it.
|
|
57
|
+
*
|
|
58
|
+
* That mode exists because the PR path needs a GitHub remote and an
|
|
59
|
+
* authenticated `gh` — reasonable for the operator flow this was built for
|
|
60
|
+
* (#873), and impossible for a hermetic acceptance run. chant#1208's CC
|
|
61
|
+
* round-trip has to demonstrate rollback offline, on an emulator, with no
|
|
62
|
+
* remote in the picture; without this it could only assert the `noop` case,
|
|
63
|
+
* which exercises none of the interesting work.
|
|
48
64
|
*/
|
|
49
65
|
export async function rollbackToRevision(opts: {
|
|
50
66
|
ref: string;
|
|
51
67
|
env: string | undefined;
|
|
52
68
|
sourceDir: string;
|
|
53
69
|
cwd: string;
|
|
70
|
+
/** Compute and return the delta; open no PR, push nothing, leave no branch. */
|
|
71
|
+
dryRun?: boolean;
|
|
54
72
|
}): Promise<RollbackResult> {
|
|
55
|
-
const { ref, env, sourceDir, cwd } = opts;
|
|
73
|
+
const { ref, env, sourceDir, cwd, dryRun } = opts;
|
|
56
74
|
const git = (args: string[], wd: string): Promise<{ stdout: string }> => execFileAsync("git", args, { cwd: wd });
|
|
57
75
|
|
|
58
76
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)).stdout.trim();
|
|
@@ -64,11 +82,28 @@ export async function rollbackToRevision(opts: {
|
|
|
64
82
|
|
|
65
83
|
await git(["worktree", "add", wt, "-b", branch, "HEAD"], repoRoot);
|
|
66
84
|
try {
|
|
67
|
-
// Restore
|
|
85
|
+
// Restore the source tree to the target revision (stages the changes).
|
|
86
|
+
//
|
|
87
|
+
// `git checkout <ref> -- <dir>` alone is a PER-PATH checkout, not a tree
|
|
88
|
+
// replacement: it restores the paths that exist at `ref` and leaves
|
|
89
|
+
// everything else untouched, so a file added since `ref` survives. That
|
|
90
|
+
// made rollback report "nothing to roll back" whenever the only difference
|
|
91
|
+
// was added files (#1327) — which is exactly what a reconcile produces,
|
|
92
|
+
// since `chant import --from <env>` writes NEW files rather than editing
|
|
93
|
+
// the authored ones. Clearing the directory first makes this a real
|
|
94
|
+
// restore, so the deletions show up in the delta.
|
|
95
|
+
await git(["rm", "-rq", "--ignore-unmatch", "--", sourceDir], wt);
|
|
68
96
|
await git(["checkout", ref, "--", sourceDir], wt);
|
|
69
97
|
const staged = (await git(["status", "--porcelain", "--", sourceDir], wt)).stdout.trim();
|
|
70
98
|
if (!staged) return { noop: true };
|
|
71
99
|
|
|
100
|
+
// The delta, before committing: `git diff` against the index shows exactly
|
|
101
|
+
// what restoring the source to `ref` changes. A dry run stops here.
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
const { stdout } = await git(["diff", "--cached", "--", sourceDir], wt);
|
|
104
|
+
return { noop: false, branch, diff: stdout };
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
await git(["commit", "-m", rollbackTitle(env, ref)], wt);
|
|
73
108
|
await git(["push", "-u", "origin", branch], wt);
|
|
74
109
|
const { stdout } = await execFileAsync(
|
|
@@ -79,5 +114,9 @@ export async function rollbackToRevision(opts: {
|
|
|
79
114
|
return { noop: false, branch, prUrl: stdout.trim() };
|
|
80
115
|
} finally {
|
|
81
116
|
await git(["worktree", "remove", "--force", wt], repoRoot).catch(() => {});
|
|
117
|
+
// Removing the worktree leaves its branch behind. For the PR path that is
|
|
118
|
+
// correct — the branch is the PR. A dry run must leave nothing, or a repo
|
|
119
|
+
// accumulates a `chant/rollback-*` branch per inspection.
|
|
120
|
+
if (dryRun) await git(["branch", "-D", branch], repoRoot).catch(() => {});
|
|
82
121
|
}
|
|
83
122
|
}
|
|
@@ -37,8 +37,18 @@ describe("status", () => {
|
|
|
37
37
|
],
|
|
38
38
|
};
|
|
39
39
|
const evidence = liveEvidenceFromChangeSet(cs);
|
|
40
|
-
expect(evidence.get("search-service")).toEqual({
|
|
41
|
-
|
|
40
|
+
expect(evidence.get("search-service")).toEqual({
|
|
41
|
+
live: true,
|
|
42
|
+
action: "noop",
|
|
43
|
+
ownership: "owned",
|
|
44
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
45
|
+
});
|
|
46
|
+
expect(evidence.get("orphan-thing")).toEqual({
|
|
47
|
+
live: true,
|
|
48
|
+
action: "adopt",
|
|
49
|
+
ownership: "foreign",
|
|
50
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
51
|
+
});
|
|
42
52
|
});
|
|
43
53
|
|
|
44
54
|
// #598: a component's name need not equal the live entity/resource name
|
|
@@ -54,7 +64,15 @@ describe("status", () => {
|
|
|
54
64
|
};
|
|
55
65
|
const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
|
|
56
66
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
57
|
-
|
|
67
|
+
// The merged verdict, plus the per-resource counts it collapsed
|
|
68
|
+
// (behold#98) — a consumer with no deploy object to read paints from
|
|
69
|
+
// those rather than from a CloudFormation stack.
|
|
70
|
+
expect(evidence.get("search-svc")).toEqual({
|
|
71
|
+
live: true,
|
|
72
|
+
action: "noop",
|
|
73
|
+
ownership: "owned",
|
|
74
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
75
|
+
});
|
|
58
76
|
// The live entity's own name is no longer a separate top-level key once
|
|
59
77
|
// it's claimed by an explicit mapping's component.
|
|
60
78
|
});
|
|
@@ -68,7 +86,14 @@ describe("status", () => {
|
|
|
68
86
|
};
|
|
69
87
|
const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
|
|
70
88
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
71
|
-
|
|
89
|
+
// A rollup of one: the identity join still reports the shape, so a
|
|
90
|
+
// consumer never branches on whether a mapping was configured.
|
|
91
|
+
expect(evidence.get("search-service")).toEqual({
|
|
92
|
+
live: true,
|
|
93
|
+
action: "noop",
|
|
94
|
+
ownership: "owned",
|
|
95
|
+
rollup: { total: 1, present: 1, absent: 0, unobserved: 0 },
|
|
96
|
+
});
|
|
72
97
|
});
|
|
73
98
|
|
|
74
99
|
test("aggregates evidence across several live names owned by one component", () => {
|
|
@@ -82,7 +107,12 @@ describe("status", () => {
|
|
|
82
107
|
const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
|
|
83
108
|
const evidence = liveEvidenceFromChangeSet(cs, mapping);
|
|
84
109
|
// Drift on any owned entity surfaces as drift for the component.
|
|
85
|
-
expect(evidence.get("neo4j-cluster")).toEqual({
|
|
110
|
+
expect(evidence.get("neo4j-cluster")).toEqual({
|
|
111
|
+
live: true,
|
|
112
|
+
action: "update",
|
|
113
|
+
ownership: "owned",
|
|
114
|
+
rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
|
|
115
|
+
});
|
|
86
116
|
});
|
|
87
117
|
|
|
88
118
|
test("a mapped component with none of its live names observed has no evidence entry", () => {
|
|
@@ -135,6 +165,39 @@ describe("status", () => {
|
|
|
135
165
|
expect(rows[0].reconciliation).toBe("drifted");
|
|
136
166
|
});
|
|
137
167
|
|
|
168
|
+
// behold#98 — floci-az and floci-gcp have no deploy object, so `stack` is
|
|
169
|
+
// absent and a renderer has nothing provider-native to colour from. The
|
|
170
|
+
// rollup is the substrate-neutral source for the same job.
|
|
171
|
+
test("surfaces a resource rollup for a component with no deploy object", () => {
|
|
172
|
+
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
173
|
+
[
|
|
174
|
+
"search-service",
|
|
175
|
+
{ live: true, ownership: "owned", rollup: { total: 4, present: 3, absent: 0, unobserved: 1 } },
|
|
176
|
+
],
|
|
177
|
+
]);
|
|
178
|
+
const rows = reconcileStatus("prod", [record()], { liveEvidence });
|
|
179
|
+
expect(rows[0].resources).toEqual({ total: 4, present: 3, absent: 0, unobserved: 1 });
|
|
180
|
+
// No CloudFormation stack to enrich from, and the row is still paintable.
|
|
181
|
+
expect(rows[0].stack).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a rollup and a stack coexist — the stack stays the richer enrichment where it exists", () => {
|
|
185
|
+
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
186
|
+
[
|
|
187
|
+
"search-service",
|
|
188
|
+
{
|
|
189
|
+
live: true,
|
|
190
|
+
ownership: "owned",
|
|
191
|
+
stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true },
|
|
192
|
+
rollup: { total: 2, present: 2, absent: 0, unobserved: 0 },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
]);
|
|
196
|
+
const rows = reconcileStatus("prod", [record()], { liveEvidence });
|
|
197
|
+
expect(rows[0].resources).toEqual({ total: 2, present: 2, absent: 0, unobserved: 0 });
|
|
198
|
+
expect(rows[0].stack?.healthy).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
138
201
|
test("surfaces machine-readable live + stack status when observed (#57 hardening)", () => {
|
|
139
202
|
const liveEvidence = new Map<string, LiveComponentEvidence>([
|
|
140
203
|
["search-service", { live: true, ownership: "owned", stack: { name: "app-prod-search", status: "CREATE_COMPLETE", healthy: true } }],
|
|
@@ -395,6 +458,28 @@ describe("status", () => {
|
|
|
395
458
|
expect(merged.get("a")!.unobserved).toBeUndefined();
|
|
396
459
|
expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
|
|
397
460
|
});
|
|
461
|
+
|
|
462
|
+
test("the change-set rollup survives the stack overlay (behold#100)", () => {
|
|
463
|
+
// The merge rebuilds the evidence object field by field, so a field it
|
|
464
|
+
// does not name is dropped. `describeStackStatus` reports a stack, not
|
|
465
|
+
// per-resource counts, so the supplement never carries a rollup — and
|
|
466
|
+
// dropping the base's meant AWS, the only substrate with a stack
|
|
467
|
+
// observer, was the one substrate whose rows lost the #1300 counts.
|
|
468
|
+
const rollup = { total: 10, present: 10, absent: 0, unobserved: 0 };
|
|
469
|
+
const base = new Map<string, LiveComponentEvidence>([["cc-canonical", { live: true, ownership: "owned", rollup }]]);
|
|
470
|
+
const supplement = new Map<string, LiveComponentEvidence>([
|
|
471
|
+
["cc-canonical", { live: true, ownership: "owned", stack: { name: "cc-canonical", status: "CREATE_COMPLETE", healthy: true } }],
|
|
472
|
+
]);
|
|
473
|
+
const merged = mergeLiveEvidence(base, supplement);
|
|
474
|
+
expect(merged.get("cc-canonical")!.rollup).toEqual(rollup);
|
|
475
|
+
expect(merged.get("cc-canonical")!.stack?.status).toBe("CREATE_COMPLETE");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("no rollup on either side leaves the field absent rather than undefined", () => {
|
|
479
|
+
const base = new Map<string, LiveComponentEvidence>([["c", { live: true }]]);
|
|
480
|
+
const supplement = new Map<string, LiveComponentEvidence>([["c", { live: true, ownership: "owned" }]]);
|
|
481
|
+
expect(mergeLiveEvidence(base, supplement).get("c")).not.toHaveProperty("rollup");
|
|
482
|
+
});
|
|
398
483
|
});
|
|
399
484
|
|
|
400
485
|
// ── The observation tri-state reaches the status join (#1089) ─────────────
|
package/src/lifecycle/status.ts
CHANGED
|
@@ -86,6 +86,19 @@ export interface ComponentStatusRow {
|
|
|
86
86
|
* present-but-not-healthy amber (mid-deploy) / red (rollback/failed).
|
|
87
87
|
*/
|
|
88
88
|
stack?: LiveStackInfo;
|
|
89
|
+
/**
|
|
90
|
+
* How this component's own resources answered (behold#98). Present whenever
|
|
91
|
+
* `--live` gathered evidence across a live-name mapping.
|
|
92
|
+
*
|
|
93
|
+
* `stack` above only exists where the substrate has a deploy object to read,
|
|
94
|
+
* which is AWS and nowhere else — floci-az and floci-gcp have none, so a
|
|
95
|
+
* consumer painting component status off `stack` has nothing to paint from
|
|
96
|
+
* there. These counts are the substrate-neutral source for the same job:
|
|
97
|
+
* they aggregate observations, which every lexicon produces, rather than a
|
|
98
|
+
* provider-specific grouping object. `stack` stays as the richer enrichment
|
|
99
|
+
* where it exists.
|
|
100
|
+
*/
|
|
101
|
+
resources?: ComponentResourceRollup;
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
/** A component's owning deploy unit and its provider-native status. */
|
|
@@ -129,6 +142,14 @@ export interface LiveComponentEvidence {
|
|
|
129
142
|
/** The owning deploy unit's raw status, when observed (AWS: the component's own
|
|
130
143
|
* CFN stack). Surfaced onto `ComponentStatusRow.stack` for a richer palette. */
|
|
131
144
|
stack?: LiveStackInfo;
|
|
145
|
+
/**
|
|
146
|
+
* How the component's own resources answered, before any merge collapsed
|
|
147
|
+
* them (behold#98). Always present when evidence exists — a component that
|
|
148
|
+
* maps to a single entity by identity gets a rollup of one, so a consumer
|
|
149
|
+
* never has to branch on whether a live-name mapping happened to be
|
|
150
|
+
* configured.
|
|
151
|
+
*/
|
|
152
|
+
rollup?: ComponentResourceRollup;
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
/**
|
|
@@ -141,6 +162,16 @@ export interface LiveComponentEvidence {
|
|
|
141
162
|
* is authoritative for **presence** (`live`) and **ownership**; the change-set's
|
|
142
163
|
* `action` is kept, since drift is still assessed from the diff. A component in
|
|
143
164
|
* only one map passes through unchanged.
|
|
165
|
+
*
|
|
166
|
+
* The change-set's `rollup` is kept too (behold#100). This merge rebuilds the
|
|
167
|
+
* evidence object field by field, so anything not named here is dropped — and
|
|
168
|
+
* `describeStackStatus` reports a stack, never per-resource counts, so the
|
|
169
|
+
* supplement has no rollup to contribute. Before this, every component on a
|
|
170
|
+
* lexicon that implements `describeStackStatus` lost the counts #1300 had just
|
|
171
|
+
* computed. That is AWS and only AWS, which made the rollup absent on exactly
|
|
172
|
+
* the substrate it was meant to be verified against: behold#98 shipped its
|
|
173
|
+
* consumer against floci-az/floci-gcp rows, where no stack observer runs and
|
|
174
|
+
* the field survived.
|
|
144
175
|
*/
|
|
145
176
|
export function mergeLiveEvidence(
|
|
146
177
|
base: Map<string, LiveComponentEvidence> | undefined,
|
|
@@ -158,6 +189,9 @@ export function mergeLiveEvidence(
|
|
|
158
189
|
ownership: sup.ownership ?? b?.ownership,
|
|
159
190
|
action: b?.action,
|
|
160
191
|
stack: sup.stack ?? b?.stack,
|
|
192
|
+
// Base first: the counts come from the change set, and a stack
|
|
193
|
+
// observation has none to offer.
|
|
194
|
+
...(b?.rollup ?? sup.rollup ? { rollup: b?.rollup ?? sup.rollup } : {}),
|
|
161
195
|
});
|
|
162
196
|
}
|
|
163
197
|
return merged;
|
|
@@ -180,6 +214,42 @@ export function resolveLiveNames(component: string, mapping?: LiveNameMapping):
|
|
|
180
214
|
return mapped && mapped.length > 0 ? mapped : [component];
|
|
181
215
|
}
|
|
182
216
|
|
|
217
|
+
/**
|
|
218
|
+
* How a component's own resources answered, one count per tri-state verdict.
|
|
219
|
+
*
|
|
220
|
+
* The merged verdict above is deliberately lossy — it answers "is this
|
|
221
|
+
* component deployed" and nothing else. A consumer painting component status
|
|
222
|
+
* without a deploy object to read (behold#98: floci-az and floci-gcp have no
|
|
223
|
+
* CloudFormation stack, so `stack` below is absent and there is nothing to
|
|
224
|
+
* colour from) needs the shape underneath: how many of the component's
|
|
225
|
+
* resources were seen, how many were confirmed gone, how many nobody could
|
|
226
|
+
* look at. Substrate-neutral by construction — it counts observations, not
|
|
227
|
+
* provider objects.
|
|
228
|
+
*/
|
|
229
|
+
export interface ComponentResourceRollup {
|
|
230
|
+
/** Resources this component owns, per the live-name mapping. */
|
|
231
|
+
total: number;
|
|
232
|
+
/** Observed present. */
|
|
233
|
+
present: number;
|
|
234
|
+
/** Looked for, reported missing. Never includes a resource nobody could read. */
|
|
235
|
+
absent: number;
|
|
236
|
+
/** NOT-OBSERVED (#1089) — a hole, never counted as absence. */
|
|
237
|
+
unobserved: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Count a component's entity verdicts without collapsing them (behold#98). */
|
|
241
|
+
function rollUp(entries: LiveComponentEvidence[]): ComponentResourceRollup {
|
|
242
|
+
let present = 0;
|
|
243
|
+
let absent = 0;
|
|
244
|
+
let unobserved = 0;
|
|
245
|
+
for (const e of entries) {
|
|
246
|
+
if (e.unobserved) unobserved += 1;
|
|
247
|
+
else if (e.live) present += 1;
|
|
248
|
+
else absent += 1;
|
|
249
|
+
}
|
|
250
|
+
return { total: entries.length, present, absent, unobserved };
|
|
251
|
+
}
|
|
252
|
+
|
|
183
253
|
/**
|
|
184
254
|
* Merge live evidence for several entity/resource names owned by one
|
|
185
255
|
* component into a single verdict. `live` and `ownership` favor the
|
|
@@ -187,10 +257,14 @@ export function resolveLiveNames(component: string, mapping?: LiveNameMapping):
|
|
|
187
257
|
* favors `update` so that drift on *any* owned entity surfaces as drift for
|
|
188
258
|
* the component as a whole, matching `reconcileStatus`'s single check for
|
|
189
259
|
* `action === "update"`.
|
|
260
|
+
*
|
|
261
|
+
* The per-entity counts survive as {@link LiveComponentEvidence.rollup}, so a
|
|
262
|
+
* consumer that needs the shape rather than the verdict is not forced to redo
|
|
263
|
+
* this join on the far side of a CLI boundary.
|
|
190
264
|
*/
|
|
191
265
|
function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence | undefined {
|
|
192
266
|
if (entries.length === 0) return undefined;
|
|
193
|
-
if (entries.length === 1) return entries[0];
|
|
267
|
+
if (entries.length === 1) return { ...entries[0], rollup: rollUp(entries) };
|
|
194
268
|
|
|
195
269
|
const live = entries.some((e) => e.live);
|
|
196
270
|
const ownership = entries.some((e) => e.ownership === "owned")
|
|
@@ -206,7 +280,7 @@ function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence
|
|
|
206
280
|
// actually seen live, which already answers "is this deployed".
|
|
207
281
|
const unobserved = live ? undefined : entries.find((e) => e.unobserved)?.unobserved;
|
|
208
282
|
|
|
209
|
-
return { live, ownership, action, ...(unobserved ? { unobserved } : {}) };
|
|
283
|
+
return { live, ownership, action, ...(unobserved ? { unobserved } : {}), rollup: rollUp(entries) };
|
|
210
284
|
}
|
|
211
285
|
|
|
212
286
|
/**
|
|
@@ -228,6 +302,14 @@ export function liveEvidenceFromChangeSet(
|
|
|
228
302
|
const evidenceByName = new Map<string, LiveComponentEvidence>();
|
|
229
303
|
for (const entry of cs.entries) {
|
|
230
304
|
evidenceByName.set(entry.name, {
|
|
305
|
+
rollup: rollUp([
|
|
306
|
+
{
|
|
307
|
+
live: entry.evidence.live,
|
|
308
|
+
...(entry.action === "unobserved" && entry.unobservedReason
|
|
309
|
+
? { unobserved: { reason: entry.unobservedReason } }
|
|
310
|
+
: {}),
|
|
311
|
+
},
|
|
312
|
+
]),
|
|
231
313
|
live: entry.evidence.live,
|
|
232
314
|
action: entry.action,
|
|
233
315
|
ownership: entry.ownership,
|
|
@@ -352,6 +434,7 @@ export function reconcileStatus(
|
|
|
352
434
|
...(liveEvidence && !evidence?.unobserved ? { live: !!evidence?.live } : {}),
|
|
353
435
|
...(evidence?.unobserved ? { unobserved: evidence.unobserved } : {}),
|
|
354
436
|
...(evidence?.stack ? { stack: evidence.stack } : {}),
|
|
437
|
+
...(evidence?.rollup ? { resources: evidence.rollup } : {}),
|
|
355
438
|
});
|
|
356
439
|
}
|
|
357
440
|
|
package/src/yaml.test.ts
CHANGED
|
@@ -244,3 +244,92 @@ describe("parseYAML block scalars (#910)", () => {
|
|
|
244
244
|
expect(parseYAML("msg: >\n a\n b\n\n c\n")).toEqual({ msg: "a b\nc\n" });
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
// #1311 — a sequence item's sibling keys survive a nested block, whichever key
|
|
249
|
+
// comes first. The bug was purely positional: the same keys in the other order
|
|
250
|
+
// parsed correctly, so nothing about the keys themselves was at fault.
|
|
251
|
+
describe("parseYAML — sibling keys after a nested block in a sequence item (#1311)", () => {
|
|
252
|
+
test("a sibling key after a nested MAPPING is not swallowed", () => {
|
|
253
|
+
expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n")).toEqual({
|
|
254
|
+
items: [{ context: { cluster: "c1" }, name: "n1" }],
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the same keys in the other order still parse — the ordering is what mattered", () => {
|
|
259
|
+
expect(parseYAML("items:\n- name: n1\n context:\n cluster: c1\n")).toEqual({
|
|
260
|
+
items: [{ name: "n1", context: { cluster: "c1" } }],
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("several siblings after a nested mapping", () => {
|
|
265
|
+
expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n user: u1\n")).toEqual({
|
|
266
|
+
items: [{ context: { cluster: "c1" }, name: "n1", user: "u1" }],
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("back-to-back nested mappings", () => {
|
|
271
|
+
expect(parseYAML("items:\n- a:\n x: 1\n b:\n y: 2\n")).toEqual({ items: [{ a: { x: 1 }, b: { y: 2 } }] });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("every item in a multi-item sequence keeps its siblings", () => {
|
|
275
|
+
expect(parseYAML("items:\n- context:\n cluster: c1\n name: n1\n- context:\n cluster: c2\n name: n2\n")).toEqual({
|
|
276
|
+
items: [
|
|
277
|
+
{ context: { cluster: "c1" }, name: "n1" },
|
|
278
|
+
{ context: { cluster: "c2" }, name: "n2" },
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a sibling key after a SAME-COLUMN nested sequence — valid YAML, and what kubectl emits", () => {
|
|
284
|
+
expect(parseYAML("items:\n- ports:\n - 80\n - 443\n name: n1\n")).toEqual({
|
|
285
|
+
items: [{ ports: [80, 443], name: "n1" }],
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a key with no value stays null when the next line is a sibling, not its content", () => {
|
|
290
|
+
// The reason a nested MAPPING must be indented PAST its key while a
|
|
291
|
+
// sequence may share its column: `other` here belongs to the item, not to
|
|
292
|
+
// `meta`. Reading both against the same threshold breaks one or the other.
|
|
293
|
+
expect(parseYAML("items:\n- name: a\n meta:\n other: b\n")).toEqual({
|
|
294
|
+
items: [{ name: "a", meta: null, other: "b" }],
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a real Kubernetes container: same-column sequences between scalar keys", () => {
|
|
299
|
+
expect(
|
|
300
|
+
parseYAML('containers:\n- name: web\n ports:\n - containerPort: 80\n env:\n - name: X\n value: "1"\n image: nginx\n'),
|
|
301
|
+
).toEqual({
|
|
302
|
+
containers: [
|
|
303
|
+
{
|
|
304
|
+
name: "web",
|
|
305
|
+
ports: [{ containerPort: 80 }],
|
|
306
|
+
env: [{ name: "X", value: "1" }],
|
|
307
|
+
image: "nginx",
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a real kubeconfig context block — the shape that surfaced this", () => {
|
|
314
|
+
const kubeconfig = [
|
|
315
|
+
"contexts:",
|
|
316
|
+
"- context:",
|
|
317
|
+
" cluster: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
|
|
318
|
+
" user: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
|
|
319
|
+
" name: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
|
|
320
|
+
"current-context: arn:aws:eks:us-east-1:000000000000:cluster/cc-eks",
|
|
321
|
+
"",
|
|
322
|
+
].join("\n");
|
|
323
|
+
const arn = "arn:aws:eks:us-east-1:000000000000:cluster/cc-eks";
|
|
324
|
+
expect(parseYAML(kubeconfig)).toEqual({
|
|
325
|
+
contexts: [{ context: { cluster: arn, user: arn }, name: arn }],
|
|
326
|
+
"current-context": arn,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a GitHub Actions step with `with:` before its sibling keys", () => {
|
|
331
|
+
expect(parseYAML("steps:\n- with:\n fetch-depth: 0\n name: checkout\n uses: actions/checkout@v4\n")).toEqual({
|
|
332
|
+
steps: [{ with: { "fetch-depth": 0 }, name: "checkout", uses: "actions/checkout@v4" }],
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
package/src/yaml.ts
CHANGED
|
@@ -329,7 +329,7 @@ function parseArrayItemValue(
|
|
|
329
329
|
inlineValue: string,
|
|
330
330
|
lines: string[],
|
|
331
331
|
currentIndex: number,
|
|
332
|
-
|
|
332
|
+
keyIndent: number,
|
|
333
333
|
): unknown {
|
|
334
334
|
if (inlineValue !== "" && !inlineValue.startsWith("#")) {
|
|
335
335
|
const header = blockScalarHeader(inlineValue);
|
|
@@ -349,16 +349,28 @@ function parseArrayItemValue(
|
|
|
349
349
|
}
|
|
350
350
|
return parseScalar(inlineValue);
|
|
351
351
|
}
|
|
352
|
-
// Empty inline value — check for nested block
|
|
352
|
+
// Empty inline value — check for a nested block. `keyIndent` is the key's own
|
|
353
|
+
// column, and the two nested shapes do NOT share a threshold (#1311):
|
|
354
|
+
//
|
|
355
|
+
// - a SEQUENCE may sit at the key's own column (valid YAML, and what
|
|
356
|
+
// kubectl and Kubernetes manifests emit);
|
|
357
|
+
// - a MAPPING must be indented past it, otherwise the next line is a
|
|
358
|
+
// sibling key and this key's value is null:
|
|
359
|
+
//
|
|
360
|
+
// - name: a
|
|
361
|
+
// meta: <- no value
|
|
362
|
+
// other: b <- a sibling, NOT meta's content
|
|
363
|
+
//
|
|
364
|
+
// Testing both against `>= keyIndent` would swallow that sibling; testing
|
|
365
|
+
// both against `> keyIndent` loses the same-column sequence.
|
|
353
366
|
const nextIdx = currentIndex + 1;
|
|
354
367
|
if (nextIdx < lines.length) {
|
|
355
368
|
const nextLine = lines[nextIdx];
|
|
356
369
|
if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
|
|
357
370
|
const ni = nextLine.search(/\S/);
|
|
358
|
-
if (
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
}
|
|
371
|
+
if (nextLine.trimStart().startsWith("- ")) {
|
|
372
|
+
if (ni >= keyIndent) return parseYAMLArray(lines, nextIdx, ni).value;
|
|
373
|
+
} else if (ni > keyIndent) {
|
|
362
374
|
return parseYAMLLines(lines, nextIdx, ni).value;
|
|
363
375
|
}
|
|
364
376
|
}
|
|
@@ -366,6 +378,37 @@ function parseArrayItemValue(
|
|
|
366
378
|
return null;
|
|
367
379
|
}
|
|
368
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Skip past the value block belonging to a key at column `keyIndent` inside a
|
|
383
|
+
* sequence item, returning the first line that is NOT part of it (#1311).
|
|
384
|
+
*
|
|
385
|
+
* Two shapes, and only the first is a matter of indentation:
|
|
386
|
+
*
|
|
387
|
+
* - a nested MAPPING is indented past its key, so anything further right
|
|
388
|
+
* belongs to it and anything at the key's own column is a sibling;
|
|
389
|
+
* - a nested SEQUENCE may sit at the SAME column as its key, which is valid
|
|
390
|
+
* YAML and what kubectl and Kubernetes manifests both emit:
|
|
391
|
+
*
|
|
392
|
+
* - name: web
|
|
393
|
+
* ports:
|
|
394
|
+
* - containerPort: 80
|
|
395
|
+
* env: <- a sibling, at `ports`' own column
|
|
396
|
+
*
|
|
397
|
+
* An indent rule cannot separate those, so the sequence is re-parsed to
|
|
398
|
+
* find where it ends. `parseYAMLArray` already reports that as `endIndex`.
|
|
399
|
+
*/
|
|
400
|
+
function skipValueBlock(lines: string[], startIndex: number, keyIndent: number): number {
|
|
401
|
+
let k = startIndex;
|
|
402
|
+
while (k < lines.length && (lines[k].trim() === "" || lines[k].trim().startsWith("#"))) k++;
|
|
403
|
+
if (k < lines.length) {
|
|
404
|
+
const ni = lines[k].search(/\S/);
|
|
405
|
+
if (ni >= keyIndent && lines[k].trimStart().startsWith("- ")) {
|
|
406
|
+
return parseYAMLArray(lines, k, ni).endIndex;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return skipNestedBlock(lines, startIndex, keyIndent + 1);
|
|
410
|
+
}
|
|
411
|
+
|
|
369
412
|
/**
|
|
370
413
|
* Skip past a nested block (object or array) starting at startIndex with the given indent.
|
|
371
414
|
* Returns the index of the first line that is NOT part of the nested block.
|
|
@@ -420,7 +463,19 @@ export function parseYAMLArray(
|
|
|
420
463
|
const nextIndent = indent + 2;
|
|
421
464
|
const firstVal = kvMatch[2].trim();
|
|
422
465
|
let j = firstVal === "" || firstVal.startsWith("#")
|
|
423
|
-
|
|
466
|
+
// The nested block belongs to THIS key and is indented past it, so
|
|
467
|
+
// skip lines indented more than the key's own column (#1311). Using
|
|
468
|
+
// the key's column itself also swallowed the item's sibling keys,
|
|
469
|
+
// which sit at exactly that column:
|
|
470
|
+
//
|
|
471
|
+
// - context: <- key at column 2
|
|
472
|
+
// cluster: c1 <- its block, column 4
|
|
473
|
+
// name: n1 <- a SIBLING at column 2, was skipped
|
|
474
|
+
//
|
|
475
|
+
// Only the item's first key was affected: the sibling loop below
|
|
476
|
+
// already skips past its own key's column, and the block-scalar
|
|
477
|
+
// branch immediately below has always used `+ 1` for this reason.
|
|
478
|
+
? skipValueBlock(lines, i + 1, nextIndent)
|
|
424
479
|
: blockScalarHeader(firstVal)
|
|
425
480
|
// Block body is indented past the key (nextIndent); skip it (#910).
|
|
426
481
|
? skipNestedBlock(lines, i + 1, nextIndent + 1)
|
|
@@ -437,9 +492,11 @@ export function parseYAMLArray(
|
|
|
437
492
|
const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
|
|
438
493
|
if (nextKV) {
|
|
439
494
|
const nextVal = nextKV[3].trim();
|
|
440
|
-
obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni
|
|
495
|
+
obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni);
|
|
441
496
|
if (nextVal === "" || nextVal.startsWith("#")) {
|
|
442
|
-
|
|
497
|
+
// Same rule as the first key above: past this key's own column,
|
|
498
|
+
// or to the end of a same-column sequence (#1311).
|
|
499
|
+
j = skipValueBlock(lines, j + 1, ni);
|
|
443
500
|
} else if (blockScalarHeader(nextVal)) {
|
|
444
501
|
// Skip the block body (indented past this key at `ni`) (#910).
|
|
445
502
|
j = skipNestedBlock(lines, j + 1, ni + 1);
|