@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.
Files changed (86) hide show
  1. package/README.md +33 -27
  2. package/dist/__tests__/finding.test.d.ts +17 -0
  3. package/dist/__tests__/finding.test.d.ts.map +1 -0
  4. package/dist/__tests__/finding.test.js +124 -0
  5. package/dist/__tests__/finding.test.js.map +1 -0
  6. package/dist/azure/__tests__/analyzer-tags.test.d.ts +8 -0
  7. package/dist/azure/__tests__/analyzer-tags.test.d.ts.map +1 -0
  8. package/dist/azure/__tests__/analyzer-tags.test.js +43 -0
  9. package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -0
  10. package/dist/azure/__tests__/config.test.d.ts +9 -0
  11. package/dist/azure/__tests__/config.test.d.ts.map +1 -0
  12. package/dist/azure/__tests__/config.test.js +70 -0
  13. package/dist/azure/__tests__/config.test.js.map +1 -0
  14. package/dist/azure/__tests__/report.test.d.ts +9 -0
  15. package/dist/azure/__tests__/report.test.d.ts.map +1 -0
  16. package/dist/azure/__tests__/report.test.js +120 -0
  17. package/dist/azure/__tests__/report.test.js.map +1 -0
  18. package/dist/azure/__tests__/utils.test.d.ts +15 -0
  19. package/dist/azure/__tests__/utils.test.d.ts.map +1 -0
  20. package/dist/azure/__tests__/utils.test.js +181 -0
  21. package/dist/azure/__tests__/utils.test.js.map +1 -0
  22. package/dist/azure/analyzer.d.ts +18 -5
  23. package/dist/azure/analyzer.d.ts.map +1 -1
  24. package/dist/azure/analyzer.js +295 -48
  25. package/dist/azure/analyzer.js.map +1 -1
  26. package/dist/azure/analyzers/__tests__/advisor.test.d.ts +9 -0
  27. package/dist/azure/analyzers/__tests__/advisor.test.d.ts.map +1 -0
  28. package/dist/azure/analyzers/__tests__/advisor.test.js +314 -0
  29. package/dist/azure/analyzers/__tests__/advisor.test.js.map +1 -0
  30. package/dist/azure/analyzers/advisor.d.ts +68 -0
  31. package/dist/azure/analyzers/advisor.d.ts.map +1 -0
  32. package/dist/azure/analyzers/advisor.js +234 -0
  33. package/dist/azure/analyzers/advisor.js.map +1 -0
  34. package/dist/azure/analyzers/index.d.ts +3 -1
  35. package/dist/azure/analyzers/index.d.ts.map +1 -1
  36. package/dist/azure/analyzers/index.js +2 -1
  37. package/dist/azure/analyzers/index.js.map +1 -1
  38. package/dist/azure/analyzers/registry.d.ts +8 -0
  39. package/dist/azure/analyzers/registry.d.ts.map +1 -1
  40. package/dist/azure/analyzers/registry.js +10 -0
  41. package/dist/azure/analyzers/registry.js.map +1 -1
  42. package/dist/azure/analyzers/subscription.d.ts +53 -0
  43. package/dist/azure/analyzers/subscription.d.ts.map +1 -0
  44. package/dist/azure/analyzers/subscription.js +18 -0
  45. package/dist/azure/analyzers/subscription.js.map +1 -0
  46. package/dist/azure/config.d.ts.map +1 -1
  47. package/dist/azure/config.js +1 -0
  48. package/dist/azure/config.js.map +1 -1
  49. package/dist/azure/index.d.ts +1 -0
  50. package/dist/azure/index.d.ts.map +1 -1
  51. package/dist/azure/index.js +1 -0
  52. package/dist/azure/index.js.map +1 -1
  53. package/dist/azure/report.d.ts.map +1 -1
  54. package/dist/azure/report.js +178 -29
  55. package/dist/azure/report.js.map +1 -1
  56. package/dist/azure/resources/__tests__/storage.test.d.ts +11 -0
  57. package/dist/azure/resources/__tests__/storage.test.d.ts.map +1 -0
  58. package/dist/azure/resources/__tests__/storage.test.js +99 -0
  59. package/dist/azure/resources/__tests__/storage.test.js.map +1 -0
  60. package/dist/azure/types.d.ts +28 -1
  61. package/dist/azure/types.d.ts.map +1 -1
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.test.d.ts +2 -0
  65. package/dist/index.test.d.ts.map +1 -0
  66. package/dist/index.test.js +78 -0
  67. package/dist/index.test.js.map +1 -0
  68. package/dist/schema.d.ts +4 -0
  69. package/dist/schema.d.ts.map +1 -1
  70. package/dist/schema.js +9 -0
  71. package/dist/schema.js.map +1 -1
  72. package/package.json +5 -3
  73. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  74. package/src/azure/__tests__/report.test.ts +35 -6
  75. package/src/azure/analyzer.ts +421 -65
  76. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  77. package/src/azure/analyzers/advisor.ts +324 -0
  78. package/src/azure/analyzers/index.ts +9 -1
  79. package/src/azure/analyzers/registry.ts +12 -0
  80. package/src/azure/analyzers/subscription.ts +56 -0
  81. package/src/azure/config.ts +1 -0
  82. package/src/azure/index.ts +1 -0
  83. package/src/azure/report.ts +206 -35
  84. package/src/azure/types.ts +29 -1
  85. package/src/index.ts +1 -1
  86. package/src/schema.ts +9 -0
@@ -19,6 +19,8 @@
19
19
  * existing report formats keep working untouched.
20
20
  */
21
21
 
22
+ import type { TokenCredential } from "@azure/identity";
23
+
22
24
  import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
23
25
  import { WebSiteManagementClient } from "@azure/arm-appservice";
24
26
  import { ComputeManagementClient } from "@azure/arm-compute";
@@ -29,10 +31,17 @@ import { DefaultAzureCredential } from "@azure/identity";
29
31
  import { getLogger } from "@logtape/logtape";
30
32
  import pLimit from "p-limit";
31
33
 
32
- import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
34
+ import type { Finding } from "../finding.js";
35
+ import type {
36
+ AzureConfig,
37
+ AzureDetailedResourceReport,
38
+ AzureSource,
39
+ } from "./types.js";
33
40
 
41
+ import { findingsFromAnalysisResult } from "../finding.js";
34
42
  import {
35
43
  type AnalysisResult,
44
+ type CostRisk,
36
45
  DEFAULT_THRESHOLDS,
37
46
  mergeResults,
38
47
  type Thresholds,
@@ -42,27 +51,48 @@ import {
42
51
  type AnalyzerContext,
43
52
  type AzureClients,
44
53
  createDefaultAnalyzers,
54
+ createDefaultSubscriptionAnalyzers,
55
+ type SubscriptionAnalyzer,
45
56
  } from "./analyzers/index.js";
46
- import { generateReport } from "./report.js";
47
57
  import { matchesTags, type MetricsCache } from "./utils.js";
48
58
 
49
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 };
50
63
 
51
64
  /**
52
- * Analyzes resources in multiple Azure subscriptions and generates a report.
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`.
72
+ *
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.
53
77
  *
54
- * @param config - Azure configuration with subscription IDs and settings
55
- * @param format - Output format (table, json, detailed-json, or lint)
78
+ * @param config - Azure configuration with subscription IDs and settings.
79
+ * `config.sources` controls which analyzers run.
56
80
  */
57
81
  export async function analyzeAzureResources(
58
82
  config: AzureConfig,
59
- format: "detailed-json" | "json" | "lint" | "table",
60
- ) {
83
+ ): Promise<AzureDetailedResourceReport[]> {
61
84
  const logger = getLogger(["savemoney", "azure"]);
62
85
  const credential = new DefaultAzureCredential();
63
86
  const allReports: AzureDetailedResourceReport[] = [];
64
87
 
88
+ const sources = config.sources ?? DEFAULT_SOURCES;
89
+ const customEnabled = sources.includes("custom");
90
+ const advisorEnabled = sources.includes("advisor");
91
+
65
92
  const analyzers = createDefaultAnalyzers();
93
+ const subscriptionAnalyzers = advisorEnabled
94
+ ? createDefaultSubscriptionAnalyzers()
95
+ : [];
66
96
  const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
67
97
 
68
98
  // Normalise concurrency the same way p-limit does to keep maxInFlight
@@ -83,6 +113,12 @@ export async function analyzeAzureResources(
83
113
 
84
114
  const sid = subscriptionId.trim();
85
115
 
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
+
86
122
  // Fresh cache per subscription — bounds peak memory to one subscription's
87
123
  // worth of metrics and keeps concurrent analyzeAzureResources calls isolated.
88
124
  const runCache: MetricsCache = new Map();
@@ -94,77 +130,64 @@ export async function analyzeAzureResources(
94
130
  network: new NetworkManagementClient(credential, sid),
95
131
  webSite: new WebSiteManagementClient(credential, sid),
96
132
  };
97
- const resourceClient = new armResources.ResourceManagementClient(
98
- credential,
99
- sid,
100
- );
101
-
102
- const inFlight = new Set<Promise<void>>();
103
133
 
104
- // Use the async iterator to avoid loading all resources into memory at once.
105
- for await (const resource of resourceClient.resources.list()) {
106
- if (!matchesTags(resource, config.filterTags)) {
107
- continue;
108
- }
109
-
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
- }
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,
148
+ thresholds,
139
149
  });
150
+ }
140
151
 
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));
152
+ if (!customEnabled && advisorEnabled && hasTagFilter(config.filterTags)) {
153
+ await collectTaggedResourceIds({
154
+ config,
155
+ credential,
156
+ sid,
157
+ taggedResourceIds,
158
+ });
147
159
  }
148
160
 
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)}`);
155
- }
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
+ });
156
173
  }
157
174
  }
158
175
 
159
- // 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.
160
181
  allReports.sort((a, b) => {
182
+ const aSub = isSubscriptionScopedReport(a);
183
+ const bSub = isSubscriptionScopedReport(b);
184
+ if (aSub !== bSub) return aSub ? 1 : -1;
161
185
  if (a.analysis.costRisk === b.analysis.costRisk)
162
186
  return (a.resource.name ?? "").localeCompare(b.resource.name ?? "");
163
- const order = { high: 0, low: 2, medium: 1 };
164
- return order[a.analysis.costRisk] - order[b.analysis.costRisk];
187
+ return RISK_ORDER[a.analysis.costRisk] - RISK_ORDER[b.analysis.costRisk];
165
188
  });
166
189
 
167
- await generateReport(allReports, format);
190
+ return allReports;
168
191
  }
169
192
 
170
193
  /**
@@ -238,3 +261,336 @@ export async function analyzeResource(
238
261
 
239
262
  return { ...result, reason: result.reason.trim() };
240
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]);
320
+ }
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;
366
+ }
367
+ const resourceId = normalizeResourceId(resource.id);
368
+ if (resourceId) {
369
+ taggedResourceIds.add(resourceId);
370
+ }
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;
479
+ }
480
+
481
+ const taggedId = normalizeResourceId(resource.id);
482
+ if (taggedId) {
483
+ taggedResourceIds.add(taggedId);
484
+ }
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);
491
+ }
492
+
493
+ const task: Promise<void> = limit(async () => {
494
+ const analysis = await analyzeResource(
495
+ resource,
496
+ analyzers,
497
+ clients,
498
+ runCache,
499
+ config.preferredLocation,
500
+ config.timespanDays,
501
+ thresholds,
502
+ config.verbose || false,
503
+ );
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));
529
+ }
530
+
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
+ }
538
+ }
539
+ }
540
+
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
+ }
596
+ }