@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.
- package/dist/api/read-client.d.ts +53 -0
- package/dist/api/read-client.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +7 -2
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +28 -4
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/api/read-client.ts +136 -0
- package/src/codegen/docs.ts +20 -0
- package/src/deep-observe.test.ts +79 -56
- package/src/deep-observe.ts +27 -21
- package/src/describe-resources.test.ts +50 -51
- package/src/describe-resources.ts +71 -41
- package/src/lifecycle-integration.test.ts +17 -11
|
@@ -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;
|
|
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"}
|
package/dist/deep-observe.d.ts
CHANGED
|
@@ -42,9 +42,12 @@
|
|
|
42
42
|
*
|
|
43
43
|
* ## Nothing here talks to real Azure on its own terms
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
* ambient token.
|
|
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
|
|
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
|
|
2
|
+
* Live introspection of an Azure resource group over ARM (#1212).
|
|
3
3
|
*
|
|
4
|
-
* For each declared Azure entity,
|
|
5
|
-
*
|
|
4
|
+
* For each declared Azure entity, GETs
|
|
5
|
+
* {endpoint}/subscriptions/{sub}/resourceGroups/{env}/providers/{type}/{name}
|
|
6
6
|
*
|
|
7
|
-
*
|
|
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
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
36
|
+
"composite": "dff5b3422ce14b471818bb6b2799870cb3ce0d55d6caae6e0fc2164dce02830b"
|
|
37
37
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-azure",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
+
}
|
package/src/codegen/docs.ts
CHANGED
|
@@ -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
|
/**
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -2,40 +2,51 @@
|
|
|
2
2
|
* Azure deep observation (#1086) — the azure row of the deep-observe
|
|
3
3
|
* contract (#1014).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
31
|
-
const
|
|
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
|
-
|
|
38
|
-
|
|
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("
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
154
|
-
expect(
|
|
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
|
-
|
|
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("
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
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 (
|
|
288
|
-
return
|
|
306
|
+
if (name === "secure-acct") {
|
|
307
|
+
return respond(armError(401, "AuthenticationFailed", "Authentication failed."));
|
|
289
308
|
}
|
|
290
|
-
return
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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 —
|
|
365
|
-
//
|
|
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 === "
|
|
392
|
+
result.unobserved.filter((u) => u.name !== "blobServices").every((u) => u.reason === "no-credentials"),
|
|
370
393
|
).toBe(true);
|
|
371
394
|
});
|
|
372
395
|
});
|
package/src/deep-observe.ts
CHANGED
|
@@ -42,12 +42,15 @@
|
|
|
42
42
|
*
|
|
43
43
|
* ## Nothing here talks to real Azure on its own terms
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
* ambient token.
|
|
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 {
|
|
50
|
-
import {
|
|
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 {
|
|
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: "
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
expect(
|
|
51
|
-
expect(
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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("
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
144
|
+
expect(fake.urls).toEqual([]);
|
|
146
145
|
});
|
|
147
146
|
});
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live introspection of an Azure resource group
|
|
2
|
+
* Live introspection of an Azure resource group over ARM (#1212).
|
|
3
3
|
*
|
|
4
|
-
* For each declared Azure entity,
|
|
5
|
-
*
|
|
4
|
+
* For each declared Azure entity, GETs
|
|
5
|
+
* {endpoint}/subscriptions/{sub}/resourceGroups/{env}/providers/{type}/{name}
|
|
6
6
|
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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:
|
|
158
|
-
status:
|
|
188
|
+
physicalId: body.id,
|
|
189
|
+
status: (body.properties?.provisioningState as string | undefined) ?? "PRESENT",
|
|
159
190
|
attributes: pruneUndefined({
|
|
160
|
-
location:
|
|
161
|
-
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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: "",
|