@pagopa/dx-savemoney 0.2.5 → 0.2.6

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.
Files changed (80) hide show
  1. package/dist/azure/analyzer.d.ts +31 -16
  2. package/dist/azure/analyzer.d.ts.map +1 -1
  3. package/dist/azure/analyzer.js +110 -81
  4. package/dist/azure/analyzer.js.map +1 -1
  5. package/dist/azure/analyzers/index.d.ts +6 -0
  6. package/dist/azure/analyzers/index.d.ts.map +1 -0
  7. package/dist/azure/analyzers/index.js +5 -0
  8. package/dist/azure/analyzers/index.js.map +1 -0
  9. package/dist/azure/analyzers/registry.d.ts +21 -0
  10. package/dist/azure/analyzers/registry.d.ts.map +1 -0
  11. package/dist/azure/analyzers/registry.js +69 -0
  12. package/dist/azure/analyzers/registry.js.map +1 -0
  13. package/dist/azure/analyzers/types.d.ts +62 -0
  14. package/dist/azure/analyzers/types.d.ts.map +1 -0
  15. package/dist/azure/analyzers/types.js +15 -0
  16. package/dist/azure/analyzers/types.js.map +1 -0
  17. package/dist/azure/config.d.ts.map +1 -1
  18. package/dist/azure/config.js +1 -0
  19. package/dist/azure/config.js.map +1 -1
  20. package/dist/azure/resources/app-service.d.ts +2 -1
  21. package/dist/azure/resources/app-service.d.ts.map +1 -1
  22. package/dist/azure/resources/app-service.js +3 -3
  23. package/dist/azure/resources/app-service.js.map +1 -1
  24. package/dist/azure/resources/container-app.d.ts +2 -1
  25. package/dist/azure/resources/container-app.d.ts.map +1 -1
  26. package/dist/azure/resources/container-app.js +9 -9
  27. package/dist/azure/resources/container-app.js.map +1 -1
  28. package/dist/azure/resources/public-ip.d.ts +2 -1
  29. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  30. package/dist/azure/resources/public-ip.js +2 -2
  31. package/dist/azure/resources/public-ip.js.map +1 -1
  32. package/dist/azure/resources/static-web-app.d.ts +2 -1
  33. package/dist/azure/resources/static-web-app.d.ts.map +1 -1
  34. package/dist/azure/resources/static-web-app.js +3 -3
  35. package/dist/azure/resources/static-web-app.js.map +1 -1
  36. package/dist/azure/resources/storage.d.ts +2 -1
  37. package/dist/azure/resources/storage.d.ts.map +1 -1
  38. package/dist/azure/resources/storage.js +2 -2
  39. package/dist/azure/resources/storage.js.map +1 -1
  40. package/dist/azure/resources/vm.d.ts +2 -1
  41. package/dist/azure/resources/vm.d.ts.map +1 -1
  42. package/dist/azure/resources/vm.js +3 -3
  43. package/dist/azure/resources/vm.js.map +1 -1
  44. package/dist/azure/types.d.ts +6 -0
  45. package/dist/azure/types.d.ts.map +1 -1
  46. package/dist/azure/utils.d.ts +35 -3
  47. package/dist/azure/utils.d.ts.map +1 -1
  48. package/dist/azure/utils.js +70 -29
  49. package/dist/azure/utils.js.map +1 -1
  50. package/dist/finding.d.ts +114 -0
  51. package/dist/finding.d.ts.map +1 -0
  52. package/dist/finding.js +51 -0
  53. package/dist/finding.js.map +1 -0
  54. package/dist/index.d.ts +3 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/schema.d.ts +1 -0
  59. package/dist/schema.d.ts.map +1 -1
  60. package/dist/schema.js +5 -0
  61. package/dist/schema.js.map +1 -1
  62. package/package.json +2 -1
  63. package/src/__tests__/finding.test.ts +149 -0
  64. package/src/azure/__tests__/utils.test.ts +164 -2
  65. package/src/azure/analyzer.ts +140 -165
  66. package/src/azure/analyzers/index.ts +6 -0
  67. package/src/azure/analyzers/registry.ts +184 -0
  68. package/src/azure/analyzers/types.ts +66 -0
  69. package/src/azure/config.ts +1 -0
  70. package/src/azure/resources/app-service.ts +4 -0
  71. package/src/azure/resources/container-app.ts +10 -0
  72. package/src/azure/resources/public-ip.ts +3 -0
  73. package/src/azure/resources/static-web-app.ts +4 -0
  74. package/src/azure/resources/storage.ts +3 -0
  75. package/src/azure/resources/vm.ts +4 -0
  76. package/src/azure/types.ts +6 -0
  77. package/src/azure/utils.ts +110 -39
  78. package/src/finding.ts +152 -0
  79. package/src/index.ts +18 -0
  80. package/src/schema.ts +5 -0
@@ -1,5 +1,22 @@
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
 
5
22
  import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
@@ -10,6 +27,7 @@ import { NetworkManagementClient } from "@azure/arm-network";
10
27
  import * as armResources from "@azure/arm-resources";
11
28
  import { DefaultAzureCredential } from "@azure/identity";
12
29
  import { getLogger } from "@logtape/logtape";
30
+ import pLimit from "p-limit";
13
31
 
14
32
  import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
15
33
 
@@ -19,19 +37,16 @@ import {
19
37
  mergeResults,
20
38
  type Thresholds,
21
39
  } from "../types.js";
22
- import { generateReport } from "./report.js";
23
40
  import {
24
- analyzeAppServicePlan,
25
- analyzeContainerApp,
26
- analyzeDisk,
27
- analyzeNic,
28
- analyzePrivateEndpoint,
29
- analyzePublicIp,
30
- analyzeStaticSite,
31
- analyzeStorageAccount,
32
- analyzeVM,
33
- } from "./resources/index.js";
34
- import { matchesTags } from "./utils.js";
41
+ type Analyzer,
42
+ type AnalyzerContext,
43
+ type AzureClients,
44
+ createDefaultAnalyzers,
45
+ } from "./analyzers/index.js";
46
+ import { generateReport } from "./report.js";
47
+ import { matchesTags, type MetricsCache } from "./utils.js";
48
+
49
+ const DEFAULT_CONCURRENCY = 8;
35
50
 
36
51
  /**
37
52
  * Analyzes resources in multiple Azure subscriptions and generates a report.
@@ -47,62 +62,96 @@ export async function analyzeAzureResources(
47
62
  const credential = new DefaultAzureCredential();
48
63
  const allReports: AzureDetailedResourceReport[] = [];
49
64
 
65
+ const analyzers = createDefaultAnalyzers();
66
+ const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
67
+
68
+ // Normalise concurrency the same way p-limit does to keep maxInFlight
69
+ // consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
70
+ // either deadlock or silently disable backpressure.
71
+ const rawConcurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
72
+ const concurrency = Number.isFinite(rawConcurrency)
73
+ ? Math.max(1, Math.floor(rawConcurrency))
74
+ : 1;
75
+ const limit = pLimit(concurrency);
76
+
77
+ // Bound the in-flight Set to `2 × concurrency` so memory stays proportional
78
+ // to the limiter width, not the total resource count in a subscription.
79
+ const maxInFlight = concurrency * 2;
80
+
50
81
  for (const subscriptionId of config.subscriptionIds) {
51
82
  logger.info(`Analyzing subscription: ${subscriptionId}`);
52
83
 
84
+ const sid = subscriptionId.trim();
85
+
86
+ // Fresh cache per subscription — bounds peak memory to one subscription's
87
+ // worth of metrics and keeps concurrent analyzeAzureResources calls isolated.
88
+ const runCache: MetricsCache = new Map();
89
+
90
+ const clients: AzureClients = {
91
+ compute: new ComputeManagementClient(credential, sid),
92
+ containerApps: new ContainerAppsAPIClient(credential, sid),
93
+ monitor: new MonitorClient(credential, sid),
94
+ network: new NetworkManagementClient(credential, sid),
95
+ webSite: new WebSiteManagementClient(credential, sid),
96
+ };
53
97
  const resourceClient = new armResources.ResourceManagementClient(
54
98
  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(),
99
+ sid,
73
100
  );
74
101
 
75
- const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
102
+ const inFlight = new Set<Promise<void>>();
76
103
 
77
- // Use the async iterator to avoid memory explosion for large environments
104
+ // Use the async iterator to avoid loading all resources into memory at once.
78
105
  for await (const resource of resourceClient.resources.list()) {
79
- // Skip resources that don't match the requested tag filter
80
106
  if (!matchesTags(resource, config.filterTags)) {
81
107
  continue;
82
108
  }
83
109
 
84
- const { costRisk, reason, suspectedUnused } = await analyzeResource(
85
- resource,
86
- monitorClient,
87
- computeClient,
88
- networkClient,
89
- webSiteClient,
90
- containerAppsClient,
91
- config.preferredLocation,
92
- config.timespanDays,
93
- thresholds,
94
- config.verbose || false,
95
- );
110
+ // Backpressure: wait for a slot before enqueuing the next task so that
111
+ // the inFlight Set stays bounded by maxInFlight instead of growing to the
112
+ // total resource count in the subscription.
113
+ while (inFlight.size >= maxInFlight) {
114
+ await Promise.race(inFlight).catch(() => undefined);
115
+ }
116
+
117
+ const task: Promise<void> = limit(async () => {
118
+ const { costRisk, reason, suspectedUnused } = await analyzeResource(
119
+ resource,
120
+ analyzers,
121
+ clients,
122
+ runCache,
123
+ config.preferredLocation,
124
+ config.timespanDays,
125
+ thresholds,
126
+ config.verbose || false,
127
+ );
128
+
129
+ if (suspectedUnused) {
130
+ allReports.push({
131
+ analysis: {
132
+ costRisk,
133
+ reason: reason || "No specific findings.",
134
+ suspectedUnused,
135
+ },
136
+ resource,
137
+ });
138
+ }
139
+ });
140
+
141
+ inFlight.add(task);
142
+ // Suppress the unhandled-rejection that would occur between task creation
143
+ // and the Promise.allSettled drain below. The .catch() handler is a no-op
144
+ // because the actual error is still visible to allSettled (which logs it)
145
+ // via the original `task` reference kept in inFlight.
146
+ void task.catch(() => undefined).finally(() => inFlight.delete(task));
147
+ }
96
148
 
97
- if (suspectedUnused) {
98
- allReports.push({
99
- analysis: {
100
- costRisk,
101
- reason: reason || "No specific findings.",
102
- suspectedUnused,
103
- },
104
- resource: resource,
105
- });
149
+ // Drain remaining tasks; surface any unexpected errors so they don't
150
+ // disappear silently and produce an incomplete report without a signal.
151
+ const results = await Promise.allSettled(inFlight);
152
+ for (const result of results) {
153
+ if (result.status === "rejected") {
154
+ logger.error(`Resource analysis failed: ${String(result.reason)}`);
106
155
  }
107
156
  }
108
157
  }
@@ -119,34 +168,32 @@ export async function analyzeAzureResources(
119
168
  }
120
169
 
121
170
  /**
122
- * Analyzes a single Azure resource based on its type.
171
+ * Analyzes a single Azure resource by dispatching it to every registered
172
+ * analyzer that supports it. Generic checks (missing tags, location
173
+ * mismatch) are applied around the analyzer-specific logic.
123
174
  *
124
- * @param resource - The Azure resource to analyze
125
- * @param monitorClient - Azure Monitor client for metrics
126
- * @param computeClient - Azure Compute client
127
- * @param networkClient - Azure Network client
128
- * @param webSiteClient - Azure Web Site client
129
- * @param containerAppsClient - Azure Container Apps client
130
- * @param preferredLocation - Preferred Azure location
131
- * @param timespanDays - Number of days to analyze metrics
132
- * @param verbose - Whether verbose logging is enabled
175
+ * @param resource The Azure resource to analyze
176
+ * @param analyzers Registered analyzers (typically `createDefaultAnalyzers()`)
177
+ * @param clients Bundle of Azure SDK clients shared across analyzers
178
+ * @param metricsCache Run-scoped metrics cache to pass through to analyzers
179
+ * @param preferredLocation Preferred Azure region (resources elsewhere are flagged)
180
+ * @param timespanDays Look-back window for Azure Monitor metrics
181
+ * @param thresholds Numeric thresholds used during analysis
182
+ * @param verbose Whether verbose logging is enabled
133
183
  * @returns Analysis result with cost risk and reason
134
184
  */
135
185
  export async function analyzeResource(
136
186
  resource: armResources.GenericResource,
137
- monitorClient: MonitorClient,
138
- computeClient: ComputeManagementClient,
139
- networkClient: NetworkManagementClient,
140
- webSiteClient: WebSiteManagementClient,
141
- containerAppsClient: ContainerAppsAPIClient,
187
+ analyzers: Analyzer[],
188
+ clients: AzureClients,
189
+ metricsCache: MetricsCache = new Map(),
142
190
  preferredLocation: string,
143
191
  timespanDays: number,
144
192
  thresholds: Thresholds,
145
193
  verbose = false,
146
194
  ): Promise<AnalysisResult> {
147
- const type = resource.type?.toLowerCase() || "";
148
- let result = {
149
- costRisk: "low" as "high" | "low" | "medium",
195
+ let result: AnalysisResult = {
196
+ costRisk: "low",
150
197
  reason: "",
151
198
  suspectedUnused: false,
152
199
  };
@@ -157,100 +204,28 @@ export async function analyzeResource(
157
204
  result.reason += "No tags found. ";
158
205
  }
159
206
 
160
- // Route to type-specific analysis hooks
161
- switch (type) {
162
- case "microsoft.app/containerapps": {
163
- const containerAppResult = await analyzeContainerApp(
164
- resource,
165
- containerAppsClient,
166
- monitorClient,
167
- timespanDays,
168
- thresholds,
169
- verbose,
170
- );
171
- result = mergeResults(result, containerAppResult);
172
- break;
173
- }
174
- case "microsoft.compute/disks": {
175
- const diskResult = await analyzeDisk(resource, computeClient, verbose);
176
- result = mergeResults(result, diskResult);
177
- break;
178
- }
179
- case "microsoft.compute/virtualmachines": {
180
- const vmResult = await analyzeVM(
181
- resource,
182
- monitorClient,
183
- computeClient,
184
- timespanDays,
185
- thresholds,
186
- verbose,
187
- );
188
- result = mergeResults(result, vmResult);
189
- break;
190
- }
191
- case "microsoft.network/networkinterfaces": {
192
- const nicResult = await analyzeNic(resource, networkClient, verbose);
193
- result = mergeResults(result, nicResult);
194
- break;
195
- }
196
- case "microsoft.network/privateendpoints": {
197
- const peResult = await analyzePrivateEndpoint(
198
- resource,
199
- networkClient,
200
- verbose,
201
- );
202
- result = mergeResults(result, peResult);
203
- break;
204
- }
205
- case "microsoft.network/publicipaddresses": {
206
- const pipResult = await analyzePublicIp(
207
- resource,
208
- networkClient,
209
- monitorClient,
210
- timespanDays,
211
- thresholds,
212
- verbose,
213
- );
214
- result = mergeResults(result, pipResult);
215
- break;
216
- }
217
- case "microsoft.storage/storageaccounts": {
218
- const storageResult = await analyzeStorageAccount(
219
- resource,
220
- monitorClient,
221
- timespanDays,
222
- thresholds,
223
- verbose,
224
- );
225
- result = mergeResults(result, storageResult);
226
- break;
227
- }
228
- case "microsoft.web/serverfarms": {
229
- const aspResult = await analyzeAppServicePlan(
230
- resource,
231
- webSiteClient,
232
- monitorClient,
233
- timespanDays,
234
- thresholds,
235
- verbose,
236
- );
237
- result = mergeResults(result, aspResult);
238
- break;
239
- }
240
- case "microsoft.web/staticsites": {
241
- const staticSiteResult = await analyzeStaticSite(
242
- resource,
243
- monitorClient,
244
- timespanDays,
245
- thresholds,
246
- verbose,
247
- );
248
- result = mergeResults(result, staticSiteResult);
249
- break;
207
+ const ctx: AnalyzerContext = {
208
+ clients,
209
+ metricsCache,
210
+ preferredLocation,
211
+ resource,
212
+ thresholds,
213
+ timespanDays,
214
+ verbose,
215
+ };
216
+
217
+ let matched = false;
218
+ for (const analyzer of analyzers) {
219
+ if (!analyzer.supports(resource)) {
220
+ continue;
250
221
  }
251
- default:
252
- result.reason += "No specific analysis for this resource type. ";
253
- break;
222
+ matched = true;
223
+ const specific = await analyzer.analyze(ctx);
224
+ result = mergeResults(result, specific);
225
+ }
226
+
227
+ if (!matched) {
228
+ result.reason += "No specific analysis for this resource type. ";
254
229
  }
255
230
 
256
231
  // Generic check for location
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Re-exports for the Azure analyzer plugin layer.
3
+ */
4
+
5
+ export { createDefaultAnalyzers } from "./registry.js";
6
+ export type { Analyzer, AnalyzerContext, AzureClients } from "./types.js";
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Default registry of Azure analyzers.
3
+ *
4
+ * Each entry wraps an existing per-type analyzer function (kept in
5
+ * `../resources/`) and exposes it through the unified `Analyzer`
6
+ * interface. The orchestrator simply iterates the registry — no big
7
+ * `switch` statement, no risk of forgetting to wire a new analyzer
8
+ * into the orchestrator when the catalog grows.
9
+ *
10
+ * Adding a new analyzer is a single insertion here.
11
+ */
12
+
13
+ import type { Analyzer } from "./types.js";
14
+
15
+ import {
16
+ analyzeAppServicePlan,
17
+ analyzeContainerApp,
18
+ analyzeDisk,
19
+ analyzeNic,
20
+ analyzePrivateEndpoint,
21
+ analyzePublicIp,
22
+ analyzeStaticSite,
23
+ analyzeStorageAccount,
24
+ analyzeVM,
25
+ } from "../resources/index.js";
26
+
27
+ /**
28
+ * Builds the default set of analyzers in the same order they were
29
+ * previously evaluated by the orchestrator's `switch` statement. The
30
+ * order is not behaviourally meaningful today (each resource is matched
31
+ * by exactly one analyzer) but is kept deterministic for predictable
32
+ * logging and to ease future debugging.
33
+ */
34
+ export function createDefaultAnalyzers(): Analyzer[] {
35
+ return [
36
+ {
37
+ analyze: ({
38
+ clients,
39
+ metricsCache,
40
+ resource,
41
+ thresholds,
42
+ timespanDays,
43
+ verbose,
44
+ }) =>
45
+ analyzeContainerApp(
46
+ resource,
47
+ clients.containerApps,
48
+ clients.monitor,
49
+ timespanDays,
50
+ thresholds,
51
+ verbose,
52
+ metricsCache,
53
+ ),
54
+ id: "azure.container-app",
55
+ supports: (r) => r.type?.toLowerCase() === "microsoft.app/containerapps",
56
+ },
57
+ {
58
+ analyze: ({ clients, resource, verbose }) =>
59
+ analyzeDisk(resource, clients.compute, verbose),
60
+ id: "azure.disk",
61
+ supports: (r) => r.type?.toLowerCase() === "microsoft.compute/disks",
62
+ },
63
+ {
64
+ analyze: ({
65
+ clients,
66
+ metricsCache,
67
+ resource,
68
+ thresholds,
69
+ timespanDays,
70
+ verbose,
71
+ }) =>
72
+ analyzeVM(
73
+ resource,
74
+ clients.monitor,
75
+ clients.compute,
76
+ timespanDays,
77
+ thresholds,
78
+ verbose,
79
+ metricsCache,
80
+ ),
81
+ id: "azure.vm",
82
+ supports: (r) =>
83
+ r.type?.toLowerCase() === "microsoft.compute/virtualmachines",
84
+ },
85
+ {
86
+ analyze: ({ clients, resource, verbose }) =>
87
+ analyzeNic(resource, clients.network, verbose),
88
+ id: "azure.nic",
89
+ supports: (r) =>
90
+ r.type?.toLowerCase() === "microsoft.network/networkinterfaces",
91
+ },
92
+ {
93
+ analyze: ({ clients, resource, verbose }) =>
94
+ analyzePrivateEndpoint(resource, clients.network, verbose),
95
+ id: "azure.private-endpoint",
96
+ supports: (r) =>
97
+ r.type?.toLowerCase() === "microsoft.network/privateendpoints",
98
+ },
99
+ {
100
+ analyze: ({
101
+ clients,
102
+ metricsCache,
103
+ resource,
104
+ thresholds,
105
+ timespanDays,
106
+ verbose,
107
+ }) =>
108
+ analyzePublicIp(
109
+ resource,
110
+ clients.network,
111
+ clients.monitor,
112
+ timespanDays,
113
+ thresholds,
114
+ verbose,
115
+ metricsCache,
116
+ ),
117
+ id: "azure.public-ip",
118
+ supports: (r) =>
119
+ r.type?.toLowerCase() === "microsoft.network/publicipaddresses",
120
+ },
121
+ {
122
+ analyze: ({
123
+ clients,
124
+ metricsCache,
125
+ resource,
126
+ thresholds,
127
+ timespanDays,
128
+ verbose,
129
+ }) =>
130
+ analyzeStorageAccount(
131
+ resource,
132
+ clients.monitor,
133
+ timespanDays,
134
+ thresholds,
135
+ verbose,
136
+ metricsCache,
137
+ ),
138
+ id: "azure.storage-account",
139
+ supports: (r) =>
140
+ r.type?.toLowerCase() === "microsoft.storage/storageaccounts",
141
+ },
142
+ {
143
+ analyze: ({
144
+ clients,
145
+ metricsCache,
146
+ resource,
147
+ thresholds,
148
+ timespanDays,
149
+ verbose,
150
+ }) =>
151
+ analyzeAppServicePlan(
152
+ resource,
153
+ clients.webSite,
154
+ clients.monitor,
155
+ timespanDays,
156
+ thresholds,
157
+ verbose,
158
+ metricsCache,
159
+ ),
160
+ id: "azure.app-service-plan",
161
+ supports: (r) => r.type?.toLowerCase() === "microsoft.web/serverfarms",
162
+ },
163
+ {
164
+ analyze: ({
165
+ clients,
166
+ metricsCache,
167
+ resource,
168
+ thresholds,
169
+ timespanDays,
170
+ verbose,
171
+ }) =>
172
+ analyzeStaticSite(
173
+ resource,
174
+ clients.monitor,
175
+ timespanDays,
176
+ thresholds,
177
+ verbose,
178
+ metricsCache,
179
+ ),
180
+ id: "azure.static-web-app",
181
+ supports: (r) => r.type?.toLowerCase() === "microsoft.web/staticsites",
182
+ },
183
+ ];
184
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Plugin architecture for Azure resource analyzers.
3
+ *
4
+ * Each `Analyzer` is a self-contained unit that:
5
+ * 1. declares the resource types it can handle (`supports`)
6
+ * 2. produces an `AnalysisResult` for a given resource (`analyze`)
7
+ *
8
+ * The orchestrator in `analyzer.ts` walks the registered analyzers for
9
+ * every resource it encounters. New sources (Azure Advisor, custom
10
+ * checks, pricing-enriched analyzers, …) can be added by implementing
11
+ * the interface and registering them in `registry.ts` without touching
12
+ * the orchestrator.
13
+ */
14
+
15
+ import type { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
16
+ import type { WebSiteManagementClient } from "@azure/arm-appservice";
17
+ import type { ComputeManagementClient } from "@azure/arm-compute";
18
+ import type { MonitorClient } from "@azure/arm-monitor";
19
+ import type { NetworkManagementClient } from "@azure/arm-network";
20
+ import type * as armResources from "@azure/arm-resources";
21
+
22
+ import type { AnalysisResult, Thresholds } from "../../types.js";
23
+ import type { MetricsCache } from "../utils.js";
24
+
25
+ /**
26
+ * Contract every analyzer must satisfy.
27
+ */
28
+ export type Analyzer = {
29
+ analyze(ctx: AnalyzerContext): Promise<AnalysisResult>;
30
+ /**
31
+ * Stable identifier of the analyzer (e.g. `azure.vm`, `azure.advisor`).
32
+ * Used for logging, telemetry and future deduplication logic.
33
+ */
34
+ readonly id: string;
35
+ supports(resource: armResources.GenericResource): boolean;
36
+ };
37
+
38
+ /**
39
+ * Per-resource analysis context handed to every analyzer.
40
+ */
41
+ export type AnalyzerContext = {
42
+ clients: AzureClients;
43
+ /**
44
+ * Run-scoped metrics cache. Pass through to `getMetric` calls so that
45
+ * concurrent `analyzeAzureResources` invocations stay isolated.
46
+ */
47
+ metricsCache: MetricsCache;
48
+ preferredLocation: string;
49
+ resource: armResources.GenericResource;
50
+ thresholds: Thresholds;
51
+ timespanDays: number;
52
+ verbose: boolean;
53
+ };
54
+
55
+ /**
56
+ * Bundle of Azure SDK clients an analyzer might need. The orchestrator
57
+ * builds them once per subscription and passes the same instances to
58
+ * every analyzer.
59
+ */
60
+ export type AzureClients = {
61
+ compute: ComputeManagementClient;
62
+ containerApps: ContainerAppsAPIClient;
63
+ monitor: MonitorClient;
64
+ network: NetworkManagementClient;
65
+ webSite: WebSiteManagementClient;
66
+ };
@@ -42,6 +42,7 @@ export async function loadAzureConfig(
42
42
  const rawYaml = yaml.load(raw);
43
43
  const parsed = ConfigSchema.parse(rawYaml);
44
44
  return {
45
+ concurrency: parsed.azure.concurrency,
45
46
  preferredLocation: parsed.azure.preferredLocation,
46
47
  subscriptionIds: parsed.azure.subscriptionIds,
47
48
  thresholds: parsed.azure.thresholds,
@@ -13,6 +13,7 @@ import type { AnalysisResult, Thresholds } from "../../types.js";
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
+ type MetricsCache,
16
17
  verboseLog,
17
18
  verboseLogAnalysisResult,
18
19
  verboseLogResourceStart,
@@ -34,6 +35,7 @@ export async function analyzeAppServicePlan(
34
35
  timespanDays: number,
35
36
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
36
37
  verbose = false,
38
+ cache?: MetricsCache,
37
39
  ): Promise<AnalysisResult> {
38
40
  verboseLogResourceStart(
39
41
  verbose,
@@ -79,6 +81,7 @@ export async function analyzeAppServicePlan(
79
81
  "CpuPercentage",
80
82
  "Average",
81
83
  timespanDays,
84
+ cache,
82
85
  );
83
86
 
84
87
  const memoryPercentage = await getMetric(
@@ -87,6 +90,7 @@ export async function analyzeAppServicePlan(
87
90
  "MemoryPercentage",
88
91
  "Average",
89
92
  timespanDays,
93
+ cache,
90
94
  );
91
95
 
92
96
  if (