@pagopa/dx-savemoney 0.2.5 → 0.3.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/README.md +33 -27
- package/dist/azure/analyzer.d.ts +49 -21
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +369 -93
- package/dist/azure/analyzer.js.map +1 -1
- 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 +8 -0
- package/dist/azure/analyzers/index.d.ts.map +1 -0
- package/dist/azure/analyzers/index.js +6 -0
- package/dist/azure/analyzers/index.js.map +1 -0
- package/dist/azure/analyzers/registry.d.ts +29 -0
- package/dist/azure/analyzers/registry.d.ts.map +1 -0
- package/dist/azure/analyzers/registry.js +79 -0
- package/dist/azure/analyzers/registry.js.map +1 -0
- 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/analyzers/types.d.ts +62 -0
- package/dist/azure/analyzers/types.d.ts.map +1 -0
- package/dist/azure/analyzers/types.js +15 -0
- package/dist/azure/analyzers/types.js.map +1 -0
- package/dist/azure/config.d.ts.map +1 -1
- package/dist/azure/config.js +2 -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/app-service.d.ts +2 -1
- package/dist/azure/resources/app-service.d.ts.map +1 -1
- package/dist/azure/resources/app-service.js +3 -3
- package/dist/azure/resources/app-service.js.map +1 -1
- package/dist/azure/resources/container-app.d.ts +2 -1
- package/dist/azure/resources/container-app.d.ts.map +1 -1
- package/dist/azure/resources/container-app.js +9 -9
- package/dist/azure/resources/container-app.js.map +1 -1
- package/dist/azure/resources/public-ip.d.ts +2 -1
- package/dist/azure/resources/public-ip.d.ts.map +1 -1
- package/dist/azure/resources/public-ip.js +2 -2
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/resources/static-web-app.d.ts +2 -1
- package/dist/azure/resources/static-web-app.d.ts.map +1 -1
- package/dist/azure/resources/static-web-app.js +3 -3
- package/dist/azure/resources/static-web-app.js.map +1 -1
- package/dist/azure/resources/storage.d.ts +2 -1
- package/dist/azure/resources/storage.d.ts.map +1 -1
- package/dist/azure/resources/storage.js +2 -2
- package/dist/azure/resources/storage.js.map +1 -1
- package/dist/azure/resources/vm.d.ts +2 -1
- package/dist/azure/resources/vm.d.ts.map +1 -1
- package/dist/azure/resources/vm.js +3 -3
- package/dist/azure/resources/vm.js.map +1 -1
- package/dist/azure/types.d.ts +34 -1
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/azure/utils.d.ts +35 -3
- package/dist/azure/utils.d.ts.map +1 -1
- package/dist/azure/utils.js +70 -29
- package/dist/azure/utils.js.map +1 -1
- package/dist/finding.d.ts +114 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +51 -0
- package/dist/finding.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +5 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +14 -0
- package/dist/schema.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/finding.test.ts +149 -0
- package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
- package/src/azure/__tests__/report.test.ts +27 -0
- package/src/azure/__tests__/utils.test.ts +164 -2
- package/src/azure/analyzer.ts +513 -182
- package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
- package/src/azure/analyzers/advisor.ts +324 -0
- package/src/azure/analyzers/index.ts +14 -0
- package/src/azure/analyzers/registry.ts +196 -0
- package/src/azure/analyzers/subscription.ts +56 -0
- package/src/azure/analyzers/types.ts +66 -0
- package/src/azure/config.ts +2 -0
- package/src/azure/index.ts +1 -0
- package/src/azure/report.ts +206 -35
- package/src/azure/resources/app-service.ts +4 -0
- package/src/azure/resources/container-app.ts +10 -0
- package/src/azure/resources/public-ip.ts +3 -0
- package/src/azure/resources/static-web-app.ts +4 -0
- package/src/azure/resources/storage.ts +3 -0
- package/src/azure/resources/vm.ts +4 -0
- package/src/azure/types.ts +35 -1
- package/src/azure/utils.ts +110 -39
- package/src/finding.ts +152 -0
- package/src/index.ts +19 -1
- package/src/schema.ts +14 -0
package/src/azure/analyzer.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Azure resource analyzer - Main orchestration logic
|
|
3
|
+
*
|
|
4
|
+
* Iterates every resource in every configured subscription, dispatches it
|
|
5
|
+
* to the registered analyzers (see `./analyzers/registry.ts`), and feeds
|
|
6
|
+
* the resulting findings into the report generator.
|
|
7
|
+
*
|
|
8
|
+
* Phase 0 refactor:
|
|
9
|
+
* - Resources are dispatched through a plugin-style `Analyzer` registry
|
|
10
|
+
* instead of a hard-coded `switch` statement.
|
|
11
|
+
* - Per-subscription resources are analyzed in parallel via a small
|
|
12
|
+
* in-process limiter (default concurrency 8, configurable via
|
|
13
|
+
* `AzureConfig.concurrency`).
|
|
14
|
+
* - Azure Monitor metric calls are memoized for the duration of a run via
|
|
15
|
+
* a per-run `MetricsCache` passed through `AnalyzerContext`, so concurrent
|
|
16
|
+
* calls to `analyzeAzureResources` stay fully isolated.
|
|
17
|
+
*
|
|
18
|
+
* The output schema (`AzureDetailedResourceReport`) is unchanged so the
|
|
19
|
+
* existing report formats keep working untouched.
|
|
3
20
|
*/
|
|
4
21
|
|
|
22
|
+
import type { TokenCredential } from "@azure/identity";
|
|
23
|
+
|
|
5
24
|
import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
|
|
6
25
|
import { WebSiteManagementClient } from "@azure/arm-appservice";
|
|
7
26
|
import { ComputeManagementClient } from "@azure/arm-compute";
|
|
@@ -10,143 +29,194 @@ import { NetworkManagementClient } from "@azure/arm-network";
|
|
|
10
29
|
import * as armResources from "@azure/arm-resources";
|
|
11
30
|
import { DefaultAzureCredential } from "@azure/identity";
|
|
12
31
|
import { getLogger } from "@logtape/logtape";
|
|
32
|
+
import pLimit from "p-limit";
|
|
13
33
|
|
|
14
|
-
import type {
|
|
34
|
+
import type { Finding } from "../finding.js";
|
|
35
|
+
import type {
|
|
36
|
+
AzureConfig,
|
|
37
|
+
AzureDetailedResourceReport,
|
|
38
|
+
AzureSource,
|
|
39
|
+
} from "./types.js";
|
|
15
40
|
|
|
41
|
+
import { findingsFromAnalysisResult } from "../finding.js";
|
|
16
42
|
import {
|
|
17
43
|
type AnalysisResult,
|
|
44
|
+
type CostRisk,
|
|
18
45
|
DEFAULT_THRESHOLDS,
|
|
19
46
|
mergeResults,
|
|
20
47
|
type Thresholds,
|
|
21
48
|
} from "../types.js";
|
|
22
|
-
import { generateReport } from "./report.js";
|
|
23
49
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
type Analyzer,
|
|
51
|
+
type AnalyzerContext,
|
|
52
|
+
type AzureClients,
|
|
53
|
+
createDefaultAnalyzers,
|
|
54
|
+
createDefaultSubscriptionAnalyzers,
|
|
55
|
+
type SubscriptionAnalyzer,
|
|
56
|
+
} from "./analyzers/index.js";
|
|
57
|
+
import { matchesTags, type MetricsCache } from "./utils.js";
|
|
58
|
+
|
|
59
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
60
|
+
const DEFAULT_SOURCES: AzureSource[] = ["advisor", "custom"];
|
|
61
|
+
|
|
62
|
+
const RISK_ORDER: Record<CostRisk, number> = { high: 0, low: 2, medium: 1 };
|
|
35
63
|
|
|
36
64
|
/**
|
|
37
|
-
* Analyzes resources in
|
|
65
|
+
* Analyzes resources in every configured Azure subscription and returns
|
|
66
|
+
* the structured report.
|
|
67
|
+
*
|
|
68
|
+
* Phase 1 change: this function no longer emits a report to stdout. The
|
|
69
|
+
* orchestrator returns `AzureDetailedResourceReport[]` and the caller
|
|
70
|
+
* (the CLI today, future GUI / API consumers tomorrow) chooses how to
|
|
71
|
+
* render it via `generateReport`.
|
|
38
72
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
73
|
+
* Each entry carries both the legacy `analysis` summary and the unified
|
|
74
|
+
* `findings: Finding[]` so consumers can pick the level of detail they
|
|
75
|
+
* need. Azure Advisor recommendations and per-resource analyzer outputs
|
|
76
|
+
* are merged into the same entry when they refer to the same resource.
|
|
77
|
+
*
|
|
78
|
+
* @param config - Azure configuration with subscription IDs and settings.
|
|
79
|
+
* `config.sources` controls which analyzers run.
|
|
41
80
|
*/
|
|
42
81
|
export async function analyzeAzureResources(
|
|
43
82
|
config: AzureConfig,
|
|
44
|
-
|
|
45
|
-
) {
|
|
83
|
+
): Promise<AzureDetailedResourceReport[]> {
|
|
46
84
|
const logger = getLogger(["savemoney", "azure"]);
|
|
47
85
|
const credential = new DefaultAzureCredential();
|
|
48
86
|
const allReports: AzureDetailedResourceReport[] = [];
|
|
49
87
|
|
|
88
|
+
const sources = config.sources ?? DEFAULT_SOURCES;
|
|
89
|
+
const customEnabled = sources.includes("custom");
|
|
90
|
+
const advisorEnabled = sources.includes("advisor");
|
|
91
|
+
|
|
92
|
+
const analyzers = createDefaultAnalyzers();
|
|
93
|
+
const subscriptionAnalyzers = advisorEnabled
|
|
94
|
+
? createDefaultSubscriptionAnalyzers()
|
|
95
|
+
: [];
|
|
96
|
+
const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
|
|
97
|
+
|
|
98
|
+
// Normalise concurrency the same way p-limit does to keep maxInFlight
|
|
99
|
+
// consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
|
|
100
|
+
// either deadlock or silently disable backpressure.
|
|
101
|
+
const rawConcurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
|
|
102
|
+
const concurrency = Number.isFinite(rawConcurrency)
|
|
103
|
+
? Math.max(1, Math.floor(rawConcurrency))
|
|
104
|
+
: 1;
|
|
105
|
+
const limit = pLimit(concurrency);
|
|
106
|
+
|
|
107
|
+
// Bound the in-flight Set to `2 × concurrency` so memory stays proportional
|
|
108
|
+
// to the limiter width, not the total resource count in a subscription.
|
|
109
|
+
const maxInFlight = concurrency * 2;
|
|
110
|
+
|
|
50
111
|
for (const subscriptionId of config.subscriptionIds) {
|
|
51
112
|
logger.info(`Analyzing subscription: ${subscriptionId}`);
|
|
52
113
|
|
|
53
|
-
const
|
|
54
|
-
credential,
|
|
55
|
-
subscriptionId.trim(),
|
|
56
|
-
);
|
|
57
|
-
const monitorClient = new MonitorClient(credential, subscriptionId.trim());
|
|
58
|
-
const computeClient = new ComputeManagementClient(
|
|
59
|
-
credential,
|
|
60
|
-
subscriptionId.trim(),
|
|
61
|
-
);
|
|
62
|
-
const networkClient = new NetworkManagementClient(
|
|
63
|
-
credential,
|
|
64
|
-
subscriptionId.trim(),
|
|
65
|
-
);
|
|
66
|
-
const webSiteClient = new WebSiteManagementClient(
|
|
67
|
-
credential,
|
|
68
|
-
subscriptionId.trim(),
|
|
69
|
-
);
|
|
70
|
-
const containerAppsClient = new ContainerAppsAPIClient(
|
|
71
|
-
credential,
|
|
72
|
-
subscriptionId.trim(),
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
|
|
76
|
-
|
|
77
|
-
// Use the async iterator to avoid memory explosion for large environments
|
|
78
|
-
for await (const resource of resourceClient.resources.list()) {
|
|
79
|
-
// Skip resources that don't match the requested tag filter
|
|
80
|
-
if (!matchesTags(resource, config.filterTags)) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
114
|
+
const sid = subscriptionId.trim();
|
|
83
115
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
// Per-subscription index keyed by lowercased resourceId so subscription-
|
|
117
|
+
// level analyzers (Advisor, future quotas, …) can merge their findings
|
|
118
|
+
// back into the matching resource report.
|
|
119
|
+
const reportsById = new Map<string, AzureDetailedResourceReport>();
|
|
120
|
+
const taggedResourceIds = new Set<string>();
|
|
121
|
+
|
|
122
|
+
// Fresh cache per subscription — bounds peak memory to one subscription's
|
|
123
|
+
// worth of metrics and keeps concurrent analyzeAzureResources calls isolated.
|
|
124
|
+
const runCache: MetricsCache = new Map();
|
|
125
|
+
|
|
126
|
+
const clients: AzureClients = {
|
|
127
|
+
compute: new ComputeManagementClient(credential, sid),
|
|
128
|
+
containerApps: new ContainerAppsAPIClient(credential, sid),
|
|
129
|
+
monitor: new MonitorClient(credential, sid),
|
|
130
|
+
network: new NetworkManagementClient(credential, sid),
|
|
131
|
+
webSite: new WebSiteManagementClient(credential, sid),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (customEnabled) {
|
|
135
|
+
await runPerResourceAnalysis({
|
|
136
|
+
analyzers,
|
|
137
|
+
clients,
|
|
138
|
+
config,
|
|
139
|
+
credential,
|
|
140
|
+
limit,
|
|
141
|
+
logger,
|
|
142
|
+
maxInFlight,
|
|
143
|
+
reports: allReports,
|
|
144
|
+
reportsById,
|
|
145
|
+
runCache,
|
|
146
|
+
sid,
|
|
147
|
+
taggedResourceIds,
|
|
93
148
|
thresholds,
|
|
94
|
-
|
|
95
|
-
|
|
149
|
+
});
|
|
150
|
+
}
|
|
96
151
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
152
|
+
if (!customEnabled && advisorEnabled && hasTagFilter(config.filterTags)) {
|
|
153
|
+
await collectTaggedResourceIds({
|
|
154
|
+
config,
|
|
155
|
+
credential,
|
|
156
|
+
sid,
|
|
157
|
+
taggedResourceIds,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (advisorEnabled && subscriptionAnalyzers.length > 0) {
|
|
162
|
+
await runSubscriptionAnalyzers({
|
|
163
|
+
analyzers: subscriptionAnalyzers,
|
|
164
|
+
credential,
|
|
165
|
+
logger,
|
|
166
|
+
reports: allReports,
|
|
167
|
+
reportsById,
|
|
168
|
+
sid,
|
|
169
|
+
tagFilterActive: hasTagFilter(config.filterTags),
|
|
170
|
+
taggedResourceIds,
|
|
171
|
+
verbose: config.verbose ?? false,
|
|
172
|
+
});
|
|
107
173
|
}
|
|
108
174
|
}
|
|
109
175
|
|
|
110
|
-
// Sort to make the output more readable
|
|
176
|
+
// Sort to make the output more readable:
|
|
177
|
+
// - Subscription-scoped findings (Reserved Instances, savings plans, ...)
|
|
178
|
+
// sink to the bottom: they aggregate many recommendations into a single
|
|
179
|
+
// fat row and are easier to consume after the per-resource rows.
|
|
180
|
+
// - Within each group, sort by cost risk then by resource name.
|
|
111
181
|
allReports.sort((a, b) => {
|
|
182
|
+
const aSub = isSubscriptionScopedReport(a);
|
|
183
|
+
const bSub = isSubscriptionScopedReport(b);
|
|
184
|
+
if (aSub !== bSub) return aSub ? 1 : -1;
|
|
112
185
|
if (a.analysis.costRisk === b.analysis.costRisk)
|
|
113
186
|
return (a.resource.name ?? "").localeCompare(b.resource.name ?? "");
|
|
114
|
-
|
|
115
|
-
return order[a.analysis.costRisk] - order[b.analysis.costRisk];
|
|
187
|
+
return RISK_ORDER[a.analysis.costRisk] - RISK_ORDER[b.analysis.costRisk];
|
|
116
188
|
});
|
|
117
189
|
|
|
118
|
-
|
|
190
|
+
return allReports;
|
|
119
191
|
}
|
|
120
192
|
|
|
121
193
|
/**
|
|
122
|
-
* Analyzes a single Azure resource
|
|
194
|
+
* Analyzes a single Azure resource by dispatching it to every registered
|
|
195
|
+
* analyzer that supports it. Generic checks (missing tags, location
|
|
196
|
+
* mismatch) are applied around the analyzer-specific logic.
|
|
123
197
|
*
|
|
124
|
-
* @param resource
|
|
125
|
-
* @param
|
|
126
|
-
* @param
|
|
127
|
-
* @param
|
|
128
|
-
* @param
|
|
129
|
-
* @param
|
|
130
|
-
* @param
|
|
131
|
-
* @param
|
|
132
|
-
* @param verbose - Whether verbose logging is enabled
|
|
198
|
+
* @param resource The Azure resource to analyze
|
|
199
|
+
* @param analyzers Registered analyzers (typically `createDefaultAnalyzers()`)
|
|
200
|
+
* @param clients Bundle of Azure SDK clients shared across analyzers
|
|
201
|
+
* @param metricsCache Run-scoped metrics cache to pass through to analyzers
|
|
202
|
+
* @param preferredLocation Preferred Azure region (resources elsewhere are flagged)
|
|
203
|
+
* @param timespanDays Look-back window for Azure Monitor metrics
|
|
204
|
+
* @param thresholds Numeric thresholds used during analysis
|
|
205
|
+
* @param verbose Whether verbose logging is enabled
|
|
133
206
|
* @returns Analysis result with cost risk and reason
|
|
134
207
|
*/
|
|
135
208
|
export async function analyzeResource(
|
|
136
209
|
resource: armResources.GenericResource,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
webSiteClient: WebSiteManagementClient,
|
|
141
|
-
containerAppsClient: ContainerAppsAPIClient,
|
|
210
|
+
analyzers: Analyzer[],
|
|
211
|
+
clients: AzureClients,
|
|
212
|
+
metricsCache: MetricsCache = new Map(),
|
|
142
213
|
preferredLocation: string,
|
|
143
214
|
timespanDays: number,
|
|
144
215
|
thresholds: Thresholds,
|
|
145
216
|
verbose = false,
|
|
146
217
|
): Promise<AnalysisResult> {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
costRisk: "low" as "high" | "low" | "medium",
|
|
218
|
+
let result: AnalysisResult = {
|
|
219
|
+
costRisk: "low",
|
|
150
220
|
reason: "",
|
|
151
221
|
suspectedUnused: false,
|
|
152
222
|
};
|
|
@@ -157,109 +227,370 @@ export async function analyzeResource(
|
|
|
157
227
|
result.reason += "No tags found. ";
|
|
158
228
|
}
|
|
159
229
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
case "microsoft.compute/disks": {
|
|
175
|
-
const diskResult = await analyzeDisk(resource, computeClient, verbose);
|
|
176
|
-
result = mergeResults(result, diskResult);
|
|
177
|
-
break;
|
|
230
|
+
const ctx: AnalyzerContext = {
|
|
231
|
+
clients,
|
|
232
|
+
metricsCache,
|
|
233
|
+
preferredLocation,
|
|
234
|
+
resource,
|
|
235
|
+
thresholds,
|
|
236
|
+
timespanDays,
|
|
237
|
+
verbose,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let matched = false;
|
|
241
|
+
for (const analyzer of analyzers) {
|
|
242
|
+
if (!analyzer.supports(resource)) {
|
|
243
|
+
continue;
|
|
178
244
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
245
|
+
matched = true;
|
|
246
|
+
const specific = await analyzer.analyze(ctx);
|
|
247
|
+
result = mergeResults(result, specific);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!matched) {
|
|
251
|
+
result.reason += "No specific analysis for this resource type. ";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Generic check for location
|
|
255
|
+
if (
|
|
256
|
+
resource.location &&
|
|
257
|
+
!resource.location.toLowerCase().includes(preferredLocation.toLowerCase())
|
|
258
|
+
) {
|
|
259
|
+
result.reason += `Resource not in preferred location (${preferredLocation}). `;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { ...result, reason: result.reason.trim() };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function shouldIncludeAdvisorFindingForTags(
|
|
266
|
+
finding: Finding,
|
|
267
|
+
taggedResourceIds: ReadonlySet<string>,
|
|
268
|
+
tagFilterActive: boolean,
|
|
269
|
+
): boolean {
|
|
270
|
+
if (!tagFilterActive) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
if (finding.source !== "advisor") {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (!isResourceScopedFinding(finding.resourceId)) {
|
|
277
|
+
// Subscription-level findings are intentionally always global.
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
return taggedResourceIds.has(normalizeResourceId(finding.resourceId));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Derives a legacy `AnalysisResult` summary from a `Finding`, so the
|
|
285
|
+
* existing report formats keep working untouched on Advisor-only
|
|
286
|
+
* resources.
|
|
287
|
+
*/
|
|
288
|
+
function analysisFromFinding(finding: Finding): AnalysisResult {
|
|
289
|
+
const trimmed = finding.reason.trim();
|
|
290
|
+
const reason = trimmed.endsWith(".") ? trimmed : `${trimmed}.`;
|
|
291
|
+
return {
|
|
292
|
+
costRisk: finding.severity,
|
|
293
|
+
reason,
|
|
294
|
+
suspectedUnused: true,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Builds a minimal `GenericResource` from a resource ID. Used when a
|
|
300
|
+
* subscription-level analyzer surfaces a resource the per-resource pass
|
|
301
|
+
* did not see — we have neither tags nor location, but `name` and `type`
|
|
302
|
+
* can be parsed deterministically from the resource ID structure.
|
|
303
|
+
*
|
|
304
|
+
* Handles three shapes:
|
|
305
|
+
* - Fully qualified: /subscriptions/{sub}/resourceGroups/{rg}/providers/{provider}/{type}/{name}
|
|
306
|
+
* - Resource-group-scoped: /subscriptions/{sub}/resourceGroups/{rg}
|
|
307
|
+
* - Subscription-scoped: /subscriptions/{sub}
|
|
308
|
+
*/
|
|
309
|
+
function buildResourceStub(resourceId: string): armResources.GenericResource {
|
|
310
|
+
const parts = resourceId.split("/").filter((s) => s.length > 0);
|
|
311
|
+
const providersIdx = parts.indexOf("providers");
|
|
312
|
+
|
|
313
|
+
if (providersIdx >= 0 && parts.length > providersIdx + 2) {
|
|
314
|
+
// Fully qualified resource ID.
|
|
315
|
+
const provider = parts[providersIdx + 1];
|
|
316
|
+
const tail = parts.slice(providersIdx + 2); // [type, name, subtype, subname, ...]
|
|
317
|
+
const typeSegments: string[] = [provider];
|
|
318
|
+
for (let i = 0; i < tail.length; i += 2) {
|
|
319
|
+
typeSegments.push(tail[i]);
|
|
190
320
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
321
|
+
return {
|
|
322
|
+
id: resourceId,
|
|
323
|
+
name: tail[tail.length - 1],
|
|
324
|
+
type: typeSegments.join("/"),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const rgIdx = parts.indexOf("resourceGroups");
|
|
329
|
+
if (rgIdx >= 0 && parts.length > rgIdx + 1) {
|
|
330
|
+
// Resource-group-scoped ID.
|
|
331
|
+
return {
|
|
332
|
+
id: resourceId,
|
|
333
|
+
name: parts[rgIdx + 1],
|
|
334
|
+
type: "Microsoft.Resources/resourceGroups",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const subIdx = parts.indexOf("subscriptions");
|
|
339
|
+
if (subIdx >= 0 && parts.length > subIdx + 1) {
|
|
340
|
+
// Subscription-scoped ID (e.g. Reserved Instance recommendations).
|
|
341
|
+
return {
|
|
342
|
+
id: resourceId,
|
|
343
|
+
name: parts[subIdx + 1],
|
|
344
|
+
type: "Microsoft.Subscription",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Fallback for completely unknown shapes.
|
|
349
|
+
return { id: resourceId, name: parts[parts.length - 1], type: undefined };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function collectTaggedResourceIds(args: {
|
|
353
|
+
config: AzureConfig;
|
|
354
|
+
credential: TokenCredential;
|
|
355
|
+
sid: string;
|
|
356
|
+
taggedResourceIds: Set<string>;
|
|
357
|
+
}): Promise<void> {
|
|
358
|
+
const { config, credential, sid, taggedResourceIds } = args;
|
|
359
|
+
const resourceClient = new armResources.ResourceManagementClient(
|
|
360
|
+
credential,
|
|
361
|
+
sid,
|
|
362
|
+
);
|
|
363
|
+
for await (const resource of resourceClient.resources.list()) {
|
|
364
|
+
if (!matchesTags(resource, config.filterTags)) {
|
|
365
|
+
continue;
|
|
195
366
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
networkClient,
|
|
200
|
-
verbose,
|
|
201
|
-
);
|
|
202
|
-
result = mergeResults(result, peResult);
|
|
203
|
-
break;
|
|
367
|
+
const resourceId = normalizeResourceId(resource.id);
|
|
368
|
+
if (resourceId) {
|
|
369
|
+
taggedResourceIds.add(resourceId);
|
|
204
370
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function hasTagFilter(filterTags: Map<string, string> | undefined): boolean {
|
|
375
|
+
return Boolean(filterTags && filterTags.size > 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isResourceScopedFinding(resourceId: string): boolean {
|
|
379
|
+
return /\/providers\//i.test(resourceId);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isSubscriptionScopedReport(r: AzureDetailedResourceReport): boolean {
|
|
383
|
+
return r.resource.type === "Microsoft.Subscription";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Inserts a `Finding` into the right report entry, creating a stub
|
|
388
|
+
* resource entry on the fly when the finding refers to a resource that
|
|
389
|
+
* the per-resource pass did not analyze.
|
|
390
|
+
*/
|
|
391
|
+
function mergeFinding(
|
|
392
|
+
finding: Finding,
|
|
393
|
+
reports: AzureDetailedResourceReport[],
|
|
394
|
+
reportsById: Map<string, AzureDetailedResourceReport>,
|
|
395
|
+
): void {
|
|
396
|
+
const idKey = finding.resourceId.toLowerCase();
|
|
397
|
+
const existing = reportsById.get(idKey);
|
|
398
|
+
if (existing) {
|
|
399
|
+
existing.findings = [...(existing.findings ?? []), finding];
|
|
400
|
+
const added = analysisFromFinding(finding);
|
|
401
|
+
// Use max costRisk (not last-wins) and join reasons with a space so we
|
|
402
|
+
// don't produce "Sentence one.Sentence two." when the existing reason is
|
|
403
|
+
// already trimmed (i.e. has no trailing separator space).
|
|
404
|
+
existing.analysis = {
|
|
405
|
+
costRisk:
|
|
406
|
+
RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
|
|
407
|
+
? existing.analysis.costRisk
|
|
408
|
+
: added.costRisk,
|
|
409
|
+
reason:
|
|
410
|
+
existing.analysis.reason && added.reason
|
|
411
|
+
? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
|
|
412
|
+
: existing.analysis.reason || added.reason,
|
|
413
|
+
suspectedUnused:
|
|
414
|
+
existing.analysis.suspectedUnused || added.suspectedUnused,
|
|
415
|
+
};
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const stub = buildResourceStub(finding.resourceId);
|
|
420
|
+
const report: AzureDetailedResourceReport = {
|
|
421
|
+
analysis: analysisFromFinding(finding),
|
|
422
|
+
findings: [finding],
|
|
423
|
+
resource: stub,
|
|
424
|
+
};
|
|
425
|
+
reports.push(report);
|
|
426
|
+
reportsById.set(idKey, report);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function normalizeResourceId(resourceId: string | undefined): string {
|
|
430
|
+
return (resourceId ?? "").trim().toLowerCase();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Runs the per-resource analyzer plugins against every resource in the
|
|
435
|
+
* given subscription. Extracted from `analyzeAzureResources` to keep that
|
|
436
|
+
* function readable now that subscription-level analyzers were added.
|
|
437
|
+
*/
|
|
438
|
+
async function runPerResourceAnalysis(args: {
|
|
439
|
+
analyzers: Analyzer[];
|
|
440
|
+
clients: AzureClients;
|
|
441
|
+
config: AzureConfig;
|
|
442
|
+
credential: TokenCredential;
|
|
443
|
+
limit: ReturnType<typeof pLimit>;
|
|
444
|
+
logger: ReturnType<typeof getLogger>;
|
|
445
|
+
maxInFlight: number;
|
|
446
|
+
reports: AzureDetailedResourceReport[];
|
|
447
|
+
reportsById: Map<string, AzureDetailedResourceReport>;
|
|
448
|
+
runCache: MetricsCache;
|
|
449
|
+
sid: string;
|
|
450
|
+
taggedResourceIds: Set<string>;
|
|
451
|
+
thresholds: Thresholds;
|
|
452
|
+
}): Promise<void> {
|
|
453
|
+
const {
|
|
454
|
+
analyzers,
|
|
455
|
+
clients,
|
|
456
|
+
config,
|
|
457
|
+
credential,
|
|
458
|
+
limit,
|
|
459
|
+
logger,
|
|
460
|
+
maxInFlight,
|
|
461
|
+
reports,
|
|
462
|
+
reportsById,
|
|
463
|
+
runCache,
|
|
464
|
+
sid,
|
|
465
|
+
taggedResourceIds,
|
|
466
|
+
thresholds,
|
|
467
|
+
} = args;
|
|
468
|
+
const resourceClient = new armResources.ResourceManagementClient(
|
|
469
|
+
credential,
|
|
470
|
+
sid,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const inFlight = new Set<Promise<void>>();
|
|
474
|
+
|
|
475
|
+
// Use the async iterator to avoid loading all resources into memory at once.
|
|
476
|
+
for await (const resource of resourceClient.resources.list()) {
|
|
477
|
+
if (!matchesTags(resource, config.filterTags)) {
|
|
478
|
+
continue;
|
|
216
479
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
timespanDays,
|
|
222
|
-
thresholds,
|
|
223
|
-
verbose,
|
|
224
|
-
);
|
|
225
|
-
result = mergeResults(result, storageResult);
|
|
226
|
-
break;
|
|
480
|
+
|
|
481
|
+
const taggedId = normalizeResourceId(resource.id);
|
|
482
|
+
if (taggedId) {
|
|
483
|
+
taggedResourceIds.add(taggedId);
|
|
227
484
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
thresholds,
|
|
235
|
-
verbose,
|
|
236
|
-
);
|
|
237
|
-
result = mergeResults(result, aspResult);
|
|
238
|
-
break;
|
|
485
|
+
|
|
486
|
+
// Backpressure: wait for a slot before enqueuing the next task so that
|
|
487
|
+
// the inFlight Set stays bounded by maxInFlight instead of growing to the
|
|
488
|
+
// total resource count in the subscription.
|
|
489
|
+
while (inFlight.size >= maxInFlight) {
|
|
490
|
+
await Promise.race(inFlight).catch(() => undefined);
|
|
239
491
|
}
|
|
240
|
-
|
|
241
|
-
|
|
492
|
+
|
|
493
|
+
const task: Promise<void> = limit(async () => {
|
|
494
|
+
const analysis = await analyzeResource(
|
|
242
495
|
resource,
|
|
243
|
-
|
|
244
|
-
|
|
496
|
+
analyzers,
|
|
497
|
+
clients,
|
|
498
|
+
runCache,
|
|
499
|
+
config.preferredLocation,
|
|
500
|
+
config.timespanDays,
|
|
245
501
|
thresholds,
|
|
246
|
-
verbose,
|
|
502
|
+
config.verbose || false,
|
|
247
503
|
);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
504
|
+
|
|
505
|
+
if (analysis.suspectedUnused) {
|
|
506
|
+
const reason = analysis.reason || "No specific findings.";
|
|
507
|
+
const report: AzureDetailedResourceReport = {
|
|
508
|
+
analysis: { ...analysis, reason },
|
|
509
|
+
findings: findingsFromAnalysisResult({
|
|
510
|
+
reason,
|
|
511
|
+
resourceId: resource.id ?? "",
|
|
512
|
+
severity: analysis.costRisk,
|
|
513
|
+
source: "custom",
|
|
514
|
+
}),
|
|
515
|
+
resource,
|
|
516
|
+
};
|
|
517
|
+
reports.push(report);
|
|
518
|
+
const idKey = (resource.id ?? "").toLowerCase();
|
|
519
|
+
if (idKey) reportsById.set(idKey, report);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
inFlight.add(task);
|
|
524
|
+
// Suppress the unhandled-rejection that would occur between task creation
|
|
525
|
+
// and the Promise.allSettled drain below. The .catch() handler is a no-op
|
|
526
|
+
// because the actual error is still visible to allSettled (which logs it)
|
|
527
|
+
// via the original `task` reference kept in inFlight.
|
|
528
|
+
void task.catch(() => undefined).finally(() => inFlight.delete(task));
|
|
254
529
|
}
|
|
255
530
|
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
531
|
+
// Drain remaining tasks; surface any unexpected errors so they don't
|
|
532
|
+
// disappear silently and produce an incomplete report without a signal.
|
|
533
|
+
const results = await Promise.allSettled(inFlight);
|
|
534
|
+
for (const result of results) {
|
|
535
|
+
if (result.status === "rejected") {
|
|
536
|
+
logger.error(`Resource analysis failed: ${String(result.reason)}`);
|
|
537
|
+
}
|
|
262
538
|
}
|
|
539
|
+
}
|
|
263
540
|
|
|
264
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Runs every subscription-level analyzer in parallel and merges their
|
|
543
|
+
* findings into the per-resource reports. Findings about resources that
|
|
544
|
+
* the per-resource pass did not surface (typical for Advisor, which
|
|
545
|
+
* reaches SQL DBs, Front Doors, etc.) produce new report entries with a
|
|
546
|
+
* minimal `GenericResource` stub derived from the resource ID.
|
|
547
|
+
*/
|
|
548
|
+
async function runSubscriptionAnalyzers(args: {
|
|
549
|
+
analyzers: SubscriptionAnalyzer[];
|
|
550
|
+
credential: TokenCredential;
|
|
551
|
+
logger: ReturnType<typeof getLogger>;
|
|
552
|
+
reports: AzureDetailedResourceReport[];
|
|
553
|
+
reportsById: Map<string, AzureDetailedResourceReport>;
|
|
554
|
+
sid: string;
|
|
555
|
+
tagFilterActive: boolean;
|
|
556
|
+
taggedResourceIds: Set<string>;
|
|
557
|
+
verbose: boolean;
|
|
558
|
+
}): Promise<void> {
|
|
559
|
+
const {
|
|
560
|
+
analyzers,
|
|
561
|
+
credential,
|
|
562
|
+
logger,
|
|
563
|
+
reports,
|
|
564
|
+
reportsById,
|
|
565
|
+
sid,
|
|
566
|
+
tagFilterActive,
|
|
567
|
+
taggedResourceIds,
|
|
568
|
+
verbose,
|
|
569
|
+
} = args;
|
|
570
|
+
|
|
571
|
+
const allFindings = await Promise.all(
|
|
572
|
+
analyzers.map((a) =>
|
|
573
|
+
a
|
|
574
|
+
.analyze({ credential, subscriptionId: sid, verbose })
|
|
575
|
+
.catch((err: unknown) => {
|
|
576
|
+
logger.error(`Subscription analyzer ${a.id} failed: ${String(err)}`);
|
|
577
|
+
return [] as Finding[];
|
|
578
|
+
}),
|
|
579
|
+
),
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
for (const findings of allFindings) {
|
|
583
|
+
for (const finding of findings) {
|
|
584
|
+
if (
|
|
585
|
+
!shouldIncludeAdvisorFindingForTags(
|
|
586
|
+
finding,
|
|
587
|
+
taggedResourceIds,
|
|
588
|
+
tagFilterActive,
|
|
589
|
+
)
|
|
590
|
+
) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
mergeFinding(finding, reports, reportsById);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
265
596
|
}
|