@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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* outside chant's entity model)
|
|
14
14
|
*/
|
|
15
15
|
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
16
|
+
import type { UnobservedEntity, UnobservedReason } from "../observation";
|
|
16
17
|
|
|
17
18
|
export interface AttributeChange {
|
|
18
19
|
/** Attribute path (e.g. "status", "physicalId", "attributes.tags.env"). */
|
|
@@ -27,8 +28,20 @@ export interface ResourceDrift {
|
|
|
27
28
|
changes: AttributeChange[];
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** A declared entity the lexicon could not observe, as reported by the diff (#1089). */
|
|
32
|
+
export interface UnobservedResource {
|
|
33
|
+
name: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
reason: UnobservedReason;
|
|
36
|
+
detail?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export interface LiveDiffResult {
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Declared in current build, and the provider reported it absent. Entities
|
|
42
|
+
* the lexicon could not observe are NOT here — they are in `unobserved`
|
|
43
|
+
* (#1089), so "missing" keeps meaning "confirmed not there".
|
|
44
|
+
*/
|
|
32
45
|
missing: string[];
|
|
33
46
|
/** Observed in cloud right now, but not declared. */
|
|
34
47
|
orphan: string[];
|
|
@@ -40,6 +53,12 @@ export interface LiveDiffResult {
|
|
|
40
53
|
driftedSinceSnapshot: ResourceDrift[];
|
|
41
54
|
/** Observed both then and now; metadata identical. */
|
|
42
55
|
unchanged: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Declared, and the lexicon could not look (#1089) — no reader for the kind,
|
|
58
|
+
* the read failed, no credentials, no binding. Not drift, not absence: a hole
|
|
59
|
+
* in the observation. Sorted by name.
|
|
60
|
+
*/
|
|
61
|
+
unobserved: UnobservedResource[];
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
export interface DiffLiveInput {
|
|
@@ -49,6 +68,12 @@ export interface DiffLiveInput {
|
|
|
49
68
|
observedNow: Record<string, ResourceMetadata>;
|
|
50
69
|
/** Resources captured by the previous snapshot, if any. */
|
|
51
70
|
observedThen: Record<string, ResourceMetadata> | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Declared entities `describeResources()` reported as NOT-OBSERVED (#1089),
|
|
73
|
+
* keyed by entity name. Absent/empty means every declared entity was looked
|
|
74
|
+
* at, so absence from `observedNow` is a confirmed absence.
|
|
75
|
+
*/
|
|
76
|
+
unobserved?: Record<string, UnobservedEntity>;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
|
|
@@ -135,6 +160,12 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
135
160
|
const observedThenMap = observedThen ?? {};
|
|
136
161
|
const observedNowNames = new Set(Object.keys(observedNow));
|
|
137
162
|
const observedThenNames = new Set(Object.keys(observedThenMap));
|
|
163
|
+
// A resource the lexicon returned is observed, whatever it also said about
|
|
164
|
+
// it — present beats not-observed.
|
|
165
|
+
const unobservedMap = input.unobserved ?? {};
|
|
166
|
+
const unobservedNames = new Set(
|
|
167
|
+
Object.keys(unobservedMap).filter((n) => !observedNowNames.has(n)),
|
|
168
|
+
);
|
|
138
169
|
|
|
139
170
|
const missing: string[] = [];
|
|
140
171
|
const orphan: string[] = [];
|
|
@@ -142,10 +173,23 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
142
173
|
const newlyObserved: string[] = [];
|
|
143
174
|
const driftedSinceSnapshot: ResourceDrift[] = [];
|
|
144
175
|
const unchanged: string[] = [];
|
|
176
|
+
const unobserved: UnobservedResource[] = [];
|
|
177
|
+
|
|
178
|
+
for (const name of unobservedNames) {
|
|
179
|
+
const entry = unobservedMap[name];
|
|
180
|
+
unobserved.push({
|
|
181
|
+
name,
|
|
182
|
+
...(entry.type ? { type: entry.type } : {}),
|
|
183
|
+
reason: entry.reason,
|
|
184
|
+
...(entry.detail ? { detail: entry.detail } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
145
187
|
|
|
146
|
-
// Declared
|
|
188
|
+
// Declared, looked at, and the provider said it isn't there → missing.
|
|
189
|
+
// Declared but never looked at is `unobserved`, not missing — the whole point
|
|
190
|
+
// of #1089: "we didn't check" must not read as "it isn't there".
|
|
147
191
|
for (const name of declared) {
|
|
148
|
-
if (!observedNowNames.has(name)) {
|
|
192
|
+
if (!observedNowNames.has(name) && !unobservedNames.has(name)) {
|
|
149
193
|
missing.push(name);
|
|
150
194
|
}
|
|
151
195
|
}
|
|
@@ -157,9 +201,10 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
203
|
|
|
160
|
-
// In previous snapshot but not observed now → disappeared
|
|
204
|
+
// In previous snapshot but not observed now → disappeared. An entity nobody
|
|
205
|
+
// could look at has not disappeared; it is unobserved.
|
|
161
206
|
for (const name of observedThenNames) {
|
|
162
|
-
if (!observedNowNames.has(name)) {
|
|
207
|
+
if (!observedNowNames.has(name) && !unobservedNames.has(name)) {
|
|
163
208
|
disappeared.push(name);
|
|
164
209
|
}
|
|
165
210
|
}
|
|
@@ -194,6 +239,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
194
239
|
newlyObserved: newlyObserved.sort(),
|
|
195
240
|
driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
|
|
196
241
|
unchanged: unchanged.sort(),
|
|
242
|
+
unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
|
|
197
243
|
};
|
|
198
244
|
}
|
|
199
245
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
acceptDeviations,
|
|
4
|
+
acceptedDeviation,
|
|
5
|
+
baselineForLexicon,
|
|
6
|
+
countAccepted,
|
|
7
|
+
emptyBaseline,
|
|
8
|
+
isObservationBaseline,
|
|
9
|
+
parseBaseline,
|
|
10
|
+
serializeBaseline,
|
|
11
|
+
} from "./observation-baseline";
|
|
12
|
+
|
|
13
|
+
describe("the baseline document", () => {
|
|
14
|
+
test("round-trips through serialize/parse", () => {
|
|
15
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
16
|
+
{ entity: "Assets", type: "AWS::S3::Bucket", path: "Tags[0].Value", value: "platform", note: "org policy" },
|
|
17
|
+
], { now: "2026-07-27T00:00:00.000Z" });
|
|
18
|
+
const parsed = parseBaseline(serializeBaseline(b));
|
|
19
|
+
expect(parsed).toEqual(b);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("serializes with sorted keys and a trailing newline, so the commit diff reads cleanly", () => {
|
|
23
|
+
const json = serializeBaseline(emptyBaseline("prod"));
|
|
24
|
+
expect(json.endsWith("\n")).toBe(true);
|
|
25
|
+
expect(JSON.parse(json)).toEqual({ baseline: "v1", environment: "prod", lexicons: {} });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("refuses to read anything that is not a versioned baseline", () => {
|
|
29
|
+
expect(parseBaseline(null)).toBeNull();
|
|
30
|
+
expect(parseBaseline("")).toBeNull();
|
|
31
|
+
expect(parseBaseline("{ not json")).toBeNull();
|
|
32
|
+
expect(parseBaseline('{"baseline":"v2","lexicons":{}}')).toBeNull();
|
|
33
|
+
expect(parseBaseline('{"lexicons":{}}')).toBeNull();
|
|
34
|
+
expect(isObservationBaseline({ baseline: "v1", lexicons: {} })).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("acceptDeviations", () => {
|
|
39
|
+
const now = "2026-07-27T00:00:00.000Z";
|
|
40
|
+
|
|
41
|
+
test("records a deviation bound to the value that was accepted", () => {
|
|
42
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
43
|
+
{ entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
|
|
44
|
+
], { now });
|
|
45
|
+
expect(baselineForLexicon(b, "aws")).toEqual({
|
|
46
|
+
Role: {
|
|
47
|
+
type: "AWS::IAM::Role",
|
|
48
|
+
accepted: [{ path: "MaxSessionDuration", value: 7200, recordedAt: now }],
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
expect(b.updated).toBe(now);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not mutate the input", () => {
|
|
55
|
+
const before = emptyBaseline("prod");
|
|
56
|
+
acceptDeviations(before, "aws", [{ entity: "R", path: "A", value: 1 }], { now });
|
|
57
|
+
expect(before.lexicons).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("re-accepting the same path replaces the entry rather than appending a second", () => {
|
|
61
|
+
let b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R", path: "A", value: 1 }], { now });
|
|
62
|
+
b = acceptDeviations(b, "aws", [{ entity: "R", path: "A", value: 2 }], { now });
|
|
63
|
+
expect(baselineForLexicon(b, "aws").R.accepted).toEqual([{ path: "A", value: 2, recordedAt: now }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("keeps deviations from other entities and other lexicons", () => {
|
|
67
|
+
let b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R1", path: "A", value: 1 }], { now });
|
|
68
|
+
b = acceptDeviations(b, "aws", [{ entity: "R2", path: "B", value: 2 }], { now });
|
|
69
|
+
b = acceptDeviations(b, "k8s", [{ entity: "D", path: "spec.replicas", value: 3 }], { now });
|
|
70
|
+
expect(Object.keys(baselineForLexicon(b, "aws")).sort()).toEqual(["R1", "R2"]);
|
|
71
|
+
expect(countAccepted(b)).toBe(3);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("accepted paths sort, so the committed file is stable across runs", () => {
|
|
75
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
76
|
+
{ entity: "R", path: "Z", value: 1 },
|
|
77
|
+
{ entity: "R", path: "A", value: 2 },
|
|
78
|
+
], { now });
|
|
79
|
+
expect(baselineForLexicon(b, "aws").R.accepted.map((a) => a.path)).toEqual(["A", "Z"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an empty accept list is a no-op", () => {
|
|
83
|
+
const b = emptyBaseline("prod");
|
|
84
|
+
expect(acceptDeviations(b, "aws", [])).toBe(b);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("lookup is by entity and path", () => {
|
|
88
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R", path: "A", value: 1 }], { now });
|
|
89
|
+
const lex = baselineForLexicon(b, "aws");
|
|
90
|
+
expect(acceptedDeviation(lex, "R", "A")?.value).toBe(1);
|
|
91
|
+
expect(acceptedDeviation(lex, "R", "B")).toBeUndefined();
|
|
92
|
+
expect(acceptedDeviation(lex, "Other", "A")).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a missing baseline reads as nothing accepted", () => {
|
|
96
|
+
expect(baselineForLexicon(null, "aws")).toEqual({});
|
|
97
|
+
expect(countAccepted(null)).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accepted-observation baseline (#1014) — a committed record of deviations
|
|
3
|
+
* somebody looked at and accepted, so they stop re-alerting.
|
|
4
|
+
*
|
|
5
|
+
* Deep observation reports every property that differs between source and
|
|
6
|
+
* cloud, including properties nobody ever declared. Some of those are real
|
|
7
|
+
* findings. Many are permanent facts of the account — a platform team's
|
|
8
|
+
* mandatory tag, a bucket setting an org policy flips on, a role an operator
|
|
9
|
+
* attached by hand and everyone agreed to keep. Without somewhere to record
|
|
10
|
+
* "yes, we know, leave it", a deep diff is a report nobody reads twice.
|
|
11
|
+
*
|
|
12
|
+
* The model is cdk-real-drift's `.cdkrd`: a snapshot of accepted *undeclared*
|
|
13
|
+
* values that the diff subtracts. Accepting is an explicit act with a git
|
|
14
|
+
* commit behind it, and the acceptance is value-bound — accept
|
|
15
|
+
* `VersioningConfiguration.Status = Enabled` and a later change to `Suspended`
|
|
16
|
+
* is drift again, because what was accepted was that value, not that path.
|
|
17
|
+
*
|
|
18
|
+
* ## What this is not
|
|
19
|
+
*
|
|
20
|
+
* Not state. The baseline never tells a deploy what to do and is never read on
|
|
21
|
+
* the write path; deleting it costs you noise suppression and nothing else.
|
|
22
|
+
* Which is also why it is safe for it to be incomplete or stale.
|
|
23
|
+
*
|
|
24
|
+
* ## Where it lives
|
|
25
|
+
*
|
|
26
|
+
* `<environment>/observation-baseline.json` on the `chant/lifecycle` orphan
|
|
27
|
+
* branch — the epic's named candidate home, and the same storage the snapshots
|
|
28
|
+
* (`<env>/<lexicon>.json`), the release ledger (`<env>/releases.jsonl`) and the
|
|
29
|
+
* build archive (`_builds/<digest>.json`) already use, through the same
|
|
30
|
+
* `writeBlobToPath`/`readBlobFromPath` plumbing. One env-keyed namespace for
|
|
31
|
+
* everything chant records *about* an environment rather than *for* it.
|
|
32
|
+
*
|
|
33
|
+
* The parse/serialize/update half below is pure and storage-free, so the
|
|
34
|
+
* decision is one function call deep if a repo-committed file (`.chant/`) turns
|
|
35
|
+
* out to be the better review surface.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
39
|
+
import { sortedJsonReplacer } from "../utils";
|
|
40
|
+
|
|
41
|
+
/** The file name under `<environment>/` on the orphan branch. */
|
|
42
|
+
export const OBSERVATION_BASELINE_FILE = "observation-baseline.json";
|
|
43
|
+
|
|
44
|
+
/** One deviation somebody accepted, bound to the value they accepted. */
|
|
45
|
+
export interface AcceptedDeviation {
|
|
46
|
+
/** Property path within the entity's normalized tree (`Tags[0].Value`, `Policy.Statement[1].Effect`). */
|
|
47
|
+
path: string;
|
|
48
|
+
/** The live value at the moment of acceptance. A different live value later is drift again. */
|
|
49
|
+
value: unknown;
|
|
50
|
+
/** Free-text justification, written by whoever accepted it. */
|
|
51
|
+
note?: string;
|
|
52
|
+
/** ISO timestamp of acceptance. */
|
|
53
|
+
recordedAt?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every accepted deviation for one declared entity. */
|
|
57
|
+
export interface BaselineEntity {
|
|
58
|
+
/** Entity type at acceptance time, for readability in the committed file. */
|
|
59
|
+
type?: string;
|
|
60
|
+
accepted: AcceptedDeviation[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Accepted deviations for one lexicon, keyed by chant entity name. */
|
|
64
|
+
export type BaselineLexicon = Record<string, BaselineEntity>;
|
|
65
|
+
|
|
66
|
+
/** The committed baseline document for one environment. */
|
|
67
|
+
export interface ObservationBaseline {
|
|
68
|
+
/** Discriminant + wire version. */
|
|
69
|
+
readonly baseline: "v1";
|
|
70
|
+
environment: string;
|
|
71
|
+
/** ISO timestamp of the last `--update-baseline`. */
|
|
72
|
+
updated?: string;
|
|
73
|
+
/** lexicon → entity → accepted deviations. */
|
|
74
|
+
lexicons: Record<string, BaselineLexicon>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An environment with nothing accepted yet. */
|
|
78
|
+
export function emptyBaseline(environment: string): ObservationBaseline {
|
|
79
|
+
return { baseline: "v1", environment, lexicons: {} };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when `value` is a well-formed {@link ObservationBaseline}. */
|
|
83
|
+
export function isObservationBaseline(value: unknown): value is ObservationBaseline {
|
|
84
|
+
return (
|
|
85
|
+
typeof value === "object" &&
|
|
86
|
+
value !== null &&
|
|
87
|
+
(value as { baseline?: unknown }).baseline === "v1" &&
|
|
88
|
+
typeof (value as { lexicons?: unknown }).lexicons === "object" &&
|
|
89
|
+
(value as { lexicons?: unknown }).lexicons !== null
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a baseline document. Returns `null` for unparseable or unrecognized
|
|
95
|
+
* content — a corrupt baseline degrades to "nothing is accepted", which is
|
|
96
|
+
* noisy but never wrong. Silently treating garbage as a baseline would
|
|
97
|
+
* suppress real drift.
|
|
98
|
+
*/
|
|
99
|
+
export function parseBaseline(content: string | null | undefined): ObservationBaseline | null {
|
|
100
|
+
if (!content) return null;
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(content);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!isObservationBaseline(parsed)) return null;
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Deterministic on-disk form: sorted keys, trailing newline, reviewable diff. */
|
|
112
|
+
export function serializeBaseline(baseline: ObservationBaseline): string {
|
|
113
|
+
return `${JSON.stringify(baseline, sortedJsonReplacer, 2)}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The accepted deviations for one lexicon, or an empty map. */
|
|
117
|
+
export function baselineForLexicon(
|
|
118
|
+
baseline: ObservationBaseline | null | undefined,
|
|
119
|
+
lexicon: string,
|
|
120
|
+
): BaselineLexicon {
|
|
121
|
+
return baseline?.lexicons?.[lexicon] ?? {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Look up one accepted deviation by entity + path. */
|
|
125
|
+
export function acceptedDeviation(
|
|
126
|
+
lexiconBaseline: BaselineLexicon,
|
|
127
|
+
entity: string,
|
|
128
|
+
path: string,
|
|
129
|
+
): AcceptedDeviation | undefined {
|
|
130
|
+
return lexiconBaseline[entity]?.accepted.find((a) => a.path === path);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One deviation to record as accepted. */
|
|
134
|
+
export interface DeviationToAccept {
|
|
135
|
+
entity: string;
|
|
136
|
+
type?: string;
|
|
137
|
+
path: string;
|
|
138
|
+
/** The live value being accepted. */
|
|
139
|
+
value: unknown;
|
|
140
|
+
note?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Record deviations as accepted, returning a new baseline (the input is not
|
|
145
|
+
* mutated). An existing acceptance for the same entity+path is replaced — that
|
|
146
|
+
* is how re-accepting after a deliberate change works, and it keeps the file
|
|
147
|
+
* from growing a second entry for every value a path has ever held.
|
|
148
|
+
*/
|
|
149
|
+
export function acceptDeviations(
|
|
150
|
+
baseline: ObservationBaseline,
|
|
151
|
+
lexicon: string,
|
|
152
|
+
deviations: readonly DeviationToAccept[],
|
|
153
|
+
opts?: { now?: string },
|
|
154
|
+
): ObservationBaseline {
|
|
155
|
+
if (deviations.length === 0) return baseline;
|
|
156
|
+
const now = opts?.now ?? new Date().toISOString();
|
|
157
|
+
const lexicons: Record<string, BaselineLexicon> = { ...baseline.lexicons };
|
|
158
|
+
const entities: BaselineLexicon = { ...(lexicons[lexicon] ?? {}) };
|
|
159
|
+
|
|
160
|
+
for (const dev of deviations) {
|
|
161
|
+
const existing = entities[dev.entity];
|
|
162
|
+
const accepted = (existing?.accepted ?? []).filter((a) => a.path !== dev.path);
|
|
163
|
+
accepted.push({
|
|
164
|
+
path: dev.path,
|
|
165
|
+
value: dev.value,
|
|
166
|
+
...(dev.note ? { note: dev.note } : {}),
|
|
167
|
+
recordedAt: now,
|
|
168
|
+
});
|
|
169
|
+
accepted.sort((a, b) => a.path.localeCompare(b.path));
|
|
170
|
+
entities[dev.entity] = {
|
|
171
|
+
...(dev.type ?? existing?.type ? { type: dev.type ?? existing?.type } : {}),
|
|
172
|
+
accepted,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lexicons[lexicon] = entities;
|
|
177
|
+
return { baseline: "v1", environment: baseline.environment, updated: now, lexicons };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Total accepted deviations across every lexicon — for the "N accepted" line. */
|
|
181
|
+
export function countAccepted(baseline: ObservationBaseline | null | undefined): number {
|
|
182
|
+
if (!baseline) return 0;
|
|
183
|
+
let n = 0;
|
|
184
|
+
for (const entities of Object.values(baseline.lexicons)) {
|
|
185
|
+
for (const entity of Object.values(entities)) n += entity.accepted.length;
|
|
186
|
+
}
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Storage (chant/lifecycle orphan branch) ─────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the accepted baseline for an environment. Returns `null` when the branch,
|
|
194
|
+
* the environment, or the file does not exist — every one of which means
|
|
195
|
+
* "nothing accepted yet", the normal state before anyone runs
|
|
196
|
+
* `--update-baseline`.
|
|
197
|
+
*/
|
|
198
|
+
export async function readObservationBaseline(
|
|
199
|
+
environment: string,
|
|
200
|
+
opts?: { cwd?: string },
|
|
201
|
+
): Promise<ObservationBaseline | null> {
|
|
202
|
+
return parseBaseline(await readBlobFromPath(environment, OBSERVATION_BASELINE_FILE, opts));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Write the accepted baseline to the orphan branch. Returns the new commit SHA. */
|
|
206
|
+
export async function writeObservationBaseline(
|
|
207
|
+
baseline: ObservationBaseline,
|
|
208
|
+
opts?: { cwd?: string },
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
return writeBlobToPath(
|
|
211
|
+
baseline.environment,
|
|
212
|
+
OBSERVATION_BASELINE_FILE,
|
|
213
|
+
serializeBaseline(baseline),
|
|
214
|
+
"Accepted observation baseline",
|
|
215
|
+
opts,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { observeResources } from "./observe";
|
|
3
|
+
import { observation } from "../observation";
|
|
3
4
|
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
4
5
|
import type { BuildResult } from "../build";
|
|
5
6
|
|
|
@@ -47,13 +48,20 @@ describe("observeResources", () => {
|
|
|
47
48
|
expect(names).toEqual(["web-vpc"]);
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
it("collects a throwing plugin into errors instead of failing the whole graph", async () => {
|
|
51
|
+
it("collects a throwing plugin into errors instead of failing the whole graph, and reports its entities unobserved (#1089)", async () => {
|
|
51
52
|
const plugins = [
|
|
52
53
|
awsPlugin(() => { throw new Error("access denied"); }),
|
|
53
54
|
];
|
|
54
|
-
const { observations, errors } = await observeResources("prod", plugins, mockBuild());
|
|
55
|
-
expect(observations).toEqual([]);
|
|
55
|
+
const { observations, errors, warnings } = await observeResources("prod", plugins, mockBuild());
|
|
56
56
|
expect(errors).toEqual(["aws: access denied"]);
|
|
57
|
+
// The failed read is a hole, not an empty environment: every declared
|
|
58
|
+
// entity comes back NOT-OBSERVED so nothing downstream reads it as absent.
|
|
59
|
+
expect(observations).toHaveLength(1);
|
|
60
|
+
expect(observations[0].resources).toEqual({});
|
|
61
|
+
expect(observations[0].unobserved).toEqual({
|
|
62
|
+
"web-vpc": { reason: "read-failed", type: "AWS::EC2::VPC", detail: "access denied" },
|
|
63
|
+
});
|
|
64
|
+
expect(warnings.join("\n")).toContain("web-vpc");
|
|
57
65
|
});
|
|
58
66
|
|
|
59
67
|
it("skips plugins with no describeResources and drops empty results", async () => {
|
|
@@ -63,6 +71,69 @@ describe("observeResources", () => {
|
|
|
63
71
|
expect(observations).toEqual([]);
|
|
64
72
|
});
|
|
65
73
|
|
|
74
|
+
// #1166 — this is exactly the "wrong endpoint" shape: AWS's stackDoesNotExist
|
|
75
|
+
// branch returns an empty map (bare `{}`, no #1089 envelope) for a declared
|
|
76
|
+
// entity nobody could actually observe. Before the fix this vanished with
|
|
77
|
+
// neither an observation nor a warning; now it must say so.
|
|
78
|
+
it("warns when a lexicon with declared entities observes zero resources and nothing is unobserved either (#1166)", async () => {
|
|
79
|
+
const empty = awsPlugin(() => ({}));
|
|
80
|
+
const { observations, warnings } = await observeResources("prod", [empty], mockBuild());
|
|
81
|
+
expect(observations).toEqual([]); // still no observation pushed — nothing to graph
|
|
82
|
+
expect(warnings).toEqual([
|
|
83
|
+
'aws: 0 live resources for env "prod" (1 declared) — check the endpoint/credentials',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not warn about zero resources when the lexicon declares no entities at all", async () => {
|
|
88
|
+
const empty = awsPlugin(() => ({}));
|
|
89
|
+
const noEntities: BuildResult = { outputs: new Map(), entities: new Map(), errors: [] } as unknown as BuildResult;
|
|
90
|
+
const { warnings } = await observeResources("prod", [empty], noEntities);
|
|
91
|
+
expect(warnings).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not double-warn when the emptiness is already explained by #1089 unobserved", async () => {
|
|
95
|
+
const plugin = {
|
|
96
|
+
name: "aws",
|
|
97
|
+
serializer: {} as ObservationLexicon["serializer"],
|
|
98
|
+
describeResources: async () =>
|
|
99
|
+
observation({}, { "web-vpc": { type: "AWS::EC2::VPC", reason: "no-binding" } }),
|
|
100
|
+
} as unknown as ObservationLexicon;
|
|
101
|
+
const { warnings } = await observeResources("prod", [plugin], mockBuild());
|
|
102
|
+
// Only the #1089 per-entity warning — no separate "0 live resources" line.
|
|
103
|
+
expect(warnings).toHaveLength(1);
|
|
104
|
+
expect(warnings[0]).toContain("no binding for this environment");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("carries a plugin's own unobserved entities through (#1089)", async () => {
|
|
108
|
+
const plugin = {
|
|
109
|
+
name: "aws",
|
|
110
|
+
serializer: {} as ObservationLexicon["serializer"],
|
|
111
|
+
describeResources: async () =>
|
|
112
|
+
observation({}, { "web-vpc": { type: "AWS::EC2::VPC", reason: "unsupported-kind" } }),
|
|
113
|
+
} as unknown as ObservationLexicon;
|
|
114
|
+
const { observations, warnings, errors } = await observeResources("prod", [plugin], mockBuild());
|
|
115
|
+
expect(errors).toEqual([]);
|
|
116
|
+
expect(observations[0].unobserved).toEqual({
|
|
117
|
+
"web-vpc": { type: "AWS::EC2::VPC", reason: "unsupported-kind" },
|
|
118
|
+
});
|
|
119
|
+
expect(warnings[0]).toContain("no reader for this resource kind");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("merges multi-stack reads with present > not-observed > absent (#1089)", async () => {
|
|
123
|
+
const stacks = ["a", "b"];
|
|
124
|
+
const plugin = {
|
|
125
|
+
name: "aws",
|
|
126
|
+
serializer: {} as ObservationLexicon["serializer"],
|
|
127
|
+
describeResources: async (opts: { stack?: string }) =>
|
|
128
|
+
opts.stack === "a"
|
|
129
|
+
? observation({}, { "web-vpc": { reason: "read-failed", detail: "stack a unreadable" } })
|
|
130
|
+
: observation({ "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } }),
|
|
131
|
+
} as unknown as ObservationLexicon;
|
|
132
|
+
const { observations } = await observeResources("prod", [plugin], mockBuild(), { stacks });
|
|
133
|
+
expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
|
|
134
|
+
expect(observations[0].unobserved).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
66
137
|
it("with no stacks: calls describeResources exactly once with no `stack` key (unchanged single-stack path)", async () => {
|
|
67
138
|
const calls: Array<{ stack?: string }> = [];
|
|
68
139
|
const plugins = [
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
* side effects. Snapshotting keeps its own copy for now; a future refactor can
|
|
10
10
|
* fold both onto this primitive.
|
|
11
11
|
*/
|
|
12
|
-
import type { ObservationLexicon
|
|
12
|
+
import type { ObservationLexicon } from "../lexicon";
|
|
13
13
|
import type { BuildResult } from "../build";
|
|
14
14
|
import type { SerializerResult } from "../serializer";
|
|
15
15
|
import type { LiveObservation } from "../graph-ir";
|
|
16
|
+
import {
|
|
17
|
+
mergeObservations,
|
|
18
|
+
normalizeObservation,
|
|
19
|
+
unobservedAll,
|
|
20
|
+
formatUnobserved,
|
|
21
|
+
type NormalizedObservation,
|
|
22
|
+
} from "../observation";
|
|
23
|
+
import { zeroResourcesWarning } from "../live-endpoint";
|
|
16
24
|
|
|
17
25
|
export interface ObserveResult {
|
|
18
26
|
observations: LiveObservation[];
|
|
@@ -25,8 +33,10 @@ export interface ObserveResult {
|
|
|
25
33
|
* `environment`. `owned` (default true for the managed-only diagram, epic #776)
|
|
26
34
|
* restricts to resources carrying chant's ownership marker; a lexicon with no
|
|
27
35
|
* marker channel logs and returns everything (its own contract). Plugins that
|
|
28
|
-
* throw are collected into `errors`
|
|
29
|
-
*
|
|
36
|
+
* throw are collected into `errors` — one failing lexicon never sinks the whole
|
|
37
|
+
* graph — and every entity they were asked about is recorded as NOT-OBSERVED
|
|
38
|
+
* (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
|
|
39
|
+
* hole instead of a silent absence.
|
|
30
40
|
*
|
|
31
41
|
* `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
|
|
32
42
|
* where there is no single stack named after the environment — AWS's
|
|
@@ -77,36 +87,86 @@ export async function observeResources(
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
try {
|
|
80
|
-
let
|
|
90
|
+
let observed: NormalizedObservation;
|
|
81
91
|
if (stacks.length > 0) {
|
|
82
|
-
|
|
92
|
+
const parts: NormalizedObservation[] = [];
|
|
83
93
|
for (const stack of stacks) {
|
|
84
|
-
|
|
94
|
+
parts.push(
|
|
95
|
+
normalizeObservation(
|
|
96
|
+
await plugin.describeResources({
|
|
97
|
+
environment,
|
|
98
|
+
buildOutput,
|
|
99
|
+
entityNames,
|
|
100
|
+
entities,
|
|
101
|
+
owned,
|
|
102
|
+
stack,
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
observed = mergeObservations(parts);
|
|
108
|
+
} else {
|
|
109
|
+
observed = normalizeObservation(
|
|
110
|
+
await plugin.describeResources({
|
|
85
111
|
environment,
|
|
86
112
|
buildOutput,
|
|
87
113
|
entityNames,
|
|
88
114
|
entities,
|
|
89
115
|
owned,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Object.assign(resources, perStack);
|
|
93
|
-
}
|
|
94
|
-
} else {
|
|
95
|
-
resources = await plugin.describeResources({
|
|
96
|
-
environment,
|
|
97
|
-
buildOutput,
|
|
98
|
-
entityNames,
|
|
99
|
-
entities,
|
|
100
|
-
owned,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
if (Object.keys(resources).length > 0) {
|
|
104
|
-
observations.push({ lexicon: plugin.name, resources });
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
105
118
|
}
|
|
119
|
+
pushObservation(observations, warnings, plugin.name, observed, environment, entityNames.length);
|
|
106
120
|
} catch (err) {
|
|
107
|
-
|
|
121
|
+
// A thrown read is the whole-lexicon failure: every declared entity is
|
|
122
|
+
// NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a
|
|
123
|
+
// failed read look like "none of these exist" to every consumer.
|
|
124
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
125
|
+
errors.push(`${plugin.name}: ${message}`);
|
|
126
|
+
pushObservation(
|
|
127
|
+
observations,
|
|
128
|
+
warnings,
|
|
129
|
+
plugin.name,
|
|
130
|
+
{
|
|
131
|
+
resources: {},
|
|
132
|
+
unobserved: unobservedAll(entityNames, "read-failed", message, entities),
|
|
133
|
+
},
|
|
134
|
+
environment,
|
|
135
|
+
entityNames.length,
|
|
136
|
+
);
|
|
108
137
|
}
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
return { observations, warnings, errors };
|
|
112
141
|
}
|
|
142
|
+
|
|
143
|
+
/** Record one lexicon's observation, warning once per unobserved entity. */
|
|
144
|
+
function pushObservation(
|
|
145
|
+
observations: LiveObservation[],
|
|
146
|
+
warnings: string[],
|
|
147
|
+
lexicon: string,
|
|
148
|
+
observed: NormalizedObservation,
|
|
149
|
+
environment: string,
|
|
150
|
+
declaredCount: number,
|
|
151
|
+
): void {
|
|
152
|
+
const hasResources = Object.keys(observed.resources).length > 0;
|
|
153
|
+
const unobservedNames = Object.keys(observed.unobserved);
|
|
154
|
+
for (const name of unobservedNames) {
|
|
155
|
+
warnings.push(`${lexicon}: not observed — ${formatUnobserved(name, observed.unobserved[name])}`);
|
|
156
|
+
}
|
|
157
|
+
if (!hasResources && unobservedNames.length === 0) {
|
|
158
|
+
// #1166 — this is exactly the "wrong endpoint" shape (AWS's
|
|
159
|
+
// stackDoesNotExist branch returns an empty map with no #1089 hole): a
|
|
160
|
+
// declared entity list with nothing observed and nothing explained.
|
|
161
|
+
// Previously this fell straight through with neither an observation nor a
|
|
162
|
+
// warning — silently indistinguishable from "nothing is deployed yet".
|
|
163
|
+
const notice = zeroResourcesWarning(lexicon, environment, declaredCount, observed);
|
|
164
|
+
if (notice) warnings.push(notice);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
observations.push({
|
|
168
|
+
lexicon,
|
|
169
|
+
resources: observed.resources,
|
|
170
|
+
...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|