@intentius/chant 0.27.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/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- 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-run.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/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.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 +100 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- 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/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.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 +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +190 -0
- package/src/cli/commands/build.ts +34 -2
- package/src/cli/commands/lint.ts +17 -25
- 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-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- 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.test.ts +94 -0
- package/src/kubectl-context.ts +207 -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/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- 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 +76 -3
- package/src/stack-output.ts +59 -26
|
@@ -23,6 +23,7 @@ describe("diffLive", () => {
|
|
|
23
23
|
newlyObserved: [],
|
|
24
24
|
driftedSinceSnapshot: [],
|
|
25
25
|
unchanged: [],
|
|
26
|
+
unobserved: [],
|
|
26
27
|
});
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -112,6 +113,44 @@ describe("diffLive", () => {
|
|
|
112
113
|
expect(result.driftedSinceSnapshot.map((d) => d.name)).toEqual(["c"]);
|
|
113
114
|
expect(result.unchanged).toEqual(["b"]);
|
|
114
115
|
});
|
|
116
|
+
|
|
117
|
+
// ── The observation tri-state (#1089) ─────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
test("declared and not observed → unobserved, not missing", () => {
|
|
120
|
+
const result = diffLive({
|
|
121
|
+
declared: new Set(["crd", "gone"]),
|
|
122
|
+
observedNow: {},
|
|
123
|
+
observedThen: undefined,
|
|
124
|
+
unobserved: { crd: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" } },
|
|
125
|
+
});
|
|
126
|
+
expect(result.missing).toEqual(["gone"]);
|
|
127
|
+
expect(result.unobserved).toEqual([
|
|
128
|
+
{ name: "crd", type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("an unobserved entity in the last snapshot has not disappeared", () => {
|
|
133
|
+
const result = diffLive({
|
|
134
|
+
declared: new Set(["crd"]),
|
|
135
|
+
observedNow: {},
|
|
136
|
+
observedThen: { crd: meta() },
|
|
137
|
+
unobserved: { crd: { reason: "no-credentials" } },
|
|
138
|
+
});
|
|
139
|
+
expect(result.disappeared).toEqual([]);
|
|
140
|
+
expect(result.missing).toEqual([]);
|
|
141
|
+
expect(result.unobserved.map((u) => u.name)).toEqual(["crd"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a resource that was returned is never also unobserved", () => {
|
|
145
|
+
const result = diffLive({
|
|
146
|
+
declared: new Set(["a"]),
|
|
147
|
+
observedNow: { a: meta() },
|
|
148
|
+
observedThen: undefined,
|
|
149
|
+
unobserved: { a: { reason: "read-failed" } },
|
|
150
|
+
});
|
|
151
|
+
expect(result.unobserved).toEqual([]);
|
|
152
|
+
expect(result.newlyObserved).toEqual(["a"]);
|
|
153
|
+
});
|
|
115
154
|
});
|
|
116
155
|
|
|
117
156
|
describe("diffLiveArtifacts", () => {
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -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,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
|
|