@intentius/chant 0.29.0 → 0.31.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/graph.d.ts.map +1 -1
- 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/codegen/generate.d.ts +16 -0
- package/dist/codegen/generate.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.map +1 -1
- package/dist/fold/fold.d.ts +23 -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 +44 -0
- 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 +18 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +47 -0
- package/dist/lexicon.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/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +86 -0
- package/src/cli/handlers/graph.ts +64 -3
- package/src/cli/handlers/lifecycle.test.ts +126 -1
- package/src/cli/handlers/lifecycle.ts +184 -3
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/generate.ts +25 -0
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +235 -79
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +88 -18
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir.ts +47 -0
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +22 -2
- package/src/lexicon.ts +59 -0
- 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/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/snapshot.ts +6 -11
package/src/fold/subset.test.ts
CHANGED
|
@@ -258,18 +258,49 @@ describe("documented divergences — NOT unified by design (see subset.ts module
|
|
|
258
258
|
expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
-
test("nested resource construction:
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// envelope
|
|
265
|
-
// fold-vs-run drift, caught by the #1025 differential on gitlab)
|
|
266
|
-
//
|
|
267
|
-
//
|
|
261
|
+
test("nested resource construction: NO LONGER a divergence (chant #1169) — both fold and EVL001 accept it", () => {
|
|
262
|
+
// This was the largest divergence in the table until #1169: a nested
|
|
263
|
+
// `new Type()` as a property value could only fold to a {__resource, props}
|
|
264
|
+
// envelope nothing constructed, so it would have serialized wrong (real
|
|
265
|
+
// fold-vs-run drift, caught by the #1025 differential on gitlab), and fold()
|
|
266
|
+
// rejected it while EVL allowed it.
|
|
267
|
+
//
|
|
268
|
+
// fold() now produces the envelope and ../discovery/fold-import.ts revives
|
|
269
|
+
// it into a REAL instance of the class the file imported, so the two sides
|
|
270
|
+
// agree. Kept as a test rather than deleted: it is the assertion that the
|
|
271
|
+
// divergence stays closed, and that the envelope carries the nested
|
|
272
|
+
// constructor's own name and props for the bridge to build from.
|
|
268
273
|
const source = `const bad = new Thing({ x: new Inner({ y: 1 }) });`;
|
|
269
274
|
const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
|
|
270
275
|
const consts = collectConsts(sourceFile);
|
|
271
276
|
const badInit = consts.get("bad") as ts.NewExpression;
|
|
272
277
|
|
|
278
|
+
expect(foldResource(badInit, consts, [])).toEqual({
|
|
279
|
+
__resource: "Thing",
|
|
280
|
+
props: { x: { __resource: "Inner", props: { y: 1 } } },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
|
|
284
|
+
expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("same-file resource used as a bare value: fold rejects it (would build a duplicate); EVL001 allows it statically", () => {
|
|
288
|
+
// chant #1169's own new divergence, in the same safe direction as every
|
|
289
|
+
// other one here. `DependsOn: [dbCluster]` hands the run path THE instance
|
|
290
|
+
// this file already exported; folding the identifier would construct a
|
|
291
|
+
// SECOND one, which discovery never registers and whose AttrRefs can never
|
|
292
|
+
// be named. Rejected, so the file falls back to run — where both references
|
|
293
|
+
// are the same object by construction. EVL sees a plain identifier and has
|
|
294
|
+
// no binding resolver (subset.ts module doc, point 1), so it stays
|
|
295
|
+
// permissive.
|
|
296
|
+
const source = `
|
|
297
|
+
const db = new DbCluster({ engine: "aurora" });
|
|
298
|
+
const bad = new Instance({ x: 1 }, { DependsOn: [db] });
|
|
299
|
+
`;
|
|
300
|
+
const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
|
|
301
|
+
const consts = collectConsts(sourceFile);
|
|
302
|
+
const badInit = consts.get("bad") as ts.NewExpression;
|
|
303
|
+
|
|
273
304
|
expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
|
|
274
305
|
|
|
275
306
|
const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
|
package/src/fold/subset.ts
CHANGED
|
@@ -35,6 +35,15 @@ import { intrinsicCallFolds, type IntrinsicDef } from "../lexicon";
|
|
|
35
35
|
* intentional asymmetry, not a bug: it can only ever be a *false
|
|
36
36
|
* negative* on EVL's part (EVL passes something `fold()` might later
|
|
37
37
|
* reject for being unresolved), never the reverse.
|
|
38
|
+
*
|
|
39
|
+
* chant #1169 adds one more resolution-dependent rejection in the same
|
|
40
|
+
* direction: an identifier bound to a same-file `const x = new T(...)`,
|
|
41
|
+
* used as a VALUE. It is shape-valid here; `fold()` answers it only when
|
|
42
|
+
* its caller pre-resolved that const to the one real instance the file
|
|
43
|
+
* built (`../discovery/fold-import.ts`), and rejects otherwise, because
|
|
44
|
+
* re-folding the initializer would construct a duplicate of a resource
|
|
45
|
+
* discovery has already registered. Shape cannot see the difference, and
|
|
46
|
+
* the rejection is a fall-back-to-run, never a wrong value.
|
|
38
47
|
* 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
|
|
39
48
|
* manifest, which isn't available to a syntax-only lint rule. `fold()`
|
|
40
49
|
* alone checks it; this module treats any tag name as shape-valid and
|
package/src/graph-ir.ts
CHANGED
|
@@ -124,6 +124,51 @@ export interface IRImport {
|
|
|
124
124
|
node: string;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* One generated CI job in the pipeline projection (`chant graph --components
|
|
129
|
+
* --format ir --projection <lexicon>`, #989) — the same job a CI-provider
|
|
130
|
+
* lexicon's `generateComponentPipeline` synthesizes for `chant build
|
|
131
|
+
* --components --generate <lexicon>` (see `ComponentPipelineJob`,
|
|
132
|
+
* ./lexicon.ts), reshaped into the IR's node vocabulary.
|
|
133
|
+
*/
|
|
134
|
+
export interface IRPipelineNode {
|
|
135
|
+
/** CI job name (the generator's job id — a safe YAML/workflow key). */
|
|
136
|
+
id: string;
|
|
137
|
+
kind: "CIJob";
|
|
138
|
+
/** The component this job triggers. */
|
|
139
|
+
component: string;
|
|
140
|
+
/** The stage/wave this job runs in — the same wave index as the component
|
|
141
|
+
* graph's `groups.byWave` (one CI stage/`needs:`-level per wave). */
|
|
142
|
+
stage: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A `needs:` dependency between two generated CI jobs — mirrors the
|
|
146
|
+
* `dependsOn` edge it derives from, consumer job → producer job. */
|
|
147
|
+
export interface IRPipelineEdge {
|
|
148
|
+
from: string;
|
|
149
|
+
to: string;
|
|
150
|
+
kind: "needs";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The CI/pipeline projection of a component graph (#989): the stages/jobs/
|
|
155
|
+
* `needs` a CI-provider lexicon (gitlab, github, forgejo, or any lexicon
|
|
156
|
+
* implementing `generateComponentPipeline`) would synthesize for `chant build
|
|
157
|
+
* --components --generate <lexicon>`, reused here — never re-derived — as
|
|
158
|
+
* first-class IR nodes/edges. A consumer (e.g. behold) reads this alongside
|
|
159
|
+
* the component graph's `nodes`/`edges`/`groups.byWave` to render the CI
|
|
160
|
+
* shape without parsing generated YAML. Present only when `chant graph
|
|
161
|
+
* --components --format ir` is invoked with `--projection <lexicon>`.
|
|
162
|
+
*/
|
|
163
|
+
export interface IRPipeline {
|
|
164
|
+
/** The CI-provider lexicon that produced this projection (e.g. "gitlab"). */
|
|
165
|
+
provider: string;
|
|
166
|
+
/** Wave-ordered stage names — 1:1 with the component graph's `groups.byWave` keys. */
|
|
167
|
+
stages: string[];
|
|
168
|
+
nodes: IRPipelineNode[];
|
|
169
|
+
edges: IRPipelineEdge[];
|
|
170
|
+
}
|
|
171
|
+
|
|
127
172
|
/** The full graph IR for a project at the default (declarable) detail level. */
|
|
128
173
|
export interface GraphIR {
|
|
129
174
|
nodes: IRNode[];
|
|
@@ -135,6 +180,8 @@ export interface GraphIR {
|
|
|
135
180
|
* `name` to another stack's export `name` to draw the cross-stack edge; the
|
|
136
181
|
* parameter's in-stack consumers are ordinary `$ref` edges to it (#513). */
|
|
137
182
|
imports?: IRImport[];
|
|
183
|
+
/** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
|
|
184
|
+
pipeline?: IRPipeline;
|
|
138
185
|
}
|
|
139
186
|
|
|
140
187
|
/** A node is anything that serializes to a resource — not a property or output. */
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ export * from "./import/parser";
|
|
|
49
49
|
export * from "./import/generator";
|
|
50
50
|
export * from "./lexicon";
|
|
51
51
|
export * from "./observation";
|
|
52
|
+
export * from "./deep-observation";
|
|
52
53
|
export * from "./lexicon-integrity";
|
|
53
54
|
export * from "./lexicon-manifest";
|
|
54
55
|
export * from "./lexicon-schema";
|
package/src/kubectl-context.ts
CHANGED
|
@@ -72,8 +72,18 @@ export interface ResolvedClusterTarget {
|
|
|
72
72
|
source: "bound" | "ambient";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* How the resolver learns which context is ambient. The default shells
|
|
77
|
+
* `kubectl config current-context`; the k8s lexicon's typed API client
|
|
78
|
+
* (chant #1074) supplies one that reads the parsed kubeconfig instead, so a
|
|
79
|
+
* client that never needs the `kubectl` binary does not acquire a dependency
|
|
80
|
+
* on it just to check the binding. Both answer the same question, so the
|
|
81
|
+
* refusal semantics below are identical either way.
|
|
82
|
+
*/
|
|
83
|
+
export type AmbientContextReader = () => Promise<string | undefined>;
|
|
84
|
+
|
|
75
85
|
/** Reads `kubectl config current-context`. Returns undefined if unset or kubectl fails. */
|
|
76
|
-
|
|
86
|
+
const currentAmbientContext: AmbientContextReader = async () => {
|
|
77
87
|
try {
|
|
78
88
|
const { stdout } = await execAsync("kubectl config current-context");
|
|
79
89
|
const trimmed = stdout.trim();
|
|
@@ -81,6 +91,15 @@ async function currentAmbientContext(): Promise<string | undefined> {
|
|
|
81
91
|
} catch {
|
|
82
92
|
return undefined;
|
|
83
93
|
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Options for {@link resolveClusterTarget}. */
|
|
97
|
+
export interface ResolveClusterTargetOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Override how the ambient context is read. Defaults to
|
|
100
|
+
* `kubectl config current-context`.
|
|
101
|
+
*/
|
|
102
|
+
ambientContext?: AmbientContextReader;
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
/**
|
|
@@ -105,6 +124,7 @@ export async function resolveClusterTarget(
|
|
|
105
124
|
config: Record<string, unknown>,
|
|
106
125
|
environment: string,
|
|
107
126
|
lexiconName: string,
|
|
127
|
+
options: ResolveClusterTargetOptions = {},
|
|
108
128
|
): Promise<ResolvedClusterTarget> {
|
|
109
129
|
const k8sConfig = config.k8s as K8sConfigShape | undefined;
|
|
110
130
|
const bound = k8sConfig?.profiles?.[environment]?.context;
|
|
@@ -118,7 +138,7 @@ export async function resolveClusterTarget(
|
|
|
118
138
|
return { source: "ambient" };
|
|
119
139
|
}
|
|
120
140
|
|
|
121
|
-
const ambient = await currentAmbientContext();
|
|
141
|
+
const ambient = await (options.ambientContext ?? currentAmbientContext)();
|
|
122
142
|
if (ambient && ambient !== bound) {
|
|
123
143
|
throw new ClusterBindingMismatchError(environment, bound, ambient);
|
|
124
144
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
|
12
12
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
14
|
import type { DescribeResourcesResult } from "./observation";
|
|
15
|
+
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
15
16
|
|
|
16
17
|
// Re-exported so lexicons can author a reference catalog (#778) from the same
|
|
17
18
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
@@ -28,6 +29,20 @@ export type {
|
|
|
28
29
|
UnobservedReason,
|
|
29
30
|
} from "./observation";
|
|
30
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
|
+
|
|
31
46
|
/**
|
|
32
47
|
* Manifest for a packaged lexicon — metadata embedded in the tarball.
|
|
33
48
|
*
|
|
@@ -571,6 +586,50 @@ export interface LexiconPlugin {
|
|
|
571
586
|
owned?: boolean;
|
|
572
587
|
}): Promise<DescribeResourcesResult>;
|
|
573
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;
|
|
632
|
+
|
|
574
633
|
/**
|
|
575
634
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
576
635
|
*
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-level live drift (#1014) — declared vs live vs accepted baseline.
|
|
3
|
+
*
|
|
4
|
+
* The thin diff (./live-diff.ts) compares whole entities on status, physical id
|
|
5
|
+
* and a few outputs. This compares their property trees, path by path, which is
|
|
6
|
+
* where a console edit actually shows up. Pure function; the reading happens in
|
|
7
|
+
* ./deep-observe.ts and the CLI.
|
|
8
|
+
*
|
|
9
|
+
* Three axes, and all three matter:
|
|
10
|
+
*
|
|
11
|
+
* - **declared** — the property tree chant synthesized, normalized with the
|
|
12
|
+
* lexicon's own hooks so it is in the same shape as the live tree.
|
|
13
|
+
* - **live** — what the provider returned, normalized with the same hooks.
|
|
14
|
+
* - **baseline** — the value somebody accepted (./observation-baseline.ts). A
|
|
15
|
+
* deviation whose live value matches the accepted value is not drift; one
|
|
16
|
+
* that has moved away from the accepted value is drift again, and the
|
|
17
|
+
* report shows all three so the reader can see what changed and from what.
|
|
18
|
+
*
|
|
19
|
+
* A path is skipped entirely when the declared value is
|
|
20
|
+
* {@link UNRESOLVED} — an unevaluated intrinsic (`Fn::Sub`, `Ref`) has no
|
|
21
|
+
* source-side value to compare, and reporting one as drift would light up every
|
|
22
|
+
* interpolated property forever.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
UNRESOLVED,
|
|
27
|
+
deepValueEqual,
|
|
28
|
+
flattenDeepProperties,
|
|
29
|
+
type DeepNormalizationHooks,
|
|
30
|
+
type NormalizedDeepObservation,
|
|
31
|
+
} from "../deep-observation";
|
|
32
|
+
import type { UnobservedResource } from "./live-diff";
|
|
33
|
+
import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How a property differs.
|
|
37
|
+
*
|
|
38
|
+
* - `changed` — declared and live both have the path, with different values.
|
|
39
|
+
* - `undeclared` — live has it, source never did. cdk-real-drift's whole reason
|
|
40
|
+
* to exist: the console-added property CloudFormation itself will not report.
|
|
41
|
+
* - `absent` — source declares it and the live tree does not carry it. Weaker
|
|
42
|
+
* than the other two: a provider that omits a property it considers unset is
|
|
43
|
+
* common, which is what the lexicon's pruning hook is for.
|
|
44
|
+
*/
|
|
45
|
+
export type PropertyDriftKind = "changed" | "undeclared" | "absent";
|
|
46
|
+
|
|
47
|
+
/** One property-level difference. */
|
|
48
|
+
export interface PropertyDrift {
|
|
49
|
+
/** Path within the normalized property tree (`Tags[0].Value`). */
|
|
50
|
+
path: string;
|
|
51
|
+
kind: PropertyDriftKind;
|
|
52
|
+
/** Value in source. Absent for `undeclared`. */
|
|
53
|
+
declared?: unknown;
|
|
54
|
+
/** Value in the cloud. Absent for `absent`. */
|
|
55
|
+
live?: unknown;
|
|
56
|
+
/**
|
|
57
|
+
* The accepted value from the baseline, when this path has one. Present on a
|
|
58
|
+
* reported drift too — that is the "accepted X, now Y" case, and hiding the
|
|
59
|
+
* accepted value there would lose the most useful column in the report.
|
|
60
|
+
*/
|
|
61
|
+
baseline?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Property-level drift for one declared entity. */
|
|
65
|
+
export interface DeepEntityDrift {
|
|
66
|
+
name: string;
|
|
67
|
+
type: string;
|
|
68
|
+
changes: PropertyDrift[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DeepDiffResult {
|
|
72
|
+
/** Entities with at least one reportable property difference. Sorted by name. */
|
|
73
|
+
drifted: DeepEntityDrift[];
|
|
74
|
+
/**
|
|
75
|
+
* Differences suppressed by the baseline — reported separately rather than
|
|
76
|
+
* dropped, so `--json` consumers and `--update-baseline` can see what is
|
|
77
|
+
* being held back and the count never silently changes meaning.
|
|
78
|
+
*/
|
|
79
|
+
accepted: DeepEntityDrift[];
|
|
80
|
+
/** Entities whose property trees matched. Sorted. */
|
|
81
|
+
unchanged: string[];
|
|
82
|
+
/** Declared entities whose *properties* could not be read (#1089). Sorted. */
|
|
83
|
+
unobserved: UnobservedResource[];
|
|
84
|
+
/** Entities the deep reader returned that were never declared. Sorted. */
|
|
85
|
+
undeclaredEntities: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A declared entity's property tree, already normalized with the lexicon's hooks. */
|
|
89
|
+
export interface DeclaredDeepEntity {
|
|
90
|
+
type: string;
|
|
91
|
+
properties: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DiffDeepInput {
|
|
95
|
+
/** Normalized declared property trees, keyed by chant entity name. */
|
|
96
|
+
declared: Record<string, DeclaredDeepEntity>;
|
|
97
|
+
/** Normalized live observation, as returned by `observeResourcesDeep()`. */
|
|
98
|
+
live: NormalizedDeepObservation;
|
|
99
|
+
/** Accepted deviations for this lexicon. Omit for "nothing accepted". */
|
|
100
|
+
baseline?: BaselineLexicon;
|
|
101
|
+
/**
|
|
102
|
+
* The lexicon's hooks, so set-like arrays are addressed by key rather than by
|
|
103
|
+
* position (see `flattenDeepProperties`). Omit and paths are positional,
|
|
104
|
+
* which still diffs correctly but shifts every path after an inserted
|
|
105
|
+
* element.
|
|
106
|
+
*/
|
|
107
|
+
hooks?: DeepNormalizationHooks;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compare declared and live property trees path by path, subtracting accepted
|
|
112
|
+
* deviations. Deterministic: every list is sorted.
|
|
113
|
+
*/
|
|
114
|
+
export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
115
|
+
const baseline = input.baseline ?? {};
|
|
116
|
+
const drifted: DeepEntityDrift[] = [];
|
|
117
|
+
const accepted: DeepEntityDrift[] = [];
|
|
118
|
+
const unchanged: string[] = [];
|
|
119
|
+
const unobserved: UnobservedResource[] = [];
|
|
120
|
+
const undeclaredEntities: string[] = [];
|
|
121
|
+
|
|
122
|
+
const liveNames = new Set(Object.keys(input.live.resources));
|
|
123
|
+
|
|
124
|
+
for (const [name, entry] of Object.entries(input.live.unobserved)) {
|
|
125
|
+
// Present beats not-observed, exactly as the thin contract resolves it.
|
|
126
|
+
if (liveNames.has(name)) continue;
|
|
127
|
+
unobserved.push({
|
|
128
|
+
name,
|
|
129
|
+
...(entry.type ? { type: entry.type } : {}),
|
|
130
|
+
reason: entry.reason,
|
|
131
|
+
...(entry.detail ? { detail: entry.detail } : {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const name of liveNames) {
|
|
136
|
+
if (!(name in input.declared)) undeclaredEntities.push(name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const name of Object.keys(input.declared).sort()) {
|
|
140
|
+
const liveEntity = input.live.resources[name];
|
|
141
|
+
// Not observed deeply → already recorded above; no properties to compare.
|
|
142
|
+
// Observed absent by the deep reader is the thin diff's `missing` case and
|
|
143
|
+
// is not restated here: a resource that does not exist has no property
|
|
144
|
+
// drift, and reporting every one of its declared properties as `absent`
|
|
145
|
+
// would bury the one line that matters.
|
|
146
|
+
if (!liveEntity) continue;
|
|
147
|
+
|
|
148
|
+
const declaredEntity = input.declared[name];
|
|
149
|
+
const type = liveEntity.type || declaredEntity.type;
|
|
150
|
+
const declaredFlat = flattenDeepProperties(declaredEntity.properties, {
|
|
151
|
+
entityType: type,
|
|
152
|
+
side: "declared",
|
|
153
|
+
hooks: input.hooks,
|
|
154
|
+
});
|
|
155
|
+
const liveFlat = flattenDeepProperties(liveEntity.properties, {
|
|
156
|
+
entityType: type,
|
|
157
|
+
side: "live",
|
|
158
|
+
hooks: input.hooks,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const paths = [...new Set([...declaredFlat.keys(), ...liveFlat.keys()])].sort();
|
|
162
|
+
const reported: PropertyDrift[] = [];
|
|
163
|
+
const suppressed: PropertyDrift[] = [];
|
|
164
|
+
|
|
165
|
+
for (const path of paths) {
|
|
166
|
+
const hasDeclared = declaredFlat.has(path);
|
|
167
|
+
const hasLive = liveFlat.has(path);
|
|
168
|
+
const declaredValue = declaredFlat.get(path);
|
|
169
|
+
const liveValue = liveFlat.get(path);
|
|
170
|
+
|
|
171
|
+
// An unevaluated intrinsic has no source-side value to compare against.
|
|
172
|
+
if (hasDeclared && declaredValue === UNRESOLVED) continue;
|
|
173
|
+
if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
|
|
174
|
+
|
|
175
|
+
const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
|
|
176
|
+
const drift: PropertyDrift = {
|
|
177
|
+
path,
|
|
178
|
+
kind,
|
|
179
|
+
...(hasDeclared ? { declared: declaredValue } : {}),
|
|
180
|
+
...(hasLive ? { live: liveValue } : {}),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const acceptedEntry = acceptedDeviation(baseline, name, path);
|
|
184
|
+
if (acceptedEntry) {
|
|
185
|
+
drift.baseline = acceptedEntry.value;
|
|
186
|
+
// Value-bound acceptance: the accepted value is not drift, a different
|
|
187
|
+
// one is drift again.
|
|
188
|
+
if (hasLive && deepValueEqual(liveValue, acceptedEntry.value)) {
|
|
189
|
+
suppressed.push(drift);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
reported.push(drift);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (suppressed.length > 0) accepted.push({ name, type, changes: suppressed });
|
|
197
|
+
if (reported.length > 0) drifted.push({ name, type, changes: reported });
|
|
198
|
+
else if (suppressed.length === 0) unchanged.push(name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
drifted: drifted.sort((a, b) => a.name.localeCompare(b.name)),
|
|
203
|
+
accepted: accepted.sort((a, b) => a.name.localeCompare(b.name)),
|
|
204
|
+
unchanged: unchanged.sort(),
|
|
205
|
+
unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
|
|
206
|
+
undeclaredEntities: undeclaredEntities.sort(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Total reported property differences across every entity. */
|
|
211
|
+
export function countPropertyDrift(result: DeepDiffResult): number {
|
|
212
|
+
return result.drifted.reduce((n, e) => n + e.changes.length, 0);
|
|
213
|
+
}
|