@intentius/chant 0.31.0 → 0.33.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/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +58 -0
- package/dist/cli/handlers/search.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +20 -0
- package/dist/graph-declared.d.ts.map +1 -0
- package/dist/graph-effective.d.ts +25 -0
- package/dist/graph-effective.d.ts.map +1 -0
- 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 +49 -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/lifecycle/observe.d.ts +14 -6
- package/dist/lifecycle/observe.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/graph.test.ts +1 -1
- package/src/cli/handlers/graph.ts +33 -11
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/handlers/search.test.ts +113 -0
- package/src/cli/handlers/search.ts +263 -0
- package/src/cli/main.ts +114 -27
- package/src/cli/registry.ts +4 -0
- package/src/config.ts +3 -0
- package/src/graph-declared.ts +33 -0
- package/src/graph-effective.test.ts +97 -0
- package/src/graph-effective.ts +110 -0
- 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 +50 -1
- 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/lifecycle/observe.test.ts +66 -2
- package/src/lifecycle/observe.ts +79 -18
- 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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { GraphIR, IRNode } from "./graph-ir";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fold DERIVED reachability facts onto EC2 instance nodes so a single-node query
|
|
5
|
+
* can answer questions that are otherwise a multi-hop join with a union (#1139).
|
|
6
|
+
*
|
|
7
|
+
* Two facts, both things a live AWS-CLI sweep gets wrong because it can't cheaply
|
|
8
|
+
* resolve the topology:
|
|
9
|
+
*
|
|
10
|
+
* - `effectiveIngress` — the union of security-group ingress rules reachable
|
|
11
|
+
* from the instance, BOTH directly (`SecurityGroupIds`) AND through its launch
|
|
12
|
+
* template (`LaunchTemplate → LaunchTemplateData → SecurityGroupIds`). The
|
|
13
|
+
* launch-template hop is exactly what a CLI agent misses (it under-counts
|
|
14
|
+
* SSH-reachable instances). Each rule is normalized to `proto:port:cidr`
|
|
15
|
+
* (e.g. `tcp:22:0.0.0.0/0`) so it is precisely queryable.
|
|
16
|
+
* - `internetFacing` — whether the instance's subnet routes to an Internet
|
|
17
|
+
* Gateway (`subnet ← SubnetRouteTableAssociation → RouteTable ← Route →
|
|
18
|
+
* InternetGateway`). "Public subnet" means an IGW route, not
|
|
19
|
+
* `MapPublicIpOnLaunch`.
|
|
20
|
+
*
|
|
21
|
+
* With these, "instances SSH-reachable from the internet" is one predicate:
|
|
22
|
+
* `kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0`
|
|
23
|
+
* — no hand-joined CLI sweep, no over/under-counting.
|
|
24
|
+
*/
|
|
25
|
+
export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
|
|
26
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
27
|
+
const edges = ir.edges ?? [];
|
|
28
|
+
const kind = (n?: IRNode): string => n?.kind ?? "";
|
|
29
|
+
const isKind = (n: IRNode | undefined, suffix: string): boolean =>
|
|
30
|
+
!!n && (kind(n) === suffix || kind(n).endsWith("::" + suffix));
|
|
31
|
+
const via = (...names: string[]) => (v: string): boolean => names.includes(v);
|
|
32
|
+
|
|
33
|
+
/** Out-neighbours of `id` (edges from → to), optionally filtered by viaAttr. */
|
|
34
|
+
const out = (id: string, pred?: (v: string) => boolean): IRNode[] =>
|
|
35
|
+
edges
|
|
36
|
+
.filter((e) => e.from === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
|
|
37
|
+
.map((e) => byId.get(e.to))
|
|
38
|
+
.filter((x): x is IRNode => !!x);
|
|
39
|
+
/** In-neighbours of `id` (edges to ← from), optionally filtered by viaAttr. */
|
|
40
|
+
const incoming = (id: string, pred?: (v: string) => boolean): IRNode[] =>
|
|
41
|
+
edges
|
|
42
|
+
.filter((e) => e.to === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
|
|
43
|
+
.map((e) => byId.get(e.from))
|
|
44
|
+
.filter((x): x is IRNode => !!x);
|
|
45
|
+
|
|
46
|
+
const normalizeIngress = (sg: IRNode): string[] => {
|
|
47
|
+
const rules = (sg.attrs as Record<string, unknown> | undefined)?.["SecurityGroupIngress"];
|
|
48
|
+
if (!Array.isArray(rules)) return [];
|
|
49
|
+
return rules.map((r) => {
|
|
50
|
+
const rule = r as Record<string, unknown>;
|
|
51
|
+
const proto = String(rule.IpProtocol ?? "-1");
|
|
52
|
+
const from = rule.FromPort as number | undefined;
|
|
53
|
+
const to = rule.ToPort as number | undefined;
|
|
54
|
+
const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
|
|
55
|
+
const cidr =
|
|
56
|
+
(rule.CidrIp as string | undefined) ??
|
|
57
|
+
(rule.CidrIpv6 as string | undefined) ??
|
|
58
|
+
(rule.SourceSecurityGroupId ? `sg:${String(rule.SourceSecurityGroupId)}` : "?");
|
|
59
|
+
return `${proto}:${port}:${cidr}`;
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Security groups reachable from an instance — direct and via launch template. */
|
|
64
|
+
const effectiveSgs = (inst: IRNode): IRNode[] => {
|
|
65
|
+
const direct = out(inst.id, via("SecurityGroupIds", "SecurityGroupId"));
|
|
66
|
+
const templates = out(inst.id, via("LaunchTemplate", "LaunchTemplateId"));
|
|
67
|
+
const viaTemplate = templates
|
|
68
|
+
.flatMap((lt) => out(lt.id, via("LaunchTemplateData", "SecurityGroupIds", "SecurityGroupId")))
|
|
69
|
+
.filter((n) => isKind(n, "SecurityGroup"));
|
|
70
|
+
const all = [...direct.filter((n) => isKind(n, "SecurityGroup")), ...viaTemplate];
|
|
71
|
+
return [...new Map(all.map((s) => [s.id, s])).values()];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The IGW an instance's subnet routes to (evidence), or undefined. */
|
|
75
|
+
const internetFacingVia = (inst: IRNode): string | undefined => {
|
|
76
|
+
for (const subnet of out(inst.id, via("SubnetId")).filter((n) => isKind(n, "Subnet"))) {
|
|
77
|
+
const assocs = incoming(subnet.id, via("SubnetId")).filter((a) => isKind(a, "SubnetRouteTableAssociation"));
|
|
78
|
+
const routeTables = assocs.flatMap((a) => out(a.id, via("RouteTableId")).filter((n) => isKind(n, "RouteTable")));
|
|
79
|
+
for (const rt of routeTables) {
|
|
80
|
+
const routes = incoming(rt.id, via("RouteTableId")).filter((n) => isKind(n, "Route"));
|
|
81
|
+
for (const route of routes) {
|
|
82
|
+
const dest = (route.attrs as Record<string, unknown> | undefined)?.["DestinationCidrBlock"];
|
|
83
|
+
const igw = out(route.id, via("GatewayId")).find((g) => isKind(g, "InternetGateway"));
|
|
84
|
+
if (igw && (dest == null || dest === "0.0.0.0/0")) {
|
|
85
|
+
const id = igw.id.includes("::") ? igw.id.slice(igw.id.lastIndexOf("::") + 2) : igw.id;
|
|
86
|
+
return `${rt.id.includes("::") ? rt.id.slice(rt.id.lastIndexOf("::") + 2) : rt.id} → ${id}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const nodes = ir.nodes.map((n) => {
|
|
95
|
+
if (!isKind(n, "Instance")) return n;
|
|
96
|
+
const effectiveIngress = effectiveSgs(n).flatMap(normalizeIngress);
|
|
97
|
+
// A live enrichment may already have set internetFacing (+ its evidence) for
|
|
98
|
+
// a subnet chant doesn't model declaratively (e.g. the account's default
|
|
99
|
+
// VPC). Keep that truth; otherwise derive it from the declared route topology.
|
|
100
|
+
const attrs = (n.attrs ?? {}) as Record<string, unknown>;
|
|
101
|
+
const liveFacing = attrs["internetFacing"] === true;
|
|
102
|
+
const declaredVia = internetFacingVia(n);
|
|
103
|
+
const via = (attrs["internetFacingVia"] as string | undefined) ?? declaredVia;
|
|
104
|
+
return {
|
|
105
|
+
...n,
|
|
106
|
+
attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
return { ...ir, nodes };
|
|
110
|
+
}
|
|
@@ -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
|
|
@@ -577,6 +608,9 @@ export interface LexiconPlugin {
|
|
|
577
608
|
* convention (AWS: the stack named after `environment`).
|
|
578
609
|
*/
|
|
579
610
|
stack?: string;
|
|
611
|
+
/** AWS region the stack is in (multi-region). When set, the observation
|
|
612
|
+
* targets this region instead of the ambient one (#1161 follow-up). */
|
|
613
|
+
region?: string;
|
|
580
614
|
/**
|
|
581
615
|
* Restrict the result to chant-owned resources (those carrying the
|
|
582
616
|
* ownership marker, #119). Where a lexicon has no durable marker channel,
|
|
@@ -664,7 +698,7 @@ export interface LexiconPlugin {
|
|
|
664
698
|
* thin to carry references (e.g. AWS CloudFormation, where it's sourced from
|
|
665
699
|
* the fuller `exportResources` config).
|
|
666
700
|
*/
|
|
667
|
-
enrichLiveAttrs?(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
|
|
701
|
+
enrichLiveAttrs?(options: { environment: string; stack?: string; stacks?: Array<string | { name: string; region?: string }>; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
|
|
668
702
|
|
|
669
703
|
/**
|
|
670
704
|
* List runtime artifacts in the given environment. Opt-in.
|
|
@@ -717,6 +751,8 @@ export interface LexiconPlugin {
|
|
|
717
751
|
* keeps its single-stack convention (AWS: the stack named after
|
|
718
752
|
* `environment`). */
|
|
719
753
|
stack?: string;
|
|
754
|
+
/** AWS region the stack is in (multi-region estates). */
|
|
755
|
+
region?: string;
|
|
720
756
|
selector?: ResourceSelector;
|
|
721
757
|
owned?: boolean;
|
|
722
758
|
verbatim?: boolean;
|
|
@@ -783,6 +819,19 @@ export interface ResourceMetadata {
|
|
|
783
819
|
* a delete, and never escalates `unknown` to one.
|
|
784
820
|
*/
|
|
785
821
|
ownership?: "owned" | "foreign" | "unknown";
|
|
822
|
+
/**
|
|
823
|
+
* Where this resource's owner-reference chain leads, for a live resource
|
|
824
|
+
* that is not itself declared (#1077). A lexicon that maintains an
|
|
825
|
+
* owner-reference graph (Kubernetes) sets this on an undeclared entry it
|
|
826
|
+
* returns; the diff engine reads `{ root: "declared" }` as `runtime`
|
|
827
|
+
* (a Pod a declared Deployment's controller created) rather than `orphan`.
|
|
828
|
+
* Distinct from {@link ownership}: that is chant's own managed-by marker,
|
|
829
|
+
* this is the provider's native parent/child graph — a runtime child
|
|
830
|
+
* usually carries no chant marker of its own at all. Absent means the
|
|
831
|
+
* lexicon supplies no chain, which is exactly today's behavior: every
|
|
832
|
+
* undeclared live resource stays `orphan`.
|
|
833
|
+
*/
|
|
834
|
+
ownerChain?: OwnerChainVerdict;
|
|
786
835
|
}
|
|
787
836
|
|
|
788
837
|
/**
|
|
@@ -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
|
}
|