@intentius/chant 0.44.1 → 0.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/lint.d.ts +10 -0
- package/dist/cli/commands/lint.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/cli/commands/lint.ts +14 -3
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/graph.ts +32 -7
- 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
|
@@ -737,6 +737,7 @@ async function observeLexicon(
|
|
|
737
737
|
return {
|
|
738
738
|
resources: {},
|
|
739
739
|
unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
|
|
740
|
+
queried: {},
|
|
740
741
|
};
|
|
741
742
|
}
|
|
742
743
|
}
|
|
@@ -806,7 +807,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
806
807
|
});
|
|
807
808
|
const observedNow = observed.resources;
|
|
808
809
|
const observedThen = prevSnapshot?.resources;
|
|
809
|
-
const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
|
|
810
|
+
const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved, queried: observed.queried });
|
|
810
811
|
// Unobserved entities are deliberately NOT drift: a hole in the read is
|
|
811
812
|
// not a change in the cloud. They are reported separately (#1089) so a
|
|
812
813
|
// "no drift detected" line can never be built on top of a failed read.
|
|
@@ -962,7 +963,12 @@ function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiff
|
|
|
962
963
|
}
|
|
963
964
|
if (diff.missing.length > 0) {
|
|
964
965
|
console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
|
|
965
|
-
for (const name of diff.missing)
|
|
966
|
+
for (const name of diff.missing) {
|
|
967
|
+
// The address the read actually went to (#1620) — the line between "not
|
|
968
|
+
// there" and "looked in the wrong place" (a defaulted namespace, say).
|
|
969
|
+
const queried = diff.queried?.[name];
|
|
970
|
+
console.log(` - ${name}${queried ? ` [queried ${queried}]` : ""}`);
|
|
971
|
+
}
|
|
966
972
|
}
|
|
967
973
|
if (diff.orphan.length > 0) {
|
|
968
974
|
console.log(formatBold("\nORPHAN (in cloud, not declared):"));
|
|
@@ -48,7 +48,7 @@ describe("publish order", () => {
|
|
|
48
48
|
// same stranding by a different route.
|
|
49
49
|
const all = execFileSync(
|
|
50
50
|
"bash",
|
|
51
|
-
["-c", 'for d in packages/*/ lexicons
|
|
51
|
+
["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
|
|
52
52
|
{ cwd: REPO, encoding: "utf8" },
|
|
53
53
|
)
|
|
54
54
|
.split("\n")
|
|
@@ -118,10 +118,9 @@ function recipeBody(name: string): string {
|
|
|
118
118
|
*/
|
|
119
119
|
describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
120
120
|
it("the whole-repo release rewrites every @intentius peer range", () => {
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// rewrite goes stale the same way the hand-listed publish steps did.
|
|
121
|
+
// The rewrite is generic (`with_entries` over every `@intentius/*`
|
|
122
|
+
// peer) rather than named keys — a hand-listed rewrite goes stale the
|
|
123
|
+
// same way the hand-listed publish steps did.
|
|
125
124
|
const body = recipeBody("release");
|
|
126
125
|
expect(body).toMatch(/\.peerDependencies\s*\|=\s*with_entries/);
|
|
127
126
|
expect(body).toMatch(/startswith\("@intentius\/"\)/);
|
|
@@ -229,6 +229,58 @@ describe("computeComponentGraph", () => {
|
|
|
229
229
|
expect(result.edges).toEqual([]);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
// #1490 — `discoverComponents` has honoured buildParams since #1108, but
|
|
233
|
+
// every caller stopped short of passing them, so the component graph was
|
|
234
|
+
// always the default-parameter graph. A component conditioned on a parameter
|
|
235
|
+
// survived `--param` that removed the resources it describes, and `chant
|
|
236
|
+
// build` and `chant graph --components` disagreed about the same source.
|
|
237
|
+
//
|
|
238
|
+
// The fixture imports the params module by absolute path rather than by the
|
|
239
|
+
// `@intentius/chant/params` specifier: it is written to a temp dir with no
|
|
240
|
+
// node_modules, so the bare specifier would resolve elsewhere (or nowhere)
|
|
241
|
+
// and the fixture would read a DIFFERENT params object than the one
|
|
242
|
+
// discovery mutates — passing the test for the wrong reason.
|
|
243
|
+
const paramsModule = new URL("../params.ts", import.meta.url).pathname;
|
|
244
|
+
const conditionalComponent = `import { params } from ${JSON.stringify(paramsModule)};
|
|
245
|
+
export const backup =
|
|
246
|
+
params.backups === "omit"
|
|
247
|
+
? undefined
|
|
248
|
+
: { name: "backup", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "shell", reason: "t" }] }] };`;
|
|
249
|
+
|
|
250
|
+
// A fresh directory per call: the ESM loader caches a module by path, so
|
|
251
|
+
// re-importing the same fixture would replay the FIRST call's parameter
|
|
252
|
+
// binding and the assertion would pass or fail for the wrong reason.
|
|
253
|
+
async function graphWith(
|
|
254
|
+
dir: string,
|
|
255
|
+
provenance?: Array<{ name: string; value: string; source: "cli" }>,
|
|
256
|
+
) {
|
|
257
|
+
await mkdir(dir, { recursive: true });
|
|
258
|
+
await writeFile(join(dir, "backup.component.ts"), conditionalComponent);
|
|
259
|
+
return computeComponentGraph(dir, undefined, provenance);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
test("a component conditioned on a build parameter follows the parameter", async () => {
|
|
263
|
+
const omitted = await graphWith(join(testDir, "omit"), [
|
|
264
|
+
{ name: "backups", value: "omit", source: "cli" },
|
|
265
|
+
]);
|
|
266
|
+
expect(omitted.success).toBe(true);
|
|
267
|
+
expect(omitted.order).toEqual([]);
|
|
268
|
+
|
|
269
|
+
const kept = await graphWith(join(testDir, "keep"), [
|
|
270
|
+
{ name: "backups", value: "pg-dump", source: "cli" },
|
|
271
|
+
]);
|
|
272
|
+
expect(kept.success).toBe(true);
|
|
273
|
+
expect(kept.order).toEqual(["backup"]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Supplying none must not inherit whatever a previous call set — the same
|
|
277
|
+
// leak `discoverComponents`'s unconditional setBuildParams guards against.
|
|
278
|
+
test("passing no parameters does not inherit the previous call's", async () => {
|
|
279
|
+
await graphWith(join(testDir, "first"), [{ name: "backups", value: "omit", source: "cli" }]);
|
|
280
|
+
const fresh = await graphWith(join(testDir, "second"));
|
|
281
|
+
expect(fresh.order).toEqual(["backup"]);
|
|
282
|
+
});
|
|
283
|
+
|
|
232
284
|
test("orders a consumer after its producer, with a consumer → producer edge", async () => {
|
|
233
285
|
await writeFile(
|
|
234
286
|
join(testDir, "alb.component.ts"),
|
|
@@ -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,
|