@intentius/chant 0.28.0 → 0.29.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/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- 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 +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +31 -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/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- 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 +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- 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.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- 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 +34 -9
- 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
|
@@ -9,6 +9,7 @@ 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
|
+
import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
|
|
12
13
|
|
|
13
14
|
/** Patterns in attribute names that suggest sensitive data. */
|
|
14
15
|
const SENSITIVE_PATTERNS = [
|
|
@@ -124,22 +125,32 @@ export async function takeSnapshot(
|
|
|
124
125
|
|
|
125
126
|
let resources: Record<string, ResourceMetadata> = {};
|
|
126
127
|
let artifacts: Record<string, ArtifactMetadata> = {};
|
|
128
|
+
let unobserved: Record<string, UnobservedEntity> = {};
|
|
127
129
|
|
|
128
130
|
try {
|
|
129
131
|
if (plugin.describeResources) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
const observed = normalizeObservation(
|
|
133
|
+
await plugin.describeResources({
|
|
134
|
+
environment,
|
|
135
|
+
buildOutput,
|
|
136
|
+
entityNames,
|
|
137
|
+
entities,
|
|
138
|
+
stack,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
|
|
138
142
|
warnings.push(...validationWarnings);
|
|
139
143
|
if (dropped.length > 0) {
|
|
140
144
|
warnings.push(`${plugin.name}: dropped ${dropped.length} invalid resource(s)`);
|
|
141
145
|
}
|
|
142
146
|
resources = valid;
|
|
147
|
+
// Record the holes (#1089). A snapshot is evidence of what was seen; an
|
|
148
|
+
// entity nobody could read must not be recorded as "was not there",
|
|
149
|
+
// because the next diff would then read it back as absent.
|
|
150
|
+
unobserved = observed.unobserved;
|
|
151
|
+
for (const [name, entry] of Object.entries(unobserved)) {
|
|
152
|
+
warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
|
|
153
|
+
}
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
if (plugin.listArtifacts) {
|
|
@@ -153,7 +164,12 @@ export async function takeSnapshot(
|
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
if (Object.keys(resources).length === 0 && Object.keys(artifacts).length === 0) {
|
|
156
|
-
|
|
167
|
+
const unreadable = Object.keys(unobserved).length;
|
|
168
|
+
errors.push(
|
|
169
|
+
unreadable > 0
|
|
170
|
+
? `${plugin.name}: nothing observed — ${unreadable} declared entity(ies) could not be read (see warnings); not snapshotting an unread environment as empty`
|
|
171
|
+
: `${plugin.name}: no valid resources or artifacts returned`,
|
|
172
|
+
);
|
|
157
173
|
continue;
|
|
158
174
|
}
|
|
159
175
|
|
|
@@ -164,15 +180,24 @@ export async function takeSnapshot(
|
|
|
164
180
|
commit: headCommit,
|
|
165
181
|
timestamp,
|
|
166
182
|
resources,
|
|
183
|
+
...(Object.keys(unobserved).length > 0 && { unobserved }),
|
|
167
184
|
...(Object.keys(artifacts).length > 0 && { artifacts }),
|
|
168
185
|
digest,
|
|
169
186
|
};
|
|
170
187
|
|
|
171
188
|
snapshots.push(snapshot);
|
|
172
189
|
} catch (err) {
|
|
190
|
+
// A thrown read is not an empty environment — record nothing and say so
|
|
191
|
+
// (#1089). Writing a snapshot here would persist "none of this exists".
|
|
173
192
|
errors.push(
|
|
174
193
|
`${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
194
|
);
|
|
195
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
196
|
+
for (const [name, entry] of Object.entries(
|
|
197
|
+
unobservedAll(entityNames, "read-failed", message, entities),
|
|
198
|
+
)) {
|
|
199
|
+
warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
|
|
200
|
+
}
|
|
176
201
|
}
|
|
177
202
|
}
|
|
178
203
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #1166 — let a declared `environment` carry its own endpoint so
|
|
3
|
+
* `--live --env <name>` is self-sufficient.
|
|
4
|
+
*
|
|
5
|
+
* The bug this closes: `chant graph --live --env floci` (and `lifecycle
|
|
6
|
+
* diff`/`plan`) observe a stack by shelling out through each lexicon's
|
|
7
|
+
* `describeResources()`. For AWS that shell-out honors the ambient
|
|
8
|
+
* `AWS_ENDPOINT_URL` env var — when a project's `floci` environment is a local
|
|
9
|
+
* emulator (`http://localhost:4566`) but the invoking shell never exported
|
|
10
|
+
* that var, the AWS CLI silently targets real AWS instead. The stack named
|
|
11
|
+
* after the environment doesn't exist there, so `describeResources` hits its
|
|
12
|
+
* `stackDoesNotExist` branch and returns an empty, unremarkable "nothing is
|
|
13
|
+
* deployed" — indistinguishable from the truthful answer. This cost real
|
|
14
|
+
* debugging time validating #1162's live overlay: the observation code was
|
|
15
|
+
* right, the manual repro just never set the var.
|
|
16
|
+
*
|
|
17
|
+
* The fix: `environments` in `chant.config.ts` can name an endpoint per
|
|
18
|
+
* environment (`config.ts`'s `EnvironmentDeclaration`), and {@link
|
|
19
|
+
* applyLiveEndpoint} injects it into the ambient env var each observing
|
|
20
|
+
* lexicon's CLI shell-out actually reads — but only for a var that isn't
|
|
21
|
+
* already set. Ambient always wins: a shell that already exports
|
|
22
|
+
* `AWS_ENDPOINT_URL` sees no change in behavior.
|
|
23
|
+
*
|
|
24
|
+
* Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
|
|
25
|
+
* all, since that's the specific footgun — a lexicon whose environment
|
|
26
|
+
* binding is resolved from `chant.config` itself (not an ambient var) has
|
|
27
|
+
* nothing to inject here:
|
|
28
|
+
*
|
|
29
|
+
* - **aws** — `AWS_ENDPOINT_URL`, read directly by
|
|
30
|
+
* `lexicons/aws/src/components/cloud-executor.ts` / `plugin.ts` before
|
|
31
|
+
* every `aws …` shell-out (`applyAwsEndpoint`/`applyAwsEndpointArgv`).
|
|
32
|
+
* - **fly** — `FLY_FLAPS_BASE_URL`, read by `resolveEndpoint()` in
|
|
33
|
+
* `lexicons/fly/src/op/activities/fly-apply.ts`, the same seam
|
|
34
|
+
* `describeResources` (`../describe-resources.ts`) calls through.
|
|
35
|
+
* - **gcp**, **k8s** — resolve their live target from `chant.config` itself
|
|
36
|
+
* (`k8s.profiles.<env>.context` via `resolveClusterTarget`,
|
|
37
|
+
* `packages/core/src/kubectl-context.ts`), not an ambient var. Nothing to
|
|
38
|
+
* inject: the config *is* the binding already.
|
|
39
|
+
* - **azure** — resolves via the `az` CLI's own logged-in
|
|
40
|
+
* subscription/session context; no ambient endpoint var exists to miss.
|
|
41
|
+
* - **temporal** — resolves its connection from `temporal.profiles.<env>`
|
|
42
|
+
* (`resolveProfile`, `lexicons/temporal/src/describe-resources.ts`), the
|
|
43
|
+
* same "config is the binding" shape as k8s/gcp.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Per-lexicon ambient env var a `--live` read honors for its endpoint. Only
|
|
50
|
+
* lexicons with a genuine ambient-var footgun are listed — see the module doc
|
|
51
|
+
* for the full audit (gcp/k8s/azure/temporal resolve their target from
|
|
52
|
+
* `chant.config` instead, so they have nothing to inject).
|
|
53
|
+
*/
|
|
54
|
+
export const LEXICON_ENDPOINT_ENV_VAR: Record<string, string> = {
|
|
55
|
+
aws: "AWS_ENDPOINT_URL",
|
|
56
|
+
fly: "FLY_FLAPS_BASE_URL",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
|
|
60
|
+
export interface AppliedEndpoint {
|
|
61
|
+
/**
|
|
62
|
+
* One line describing what happened, or `undefined` when the environment
|
|
63
|
+
* declares no endpoint at all (nothing to say). Present whether the
|
|
64
|
+
* declared endpoint was applied OR an ambient var already won — #1166's
|
|
65
|
+
* "no silent anything" stance: an operator should never have to guess which
|
|
66
|
+
* target a `--live` read actually used.
|
|
67
|
+
*/
|
|
68
|
+
notice?: string;
|
|
69
|
+
/** Undo whatever ambient env vars this call set. Always safe to call. */
|
|
70
|
+
restore: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve `environment`'s declared endpoint (if any) from `config.environments`
|
|
75
|
+
* and apply it to the ambient env var of every lexicon in `lexicons` that has
|
|
76
|
+
* one ({@link LEXICON_ENDPOINT_ENV_VAR}) — but only when that var isn't
|
|
77
|
+
* already set. Ambient always wins (#1166): behavior for a shell that already
|
|
78
|
+
* exports `AWS_ENDPOINT_URL` is unchanged.
|
|
79
|
+
*
|
|
80
|
+
* Call before a `--live` describe/enrich pass; `restore()` in a `finally` so
|
|
81
|
+
* the injected value never leaks into a later invocation in the same process
|
|
82
|
+
* (tests, or a long-lived host like the MCP server).
|
|
83
|
+
*/
|
|
84
|
+
export function applyLiveEndpoint(
|
|
85
|
+
environments: EnvironmentDeclaration[] | undefined,
|
|
86
|
+
environment: string,
|
|
87
|
+
lexicons: readonly string[],
|
|
88
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
89
|
+
): AppliedEndpoint {
|
|
90
|
+
const endpoint = environmentEndpoint(environments, environment);
|
|
91
|
+
if (!endpoint) return { restore: () => {} };
|
|
92
|
+
|
|
93
|
+
const applied: string[] = [];
|
|
94
|
+
const overridden: string[] = [];
|
|
95
|
+
const seen = new Set<string>(); // a var shared by two lexicons is only reported once
|
|
96
|
+
for (const lexicon of lexicons) {
|
|
97
|
+
const varName = LEXICON_ENDPOINT_ENV_VAR[lexicon];
|
|
98
|
+
if (!varName || seen.has(varName)) continue;
|
|
99
|
+
seen.add(varName);
|
|
100
|
+
if (env[varName]) {
|
|
101
|
+
overridden.push(varName);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
env[varName] = endpoint;
|
|
105
|
+
applied.push(varName);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const notices: string[] = [];
|
|
109
|
+
if (applied.length > 0) {
|
|
110
|
+
notices.push(
|
|
111
|
+
`environment "${environment}" declares endpoint ${endpoint} — applied to ${applied.join(", ")} for this read`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (overridden.length > 0) {
|
|
115
|
+
notices.push(
|
|
116
|
+
`ambient ${overridden.join(", ")} already set — keeping it over environment "${environment}"'s declared endpoint (${endpoint})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
notice: notices.length > 0 ? notices.join("; ") : undefined,
|
|
122
|
+
restore: () => {
|
|
123
|
+
for (const varName of applied) delete env[varName];
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* #1166 acceptance: when a `--live` describe comes back with zero resources
|
|
130
|
+
* for a lexicon that had declared entities to look for, and nothing was
|
|
131
|
+
* already reported NOT-OBSERVED (#1089) either, that is either "genuinely
|
|
132
|
+
* nothing is deployed yet" or a misconfigured endpoint/credentials — the two
|
|
133
|
+
* are visually identical, so a caller must say so rather than stay quiet.
|
|
134
|
+
* Returns `undefined` when there is nothing to declare (no declared entities
|
|
135
|
+
* to have asked about, or the emptiness is already explained by #1089's
|
|
136
|
+
* `unobserved`).
|
|
137
|
+
*/
|
|
138
|
+
export function zeroResourcesWarning(
|
|
139
|
+
lexicon: string,
|
|
140
|
+
environment: string,
|
|
141
|
+
declaredCount: number,
|
|
142
|
+
observed: { resources: Record<string, unknown>; unobserved: Record<string, unknown> },
|
|
143
|
+
): string | undefined {
|
|
144
|
+
if (declaredCount === 0) return undefined;
|
|
145
|
+
if (Object.keys(observed.resources).length > 0) return undefined;
|
|
146
|
+
if (Object.keys(observed.unobserved).length > 0) return undefined;
|
|
147
|
+
return `${lexicon}: 0 live resources for env "${environment}" (${declaredCount} declared) — check the endpoint/credentials`;
|
|
148
|
+
}
|