@intentius/chant-lexicon-azure 0.30.0 → 0.32.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Azure deep observation (#1086) — the azure row of the deep-observe contract
3
+ * (#1014).
4
+ *
5
+ * `az resource show --resource-group <env> --name <name> --resource-type
6
+ * <type> -o json` already returns the full ARM resource — the same call the
7
+ * thin path (./describe-resources.ts) makes, which keeps `provisioningState`
8
+ * (as `status`) plus `location`/`tags`. The depth is free; the work is
9
+ * entirely normalization, per the issue.
10
+ *
11
+ * ## Why the live tree is flattened, not passed through raw
12
+ *
13
+ * Unlike AWS (whose declared props mirror CloudFormation's vocabulary 1:1, so
14
+ * the Cloud Control payload normalizes and diffs as-is), chant's Azure
15
+ * resource classes do not mirror the ARM response shape verbatim: codegen
16
+ * (`spec/parse.ts`'s `RESOURCE_LEVEL_FIELDS` pass) flattens ARM's `properties`
17
+ * wrapper onto the constructor's top level, so a storage account's declared
18
+ * `minimumTlsVersion` is a sibling of `location`/`tags`, not nested under a
19
+ * `properties` key. Passing the raw `az resource show` response through
20
+ * unflattened would make `properties.minimumTlsVersion` a path with no
21
+ * declared counterpart, ever — the same permanent-noise problem AWS avoids by
22
+ * having a vocabulary match to begin with. So this reader flattens
23
+ * `properties.*` up to the same top level chant's generated classes use,
24
+ * alongside the resource-level fields (`name`, `location`, `sku`, `kind`,
25
+ * `identity`, `tags`, `zones`, `plan`) ARM returns as siblings of `properties`.
26
+ *
27
+ * `id`, `type`, `etag` and `systemData` are the one thing that's *not*
28
+ * flattened in — they have no declared counterpart of any kind (chant's
29
+ * codegen skips `type`/`apiVersion` explicitly, and `id`/`etag`/`systemData`
30
+ * are pure ARM-envelope bookkeeping), so they are simply never read into the
31
+ * tree, the same way `type`/`physicalId` live outside `properties` on {@link
32
+ * DeepResourceObservation} rather than inside it.
33
+ *
34
+ * ## Scope
35
+ *
36
+ * Same as the thin path: any top-level ARM type (`Microsoft.<provider>/<kind>`,
37
+ * exactly one `/`) is readable; a nested compound type
38
+ * (`Microsoft.Storage/storageAccounts/blobServices`) is `unsupported-kind`,
39
+ * because `az resource show` does not accept a compound type name either.
40
+ * Deferred, not fixed, here — same as the issue's own "worth fixing here or
41
+ * explicitly deferring" — widening it is additive and needs no contract change.
42
+ *
43
+ * ## Nothing here talks to real Azure on its own terms
44
+ *
45
+ * `node:child_process` `exec`, exactly like the thin path — no ARM SDK, no
46
+ * ambient token. Every test replaces `child_process.exec`.
47
+ */
48
+ import type { DeepNormalizationHooks, DeepObservationResult } from "@intentius/chant/lexicon";
49
+ /**
50
+ * Server-populated wherever they appear — matched on the final path segment,
51
+ * the same technique AWS's `AWS_READ_ONLY_NAMES` uses, because ARM repeats
52
+ * `provisioningState` at every resource-properties depth and a per-type list
53
+ * of full paths would be a maintenance trap. Deliberately excludes ambiguous
54
+ * names like `id`/`name`: a subnet's `properties.routeTable.id` is a declared
55
+ * cross-reference, and pruning it is how a normalization pass starts hiding
56
+ * real drift. (The resource's *own* envelope `id` is handled separately —
57
+ * see the module doc — precisely so this list never has to touch `id` at all.)
58
+ */
59
+ export declare const AZURE_READ_ONLY_NAMES: ReadonlySet<string>;
60
+ /**
61
+ * ARM service defaults, per type, as index-erased property paths. Subtracted
62
+ * only where source never declared the property — cdk-real-drift's default
63
+ * subtraction, same as AWS's `AWS_SERVICE_DEFAULTS`. Sparse and evidence-based
64
+ * rather than exhaustive across 1900+ ARM types: widening this table is
65
+ * additive and needs no contract change.
66
+ */
67
+ export declare const AZURE_SERVICE_DEFAULTS: Record<string, Record<string, unknown>>;
68
+ /**
69
+ * The azure lexicon's noise rules. Three classes, same as AWS: server-
70
+ * populated fields (by name, unconditional), controller-injected boilerplate
71
+ * (by name, unconditional), and ARM service defaults (gated on
72
+ * `counterpart === "absent"`) — plus tag-map defaults, which AWS has no
73
+ * equivalent for since its `Tags` is an array rather than a plain object (an
74
+ * ARM tag map's key order is already canonicalized unconditionally by core,
75
+ * since it is a plain object; only the *default empty map* needs a rule here).
76
+ */
77
+ export declare const azureDeepNormalizationHooks: DeepNormalizationHooks;
78
+ export interface AzureDeepObserveOptions {
79
+ environment: string;
80
+ entityNames: string[];
81
+ entities: Map<string, {
82
+ entityType: string;
83
+ props: Record<string, unknown>;
84
+ }>;
85
+ }
86
+ /**
87
+ * Read the live property tree for each declared entity via `az resource
88
+ * show`. One call per entity, same as the thin path — ARM has no bulk
89
+ * "describe everything in this group" call with per-resource depth the way
90
+ * CloudFormation's `describe-stack-resources` does, so there is no cheap
91
+ * list-then-describe split to make here the way AWS's or temporal's readers
92
+ * do.
93
+ */
94
+ export declare function observeResourcesDeepAzure(options: AzureDeepObserveOptions): Promise<DeepObservationResult>;
95
+ //# sourceMappingURL=deep-observe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAIH,OAAO,KAAK,EAGV,sBAAsB,EACtB,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAkDlC;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAKpD,CAAC;AAUH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAI1E,CAAC;AAuCF;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,EAAE,sBAiCzC,CAAC;AAIF,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E;AAED;;;;;;;GAOG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,qBAAqB,CAAC,CAgEhC"}
@@ -17,6 +17,16 @@
17
17
  * are holes for the same reason.
18
18
  */
19
19
  import type { ObservationResult, UnobservedReason } from "@intentius/chant/lexicon";
20
+ /**
21
+ * Top-level ARM type — i.e. exactly one `/` separating provider from kind.
22
+ * Nested types like `Microsoft.Storage/storageAccounts/blobServices` need a
23
+ * different query path that this implementation doesn't yet support.
24
+ *
25
+ * Exported so the deep reader (./deep-observe.ts, #1086) applies the exact
26
+ * same scope limit — a nested type is `unsupported-kind` at both depths, not
27
+ * silently deeper on one and not the other.
28
+ */
29
+ export declare function isTopLevelType(entityType: string): boolean;
20
30
  /**
21
31
  * Classify an `az resource show` failure (#1089). Only the CLI's explicit
22
32
  * not-found establishes absence; everything else — an expired login, a
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAmCxH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkC9H;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA2E7B"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAyBxH;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG1D;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkC9H;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA2E7B"}
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { defaultTags, type TagEntry, type DefaultTags } from "./default-tags.js"
7
7
  export { azureSerializer } from "./serializer.js";
8
8
  export { azurePlugin } from "./plugin.js";
9
9
  export { Parameter } from "./parameter.js";
10
+ export { observeResourcesDeepAzure, azureDeepNormalizationHooks, AZURE_READ_ONLY_NAMES, AZURE_SERVICE_DEFAULTS, } from "./deep-observe.js";
10
11
  export { ResourceId, ResourceIdIntrinsic, Reference, ReferenceIntrinsic, Concat, ConcatIntrinsic, ResourceGroup, ResourceGroupIntrinsic, Subscription, SubscriptionIntrinsic, UniqueString, UniqueStringIntrinsic, Format, FormatIntrinsic, If, IfIntrinsic, ListKeys, ListKeysIntrinsic, } from "./intrinsics.js";
11
12
  export { Azure, ResourceGroupName, ResourceGroupLocation, ResourceGroupId, SubscriptionId, TenantId, DeploymentName, } from "./pseudo.js";
12
13
  export { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC9E,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC,OAAO,EACL,UAAU,EAAE,mBAAmB,EAC/B,SAAS,EAAE,kBAAkB,EAC7B,MAAM,EAAE,eAAe,EACvB,aAAa,EAAE,sBAAsB,EACrC,YAAY,EAAE,qBAAqB,EACnC,YAAY,EAAE,qBAAqB,EACnC,MAAM,EAAE,eAAe,EACvB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,iBAAiB,GAC5B,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,KAAK,EACL,iBAAiB,EACjB,qBAAqB,EACrB,eAAe,EACf,cAAc,EACd,QAAQ,EACR,cAAc,GACf,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,cAAc,EAAE,KAAK,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAGzF,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EACL,oBAAoB,EAAE,KAAK,yBAAyB,EAAE,KAAK,0BAA0B,EACrF,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAC9C,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,cAAc,EAAE,KAAK,mBAAmB,EAAE,KAAK,oBAAoB,EACnE,uBAAuB,EAAE,KAAK,4BAA4B,EAAE,KAAK,6BAA6B,EAC9F,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,kBAAkB,EAAE,KAAK,uBAAuB,EAAE,KAAK,wBAAwB,EAC/E,cAAc,EAAE,KAAK,mBAAmB,EAAE,KAAK,oBAAoB,EACnE,kBAAkB,EAAE,KAAK,uBAAuB,EAAE,KAAK,wBAAwB,EAC/E,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,uBAAuB,EAC5E,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,eAAe,EAAE,KAAK,oBAAoB,EAAE,KAAK,qBAAqB,GACvE,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGnD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC9E,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAIxC,OAAO,EACL,yBAAyB,EACzB,2BAA2B,EAC3B,qBAAqB,EACrB,sBAAsB,GACvB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,UAAU,EAAE,mBAAmB,EAC/B,SAAS,EAAE,kBAAkB,EAC7B,MAAM,EAAE,eAAe,EACvB,aAAa,EAAE,sBAAsB,EACrC,YAAY,EAAE,qBAAqB,EACnC,YAAY,EAAE,qBAAqB,EACnC,MAAM,EAAE,eAAe,EACvB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,iBAAiB,GAC5B,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,KAAK,EACL,iBAAiB,EACjB,qBAAqB,EACrB,eAAe,EACf,cAAc,EACd,QAAQ,EACR,cAAc,GACf,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,cAAc,EAAE,KAAK,oBAAoB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAGzF,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EACL,oBAAoB,EAAE,KAAK,yBAAyB,EAAE,KAAK,0BAA0B,EACrF,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAC9C,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,cAAc,EAAE,KAAK,mBAAmB,EAAE,KAAK,oBAAoB,EACnE,uBAAuB,EAAE,KAAK,4BAA4B,EAAE,KAAK,6BAA6B,EAC9F,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,iBAAiB,EAC1D,kBAAkB,EAAE,KAAK,uBAAuB,EAAE,KAAK,wBAAwB,EAC/E,cAAc,EAAE,KAAK,mBAAmB,EAAE,KAAK,oBAAoB,EACnE,kBAAkB,EAAE,KAAK,uBAAuB,EAAE,KAAK,wBAAwB,EAC/E,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,uBAAuB,EAC5E,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EACvD,eAAe,EAAE,KAAK,oBAAoB,EAAE,KAAK,qBAAqB,GACvE,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGnD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "50025ac710a4bd52992e4a6b196a56d2ff14735a095dd7f72cf8bcb389b761ea",
4
+ "manifest.json": "26f39f43ef7014727ea4212bc218d7dac00e6d89dc5d827ada31340b83274afe",
5
5
  "meta.json": "051fb4ee43f7de48a36f1abc59c8ff5207c3b75aa28f13db6440b9f5b0337605",
6
6
  "types/index.d.ts": "617a6de433bd92a126170a7f5ae903d6d7aa83fbb0b57169d3450b5a13e72e0d",
7
7
  "rules/hardcoded-location.ts": "a9b1d1cec93f2ca9c5206641f53fc9621cefcfd2c00b3ab89c194b30a75fa949",
@@ -33,5 +33,5 @@
33
33
  "skills/chant-azure-patterns.md": "1a3bacfac612826b77332d2692a59195638db9f1b5e8ea2eda7579621d25f603",
34
34
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
35
35
  },
36
- "composite": "923cf13c8ea5dfb598f2aecae0e957d42e977bc511747871532644ab9230d8ed"
36
+ "composite": "6acb8658d790ae68bf979a47f2a36d47cfb9beb72b922d146d146b60164a3227"
37
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAsB5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aAqbzB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAuB5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aAqczB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Azure lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -74,7 +74,7 @@
74
74
  "typescript": "^5.9.3"
75
75
  },
76
76
  "peerDependencies": {
77
- "@intentius/chant": "^0.30.0",
77
+ "@intentius/chant": "^0.32.0",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Azure deep observation (#1086) — the azure row of the deep-observe
3
+ * contract (#1014).
4
+ *
5
+ * `node:child_process`'s `exec` is mocked, exactly like
6
+ * describe-resources.test.ts — no ARM SDK, no ambient `az login` session,
7
+ * nothing reaches a network.
8
+ */
9
+ import { describe, test, expect, vi, beforeEach } from "vitest";
10
+
11
+ const execMock = vi.fn();
12
+ vi.mock("node:child_process", async () => {
13
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
14
+ return {
15
+ ...actual,
16
+ exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
17
+ Promise.resolve(execMock(cmd)).then(
18
+ (out) => cb(null, out as { stdout: string; stderr: string }),
19
+ (err) => cb(err as Error, { stdout: "", stderr: "" }),
20
+ );
21
+ },
22
+ };
23
+ });
24
+
25
+ const { azurePlugin } = await import("./plugin");
26
+ const { observeResourcesDeepAzure, azureDeepNormalizationHooks } = await import("./deep-observe");
27
+ const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
28
+ const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
29
+
30
+ const ok = (body: Record<string, unknown>) => Promise.resolve({ stdout: JSON.stringify(body), stderr: "" });
31
+ const fail = (stderr: string) => Promise.reject(Object.assign(new Error("az failed"), { stderr }));
32
+
33
+ const entities = (
34
+ record: Record<string, { entityType: string; props: Record<string, unknown> }>,
35
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
36
+
37
+ beforeEach(() => {
38
+ execMock.mockReset();
39
+ });
40
+
41
+ describe("the azure noise rules", () => {
42
+ test("prunes server-populated names wherever they appear", () => {
43
+ const out = normalizeDeepProperties(
44
+ {
45
+ name: "acct",
46
+ resourceGuid: "11111111-2222-3333-4444-555555555555",
47
+ properties: { provisioningState: "Succeeded", nested: { provisioningState: "Succeeded", keep: 1 } },
48
+ identity: { type: "SystemAssigned", principalId: "guid-1", tenantId: "guid-2" },
49
+ },
50
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks },
51
+ );
52
+ expect(out).toEqual({
53
+ name: "acct",
54
+ properties: { nested: { keep: 1 } },
55
+ identity: { type: "SystemAssigned" },
56
+ });
57
+ });
58
+
59
+ test("prunes defaultSecurityRules unconditionally — server boilerplate nobody declares", () => {
60
+ const out = normalizeDeepProperties(
61
+ { securityRules: [{ name: "allow-ssh", priority: 100 }], defaultSecurityRules: [{ name: "AllowVnetInBound" }] },
62
+ { entityType: "Microsoft.Network/networkSecurityGroups", side: "live", hooks: azureDeepNormalizationHooks },
63
+ );
64
+ expect(out).toEqual({ securityRules: [{ name: "allow-ssh", priority: 100 }] });
65
+ });
66
+
67
+ test("subtracts an empty tag map only where source declares no tags at all", () => {
68
+ const declaredNothing = normalizeDeepProperties(
69
+ { name: "acct", tags: {} },
70
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks, counterpartPaths: new Set(["name"]) },
71
+ );
72
+ expect(declaredNothing).toEqual({ name: "acct" });
73
+
74
+ const declaredTags = normalizeDeepProperties(
75
+ { name: "acct", tags: {} },
76
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks, counterpartPaths: new Set(["name", "tags"]) },
77
+ );
78
+ expect(declaredTags).toEqual({ name: "acct", tags: {} });
79
+ });
80
+
81
+ test("subtracts a per-type service default only where source is silent about the property", () => {
82
+ const declaredNothing = normalizeDeepProperties(
83
+ { name: "acct", minimumTlsVersion: "TLS1_2" },
84
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks, counterpartPaths: new Set(["name"]) },
85
+ );
86
+ expect(declaredNothing).toEqual({ name: "acct" });
87
+
88
+ const declaredIt = normalizeDeepProperties(
89
+ { name: "acct", minimumTlsVersion: "TLS1_2" },
90
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks, counterpartPaths: new Set(["name", "minimumTlsVersion"]) },
91
+ );
92
+ expect(declaredIt).toEqual({ name: "acct", minimumTlsVersion: "TLS1_2" });
93
+ });
94
+
95
+ test("a one-sided pass never subtracts defaults or the empty tag map — the reader has no declared tree yet", () => {
96
+ const out = normalizeDeepProperties(
97
+ { name: "acct", tags: {}, minimumTlsVersion: "TLS1_2" },
98
+ { entityType: "Microsoft.Storage/storageAccounts", side: "live", hooks: azureDeepNormalizationHooks },
99
+ );
100
+ expect(out).toEqual({ name: "acct", tags: {}, minimumTlsVersion: "TLS1_2" });
101
+ });
102
+
103
+ test("canonicalizes a named rule array by its name field", () => {
104
+ const out = normalizeDeepProperties(
105
+ { securityRules: [{ name: "deny-all", priority: 200 }, { name: "allow-ssh", priority: 100 }] },
106
+ { entityType: "Microsoft.Network/networkSecurityGroups", side: "live", hooks: azureDeepNormalizationHooks },
107
+ );
108
+ expect((out.securityRules as Array<{ name: string }>).map((r) => r.name)).toEqual(["allow-ssh", "deny-all"]);
109
+ });
110
+
111
+ test("canonicalizes a primitive-valued set by value, nested under any depth", () => {
112
+ const out = normalizeDeepProperties(
113
+ { addressSpace: { addressPrefixes: ["10.1.0.0/16", "10.0.0.0/16"] } },
114
+ { entityType: "Microsoft.Network/virtualNetworks", side: "live", hooks: azureDeepNormalizationHooks },
115
+ );
116
+ expect((out.addressSpace as { addressPrefixes: string[] }).addressPrefixes).toEqual(["10.0.0.0/16", "10.1.0.0/16"]);
117
+ });
118
+
119
+ test("leaves an array not on the known-set list in source order", () => {
120
+ const out = normalizeDeepProperties(
121
+ { routes: [{ name: "z" }, { name: "a" }] },
122
+ { entityType: "Microsoft.Network/routeTables", side: "live", hooks: azureDeepNormalizationHooks },
123
+ );
124
+ expect((out.routes as Array<{ name: string }>).map((r) => r.name)).toEqual(["z", "a"]);
125
+ });
126
+ });
127
+
128
+ describe("observeResourcesDeepAzure", () => {
129
+ test("queries az resource show with rg + name + type and flattens properties.* onto the top level", async () => {
130
+ let receivedCmd = "";
131
+ execMock.mockImplementation((cmd: string) => {
132
+ receivedCmd = cmd;
133
+ return ok({
134
+ id: "/subscriptions/sub/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/mydata",
135
+ name: "mydata",
136
+ type: "Microsoft.Storage/storageAccounts",
137
+ location: "eastus",
138
+ tags: { env: "prod" },
139
+ etag: "\"abc\"",
140
+ systemData: { createdBy: "someone@example.com" },
141
+ properties: { provisioningState: "Succeeded", minimumTlsVersion: "TLS1_2", allowBlobPublicAccess: false },
142
+ });
143
+ });
144
+
145
+ const result = normalizeDeepObservation(
146
+ await observeResourcesDeepAzure({
147
+ environment: "prod-rg",
148
+ entityNames: ["dataAccount"],
149
+ entities: entities({ dataAccount: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mydata" } } }),
150
+ }),
151
+ );
152
+
153
+ expect(receivedCmd).toContain("az resource show");
154
+ expect(receivedCmd).toContain("--resource-group prod-rg");
155
+ expect(receivedCmd).toContain("--name mydata");
156
+ expect(receivedCmd).toContain("--resource-type Microsoft.Storage/storageAccounts");
157
+
158
+ // id/type/etag/systemData/provisioningState never reach the tree at all —
159
+ // no declared tree yet, so this is what a one-sided read looks like.
160
+ expect(result.resources.dataAccount).toEqual({
161
+ type: "Microsoft.Storage/storageAccounts",
162
+ physicalId: "/subscriptions/sub/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/mydata",
163
+ properties: { name: "mydata", location: "eastus", tags: { env: "prod" }, minimumTlsVersion: "TLS1_2", allowBlobPublicAccess: false },
164
+ });
165
+ });
166
+
167
+ test("a resource not found leaves the entity out — a confirmed absence", async () => {
168
+ execMock.mockImplementation(() => fail("ResourceNotFound: ..."));
169
+ const result = normalizeDeepObservation(
170
+ await observeResourcesDeepAzure({
171
+ environment: "prod-rg",
172
+ entityNames: ["missing"],
173
+ entities: entities({ missing: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "missing" } } }),
174
+ }),
175
+ );
176
+ expect(result).toEqual({ resources: {}, unobserved: {} });
177
+ });
178
+
179
+ test("an expired az login is a hole, not an absence", async () => {
180
+ execMock.mockImplementation(() => fail("Please run 'az login' to setup account."));
181
+ const result = normalizeDeepObservation(
182
+ await observeResourcesDeepAzure({
183
+ environment: "prod-rg",
184
+ entityNames: ["acct"],
185
+ entities: entities({ acct: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "acct" } } }),
186
+ }),
187
+ );
188
+ expect(result.resources).toEqual({});
189
+ expect(result.unobserved.acct.reason).toBe("no-credentials");
190
+ });
191
+
192
+ test("a nested ARM type is unsupported-kind, never absent — az resource show is never called", async () => {
193
+ const result = normalizeDeepObservation(
194
+ await observeResourcesDeepAzure({
195
+ environment: "prod-rg",
196
+ entityNames: ["nested"],
197
+ entities: entities({ nested: { entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "default" } } }),
198
+ }),
199
+ );
200
+ expect(result.resources).toEqual({});
201
+ expect(result.unobserved.nested.reason).toBe("unsupported-kind");
202
+ expect(execMock).not.toHaveBeenCalled();
203
+ });
204
+
205
+ test("a non-ARM entity type is unsupported-kind — no ARM type to query", async () => {
206
+ const result = normalizeDeepObservation(
207
+ await observeResourcesDeepAzure({
208
+ environment: "prod-rg",
209
+ entityNames: ["x"],
210
+ entities: entities({ x: { entityType: "AWS::S3::Bucket", props: { name: "x" } } }),
211
+ }),
212
+ );
213
+ expect(result.unobserved.x.reason).toBe("unsupported-kind");
214
+ expect(execMock).not.toHaveBeenCalled();
215
+ });
216
+
217
+ test("an entity with no name is a hole — nothing was queried", async () => {
218
+ const result = normalizeDeepObservation(
219
+ await observeResourcesDeepAzure({
220
+ environment: "prod-rg",
221
+ entityNames: ["broken"],
222
+ entities: entities({ broken: { entityType: "Microsoft.Storage/storageAccounts", props: {} } }),
223
+ }),
224
+ );
225
+ expect(result.unobserved.broken.reason).toBe("read-failed");
226
+ expect(execMock).not.toHaveBeenCalled();
227
+ });
228
+ });
229
+
230
+ /**
231
+ * The acceptance test for #1086: the real plugin, a mutated live resource
232
+ * group, a baseline, and exactly the genuine drift — same shape as the AWS
233
+ * reference's end-to-end test.
234
+ */
235
+ describe("end to end: declared + mutated live + baseline (#1086)", () => {
236
+ const declared = entities({
237
+ // Declared with TLS enforced, public blob access off, one tag.
238
+ dataAccount: {
239
+ entityType: "Microsoft.Storage/storageAccounts",
240
+ props: { name: "mydata", location: "eastus", tags: { env: "prod" }, minimumTlsVersion: "TLS1_2", allowBlobPublicAccess: false },
241
+ },
242
+ // Declared with two address prefixes, in source order.
243
+ vnet: {
244
+ entityType: "Microsoft.Network/virtualNetworks",
245
+ props: { name: "core-vnet", location: "eastus", addressSpace: { addressPrefixes: ["10.0.0.0/16", "10.1.0.0/16"] } },
246
+ },
247
+ // No reader for a nested ARM type.
248
+ blobServices: { entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "default" } },
249
+ // The deep read of this one fails outright.
250
+ secureAcct: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "secure-acct" } },
251
+ });
252
+
253
+ const wireMocks = (): void => {
254
+ execMock.mockImplementation((cmd: string) => {
255
+ if (cmd.includes("--name mydata")) {
256
+ return ok({
257
+ id: "/subscriptions/sub/resourceGroups/prod/providers/Microsoft.Storage/storageAccounts/mydata",
258
+ name: "mydata",
259
+ location: "eastus",
260
+ etag: "\"abc\"",
261
+ // NOISE: an out-of-band tag the platform team adds to every bucket.
262
+ tags: { env: "prod", "cost-center": "platform" },
263
+ properties: {
264
+ // NOISE: server-populated.
265
+ provisioningState: "Succeeded",
266
+ resourceGuid: "11111111-2222-3333-4444-555555555555",
267
+ // NOISE: matches the declared value, so no drift either way.
268
+ minimumTlsVersion: "TLS1_2",
269
+ // GENUINE: somebody flipped this in the portal.
270
+ allowBlobPublicAccess: true,
271
+ },
272
+ });
273
+ }
274
+ if (cmd.includes("--name core-vnet")) {
275
+ return ok({
276
+ id: "/subscriptions/sub/resourceGroups/prod/providers/Microsoft.Network/virtualNetworks/core-vnet",
277
+ name: "core-vnet",
278
+ location: "eastus",
279
+ properties: {
280
+ provisioningState: "Succeeded",
281
+ resourceGuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
282
+ // NOISE: same two prefixes, different order.
283
+ addressSpace: { addressPrefixes: ["10.1.0.0/16", "10.0.0.0/16"] },
284
+ },
285
+ });
286
+ }
287
+ if (cmd.includes("--name secure-acct")) {
288
+ return fail("Please run 'az login' to setup account.");
289
+ }
290
+ return fail("unexpected call");
291
+ });
292
+ };
293
+
294
+ const baseline = {
295
+ dataAccount: {
296
+ type: "Microsoft.Storage/storageAccounts",
297
+ accepted: [{ path: "tags.cost-center", value: "platform" }],
298
+ },
299
+ };
300
+
301
+ test("exactly the genuine drift surfaces; noise, defaults and the accepted tag do not", async () => {
302
+ wireMocks();
303
+ const result = await deepDiffForLexicon(azurePlugin, {
304
+ environment: "prod",
305
+ buildOutput: "",
306
+ entities: declared,
307
+ baseline,
308
+ });
309
+
310
+ // One finding, one property: the console-flipped public-access setting.
311
+ expect(result.drifted).toEqual([
312
+ {
313
+ name: "dataAccount",
314
+ type: "Microsoft.Storage/storageAccounts",
315
+ changes: [{ path: "allowBlobPublicAccess", kind: "changed", declared: false, live: true }],
316
+ },
317
+ ]);
318
+
319
+ // The vnet is clean: reordering and server-populated fields subtract.
320
+ expect(result.unchanged).toEqual(["vnet"]);
321
+
322
+ // The platform team's tag is accepted, so it is reported as suppressed
323
+ // rather than as drift.
324
+ expect(result.accepted.map((e) => e.name)).toEqual(["dataAccount"]);
325
+ expect(result.accepted[0].changes.map((c) => c.path)).toEqual(["tags.cost-center"]);
326
+
327
+ // An unreadable deep read is a hole with a reason — never silence, never
328
+ // noise, and never a create.
329
+ expect(result.unobserved).toEqual([
330
+ {
331
+ name: "blobServices",
332
+ type: "Microsoft.Storage/storageAccounts/blobServices",
333
+ reason: "unsupported-kind",
334
+ detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
335
+ },
336
+ { name: "secureAcct", type: "Microsoft.Storage/storageAccounts", reason: "no-credentials", detail: "Please run 'az login' to setup account." },
337
+ ]);
338
+ });
339
+
340
+ test("without the baseline the platform tag is drift, and accepting it is what silences it", async () => {
341
+ wireMocks();
342
+ const result = await deepDiffForLexicon(azurePlugin, { environment: "prod", buildOutput: "", entities: declared });
343
+ const dataAccount = result.drifted.find((d) => d.name === "dataAccount");
344
+ expect(dataAccount?.changes.map((c) => c.path).sort()).toEqual(["allowBlobPublicAccess", "tags.cost-center"]);
345
+ expect(result.accepted).toEqual([]);
346
+ });
347
+
348
+ test("an accepted value that later changes is drift again, with all three axes", async () => {
349
+ wireMocks();
350
+ const result = await deepDiffForLexicon(azurePlugin, {
351
+ environment: "prod",
352
+ buildOutput: "",
353
+ entities: declared,
354
+ baseline: { dataAccount: { accepted: [{ path: "tags.cost-center", value: "someone-elses-team" }] } },
355
+ });
356
+ const change = result.drifted.find((d) => d.name === "dataAccount")?.changes.find((c) => c.path === "tags.cost-center");
357
+ expect(change).toEqual({ path: "tags.cost-center", kind: "undeclared", live: "platform", baseline: "someone-elses-team" });
358
+ });
359
+
360
+ test("a whole-lexicon failure is a hole for every declared entity, not a clean report", async () => {
361
+ execMock.mockImplementation(() => fail("Unable to locate credentials"));
362
+ const result = await deepDiffForLexicon(azurePlugin, { environment: "prod", buildOutput: "", entities: declared });
363
+ expect(result.drifted).toEqual([]);
364
+ // blobServices is still unsupported-kind — az resource show is never even
365
+ // called for it, so a broken CLI doesn't change its verdict.
366
+ expect(result.unobserved.map((u) => u.name).sort()).toEqual(["blobServices", "dataAccount", "secureAcct", "vnet"]);
367
+ expect(result.unobserved.find((u) => u.name === "blobServices")?.reason).toBe("unsupported-kind");
368
+ expect(
369
+ result.unobserved.filter((u) => u.name !== "blobServices").every((u) => u.reason === "read-failed"),
370
+ ).toBe(true);
371
+ });
372
+ });
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Azure deep observation (#1086) — the azure row of the deep-observe contract
3
+ * (#1014).
4
+ *
5
+ * `az resource show --resource-group <env> --name <name> --resource-type
6
+ * <type> -o json` already returns the full ARM resource — the same call the
7
+ * thin path (./describe-resources.ts) makes, which keeps `provisioningState`
8
+ * (as `status`) plus `location`/`tags`. The depth is free; the work is
9
+ * entirely normalization, per the issue.
10
+ *
11
+ * ## Why the live tree is flattened, not passed through raw
12
+ *
13
+ * Unlike AWS (whose declared props mirror CloudFormation's vocabulary 1:1, so
14
+ * the Cloud Control payload normalizes and diffs as-is), chant's Azure
15
+ * resource classes do not mirror the ARM response shape verbatim: codegen
16
+ * (`spec/parse.ts`'s `RESOURCE_LEVEL_FIELDS` pass) flattens ARM's `properties`
17
+ * wrapper onto the constructor's top level, so a storage account's declared
18
+ * `minimumTlsVersion` is a sibling of `location`/`tags`, not nested under a
19
+ * `properties` key. Passing the raw `az resource show` response through
20
+ * unflattened would make `properties.minimumTlsVersion` a path with no
21
+ * declared counterpart, ever — the same permanent-noise problem AWS avoids by
22
+ * having a vocabulary match to begin with. So this reader flattens
23
+ * `properties.*` up to the same top level chant's generated classes use,
24
+ * alongside the resource-level fields (`name`, `location`, `sku`, `kind`,
25
+ * `identity`, `tags`, `zones`, `plan`) ARM returns as siblings of `properties`.
26
+ *
27
+ * `id`, `type`, `etag` and `systemData` are the one thing that's *not*
28
+ * flattened in — they have no declared counterpart of any kind (chant's
29
+ * codegen skips `type`/`apiVersion` explicitly, and `id`/`etag`/`systemData`
30
+ * are pure ARM-envelope bookkeeping), so they are simply never read into the
31
+ * tree, the same way `type`/`physicalId` live outside `properties` on {@link
32
+ * DeepResourceObservation} rather than inside it.
33
+ *
34
+ * ## Scope
35
+ *
36
+ * Same as the thin path: any top-level ARM type (`Microsoft.<provider>/<kind>`,
37
+ * exactly one `/`) is readable; a nested compound type
38
+ * (`Microsoft.Storage/storageAccounts/blobServices`) is `unsupported-kind`,
39
+ * because `az resource show` does not accept a compound type name either.
40
+ * Deferred, not fixed, here — same as the issue's own "worth fixing here or
41
+ * explicitly deferring" — widening it is additive and needs no contract change.
42
+ *
43
+ * ## Nothing here talks to real Azure on its own terms
44
+ *
45
+ * `node:child_process` `exec`, exactly like the thin path — no ARM SDK, no
46
+ * ambient token. Every test replaces `child_process.exec`.
47
+ */
48
+
49
+ import { exec } from "node:child_process";
50
+ import { promisify } from "node:util";
51
+ import type {
52
+ DeepArrayElement,
53
+ DeepNode,
54
+ DeepNormalizationHooks,
55
+ DeepObservationResult,
56
+ DeepResourceObservation,
57
+ UnobservedEntity,
58
+ } from "@intentius/chant/lexicon";
59
+ import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
60
+ import { classifyAzFailure, isTopLevelType } from "./describe-resources";
61
+
62
+ const execAsync = promisify(exec);
63
+
64
+ /** The full `az resource show -o json` shape this reader reads (a superset of the thin path's `AzResourceShowResponse`). */
65
+ interface ArmResourceShowResponse {
66
+ id?: string;
67
+ name?: string;
68
+ type?: string;
69
+ location?: string;
70
+ kind?: string;
71
+ sku?: unknown;
72
+ identity?: unknown;
73
+ tags?: Record<string, string>;
74
+ zones?: string[];
75
+ plan?: unknown;
76
+ etag?: string;
77
+ systemData?: unknown;
78
+ properties?: Record<string, unknown>;
79
+ }
80
+
81
+ /**
82
+ * Reshape one `az resource show` response into chant's own flat declared
83
+ * vocabulary: the resource-level fields ARM returns as siblings of
84
+ * `properties`, plus every `properties.*` field spread onto that same top
85
+ * level (see the module doc). `id`/`type`/`etag`/`systemData` are envelope
86
+ * bookkeeping with no declared counterpart and are never read in at all.
87
+ */
88
+ function buildLiveProperties(obj: ArmResourceShowResponse): Record<string, unknown> {
89
+ const out: Record<string, unknown> = {};
90
+ if (obj.name !== undefined) out.name = obj.name;
91
+ if (obj.location !== undefined) out.location = obj.location;
92
+ if (obj.sku !== undefined) out.sku = obj.sku;
93
+ if (obj.kind !== undefined) out.kind = obj.kind;
94
+ if (obj.identity !== undefined) out.identity = obj.identity;
95
+ if (obj.tags !== undefined) out.tags = obj.tags;
96
+ if (obj.zones !== undefined) out.zones = obj.zones;
97
+ if (obj.plan !== undefined) out.plan = obj.plan;
98
+ if (obj.properties && typeof obj.properties === "object") {
99
+ for (const [k, v] of Object.entries(obj.properties)) {
100
+ if (v !== undefined) out[k] = v;
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // ── Noise rules (#1086) ──────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Server-populated wherever they appear — matched on the final path segment,
110
+ * the same technique AWS's `AWS_READ_ONLY_NAMES` uses, because ARM repeats
111
+ * `provisioningState` at every resource-properties depth and a per-type list
112
+ * of full paths would be a maintenance trap. Deliberately excludes ambiguous
113
+ * names like `id`/`name`: a subnet's `properties.routeTable.id` is a declared
114
+ * cross-reference, and pruning it is how a normalization pass starts hiding
115
+ * real drift. (The resource's *own* envelope `id` is handled separately —
116
+ * see the module doc — precisely so this list never has to touch `id` at all.)
117
+ */
118
+ export const AZURE_READ_ONLY_NAMES: ReadonlySet<string> = new Set([
119
+ "provisioningState",
120
+ "resourceGuid",
121
+ "principalId",
122
+ "tenantId",
123
+ ]);
124
+
125
+ /**
126
+ * Server-injected boilerplate no chant user ever declares nor could
127
+ * meaningfully override — Azure stamps every network security group with
128
+ * these regardless of what was requested, so unlike `AZURE_READ_ONLY_NAMES`
129
+ * this is dropped outright rather than compared.
130
+ */
131
+ const AZURE_UNCONDITIONAL_PRUNE_NAMES: ReadonlySet<string> = new Set(["defaultSecurityRules"]);
132
+
133
+ /**
134
+ * ARM service defaults, per type, as index-erased property paths. Subtracted
135
+ * only where source never declared the property — cdk-real-drift's default
136
+ * subtraction, same as AWS's `AWS_SERVICE_DEFAULTS`. Sparse and evidence-based
137
+ * rather than exhaustive across 1900+ ARM types: widening this table is
138
+ * additive and needs no contract change.
139
+ */
140
+ export const AZURE_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
141
+ "Microsoft.Storage/storageAccounts": {
142
+ minimumTlsVersion: "TLS1_2",
143
+ },
144
+ };
145
+
146
+ /** Arrays known to be ARM sets (server order is not authoring order), keyed by well-known ARM naming conventions. */
147
+ const AZURE_SET_ARRAY_NAMES: ReadonlySet<string> = new Set([
148
+ "securityRules",
149
+ "subnets",
150
+ "ipRules",
151
+ "virtualNetworkRules",
152
+ "addressPrefixes",
153
+ "dnsServers",
154
+ "zones",
155
+ ]);
156
+
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return typeof value === "object" && value !== null && !Array.isArray(value);
159
+ }
160
+
161
+ function isEmptyRecord(value: unknown): boolean {
162
+ return isRecord(value) && Object.keys(value).length === 0;
163
+ }
164
+
165
+ /** Stable JSON with sorted keys — the fallback ordering key for a set-like array without a `name` field. */
166
+ function canonicalJson(value: unknown): string {
167
+ return (
168
+ JSON.stringify(value, (_k, v: unknown) =>
169
+ v && typeof v === "object" && !Array.isArray(v)
170
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
171
+ : v,
172
+ ) ?? ""
173
+ );
174
+ }
175
+
176
+ /** The final segment of an index-erased pattern (`properties.networkAcls.ipRules[]` → `ipRules`). */
177
+ function lastSegment(pattern: string): string {
178
+ const withoutIndex = pattern.replace(/\[\]$/, "");
179
+ const dot = withoutIndex.lastIndexOf(".");
180
+ return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
181
+ }
182
+
183
+ /**
184
+ * The azure lexicon's noise rules. Three classes, same as AWS: server-
185
+ * populated fields (by name, unconditional), controller-injected boilerplate
186
+ * (by name, unconditional), and ARM service defaults (gated on
187
+ * `counterpart === "absent"`) — plus tag-map defaults, which AWS has no
188
+ * equivalent for since its `Tags` is an array rather than a plain object (an
189
+ * ARM tag map's key order is already canonicalized unconditionally by core,
190
+ * since it is a plain object; only the *default empty map* needs a rule here).
191
+ */
192
+ export const azureDeepNormalizationHooks: DeepNormalizationHooks = {
193
+ prune(node: DeepNode): boolean {
194
+ const name = lastSegment(node.pattern);
195
+ if (AZURE_READ_ONLY_NAMES.has(name) || AZURE_UNCONDITIONAL_PRUNE_NAMES.has(name)) return true;
196
+
197
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
198
+
199
+ // ARM always returns *some* tag map; an empty one when nobody declared any
200
+ // tag at all is the default, not a finding.
201
+ if (node.pattern === "tags" && isEmptyRecord(node.value)) return true;
202
+
203
+ const defaults = AZURE_SERVICE_DEFAULTS[node.entityType];
204
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
205
+ return canonicalJson(defaults[node.pattern]) === canonicalJson(node.value);
206
+ },
207
+
208
+ /**
209
+ * The key doubles as a path segment (`securityRules[#allow-ssh]`), so a
210
+ * `name` field is the element's own identity where ARM gives one — which it
211
+ * does for essentially every nested rule/subnet/range collection — and
212
+ * canonical JSON only as a fallback for primitive-valued sets
213
+ * (`addressPrefixes`, `dnsServers`).
214
+ */
215
+ orderKey(element: DeepArrayElement): string | undefined {
216
+ const name = lastSegment(element.pattern);
217
+ if (!AZURE_SET_ARRAY_NAMES.has(name)) return undefined;
218
+ const el = element.element;
219
+ if (isRecord(el)) {
220
+ const key = typeof el.name === "string" ? el.name : undefined;
221
+ return key ?? canonicalJson(el);
222
+ }
223
+ return typeof el === "string" ? el : canonicalJson(el);
224
+ },
225
+ };
226
+
227
+ // ── The reader ───────────────────────────────────────────────────────────────
228
+
229
+ export interface AzureDeepObserveOptions {
230
+ environment: string;
231
+ entityNames: string[];
232
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
233
+ }
234
+
235
+ /**
236
+ * Read the live property tree for each declared entity via `az resource
237
+ * show`. One call per entity, same as the thin path — ARM has no bulk
238
+ * "describe everything in this group" call with per-resource depth the way
239
+ * CloudFormation's `describe-stack-resources` does, so there is no cheap
240
+ * list-then-describe split to make here the way AWS's or temporal's readers
241
+ * do.
242
+ */
243
+ export async function observeResourcesDeepAzure(
244
+ options: AzureDeepObserveOptions,
245
+ ): Promise<DeepObservationResult> {
246
+ const resources: Record<string, DeepResourceObservation> = {};
247
+ const unobserved: Record<string, UnobservedEntity> = {};
248
+
249
+ for (const [entityName, { entityType, props }] of options.entities) {
250
+ if (!entityType.startsWith("Microsoft.")) {
251
+ unobserved[entityName] = {
252
+ type: entityType,
253
+ reason: "unsupported-kind",
254
+ detail: "not an ARM resource type (expected Microsoft.<provider>/<kind>)",
255
+ };
256
+ continue;
257
+ }
258
+
259
+ if (!isTopLevelType(entityType)) {
260
+ unobserved[entityName] = {
261
+ type: entityType,
262
+ reason: "unsupported-kind",
263
+ detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
264
+ };
265
+ continue;
266
+ }
267
+
268
+ const name = props.name as string | undefined;
269
+ if (!name) {
270
+ unobserved[entityName] = {
271
+ type: entityType,
272
+ reason: "read-failed",
273
+ detail: "declared entity has no name to query by",
274
+ };
275
+ continue;
276
+ }
277
+
278
+ const cmd = [
279
+ "az", "resource", "show",
280
+ "--resource-group", options.environment,
281
+ "--name", name,
282
+ "--resource-type", entityType,
283
+ "-o", "json",
284
+ ].join(" ");
285
+
286
+ try {
287
+ const { stdout } = await execAsync(cmd);
288
+ const obj: ArmResourceShowResponse = JSON.parse(stdout);
289
+ resources[entityName] = {
290
+ type: entityType,
291
+ physicalId: obj.id,
292
+ properties: normalizeDeepProperties(buildLiveProperties(obj), {
293
+ entityType,
294
+ side: "live",
295
+ hooks: azureDeepNormalizationHooks,
296
+ }),
297
+ };
298
+ } catch (err) {
299
+ // Not-found leaves the entity out (absence, same as the thin path).
300
+ // Auth/binding/other failures are holes so they can't become creates.
301
+ const outcome = classifyAzFailure(err);
302
+ if (!outcome.absent) {
303
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
304
+ }
305
+ }
306
+ }
307
+
308
+ return deepObservation(resources, unobserved);
309
+ }
@@ -48,8 +48,12 @@ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<strin
48
48
  * Top-level ARM type — i.e. exactly one `/` separating provider from kind.
49
49
  * Nested types like `Microsoft.Storage/storageAccounts/blobServices` need a
50
50
  * different query path that this implementation doesn't yet support.
51
+ *
52
+ * Exported so the deep reader (./deep-observe.ts, #1086) applies the exact
53
+ * same scope limit — a nested type is `unsupported-kind` at both depths, not
54
+ * silently deeper on one and not the other.
51
55
  */
52
- function isTopLevelType(entityType: string): boolean {
56
+ export function isTopLevelType(entityType: string): boolean {
53
57
  const slashCount = (entityType.match(/\//g) ?? []).length;
54
58
  return slashCount === 1;
55
59
  }
package/src/index.ts CHANGED
@@ -10,6 +10,15 @@ export { azureSerializer } from "./serializer";
10
10
  export { azurePlugin } from "./plugin";
11
11
  export { Parameter } from "./parameter";
12
12
 
13
+ // Deep observation (#1086): the `az resource show` reader and the noise
14
+ // rules it shares with core's normalization pass.
15
+ export {
16
+ observeResourcesDeepAzure,
17
+ azureDeepNormalizationHooks,
18
+ AZURE_READ_ONLY_NAMES,
19
+ AZURE_SERVICE_DEFAULTS,
20
+ } from "./deep-observe";
21
+
13
22
  // --- Intrinsics ---
14
23
  export {
15
24
  ResourceId, ResourceIdIntrinsic,
package/src/plugin.ts CHANGED
@@ -19,6 +19,7 @@ import { ArmParser } from "./import/parser";
19
19
  import { ArmGenerator } from "./import/generator";
20
20
  import { azureCompletions } from "./lsp/completions";
21
21
  import { azureHover } from "./lsp/hover";
22
+ import { azureDeepNormalizationHooks, observeResourcesDeepAzure } from "./deep-observe";
22
23
 
23
24
  /**
24
25
  * Azure Resource Manager lexicon plugin.
@@ -459,6 +460,22 @@ export const tags = defaultTags([
459
460
  return describeResources(options);
460
461
  },
461
462
 
463
+ /**
464
+ * Property-level live read (#1086) — `az resource show` already returns
465
+ * the full ARM resource; this normalizes it. Implementation in
466
+ * ./deep-observe.ts.
467
+ */
468
+ async observeResourcesDeep(options) {
469
+ return observeResourcesDeepAzure({
470
+ environment: options.environment,
471
+ entityNames: options.entityNames,
472
+ entities: options.entities,
473
+ });
474
+ },
475
+
476
+ /** The noise rules the deep pass applies to both the live and declared trees. */
477
+ deepNormalizationHooks: azureDeepNormalizationHooks,
478
+
462
479
  async exportResources(options) {
463
480
  const { exportResources } = await import("./export-resources");
464
481
  return exportResources(options);