@intentius/chant 0.28.0 → 0.30.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/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +35 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +73 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +78 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.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/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +155 -6
- package/src/cli/handlers/graph.ts +124 -29
- package/src/cli/handlers/lifecycle.test.ts +410 -6
- package/src/cli/handlers/lifecycle.ts +475 -182
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +1216 -116
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +100 -20
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +115 -12
- package/src/index.ts +2 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +100 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +40 -20
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +55 -0
- package/src/stack-output.ts +41 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
2
|
+
import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
3
3
|
import type { BuildResult } from "../build";
|
|
4
4
|
|
|
5
5
|
const writeSnapshotMock = vi.fn();
|
|
@@ -114,6 +114,44 @@ describe("takeSnapshot", () => {
|
|
|
114
114
|
expect(result.errors.some((e) => e.includes("aws") && e.includes("no valid"))).toBe(true);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// #1089 — a snapshot is evidence of what was seen. An entity nobody could
|
|
118
|
+
// read must be recorded as a hole, not omitted (which the next diff would
|
|
119
|
+
// read back as "was not there").
|
|
120
|
+
test("records unobserved entities alongside the resources", async () => {
|
|
121
|
+
const plugin = createMockPlugin({
|
|
122
|
+
name: "k8s",
|
|
123
|
+
describeResources: staticObservation(
|
|
124
|
+
{ web: { type: "K8s::Apps::Deployment", status: "READY" } },
|
|
125
|
+
{ widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" } },
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web", "widget"] }));
|
|
129
|
+
expect(result.snapshots[0].unobserved).toEqual({
|
|
130
|
+
widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" },
|
|
131
|
+
});
|
|
132
|
+
expect(result.warnings.some((w) => w.includes("widget") && w.includes("not observed"))).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an entirely unreadable environment is not snapshotted as empty", async () => {
|
|
136
|
+
const plugin = createMockPlugin({
|
|
137
|
+
name: "k8s",
|
|
138
|
+
describeResources: staticObservation({}, { web: { reason: "no-credentials" } }),
|
|
139
|
+
});
|
|
140
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
|
|
141
|
+
expect(result.snapshots).toEqual([]);
|
|
142
|
+
expect(result.errors.some((e) => e.includes("could not be read"))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a throwing plugin reports each declared entity as unobserved in the warnings", async () => {
|
|
146
|
+
const plugin = createMockPlugin({
|
|
147
|
+
name: "k8s",
|
|
148
|
+
describeResources: async () => { throw new Error("kubeconfig missing"); },
|
|
149
|
+
});
|
|
150
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
|
|
151
|
+
expect(result.snapshots).toEqual([]);
|
|
152
|
+
expect(result.warnings.some((w) => w.includes("web") && w.includes("kubeconfig missing"))).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
117
155
|
test("resources missing required type/status are dropped with warning", async () => {
|
|
118
156
|
const plugin = createMockPlugin({
|
|
119
157
|
name: "aws",
|
|
@@ -9,16 +9,12 @@ import type { LifecycleSnapshot } from "./types";
|
|
|
9
9
|
import { computeBuildDigest } from "./digest";
|
|
10
10
|
import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
|
|
11
11
|
import { sortedJsonReplacer } from "../utils";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/private.?key/i,
|
|
19
|
-
/credential/i,
|
|
20
|
-
/connection.?string/i,
|
|
21
|
-
];
|
|
12
|
+
import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
|
|
13
|
+
// One list of secret-bearing property names for both observation depths
|
|
14
|
+
// (#1014): the thin path warns on them here, the deep path masks them before a
|
|
15
|
+
// property tree is ever rendered or committed. Two lists would eventually
|
|
16
|
+
// disagree about what counts as a secret.
|
|
17
|
+
import { isSensitiveKey } from "../deep-observation";
|
|
22
18
|
|
|
23
19
|
/**
|
|
24
20
|
* Check for potential sensitive data in resource attributes and return warnings.
|
|
@@ -30,7 +26,7 @@ function checkSensitiveData(
|
|
|
30
26
|
for (const [name, meta] of Object.entries(resources)) {
|
|
31
27
|
if (!meta.attributes) continue;
|
|
32
28
|
for (const attrName of Object.keys(meta.attributes)) {
|
|
33
|
-
if (
|
|
29
|
+
if (isSensitiveKey(attrName)) {
|
|
34
30
|
warnings.push(
|
|
35
31
|
`Potential sensitive data in ${name}.attributes.${attrName} — ensure it is scrubbed`,
|
|
36
32
|
);
|
|
@@ -124,22 +120,32 @@ export async function takeSnapshot(
|
|
|
124
120
|
|
|
125
121
|
let resources: Record<string, ResourceMetadata> = {};
|
|
126
122
|
let artifacts: Record<string, ArtifactMetadata> = {};
|
|
123
|
+
let unobserved: Record<string, UnobservedEntity> = {};
|
|
127
124
|
|
|
128
125
|
try {
|
|
129
126
|
if (plugin.describeResources) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
127
|
+
const observed = normalizeObservation(
|
|
128
|
+
await plugin.describeResources({
|
|
129
|
+
environment,
|
|
130
|
+
buildOutput,
|
|
131
|
+
entityNames,
|
|
132
|
+
entities,
|
|
133
|
+
stack,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
|
|
138
137
|
warnings.push(...validationWarnings);
|
|
139
138
|
if (dropped.length > 0) {
|
|
140
139
|
warnings.push(`${plugin.name}: dropped ${dropped.length} invalid resource(s)`);
|
|
141
140
|
}
|
|
142
141
|
resources = valid;
|
|
142
|
+
// Record the holes (#1089). A snapshot is evidence of what was seen; an
|
|
143
|
+
// entity nobody could read must not be recorded as "was not there",
|
|
144
|
+
// because the next diff would then read it back as absent.
|
|
145
|
+
unobserved = observed.unobserved;
|
|
146
|
+
for (const [name, entry] of Object.entries(unobserved)) {
|
|
147
|
+
warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
|
|
148
|
+
}
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
if (plugin.listArtifacts) {
|
|
@@ -153,7 +159,12 @@ export async function takeSnapshot(
|
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
if (Object.keys(resources).length === 0 && Object.keys(artifacts).length === 0) {
|
|
156
|
-
|
|
162
|
+
const unreadable = Object.keys(unobserved).length;
|
|
163
|
+
errors.push(
|
|
164
|
+
unreadable > 0
|
|
165
|
+
? `${plugin.name}: nothing observed — ${unreadable} declared entity(ies) could not be read (see warnings); not snapshotting an unread environment as empty`
|
|
166
|
+
: `${plugin.name}: no valid resources or artifacts returned`,
|
|
167
|
+
);
|
|
157
168
|
continue;
|
|
158
169
|
}
|
|
159
170
|
|
|
@@ -164,15 +175,24 @@ export async function takeSnapshot(
|
|
|
164
175
|
commit: headCommit,
|
|
165
176
|
timestamp,
|
|
166
177
|
resources,
|
|
178
|
+
...(Object.keys(unobserved).length > 0 && { unobserved }),
|
|
167
179
|
...(Object.keys(artifacts).length > 0 && { artifacts }),
|
|
168
180
|
digest,
|
|
169
181
|
};
|
|
170
182
|
|
|
171
183
|
snapshots.push(snapshot);
|
|
172
184
|
} catch (err) {
|
|
185
|
+
// A thrown read is not an empty environment — record nothing and say so
|
|
186
|
+
// (#1089). Writing a snapshot here would persist "none of this exists".
|
|
173
187
|
errors.push(
|
|
174
188
|
`${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
189
|
);
|
|
190
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
191
|
+
for (const [name, entry] of Object.entries(
|
|
192
|
+
unobservedAll(entityNames, "read-failed", message, entities),
|
|
193
|
+
)) {
|
|
194
|
+
warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
|
|
195
|
+
}
|
|
176
196
|
}
|
|
177
197
|
}
|
|
178
198
|
|
|
@@ -32,8 +32,8 @@ describe("status", () => {
|
|
|
32
32
|
const cs: ChangeSet = {
|
|
33
33
|
env: "prod",
|
|
34
34
|
entries: [
|
|
35
|
-
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
36
|
-
{ name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true }, ownership: "foreign" },
|
|
35
|
+
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
36
|
+
{ name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true, observed: true }, ownership: "foreign" },
|
|
37
37
|
],
|
|
38
38
|
};
|
|
39
39
|
const evidence = liveEvidenceFromChangeSet(cs);
|
|
@@ -49,7 +49,7 @@ describe("status", () => {
|
|
|
49
49
|
const cs: ChangeSet = {
|
|
50
50
|
env: "prod",
|
|
51
51
|
entries: [
|
|
52
|
-
{ name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
52
|
+
{ name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
53
53
|
],
|
|
54
54
|
};
|
|
55
55
|
const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
|
|
@@ -63,7 +63,7 @@ describe("status", () => {
|
|
|
63
63
|
const cs: ChangeSet = {
|
|
64
64
|
env: "prod",
|
|
65
65
|
entries: [
|
|
66
|
-
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
66
|
+
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
67
67
|
],
|
|
68
68
|
};
|
|
69
69
|
const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
|
|
@@ -75,8 +75,8 @@ describe("status", () => {
|
|
|
75
75
|
const cs: ChangeSet = {
|
|
76
76
|
env: "prod",
|
|
77
77
|
entries: [
|
|
78
|
-
{ name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
79
|
-
{ name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
78
|
+
{ name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
79
|
+
{ name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
80
80
|
],
|
|
81
81
|
};
|
|
82
82
|
const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
|
|
@@ -297,7 +297,7 @@ describe("status", () => {
|
|
|
297
297
|
const cs: ChangeSet = {
|
|
298
298
|
env: "prod",
|
|
299
299
|
entries: [
|
|
300
|
-
{ name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
300
|
+
{ name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
301
301
|
],
|
|
302
302
|
};
|
|
303
303
|
const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
|
|
@@ -311,7 +311,7 @@ describe("status", () => {
|
|
|
311
311
|
const cs: ChangeSet = {
|
|
312
312
|
env: "prod",
|
|
313
313
|
entries: [
|
|
314
|
-
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
|
|
314
|
+
{ name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
|
|
315
315
|
],
|
|
316
316
|
};
|
|
317
317
|
const liveEvidence = liveEvidenceFromChangeSet(cs);
|
|
@@ -381,5 +381,86 @@ describe("status", () => {
|
|
|
381
381
|
const merged = mergeLiveEvidence(undefined, supplement);
|
|
382
382
|
expect(merged.get("c")).toEqual({ live: true, ownership: "owned", action: undefined });
|
|
383
383
|
});
|
|
384
|
+
|
|
385
|
+
test("a direct stack observation clears an inherited hole; a failing one keeps it (#1089)", () => {
|
|
386
|
+
const base = new Map<string, LiveComponentEvidence>([
|
|
387
|
+
["a", { live: false, unobserved: { reason: "read-failed" } }],
|
|
388
|
+
["b", { live: false, unobserved: { reason: "read-failed" } }],
|
|
389
|
+
]);
|
|
390
|
+
const supplement = new Map<string, LiveComponentEvidence>([
|
|
391
|
+
["a", { live: true, ownership: "owned" }],
|
|
392
|
+
["b", { live: false, unobserved: { reason: "read-failed", detail: "no determinate status" } }],
|
|
393
|
+
]);
|
|
394
|
+
const merged = mergeLiveEvidence(base, supplement);
|
|
395
|
+
expect(merged.get("a")!.unobserved).toBeUndefined();
|
|
396
|
+
expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── The observation tri-state reaches the status join (#1089) ─────────────
|
|
401
|
+
|
|
402
|
+
describe("not-observed never becomes 'stale' (#1089)", () => {
|
|
403
|
+
const record = {
|
|
404
|
+
version: 1,
|
|
405
|
+
component: "search-svc",
|
|
406
|
+
env: "prod",
|
|
407
|
+
digest: "sha256:abc",
|
|
408
|
+
gitSha: "g",
|
|
409
|
+
runId: "r",
|
|
410
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
411
|
+
actor: "ci",
|
|
412
|
+
} as const;
|
|
413
|
+
|
|
414
|
+
test("a recorded component whose live state could not be read reports unknown", () => {
|
|
415
|
+
const rows = reconcileStatus("prod", [record], {
|
|
416
|
+
liveEvidence: new Map<string, LiveComponentEvidence>([
|
|
417
|
+
["search-svc", { live: false, unobserved: { reason: "no-binding", detail: "no kubectl context" } }],
|
|
418
|
+
]),
|
|
419
|
+
});
|
|
420
|
+
expect(rows[0].reconciliation).toBe("unknown");
|
|
421
|
+
expect(rows[0].detail).toContain("could not be observed");
|
|
422
|
+
expect(rows[0].detail).toContain("no kubectl context");
|
|
423
|
+
// `live` is omitted entirely — `false` would read as "not deployed".
|
|
424
|
+
expect(rows[0].live).toBeUndefined();
|
|
425
|
+
expect(rows[0].unobserved).toEqual({ reason: "no-binding", detail: "no kubectl context" });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("the same component, actually observed absent, still reports stale", () => {
|
|
429
|
+
const rows = reconcileStatus("prod", [record], {
|
|
430
|
+
liveEvidence: new Map<string, LiveComponentEvidence>([["search-svc", { live: false }]]),
|
|
431
|
+
});
|
|
432
|
+
expect(rows[0].reconciliation).toBe("stale");
|
|
433
|
+
expect(rows[0].live).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("an unrecorded component that could not be read is unknown, not unrecorded", () => {
|
|
437
|
+
const rows = reconcileStatus("prod", [], {
|
|
438
|
+
allComponents: ["search-svc"],
|
|
439
|
+
liveEvidence: new Map<string, LiveComponentEvidence>([
|
|
440
|
+
["search-svc", { live: false, unobserved: { reason: "read-failed" } }],
|
|
441
|
+
]),
|
|
442
|
+
});
|
|
443
|
+
expect(rows[0].reconciliation).toBe("unknown");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("liveEvidenceFromChangeSet carries the plan's unobserved verdict", () => {
|
|
447
|
+
const evidence = liveEvidenceFromChangeSet({
|
|
448
|
+
env: "prod",
|
|
449
|
+
entries: [
|
|
450
|
+
{
|
|
451
|
+
name: "search-svc",
|
|
452
|
+
action: "unobserved",
|
|
453
|
+
evidence: { declared: true, inSnapshot: false, live: false, observed: false },
|
|
454
|
+
ownership: "unknown",
|
|
455
|
+
unobservedReason: "unsupported-kind",
|
|
456
|
+
unobservedDetail: "no reader",
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
});
|
|
460
|
+
expect(evidence.get("search-svc")!.unobserved).toEqual({
|
|
461
|
+
reason: "unsupported-kind",
|
|
462
|
+
detail: "no reader",
|
|
463
|
+
});
|
|
464
|
+
});
|
|
384
465
|
});
|
|
385
466
|
});
|
package/src/lifecycle/status.ts
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* lower-confidence signal rather than silently treated as "reconciled".
|
|
22
22
|
* - **reconciled** — a release record exists and live evidence (via
|
|
23
23
|
* ownership) confirms the component is present and owned by chant.
|
|
24
|
+
* - **unknown** — live evidence was requested and could not be read (#1089),
|
|
25
|
+
* or was not requested at all. A component chant could not observe is never
|
|
26
|
+
* reported `stale`: "the read failed" and "it is gone" are different facts.
|
|
24
27
|
*
|
|
25
28
|
* This is deliberately a light-touch reconciliation: chant's lexicons report
|
|
26
29
|
* resource-level status (`ResourceMetadata`), not "the digest currently
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
import type { ChangeSet, ChangeAction } from "./change-set";
|
|
37
40
|
import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
|
|
38
41
|
import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
|
|
42
|
+
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
39
43
|
|
|
40
44
|
/** One row of `chant components status [env]` — the per-component join of recorded vs live. */
|
|
41
45
|
export interface ComponentStatusRow {
|
|
@@ -63,9 +67,18 @@ export interface ComponentStatusRow {
|
|
|
63
67
|
/**
|
|
64
68
|
* Machine-readable "observed live", when live evidence was gathered (`--live`).
|
|
65
69
|
* A consumer joining this row onto a graph node should read this rather than
|
|
66
|
-
* string-matching `detail`. Absent when `--live` was not requested
|
|
70
|
+
* string-matching `detail`. Absent when `--live` was not requested — and, since
|
|
71
|
+
* #1089, also absent when live state could not be read at all: `false` means
|
|
72
|
+
* "looked, not there", never "did not look". Read {@link unobserved} for that
|
|
73
|
+
* case; a consumer that treats absent as "unknown" already handles it.
|
|
67
74
|
*/
|
|
68
75
|
live?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Set when `--live` was requested and the observation could not read this
|
|
78
|
+
* component (#1089). `live` is absent alongside it and `reconciliation` is
|
|
79
|
+
* `unknown` — the row reports a hole rather than a verdict.
|
|
80
|
+
*/
|
|
81
|
+
unobserved?: { reason: UnobservedReason; detail?: string };
|
|
69
82
|
/**
|
|
70
83
|
* The owning deploy unit's raw status, when a lexicon reported it (AWS: the
|
|
71
84
|
* component's own CFN stack via `describeStackStatus`). Lets a renderer paint a
|
|
@@ -103,6 +116,12 @@ export interface ComponentStatusResult {
|
|
|
103
116
|
export interface LiveComponentEvidence {
|
|
104
117
|
/** True if this component name was observed live at all (declared+live, or orphan+live). */
|
|
105
118
|
live: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Set when the observation could not read this component (#1089). `live` is
|
|
121
|
+
* `false` alongside it, but only because the boolean has nowhere else to go —
|
|
122
|
+
* every consumer must branch on this field before believing `live: false`.
|
|
123
|
+
*/
|
|
124
|
+
unobserved?: { reason: UnobservedReason; detail?: string };
|
|
106
125
|
/** The `ChangeSet` action chant's existing plan logic assigned, when the component maps to a tracked entity/resource name. */
|
|
107
126
|
action?: ChangeAction;
|
|
108
127
|
/** Ownership verdict, when known. */
|
|
@@ -132,6 +151,10 @@ export function mergeLiveEvidence(
|
|
|
132
151
|
const b = merged.get(component);
|
|
133
152
|
merged.set(component, {
|
|
134
153
|
live: sup.live,
|
|
154
|
+
// A direct stack observation that succeeded answers the question the
|
|
155
|
+
// change-set axis could not — so it clears an inherited "not observed".
|
|
156
|
+
// A supplement that itself could not read keeps the hole.
|
|
157
|
+
...(sup.unobserved ? { unobserved: sup.unobserved } : {}),
|
|
135
158
|
ownership: sup.ownership ?? b?.ownership,
|
|
136
159
|
action: b?.action,
|
|
137
160
|
stack: sup.stack ?? b?.stack,
|
|
@@ -178,8 +201,12 @@ function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence
|
|
|
178
201
|
const action = entries.some((e) => e.action === "update")
|
|
179
202
|
? "update"
|
|
180
203
|
: entries.find((e) => e.action !== undefined)?.action;
|
|
204
|
+
// A component whose entities were partly readable is still partly unknown, so
|
|
205
|
+
// any unobserved entity keeps the hole — unless something under it was
|
|
206
|
+
// actually seen live, which already answers "is this deployed".
|
|
207
|
+
const unobserved = live ? undefined : entries.find((e) => e.unobserved)?.unobserved;
|
|
181
208
|
|
|
182
|
-
return { live, ownership, action };
|
|
209
|
+
return { live, ownership, action, ...(unobserved ? { unobserved } : {}) };
|
|
183
210
|
}
|
|
184
211
|
|
|
185
212
|
/**
|
|
@@ -204,6 +231,16 @@ export function liveEvidenceFromChangeSet(
|
|
|
204
231
|
live: entry.evidence.live,
|
|
205
232
|
action: entry.action,
|
|
206
233
|
ownership: entry.ownership,
|
|
234
|
+
// Carry the plan's "could not look" verdict through so the status join
|
|
235
|
+
// reports a hole instead of reading `live: false` as "gone" (#1089).
|
|
236
|
+
...(entry.action === "unobserved" && entry.unobservedReason
|
|
237
|
+
? {
|
|
238
|
+
unobserved: {
|
|
239
|
+
reason: entry.unobservedReason,
|
|
240
|
+
...(entry.unobservedDetail ? { detail: entry.unobservedDetail } : {}),
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
: {}),
|
|
207
244
|
});
|
|
208
245
|
}
|
|
209
246
|
|
|
@@ -275,6 +312,15 @@ export function reconcileStatus(
|
|
|
275
312
|
detail = recorded
|
|
276
313
|
? "recorded; live status not queried (pass --live to reconcile)"
|
|
277
314
|
: "no release record found";
|
|
315
|
+
} else if (evidence?.unobserved) {
|
|
316
|
+
// Live evidence was requested and could not be read (#1089). Neither
|
|
317
|
+
// `stale` (which claims the component is gone) nor `reconciled` is
|
|
318
|
+
// supportable — the honest verdict is that nothing is known.
|
|
319
|
+
reconciliation = "unknown";
|
|
320
|
+
const why = `${unobservedReasonText(evidence.unobserved.reason)}${evidence.unobserved.detail ? `: ${evidence.unobserved.detail}` : ""}`;
|
|
321
|
+
detail = recorded
|
|
322
|
+
? `recorded ${recorded.timestamp} (digest ${recorded.digest}), but live state could not be observed — ${why}`
|
|
323
|
+
: `no release record, and live state could not be observed — ${why}`;
|
|
278
324
|
} else if (!recorded && evidence?.live) {
|
|
279
325
|
reconciliation = "unrecorded";
|
|
280
326
|
detail = `live${evidence.ownership === "owned" ? " and chant-owned" : ""}, but no release record exists — deployed outside the recorded path`;
|
|
@@ -300,7 +346,11 @@ export function reconcileStatus(
|
|
|
300
346
|
componentBom,
|
|
301
347
|
reconciliation,
|
|
302
348
|
detail,
|
|
303
|
-
|
|
349
|
+
// `live` is only emitted when it is a real answer: requested AND read.
|
|
350
|
+
// An unread component leaves it absent (= unknown to every consumer)
|
|
351
|
+
// rather than reporting `false`, which would read as "not deployed".
|
|
352
|
+
...(liveEvidence && !evidence?.unobserved ? { live: !!evidence?.live } : {}),
|
|
353
|
+
...(evidence?.unobserved ? { unobserved: evidence.unobserved } : {}),
|
|
304
354
|
...(evidence?.stack ? { stack: evidence.stack } : {}),
|
|
305
355
|
});
|
|
306
356
|
}
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
2
|
+
import type { UnobservedEntity } from "../observation";
|
|
2
3
|
|
|
3
4
|
export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
4
5
|
|
|
@@ -18,6 +19,13 @@ export interface LifecycleSnapshot {
|
|
|
18
19
|
timestamp: string;
|
|
19
20
|
/** Resource metadata keyed by logical name */
|
|
20
21
|
resources: Record<string, ResourceMetadata>;
|
|
22
|
+
/**
|
|
23
|
+
* Declared entities this observation could not read (#1089), keyed by logical
|
|
24
|
+
* name. Additive and optional: a snapshot without it observed everything it
|
|
25
|
+
* was asked about. Present so a later diff can tell "was not there when the
|
|
26
|
+
* snapshot was taken" from "was never looked at".
|
|
27
|
+
*/
|
|
28
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
21
29
|
/** Artifact metadata keyed by server-side identifier (lexicon-specific). */
|
|
22
30
|
artifacts?: Record<string, ArtifactMetadata>;
|
|
23
31
|
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
|
|
3
|
+
import type { EnvironmentDeclaration } from "./config";
|
|
4
|
+
|
|
5
|
+
describe("applyLiveEndpoint (#1166)", () => {
|
|
6
|
+
test("no-op — and no notice — when the environment declares no endpoint at all", () => {
|
|
7
|
+
const env: NodeJS.ProcessEnv = {};
|
|
8
|
+
const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
|
|
9
|
+
expect(result.notice).toBeUndefined();
|
|
10
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
11
|
+
result.restore(); // always safe, even as a no-op
|
|
12
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
|
|
16
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
17
|
+
const env: NodeJS.ProcessEnv = {};
|
|
18
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
19
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
20
|
+
expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
|
|
21
|
+
expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
|
|
25
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
26
|
+
const env: NodeJS.ProcessEnv = {};
|
|
27
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
28
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
29
|
+
result.restore();
|
|
30
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
|
|
34
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
35
|
+
const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
|
|
36
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
|
|
37
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
|
|
38
|
+
expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
|
|
39
|
+
result.restore();
|
|
40
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // restore never touches what it didn't set
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a bare-string environment entry has no endpoint to apply", () => {
|
|
44
|
+
const env: NodeJS.ProcessEnv = {};
|
|
45
|
+
const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
|
|
46
|
+
expect(result.notice).toBeUndefined();
|
|
47
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("only applies to lexicons actually observing, and only those with a known endpoint var", () => {
|
|
51
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
52
|
+
const env: NodeJS.ProcessEnv = {};
|
|
53
|
+
// k8s has no ambient-var knob (config-resolved instead) — nothing to set.
|
|
54
|
+
const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
|
|
55
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
56
|
+
expect(result.notice).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
|
|
60
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
61
|
+
const env: NodeJS.ProcessEnv = {};
|
|
62
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
63
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
|
|
64
|
+
expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
|
|
65
|
+
result.restore();
|
|
66
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
67
|
+
expect(env.FLY_FLAPS_BASE_URL).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
|
|
71
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
72
|
+
const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
|
|
73
|
+
const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
|
|
74
|
+
expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
|
|
75
|
+
expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
|
|
76
|
+
expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
|
|
77
|
+
expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
|
|
81
|
+
expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a name that isn't declared at all has no endpoint to apply", () => {
|
|
85
|
+
const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
|
|
86
|
+
const env: NodeJS.ProcessEnv = {};
|
|
87
|
+
const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
|
|
88
|
+
expect(result.notice).toBeUndefined();
|
|
89
|
+
expect(env.AWS_ENDPOINT_URL).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("zeroResourcesWarning (#1166)", () => {
|
|
94
|
+
test("undefined when nothing was declared to look for", () => {
|
|
95
|
+
expect(zeroResourcesWarning("aws", "floci", 0, { resources: {}, unobserved: {} })).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("undefined when resources were actually observed", () => {
|
|
99
|
+
expect(
|
|
100
|
+
zeroResourcesWarning("aws", "floci", 2, { resources: { a: {} }, unobserved: {} }),
|
|
101
|
+
).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("undefined when the emptiness is already explained by #1089 unobserved", () => {
|
|
105
|
+
expect(
|
|
106
|
+
zeroResourcesWarning("aws", "floci", 2, { resources: {}, unobserved: { a: { reason: "no-binding" } } }),
|
|
107
|
+
).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("warns with declared count and the check-endpoint hint when truly empty and unexplained", () => {
|
|
111
|
+
expect(zeroResourcesWarning("aws", "floci", 3, { resources: {}, unobserved: {} })).toBe(
|
|
112
|
+
'aws: 0 live resources for env "floci" (3 declared) — check the endpoint/credentials',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|