@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
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* load-bearing.
|
|
14
14
|
*/
|
|
15
15
|
import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
|
|
16
|
+
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* What the projection proposes for a single resource.
|
|
19
20
|
*
|
|
20
|
-
* - `create` — declared in source,
|
|
21
|
+
* - `create` — declared in source, and the provider **confirmed** it absent.
|
|
21
22
|
* - `update` — declared and live, but live config drifted.
|
|
22
23
|
* - `delete` — a chant-owned resource that is live but no longer declared.
|
|
23
24
|
* Only emitted once ownership is known (#121); never inferred from the
|
|
@@ -25,8 +26,11 @@ import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff"
|
|
|
25
26
|
* - `adopt` — live but undeclared, ownership not established → a candidate to
|
|
26
27
|
* pull back into source, never an auto-delete.
|
|
27
28
|
* - `noop` — declared and live with no drift, or already reconciled.
|
|
29
|
+
* - `unobserved` — declared, and the lexicon could not look (#1089). Not a
|
|
30
|
+
* proposal at all: it is the plan admitting a hole. Never a create, never a
|
|
31
|
+
* delete. Read `unobservedReason` for which hole.
|
|
28
32
|
*/
|
|
29
|
-
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop";
|
|
33
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
@@ -47,13 +51,24 @@ export interface ChangeSetEntry {
|
|
|
47
51
|
declared: boolean;
|
|
48
52
|
/** Present in the last snapshot. */
|
|
49
53
|
inSnapshot: boolean;
|
|
50
|
-
/** Observed in the live system right now. */
|
|
54
|
+
/** Observed present in the live system right now. */
|
|
51
55
|
live: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The lexicon actually looked at this entity (#1089). `false` with
|
|
58
|
+
* `live: false` means "unknown", not "absent" — the distinction the whole
|
|
59
|
+
* change set now rests on. Absent-and-looked-at is `observed: true,
|
|
60
|
+
* live: false`.
|
|
61
|
+
*/
|
|
62
|
+
observed: boolean;
|
|
52
63
|
};
|
|
53
64
|
/** Attribute-level changes, for `update`. */
|
|
54
65
|
deltas?: AttributeChange[];
|
|
55
66
|
/** Live-marker ownership. Defaults to `unknown`. */
|
|
56
67
|
ownership: Ownership;
|
|
68
|
+
/** Why the entity could not be observed, for `action: "unobserved"` (#1089). */
|
|
69
|
+
unobservedReason?: UnobservedReason;
|
|
70
|
+
/** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
|
|
71
|
+
unobservedDetail?: string;
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
export interface ChangeSet {
|
|
@@ -67,11 +82,16 @@ export interface ChangeSet {
|
|
|
67
82
|
* `create`/`update` are precise from declared-vs-live. `delete` is never
|
|
68
83
|
* emitted here — an undeclared live resource classifies as `adopt` until
|
|
69
84
|
* ownership is known.
|
|
85
|
+
*
|
|
86
|
+
* A declared entity the lexicon could not observe (`input.unobserved`, #1089)
|
|
87
|
+
* classifies as `unobserved` and nothing else: no `create` is ever synthesized
|
|
88
|
+
* from a read that did not happen.
|
|
70
89
|
*/
|
|
71
90
|
export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
72
91
|
const diff = diffLive(input);
|
|
73
92
|
const { declared, observedNow } = input;
|
|
74
93
|
const observedThen = input.observedThen ?? {};
|
|
94
|
+
const unobservedInput = input.unobserved ?? {};
|
|
75
95
|
|
|
76
96
|
const driftByName = new Map(
|
|
77
97
|
diff.driftedSinceSnapshot.map((d) => [d.name, d.changes] as const),
|
|
@@ -81,6 +101,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
81
101
|
...declared,
|
|
82
102
|
...Object.keys(observedNow),
|
|
83
103
|
...Object.keys(observedThen),
|
|
104
|
+
...Object.keys(unobservedInput),
|
|
84
105
|
]);
|
|
85
106
|
|
|
86
107
|
const entries: ChangeSetEntry[] = [];
|
|
@@ -88,8 +109,11 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
88
109
|
const isDeclared = declared.has(name);
|
|
89
110
|
const live = Object.prototype.hasOwnProperty.call(observedNow, name);
|
|
90
111
|
const inSnapshot = Object.prototype.hasOwnProperty.call(observedThen, name);
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
// A returned resource was observed by definition; `unobserved` only counts
|
|
113
|
+
// for entities the lexicon did not return.
|
|
114
|
+
const unobservedEntry = live ? undefined : unobservedInput[name];
|
|
115
|
+
const type = observedNow[name]?.type ?? observedThen[name]?.type ?? unobservedEntry?.type;
|
|
116
|
+
const evidence = { declared: isDeclared, inSnapshot, live, observed: !unobservedEntry };
|
|
93
117
|
|
|
94
118
|
// Ownership comes from the LIVE marker only (carried on observedNow), never
|
|
95
119
|
// from the snapshot. This is the invariant that keeps the snapshot from
|
|
@@ -100,8 +124,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
100
124
|
let action: ChangeAction;
|
|
101
125
|
let deltas: AttributeChange[] | undefined;
|
|
102
126
|
|
|
103
|
-
if (
|
|
104
|
-
//
|
|
127
|
+
if (unobservedEntry) {
|
|
128
|
+
// The lexicon never looked. Absence is not established, so neither a
|
|
129
|
+
// create (declared) nor a delete/adopt (undeclared) can be proposed —
|
|
130
|
+
// the entry exists to say the plan has a hole here.
|
|
131
|
+
action = "unobserved";
|
|
132
|
+
} else if (isDeclared && !live) {
|
|
133
|
+
// Declared in source, and the provider confirmed it absent → create.
|
|
105
134
|
action = "create";
|
|
106
135
|
} else if (isDeclared && live) {
|
|
107
136
|
const drift = driftByName.get(name);
|
|
@@ -120,14 +149,27 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
120
149
|
action = "noop";
|
|
121
150
|
}
|
|
122
151
|
|
|
123
|
-
entries.push({
|
|
152
|
+
entries.push({
|
|
153
|
+
name,
|
|
154
|
+
type,
|
|
155
|
+
action,
|
|
156
|
+
evidence,
|
|
157
|
+
deltas,
|
|
158
|
+
ownership,
|
|
159
|
+
...(unobservedEntry
|
|
160
|
+
? {
|
|
161
|
+
unobservedReason: unobservedEntry.reason,
|
|
162
|
+
...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
165
|
+
});
|
|
124
166
|
}
|
|
125
167
|
|
|
126
168
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
169
|
return { env, entries };
|
|
128
170
|
}
|
|
129
171
|
|
|
130
|
-
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop"];
|
|
172
|
+
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
|
|
131
173
|
|
|
132
174
|
/** Count entries per action. */
|
|
133
175
|
export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
@@ -137,6 +179,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
137
179
|
delete: 0,
|
|
138
180
|
adopt: 0,
|
|
139
181
|
noop: 0,
|
|
182
|
+
unobserved: 0,
|
|
140
183
|
};
|
|
141
184
|
for (const e of cs.entries) counts[e.action]++;
|
|
142
185
|
return counts;
|
|
@@ -148,8 +191,10 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
148
191
|
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
149
192
|
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
150
193
|
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
151
|
-
* directly. Only the mutating actions count: `adopt` and `
|
|
152
|
-
* since the widget has no column for "live but undeclared"
|
|
194
|
+
* directly. Only the mutating actions count: `adopt`, `noop` and `unobserved`
|
|
195
|
+
* are excluded, since the widget has no column for "live but undeclared", "no
|
|
196
|
+
* change", or "could not look" (#1089). The widget is therefore a floor, not a
|
|
197
|
+
* complete plan: read the full change set when entities are unobserved.
|
|
153
198
|
*
|
|
154
199
|
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
155
200
|
* fixed string, not a claim chant makes.
|
|
@@ -175,10 +220,17 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
175
220
|
for (const action of ACTION_ORDER) {
|
|
176
221
|
const group = cs.entries.filter((e) => e.action === action);
|
|
177
222
|
if (group.length === 0) continue;
|
|
178
|
-
lines.push(
|
|
223
|
+
lines.push(
|
|
224
|
+
action === "unobserved"
|
|
225
|
+
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
226
|
+
: `\n${action.toUpperCase()}:`,
|
|
227
|
+
);
|
|
179
228
|
for (const e of group) {
|
|
180
229
|
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
181
|
-
|
|
230
|
+
const why = e.unobservedReason
|
|
231
|
+
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
232
|
+
: "";
|
|
233
|
+
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}`);
|
|
182
234
|
for (const d of e.deltas ?? []) {
|
|
183
235
|
lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
184
236
|
}
|
|
@@ -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",
|