@intentius/chant 0.31.0 → 0.32.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/command-group.d.ts +134 -0
- package/dist/cli/command-group.d.ts.map +1 -0
- package/dist/cli/conflict-check.d.ts +1 -1
- package/dist/cli/conflict-check.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/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +40 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +15 -7
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +118 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/dist/owner-chain.d.ts +99 -0
- package/dist/owner-chain.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/command-group.test.ts +208 -0
- package/src/cli/command-group.ts +199 -0
- package/src/cli/conflict-check.test.ts +36 -1
- package/src/cli/conflict-check.ts +22 -1
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/main.ts +107 -27
- package/src/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- package/src/lexicon.ts +44 -0
- package/src/lifecycle/change-set.test.ts +100 -0
- package/src/lifecycle/change-set.ts +39 -10
- package/src/lifecycle/live-diff.test.ts +88 -0
- package/src/lifecycle/live-diff.ts +55 -8
- package/src/managed-fields.test.ts +179 -0
- package/src/managed-fields.ts +328 -0
- package/src/owner-chain.test.ts +97 -0
- package/src/owner-chain.ts +128 -0
package/src/cli/main.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown }
|
|
|
27
27
|
import { runGraph } from "./handlers/graph";
|
|
28
28
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
29
29
|
import { runEmulator } from "./handlers/emulator";
|
|
30
|
+
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
31
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Long-form flags that are pure booleans in {@link parseArgs} — their branch
|
|
@@ -118,31 +120,24 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
118
120
|
env: undefined,
|
|
119
121
|
};
|
|
120
122
|
|
|
123
|
+
// chant #1127 — generic joined `--flag=value` support, factored out to
|
|
124
|
+
// ./command-group.ts (chant #1078) so a lexicon's own mounted command can
|
|
125
|
+
// apply the identical splitting discipline to its own flag vocabulary.
|
|
126
|
+
// Every value-taking flag below is matched by an exact `arg === "--flag"`
|
|
127
|
+
// check and then consumes the *next* array element (`args[++i]`) as its
|
|
128
|
+
// value; a joined token like `--env=prod` never matches any of those,
|
|
129
|
+
// doesn't match the trailing positional branch either (it starts with
|
|
130
|
+
// `-`), and used to vanish with no error. Splitting the token at its FIRST
|
|
131
|
+
// `=` and re-dispatching as two array elements makes every flag below see
|
|
132
|
+
// the exact shape it already handles — including a flag like `--param`
|
|
133
|
+
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
134
|
+
// splits to flag `--param`, value `tier=production`, not further split on
|
|
135
|
+
// the second `=`).
|
|
136
|
+
args = splitJoinedFlags(args, BOOLEAN_FLAGS);
|
|
137
|
+
|
|
121
138
|
let i = 0;
|
|
122
139
|
while (i < args.length) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// chant #1127 — generic joined `--flag=value` support. Every value-taking
|
|
126
|
-
// flag below is matched by an exact `arg === "--flag"` check and then
|
|
127
|
-
// consumes the *next* array element (`args[++i]`) as its value; a joined
|
|
128
|
-
// token like `--env=prod` never matches any of those, doesn't match the
|
|
129
|
-
// trailing positional branch either (it starts with `-`), and used to
|
|
130
|
-
// vanish with no error. Splitting the token at its FIRST `=` and
|
|
131
|
-
// re-dispatching as two array elements makes every flag below see the
|
|
132
|
-
// exact shape it already handles — including a flag like `--param`
|
|
133
|
-
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
134
|
-
// splits to flag `--param`, value `tier=production`, not further split
|
|
135
|
-
// on the second `=`).
|
|
136
|
-
if (arg.startsWith("--") && arg.includes("=")) {
|
|
137
|
-
const eq = arg.indexOf("=");
|
|
138
|
-
const flag = arg.slice(0, eq);
|
|
139
|
-
const value = arg.slice(eq + 1);
|
|
140
|
-
if (BOOLEAN_FLAGS.has(flag)) {
|
|
141
|
-
throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
|
|
142
|
-
}
|
|
143
|
-
args.splice(i, 1, flag, value);
|
|
144
|
-
arg = args[i];
|
|
145
|
-
}
|
|
140
|
+
const arg = args[i];
|
|
146
141
|
|
|
147
142
|
if (arg === "--help" || arg === "-h") {
|
|
148
143
|
result.help = true;
|
|
@@ -348,9 +343,12 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
348
343
|
}
|
|
349
344
|
|
|
350
345
|
/**
|
|
351
|
-
* Print help message
|
|
346
|
+
* Print help message. `groups` — lexicon-contributed command groups
|
|
347
|
+
* (chant #1078), best-effort loaded from the current project; composed in
|
|
348
|
+
* below the static command list so `--help` lists every mounted verb group
|
|
349
|
+
* alongside core's own commands.
|
|
352
350
|
*/
|
|
353
|
-
function printHelp(): void {
|
|
351
|
+
function printHelp(groups: CommandGroup[] = []): void {
|
|
354
352
|
console.log(`
|
|
355
353
|
chant - Declarative infrastructure specification toolkit
|
|
356
354
|
|
|
@@ -575,6 +573,60 @@ Examples:
|
|
|
575
573
|
chant describe myComponent src/
|
|
576
574
|
chant describe myComponent src/ --format json
|
|
577
575
|
`);
|
|
576
|
+
const groupsHelp = formatCommandGroupsHelp(groups);
|
|
577
|
+
if (groupsHelp) console.log(groupsHelp);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Best-effort load the current project's lexicon plugins for a purely
|
|
582
|
+
* read-only lookup (help composition, plugin-command dispatch) — never
|
|
583
|
+
* throws, empty on any failure (no config, no lexicons, not a chant
|
|
584
|
+
* project at all). Mirrors the existing best-effort loading already used
|
|
585
|
+
* for `emulator`/`components status` in {@link main} below.
|
|
586
|
+
*/
|
|
587
|
+
async function loadPluginsBestEffort(): Promise<LexiconPlugin[]> {
|
|
588
|
+
try {
|
|
589
|
+
const lexiconNames = await resolveProjectLexicons(resolve("."));
|
|
590
|
+
return await loadPlugins(lexiconNames);
|
|
591
|
+
} catch {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* chant #1078 — the lexicon command-group seam's dispatch-time half. Core's
|
|
598
|
+
* own `parseArgs`/`resolveCommand` know nothing about a lexicon's mounted
|
|
599
|
+
* verbs, so this is only ever consulted after BOTH of those have already
|
|
600
|
+
* failed to make sense of the invocation: either `parseArgs` threw on a flag
|
|
601
|
+
* it doesn't recognize (which is *expected* for a mounted command's own
|
|
602
|
+
* vocabulary — core has none), or it parsed fine but `resolveCommand` found
|
|
603
|
+
* no match in the static registry (a mounted command with no extra flags,
|
|
604
|
+
* e.g. `chant kube version`). Either way, a lexicon-mounted command's group
|
|
605
|
+
* name and verb are always the first two CLI tokens (mirrors the `emulator
|
|
606
|
+
* <up|down|status>` compound shape from #920), so `rawArgv` — the untouched
|
|
607
|
+
* `process.argv.slice(2)` — is all this needs; nothing from the partially or
|
|
608
|
+
* fully parsed `ParsedArgs` is used, on purpose, since core's flag-parsing
|
|
609
|
+
* failure or success is irrelevant to a namespace it doesn't own.
|
|
610
|
+
*
|
|
611
|
+
* Returns `undefined` when nothing claims the leading token as a command
|
|
612
|
+
* group at all, so the caller falls back to its own error handling
|
|
613
|
+
* unchanged — a project with no lexicon exposing `commands()` (or none of
|
|
614
|
+
* its plugins matching) is completely unaffected.
|
|
615
|
+
*/
|
|
616
|
+
async function tryPluginCommand(rawArgv: string[]): Promise<number | undefined> {
|
|
617
|
+
const [groupName, verbName] = rawArgv;
|
|
618
|
+
if (!groupName || groupName.startsWith("-")) return undefined;
|
|
619
|
+
|
|
620
|
+
const plugins = await loadPluginsBestEffort();
|
|
621
|
+
const rawArgs = rawArgv.slice(2);
|
|
622
|
+
const result = await dispatchCommandGroup(plugins, groupName, verbName, rawArgs);
|
|
623
|
+
|
|
624
|
+
if (result.kind === "no-group") return undefined;
|
|
625
|
+
if (result.kind === "usage-error") {
|
|
626
|
+
console.error(formatError({ message: result.message, hint: result.hint }));
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
return result.exitCode;
|
|
578
630
|
}
|
|
579
631
|
|
|
580
632
|
/**
|
|
@@ -688,10 +740,29 @@ const registry: CommandDef[] = [
|
|
|
688
740
|
* Main entry point
|
|
689
741
|
*/
|
|
690
742
|
async function main(): Promise<void> {
|
|
691
|
-
const
|
|
743
|
+
const rawArgv = process.argv.slice(2);
|
|
744
|
+
|
|
745
|
+
let args: ParsedArgs;
|
|
746
|
+
try {
|
|
747
|
+
args = parseArgs(rawArgv);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
// chant #1078 — core's parser has no idea what flags a lexicon's own
|
|
750
|
+
// mounted verb accepts, so an "unknown flag" here is expected, not a
|
|
751
|
+
// real error, until we've checked whether this invocation actually
|
|
752
|
+
// targets a command group. `tryPluginCommand` returns `undefined` when
|
|
753
|
+
// nothing claims the leading token, in which case this was a genuine
|
|
754
|
+
// core-flag error and the original is rethrown unchanged.
|
|
755
|
+
const code = await tryPluginCommand(rawArgv);
|
|
756
|
+
if (code !== undefined) {
|
|
757
|
+
await flushAndExit(code);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
throw err;
|
|
761
|
+
}
|
|
692
762
|
|
|
693
763
|
if (args.help || !args.command) {
|
|
694
|
-
|
|
764
|
+
const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
|
|
765
|
+
printHelp(groups);
|
|
695
766
|
process.exit(args.help ? 0 : 1);
|
|
696
767
|
}
|
|
697
768
|
|
|
@@ -742,6 +813,15 @@ async function main(): Promise<void> {
|
|
|
742
813
|
|
|
743
814
|
const match = resolveCommand(args, registry);
|
|
744
815
|
if (!match) {
|
|
816
|
+
// chant #1078 — not one of core's own commands; check whether a lexicon
|
|
817
|
+
// mounted a command group under this name before giving up. This is the
|
|
818
|
+
// "parsed fine, matched nothing" trigger for the seam — the flag-error
|
|
819
|
+
// trigger is above, in the `parseArgs` catch block.
|
|
820
|
+
const code = await tryPluginCommand(rawArgv);
|
|
821
|
+
if (code !== undefined) {
|
|
822
|
+
await flushAndExit(code);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
745
825
|
console.error(formatError({
|
|
746
826
|
message: `Unknown command: ${args.command}`,
|
|
747
827
|
hint: 'Run "chant --help" to see available commands',
|
|
@@ -60,6 +60,23 @@ describe("buildLiveGraphIr", () => {
|
|
|
60
60
|
expect(node.attrs).toEqual({});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// #1077 — owner-reference chain classification
|
|
64
|
+
it("carries runtimeOwner only when the owner chain resolves to a declared entity", () => {
|
|
65
|
+
const ir = buildLiveGraphIr([
|
|
66
|
+
{
|
|
67
|
+
lexicon: "k8s",
|
|
68
|
+
resources: {
|
|
69
|
+
"prod/web-abc": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "declared", entity: "web" } },
|
|
70
|
+
"prod/other": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "foreign" } },
|
|
71
|
+
"prod/plain": { type: "K8s::Core::Pod", status: "Running" },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
expect(ir.nodes.find((n) => n.id === "prod/web-abc")!.runtimeOwner).toBe("web");
|
|
76
|
+
expect(ir.nodes.find((n) => n.id === "prod/other")!.runtimeOwner).toBeUndefined();
|
|
77
|
+
expect(ir.nodes.find((n) => n.id === "prod/plain")!.runtimeOwner).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
63
80
|
it("is deterministic for a fixed observation set", () => {
|
|
64
81
|
expect(JSON.stringify(buildLiveGraphIr(observations))).toBe(
|
|
65
82
|
JSON.stringify(buildLiveGraphIr(observations)),
|
|
@@ -85,6 +102,18 @@ describe("overlayGraphs (#780 drift overlay)", () => {
|
|
|
85
102
|
expect(ir.nodes.map((n) => n.id).sort()).toEqual(["planned-db", "rogue-sg", "web-vpc"]);
|
|
86
103
|
expect(ir.edges).toHaveLength(1);
|
|
87
104
|
});
|
|
105
|
+
|
|
106
|
+
// #1077 — a provisioned, undeclared node whose owner chain reaches a
|
|
107
|
+
// declared entity paints `runtime`, not `warn` — it is expected runtime,
|
|
108
|
+
// not a foreign resource needing attention.
|
|
109
|
+
it("classifies a runtime child via _status, distinct from foreign", () => {
|
|
110
|
+
const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "web" };
|
|
111
|
+
const liveWithChild: GraphIR = { nodes: [node("web-vpc"), node("rogue-sg"), podNode], edges: [], groups: {} };
|
|
112
|
+
const ir = overlayGraphs(liveWithChild, declared);
|
|
113
|
+
const statusOf = (id: string) => (ir.nodes.find((n) => n.id === id)!.attrs as { _status?: string })._status;
|
|
114
|
+
expect(statusOf("prod/web-abc")).toBe("runtime");
|
|
115
|
+
expect(statusOf("rogue-sg")).toBe("warn"); // still foreign — no runtimeOwner
|
|
116
|
+
});
|
|
88
117
|
});
|
|
89
118
|
|
|
90
119
|
describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
@@ -135,6 +164,17 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
|
135
164
|
expect(ir.groups.byLexicon).toEqual({ aws: ["planned-db", "web-vpc"], k8s: ["app-ingress"] });
|
|
136
165
|
});
|
|
137
166
|
|
|
167
|
+
// #1077 — a live, undeclared node whose owner chain reaches a declared
|
|
168
|
+
// entity is appended `runtime`, not `warn`, even though it is just as
|
|
169
|
+
// "foreign" (undeclared) from the declared graph's point of view.
|
|
170
|
+
it("appends a runtime child as `runtime`, distinct from foreign", () => {
|
|
171
|
+
const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "app-ingress" };
|
|
172
|
+
const liveWithChild: GraphIR = { ...live, nodes: [...live.nodes, podNode] };
|
|
173
|
+
const ir = sourceOverlayGraphs(declared, liveWithChild);
|
|
174
|
+
expect(statusOf(ir, "prod/web-abc")).toBe("runtime");
|
|
175
|
+
expect(statusOf(ir, "rogue-sg")).toBe("warn"); // still foreign
|
|
176
|
+
});
|
|
177
|
+
|
|
138
178
|
it("drops a live edge between two managed nodes — declared edges already cover it", () => {
|
|
139
179
|
const liveDup: GraphIR = { ...live, edges: [{ from: "app-ingress", to: "web-vpc", kind: "ref", viaAttr: "live-label" }] };
|
|
140
180
|
const ir = sourceOverlayGraphs(declared, liveDup);
|
package/src/graph-ir.ts
CHANGED
|
@@ -67,6 +67,15 @@ export interface IRNode {
|
|
|
67
67
|
* `owned` = chant-managed. Absent for source-derived IR.
|
|
68
68
|
*/
|
|
69
69
|
ownership?: "owned" | "foreign";
|
|
70
|
+
/**
|
|
71
|
+
* Live-only, undeclared nodes: the declared entity this node's
|
|
72
|
+
* owner-reference chain resolves to (#1077) — a Pod a declared Deployment's
|
|
73
|
+
* controller created, for instance. Presence of this field is what an
|
|
74
|
+
* overlay reads to paint the node `runtime` instead of `warn`/foreign; a
|
|
75
|
+
* node without it (including every declared, source-derived node) is
|
|
76
|
+
* unaffected.
|
|
77
|
+
*/
|
|
78
|
+
runtimeOwner?: string;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/** A directed dependency: `from` references an attribute of `to`. */
|
|
@@ -504,6 +513,11 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
504
513
|
// information for a painter, and the IR's `ownership` field means "a
|
|
505
514
|
// verdict was reached" — so only owned/foreign land on the node.
|
|
506
515
|
if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
|
|
516
|
+
// Owner-reference chain (#1077): only a resolved `declared` root is
|
|
517
|
+
// carried onto the node — the same "a verdict was reached" rule as
|
|
518
|
+
// ownership above, since `unowned`/`foreign`/`unknown` all mean "no
|
|
519
|
+
// declared owner", which is simply the absence of this field.
|
|
520
|
+
if (meta.ownerChain?.root === "declared") node.runtimeOwner = meta.ownerChain.entity;
|
|
507
521
|
nodes.push(node);
|
|
508
522
|
(byLexicon[lexicon] ??= []).push(name);
|
|
509
523
|
// A live lexicon maps to one deployable stack, same as the source IR.
|
|
@@ -540,8 +554,9 @@ export function collectUnobserved(observations: LiveObservation[]): Record<strin
|
|
|
540
554
|
return out;
|
|
541
555
|
}
|
|
542
556
|
|
|
543
|
-
/** Paint status a node carries in an overlay. `neutral` = chant could not look
|
|
544
|
-
|
|
557
|
+
/** Paint status a node carries in an overlay. `neutral` = chant could not look;
|
|
558
|
+
* `runtime` = live, undeclared, owner chain reaches a declared entity (#1077). */
|
|
559
|
+
type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral" | "runtime";
|
|
545
560
|
|
|
546
561
|
function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
|
|
547
562
|
return {
|
|
@@ -558,7 +573,10 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
|
|
|
558
573
|
* Overlay the declared graph on the provisioned one (#780, `chant graph --live
|
|
559
574
|
* --overlay`) and classify each resource, tagging a `_status` a renderer colours:
|
|
560
575
|
* - **managed** (declared + provisioned) → `good`
|
|
561
|
-
* - **
|
|
576
|
+
* - **runtime** (provisioned, not declared, owner chain reaches a declared
|
|
577
|
+
* entity — #1077) → `runtime`, e.g. a Pod a declared Deployment's
|
|
578
|
+
* controller created — expected, not a foreign resource needing attention
|
|
579
|
+
* - **foreign** (provisioned, not declared, no declared owner) → `warn`
|
|
562
580
|
* - **pending** (declared, provider confirmed absent) → `accent`
|
|
563
581
|
* - **unobserved** (declared, chant could not look — #1089) → `neutral`,
|
|
564
582
|
* plus an `_unobserved` attr carrying the reason
|
|
@@ -570,7 +588,9 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
|
|
|
570
588
|
const liveIds = new Set(live.nodes.map((n) => n.id));
|
|
571
589
|
const unobserved = opts?.unobserved ?? {};
|
|
572
590
|
|
|
573
|
-
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
591
|
+
const nodes: IRNode[] = live.nodes.map((n) =>
|
|
592
|
+
tagStatus(n, declaredIds.has(n.id) ? "good" : n.runtimeOwner ? "runtime" : "warn"),
|
|
593
|
+
);
|
|
574
594
|
for (const n of declared.nodes) {
|
|
575
595
|
if (liveIds.has(n.id)) continue;
|
|
576
596
|
const u = unobserved[n.id];
|
|
@@ -597,8 +617,10 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
|
|
|
597
617
|
* the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
|
|
598
618
|
* to paint the whole estate "pending", which is the diagram equivalent of
|
|
599
619
|
* planning a create for something that already exists.
|
|
600
|
-
* **Foreign** resources (provisioned, not declared
|
|
601
|
-
* `warn
|
|
620
|
+
* **Foreign** resources (provisioned, not declared, no declared owner) are
|
|
621
|
+
* appended and tagged `warn`; a provisioned-but-undeclared resource whose
|
|
622
|
+
* owner chain reaches a declared entity (#1077) is tagged `runtime` instead —
|
|
623
|
+
* both carry any live-reconstructed edges that touch them, since a declared
|
|
602
624
|
* edge cannot describe an undeclared resource. Declared groups/exports pass
|
|
603
625
|
* through unchanged; nodes and edges are sorted for deterministic output.
|
|
604
626
|
*/
|
|
@@ -620,7 +642,10 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
|
|
|
620
642
|
if (obs.ownership) merged.ownership = obs.ownership;
|
|
621
643
|
return tagStatus(merged, "good");
|
|
622
644
|
});
|
|
623
|
-
for (const n of live.nodes)
|
|
645
|
+
for (const n of live.nodes) {
|
|
646
|
+
if (!foreignIds.has(n.id)) continue;
|
|
647
|
+
nodes.push(tagStatus(n, n.runtimeOwner ? "runtime" : "warn")); // runtime child or foreign
|
|
648
|
+
}
|
|
624
649
|
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
625
650
|
|
|
626
651
|
// Declared edges are the canvas (the cross-substrate topology). Add only the
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ export * from "./import/generator";
|
|
|
50
50
|
export * from "./lexicon";
|
|
51
51
|
export * from "./observation";
|
|
52
52
|
export * from "./deep-observation";
|
|
53
|
+
export * from "./owner-chain";
|
|
53
54
|
export * from "./lexicon-integrity";
|
|
54
55
|
export * from "./lexicon-manifest";
|
|
55
56
|
export * from "./lexicon-schema";
|
package/src/lexicon.ts
CHANGED
|
@@ -13,6 +13,12 @@ import type { RuleMeta } from "./audit/catalog";
|
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
14
|
import type { DescribeResourcesResult } from "./observation";
|
|
15
15
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
16
|
+
import type { OwnerChainVerdict } from "./owner-chain";
|
|
17
|
+
import type { CommandGroup } from "./cli/command-group";
|
|
18
|
+
|
|
19
|
+
// Re-exported so a lexicon can author its command group (#1078) from the
|
|
20
|
+
// same `@intentius/chant/lexicon` entry it imports the plugin contract from.
|
|
21
|
+
export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./cli/command-group";
|
|
16
22
|
|
|
17
23
|
// Re-exported so lexicons can author a reference catalog (#778) from the same
|
|
18
24
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
@@ -434,6 +440,24 @@ export interface LexiconPlugin {
|
|
|
434
440
|
* account. Absent when the lexicon has no local emulator. */
|
|
435
441
|
readonly emulator?: EmulatorCapability;
|
|
436
442
|
|
|
443
|
+
/**
|
|
444
|
+
* A CLI verb group this lexicon contributes, mounted under `chant <name>
|
|
445
|
+
* <verb>` (#1078). Core learns that a lexicon MAY contribute a command
|
|
446
|
+
* group and learns nothing about what is inside it — it finds the group by
|
|
447
|
+
* name and dispatches to the matched verb's handler wholesale, the same
|
|
448
|
+
* "spec, not behavior" shape as {@link emulator}. Unlike `emulator`, which
|
|
449
|
+
* core itself aggregates across every configured lexicon in one command
|
|
450
|
+
* (`chant emulator up --all`), a command group is owned end-to-end by ONE
|
|
451
|
+
* lexicon: `get -o wide -l app=x --field-selector` is irreducibly
|
|
452
|
+
* Kubernetes vocabulary, not something core could generalize or merge
|
|
453
|
+
* across plugins even if it wanted to. Absent when the lexicon contributes
|
|
454
|
+
* no CLI surface — registering nothing here changes nothing else about how
|
|
455
|
+
* the lexicon behaves; the build/fold path never calls this or invokes any
|
|
456
|
+
* verb's handler, since command dispatch happens only in the CLI's own
|
|
457
|
+
* entry point, never in discovery/build/fold.
|
|
458
|
+
*/
|
|
459
|
+
commands?(): CommandGroup;
|
|
460
|
+
|
|
437
461
|
// ── Optional extensions ───────────────────────────────────
|
|
438
462
|
/** Return lint rules provided by this lexicon */
|
|
439
463
|
lintRules?(): LintRule[];
|
|
@@ -555,6 +579,13 @@ export interface LexiconPlugin {
|
|
|
555
579
|
* `ownership: "unknown"` on what it returns rather than degrading silently —
|
|
556
580
|
* the change set never escalates `unknown` to a `delete`.
|
|
557
581
|
*
|
|
582
|
+
* An undeclared entry this method returns may carry {@link
|
|
583
|
+
* ResourceMetadata.ownerChain} (#1077) — set it when the provider's own
|
|
584
|
+
* parent/child graph (Kubernetes `ownerReferences`) shows this object's
|
|
585
|
+
* chain reaching a declared entity, so the diff engine classifies it
|
|
586
|
+
* `runtime` instead of `orphan`. Optional; a lexicon that never sets it
|
|
587
|
+
* keeps every undeclared entry classified `orphan`, unchanged.
|
|
588
|
+
*
|
|
558
589
|
* `entities` carries the chant-side entity declarations for this lexicon,
|
|
559
590
|
* keyed by chant entity name (e.g. the export name from a `*.ts` file).
|
|
560
591
|
* Implementations that need to map cloud-side names back to chant entity
|
|
@@ -783,6 +814,19 @@ export interface ResourceMetadata {
|
|
|
783
814
|
* a delete, and never escalates `unknown` to one.
|
|
784
815
|
*/
|
|
785
816
|
ownership?: "owned" | "foreign" | "unknown";
|
|
817
|
+
/**
|
|
818
|
+
* Where this resource's owner-reference chain leads, for a live resource
|
|
819
|
+
* that is not itself declared (#1077). A lexicon that maintains an
|
|
820
|
+
* owner-reference graph (Kubernetes) sets this on an undeclared entry it
|
|
821
|
+
* returns; the diff engine reads `{ root: "declared" }` as `runtime`
|
|
822
|
+
* (a Pod a declared Deployment's controller created) rather than `orphan`.
|
|
823
|
+
* Distinct from {@link ownership}: that is chant's own managed-by marker,
|
|
824
|
+
* this is the provider's native parent/child graph — a runtime child
|
|
825
|
+
* usually carries no chant marker of its own at all. Absent means the
|
|
826
|
+
* lexicon supplies no chain, which is exactly today's behavior: every
|
|
827
|
+
* undeclared live resource stays `orphan`.
|
|
828
|
+
*/
|
|
829
|
+
ownerChain?: OwnerChainVerdict;
|
|
786
830
|
}
|
|
787
831
|
|
|
788
832
|
/**
|
|
@@ -106,6 +106,77 @@ describe("buildChangeSet (#118)", () => {
|
|
|
106
106
|
expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
// ── Owner-reference chain classification (#1077) ──────────────────────────
|
|
110
|
+
|
|
111
|
+
test("undeclared, owner chain reaches a declared entity → runtime, never delete or adopt", () => {
|
|
112
|
+
const cs = buildChangeSet("prod", {
|
|
113
|
+
declared: new Set(["web"]),
|
|
114
|
+
observedNow: {
|
|
115
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
116
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
117
|
+
},
|
|
118
|
+
observedThen: undefined,
|
|
119
|
+
});
|
|
120
|
+
const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
|
|
121
|
+
expect(e.action).toBe("runtime");
|
|
122
|
+
expect(e.runtimeOwner).toBe("web");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a runtime child that also carries chant's own ownership marker is still `runtime`, never `delete`", () => {
|
|
126
|
+
// Guards the ordering in buildChangeSet: runtimeOwner must be checked
|
|
127
|
+
// before the ownership marker, in case a runtime child ever inherits the
|
|
128
|
+
// marker (e.g. label propagation from its owner's pod template).
|
|
129
|
+
const cs = buildChangeSet("prod", {
|
|
130
|
+
declared: new Set(),
|
|
131
|
+
observedNow: {
|
|
132
|
+
"prod/web-abc": meta({ ownership: "owned", ownerChain: { root: "declared", entity: "web" } }),
|
|
133
|
+
},
|
|
134
|
+
observedThen: undefined,
|
|
135
|
+
});
|
|
136
|
+
const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
|
|
137
|
+
expect(e.action).toBe("runtime");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("undeclared, unowned → orphan/adopt, not runtime", () => {
|
|
141
|
+
const cs = buildChangeSet("prod", {
|
|
142
|
+
declared: new Set(),
|
|
143
|
+
observedNow: { "prod/standalone": meta({ ownerChain: { root: "unowned" } }) },
|
|
144
|
+
observedThen: undefined,
|
|
145
|
+
});
|
|
146
|
+
const e = cs.entries.find((x) => x.name === "prod/standalone")!;
|
|
147
|
+
expect(e.action).toBe("adopt");
|
|
148
|
+
expect(e.runtimeOwner).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("undeclared, foreign root → orphan/adopt, not runtime", () => {
|
|
152
|
+
const cs = buildChangeSet("prod", {
|
|
153
|
+
declared: new Set(),
|
|
154
|
+
observedNow: { "prod/other": meta({ ownerChain: { root: "foreign" } }) },
|
|
155
|
+
observedThen: undefined,
|
|
156
|
+
});
|
|
157
|
+
expect(cs.entries.find((x) => x.name === "prod/other")!.action).toBe("adopt");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("undeclared, unresolved chain (unreadable/cycle/depth) → conservative adopt, not runtime", () => {
|
|
161
|
+
const cs = buildChangeSet("prod", {
|
|
162
|
+
declared: new Set(),
|
|
163
|
+
observedNow: { "prod/mystery": meta({ ownerChain: { root: "unknown" } }) },
|
|
164
|
+
observedThen: undefined,
|
|
165
|
+
});
|
|
166
|
+
const e = cs.entries.find((x) => x.name === "prod/mystery")!;
|
|
167
|
+
expect(e.action).toBe("adopt");
|
|
168
|
+
expect(e.runtimeOwner).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a lexicon with no owner chain at all is unaffected — undeclared stays adopt/delete as before", () => {
|
|
172
|
+
const cs = buildChangeSet("prod", {
|
|
173
|
+
declared: new Set(),
|
|
174
|
+
observedNow: { orphan: meta({ ownership: "owned" }) },
|
|
175
|
+
observedThen: undefined,
|
|
176
|
+
});
|
|
177
|
+
expect(cs.entries.find((x) => x.name === "orphan")!.action).toBe("delete");
|
|
178
|
+
});
|
|
179
|
+
|
|
109
180
|
test("only in snapshot (gone now, undeclared) → noop", () => {
|
|
110
181
|
const cs = buildChangeSet("prod", {
|
|
111
182
|
declared: new Set(),
|
|
@@ -148,6 +219,23 @@ describe("summarize / renderChangeSet", () => {
|
|
|
148
219
|
expect(out).toContain("ADOPT:");
|
|
149
220
|
expect(out).toContain("orphan");
|
|
150
221
|
});
|
|
222
|
+
|
|
223
|
+
test("summarize and render surface the runtime action (#1077)", () => {
|
|
224
|
+
const withRuntime = buildChangeSet("prod", {
|
|
225
|
+
declared: new Set(["web"]),
|
|
226
|
+
observedNow: {
|
|
227
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
228
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
229
|
+
},
|
|
230
|
+
observedThen: undefined,
|
|
231
|
+
});
|
|
232
|
+
expect(summarize(withRuntime).runtime).toBe(1);
|
|
233
|
+
expect(summarize(withRuntime).adopt).toBe(0);
|
|
234
|
+
const out = renderChangeSet(withRuntime);
|
|
235
|
+
expect(out).toContain("RUNTIME");
|
|
236
|
+
expect(out).toContain("prod/web-abc");
|
|
237
|
+
expect(out).toContain("owned by web");
|
|
238
|
+
});
|
|
151
239
|
});
|
|
152
240
|
|
|
153
241
|
describe("gitlabMrReport (#329)", () => {
|
|
@@ -169,6 +257,18 @@ describe("gitlabMrReport (#329)", () => {
|
|
|
169
257
|
expect(gitlabMrReport(cs)).toEqual({ create: 1, update: 1, delete: 1 });
|
|
170
258
|
});
|
|
171
259
|
|
|
260
|
+
test("a runtime child (#1077) is excluded from the widget — never counted as a change", () => {
|
|
261
|
+
const cs = buildChangeSet("prod", {
|
|
262
|
+
declared: new Set(["web"]),
|
|
263
|
+
observedNow: {
|
|
264
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
265
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
266
|
+
},
|
|
267
|
+
observedThen: undefined,
|
|
268
|
+
});
|
|
269
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
270
|
+
});
|
|
271
|
+
|
|
172
272
|
test("empty plan reports all zeros", () => {
|
|
173
273
|
const cs = buildChangeSet("prod", {
|
|
174
274
|
declared: new Set(),
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `chant lifecycle diff --live` computes a three-way comparison — declared now /
|
|
5
5
|
* last snapshot / live now — and prints it. `buildChangeSet` promotes that
|
|
6
|
-
* same signal into a classified create/update/delete/adopt/noop set
|
|
7
|
-
* tooling (reconcile, apply) can act on.
|
|
6
|
+
* same signal into a classified create/update/delete/adopt/runtime/noop set
|
|
7
|
+
* that other tooling (reconcile, apply) can act on.
|
|
8
8
|
*
|
|
9
9
|
* Strictly read-only and pure: no I/O, no mutation. The classification reads
|
|
10
10
|
* ownership from the live marker only (populated downstream); until ownership
|
|
@@ -25,12 +25,17 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
|
25
25
|
* snapshot.
|
|
26
26
|
* - `adopt` — live but undeclared, ownership not established → a candidate to
|
|
27
27
|
* pull back into source, never an auto-delete.
|
|
28
|
+
* - `runtime` — live but undeclared, and its owner-reference chain reaches a
|
|
29
|
+
* declared entity (#1077): a Pod a declared Deployment's controller
|
|
30
|
+
* created, for instance. Never a delete, never an adopt candidate — it is
|
|
31
|
+
* not drift, just the runtime doing its job. `runtimeOwner` names the
|
|
32
|
+
* declared entity it belongs to.
|
|
28
33
|
* - `noop` — declared and live with no drift, or already reconciled.
|
|
29
34
|
* - `unobserved` — declared, and the lexicon could not look (#1089). Not a
|
|
30
35
|
* proposal at all: it is the plan admitting a hole. Never a create, never a
|
|
31
36
|
* delete. Read `unobservedReason` for which hole.
|
|
32
37
|
*/
|
|
33
|
-
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
|
|
38
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
@@ -69,6 +74,8 @@ export interface ChangeSetEntry {
|
|
|
69
74
|
unobservedReason?: UnobservedReason;
|
|
70
75
|
/** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
|
|
71
76
|
unobservedDetail?: string;
|
|
77
|
+
/** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
|
|
78
|
+
runtimeOwner?: string;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
export interface ChangeSet {
|
|
@@ -121,6 +128,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
121
128
|
// record chant has to host.
|
|
122
129
|
const ownership: Ownership = observedNow[name]?.ownership ?? "unknown";
|
|
123
130
|
|
|
131
|
+
// Owner-reference chain (#1077), same live-only provenance as ownership
|
|
132
|
+
// above. Only a `declared` root changes the classification; `unknown` is
|
|
133
|
+
// deliberately not escalated (#1168's tri-state precedent — an
|
|
134
|
+
// unconfirmed chain never earns the more confident verdict).
|
|
135
|
+
const runtimeOwner =
|
|
136
|
+
!isDeclared && observedNow[name]?.ownerChain?.root === "declared"
|
|
137
|
+
? observedNow[name]!.ownerChain!.entity
|
|
138
|
+
: undefined;
|
|
139
|
+
|
|
124
140
|
let action: ChangeAction;
|
|
125
141
|
let deltas: AttributeChange[] | undefined;
|
|
126
142
|
|
|
@@ -140,6 +156,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
140
156
|
} else {
|
|
141
157
|
action = "noop";
|
|
142
158
|
}
|
|
159
|
+
} else if (live && runtimeOwner) {
|
|
160
|
+
// Live, undeclared, and its owner chain reaches a declared entity
|
|
161
|
+
// (#1077) — expected runtime, never a delete/adopt candidate, checked
|
|
162
|
+
// ahead of the ownership marker below: even a runtime child that
|
|
163
|
+
// happens to carry chant's own marker (label propagation from its
|
|
164
|
+
// owner's template) must never be proposed for deletion.
|
|
165
|
+
action = "runtime";
|
|
143
166
|
} else if (live) {
|
|
144
167
|
// Live but undeclared. Only a chant-owned orphan is a safe delete; a
|
|
145
168
|
// foreign or unknown orphan can be adopted but never auto-deleted.
|
|
@@ -162,6 +185,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
162
185
|
...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
|
|
163
186
|
}
|
|
164
187
|
: {}),
|
|
188
|
+
...(runtimeOwner ? { runtimeOwner } : {}),
|
|
165
189
|
});
|
|
166
190
|
}
|
|
167
191
|
|
|
@@ -169,7 +193,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
169
193
|
return { env, entries };
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
|
|
196
|
+
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
|
|
173
197
|
|
|
174
198
|
/** Count entries per action. */
|
|
175
199
|
export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
@@ -178,6 +202,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
178
202
|
update: 0,
|
|
179
203
|
delete: 0,
|
|
180
204
|
adopt: 0,
|
|
205
|
+
runtime: 0,
|
|
181
206
|
noop: 0,
|
|
182
207
|
unobserved: 0,
|
|
183
208
|
};
|
|
@@ -191,10 +216,11 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
191
216
|
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
192
217
|
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
193
218
|
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
194
|
-
* directly. Only the mutating actions count: `adopt`, `noop` and
|
|
195
|
-
* are excluded, since the widget has no column for "live but
|
|
196
|
-
*
|
|
197
|
-
*
|
|
219
|
+
* directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
|
|
220
|
+
* `unobserved` are excluded, since the widget has no column for "live but
|
|
221
|
+
* undeclared", "expected runtime child" (#1077), "no change", or "could not
|
|
222
|
+
* look" (#1089). The widget is therefore a floor, not a complete plan: read
|
|
223
|
+
* the full change set when entities are unobserved or classified runtime.
|
|
198
224
|
*
|
|
199
225
|
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
200
226
|
* fixed string, not a claim chant makes.
|
|
@@ -223,14 +249,17 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
223
249
|
lines.push(
|
|
224
250
|
action === "unobserved"
|
|
225
251
|
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
226
|
-
:
|
|
252
|
+
: action === "runtime"
|
|
253
|
+
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
254
|
+
: `\n${action.toUpperCase()}:`,
|
|
227
255
|
);
|
|
228
256
|
for (const e of group) {
|
|
229
257
|
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
230
258
|
const why = e.unobservedReason
|
|
231
259
|
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
232
260
|
: "";
|
|
233
|
-
|
|
261
|
+
const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
|
|
262
|
+
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
|
|
234
263
|
for (const d of e.deltas ?? []) {
|
|
235
264
|
lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
236
265
|
}
|