@pagopa/dx-savemoney 0.2.6 → 0.3.1
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/README.md +33 -27
- package/dist/__tests__/finding.test.d.ts +17 -0
- package/dist/__tests__/finding.test.d.ts.map +1 -0
- package/dist/__tests__/finding.test.js +124 -0
- package/dist/__tests__/finding.test.js.map +1 -0
- package/dist/azure/__tests__/analyzer-tags.test.d.ts +8 -0
- package/dist/azure/__tests__/analyzer-tags.test.d.ts.map +1 -0
- package/dist/azure/__tests__/analyzer-tags.test.js +43 -0
- package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -0
- package/dist/azure/__tests__/config.test.d.ts +9 -0
- package/dist/azure/__tests__/config.test.d.ts.map +1 -0
- package/dist/azure/__tests__/config.test.js +70 -0
- package/dist/azure/__tests__/config.test.js.map +1 -0
- package/dist/azure/__tests__/report.test.d.ts +9 -0
- package/dist/azure/__tests__/report.test.d.ts.map +1 -0
- package/dist/azure/__tests__/report.test.js +120 -0
- package/dist/azure/__tests__/report.test.js.map +1 -0
- package/dist/azure/__tests__/utils.test.d.ts +15 -0
- package/dist/azure/__tests__/utils.test.d.ts.map +1 -0
- package/dist/azure/__tests__/utils.test.js +181 -0
- package/dist/azure/__tests__/utils.test.js.map +1 -0
- package/dist/azure/analyzer.d.ts +18 -5
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +295 -48
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts +9 -0
- package/dist/azure/analyzers/__tests__/advisor.test.d.ts.map +1 -0
- package/dist/azure/analyzers/__tests__/advisor.test.js +314 -0
- package/dist/azure/analyzers/__tests__/advisor.test.js.map +1 -0
- package/dist/azure/analyzers/advisor.d.ts +68 -0
- package/dist/azure/analyzers/advisor.d.ts.map +1 -0
- package/dist/azure/analyzers/advisor.js +234 -0
- package/dist/azure/analyzers/advisor.js.map +1 -0
- package/dist/azure/analyzers/index.d.ts +3 -1
- package/dist/azure/analyzers/index.d.ts.map +1 -1
- package/dist/azure/analyzers/index.js +2 -1
- package/dist/azure/analyzers/index.js.map +1 -1
- package/dist/azure/analyzers/registry.d.ts +8 -0
- package/dist/azure/analyzers/registry.d.ts.map +1 -1
- package/dist/azure/analyzers/registry.js +10 -0
- package/dist/azure/analyzers/registry.js.map +1 -1
- package/dist/azure/analyzers/subscription.d.ts +53 -0
- package/dist/azure/analyzers/subscription.d.ts.map +1 -0
- package/dist/azure/analyzers/subscription.js +18 -0
- package/dist/azure/analyzers/subscription.js.map +1 -0
- package/dist/azure/config.d.ts.map +1 -1
- package/dist/azure/config.js +1 -0
- package/dist/azure/config.js.map +1 -1
- package/dist/azure/index.d.ts +1 -0
- package/dist/azure/index.d.ts.map +1 -1
- package/dist/azure/index.js +1 -0
- package/dist/azure/index.js.map +1 -1
- package/dist/azure/report.d.ts.map +1 -1
- package/dist/azure/report.js +178 -29
- package/dist/azure/report.js.map +1 -1
- package/dist/azure/resources/__tests__/storage.test.d.ts +11 -0
- package/dist/azure/resources/__tests__/storage.test.d.ts.map +1 -0
- package/dist/azure/resources/__tests__/storage.test.js +99 -0
- package/dist/azure/resources/__tests__/storage.test.js.map +1 -0
- package/dist/azure/types.d.ts +28 -1
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +78 -0
- package/dist/index.test.js.map +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -1
- package/package.json +5 -3
- package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
- package/src/azure/__tests__/report.test.ts +35 -6
- package/src/azure/analyzer.ts +421 -65
- package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
- package/src/azure/analyzers/advisor.ts +324 -0
- package/src/azure/analyzers/index.ts +9 -1
- package/src/azure/analyzers/registry.ts +12 -0
- package/src/azure/analyzers/subscription.ts +56 -0
- package/src/azure/config.ts +1 -0
- package/src/azure/index.ts +1 -0
- package/src/azure/report.ts +206 -35
- package/src/azure/types.ts +29 -1
- package/src/index.ts +1 -1
- package/src/schema.ts +9 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for matchesTags() — verifies AND-logic tag filtering:
|
|
3
|
+
* 1. No filter (undefined/empty) → always include the resource.
|
|
4
|
+
* 2. Exact key-value match → include.
|
|
5
|
+
* 3. Key missing on resource → exclude.
|
|
6
|
+
* 4. Key present but wrong value → exclude.
|
|
7
|
+
* 5. Multiple tags: all match → include; any mismatch → exclude.
|
|
8
|
+
*
|
|
9
|
+
* Tests for getMetric() cache behaviour:
|
|
10
|
+
* 6. resetMetricsCache() clears state between runs.
|
|
11
|
+
* 7. Concurrent calls for the same key coalesce into one network call.
|
|
12
|
+
* 8. A failed call is cached as a fulfilled null result (not retried silently).
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { _metricsCacheSize, getMetric, matchesTags, resetMetricsCache, } from "../utils.js";
|
|
16
|
+
function makeResource(tags) {
|
|
17
|
+
return { id: "r1", name: "res", tags };
|
|
18
|
+
}
|
|
19
|
+
describe("matchesTags", () => {
|
|
20
|
+
describe("when no filter is provided", () => {
|
|
21
|
+
it("returns true for undefined filterTags", () => {
|
|
22
|
+
expect(matchesTags(makeResource({ env: "prod" }), undefined)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("returns true for empty Map", () => {
|
|
25
|
+
expect(matchesTags(makeResource({ env: "prod" }), new Map())).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it("returns true even when resource has no tags", () => {
|
|
28
|
+
expect(matchesTags(makeResource(), undefined)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("single tag filter", () => {
|
|
32
|
+
it("returns true when tag key and value match exactly", () => {
|
|
33
|
+
expect(matchesTags(makeResource({ env: "prod" }), new Map([["env", "prod"]]))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it("returns false when tag key is missing from resource", () => {
|
|
36
|
+
expect(matchesTags(makeResource({ app: "myapp" }), new Map([["env", "prod"]]))).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it("returns false when tag key exists but value differs", () => {
|
|
39
|
+
expect(matchesTags(makeResource({ env: "dev" }), new Map([["env", "prod"]]))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
it("returns false when resource has no tags at all", () => {
|
|
42
|
+
expect(matchesTags(makeResource(), new Map([["env", "prod"]]))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it("is case-sensitive for tag values", () => {
|
|
45
|
+
expect(matchesTags(makeResource({ env: "Prod" }), new Map([["env", "prod"]]))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe("multiple tag filters (AND logic)", () => {
|
|
49
|
+
it("returns true when all filter tags match", () => {
|
|
50
|
+
const resource = makeResource({
|
|
51
|
+
env: "prod",
|
|
52
|
+
region: "italy",
|
|
53
|
+
team: "dx",
|
|
54
|
+
});
|
|
55
|
+
expect(matchesTags(resource, new Map([
|
|
56
|
+
["env", "prod"],
|
|
57
|
+
["team", "dx"],
|
|
58
|
+
]))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it("returns false when one of the filter tags is missing", () => {
|
|
61
|
+
const resource = makeResource({ env: "prod" });
|
|
62
|
+
expect(matchesTags(resource, new Map([
|
|
63
|
+
["env", "prod"],
|
|
64
|
+
["team", "dx"],
|
|
65
|
+
]))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it("returns false when one of the filter tags has the wrong value", () => {
|
|
68
|
+
const resource = makeResource({ env: "prod", team: "backend" });
|
|
69
|
+
expect(matchesTags(resource, new Map([
|
|
70
|
+
["env", "prod"],
|
|
71
|
+
["team", "dx"],
|
|
72
|
+
]))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it("returns false when both filter tags have wrong values", () => {
|
|
75
|
+
const resource = makeResource({ env: "dev", team: "backend" });
|
|
76
|
+
expect(matchesTags(resource, new Map([
|
|
77
|
+
["env", "prod"],
|
|
78
|
+
["team", "dx"],
|
|
79
|
+
]))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// ── metrics cache ──────────────────────────────────────────────────────────
|
|
84
|
+
function makeFailingMonitorClient(calls = []) {
|
|
85
|
+
return {
|
|
86
|
+
metrics: {
|
|
87
|
+
list: vi.fn().mockImplementation(async () => {
|
|
88
|
+
calls.push(1);
|
|
89
|
+
throw new Error("network error");
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function makeMonitorClient(returnValue, calls = []) {
|
|
95
|
+
return {
|
|
96
|
+
metrics: {
|
|
97
|
+
list: vi.fn().mockImplementation(async () => {
|
|
98
|
+
calls.push(1);
|
|
99
|
+
if (returnValue === null) {
|
|
100
|
+
return { value: [] };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
value: [
|
|
104
|
+
{
|
|
105
|
+
timeseries: [{ data: [{ average: returnValue }] }],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
describe("getMetric — in-memory cache", () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
resetMetricsCache();
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
});
|
|
120
|
+
it("resetMetricsCache clears all cached entries", async () => {
|
|
121
|
+
const calls = [];
|
|
122
|
+
const client = makeMonitorClient(42, calls);
|
|
123
|
+
await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
|
|
124
|
+
expect(_metricsCacheSize()).toBe(1);
|
|
125
|
+
resetMetricsCache();
|
|
126
|
+
expect(_metricsCacheSize()).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
it("does not call the API a second time for the same key", async () => {
|
|
129
|
+
const calls = [];
|
|
130
|
+
const client = makeMonitorClient(10, calls);
|
|
131
|
+
await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
|
|
132
|
+
await getMetric(client, "/res/1", "Percentage CPU", "Average", 7);
|
|
133
|
+
expect(calls.length).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
it("concurrent calls for the same key coalesce into one network call", async () => {
|
|
136
|
+
const calls = [];
|
|
137
|
+
const client = makeMonitorClient(5, calls);
|
|
138
|
+
await Promise.all([
|
|
139
|
+
getMetric(client, "/res/2", "Network In Total", "Average", 30),
|
|
140
|
+
getMetric(client, "/res/2", "Network In Total", "Average", 30),
|
|
141
|
+
getMetric(client, "/res/2", "Network In Total", "Average", 30),
|
|
142
|
+
]);
|
|
143
|
+
expect(calls.length).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
it("different keys result in separate network calls", async () => {
|
|
146
|
+
const calls = [];
|
|
147
|
+
const client = makeMonitorClient(1, calls);
|
|
148
|
+
await getMetric(client, "/res/a", "Percentage CPU", "Average", 7);
|
|
149
|
+
await getMetric(client, "/res/b", "Percentage CPU", "Average", 7);
|
|
150
|
+
expect(calls.length).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
it("returns null and caches the null result when no data points are available", async () => {
|
|
153
|
+
const calls = [];
|
|
154
|
+
const client = makeMonitorClient(null, calls);
|
|
155
|
+
const first = await getMetric(client, "/res/3", "Percentage CPU", "Average", 7);
|
|
156
|
+
const second = await getMetric(client, "/res/3", "Percentage CPU", "Average", 7);
|
|
157
|
+
expect(first).toBeNull();
|
|
158
|
+
expect(second).toBeNull();
|
|
159
|
+
expect(calls.length).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
it("a failing call is cached and returns null on retry", async () => {
|
|
162
|
+
const calls = [];
|
|
163
|
+
const client = makeFailingMonitorClient(calls);
|
|
164
|
+
const first = await getMetric(client, "/res/4", "Percentage CPU", "Average", 7);
|
|
165
|
+
const second = await getMetric(client, "/res/4", "Percentage CPU", "Average", 7);
|
|
166
|
+
// Both calls return null (error is swallowed by getMetric)
|
|
167
|
+
expect(first).toBeNull();
|
|
168
|
+
expect(second).toBeNull();
|
|
169
|
+
// Only one actual network call despite two getMetric calls
|
|
170
|
+
expect(calls.length).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
it("after resetMetricsCache the API is called again", async () => {
|
|
173
|
+
const calls = [];
|
|
174
|
+
const client = makeMonitorClient(7, calls);
|
|
175
|
+
await getMetric(client, "/res/5", "Percentage CPU", "Average", 7);
|
|
176
|
+
resetMetricsCache();
|
|
177
|
+
await getMetric(client, "/res/5", "Percentage CPU", "Average", 7);
|
|
178
|
+
expect(calls.length).toBe(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
//# sourceMappingURL=utils.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.test.js","sourceRoot":"","sources":["../../../src/azure/__tests__/utils.test.ts"],"names":[],"mappings":"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"}
|
package/dist/azure/analyzer.d.ts
CHANGED
|
@@ -19,17 +19,29 @@
|
|
|
19
19
|
* existing report formats keep working untouched.
|
|
20
20
|
*/
|
|
21
21
|
import * as armResources from "@azure/arm-resources";
|
|
22
|
-
import type {
|
|
22
|
+
import type { Finding } from "../finding.js";
|
|
23
|
+
import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
|
|
23
24
|
import { type AnalysisResult, type Thresholds } from "../types.js";
|
|
24
25
|
import { type Analyzer, type AzureClients } from "./analyzers/index.js";
|
|
25
26
|
import { type MetricsCache } from "./utils.js";
|
|
26
27
|
/**
|
|
27
|
-
* Analyzes resources in
|
|
28
|
+
* Analyzes resources in every configured Azure subscription and returns
|
|
29
|
+
* the structured report.
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
+
* Phase 1 change: this function no longer emits a report to stdout. The
|
|
32
|
+
* orchestrator returns `AzureDetailedResourceReport[]` and the caller
|
|
33
|
+
* (the CLI today, future GUI / API consumers tomorrow) chooses how to
|
|
34
|
+
* render it via `generateReport`.
|
|
35
|
+
*
|
|
36
|
+
* Each entry carries both the legacy `analysis` summary and the unified
|
|
37
|
+
* `findings: Finding[]` so consumers can pick the level of detail they
|
|
38
|
+
* need. Azure Advisor recommendations and per-resource analyzer outputs
|
|
39
|
+
* are merged into the same entry when they refer to the same resource.
|
|
40
|
+
*
|
|
41
|
+
* @param config - Azure configuration with subscription IDs and settings.
|
|
42
|
+
* `config.sources` controls which analyzers run.
|
|
31
43
|
*/
|
|
32
|
-
export declare function analyzeAzureResources(config: AzureConfig
|
|
44
|
+
export declare function analyzeAzureResources(config: AzureConfig): Promise<AzureDetailedResourceReport[]>;
|
|
33
45
|
/**
|
|
34
46
|
* Analyzes a single Azure resource by dispatching it to every registered
|
|
35
47
|
* analyzer that supports it. Generic checks (missing tags, location
|
|
@@ -46,4 +58,5 @@ export declare function analyzeAzureResources(config: AzureConfig, format: "deta
|
|
|
46
58
|
* @returns Analysis result with cost risk and reason
|
|
47
59
|
*/
|
|
48
60
|
export declare function analyzeResource(resource: armResources.GenericResource, analyzers: Analyzer[], clients: AzureClients, metricsCache: MetricsCache | undefined, preferredLocation: string, timespanDays: number, thresholds: Thresholds, verbose?: boolean): Promise<AnalysisResult>;
|
|
61
|
+
export declare function shouldIncludeAdvisorFindingForTags(finding: Finding, taggedResourceIds: ReadonlySet<string>, tagFilterActive: boolean): boolean;
|
|
49
62
|
//# sourceMappingURL=analyzer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../src/azure/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;
|
|
1
|
+
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../src/azure/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,OAAO,KAAK,YAAY,MAAM,sBAAsB,CAAC;AAKrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EACV,WAAW,EACX,2BAA2B,EAE5B,MAAM,YAAY,CAAC;AAGpB,OAAO,EACL,KAAK,cAAc,EAInB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,KAAK,QAAQ,EAEb,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAO5D;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,2BAA2B,EAAE,CAAC,CA4GxC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,YAAY,CAAC,eAAe,EACtC,SAAS,EAAE,QAAQ,EAAE,EACrB,OAAO,EAAE,YAAY,EACrB,YAAY,EAAE,YAAY,YAAY,EACtC,iBAAiB,EAAE,MAAM,EACzB,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,UAAU,EACtB,OAAO,UAAQ,GACd,OAAO,CAAC,cAAc,CAAC,CA8CzB;AAED,wBAAgB,kCAAkC,CAChD,OAAO,EAAE,OAAO,EAChB,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,EACtC,eAAe,EAAE,OAAO,GACvB,OAAO,CAYT"}
|
package/dist/azure/analyzer.js
CHANGED
|
@@ -27,22 +27,41 @@ import * as armResources from "@azure/arm-resources";
|
|
|
27
27
|
import { DefaultAzureCredential } from "@azure/identity";
|
|
28
28
|
import { getLogger } from "@logtape/logtape";
|
|
29
29
|
import pLimit from "p-limit";
|
|
30
|
+
import { findingsFromAnalysisResult } from "../finding.js";
|
|
30
31
|
import { DEFAULT_THRESHOLDS, mergeResults, } from "../types.js";
|
|
31
|
-
import { createDefaultAnalyzers, } from "./analyzers/index.js";
|
|
32
|
-
import { generateReport } from "./report.js";
|
|
32
|
+
import { createDefaultAnalyzers, createDefaultSubscriptionAnalyzers, } from "./analyzers/index.js";
|
|
33
33
|
import { matchesTags } from "./utils.js";
|
|
34
34
|
const DEFAULT_CONCURRENCY = 8;
|
|
35
|
+
const DEFAULT_SOURCES = ["advisor", "custom"];
|
|
36
|
+
const RISK_ORDER = { high: 0, low: 2, medium: 1 };
|
|
35
37
|
/**
|
|
36
|
-
* Analyzes resources in
|
|
38
|
+
* Analyzes resources in every configured Azure subscription and returns
|
|
39
|
+
* the structured report.
|
|
37
40
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
41
|
+
* Phase 1 change: this function no longer emits a report to stdout. The
|
|
42
|
+
* orchestrator returns `AzureDetailedResourceReport[]` and the caller
|
|
43
|
+
* (the CLI today, future GUI / API consumers tomorrow) chooses how to
|
|
44
|
+
* render it via `generateReport`.
|
|
45
|
+
*
|
|
46
|
+
* Each entry carries both the legacy `analysis` summary and the unified
|
|
47
|
+
* `findings: Finding[]` so consumers can pick the level of detail they
|
|
48
|
+
* need. Azure Advisor recommendations and per-resource analyzer outputs
|
|
49
|
+
* are merged into the same entry when they refer to the same resource.
|
|
50
|
+
*
|
|
51
|
+
* @param config - Azure configuration with subscription IDs and settings.
|
|
52
|
+
* `config.sources` controls which analyzers run.
|
|
40
53
|
*/
|
|
41
|
-
export async function analyzeAzureResources(config
|
|
54
|
+
export async function analyzeAzureResources(config) {
|
|
42
55
|
const logger = getLogger(["savemoney", "azure"]);
|
|
43
56
|
const credential = new DefaultAzureCredential();
|
|
44
57
|
const allReports = [];
|
|
58
|
+
const sources = config.sources ?? DEFAULT_SOURCES;
|
|
59
|
+
const customEnabled = sources.includes("custom");
|
|
60
|
+
const advisorEnabled = sources.includes("advisor");
|
|
45
61
|
const analyzers = createDefaultAnalyzers();
|
|
62
|
+
const subscriptionAnalyzers = advisorEnabled
|
|
63
|
+
? createDefaultSubscriptionAnalyzers()
|
|
64
|
+
: [];
|
|
46
65
|
const thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
|
|
47
66
|
// Normalise concurrency the same way p-limit does to keep maxInFlight
|
|
48
67
|
// consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
|
|
@@ -58,6 +77,11 @@ export async function analyzeAzureResources(config, format) {
|
|
|
58
77
|
for (const subscriptionId of config.subscriptionIds) {
|
|
59
78
|
logger.info(`Analyzing subscription: ${subscriptionId}`);
|
|
60
79
|
const sid = subscriptionId.trim();
|
|
80
|
+
// Per-subscription index keyed by lowercased resourceId so subscription-
|
|
81
|
+
// level analyzers (Advisor, future quotas, …) can merge their findings
|
|
82
|
+
// back into the matching resource report.
|
|
83
|
+
const reportsById = new Map();
|
|
84
|
+
const taggedResourceIds = new Set();
|
|
61
85
|
// Fresh cache per subscription — bounds peak memory to one subscription's
|
|
62
86
|
// worth of metrics and keeps concurrent analyzeAzureResources calls isolated.
|
|
63
87
|
const runCache = new Map();
|
|
@@ -68,56 +92,60 @@ export async function analyzeAzureResources(config, format) {
|
|
|
68
92
|
network: new NetworkManagementClient(credential, sid),
|
|
69
93
|
webSite: new WebSiteManagementClient(credential, sid),
|
|
70
94
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (suspectedUnused) {
|
|
87
|
-
allReports.push({
|
|
88
|
-
analysis: {
|
|
89
|
-
costRisk,
|
|
90
|
-
reason: reason || "No specific findings.",
|
|
91
|
-
suspectedUnused,
|
|
92
|
-
},
|
|
93
|
-
resource,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
95
|
+
if (customEnabled) {
|
|
96
|
+
await runPerResourceAnalysis({
|
|
97
|
+
analyzers,
|
|
98
|
+
clients,
|
|
99
|
+
config,
|
|
100
|
+
credential,
|
|
101
|
+
limit,
|
|
102
|
+
logger,
|
|
103
|
+
maxInFlight,
|
|
104
|
+
reports: allReports,
|
|
105
|
+
reportsById,
|
|
106
|
+
runCache,
|
|
107
|
+
sid,
|
|
108
|
+
taggedResourceIds,
|
|
109
|
+
thresholds,
|
|
96
110
|
});
|
|
97
|
-
inFlight.add(task);
|
|
98
|
-
// Suppress the unhandled-rejection that would occur between task creation
|
|
99
|
-
// and the Promise.allSettled drain below. The .catch() handler is a no-op
|
|
100
|
-
// because the actual error is still visible to allSettled (which logs it)
|
|
101
|
-
// via the original `task` reference kept in inFlight.
|
|
102
|
-
void task.catch(() => undefined).finally(() => inFlight.delete(task));
|
|
103
111
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
112
|
+
if (!customEnabled && advisorEnabled && hasTagFilter(config.filterTags)) {
|
|
113
|
+
await collectTaggedResourceIds({
|
|
114
|
+
config,
|
|
115
|
+
credential,
|
|
116
|
+
sid,
|
|
117
|
+
taggedResourceIds,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (advisorEnabled && subscriptionAnalyzers.length > 0) {
|
|
121
|
+
await runSubscriptionAnalyzers({
|
|
122
|
+
analyzers: subscriptionAnalyzers,
|
|
123
|
+
credential,
|
|
124
|
+
logger,
|
|
125
|
+
reports: allReports,
|
|
126
|
+
reportsById,
|
|
127
|
+
sid,
|
|
128
|
+
tagFilterActive: hasTagFilter(config.filterTags),
|
|
129
|
+
taggedResourceIds,
|
|
130
|
+
verbose: config.verbose ?? false,
|
|
131
|
+
});
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
|
-
// Sort to make the output more readable
|
|
134
|
+
// Sort to make the output more readable:
|
|
135
|
+
// - Subscription-scoped findings (Reserved Instances, savings plans, ...)
|
|
136
|
+
// sink to the bottom: they aggregate many recommendations into a single
|
|
137
|
+
// fat row and are easier to consume after the per-resource rows.
|
|
138
|
+
// - Within each group, sort by cost risk then by resource name.
|
|
114
139
|
allReports.sort((a, b) => {
|
|
140
|
+
const aSub = isSubscriptionScopedReport(a);
|
|
141
|
+
const bSub = isSubscriptionScopedReport(b);
|
|
142
|
+
if (aSub !== bSub)
|
|
143
|
+
return aSub ? 1 : -1;
|
|
115
144
|
if (a.analysis.costRisk === b.analysis.costRisk)
|
|
116
145
|
return (a.resource.name ?? "").localeCompare(b.resource.name ?? "");
|
|
117
|
-
|
|
118
|
-
return order[a.analysis.costRisk] - order[b.analysis.costRisk];
|
|
146
|
+
return RISK_ORDER[a.analysis.costRisk] - RISK_ORDER[b.analysis.costRisk];
|
|
119
147
|
});
|
|
120
|
-
|
|
148
|
+
return allReports;
|
|
121
149
|
}
|
|
122
150
|
/**
|
|
123
151
|
* Analyzes a single Azure resource by dispatching it to every registered
|
|
@@ -173,4 +201,223 @@ export async function analyzeResource(resource, analyzers, clients, metricsCache
|
|
|
173
201
|
}
|
|
174
202
|
return { ...result, reason: result.reason.trim() };
|
|
175
203
|
}
|
|
204
|
+
export function shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, tagFilterActive) {
|
|
205
|
+
if (!tagFilterActive) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (finding.source !== "advisor") {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
if (!isResourceScopedFinding(finding.resourceId)) {
|
|
212
|
+
// Subscription-level findings are intentionally always global.
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return taggedResourceIds.has(normalizeResourceId(finding.resourceId));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Derives a legacy `AnalysisResult` summary from a `Finding`, so the
|
|
219
|
+
* existing report formats keep working untouched on Advisor-only
|
|
220
|
+
* resources.
|
|
221
|
+
*/
|
|
222
|
+
function analysisFromFinding(finding) {
|
|
223
|
+
const trimmed = finding.reason.trim();
|
|
224
|
+
const reason = trimmed.endsWith(".") ? trimmed : `${trimmed}.`;
|
|
225
|
+
return {
|
|
226
|
+
costRisk: finding.severity,
|
|
227
|
+
reason,
|
|
228
|
+
suspectedUnused: true,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Builds a minimal `GenericResource` from a resource ID. Used when a
|
|
233
|
+
* subscription-level analyzer surfaces a resource the per-resource pass
|
|
234
|
+
* did not see — we have neither tags nor location, but `name` and `type`
|
|
235
|
+
* can be parsed deterministically from the resource ID structure.
|
|
236
|
+
*
|
|
237
|
+
* Handles three shapes:
|
|
238
|
+
* - Fully qualified: /subscriptions/{sub}/resourceGroups/{rg}/providers/{provider}/{type}/{name}
|
|
239
|
+
* - Resource-group-scoped: /subscriptions/{sub}/resourceGroups/{rg}
|
|
240
|
+
* - Subscription-scoped: /subscriptions/{sub}
|
|
241
|
+
*/
|
|
242
|
+
function buildResourceStub(resourceId) {
|
|
243
|
+
const parts = resourceId.split("/").filter((s) => s.length > 0);
|
|
244
|
+
const providersIdx = parts.indexOf("providers");
|
|
245
|
+
if (providersIdx >= 0 && parts.length > providersIdx + 2) {
|
|
246
|
+
// Fully qualified resource ID.
|
|
247
|
+
const provider = parts[providersIdx + 1];
|
|
248
|
+
const tail = parts.slice(providersIdx + 2); // [type, name, subtype, subname, ...]
|
|
249
|
+
const typeSegments = [provider];
|
|
250
|
+
for (let i = 0; i < tail.length; i += 2) {
|
|
251
|
+
typeSegments.push(tail[i]);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
id: resourceId,
|
|
255
|
+
name: tail[tail.length - 1],
|
|
256
|
+
type: typeSegments.join("/"),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const rgIdx = parts.indexOf("resourceGroups");
|
|
260
|
+
if (rgIdx >= 0 && parts.length > rgIdx + 1) {
|
|
261
|
+
// Resource-group-scoped ID.
|
|
262
|
+
return {
|
|
263
|
+
id: resourceId,
|
|
264
|
+
name: parts[rgIdx + 1],
|
|
265
|
+
type: "Microsoft.Resources/resourceGroups",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const subIdx = parts.indexOf("subscriptions");
|
|
269
|
+
if (subIdx >= 0 && parts.length > subIdx + 1) {
|
|
270
|
+
// Subscription-scoped ID (e.g. Reserved Instance recommendations).
|
|
271
|
+
return {
|
|
272
|
+
id: resourceId,
|
|
273
|
+
name: parts[subIdx + 1],
|
|
274
|
+
type: "Microsoft.Subscription",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Fallback for completely unknown shapes.
|
|
278
|
+
return { id: resourceId, name: parts[parts.length - 1], type: undefined };
|
|
279
|
+
}
|
|
280
|
+
async function collectTaggedResourceIds(args) {
|
|
281
|
+
const { config, credential, sid, taggedResourceIds } = args;
|
|
282
|
+
const resourceClient = new armResources.ResourceManagementClient(credential, sid);
|
|
283
|
+
for await (const resource of resourceClient.resources.list()) {
|
|
284
|
+
if (!matchesTags(resource, config.filterTags)) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const resourceId = normalizeResourceId(resource.id);
|
|
288
|
+
if (resourceId) {
|
|
289
|
+
taggedResourceIds.add(resourceId);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function hasTagFilter(filterTags) {
|
|
294
|
+
return Boolean(filterTags && filterTags.size > 0);
|
|
295
|
+
}
|
|
296
|
+
function isResourceScopedFinding(resourceId) {
|
|
297
|
+
return /\/providers\//i.test(resourceId);
|
|
298
|
+
}
|
|
299
|
+
function isSubscriptionScopedReport(r) {
|
|
300
|
+
return r.resource.type === "Microsoft.Subscription";
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Inserts a `Finding` into the right report entry, creating a stub
|
|
304
|
+
* resource entry on the fly when the finding refers to a resource that
|
|
305
|
+
* the per-resource pass did not analyze.
|
|
306
|
+
*/
|
|
307
|
+
function mergeFinding(finding, reports, reportsById) {
|
|
308
|
+
const idKey = finding.resourceId.toLowerCase();
|
|
309
|
+
const existing = reportsById.get(idKey);
|
|
310
|
+
if (existing) {
|
|
311
|
+
existing.findings = [...(existing.findings ?? []), finding];
|
|
312
|
+
const added = analysisFromFinding(finding);
|
|
313
|
+
// Use max costRisk (not last-wins) and join reasons with a space so we
|
|
314
|
+
// don't produce "Sentence one.Sentence two." when the existing reason is
|
|
315
|
+
// already trimmed (i.e. has no trailing separator space).
|
|
316
|
+
existing.analysis = {
|
|
317
|
+
costRisk: RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
|
|
318
|
+
? existing.analysis.costRisk
|
|
319
|
+
: added.costRisk,
|
|
320
|
+
reason: existing.analysis.reason && added.reason
|
|
321
|
+
? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
|
|
322
|
+
: existing.analysis.reason || added.reason,
|
|
323
|
+
suspectedUnused: existing.analysis.suspectedUnused || added.suspectedUnused,
|
|
324
|
+
};
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const stub = buildResourceStub(finding.resourceId);
|
|
328
|
+
const report = {
|
|
329
|
+
analysis: analysisFromFinding(finding),
|
|
330
|
+
findings: [finding],
|
|
331
|
+
resource: stub,
|
|
332
|
+
};
|
|
333
|
+
reports.push(report);
|
|
334
|
+
reportsById.set(idKey, report);
|
|
335
|
+
}
|
|
336
|
+
function normalizeResourceId(resourceId) {
|
|
337
|
+
return (resourceId ?? "").trim().toLowerCase();
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Runs the per-resource analyzer plugins against every resource in the
|
|
341
|
+
* given subscription. Extracted from `analyzeAzureResources` to keep that
|
|
342
|
+
* function readable now that subscription-level analyzers were added.
|
|
343
|
+
*/
|
|
344
|
+
async function runPerResourceAnalysis(args) {
|
|
345
|
+
const { analyzers, clients, config, credential, limit, logger, maxInFlight, reports, reportsById, runCache, sid, taggedResourceIds, thresholds, } = args;
|
|
346
|
+
const resourceClient = new armResources.ResourceManagementClient(credential, sid);
|
|
347
|
+
const inFlight = new Set();
|
|
348
|
+
// Use the async iterator to avoid loading all resources into memory at once.
|
|
349
|
+
for await (const resource of resourceClient.resources.list()) {
|
|
350
|
+
if (!matchesTags(resource, config.filterTags)) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const taggedId = normalizeResourceId(resource.id);
|
|
354
|
+
if (taggedId) {
|
|
355
|
+
taggedResourceIds.add(taggedId);
|
|
356
|
+
}
|
|
357
|
+
// Backpressure: wait for a slot before enqueuing the next task so that
|
|
358
|
+
// the inFlight Set stays bounded by maxInFlight instead of growing to the
|
|
359
|
+
// total resource count in the subscription.
|
|
360
|
+
while (inFlight.size >= maxInFlight) {
|
|
361
|
+
await Promise.race(inFlight).catch(() => undefined);
|
|
362
|
+
}
|
|
363
|
+
const task = limit(async () => {
|
|
364
|
+
const analysis = await analyzeResource(resource, analyzers, clients, runCache, config.preferredLocation, config.timespanDays, thresholds, config.verbose || false);
|
|
365
|
+
if (analysis.suspectedUnused) {
|
|
366
|
+
const reason = analysis.reason || "No specific findings.";
|
|
367
|
+
const report = {
|
|
368
|
+
analysis: { ...analysis, reason },
|
|
369
|
+
findings: findingsFromAnalysisResult({
|
|
370
|
+
reason,
|
|
371
|
+
resourceId: resource.id ?? "",
|
|
372
|
+
severity: analysis.costRisk,
|
|
373
|
+
source: "custom",
|
|
374
|
+
}),
|
|
375
|
+
resource,
|
|
376
|
+
};
|
|
377
|
+
reports.push(report);
|
|
378
|
+
const idKey = (resource.id ?? "").toLowerCase();
|
|
379
|
+
if (idKey)
|
|
380
|
+
reportsById.set(idKey, report);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
inFlight.add(task);
|
|
384
|
+
// Suppress the unhandled-rejection that would occur between task creation
|
|
385
|
+
// and the Promise.allSettled drain below. The .catch() handler is a no-op
|
|
386
|
+
// because the actual error is still visible to allSettled (which logs it)
|
|
387
|
+
// via the original `task` reference kept in inFlight.
|
|
388
|
+
void task.catch(() => undefined).finally(() => inFlight.delete(task));
|
|
389
|
+
}
|
|
390
|
+
// Drain remaining tasks; surface any unexpected errors so they don't
|
|
391
|
+
// disappear silently and produce an incomplete report without a signal.
|
|
392
|
+
const results = await Promise.allSettled(inFlight);
|
|
393
|
+
for (const result of results) {
|
|
394
|
+
if (result.status === "rejected") {
|
|
395
|
+
logger.error(`Resource analysis failed: ${String(result.reason)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Runs every subscription-level analyzer in parallel and merges their
|
|
401
|
+
* findings into the per-resource reports. Findings about resources that
|
|
402
|
+
* the per-resource pass did not surface (typical for Advisor, which
|
|
403
|
+
* reaches SQL DBs, Front Doors, etc.) produce new report entries with a
|
|
404
|
+
* minimal `GenericResource` stub derived from the resource ID.
|
|
405
|
+
*/
|
|
406
|
+
async function runSubscriptionAnalyzers(args) {
|
|
407
|
+
const { analyzers, credential, logger, reports, reportsById, sid, tagFilterActive, taggedResourceIds, verbose, } = args;
|
|
408
|
+
const allFindings = await Promise.all(analyzers.map((a) => a
|
|
409
|
+
.analyze({ credential, subscriptionId: sid, verbose })
|
|
410
|
+
.catch((err) => {
|
|
411
|
+
logger.error(`Subscription analyzer ${a.id} failed: ${String(err)}`);
|
|
412
|
+
return [];
|
|
413
|
+
})));
|
|
414
|
+
for (const findings of allFindings) {
|
|
415
|
+
for (const finding of findings) {
|
|
416
|
+
if (!shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, tagFilterActive)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
mergeFinding(finding, reports, reportsById);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
176
423
|
//# sourceMappingURL=analyzer.js.map
|