@intentius/chant 0.44.1 → 0.44.3
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/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +10 -0
- package/dist/cli/commands/lint.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/components/cli-support.d.ts +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/governance.d.ts +1 -1
- package/dist/graph-detail.d.ts +31 -13
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +7 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +18 -0
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +2 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/observation.d.ts +33 -3
- package/dist/observation.d.ts.map +1 -1
- package/dist/reconcile.d.ts +5 -5
- package/dist/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +51 -19
- package/src/cli/commands/lint.ts +14 -3
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.test.ts +191 -15
- package/src/cli/handlers/graph.ts +131 -14
- package/src/cli/handlers/lifecycle.ts +8 -2
- package/src/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +3 -4
- package/src/components/cli-support.test.ts +52 -0
- package/src/components/cli-support.ts +13 -2
- package/src/governance.test.ts +1 -1
- package/src/governance.ts +1 -1
- package/src/graph-detail.test.ts +117 -6
- package/src/graph-detail.ts +76 -18
- package/src/lifecycle/change-set.test.ts +26 -0
- package/src/lifecycle/change-set.ts +13 -0
- package/src/lifecycle/live-diff.test.ts +35 -0
- package/src/lifecycle/live-diff.ts +21 -0
- package/src/lifecycle/observe.ts +2 -1
- package/src/lint/component-checks.ts +8 -1
- package/src/observation.test.ts +54 -7
- package/src/observation.ts +53 -13
- package/src/reconcile.ts +7 -7
|
@@ -145,8 +145,19 @@ export interface ComponentGraphResult {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/** Compute the components' dependency graph under `path`, for `chant graph --components`. */
|
|
148
|
-
export async function computeComponentGraph(
|
|
149
|
-
|
|
148
|
+
export async function computeComponentGraph(
|
|
149
|
+
path: string,
|
|
150
|
+
sandbox?: boolean,
|
|
151
|
+
buildParams?: BuildParamProvenance[],
|
|
152
|
+
): Promise<ComponentGraphResult> {
|
|
153
|
+
// #1490 — without this the component graph is always the DEFAULT-parameter
|
|
154
|
+
// graph. `discoverComponents` has honoured `buildParams` since #1108; every
|
|
155
|
+
// caller simply stopped short of passing them, so `--param backups=omit`
|
|
156
|
+
// dropped the CronJob from `chant build` and left the component that
|
|
157
|
+
// describes it in `chant graph --components`. Two commands disagreeing about
|
|
158
|
+
// what the source says, which is the thing #1064 and #1108 each fixed one
|
|
159
|
+
// layer of.
|
|
160
|
+
const result = await discoverComponents(path, { sandbox, buildParams });
|
|
150
161
|
if (result.errors.length > 0) {
|
|
151
162
|
return { success: false, order: [], waves: [], edges: [], error: result.errors.map((e) => e.message).join("\n") };
|
|
152
163
|
}
|
package/src/governance.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { GOVERNANCE_VERBS, isGovernanceVerb, type GovernanceVerb } from "./governance.js";
|
|
3
3
|
import { renderChangeSet, runReconcile, type ChangeSet, type Cycle } from "./reconcile.js";
|
|
4
4
|
|
|
5
|
-
describe("governance verbs
|
|
5
|
+
describe("governance verbs", () => {
|
|
6
6
|
it("the runtime list and the type stay in sync", () => {
|
|
7
7
|
// Compile-time: assigning the list's element type to GovernanceVerb and
|
|
8
8
|
// back fails if either side gains a member the other lacks.
|
package/src/governance.ts
CHANGED
package/src/graph-detail.test.ts
CHANGED
|
@@ -34,8 +34,12 @@ const base: GraphIR = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
describe("applyDetail", () => {
|
|
37
|
-
test("T2 (declarables)
|
|
38
|
-
|
|
37
|
+
test("T2 (declarables) keeps every node and its scalar/reference attrs", () => {
|
|
38
|
+
const ir = applyDetail(base, DETAIL.DECLARABLES);
|
|
39
|
+
expect(ir.nodes.map((n) => n.id)).toEqual(base.nodes.map((n) => n.id));
|
|
40
|
+
expect(ir.edges).toEqual(base.edges);
|
|
41
|
+
// Top-level reference envelopes are part of the resource view.
|
|
42
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ network: { $ref: "vpc.id" } });
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
test("T0 (stacks) collapses to one node per lexicon with cross-lexicon edges", () => {
|
|
@@ -55,6 +59,19 @@ describe("applyDetail", () => {
|
|
|
55
59
|
expect(ir.edges).toContainEqual({ from: "db", to: "subnet", kind: "ref" });
|
|
56
60
|
// The intra-gcp edge survives with its label.
|
|
57
61
|
expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
|
|
62
|
+
// Topology view: a surviving plain node sheds its properties.
|
|
63
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("T1 keeps overlay paint on surviving plain nodes", () => {
|
|
67
|
+
const painted: GraphIR = {
|
|
68
|
+
...base,
|
|
69
|
+
nodes: base.nodes.map((n) =>
|
|
70
|
+
n.id === "subnet" ? { ...n, attrs: { ...n.attrs, _status: "warn" } } : n,
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
const ir = applyDetail(painted, DETAIL.COMPOSITES);
|
|
74
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({ _status: "warn" });
|
|
58
75
|
});
|
|
59
76
|
|
|
60
77
|
test("T1 (composites) carries the cross-stack imports forward (survive the collapse as plain nodes)", () => {
|
|
@@ -91,16 +108,96 @@ describe("applyDetail", () => {
|
|
|
91
108
|
});
|
|
92
109
|
});
|
|
93
110
|
|
|
111
|
+
// #1489 — the k8s collapse: a convention-linked graph whose nodes carry nested
|
|
112
|
+
// property trees (every real k8s resource) must still differentiate across the
|
|
113
|
+
// dial. Modelled on the fountain-ops estate the issue measured: no composites,
|
|
114
|
+
// no `$ref` edges, full `metadata`/`spec` trees on every node.
|
|
115
|
+
describe("detail monotonicity (#1489)", () => {
|
|
116
|
+
const k8sish: GraphIR = {
|
|
117
|
+
nodes: [
|
|
118
|
+
{
|
|
119
|
+
id: "web",
|
|
120
|
+
kind: "K8s::Apps::Deployment",
|
|
121
|
+
lexicon: "k8s",
|
|
122
|
+
attrs: {
|
|
123
|
+
name: "web",
|
|
124
|
+
namespace: "prod",
|
|
125
|
+
uid: "u-1",
|
|
126
|
+
metadata: { labels: { app: "web" } },
|
|
127
|
+
spec: {
|
|
128
|
+
replicas: 3,
|
|
129
|
+
selector: { matchLabels: { app: "web" } },
|
|
130
|
+
template: { spec: { containers: [{ name: "web", image: "nginx:1.27" }] } },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "webSvc",
|
|
136
|
+
kind: "K8s::Core::Service",
|
|
137
|
+
lexicon: "k8s",
|
|
138
|
+
attrs: {
|
|
139
|
+
name: "web",
|
|
140
|
+
namespace: "prod",
|
|
141
|
+
metadata: {},
|
|
142
|
+
spec: { selector: { app: "web" }, ports: [{ port: 80 }] },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{ id: "prodNs", kind: "K8s::Core::Namespace", lexicon: "k8s", attrs: { name: "prod", metadata: {} } },
|
|
146
|
+
],
|
|
147
|
+
// Label-convention edges — no producer attribute anywhere.
|
|
148
|
+
edges: [{ from: "webSvc", to: "web", kind: "ref", viaAttr: "spec.selector" }],
|
|
149
|
+
groups: { byLexicon: { k8s: ["prodNs", "web", "webSvc"] } },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const attrKeys = (ir: GraphIR, id: string): string[] =>
|
|
153
|
+
Object.keys(ir.nodes.find((n) => n.id === id)!.attrs).sort();
|
|
154
|
+
|
|
155
|
+
test("attrs at level n are a subset of attrs at level n+1, strictly growing for a nested spec", () => {
|
|
156
|
+
const t1 = applyDetail(k8sish, DETAIL.COMPOSITES);
|
|
157
|
+
const t2 = applyDetail(k8sish, DETAIL.DECLARABLES);
|
|
158
|
+
const t3 = applyDetail(k8sish, DETAIL.ATTRIBUTES);
|
|
159
|
+
for (const id of ["web", "webSvc", "prodNs"]) {
|
|
160
|
+
const k1 = attrKeys(t1, id);
|
|
161
|
+
const k2 = attrKeys(t2, id);
|
|
162
|
+
const k3 = attrKeys(t3, id);
|
|
163
|
+
// subset at every step…
|
|
164
|
+
expect(k2).toEqual(expect.arrayContaining(k1));
|
|
165
|
+
expect(k3).toEqual(expect.arrayContaining(k2));
|
|
166
|
+
// …and strictly growing: every node here has a nested tree beyond its scalars.
|
|
167
|
+
expect(k1.length).toBeLessThan(k2.length);
|
|
168
|
+
expect(k2.length).toBeLessThan(k3.length);
|
|
169
|
+
}
|
|
170
|
+
// The resource view is identity scalars; the attribute view adds the trees.
|
|
171
|
+
expect(attrKeys(t2, "web")).toEqual(["name", "namespace", "uid"]);
|
|
172
|
+
expect(attrKeys(t3, "web")).toEqual(["metadata", "name", "namespace", "spec", "uid"]);
|
|
173
|
+
expect(t3.nodes.find((n) => n.id === "web")!.attrs.spec).toEqual(
|
|
174
|
+
k8sish.nodes.find((n) => n.id === "web")!.attrs.spec,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("regression: levels 2 and 3 are not byte-identical for a convention-linked k8s graph", () => {
|
|
179
|
+
const t2 = JSON.stringify(applyDetail(k8sish, DETAIL.DECLARABLES));
|
|
180
|
+
const t3 = JSON.stringify(applyDetail(k8sish, DETAIL.ATTRIBUTES));
|
|
181
|
+
expect(t3).not.toEqual(t2);
|
|
182
|
+
expect(t3.length).toBeGreaterThan(t2.length);
|
|
183
|
+
// …and 1 vs 2 differ too: three genuinely distinct zoom stops.
|
|
184
|
+
const t1 = JSON.stringify(applyDetail(k8sish, DETAIL.COMPOSITES));
|
|
185
|
+
expect(t2).not.toEqual(t1);
|
|
186
|
+
expect(t2.length).toBeGreaterThan(t1.length);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
94
190
|
// #1489 — an inert --detail 3 names itself instead of silently emitting the
|
|
95
|
-
// same bytes as --detail 2
|
|
96
|
-
//
|
|
191
|
+
// same bytes as --detail 2. Since the attrs projection landed, T3 adds the
|
|
192
|
+
// property tree as well as edge annotations, so the notice only fires for a
|
|
193
|
+
// graph that has neither: flat attrs AND convention-linked edges.
|
|
97
194
|
describe("detailInertNotice (#1489)", () => {
|
|
98
195
|
test("detail 3 that added toAttr annotations: no notice", () => {
|
|
99
196
|
const detailed = applyDetail(base, DETAIL.ATTRIBUTES);
|
|
100
197
|
expect(detailInertNotice(base, detailed)).toBeUndefined();
|
|
101
198
|
});
|
|
102
199
|
|
|
103
|
-
test("
|
|
200
|
+
test("nested property trees make detail 3 real even without producer attrs: no notice", () => {
|
|
104
201
|
const labelLinked: GraphIR = {
|
|
105
202
|
nodes: [
|
|
106
203
|
{ id: "svc", kind: "Service", lexicon: "k8s", attrs: { spec: { selector: { app: "web" } } } },
|
|
@@ -110,9 +207,23 @@ describe("detailInertNotice (#1489)", () => {
|
|
|
110
207
|
groups: {},
|
|
111
208
|
};
|
|
112
209
|
const detailed = applyDetail(labelLinked, DETAIL.ATTRIBUTES);
|
|
113
|
-
|
|
210
|
+
expect(detailInertNotice(labelLinked, detailed)).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("flat attrs + convention-linked edges: notice names both causes", () => {
|
|
214
|
+
const flat: GraphIR = {
|
|
215
|
+
nodes: [
|
|
216
|
+
{ id: "svc", kind: "Service", lexicon: "k8s", attrs: { name: "svc" } },
|
|
217
|
+
{ id: "web", kind: "Deployment", lexicon: "k8s", attrs: { name: "web" } },
|
|
218
|
+
],
|
|
219
|
+
edges: [{ from: "svc", to: "web", kind: "ref", viaAttr: "spec.selector" }],
|
|
220
|
+
groups: {},
|
|
221
|
+
};
|
|
222
|
+
const detailed = applyDetail(flat, DETAIL.ATTRIBUTES);
|
|
223
|
+
const notice = detailInertNotice(flat, detailed);
|
|
114
224
|
expect(notice).toContain("--detail 3");
|
|
115
225
|
expect(notice).toContain("1 edge(s)");
|
|
226
|
+
expect(notice).toContain("resource view");
|
|
116
227
|
expect(notice).toContain("identical to --detail 2");
|
|
117
228
|
});
|
|
118
229
|
|
package/src/graph-detail.ts
CHANGED
|
@@ -5,10 +5,27 @@ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
|
5
5
|
* transform over the base graph IR (no re-discovery), so every emitter and the
|
|
6
6
|
* painter get them for free. See issue #494 / epic #492.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* The dial moves two things: the graph's shape (what collapses) and each
|
|
9
|
+
* node's attribute payload (how much of the resource reaches the node). The
|
|
10
|
+
* payload is monotonic — a node's attrs at level n are a subset of its attrs
|
|
11
|
+
* at level n+1 — so a consumer stepping through the levels always sees the
|
|
12
|
+
* graph grow (#1489):
|
|
13
|
+
*
|
|
14
|
+
* - 0 STACKS — one node per lexicon; edges are cross-lexicon deps; no attrs
|
|
15
|
+
* - 1 COMPOSITES — composite instances collapsed to a single node each;
|
|
16
|
+
* topology view — attrs carry only overlay paint (`_*`)
|
|
17
|
+
* and composite membership
|
|
18
|
+
* - 2 DECLARABLES — every resource; the resource view of its attrs — scalar
|
|
19
|
+
* fields and reference envelopes, not nested property trees
|
|
20
|
+
* - 3 ATTRIBUTES — declarables carrying the full property tree, plus the
|
|
21
|
+
* producer attribute on each reference edge
|
|
22
|
+
*
|
|
23
|
+
* Before #1489 the dial never touched attrs: every level carried the full
|
|
24
|
+
* projected config, and T3's only delta over T2 (the producer attribute on
|
|
25
|
+
* `$ref`-derived edges) is empty for a lexicon whose resources link by
|
|
26
|
+
* name/label convention — the k8s lexicon end to end — so levels 2 and 3 came
|
|
27
|
+
* out byte-identical and a zoom picker mapped onto them rendered the same
|
|
28
|
+
* graph twice (behold#131).
|
|
12
29
|
*/
|
|
13
30
|
export type DetailLevel = 0 | 1 | 2 | 3;
|
|
14
31
|
|
|
@@ -30,8 +47,40 @@ export function applyDetail(ir: GraphIR, level: DetailLevel): GraphIR {
|
|
|
30
47
|
return toAttributes(ir);
|
|
31
48
|
case 2:
|
|
32
49
|
default:
|
|
33
|
-
return ir;
|
|
50
|
+
return toDeclarables(ir);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Overlay paint (`_status`, `_unobserved`, …) is a verdict about the node,
|
|
55
|
+
* not a property of the resource — it survives every tier above STACKS so a
|
|
56
|
+
* zoomed-out drift view keeps its colours. */
|
|
57
|
+
function paintAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
|
|
58
|
+
const out: Record<string, unknown> = {};
|
|
59
|
+
for (const [k, v] of Object.entries(attrs)) if (k.startsWith("_")) out[k] = v;
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A `{ $ref: "producer.attribute" }` envelope — a reference, not a property tree. */
|
|
64
|
+
function isRefEnvelope(v: unknown): boolean {
|
|
65
|
+
return (
|
|
66
|
+
typeof v === "object" &&
|
|
67
|
+
v !== null &&
|
|
68
|
+
!Array.isArray(v) &&
|
|
69
|
+
typeof (v as { $ref?: unknown }).$ref === "string"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** T2's resource view of a node's attrs: overlay paint, scalar fields
|
|
74
|
+
* (identity, status-relevant values), and top-level reference envelopes.
|
|
75
|
+
* Nested property trees — a k8s `spec`/`metadata`, a Tags list — wait for T3. */
|
|
76
|
+
function resourceAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
|
|
77
|
+
const out: Record<string, unknown> = {};
|
|
78
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
79
|
+
if (k.startsWith("_") || v === null || typeof v !== "object" || isRefEnvelope(v)) {
|
|
80
|
+
out[k] = v;
|
|
81
|
+
}
|
|
34
82
|
}
|
|
83
|
+
return out;
|
|
35
84
|
}
|
|
36
85
|
|
|
37
86
|
function edgeSortKey(e: IREdge): string {
|
|
@@ -67,7 +116,14 @@ function toStacks(ir: GraphIR): GraphIR {
|
|
|
67
116
|
return { nodes, edges: sortEdges([...seen.values()]), groups: {} };
|
|
68
117
|
}
|
|
69
118
|
|
|
70
|
-
/**
|
|
119
|
+
/** T2 — every resource, each carrying the resource view of its attrs (the
|
|
120
|
+
* scalar/reference projection; the full property tree is T3's addition). */
|
|
121
|
+
function toDeclarables(ir: GraphIR): GraphIR {
|
|
122
|
+
return { ...ir, nodes: ir.nodes.map((n) => ({ ...n, attrs: resourceAttrs(n.attrs) })) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** T1 — collapse each composite instance to one node; internal edges disappear.
|
|
126
|
+
* Topology view: surviving plain nodes keep only overlay paint, not properties. */
|
|
71
127
|
function toComposites(ir: GraphIR): GraphIR {
|
|
72
128
|
const idMap = new Map<string, string>();
|
|
73
129
|
for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ?? n.id);
|
|
@@ -80,7 +136,7 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
80
136
|
arr.push(n);
|
|
81
137
|
instances.set(n.compositeInstance, arr);
|
|
82
138
|
} else {
|
|
83
|
-
plain.push({ ...n });
|
|
139
|
+
plain.push({ ...n, attrs: paintAttrs(n.attrs) });
|
|
84
140
|
}
|
|
85
141
|
}
|
|
86
142
|
|
|
@@ -162,27 +218,29 @@ function findRefAttr(attrs: Record<string, unknown>, producer: string): string |
|
|
|
162
218
|
/**
|
|
163
219
|
* chant #1489 — the message to print when `--detail 3` changed nothing.
|
|
164
220
|
*
|
|
165
|
-
* T3
|
|
166
|
-
* edges. A graph whose
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* the bug; this names it.
|
|
221
|
+
* T3 adds two things over T2: the full property tree on each node and the
|
|
222
|
+
* producer attribute on `$ref`-derived edges. A graph whose nodes carry no
|
|
223
|
+
* properties beyond the resource view AND whose edges reference nothing (they
|
|
224
|
+
* link by name or label convention) gains neither, so levels 2 and 3 come out
|
|
225
|
+
* byte-identical — which reads as a broken dial from any consumer stepping
|
|
226
|
+
* through the levels (behold#131 was filed over exactly this). Accepting a
|
|
227
|
+
* value that changes nothing without saying so is the bug; this names it.
|
|
172
228
|
*
|
|
173
229
|
* Returns the warning text, or undefined when detail 3 did add something.
|
|
174
|
-
*
|
|
175
|
-
* the
|
|
230
|
+
* `base` is the pre-detail IR the caller already holds (the same one it fed
|
|
231
|
+
* `applyDetail`); the T2 view to compare against is derived here, so callers
|
|
232
|
+
* don't run the projection twice for a message. Pure — no I/O.
|
|
176
233
|
*/
|
|
177
234
|
export function detailInertNotice(base: GraphIR, detailed: GraphIR): string | undefined {
|
|
178
|
-
if (JSON.stringify(detailed) !== JSON.stringify(base)) return undefined;
|
|
235
|
+
if (JSON.stringify(detailed) !== JSON.stringify(toDeclarables(base))) return undefined;
|
|
179
236
|
const edges = base.edges.length;
|
|
180
237
|
const why =
|
|
181
238
|
edges === 0
|
|
182
239
|
? "this graph has no edges at all"
|
|
183
240
|
: `none of this graph's ${edges} edge(s) reference a producer attribute — they link by name or label convention`;
|
|
184
241
|
return (
|
|
185
|
-
`--detail 3 adds the producer attribute
|
|
242
|
+
`--detail 3 adds each node's full property tree and the producer attribute on reference edges, ` +
|
|
243
|
+
`but every node's properties already fit the resource view and ${why}, ` +
|
|
186
244
|
`so the output is identical to --detail 2.`
|
|
187
245
|
);
|
|
188
246
|
}
|
|
@@ -320,6 +320,32 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
|
320
320
|
expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
|
|
321
321
|
});
|
|
322
322
|
|
|
323
|
+
test("a create carries the address the provider confirmed absent, when the lexicon reported one (#1620)", () => {
|
|
324
|
+
const cs = buildChangeSet("prod", {
|
|
325
|
+
declared: new Set(["web"]),
|
|
326
|
+
observedNow: {},
|
|
327
|
+
observedThen: undefined,
|
|
328
|
+
queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
|
|
329
|
+
});
|
|
330
|
+
const e = cs.entries.find((x) => x.name === "web")!;
|
|
331
|
+
// The verdict is unchanged — the address is diagnostic, never load-bearing.
|
|
332
|
+
expect(e.action).toBe("create");
|
|
333
|
+
expect(e.queried).toBe("/apis/apps/v1/namespaces/default/deployments/web");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("an unobserved entry's own queried address wins over the map (#1620)", () => {
|
|
337
|
+
const cs = buildChangeSet("prod", {
|
|
338
|
+
declared: new Set(["web"]),
|
|
339
|
+
observedNow: {},
|
|
340
|
+
observedThen: undefined,
|
|
341
|
+
unobserved: { web: { reason: "read-failed", queried: "from-entry" } },
|
|
342
|
+
queried: { web: "from-map" },
|
|
343
|
+
});
|
|
344
|
+
const e = cs.entries.find((x) => x.name === "web")!;
|
|
345
|
+
expect(e.action).toBe("unobserved");
|
|
346
|
+
expect(e.queried).toBe("from-entry");
|
|
347
|
+
});
|
|
348
|
+
|
|
323
349
|
test("a returned resource wins over an unobserved claim for the same name", () => {
|
|
324
350
|
const cs = buildChangeSet("prod", {
|
|
325
351
|
declared: new Set(["queue"]),
|
|
@@ -74,6 +74,13 @@ export interface ChangeSetEntry {
|
|
|
74
74
|
unobservedReason?: UnobservedReason;
|
|
75
75
|
/** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
|
|
76
76
|
unobservedDetail?: string;
|
|
77
|
+
/**
|
|
78
|
+
* The resolved address the live read was issued against (#1620), when the
|
|
79
|
+
* lexicon reported one. On a `create` it says which address the provider
|
|
80
|
+
* confirmed absent — the line between "not there" and "looked in the wrong
|
|
81
|
+
* place" (a defaulted namespace, an endpoint override, the wrong region).
|
|
82
|
+
*/
|
|
83
|
+
queried?: string;
|
|
77
84
|
/** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
|
|
78
85
|
runtimeOwner?: string;
|
|
79
86
|
}
|
|
@@ -172,6 +179,11 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
172
179
|
action = "noop";
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
// The address the read went to (#1620) — from the unobserved entry when
|
|
183
|
+
// there is one, else the observation's queried map. Diagnostic only; the
|
|
184
|
+
// classification above never reads it.
|
|
185
|
+
const queried = unobservedEntry?.queried ?? input.queried?.[name];
|
|
186
|
+
|
|
175
187
|
entries.push({
|
|
176
188
|
name,
|
|
177
189
|
type,
|
|
@@ -185,6 +197,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
185
197
|
...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
|
|
186
198
|
}
|
|
187
199
|
: {}),
|
|
200
|
+
...(queried ? { queried } : {}),
|
|
188
201
|
...(runtimeOwner ? { runtimeOwner } : {}),
|
|
189
202
|
});
|
|
190
203
|
}
|
|
@@ -229,6 +229,41 @@ describe("diffLive", () => {
|
|
|
229
229
|
expect(result.unobserved.map((u) => u.name)).toEqual(["crd"]);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test("the queried addresses pass through, and a missing entity's address is readable off the result (#1620)", () => {
|
|
233
|
+
const result = diffLive({
|
|
234
|
+
declared: new Set(["web"]),
|
|
235
|
+
observedNow: {},
|
|
236
|
+
observedThen: undefined,
|
|
237
|
+
queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
|
|
238
|
+
});
|
|
239
|
+
// The verdict itself does not move: still a confirmed absence.
|
|
240
|
+
expect(result.missing).toEqual(["web"]);
|
|
241
|
+
// But the row can explain itself — behold renders `queried: <path> → 404`.
|
|
242
|
+
expect(result.queried).toEqual({ web: "/apis/apps/v1/namespaces/default/deployments/web" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("no queried input → no queried key on the result — other lexicons omitting it stays valid (#1620)", () => {
|
|
246
|
+
const result = diffLive({ declared: new Set(["web"]), observedNow: {}, observedThen: undefined });
|
|
247
|
+
expect("queried" in result).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an unobserved row carries the address of the failed read, from the entry or the map (#1620)", () => {
|
|
251
|
+
const result = diffLive({
|
|
252
|
+
declared: new Set(["a", "b"]),
|
|
253
|
+
observedNow: {},
|
|
254
|
+
observedThen: undefined,
|
|
255
|
+
unobserved: {
|
|
256
|
+
a: { reason: "read-failed", detail: "HTTP 500", queried: "/api/v1/namespaces/default/services/a" },
|
|
257
|
+
b: { reason: "no-credentials" },
|
|
258
|
+
},
|
|
259
|
+
queried: { b: "/api/v1/namespaces/default/services/b" },
|
|
260
|
+
});
|
|
261
|
+
expect(result.unobserved).toEqual([
|
|
262
|
+
{ name: "a", reason: "read-failed", detail: "HTTP 500", queried: "/api/v1/namespaces/default/services/a" },
|
|
263
|
+
{ name: "b", reason: "no-credentials", queried: "/api/v1/namespaces/default/services/b" },
|
|
264
|
+
]);
|
|
265
|
+
});
|
|
266
|
+
|
|
232
267
|
test("a resource that was returned is never also unobserved", () => {
|
|
233
268
|
const result = diffLive({
|
|
234
269
|
declared: new Set(["a"]),
|
|
@@ -34,6 +34,8 @@ export interface UnobservedResource {
|
|
|
34
34
|
type?: string;
|
|
35
35
|
reason: UnobservedReason;
|
|
36
36
|
detail?: string;
|
|
37
|
+
/** The resolved address the failed read was issued against (#1620), when the lexicon reported one. */
|
|
38
|
+
queried?: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -84,6 +86,16 @@ export interface LiveDiffResult {
|
|
|
84
86
|
* in the observation. Sorted by name.
|
|
85
87
|
*/
|
|
86
88
|
unobserved: UnobservedResource[];
|
|
89
|
+
/**
|
|
90
|
+
* The resolved query address per entity name (#1620) — what the live read
|
|
91
|
+
* was actually issued against, as the lexicon reported it. Present only when
|
|
92
|
+
* the lexicon supplied addresses; other lexicons omitting it stays valid.
|
|
93
|
+
* This is where a `missing` entry explains itself: `missing` is a bare name
|
|
94
|
+
* list, and `queried[name]` says which address the provider answered 404
|
|
95
|
+
* for — a declared k8s object with no namespace reads from the *defaulted*
|
|
96
|
+
* namespace, and only this field makes that visible.
|
|
97
|
+
*/
|
|
98
|
+
queried?: Record<string, string>;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
export interface DiffLiveInput {
|
|
@@ -99,6 +111,12 @@ export interface DiffLiveInput {
|
|
|
99
111
|
* at, so absence from `observedNow` is a confirmed absence.
|
|
100
112
|
*/
|
|
101
113
|
unobserved?: Record<string, UnobservedEntity>;
|
|
114
|
+
/**
|
|
115
|
+
* Resolved query address per entity name (#1620), as the observation
|
|
116
|
+
* reported it. Passed through to the result and joined onto unobserved rows;
|
|
117
|
+
* never consulted for classification.
|
|
118
|
+
*/
|
|
119
|
+
queried?: Record<string, string>;
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
|
|
@@ -223,11 +241,13 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
223
241
|
|
|
224
242
|
for (const name of unobservedNames) {
|
|
225
243
|
const entry = unobservedMap[name];
|
|
244
|
+
const queriedAddress = entry.queried ?? input.queried?.[name];
|
|
226
245
|
unobserved.push({
|
|
227
246
|
name,
|
|
228
247
|
...(entry.type ? { type: entry.type } : {}),
|
|
229
248
|
reason: entry.reason,
|
|
230
249
|
...(entry.detail ? { detail: entry.detail } : {}),
|
|
250
|
+
...(queriedAddress ? { queried: queriedAddress } : {}),
|
|
231
251
|
});
|
|
232
252
|
}
|
|
233
253
|
|
|
@@ -330,6 +350,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
330
350
|
driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
|
|
331
351
|
unchanged: unchanged.sort(),
|
|
332
352
|
unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
|
|
353
|
+
...(input.queried && Object.keys(input.queried).length > 0 ? { queried: input.queried } : {}),
|
|
333
354
|
};
|
|
334
355
|
}
|
|
335
356
|
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface ObserveResult {
|
|
|
43
43
|
function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
|
|
44
44
|
const q = <T>(m: Record<string, T>): Record<string, T> =>
|
|
45
45
|
Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
|
|
46
|
-
return { resources: q(obs.resources), unobserved: q(obs.unobserved) };
|
|
46
|
+
return { resources: q(obs.resources), unobserved: q(obs.unobserved), queried: q(obs.queried) };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
@@ -253,6 +253,7 @@ export async function observeResources(
|
|
|
253
253
|
{
|
|
254
254
|
resources: {},
|
|
255
255
|
unobserved: unobservedAll(entityNames, "read-failed", message, entities),
|
|
256
|
+
queried: {},
|
|
256
257
|
},
|
|
257
258
|
environment,
|
|
258
259
|
entityNames.length,
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type { Component } from "../components/component";
|
|
26
26
|
import type { DiscoveredComponent } from "../components/discover";
|
|
27
27
|
import { discoverComponents } from "../components/discover";
|
|
28
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
28
29
|
import type { RollbackPolicy } from "../components/capability";
|
|
29
30
|
import type { Severity } from "./rule";
|
|
30
31
|
|
|
@@ -105,10 +106,16 @@ export async function runComponentChecks(
|
|
|
105
106
|
checks: ComponentCheck[],
|
|
106
107
|
registryContext?: Pick<ComponentCheckContext, "knownKinds" | "rollbackPolicies">,
|
|
107
108
|
sandbox?: boolean,
|
|
109
|
+
buildParams?: BuildParamProvenance[],
|
|
108
110
|
): Promise<ComponentCheckDiagnostic[]> {
|
|
109
111
|
if (checks.length === 0) return [];
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
// #1490 — this import runs BEFORE the caller's own component discovery, and
|
|
114
|
+
// an ES module is evaluated once per path. Whatever parameters are in effect
|
|
115
|
+
// here are the ones every later reader sees, however carefully that reader
|
|
116
|
+
// resolves its own. Passing them at the second call and not this one left
|
|
117
|
+
// the graph on defaults while the CLI reported the values it had resolved.
|
|
118
|
+
const result = await discoverComponents(path, { sandbox, buildParams });
|
|
112
119
|
const diagnostics: ComponentCheckDiagnostic[] = [];
|
|
113
120
|
|
|
114
121
|
for (const err of result.errors) {
|