@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
package/src/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export * from "./lint/discover";
|
|
|
48
48
|
export * from "./import/parser";
|
|
49
49
|
export * from "./import/generator";
|
|
50
50
|
export * from "./lexicon";
|
|
51
|
+
export * from "./observation";
|
|
52
|
+
export * from "./deep-observation";
|
|
51
53
|
export * from "./lexicon-integrity";
|
|
52
54
|
export * from "./lexicon-manifest";
|
|
53
55
|
export * from "./lexicon-schema";
|
package/src/kubectl-context.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { exec } from "node:child_process";
|
|
24
24
|
import { promisify } from "node:util";
|
|
25
|
+
import type { UnobservedReason } from "./observation";
|
|
25
26
|
|
|
26
27
|
const execAsync = promisify(exec);
|
|
27
28
|
|
|
@@ -124,3 +125,83 @@ export async function resolveClusterTarget(
|
|
|
124
125
|
|
|
125
126
|
return { context: bound, source: "bound" };
|
|
126
127
|
}
|
|
128
|
+
|
|
129
|
+
// ── kubectl read outcomes (#1089) ───────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What a failed `kubectl get` actually proved. Shared by the k8s and gcp
|
|
133
|
+
* lexicons, which read through the same kubectl path and used to collapse every
|
|
134
|
+
* non-zero exit into "not there" — so an expired token, a downed API server, or
|
|
135
|
+
* an uninstalled CRD all classified as `create`.
|
|
136
|
+
*/
|
|
137
|
+
export type KubectlReadOutcome =
|
|
138
|
+
/** The API server answered and the object is not there. Safe to plan a create. */
|
|
139
|
+
| { kind: "absent" }
|
|
140
|
+
/** The read proved nothing about the object's existence. */
|
|
141
|
+
| { kind: "unobserved"; reason: UnobservedReason; detail: string };
|
|
142
|
+
|
|
143
|
+
/** Pull whatever the child process actually said out of an exec rejection. */
|
|
144
|
+
function execErrorText(err: unknown): string {
|
|
145
|
+
if (typeof err === "object" && err !== null) {
|
|
146
|
+
const e = err as { stderr?: unknown; message?: unknown };
|
|
147
|
+
const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
|
|
148
|
+
if (stderr) return stderr;
|
|
149
|
+
if (typeof e.message === "string") return e.message.trim();
|
|
150
|
+
}
|
|
151
|
+
return String(err);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Collapse kubectl's noise to one line for a plan/diff entry. */
|
|
155
|
+
function firstLine(text: string, max = 200): string {
|
|
156
|
+
const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
|
|
157
|
+
return line.length > max ? `${line.slice(0, max - 3)}...` : line;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify a `kubectl get` failure into the observation tri-state (#1089).
|
|
162
|
+
*
|
|
163
|
+
* Only a genuine `NotFound` from the API server — or a kind the server does not
|
|
164
|
+
* serve at all, where no instance can exist — establishes absence. Auth,
|
|
165
|
+
* connectivity, and unresolvable contexts establish nothing, and must reach the
|
|
166
|
+
* change set as NOT-OBSERVED rather than as an empty result.
|
|
167
|
+
*/
|
|
168
|
+
export function classifyKubectlFailure(err: unknown): KubectlReadOutcome {
|
|
169
|
+
const text = execErrorText(err);
|
|
170
|
+
const lower = text.toLowerCase();
|
|
171
|
+
|
|
172
|
+
// The object was looked for and is not there.
|
|
173
|
+
if (lower.includes("notfound") || /error from server \(notfound\)/.test(lower) || lower.includes("not found")) {
|
|
174
|
+
return { kind: "absent" };
|
|
175
|
+
}
|
|
176
|
+
// The cluster serves no such kind, so no instance of it can exist there. The
|
|
177
|
+
// usual cause is a CRD this same plan has not applied yet — a real absence,
|
|
178
|
+
// and the case a create is for.
|
|
179
|
+
if (
|
|
180
|
+
lower.includes("the server doesn't have a resource type") ||
|
|
181
|
+
lower.includes("the server could not find the requested resource")
|
|
182
|
+
) {
|
|
183
|
+
return { kind: "absent" };
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
lower.includes("unauthorized") ||
|
|
187
|
+
lower.includes("forbidden") ||
|
|
188
|
+
lower.includes("you must be logged in") ||
|
|
189
|
+
lower.includes("invalid bearer token") ||
|
|
190
|
+
lower.includes("credentials")
|
|
191
|
+
) {
|
|
192
|
+
return { kind: "unobserved", reason: "no-credentials", detail: firstLine(text) };
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
lower.includes("unable to connect to the server") ||
|
|
196
|
+
lower.includes("connection refused") ||
|
|
197
|
+
lower.includes("no configuration has been provided") ||
|
|
198
|
+
lower.includes("did you specify the right host or port") ||
|
|
199
|
+
lower.includes("context was not found") ||
|
|
200
|
+
/context ".*" does not exist/.test(lower) ||
|
|
201
|
+
lower.includes("no such host") ||
|
|
202
|
+
lower.includes("i/o timeout")
|
|
203
|
+
) {
|
|
204
|
+
return { kind: "unobserved", reason: "no-binding", detail: firstLine(text) };
|
|
205
|
+
}
|
|
206
|
+
return { kind: "unobserved", reason: "read-failed", detail: firstLine(text) };
|
|
207
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -11,11 +11,38 @@ import type { DriverComponent } from "./components/driver";
|
|
|
11
11
|
import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
12
12
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
|
+
import type { DescribeResourcesResult } from "./observation";
|
|
15
|
+
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
14
16
|
|
|
15
17
|
// Re-exported so lexicons can author a reference catalog (#778) from the same
|
|
16
18
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
17
19
|
export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
|
|
18
20
|
|
|
21
|
+
// The observation contract (#1089), re-exported from the same entry so a
|
|
22
|
+
// lexicon's `describeResources` can report NOT-OBSERVED without a second
|
|
23
|
+
// import path. Runtime helpers live in `@intentius/chant/observation`.
|
|
24
|
+
export type {
|
|
25
|
+
DescribeResourcesResult,
|
|
26
|
+
ObservationResult,
|
|
27
|
+
NormalizedObservation,
|
|
28
|
+
UnobservedEntity,
|
|
29
|
+
UnobservedReason,
|
|
30
|
+
} from "./observation";
|
|
31
|
+
|
|
32
|
+
// The deep observation contract (#1014), re-exported for the same reason: a
|
|
33
|
+
// lexicon authoring `observeResourcesDeep` + its pruning/ordering hooks types
|
|
34
|
+
// them from the same entry. Runtime helpers live in
|
|
35
|
+
// `@intentius/chant/deep-observation`.
|
|
36
|
+
export type {
|
|
37
|
+
DeepObservationResult,
|
|
38
|
+
DeepResourceObservation,
|
|
39
|
+
NormalizedDeepObservation,
|
|
40
|
+
DeepNormalizationHooks,
|
|
41
|
+
DeepNode,
|
|
42
|
+
DeepArrayElement,
|
|
43
|
+
DeepSide,
|
|
44
|
+
} from "./deep-observation";
|
|
45
|
+
|
|
19
46
|
/**
|
|
20
47
|
* Manifest for a packaged lexicon — metadata embedded in the tarball.
|
|
21
48
|
*
|
|
@@ -507,6 +534,27 @@ export interface LexiconPlugin {
|
|
|
507
534
|
* Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
|
|
508
535
|
* AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
|
|
509
536
|
*
|
|
537
|
+
* **The observation contract (#1089).** Returning nothing for a declared
|
|
538
|
+
* entity is a claim, and there are two different claims to make. Either the
|
|
539
|
+
* provider was asked and reported the resource absent — which is what lets
|
|
540
|
+
* the change set propose `create` — or the lexicon never looked, which must
|
|
541
|
+
* not. An implementation that has a "did not look" case (no reader for the
|
|
542
|
+
* kind, the read errored, no credentials, no cluster binding) must return the
|
|
543
|
+
* {@link ObservationResult} envelope and name those entities in `unobserved`
|
|
544
|
+
* with a total {@link UnobservedReason}. Warning on stderr is not enough: a
|
|
545
|
+
* warning is invisible to `lifecycle plan`, which is where the wrong `create`
|
|
546
|
+
* gets proposed. Returning the bare `name → ResourceMetadata` map is still
|
|
547
|
+
* valid and means "everything I was asked about, I looked at".
|
|
548
|
+
*
|
|
549
|
+
* Throwing is the whole-lexicon failure (see the k8s cluster-binding refusal,
|
|
550
|
+
* #1100): core catches it and marks every declared entity NOT-OBSERVED with
|
|
551
|
+
* `read-failed`, so a failed read is never a list of creates.
|
|
552
|
+
*
|
|
553
|
+
* Ownership verdicts are total (#1089). When `owned` is requested and the
|
|
554
|
+
* lexicon has no marker channel on this path, it must stamp
|
|
555
|
+
* `ownership: "unknown"` on what it returns rather than degrading silently —
|
|
556
|
+
* the change set never escalates `unknown` to a `delete`.
|
|
557
|
+
*
|
|
510
558
|
* `entities` carries the chant-side entity declarations for this lexicon,
|
|
511
559
|
* keyed by chant entity name (e.g. the export name from a `*.ts` file).
|
|
512
560
|
* Implementations that need to map cloud-side names back to chant entity
|
|
@@ -536,7 +584,51 @@ export interface LexiconPlugin {
|
|
|
536
584
|
* everything.
|
|
537
585
|
*/
|
|
538
586
|
owned?: boolean;
|
|
539
|
-
}): Promise<
|
|
587
|
+
}): Promise<DescribeResourcesResult>;
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Read the full live *property tree* for each declared entity (#1014). Opt-in,
|
|
591
|
+
* and strictly deeper than {@link describeResources}, which reports existence
|
|
592
|
+
* plus a handful of scrubbed outputs. A lexicon that implements neither, or
|
|
593
|
+
* only the thin one, is unaffected — `lifecycle diff --live` gains
|
|
594
|
+
* property-level entries only where this exists.
|
|
595
|
+
*
|
|
596
|
+
* The result is keyed by chant entity name, exactly like the thin read, and
|
|
597
|
+
* carries the same NOT-OBSERVED map. That is the composition rule with #1089:
|
|
598
|
+
* a deep read that fails for one entity says so with a total
|
|
599
|
+
* {@link UnobservedReason}. It never returns a thin-but-clean tree, because a
|
|
600
|
+
* clean tree is a claim that nothing drifted.
|
|
601
|
+
*
|
|
602
|
+
* Properties must be normalized before they are returned — run
|
|
603
|
+
* `normalizeDeepProperties` (../deep-observation.ts) with this lexicon's own
|
|
604
|
+
* {@link deepNormalizationHooks}, so the trees a consumer sees are already
|
|
605
|
+
* free of arns, timestamps, status subtrees and unstable orderings.
|
|
606
|
+
*
|
|
607
|
+
* Throwing is the whole-lexicon failure, same as the thin read: core turns it
|
|
608
|
+
* into `read-failed` for every declared entity.
|
|
609
|
+
*/
|
|
610
|
+
observeResourcesDeep?(options: {
|
|
611
|
+
environment: string;
|
|
612
|
+
buildOutput: string;
|
|
613
|
+
entityNames: string[];
|
|
614
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
615
|
+
/** Deployed stack to observe, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
|
|
616
|
+
stack?: string;
|
|
617
|
+
/** Restrict to chant-owned resources (#119). A lexicon with no marker channel on this path says so. */
|
|
618
|
+
owned?: boolean;
|
|
619
|
+
}): Promise<DeepObservationResult>;
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* This lexicon's noise rules for deep observation (#1014): which fields are
|
|
623
|
+
* read-only / server-populated / controller-managed / provider-defaulted, and
|
|
624
|
+
* which arrays are sets. Data, not a method — core applies the same rules to
|
|
625
|
+
* the *declared* tree, which no reader ever touches, and the two sides have
|
|
626
|
+
* to be normalized identically to be comparable.
|
|
627
|
+
*
|
|
628
|
+
* Ships alongside {@link observeResourcesDeep}; a reader without hooks
|
|
629
|
+
* produces a diff made almost entirely of noise.
|
|
630
|
+
*/
|
|
631
|
+
deepNormalizationHooks?: DeepNormalizationHooks;
|
|
540
632
|
|
|
541
633
|
/**
|
|
542
634
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
@@ -683,12 +775,14 @@ export interface ResourceMetadata {
|
|
|
683
775
|
/** Cloud-assigned output properties */
|
|
684
776
|
attributes?: Record<string, unknown>;
|
|
685
777
|
/**
|
|
686
|
-
* Live ownership verdict from the resource's marker (#119/#120)
|
|
687
|
-
*
|
|
688
|
-
* no marker
|
|
689
|
-
*
|
|
778
|
+
* Live ownership verdict from the resource's marker (#119/#120). `owned` =
|
|
779
|
+
* carries chant's marker; `foreign` = no marker; `unknown` = the lexicon has
|
|
780
|
+
* no marker channel on this read path and says so rather than degrading
|
|
781
|
+
* silently (#1089 — verdicts are total). Absent is read as `unknown`. The
|
|
782
|
+
* change set reads this — never the snapshot — to decide whether an orphan is
|
|
783
|
+
* a delete, and never escalates `unknown` to one.
|
|
690
784
|
*/
|
|
691
|
-
ownership?: "owned" | "foreign";
|
|
785
|
+
ownership?: "owned" | "foreign" | "unknown";
|
|
692
786
|
}
|
|
693
787
|
|
|
694
788
|
/**
|
|
@@ -17,7 +17,7 @@ describe("buildChangeSet (#118)", () => {
|
|
|
17
17
|
});
|
|
18
18
|
const e = cs.entries.find((x) => x.name === "bucket")!;
|
|
19
19
|
expect(e.action).toBe("create");
|
|
20
|
-
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
|
|
20
|
+
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
|
|
21
21
|
expect(e.ownership).toBe("unknown");
|
|
22
22
|
});
|
|
23
23
|
|
|
@@ -187,3 +187,95 @@ describe("gitlabMrReport (#329)", () => {
|
|
|
187
187
|
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
188
188
|
});
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
// ── The observation tri-state (#1089) ───────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("buildChangeSet: not-observed is not absent (#1089)", () => {
|
|
194
|
+
test("declared and not observed → unobserved, never create", () => {
|
|
195
|
+
const cs = buildChangeSet("prod", {
|
|
196
|
+
declared: new Set(["crd-widget"]),
|
|
197
|
+
observedNow: {},
|
|
198
|
+
observedThen: undefined,
|
|
199
|
+
unobserved: {
|
|
200
|
+
"crd-widget": {
|
|
201
|
+
type: "K8s::Example::Widget",
|
|
202
|
+
reason: "unsupported-kind",
|
|
203
|
+
detail: "no kubectl mapping",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const e = cs.entries.find((x) => x.name === "crd-widget")!;
|
|
208
|
+
expect(e.action).toBe("unobserved");
|
|
209
|
+
expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: false });
|
|
210
|
+
expect(e.unobservedReason).toBe("unsupported-kind");
|
|
211
|
+
expect(e.type).toBe("K8s::Example::Widget");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the same entity, confirmed absent, still classifies as create", () => {
|
|
215
|
+
const cs = buildChangeSet("prod", {
|
|
216
|
+
declared: new Set(["crd-widget"]),
|
|
217
|
+
observedNow: {},
|
|
218
|
+
observedThen: undefined,
|
|
219
|
+
});
|
|
220
|
+
expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a returned resource wins over an unobserved claim for the same name", () => {
|
|
224
|
+
const cs = buildChangeSet("prod", {
|
|
225
|
+
declared: new Set(["queue"]),
|
|
226
|
+
observedNow: { queue: meta() },
|
|
227
|
+
observedThen: undefined,
|
|
228
|
+
unobserved: { queue: { reason: "read-failed" } },
|
|
229
|
+
});
|
|
230
|
+
const e = cs.entries.find((x) => x.name === "queue")!;
|
|
231
|
+
expect(e.action).toBe("noop");
|
|
232
|
+
expect(e.evidence.observed).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("an unobserved entity that is in the snapshot is not read as gone", () => {
|
|
236
|
+
const cs = buildChangeSet("prod", {
|
|
237
|
+
declared: new Set(["queue"]),
|
|
238
|
+
observedNow: {},
|
|
239
|
+
observedThen: { queue: meta() },
|
|
240
|
+
unobserved: { queue: { reason: "no-credentials" } },
|
|
241
|
+
});
|
|
242
|
+
const e = cs.entries.find((x) => x.name === "queue")!;
|
|
243
|
+
expect(e.action).toBe("unobserved");
|
|
244
|
+
expect(e.evidence.inSnapshot).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an unobserved entity is never a delete, even with an owned marker in the snapshot", () => {
|
|
248
|
+
const cs = buildChangeSet("prod", {
|
|
249
|
+
declared: new Set(),
|
|
250
|
+
observedNow: {},
|
|
251
|
+
observedThen: { legacy: meta({ ownership: "owned" }) },
|
|
252
|
+
unobserved: { legacy: { reason: "read-failed" } },
|
|
253
|
+
});
|
|
254
|
+
expect(cs.entries.find((x) => x.name === "legacy")!.action).toBe("unobserved");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("summarize and render surface the hole", () => {
|
|
258
|
+
const cs = buildChangeSet("prod", {
|
|
259
|
+
declared: new Set(["a"]),
|
|
260
|
+
observedNow: {},
|
|
261
|
+
observedThen: undefined,
|
|
262
|
+
unobserved: { a: { reason: "no-binding", detail: "no kubectl context for prod" } },
|
|
263
|
+
});
|
|
264
|
+
expect(summarize(cs).unobserved).toBe(1);
|
|
265
|
+
expect(summarize(cs).create).toBe(0);
|
|
266
|
+
const out = renderChangeSet(cs);
|
|
267
|
+
expect(out).toContain("UNOBSERVED");
|
|
268
|
+
expect(out).toContain("no binding for this environment");
|
|
269
|
+
expect(out).toContain("no kubectl context for prod");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the GitLab widget excludes unobserved — its three columns cannot express a hole", () => {
|
|
273
|
+
const cs = buildChangeSet("prod", {
|
|
274
|
+
declared: new Set(["a"]),
|
|
275
|
+
observedNow: {},
|
|
276
|
+
observedThen: undefined,
|
|
277
|
+
unobserved: { a: { reason: "read-failed" } },
|
|
278
|
+
});
|
|
279
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { countPropertyDrift, diffDeep } from "./deep-diff";
|
|
3
|
+
import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
|
|
4
|
+
import type { BaselineLexicon } from "./observation-baseline";
|
|
5
|
+
|
|
6
|
+
const live = (
|
|
7
|
+
resources: Record<string, { type: string; properties: Record<string, unknown> }>,
|
|
8
|
+
unobserved: NormalizedDeepObservation["unobserved"] = {},
|
|
9
|
+
): NormalizedDeepObservation => ({ resources, unobserved });
|
|
10
|
+
|
|
11
|
+
describe("diffDeep", () => {
|
|
12
|
+
test("identical trees are unchanged", () => {
|
|
13
|
+
const result = diffDeep({
|
|
14
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: { BucketName: "x" } } },
|
|
15
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { BucketName: "x" } } }),
|
|
16
|
+
});
|
|
17
|
+
expect(result.unchanged).toEqual(["b"]);
|
|
18
|
+
expect(result.drifted).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a changed property reports declared and live", () => {
|
|
22
|
+
const result = diffDeep({
|
|
23
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: { Versioning: { Status: "Enabled" } } } },
|
|
24
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { Versioning: { Status: "Suspended" } } } }),
|
|
25
|
+
});
|
|
26
|
+
expect(result.drifted).toEqual([
|
|
27
|
+
{
|
|
28
|
+
name: "b",
|
|
29
|
+
type: "AWS::S3::Bucket",
|
|
30
|
+
changes: [{ path: "Versioning.Status", kind: "changed", declared: "Enabled", live: "Suspended" }],
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
expect(countPropertyDrift(result)).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a property only the cloud has is undeclared drift", () => {
|
|
37
|
+
const result = diffDeep({
|
|
38
|
+
declared: { b: { type: "T", properties: {} } },
|
|
39
|
+
live: live({ b: { type: "T", properties: { LoggingConfiguration: { TargetBucket: "logs" } } } }),
|
|
40
|
+
});
|
|
41
|
+
expect(result.drifted[0].changes).toEqual([
|
|
42
|
+
{ path: "LoggingConfiguration.TargetBucket", kind: "undeclared", live: "logs" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a declared property the cloud does not carry is absent", () => {
|
|
47
|
+
const result = diffDeep({
|
|
48
|
+
declared: { b: { type: "T", properties: { A: 1 } } },
|
|
49
|
+
live: live({ b: { type: "T", properties: {} } }),
|
|
50
|
+
});
|
|
51
|
+
expect(result.drifted[0].changes).toEqual([{ path: "A", kind: "absent", declared: 1 }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an unevaluated intrinsic on the declared side is never drift", () => {
|
|
55
|
+
const result = diffDeep({
|
|
56
|
+
declared: { b: { type: "T", properties: { BucketName: UNRESOLVED, Other: "x" } } },
|
|
57
|
+
live: live({ b: { type: "T", properties: { BucketName: "prod-data", Other: "x" } } }),
|
|
58
|
+
});
|
|
59
|
+
expect(result.drifted).toEqual([]);
|
|
60
|
+
expect(result.unchanged).toEqual(["b"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an entity the deep read could not look at is a hole, not drift", () => {
|
|
64
|
+
const result = diffDeep({
|
|
65
|
+
declared: { b: { type: "T", properties: { A: 1 } } },
|
|
66
|
+
live: live({}, { b: { type: "T", reason: "unsupported-kind", detail: "no reader" } }),
|
|
67
|
+
});
|
|
68
|
+
expect(result.drifted).toEqual([]);
|
|
69
|
+
expect(result.unobserved).toEqual([
|
|
70
|
+
{ name: "b", type: "T", reason: "unsupported-kind", detail: "no reader" },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("present beats not-observed", () => {
|
|
75
|
+
const result = diffDeep({
|
|
76
|
+
declared: { b: { type: "T", properties: { A: 1 } } },
|
|
77
|
+
live: live({ b: { type: "T", properties: { A: 1 } } }, { b: { reason: "read-failed" } }),
|
|
78
|
+
});
|
|
79
|
+
expect(result.unobserved).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an entity absent from the deep read reports no property drift at all", () => {
|
|
83
|
+
// The thin diff already calls this `missing`; restating every declared
|
|
84
|
+
// property as `absent` would bury that one line.
|
|
85
|
+
const result = diffDeep({
|
|
86
|
+
declared: { b: { type: "T", properties: { A: 1, B: 2 } } },
|
|
87
|
+
live: live({}),
|
|
88
|
+
});
|
|
89
|
+
expect(result.drifted).toEqual([]);
|
|
90
|
+
expect(result.unchanged).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a live entity nobody declared is reported separately", () => {
|
|
94
|
+
const result = diffDeep({
|
|
95
|
+
declared: {},
|
|
96
|
+
live: live({ ghost: { type: "T", properties: { A: 1 } } }),
|
|
97
|
+
});
|
|
98
|
+
expect(result.undeclaredEntities).toEqual(["ghost"]);
|
|
99
|
+
expect(result.drifted).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("diffDeep with an accepted baseline", () => {
|
|
104
|
+
const baseline: BaselineLexicon = {
|
|
105
|
+
b: {
|
|
106
|
+
type: "AWS::S3::Bucket",
|
|
107
|
+
accepted: [{ path: "Tags[0].Value", value: "platform", note: "set by the platform team" }],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
test("an accepted deviation is not drift", () => {
|
|
112
|
+
const result = diffDeep({
|
|
113
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: {} } },
|
|
114
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { Tags: [{ Value: "platform" }] } } }),
|
|
115
|
+
baseline,
|
|
116
|
+
});
|
|
117
|
+
expect(result.drifted).toEqual([]);
|
|
118
|
+
expect(result.accepted[0].changes[0]).toEqual({
|
|
119
|
+
path: "Tags[0].Value",
|
|
120
|
+
kind: "undeclared",
|
|
121
|
+
live: "platform",
|
|
122
|
+
baseline: "platform",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a value that moved away from the accepted one is drift again, and shows all three axes", () => {
|
|
127
|
+
const result = diffDeep({
|
|
128
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: {} } },
|
|
129
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { Tags: [{ Value: "someone-else" }] } } }),
|
|
130
|
+
baseline,
|
|
131
|
+
});
|
|
132
|
+
expect(result.drifted[0].changes[0]).toEqual({
|
|
133
|
+
path: "Tags[0].Value",
|
|
134
|
+
kind: "undeclared",
|
|
135
|
+
live: "someone-else",
|
|
136
|
+
baseline: "platform",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an entity with only accepted deviations is not counted as unchanged", () => {
|
|
141
|
+
const result = diffDeep({
|
|
142
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: {} } },
|
|
143
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { Tags: [{ Value: "platform" }] } } }),
|
|
144
|
+
baseline,
|
|
145
|
+
});
|
|
146
|
+
expect(result.unchanged).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("the baseline never suppresses a different path", () => {
|
|
150
|
+
const result = diffDeep({
|
|
151
|
+
declared: { b: { type: "AWS::S3::Bucket", properties: {} } },
|
|
152
|
+
live: live({ b: { type: "AWS::S3::Bucket", properties: { Tags: [{ Value: "platform" }], Extra: 1 } } }),
|
|
153
|
+
baseline,
|
|
154
|
+
});
|
|
155
|
+
expect(result.drifted[0].changes.map((c) => c.path)).toEqual(["Extra"]);
|
|
156
|
+
});
|
|
157
|
+
});
|