@intentius/chant-lexicon-azure 0.34.1 → 0.37.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,53 @@
1
+ /**
2
+ * The native Azure read transport (#1212) — the read half of what
3
+ * `op/activities/az-apply.ts` already does for writes.
4
+ *
5
+ * Azure's observers shelled `az resource show`, once per declared entity,
6
+ * serially, and read the failure out of the CLI's stderr. The applier does
7
+ * not: `azApply` PUTs each resource at its ARM URL over `fetch`, honours an
8
+ * endpoint override, and is injectable for tests. Since `az resource show`
9
+ * returns the ARM JSON that endpoint would have returned anyway, this is
10
+ * transport only — the payload the readers normalize is unchanged, which is
11
+ * what made Azure the cheapest of the three observers to move.
12
+ *
13
+ * ## Signing
14
+ *
15
+ * Requests are unsigned, exactly like `azApply`'s. That is what makes the
16
+ * emulator lane work with no credential plumbing, and the reason this is
17
+ * scoped to floci-az rather than announced as a real-ARM read path: real ARM
18
+ * wants a bearer token. The `az` CLI path remains for a signed read until
19
+ * token acquisition lands here.
20
+ */
21
+ import { type AzHttp } from "../op/activities/az-apply.js";
22
+ export interface AzureReadClientOptions {
23
+ /** Endpoint override (floci-az `http://localhost:4577`). Omit for real ARM. */
24
+ endpoint?: string;
25
+ subscriptionId?: string;
26
+ /** Resource group the environment maps to. */
27
+ resourceGroup: string;
28
+ http?: AzHttp;
29
+ signal?: AbortSignal;
30
+ }
31
+ /** A failed read, carrying the status and ARM's own error code where it sent one. */
32
+ export declare class AzureReadError extends Error {
33
+ readonly status: number;
34
+ readonly code?: string | undefined;
35
+ constructor(message: string, status: number, code?: string | undefined);
36
+ }
37
+ /** True when the read failed only because the resource is not there. */
38
+ export declare function isNotFound(err: unknown): boolean;
39
+ /** The ARM URL for one resource in the group — the same shape the applier PUTs to. */
40
+ export declare function armResourceReadUrl(options: AzureReadClientOptions, type: string, name: string, apiVersion?: string): string;
41
+ /** One resource as ARM returns it. */
42
+ export interface ArmResourceBody {
43
+ id?: string;
44
+ name?: string;
45
+ type?: string;
46
+ location?: string;
47
+ tags?: Record<string, string>;
48
+ properties?: Record<string, unknown>;
49
+ [key: string]: unknown;
50
+ }
51
+ /** GET one resource. Throws {@link AzureReadError}; 404 means absent, which callers check with {@link isNotFound}. */
52
+ export declare function getResource(options: AzureReadClientOptions, type: string, name: string, apiVersion?: string): Promise<ArmResourceBody>;
53
+ //# sourceMappingURL=read-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-client.d.ts","sourceRoot":"","sources":["../../src/api/read-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,2BAA2B,CAAC;AASxD,MAAM,WAAW,sBAAsB;IACrC,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,qFAAqF;AACrF,qBAAa,cAAe,SAAQ,KAAK;IAGrC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;gBAFtB,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;CAKzB;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEhD;AAgBD,sFAAsF;AACtF,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,sBAAsB,EAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,SAAsB,GAC/B,MAAM,CAIR;AAwBD,sCAAsC;AACtC,MAAM,WAAW,eAAe;IAC9B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,sHAAsH;AACtH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,sBAAsB,EAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,eAAe,CAAC,CAa1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAqEH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAyFH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAM9E"}
@@ -42,9 +42,12 @@
42
42
  *
43
43
  * ## Nothing here talks to real Azure on its own terms
44
44
  *
45
- * `node:child_process` `exec`, exactly like the thin path no ARM SDK, no
46
- * ambient token. Every test replaces `child_process.exec`.
45
+ * ARM over the applier's own transport (`./api/read-client.ts`), exactly like
46
+ * the thin path (#1212) — no CLI, no ARM SDK, no ambient token. The payload is
47
+ * the same ARM JSON `az resource show` was relaying, so the normalization below
48
+ * is untouched by the move.
47
49
  */
50
+ import type { AzHttp } from "./op/activities/az-apply.js";
48
51
  import type { DeepNormalizationHooks, DeepObservationResult } from "@intentius/chant/lexicon";
49
52
  /**
50
53
  * Server-populated wherever they appear — matched on the final path segment,
@@ -76,6 +79,8 @@ export declare const AZURE_SERVICE_DEFAULTS: Record<string, Record<string, unkno
76
79
  */
77
80
  export declare const azureDeepNormalizationHooks: DeepNormalizationHooks;
78
81
  export interface AzureDeepObserveOptions {
82
+ /** Injectable transport, mirroring `azApply`'s — tests reach the reader with no network. */
83
+ http?: AzHttp;
79
84
  environment: string;
80
85
  entityNames: string[];
81
86
  entities: Map<string, {
@@ -1 +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"}
1
+ {"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAGV,sBAAsB,EACtB,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAiDlC;;;;;;;;;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,4FAA4F;IAC5F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,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,CAkEhC"}
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Live introspection of an Azure resource group via the az CLI.
2
+ * Live introspection of an Azure resource group over ARM (#1212).
3
3
  *
4
- * For each declared Azure entity, runs:
5
- * az resource show --resource-group <env> --name <name> --resource-type <type> -o json
4
+ * For each declared Azure entity, GETs
5
+ * {endpoint}/subscriptions/{sub}/resourceGroups/{env}/providers/{type}/{name}
6
6
  *
7
- * and maps the response to a ResourceMetadata entry keyed by chant entity name
7
+ * on the applier's own transport (`./api/read-client.ts`, which is
8
+ * `az-apply.ts`'s client pointed at the read side) rather than shelling
9
+ * `az resource show`. The payload is the same ARM JSON either way — the CLI was
10
+ * only ever relaying it — so this is transport, not translation: no CLI to
11
+ * spawn, reads that run concurrently, failures carrying ARM's own error code,
12
+ * and an emulator override that reaches floci-az the same way every other
13
+ * lexicon's does.
14
+ *
15
+ * The response maps to a ResourceMetadata entry keyed by chant entity name
8
16
  * (using props.name from #39's entity-prop pass-through). The environment
9
17
  * argument is treated as the Azure resource group name.
10
18
  *
@@ -17,6 +25,7 @@
17
25
  * are holes for the same reason.
18
26
  */
19
27
  import type { ObservationResult, UnobservedReason } from "@intentius/chant/lexicon";
28
+ import type { AzHttp } from "./op/activities/az-apply.js";
20
29
  /**
21
30
  * Top-level ARM type — i.e. exactly one `/` separating provider from kind.
22
31
  * Nested types like `Microsoft.Storage/storageAccounts/blobServices` need a
@@ -39,6 +48,19 @@ export declare function classifyAzFailure(err: unknown): {
39
48
  reason: UnobservedReason;
40
49
  detail: string;
41
50
  };
51
+ /**
52
+ * Classify an ARM failure off its own error code (#1212).
53
+ *
54
+ * The CLI classifier above matched on prose because stderr was all it had.
55
+ * ARM sends `{ error: { code, message } }`, so the code is the signal and the
56
+ * message is only for the human — the same distinction the AWS read client
57
+ * makes. Kept beside `classifyAzFailure` rather than replacing it: the CLI
58
+ * path still exists for a signed read against real ARM.
59
+ */
60
+ export declare function classifyArmFailure(err: unknown): {
61
+ reason: UnobservedReason;
62
+ detail: string;
63
+ };
42
64
  export declare function describeResources(options: {
43
65
  environment: string;
44
66
  buildOutput: string;
@@ -47,5 +69,7 @@ export declare function describeResources(options: {
47
69
  entityType: string;
48
70
  props: Record<string, unknown>;
49
71
  }>;
72
+ /** Injectable transport, mirroring `azApply`'s — tests reach the reader with no network. */
73
+ http?: AzHttp;
50
74
  }): Promise<ObservationResult>;
51
75
  //# sourceMappingURL=describe-resources.d.ts.map
@@ -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;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"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AAUvD;;;;;;;;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;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAmB7F;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;IAC9E,4FAA4F;IAC5F,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA+E7B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "a4aa3da45689da7b4702ca100a9aa2ff356a61483329179d5eb69648e185df01",
4
+ "manifest.json": "bc503f53d6bf2deade950968028c37335319c8868a0f7512e260d20f10dbfeed",
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": "ea9d89a3dd9d818fe6dbb3400c696232c2a74e6bf8af1c3af9296ea5fbb9e865"
36
+ "composite": "dff5b3422ce14b471818bb6b2799870cb3ce0d55d6caae6e0fc2164dce02830b"
37
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.34.1",
3
+ "version": "0.37.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.34.1",
3
+ "version": "0.37.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.34.1",
77
+ "@intentius/chant": "^0.37.0",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The native Azure read transport (#1212) — the read half of what
3
+ * `op/activities/az-apply.ts` already does for writes.
4
+ *
5
+ * Azure's observers shelled `az resource show`, once per declared entity,
6
+ * serially, and read the failure out of the CLI's stderr. The applier does
7
+ * not: `azApply` PUTs each resource at its ARM URL over `fetch`, honours an
8
+ * endpoint override, and is injectable for tests. Since `az resource show`
9
+ * returns the ARM JSON that endpoint would have returned anyway, this is
10
+ * transport only — the payload the readers normalize is unchanged, which is
11
+ * what made Azure the cheapest of the three observers to move.
12
+ *
13
+ * ## Signing
14
+ *
15
+ * Requests are unsigned, exactly like `azApply`'s. That is what makes the
16
+ * emulator lane work with no credential plumbing, and the reason this is
17
+ * scoped to floci-az rather than announced as a real-ARM read path: real ARM
18
+ * wants a bearer token. The `az` CLI path remains for a signed read until
19
+ * token acquisition lands here.
20
+ */
21
+
22
+ import { type AzHttp } from "../op/activities/az-apply";
23
+
24
+ /** Default ARM host, matching the applier's. */
25
+ const DEFAULT_ENDPOINT = "https://management.azure.com";
26
+ /** floci-az's fixed local subscription, matching the applier's. */
27
+ const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
28
+ /** Generic ARM resource read, adequate for every top-level type chant declares. */
29
+ const DEFAULT_API_VERSION = "2021-04-01";
30
+
31
+ export interface AzureReadClientOptions {
32
+ /** Endpoint override (floci-az `http://localhost:4577`). Omit for real ARM. */
33
+ endpoint?: string;
34
+ subscriptionId?: string;
35
+ /** Resource group the environment maps to. */
36
+ resourceGroup: string;
37
+ http?: AzHttp;
38
+ signal?: AbortSignal;
39
+ }
40
+
41
+ /** A failed read, carrying the status and ARM's own error code where it sent one. */
42
+ export class AzureReadError extends Error {
43
+ constructor(
44
+ message: string,
45
+ readonly status: number,
46
+ readonly code?: string,
47
+ ) {
48
+ super(message);
49
+ this.name = "AzureReadError";
50
+ }
51
+ }
52
+
53
+ /** True when the read failed only because the resource is not there. */
54
+ export function isNotFound(err: unknown): boolean {
55
+ return err instanceof AzureReadError && err.status === 404;
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ const defaultHttp: AzHttp = async (method, url, body, signal) => {
63
+ const res = await fetch(url, {
64
+ method,
65
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
66
+ body: body === undefined ? undefined : JSON.stringify(body),
67
+ signal,
68
+ });
69
+ return { status: res.status, text: await res.text() };
70
+ };
71
+
72
+ /** The ARM URL for one resource in the group — the same shape the applier PUTs to. */
73
+ export function armResourceReadUrl(
74
+ options: AzureReadClientOptions,
75
+ type: string,
76
+ name: string,
77
+ apiVersion = DEFAULT_API_VERSION,
78
+ ): string {
79
+ const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
80
+ const subscription = options.subscriptionId ?? DEFAULT_SUBSCRIPTION;
81
+ return `${base}/subscriptions/${subscription}/resourceGroups/${options.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
82
+ }
83
+
84
+ /**
85
+ * ARM's error envelope is `{ error: { code, message } }`. Reading the code
86
+ * rather than the prose is the point of moving off the CLI: `ResourceNotFound`
87
+ * is a fact, "(ResourceNotFound) The Resource … was not found" is a sentence
88
+ * that changes between CLI versions.
89
+ */
90
+ function readError(status: number, text: string): AzureReadError {
91
+ let code: string | undefined;
92
+ let message = `ARM read failed with HTTP ${status}`;
93
+ try {
94
+ const body: unknown = JSON.parse(text);
95
+ const error = isRecord(body) ? body.error : undefined;
96
+ if (isRecord(error)) {
97
+ if (typeof error.code === "string") code = error.code;
98
+ if (typeof error.message === "string") message = error.message;
99
+ }
100
+ } catch {
101
+ // A non-JSON body from a proxy or a gateway — the status is all there is.
102
+ }
103
+ return new AzureReadError(message, status, code);
104
+ }
105
+
106
+ /** One resource as ARM returns it. */
107
+ export interface ArmResourceBody {
108
+ id?: string;
109
+ name?: string;
110
+ type?: string;
111
+ location?: string;
112
+ tags?: Record<string, string>;
113
+ properties?: Record<string, unknown>;
114
+ [key: string]: unknown;
115
+ }
116
+
117
+ /** GET one resource. Throws {@link AzureReadError}; 404 means absent, which callers check with {@link isNotFound}. */
118
+ export async function getResource(
119
+ options: AzureReadClientOptions,
120
+ type: string,
121
+ name: string,
122
+ apiVersion?: string,
123
+ ): Promise<ArmResourceBody> {
124
+ const http = options.http ?? defaultHttp;
125
+ const url = armResourceReadUrl(options, type, name, apiVersion);
126
+ const res = await http("GET", url, undefined, options.signal);
127
+ if (res.status >= 300) throw readError(res.status, res.text);
128
+ try {
129
+ const body: unknown = JSON.parse(res.text);
130
+ if (!isRecord(body)) throw new AzureReadError(`unparseable ARM body for ${type}/${name}`, res.status);
131
+ return body as ArmResourceBody;
132
+ } catch (err) {
133
+ if (err instanceof AzureReadError) throw err;
134
+ throw new AzureReadError(`unparseable ARM body for ${type}/${name}`, res.status);
135
+ }
136
+ }
@@ -72,6 +72,26 @@ const docsConfig: DocsConfig = {
72
72
  overview,
73
73
  outputFormat,
74
74
  serviceFromType,
75
+ // `lint-rules` below is the rules documentation; the generated `rules` table
76
+ // would duplicate it and land in no sidebar (#1312). aws, k8s, gitlab and
77
+ // github already suppress it for the same reason.
78
+ suppressPages: ["rules"],
79
+ // These pages are hand-written files under docs/src/content/docs/. The
80
+ // pipeline preserves pages it did not emit, but Starlight has no
81
+ // auto-discovery, so without an entry here they are reachable only by URL —
82
+ // which is what had happened to all ten of them.
83
+ sidebarExtra: [
84
+ { label: "Getting Started", slug: "getting-started" },
85
+ { label: "Resources", slug: "resources" },
86
+ { label: "Parameters & Outputs", slug: "parameters-outputs" },
87
+ { label: "Composites", slug: "composites" },
88
+ { label: "Linked Templates", slug: "linked-templates" },
89
+ { label: "Lint Rules", slug: "lint-rules" },
90
+ { label: "Importing ARM Templates", slug: "importing" },
91
+ { label: "Deploying to AKS", slug: "aks-kubernetes" },
92
+ { label: "Examples", slug: "examples" },
93
+ { label: "AI Skills", slug: "skills" },
94
+ ],
75
95
  };
76
96
 
77
97
  /**
@@ -2,40 +2,51 @@
2
2
  * Azure deep observation (#1086) — the azure row of the deep-observe
3
3
  * contract (#1014).
4
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.
5
+ * The transport is faked, exactly like describe-resources.test.ts (#1212) — no
6
+ * CLI, no ARM SDK, no ambient `az login` session, nothing reaches a network.
7
+ * `http` is injected where the reader is called directly, and `fetch` is
8
+ * stubbed where the plugin builds its own client.
8
9
  */
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
- });
10
+ import { describe, test, expect, vi, afterEach } from "vitest";
11
+
12
+ /** For the paths that go through the plugin, which builds its own transport. */
13
+ const stubArmFetch = (response: { status: number; text: string }): void => {
14
+ vi.spyOn(globalThis, "fetch").mockImplementation((async () => ({
15
+ status: response.status,
16
+ text: () => Promise.resolve(response.text),
17
+ })) as unknown as typeof fetch);
18
+ };
24
19
 
25
20
  const { azurePlugin } = await import("./plugin");
26
21
  const { observeResourcesDeepAzure, azureDeepNormalizationHooks } = await import("./deep-observe");
27
22
  const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
28
23
  const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
29
24
 
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 }));
25
+ /** An ARM 200, in place of the CLI stdout this used to fake (#1212). */
26
+ const ok = (body: Record<string, unknown>) => ({ status: 200, text: JSON.stringify(body) });
27
+ /** ARM's error envelope — the code is the signal, not the prose. */
28
+ const armError = (status: number, code: string, message = code) => ({
29
+ status,
30
+ text: JSON.stringify({ error: { code, message } }),
31
+ });
32
+
33
+ /** Routes by the resource name in the ARM URL, which is where it lives now. */
34
+ function armFake(route: (name: string) => { status: number; text: string }) {
35
+ const urls: string[] = [];
36
+ const http = async (_method: string, url: string) => {
37
+ urls.push(url);
38
+ const name = decodeURIComponent(url.split("?")[0].split("/").pop() ?? "");
39
+ return route(name);
40
+ };
41
+ return { http, urls };
42
+ }
32
43
 
33
44
  const entities = (
34
45
  record: Record<string, { entityType: string; props: Record<string, unknown> }>,
35
46
  ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
36
47
 
37
- beforeEach(() => {
38
- execMock.mockReset();
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
39
50
  });
40
51
 
41
52
  describe("the azure noise rules", () => {
@@ -126,11 +137,9 @@ describe("the azure noise rules", () => {
126
137
  });
127
138
 
128
139
  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({
140
+ test("reads the resource over ARM and flattens properties.* onto the top level", async () => {
141
+ const fake = armFake(() =>
142
+ ok({
134
143
  id: "/subscriptions/sub/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/mydata",
135
144
  name: "mydata",
136
145
  type: "Microsoft.Storage/storageAccounts",
@@ -139,21 +148,20 @@ describe("observeResourcesDeepAzure", () => {
139
148
  etag: "\"abc\"",
140
149
  systemData: { createdBy: "someone@example.com" },
141
150
  properties: { provisioningState: "Succeeded", minimumTlsVersion: "TLS1_2", allowBlobPublicAccess: false },
142
- });
143
- });
151
+ }),
152
+ );
144
153
 
145
154
  const result = normalizeDeepObservation(
146
155
  await observeResourcesDeepAzure({
147
156
  environment: "prod-rg",
148
157
  entityNames: ["dataAccount"],
149
158
  entities: entities({ dataAccount: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mydata" } } }),
159
+ http: fake.http,
150
160
  }),
151
161
  );
152
162
 
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");
163
+ expect(fake.urls[0]).toContain("/resourceGroups/prod-rg/");
164
+ expect(fake.urls[0]).toContain("/providers/Microsoft.Storage/storageAccounts/mydata");
157
165
 
158
166
  // id/type/etag/systemData/provisioningState never reach the tree at all —
159
167
  // no declared tree yet, so this is what a one-sided read looks like.
@@ -165,24 +173,26 @@ describe("observeResourcesDeepAzure", () => {
165
173
  });
166
174
 
167
175
  test("a resource not found leaves the entity out — a confirmed absence", async () => {
168
- execMock.mockImplementation(() => fail("ResourceNotFound: ..."));
176
+ const fake = armFake(() => armError(404, "ResourceNotFound", "not found"));
169
177
  const result = normalizeDeepObservation(
170
178
  await observeResourcesDeepAzure({
171
179
  environment: "prod-rg",
172
180
  entityNames: ["missing"],
173
181
  entities: entities({ missing: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "missing" } } }),
182
+ http: fake.http,
174
183
  }),
175
184
  );
176
185
  expect(result).toEqual({ resources: {}, unobserved: {} });
177
186
  });
178
187
 
179
- test("an expired az login is a hole, not an absence", async () => {
180
- execMock.mockImplementation(() => fail("Please run 'az login' to setup account."));
188
+ test("a refused credential is a hole, not an absence", async () => {
189
+ const fake = armFake(() => armError(401, "AuthenticationFailed", "Authentication failed."));
181
190
  const result = normalizeDeepObservation(
182
191
  await observeResourcesDeepAzure({
183
192
  environment: "prod-rg",
184
193
  entityNames: ["acct"],
185
194
  entities: entities({ acct: { entityType: "Microsoft.Storage/storageAccounts", props: { name: "acct" } } }),
195
+ http: fake.http,
186
196
  }),
187
197
  );
188
198
  expect(result.resources).toEqual({});
@@ -190,40 +200,46 @@ describe("observeResourcesDeepAzure", () => {
190
200
  });
191
201
 
192
202
  test("a nested ARM type is unsupported-kind, never absent — az resource show is never called", async () => {
203
+ const fake = armFake(() => ok({}));
193
204
  const result = normalizeDeepObservation(
194
205
  await observeResourcesDeepAzure({
195
206
  environment: "prod-rg",
196
207
  entityNames: ["nested"],
197
208
  entities: entities({ nested: { entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "default" } } }),
209
+ http: fake.http,
198
210
  }),
199
211
  );
200
212
  expect(result.resources).toEqual({});
201
213
  expect(result.unobserved.nested.reason).toBe("unsupported-kind");
202
- expect(execMock).not.toHaveBeenCalled();
214
+ expect(fake.urls).toEqual([]);
203
215
  });
204
216
 
205
217
  test("a non-ARM entity type is unsupported-kind — no ARM type to query", async () => {
218
+ const fake = armFake(() => ok({}));
206
219
  const result = normalizeDeepObservation(
207
220
  await observeResourcesDeepAzure({
208
221
  environment: "prod-rg",
209
222
  entityNames: ["x"],
210
223
  entities: entities({ x: { entityType: "AWS::S3::Bucket", props: { name: "x" } } }),
224
+ http: fake.http,
211
225
  }),
212
226
  );
213
227
  expect(result.unobserved.x.reason).toBe("unsupported-kind");
214
- expect(execMock).not.toHaveBeenCalled();
228
+ expect(fake.urls).toEqual([]);
215
229
  });
216
230
 
217
231
  test("an entity with no name is a hole — nothing was queried", async () => {
232
+ const fake = armFake(() => ok({}));
218
233
  const result = normalizeDeepObservation(
219
234
  await observeResourcesDeepAzure({
220
235
  environment: "prod-rg",
221
236
  entityNames: ["broken"],
222
237
  entities: entities({ broken: { entityType: "Microsoft.Storage/storageAccounts", props: {} } }),
238
+ http: fake.http,
223
239
  }),
224
240
  );
225
241
  expect(result.unobserved.broken.reason).toBe("read-failed");
226
- expect(execMock).not.toHaveBeenCalled();
242
+ expect(fake.urls).toEqual([]);
227
243
  });
228
244
  });
229
245
 
@@ -251,9 +267,12 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
251
267
  });
252
268
 
253
269
  const wireMocks = (): void => {
254
- execMock.mockImplementation((cmd: string) => {
255
- if (cmd.includes("--name mydata")) {
256
- return ok({
270
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (url: string) => {
271
+ const name = decodeURIComponent(url.split("?")[0].split("/").pop() ?? "");
272
+ const respond = (r: { status: number; text: string }) =>
273
+ ({ status: r.status, text: () => Promise.resolve(r.text) });
274
+ if (name === "mydata") {
275
+ return respond(ok({
257
276
  id: "/subscriptions/sub/resourceGroups/prod/providers/Microsoft.Storage/storageAccounts/mydata",
258
277
  name: "mydata",
259
278
  location: "eastus",
@@ -269,10 +288,10 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
269
288
  // GENUINE: somebody flipped this in the portal.
270
289
  allowBlobPublicAccess: true,
271
290
  },
272
- });
291
+ }));
273
292
  }
274
- if (cmd.includes("--name core-vnet")) {
275
- return ok({
293
+ if (name === "core-vnet") {
294
+ return respond(ok({
276
295
  id: "/subscriptions/sub/resourceGroups/prod/providers/Microsoft.Network/virtualNetworks/core-vnet",
277
296
  name: "core-vnet",
278
297
  location: "eastus",
@@ -282,13 +301,13 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
282
301
  // NOISE: same two prefixes, different order.
283
302
  addressSpace: { addressPrefixes: ["10.1.0.0/16", "10.0.0.0/16"] },
284
303
  },
285
- });
304
+ }));
286
305
  }
287
- if (cmd.includes("--name secure-acct")) {
288
- return fail("Please run 'az login' to setup account.");
306
+ if (name === "secure-acct") {
307
+ return respond(armError(401, "AuthenticationFailed", "Authentication failed."));
289
308
  }
290
- return fail("unexpected call");
291
- });
309
+ return respond(armError(400, "BadRequest", `unexpected call for ${name}`));
310
+ }) as unknown as typeof fetch);
292
311
  };
293
312
 
294
313
  const baseline = {
@@ -331,9 +350,9 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
331
350
  name: "blobServices",
332
351
  type: "Microsoft.Storage/storageAccounts/blobServices",
333
352
  reason: "unsupported-kind",
334
- detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
353
+ detail: "a nested ARM type needs a different read path; chant never queried this resource",
335
354
  },
336
- { name: "secureAcct", type: "Microsoft.Storage/storageAccounts", reason: "no-credentials", detail: "Please run 'az login' to setup account." },
355
+ { name: "secureAcct", type: "Microsoft.Storage/storageAccounts", reason: "no-credentials", detail: "AuthenticationFailed: Authentication failed." },
337
356
  ]);
338
357
  });
339
358
 
@@ -358,15 +377,19 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
358
377
  });
359
378
 
360
379
  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"));
380
+ stubArmFetch(armError(401, "AuthenticationFailed", "Authentication failed."));
362
381
  const result = await deepDiffForLexicon(azurePlugin, { environment: "prod", buildOutput: "", entities: declared });
363
382
  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.
383
+ // blobServices is still unsupported-kind — it is never addressed at all, so
384
+ // a refused transport doesn't change its verdict.
366
385
  expect(result.unobserved.map((u) => u.name).sort()).toEqual(["blobServices", "dataAccount", "secureAcct", "vnet"]);
367
386
  expect(result.unobserved.find((u) => u.name === "blobServices")?.reason).toBe("unsupported-kind");
387
+ // `no-credentials`, not the `read-failed` the CLI path reported: ARM sends
388
+ // `AuthenticationFailed` as a code, where the CLI's prose ("Unable to
389
+ // locate credentials") matched none of the patterns and fell through to the
390
+ // generic verdict. The code makes the credential case legible (#1212).
368
391
  expect(
369
- result.unobserved.filter((u) => u.name !== "blobServices").every((u) => u.reason === "read-failed"),
392
+ result.unobserved.filter((u) => u.name !== "blobServices").every((u) => u.reason === "no-credentials"),
370
393
  ).toBe(true);
371
394
  });
372
395
  });
@@ -42,12 +42,15 @@
42
42
  *
43
43
  * ## Nothing here talks to real Azure on its own terms
44
44
  *
45
- * `node:child_process` `exec`, exactly like the thin path no ARM SDK, no
46
- * ambient token. Every test replaces `child_process.exec`.
45
+ * ARM over the applier's own transport (`./api/read-client.ts`), exactly like
46
+ * the thin path (#1212) — no CLI, no ARM SDK, no ambient token. The payload is
47
+ * the same ARM JSON `az resource show` was relaying, so the normalization below
48
+ * is untouched by the move.
47
49
  */
48
50
 
49
- import { exec } from "node:child_process";
50
- import { promisify } from "node:util";
51
+ import { boundedConcurrently } from "@intentius/chant/observation";
52
+ import { getResource, isNotFound, type AzureReadClientOptions } from "./api/read-client";
53
+ import type { AzHttp } from "./op/activities/az-apply";
51
54
  import type {
52
55
  DeepArrayElement,
53
56
  DeepNode,
@@ -57,9 +60,8 @@ import type {
57
60
  UnobservedEntity,
58
61
  } from "@intentius/chant/lexicon";
59
62
  import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
60
- import { classifyAzFailure, isTopLevelType } from "./describe-resources";
63
+ import { classifyArmFailure, isTopLevelType } from "./describe-resources";
61
64
 
62
- const execAsync = promisify(exec);
63
65
 
64
66
  /** The full `az resource show -o json` shape this reader reads (a superset of the thin path's `AzResourceShowResponse`). */
65
67
  interface ArmResourceShowResponse {
@@ -227,6 +229,8 @@ export const azureDeepNormalizationHooks: DeepNormalizationHooks = {
227
229
  // ── The reader ───────────────────────────────────────────────────────────────
228
230
 
229
231
  export interface AzureDeepObserveOptions {
232
+ /** Injectable transport, mirroring `azApply`'s — tests reach the reader with no network. */
233
+ http?: AzHttp;
230
234
  environment: string;
231
235
  entityNames: string[];
232
236
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
@@ -245,6 +249,14 @@ export async function observeResourcesDeepAzure(
245
249
  ): Promise<DeepObservationResult> {
246
250
  const resources: Record<string, DeepResourceObservation> = {};
247
251
  const unobserved: Record<string, UnobservedEntity> = {};
252
+ const readable: Array<{ entityName: string; entityType: string; name: string }> = [];
253
+
254
+ const client: AzureReadClientOptions = {
255
+ resourceGroup: options.environment,
256
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
257
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
258
+ ...(options.http ? { http: options.http } : {}),
259
+ };
248
260
 
249
261
  for (const [entityName, { entityType, props }] of options.entities) {
250
262
  if (!entityType.startsWith("Microsoft.")) {
@@ -260,7 +272,7 @@ export async function observeResourcesDeepAzure(
260
272
  unobserved[entityName] = {
261
273
  type: entityType,
262
274
  reason: "unsupported-kind",
263
- detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
275
+ detail: "a nested ARM type needs a different read path; chant never queried this resource",
264
276
  };
265
277
  continue;
266
278
  }
@@ -275,17 +287,12 @@ export async function observeResourcesDeepAzure(
275
287
  continue;
276
288
  }
277
289
 
278
- const cmd = [
279
- "az", "resource", "show",
280
- "--resource-group", options.environment,
281
- "--name", name,
282
- "--resource-type", entityType,
283
- "-o", "json",
284
- ].join(" ");
290
+ readable.push({ entityName, entityType, name });
291
+ }
285
292
 
293
+ await boundedConcurrently(readable, async ({ entityName, entityType, name }) => {
286
294
  try {
287
- const { stdout } = await execAsync(cmd);
288
- const obj: ArmResourceShowResponse = JSON.parse(stdout);
295
+ const obj = (await getResource(client, entityType, name)) as ArmResourceShowResponse;
289
296
  resources[entityName] = {
290
297
  type: entityType,
291
298
  physicalId: obj.id,
@@ -298,12 +305,11 @@ export async function observeResourcesDeepAzure(
298
305
  } catch (err) {
299
306
  // Not-found leaves the entity out (absence, same as the thin path).
300
307
  // 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
- }
308
+ if (isNotFound(err)) return;
309
+ const outcome = classifyArmFailure(err);
310
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
305
311
  }
306
- }
312
+ });
307
313
 
308
314
  return deepObservation(resources, unobserved);
309
315
  }
@@ -1,54 +1,53 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
2
-
3
- const execMock = vi.fn();
4
- vi.mock("node:child_process", async () => {
5
- const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
6
- return { ...actual, exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
7
- Promise.resolve(execMock(cmd)).then(
8
- (out) => cb(null, out),
9
- (err) => cb(err as Error, { stdout: "", stderr: "" }),
10
- );
11
- } };
12
- });
1
+ import { describe, test, expect, vi } from "vitest";
13
2
 
14
3
  const { describeResources } = await import("./describe-resources");
15
4
 
5
+ /** An ARM response, in place of the CLI stdout this used to fake (#1212). */
6
+ const armOk = (body: unknown) => ({ status: 200, text: JSON.stringify(body) });
7
+ /** ARM's error envelope: the code is the signal, the message is for the human. */
8
+ const armError = (status: number, code: string, message: string) => ({
9
+ status,
10
+ text: JSON.stringify({ error: { code, message } }),
11
+ });
12
+
13
+ /** Records every ARM URL the reader asked for, so a test can assert the address. */
14
+ function httpFake(respond: (url: string) => { status: number; text: string }) {
15
+ const urls: string[] = [];
16
+ const http = async (_method: string, url: string) => {
17
+ urls.push(url);
18
+ return respond(url);
19
+ };
20
+ return { http, urls };
21
+ }
22
+
16
23
  function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
17
24
  return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
18
25
  }
19
26
 
20
27
  describe("azure describeResources", () => {
21
- beforeEach(() => {
22
- execMock.mockReset();
23
- });
24
-
25
28
  test("queries az resource show with rg + name + type and maps response", async () => {
26
- let receivedCmd = "";
27
- execMock.mockImplementation((cmd: string) => {
28
- receivedCmd = cmd;
29
- return {
30
- stdout: JSON.stringify({
29
+ const fake = httpFake(() =>
30
+ armOk({
31
31
  id: "/subscriptions/sub/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/mydata",
32
32
  name: "mydata",
33
33
  type: "Microsoft.Storage/storageAccounts",
34
34
  location: "eastus",
35
35
  properties: { provisioningState: "Succeeded" },
36
- tags: { env: "prod" },
37
- }),
38
- stderr: "",
39
- };
40
- });
36
+ tags: { env: "prod" },
37
+ }),
38
+ );
41
39
 
42
40
  const entities = makeEntities([
43
41
  { name: "dataAccount", entityType: "Microsoft.Storage/storageAccounts", props: { name: "mydata" } },
44
42
  ]);
45
43
 
46
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["dataAccount"], entities });
44
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["dataAccount"], entities, http: fake.http });
47
45
 
48
- expect(receivedCmd).toContain("az resource show");
49
- expect(receivedCmd).toContain("--resource-group prod-rg");
50
- expect(receivedCmd).toContain("--name mydata");
51
- expect(receivedCmd).toContain("--resource-type Microsoft.Storage/storageAccounts");
46
+ // The resource group is the environment, and the type and name address the
47
+ // resource — the same URL shape the applier PUTs to.
48
+ expect(fake.urls[0]).toContain("/resourceGroups/prod-rg/");
49
+ expect(fake.urls[0]).toContain("/providers/Microsoft.Storage/storageAccounts/mydata");
50
+ expect(fake.urls[0]).toContain("api-version=");
52
51
 
53
52
  expect(result.resources["dataAccount"]).toMatchObject({
54
53
  type: "Microsoft.Storage/storageAccounts",
@@ -59,45 +58,42 @@ describe("azure describeResources", () => {
59
58
  });
60
59
 
61
60
  test("missing provisioningState falls back to PRESENT", async () => {
62
- execMock.mockResolvedValue({
63
- stdout: JSON.stringify({ id: "id", name: "x", type: "Microsoft.Network/virtualNetworks", location: "eastus", properties: {} }),
64
- stderr: "",
65
- });
61
+ const fake = httpFake(() =>
62
+ armOk({ id: "id", name: "x", type: "Microsoft.Network/virtualNetworks", location: "eastus", properties: {} }),
63
+ );
66
64
 
67
65
  const entities = makeEntities([
68
66
  { name: "vnet", entityType: "Microsoft.Network/virtualNetworks", props: { name: "x" } },
69
67
  ]);
70
68
 
71
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["vnet"], entities });
69
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["vnet"], entities, http: fake.http });
72
70
 
73
71
  expect(result.resources["vnet"].status).toBe("PRESENT");
74
72
  });
75
73
 
76
74
  test("az failure (resource not found) leaves entity out — a confirmed absence", async () => {
77
- execMock.mockImplementation(() => { throw new Error("ResourceNotFound: ..."); });
75
+ const fake = httpFake(() => armError(404, "ResourceNotFound", "The Resource was not found."));
78
76
 
79
77
  const entities = makeEntities([
80
78
  { name: "missing", entityType: "Microsoft.Storage/storageAccounts", props: { name: "missing" } },
81
79
  ]);
82
80
 
83
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["missing"], entities });
81
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["missing"], entities, http: fake.http });
84
82
 
85
83
  expect(result.resources).toEqual({});
86
84
  expect(result.unobserved ?? {}).toEqual({});
87
85
  });
88
86
 
89
- test("an expired az login is unobserved, not absent (#1089)", async () => {
90
- execMock.mockImplementation(() => {
91
- throw Object.assign(new Error("az failed"), {
92
- stderr: "Please run 'az login' to setup account.",
93
- });
94
- });
87
+ test("a refused credential is unobserved, not absent (#1089)", async () => {
88
+ const fake = httpFake(() =>
89
+ armError(401, "AuthenticationFailed", "Authentication failed. The 'Authorization' header is missing."),
90
+ );
95
91
 
96
92
  const entities = makeEntities([
97
93
  { name: "acct", entityType: "Microsoft.Storage/storageAccounts", props: { name: "acct" } },
98
94
  ]);
99
95
 
100
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["acct"], entities });
96
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["acct"], entities, http: fake.http });
101
97
 
102
98
  expect(result.resources).toEqual({});
103
99
  expect(result.unobserved?.acct?.reason).toBe("no-credentials");
@@ -105,11 +101,12 @@ describe("azure describeResources", () => {
105
101
 
106
102
  test("nested-type entities are unobserved, not absent (#1089)", async () => {
107
103
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
104
+ const fake = httpFake(() => armOk({}));
108
105
  const entities = makeEntities([
109
106
  { name: "nested", entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "x" } },
110
107
  ]);
111
108
 
112
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["nested"], entities });
109
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["nested"], entities, http: fake.http });
113
110
 
114
111
  expect(result.resources).toEqual({});
115
112
  expect(result.unobserved?.nested).toMatchObject({
@@ -117,31 +114,33 @@ describe("azure describeResources", () => {
117
114
  reason: "unsupported-kind",
118
115
  });
119
116
  expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("nested-type"));
120
- expect(execMock).not.toHaveBeenCalled();
117
+ expect(fake.urls).toEqual([]);
121
118
  warnSpy.mockRestore();
122
119
  });
123
120
 
124
121
  test("non-Azure entity types are unobserved — no ARM type to query", async () => {
122
+ const fake = httpFake(() => armOk({}));
125
123
  const entities = makeEntities([
126
124
  { name: "x", entityType: "AWS::S3::Bucket", props: { name: "x" } },
127
125
  ]);
128
126
 
129
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["x"], entities });
127
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["x"], entities, http: fake.http });
130
128
 
131
129
  expect(result.resources).toEqual({});
132
130
  expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
133
- expect(execMock).not.toHaveBeenCalled();
131
+ expect(fake.urls).toEqual([]);
134
132
  });
135
133
 
136
134
  test("entity without name is unobserved — nothing was queried", async () => {
135
+ const fake = httpFake(() => armOk({}));
137
136
  const entities = makeEntities([
138
137
  { name: "broken", entityType: "Microsoft.Storage/storageAccounts", props: {} },
139
138
  ]);
140
139
 
141
- const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["broken"], entities });
140
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["broken"], entities, http: fake.http });
142
141
 
143
142
  expect(result.resources).toEqual({});
144
143
  expect(result.unobserved?.broken?.reason).toBe("read-failed");
145
- expect(execMock).not.toHaveBeenCalled();
144
+ expect(fake.urls).toEqual([]);
146
145
  });
147
146
  });
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Live introspection of an Azure resource group via the az CLI.
2
+ * Live introspection of an Azure resource group over ARM (#1212).
3
3
  *
4
- * For each declared Azure entity, runs:
5
- * az resource show --resource-group <env> --name <name> --resource-type <type> -o json
4
+ * For each declared Azure entity, GETs
5
+ * {endpoint}/subscriptions/{sub}/resourceGroups/{env}/providers/{type}/{name}
6
6
  *
7
- * and maps the response to a ResourceMetadata entry keyed by chant entity name
7
+ * on the applier's own transport (`./api/read-client.ts`, which is
8
+ * `az-apply.ts`'s client pointed at the read side) rather than shelling
9
+ * `az resource show`. The payload is the same ARM JSON either way — the CLI was
10
+ * only ever relaying it — so this is transport, not translation: no CLI to
11
+ * spawn, reads that run concurrently, failures carrying ARM's own error code,
12
+ * and an emulator override that reaches floci-az the same way every other
13
+ * lexicon's does.
14
+ *
15
+ * The response maps to a ResourceMetadata entry keyed by chant entity name
8
16
  * (using props.name from #39's entity-prop pass-through). The environment
9
17
  * argument is treated as the Azure resource group name.
10
18
  *
@@ -17,24 +25,10 @@
17
25
  * are holes for the same reason.
18
26
  */
19
27
 
20
- import { exec } from "node:child_process";
21
- import { promisify } from "node:util";
22
28
  import type { ObservationResult, ResourceMetadata, UnobservedEntity, UnobservedReason } from "@intentius/chant/lexicon";
23
- import { observation } from "@intentius/chant/observation";
24
-
25
- const execAsync = promisify(exec);
26
-
27
- interface AzResourceShowResponse {
28
- id?: string;
29
- name?: string;
30
- type?: string;
31
- location?: string;
32
- properties?: {
33
- provisioningState?: string;
34
- [k: string]: unknown;
35
- };
36
- tags?: Record<string, string>;
37
- }
29
+ import { boundedConcurrently, observation } from "@intentius/chant/observation";
30
+ import { AzureReadError, getResource, isNotFound, type AzureReadClientOptions } from "./api/read-client";
31
+ import type { AzHttp } from "./op/activities/az-apply";
38
32
 
39
33
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
40
34
  const out: Record<string, unknown> = {};
@@ -99,19 +93,60 @@ export function classifyAzFailure(err: unknown): { absent: true } | { absent: fa
99
93
  return { absent: false, reason: "read-failed", detail };
100
94
  }
101
95
 
96
+ /**
97
+ * Classify an ARM failure off its own error code (#1212).
98
+ *
99
+ * The CLI classifier above matched on prose because stderr was all it had.
100
+ * ARM sends `{ error: { code, message } }`, so the code is the signal and the
101
+ * message is only for the human — the same distinction the AWS read client
102
+ * makes. Kept beside `classifyAzFailure` rather than replacing it: the CLI
103
+ * path still exists for a signed read against real ARM.
104
+ */
105
+ export function classifyArmFailure(err: unknown): { reason: UnobservedReason; detail: string } {
106
+ const code = err instanceof AzureReadError ? (err.code ?? "") : "";
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ const detail = (code ? `${code}: ${message}` : message).slice(0, 200);
109
+ const both = `${code} ${message}`.toLowerCase();
110
+
111
+ if (
112
+ both.includes("authenticationfailed") ||
113
+ both.includes("authorizationfailed") ||
114
+ both.includes("expired") ||
115
+ both.includes("forbidden") ||
116
+ (err instanceof AzureReadError && (err.status === 401 || err.status === 403))
117
+ ) {
118
+ return { reason: "no-credentials", detail };
119
+ }
120
+ if (both.includes("subscriptionnotfound") || both.includes("resourcegroupnotfound")) {
121
+ return { reason: "no-binding", detail };
122
+ }
123
+ return { reason: "read-failed", detail };
124
+ }
125
+
102
126
  export async function describeResources(options: {
103
127
  environment: string;
104
128
  buildOutput: string;
105
129
  entityNames: string[];
106
130
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
131
+ /** Injectable transport, mirroring `azApply`'s — tests reach the reader with no network. */
132
+ http?: AzHttp;
107
133
  }): Promise<ObservationResult> {
108
134
  const result: Record<string, ResourceMetadata> = {};
109
135
  const unobserved: Record<string, UnobservedEntity> = {};
110
136
  const skippedNested: string[] = [];
137
+ const readable: Array<{ entityName: string; entityType: string; name: string }> = [];
138
+
139
+ // The environment is the resource group, as it has always been on this path.
140
+ const client: AzureReadClientOptions = {
141
+ resourceGroup: options.environment,
142
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
143
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
144
+ ...(options.http ? { http: options.http } : {}),
145
+ };
111
146
 
112
147
  for (const [entityName, { entityType, props }] of options.entities) {
113
148
  if (!entityType.startsWith("Microsoft.")) {
114
- // Not an ARM resource type, so `az resource show` has nothing to ask for.
149
+ // Not an ARM resource type, so there is no ARM URL to GET.
115
150
  // Unobserved rather than skipped: a silent skip reads as absence (#1089).
116
151
  unobserved[entityName] = {
117
152
  type: entityType,
@@ -126,7 +161,7 @@ export async function describeResources(options: {
126
161
  unobserved[entityName] = {
127
162
  type: entityType,
128
163
  reason: "unsupported-kind",
129
- detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
164
+ detail: "a nested ARM type needs a different read path; chant never queried this resource",
130
165
  };
131
166
  continue;
132
167
  }
@@ -141,35 +176,30 @@ export async function describeResources(options: {
141
176
  continue;
142
177
  }
143
178
 
144
- const cmd = [
145
- "az", "resource", "show",
146
- "--resource-group", options.environment,
147
- "--name", name,
148
- "--resource-type", entityType,
149
- "-o", "json",
150
- ].join(" ");
179
+ readable.push({ entityName, entityType, name });
180
+ }
151
181
 
182
+ // Concurrent, where the CLI path was one spawn after another (#1201/#1212).
183
+ await boundedConcurrently(readable, async ({ entityName, entityType, name }) => {
152
184
  try {
153
- const { stdout } = await execAsync(cmd);
154
- const obj: AzResourceShowResponse = JSON.parse(stdout);
185
+ const body = await getResource(client, entityType, name);
155
186
  result[entityName] = {
156
187
  type: entityType,
157
- physicalId: obj.id,
158
- status: obj.properties?.provisioningState ?? "PRESENT",
188
+ physicalId: body.id,
189
+ status: (body.properties?.provisioningState as string | undefined) ?? "PRESENT",
159
190
  attributes: pruneUndefined({
160
- location: obj.location,
161
- tags: obj.tags,
191
+ location: body.location,
192
+ tags: body.tags,
162
193
  }),
163
194
  };
164
195
  } catch (err) {
165
196
  // Not-found leaves the entity out (absence). Auth/binding/other failures
166
197
  // are recorded as holes so they can't become creates (#1089).
167
- const outcome = classifyAzFailure(err);
168
- if (!outcome.absent) {
169
- unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
170
- }
198
+ if (isNotFound(err)) return;
199
+ const outcome = classifyArmFailure(err);
200
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
171
201
  }
172
- }
202
+ });
173
203
 
174
204
  if (skippedNested.length > 0) {
175
205
  // eslint-disable-next-line no-console
@@ -4,7 +4,7 @@
4
4
  * Drives the REAL azurePlugin through core's live-import driver and the
5
5
  * changeset path, with the `az` CLI edge mocked.
6
6
  */
7
- import { describe, test, expect, vi, beforeEach } from "vitest";
7
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
8
8
  import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
@@ -57,8 +57,17 @@ const resourceShow = {
57
57
  properties: { provisioningState: "Succeeded" },
58
58
  };
59
59
 
60
+ /** The Azure reads speak ARM over `fetch` now (#1212), so that is the seam. */
61
+ const stubArm = (respond: () => { status: number; text: string }): void => {
62
+ vi.spyOn(globalThis, "fetch").mockImplementation((async () => {
63
+ const r = respond();
64
+ return { status: r.status, text: () => Promise.resolve(r.text) };
65
+ }) as unknown as typeof fetch);
66
+ };
67
+
60
68
  describe("azure lifecycle integration (#163)", () => {
61
69
  beforeEach(() => execMock.mockReset());
70
+ afterEach(() => vi.restoreAllMocks());
62
71
 
63
72
  test("live-import driver: real exportResources → IR → generated source", async () => {
64
73
  execMock.mockReturnValue({ stdout: JSON.stringify(liveTemplate), stderr: "" });
@@ -81,11 +90,7 @@ describe("azure lifecycle integration (#163)", () => {
81
90
  });
82
91
 
83
92
  test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
84
- execMock.mockImplementation((cmd?: string) =>
85
- cmd?.includes("resource show")
86
- ? { stdout: JSON.stringify(resourceShow), stderr: "" }
87
- : new Error("unexpected"),
88
- );
93
+ stubArm(() => ({ status: 200, text: JSON.stringify(resourceShow) }));
89
94
 
90
95
  const { resources: observedNow } = normalizeObservation(
91
96
  await azurePlugin.describeResources!({
@@ -122,12 +127,12 @@ describeObservationConformance({
122
127
  lexicon: "azure",
123
128
  scenarios: [
124
129
  {
125
- name: "a nested ARM type az resource show cannot query",
130
+ name: "a nested ARM type the reader cannot address",
126
131
  declared: ["blobSvc", "gone"],
127
132
  expectUnobserved: ["blobSvc"],
128
133
  expectAbsent: ["gone"],
129
134
  run: () => {
130
- execMock.mockImplementation(() => new Error("ResourceNotFound: gone"));
135
+ stubArm(() => ({ status: 404, text: JSON.stringify({ error: { code: "ResourceNotFound", message: "not found" } }) }));
131
136
  return azurePlugin.describeResources!({
132
137
  environment: "prod",
133
138
  buildOutput: "",
@@ -144,9 +149,10 @@ describeObservationConformance({
144
149
  declared: ["myStore"],
145
150
  expectUnobserved: ["myStore"],
146
151
  run: () => {
147
- execMock.mockImplementation(() =>
148
- Object.assign(new Error("az failed"), { stderr: "Please run 'az login' to setup account." }),
149
- );
152
+ stubArm(() => ({
153
+ status: 401,
154
+ text: JSON.stringify({ error: { code: "AuthenticationFailed", message: "Authentication failed." } }),
155
+ }));
150
156
  return azurePlugin.describeResources!({
151
157
  environment: "prod",
152
158
  buildOutput: "",