@pagopa/dx-savemoney 0.3.3 → 0.5.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.
Files changed (174) hide show
  1. package/README.md +12 -0
  2. package/dist/__tests__/finding.test.js +22 -0
  3. package/dist/__tests__/finding.test.js.map +1 -1
  4. package/dist/azure/__tests__/analyzer-tags.test.js +28 -1
  5. package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -1
  6. package/dist/azure/__tests__/config.test.js +31 -1
  7. package/dist/azure/__tests__/config.test.js.map +1 -1
  8. package/dist/azure/__tests__/report.test.js +71 -0
  9. package/dist/azure/__tests__/report.test.js.map +1 -1
  10. package/dist/azure/analyzer.d.ts +8 -1
  11. package/dist/azure/analyzer.d.ts.map +1 -1
  12. package/dist/azure/analyzer.js +57 -41
  13. package/dist/azure/analyzer.js.map +1 -1
  14. package/dist/azure/analyzers/registry.d.ts.map +1 -1
  15. package/dist/azure/analyzers/registry.js +4 -4
  16. package/dist/azure/analyzers/registry.js.map +1 -1
  17. package/dist/azure/analyzers/types.d.ts +6 -0
  18. package/dist/azure/analyzers/types.d.ts.map +1 -1
  19. package/dist/azure/config.d.ts.map +1 -1
  20. package/dist/azure/config.js +1 -5
  21. package/dist/azure/config.js.map +1 -1
  22. package/dist/azure/pricing/__tests__/cache.test.d.ts +8 -0
  23. package/dist/azure/pricing/__tests__/cache.test.d.ts.map +1 -0
  24. package/dist/azure/pricing/__tests__/cache.test.js +56 -0
  25. package/dist/azure/pricing/__tests__/cache.test.js.map +1 -0
  26. package/dist/azure/pricing/__tests__/client.test.d.ts +9 -0
  27. package/dist/azure/pricing/__tests__/client.test.d.ts.map +1 -0
  28. package/dist/azure/pricing/__tests__/client.test.js +181 -0
  29. package/dist/azure/pricing/__tests__/client.test.js.map +1 -0
  30. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts +6 -0
  31. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts.map +1 -0
  32. package/dist/azure/pricing/__tests__/pricing-service.test.js +283 -0
  33. package/dist/azure/pricing/__tests__/pricing-service.test.js.map +1 -0
  34. package/dist/azure/pricing/cache.d.ts +45 -0
  35. package/dist/azure/pricing/cache.d.ts.map +1 -0
  36. package/dist/azure/pricing/cache.js +79 -0
  37. package/dist/azure/pricing/cache.js.map +1 -0
  38. package/dist/azure/pricing/client.d.ts +67 -0
  39. package/dist/azure/pricing/client.d.ts.map +1 -0
  40. package/dist/azure/pricing/client.js +96 -0
  41. package/dist/azure/pricing/client.js.map +1 -0
  42. package/dist/azure/pricing/index.d.ts +6 -0
  43. package/dist/azure/pricing/index.d.ts.map +1 -0
  44. package/dist/azure/pricing/index.js +6 -0
  45. package/dist/azure/pricing/index.js.map +1 -0
  46. package/dist/azure/pricing/pricing-service.d.ts +46 -0
  47. package/dist/azure/pricing/pricing-service.d.ts.map +1 -0
  48. package/dist/azure/pricing/pricing-service.js +84 -0
  49. package/dist/azure/pricing/pricing-service.js.map +1 -0
  50. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts +5 -0
  51. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts.map +1 -0
  52. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js +137 -0
  53. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js.map +1 -0
  54. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts +5 -0
  55. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts.map +1 -0
  56. package/dist/azure/pricing/resolvers/__tests__/disk.test.js +160 -0
  57. package/dist/azure/pricing/resolvers/__tests__/disk.test.js.map +1 -0
  58. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts +5 -0
  59. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts.map +1 -0
  60. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js +113 -0
  61. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js.map +1 -0
  62. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts +9 -0
  63. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts.map +1 -0
  64. package/dist/azure/pricing/resolvers/__tests__/vm.test.js +204 -0
  65. package/dist/azure/pricing/resolvers/__tests__/vm.test.js.map +1 -0
  66. package/dist/azure/pricing/resolvers/app-service.d.ts +27 -0
  67. package/dist/azure/pricing/resolvers/app-service.d.ts.map +1 -0
  68. package/dist/azure/pricing/resolvers/app-service.js +67 -0
  69. package/dist/azure/pricing/resolvers/app-service.js.map +1 -0
  70. package/dist/azure/pricing/resolvers/disk.d.ts +59 -0
  71. package/dist/azure/pricing/resolvers/disk.d.ts.map +1 -0
  72. package/dist/azure/pricing/resolvers/disk.js +140 -0
  73. package/dist/azure/pricing/resolvers/disk.js.map +1 -0
  74. package/dist/azure/pricing/resolvers/index.d.ts +12 -0
  75. package/dist/azure/pricing/resolvers/index.d.ts.map +1 -0
  76. package/dist/azure/pricing/resolvers/index.js +8 -0
  77. package/dist/azure/pricing/resolvers/index.js.map +1 -0
  78. package/dist/azure/pricing/resolvers/public-ip.d.ts +32 -0
  79. package/dist/azure/pricing/resolvers/public-ip.d.ts.map +1 -0
  80. package/dist/azure/pricing/resolvers/public-ip.js +56 -0
  81. package/dist/azure/pricing/resolvers/public-ip.js.map +1 -0
  82. package/dist/azure/pricing/resolvers/vm.d.ts +51 -0
  83. package/dist/azure/pricing/resolvers/vm.d.ts.map +1 -0
  84. package/dist/azure/pricing/resolvers/vm.js +89 -0
  85. package/dist/azure/pricing/resolvers/vm.js.map +1 -0
  86. package/dist/azure/pricing/schema.d.ts +88 -0
  87. package/dist/azure/pricing/schema.d.ts.map +1 -0
  88. package/dist/azure/pricing/schema.js +64 -0
  89. package/dist/azure/pricing/schema.js.map +1 -0
  90. package/dist/azure/pricing/test-cache-dir.d.ts +3 -0
  91. package/dist/azure/pricing/test-cache-dir.d.ts.map +1 -0
  92. package/dist/azure/pricing/test-cache-dir.js +17 -0
  93. package/dist/azure/pricing/test-cache-dir.js.map +1 -0
  94. package/dist/azure/report.d.ts.map +1 -1
  95. package/dist/azure/report.js +57 -18
  96. package/dist/azure/report.js.map +1 -1
  97. package/dist/azure/resources/__tests__/app-service.test.d.ts +5 -0
  98. package/dist/azure/resources/__tests__/app-service.test.d.ts.map +1 -0
  99. package/dist/azure/resources/__tests__/app-service.test.js +105 -0
  100. package/dist/azure/resources/__tests__/app-service.test.js.map +1 -0
  101. package/dist/azure/resources/app-service.d.ts +8 -3
  102. package/dist/azure/resources/app-service.d.ts.map +1 -1
  103. package/dist/azure/resources/app-service.js +35 -2
  104. package/dist/azure/resources/app-service.js.map +1 -1
  105. package/dist/azure/resources/disk.d.ts +3 -1
  106. package/dist/azure/resources/disk.d.ts.map +1 -1
  107. package/dist/azure/resources/disk.js +35 -3
  108. package/dist/azure/resources/disk.js.map +1 -1
  109. package/dist/azure/resources/public-ip.d.ts +3 -1
  110. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  111. package/dist/azure/resources/public-ip.js +41 -3
  112. package/dist/azure/resources/public-ip.js.map +1 -1
  113. package/dist/azure/resources/vm.d.ts +4 -1
  114. package/dist/azure/resources/vm.d.ts.map +1 -1
  115. package/dist/azure/resources/vm.js +49 -6
  116. package/dist/azure/resources/vm.js.map +1 -1
  117. package/dist/azure/types.d.ts +18 -3
  118. package/dist/azure/types.d.ts.map +1 -1
  119. package/dist/finding.d.ts +3 -3
  120. package/dist/finding.d.ts.map +1 -1
  121. package/dist/finding.js +7 -0
  122. package/dist/finding.js.map +1 -1
  123. package/dist/index.d.ts +3 -2
  124. package/dist/index.d.ts.map +1 -1
  125. package/dist/index.js +1 -0
  126. package/dist/index.js.map +1 -1
  127. package/dist/schema.d.ts +5 -1
  128. package/dist/schema.d.ts.map +1 -1
  129. package/dist/schema.js +5 -3
  130. package/dist/schema.js.map +1 -1
  131. package/dist/types.d.ts +8 -0
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/types.js +2 -0
  134. package/dist/types.js.map +1 -1
  135. package/package.json +1 -1
  136. package/src/__tests__/finding.test.ts +24 -0
  137. package/src/azure/__tests__/analyzer-tags.test.ts +36 -1
  138. package/src/azure/__tests__/config.test.ts +43 -1
  139. package/src/azure/__tests__/fixtures/full-override.yaml +2 -0
  140. package/src/azure/__tests__/report.test.ts +78 -0
  141. package/src/azure/analyzer.ts +70 -52
  142. package/src/azure/analyzers/registry.ts +8 -2
  143. package/src/azure/analyzers/types.ts +6 -0
  144. package/src/azure/config.ts +1 -5
  145. package/src/azure/pricing/__tests__/cache.test.ts +67 -0
  146. package/src/azure/pricing/__tests__/client.test.ts +212 -0
  147. package/src/azure/pricing/__tests__/fixtures/vm-b2s-westeurope.json +51 -0
  148. package/src/azure/pricing/__tests__/pricing-service.test.ts +315 -0
  149. package/src/azure/pricing/cache.ts +92 -0
  150. package/src/azure/pricing/client.ts +137 -0
  151. package/src/azure/pricing/index.ts +6 -0
  152. package/src/azure/pricing/pricing-service.ts +125 -0
  153. package/src/azure/pricing/resolvers/__tests__/app-service.test.ts +154 -0
  154. package/src/azure/pricing/resolvers/__tests__/disk.test.ts +189 -0
  155. package/src/azure/pricing/resolvers/__tests__/public-ip.test.ts +130 -0
  156. package/src/azure/pricing/resolvers/__tests__/vm.test.ts +232 -0
  157. package/src/azure/pricing/resolvers/app-service.ts +102 -0
  158. package/src/azure/pricing/resolvers/disk.ts +170 -0
  159. package/src/azure/pricing/resolvers/index.ts +31 -0
  160. package/src/azure/pricing/resolvers/public-ip.ts +87 -0
  161. package/src/azure/pricing/resolvers/vm.ts +125 -0
  162. package/src/azure/pricing/schema.ts +69 -0
  163. package/src/azure/pricing/test-cache-dir.ts +26 -0
  164. package/src/azure/report.ts +86 -21
  165. package/src/azure/resources/__tests__/app-service.test.ts +165 -0
  166. package/src/azure/resources/app-service.ts +65 -4
  167. package/src/azure/resources/disk.ts +46 -4
  168. package/src/azure/resources/public-ip.ts +61 -2
  169. package/src/azure/resources/vm.ts +89 -11
  170. package/src/azure/types.ts +19 -3
  171. package/src/finding.ts +10 -3
  172. package/src/index.ts +4 -3
  173. package/src/schema.ts +7 -3
  174. package/src/types.ts +12 -0
@@ -32,11 +32,7 @@ import { getLogger } from "@logtape/logtape";
32
32
  import pLimit from "p-limit";
33
33
 
34
34
  import type { Finding } from "../finding.js";
35
- import type {
36
- AzureConfig,
37
- AzureDetailedResourceReport,
38
- AzureSource,
39
- } from "./types.js";
35
+ import type { AzureConfig, AzureDetailedResourceReport } from "./types.js";
40
36
 
41
37
  import { findingsFromAnalysisResult } from "../finding.js";
42
38
  import {
@@ -54,10 +50,10 @@ import {
54
50
  createDefaultSubscriptionAnalyzers,
55
51
  type SubscriptionAnalyzer,
56
52
  } from "./analyzers/index.js";
53
+ import { PricingClient, PricingService } from "./pricing/index.js";
57
54
  import { matchesTags, type MetricsCache } from "./utils.js";
58
55
 
59
56
  const DEFAULT_CONCURRENCY = 8;
60
- const DEFAULT_SOURCES: AzureSource[] = ["advisor", "custom"];
61
57
 
62
58
  const RISK_ORDER: Record<CostRisk, number> = { high: 0, low: 2, medium: 1 };
63
59
 
@@ -85,9 +81,8 @@ export async function analyzeAzureResources(
85
81
  const credential = new DefaultAzureCredential();
86
82
  const allReports: AzureDetailedResourceReport[] = [];
87
83
 
88
- const sources = config.sources ?? DEFAULT_SOURCES;
89
- const customEnabled = sources.includes("custom");
90
- const advisorEnabled = sources.includes("advisor");
84
+ const customEnabled = config.sources.includes("custom");
85
+ const advisorEnabled = config.sources.includes("advisor");
91
86
 
92
87
  const analyzers = createDefaultAnalyzers();
93
88
  const subscriptionAnalyzers = advisorEnabled
@@ -95,6 +90,10 @@ export async function analyzeAzureResources(
95
90
  : [];
96
91
  const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
97
92
 
93
+ // Build a single PricingService for the whole run when enabled, so the
94
+ // disk cache and in-memory memo are shared across subscriptions.
95
+ const pricing = buildPricingService(config);
96
+
98
97
  // Normalise concurrency the same way p-limit does to keep maxInFlight
99
98
  // consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
100
99
  // either deadlock or silently disable backpressure.
@@ -140,6 +139,7 @@ export async function analyzeAzureResources(
140
139
  limit,
141
140
  logger,
142
141
  maxInFlight,
142
+ pricing,
143
143
  reports: allReports,
144
144
  reportsById,
145
145
  runCache,
@@ -214,6 +214,7 @@ export async function analyzeResource(
214
214
  timespanDays: number,
215
215
  thresholds: Thresholds,
216
216
  verbose = false,
217
+ pricing?: PricingService,
217
218
  ): Promise<AnalysisResult> {
218
219
  let result: AnalysisResult = {
219
220
  costRisk: "low",
@@ -231,6 +232,7 @@ export async function analyzeResource(
231
232
  clients,
232
233
  metricsCache,
233
234
  preferredLocation,
235
+ pricing,
234
236
  resource,
235
237
  thresholds,
236
238
  timespanDays,
@@ -262,6 +264,50 @@ export async function analyzeResource(
262
264
  return { ...result, reason: result.reason.trim() };
263
265
  }
264
266
 
267
+ /**
268
+ * Inserts a `Finding` into the right report entry, creating a stub
269
+ * resource entry on the fly when the finding refers to a resource that
270
+ * the per-resource pass did not analyze.
271
+ */
272
+ export function mergeFinding(
273
+ finding: Finding,
274
+ reports: AzureDetailedResourceReport[],
275
+ reportsById: Map<string, AzureDetailedResourceReport>,
276
+ ): void {
277
+ const idKey = finding.resourceId.toLowerCase();
278
+ const existing = reportsById.get(idKey);
279
+ if (existing) {
280
+ existing.findings = [...(existing.findings ?? []), finding];
281
+ const added = analysisFromFinding(finding);
282
+ // Use max costRisk (not last-wins) and join reasons with a space so we
283
+ // don't produce "Sentence one.Sentence two." when the existing reason is
284
+ // already trimmed (i.e. has no trailing separator space).
285
+ existing.analysis = {
286
+ costRisk:
287
+ RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
288
+ ? existing.analysis.costRisk
289
+ : added.costRisk,
290
+ estimatedMonthlySavings: existing.analysis.estimatedMonthlySavings,
291
+ reason:
292
+ existing.analysis.reason && added.reason
293
+ ? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
294
+ : existing.analysis.reason || added.reason,
295
+ suspectedUnused:
296
+ existing.analysis.suspectedUnused || added.suspectedUnused,
297
+ };
298
+ return;
299
+ }
300
+
301
+ const stub = buildResourceStub(finding.resourceId);
302
+ const report: AzureDetailedResourceReport = {
303
+ analysis: analysisFromFinding(finding),
304
+ findings: [finding],
305
+ resource: stub,
306
+ };
307
+ reports.push(report);
308
+ reportsById.set(idKey, report);
309
+ }
310
+
265
311
  export function shouldIncludeAdvisorFindingForTags(
266
312
  finding: Finding,
267
313
  taggedResourceIds: ReadonlySet<string>,
@@ -295,6 +341,18 @@ function analysisFromFinding(finding: Finding): AnalysisResult {
295
341
  };
296
342
  }
297
343
 
344
+ /**
345
+ * Builds the shared `PricingService` for the run when pricing enrichment
346
+ * is enabled. Returns `undefined` when the CLI disables pricing via
347
+ * `--no-pricing`; otherwise the client/cache use internal defaults.
348
+ */
349
+ function buildPricingService(config: AzureConfig): PricingService | undefined {
350
+ if (config.pricing?.enabled === false) {
351
+ return undefined;
352
+ }
353
+ return new PricingService(new PricingClient());
354
+ }
355
+
298
356
  /**
299
357
  * Builds a minimal `GenericResource` from a resource ID. Used when a
300
358
  * subscription-level analyzer surfaces a resource the per-resource pass
@@ -383,49 +441,6 @@ function isSubscriptionScopedReport(r: AzureDetailedResourceReport): boolean {
383
441
  return r.resource.type === "Microsoft.Subscription";
384
442
  }
385
443
 
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
444
  function normalizeResourceId(resourceId: string | undefined): string {
430
445
  return (resourceId ?? "").trim().toLowerCase();
431
446
  }
@@ -443,6 +458,7 @@ async function runPerResourceAnalysis(args: {
443
458
  limit: ReturnType<typeof pLimit>;
444
459
  logger: ReturnType<typeof getLogger>;
445
460
  maxInFlight: number;
461
+ pricing?: PricingService;
446
462
  reports: AzureDetailedResourceReport[];
447
463
  reportsById: Map<string, AzureDetailedResourceReport>;
448
464
  runCache: MetricsCache;
@@ -458,6 +474,7 @@ async function runPerResourceAnalysis(args: {
458
474
  limit,
459
475
  logger,
460
476
  maxInFlight,
477
+ pricing,
461
478
  reports,
462
479
  reportsById,
463
480
  runCache,
@@ -500,6 +517,7 @@ async function runPerResourceAnalysis(args: {
500
517
  config.timespanDays,
501
518
  thresholds,
502
519
  config.verbose || false,
520
+ pricing,
503
521
  );
504
522
 
505
523
  if (analysis.suspectedUnused) {
@@ -57,8 +57,8 @@ export function createDefaultAnalyzers(): Analyzer[] {
57
57
  supports: (r) => r.type?.toLowerCase() === "microsoft.app/containerapps",
58
58
  },
59
59
  {
60
- analyze: ({ clients, resource, verbose }) =>
61
- analyzeDisk(resource, clients.compute, verbose),
60
+ analyze: ({ clients, pricing, resource, verbose }) =>
61
+ analyzeDisk(resource, clients.compute, verbose, pricing),
62
62
  id: "azure.disk",
63
63
  supports: (r) => r.type?.toLowerCase() === "microsoft.compute/disks",
64
64
  },
@@ -66,6 +66,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
66
66
  analyze: ({
67
67
  clients,
68
68
  metricsCache,
69
+ pricing,
69
70
  resource,
70
71
  thresholds,
71
72
  timespanDays,
@@ -79,6 +80,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
79
80
  thresholds,
80
81
  verbose,
81
82
  metricsCache,
83
+ pricing,
82
84
  ),
83
85
  id: "azure.vm",
84
86
  supports: (r) =>
@@ -102,6 +104,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
102
104
  analyze: ({
103
105
  clients,
104
106
  metricsCache,
107
+ pricing,
105
108
  resource,
106
109
  thresholds,
107
110
  timespanDays,
@@ -115,6 +118,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
115
118
  thresholds,
116
119
  verbose,
117
120
  metricsCache,
121
+ pricing,
118
122
  ),
119
123
  id: "azure.public-ip",
120
124
  supports: (r) =>
@@ -145,6 +149,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
145
149
  analyze: ({
146
150
  clients,
147
151
  metricsCache,
152
+ pricing,
148
153
  resource,
149
154
  thresholds,
150
155
  timespanDays,
@@ -158,6 +163,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
158
163
  thresholds,
159
164
  verbose,
160
165
  metricsCache,
166
+ pricing,
161
167
  ),
162
168
  id: "azure.app-service-plan",
163
169
  supports: (r) => r.type?.toLowerCase() === "microsoft.web/serverfarms",
@@ -20,6 +20,7 @@ import type { NetworkManagementClient } from "@azure/arm-network";
20
20
  import type * as armResources from "@azure/arm-resources";
21
21
 
22
22
  import type { AnalysisResult, Thresholds } from "../../types.js";
23
+ import type { PricingService } from "../pricing/pricing-service.js";
23
24
  import type { MetricsCache } from "../utils.js";
24
25
 
25
26
  /**
@@ -46,6 +47,11 @@ export type AnalyzerContext = {
46
47
  */
47
48
  metricsCache: MetricsCache;
48
49
  preferredLocation: string;
50
+ /**
51
+ * Optional pricing facade used to enrich findings with an estimated
52
+ * monthly cost. Absent when pricing is disabled via config / CLI.
53
+ */
54
+ pricing?: PricingService;
49
55
  resource: armResources.GenericResource;
50
56
  thresholds: Thresholds;
51
57
  timespanDays: number;
@@ -71,11 +71,7 @@ export async function loadAzureConfig(
71
71
  ? process.env.ARM_SUBSCRIPTION_ID.split(",")
72
72
  : (await prompt("Enter Subscription IDs (comma-separated): ")).split(",");
73
73
 
74
- return {
75
- preferredLocation: "italynorth",
76
- subscriptionIds,
77
- timespanDays: 30,
78
- };
74
+ return ConfigSchema.parse({ azure: { subscriptionIds } }).azure;
79
75
  }
80
76
 
81
77
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Tests for the disk-backed pricing cache.
3
+ *
4
+ * Each test uses a freshly-created temp directory so they do not leak
5
+ * state into the developer's real pricing cache.
6
+ */
7
+
8
+ import { writeFile } from "node:fs/promises";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import { DiskCache } from "../cache.js";
12
+ import { makeTestCacheDir, removeTestCacheDir } from "../test-cache-dir.js";
13
+
14
+ describe("DiskCache", () => {
15
+ let dir: string;
16
+ beforeEach(async () => {
17
+ dir = await makeTestCacheDir("cache");
18
+ });
19
+ afterEach(async () => {
20
+ await removeTestCacheDir(dir);
21
+ });
22
+
23
+ it("returns undefined on a miss", async () => {
24
+ const cache = new DiskCache({ dir });
25
+ await expect(cache.get("nope")).resolves.toBeUndefined();
26
+ });
27
+
28
+ it("returns the value written under the same key", async () => {
29
+ const cache = new DiskCache({ dir });
30
+ await cache.set("hello", { hits: 42 });
31
+ await expect(cache.get("hello")).resolves.toEqual({ hits: 42 });
32
+ });
33
+
34
+ it("treats different keys as independent entries", async () => {
35
+ const cache = new DiskCache({ dir });
36
+ await cache.set("a", 1);
37
+ await cache.set("b", 2);
38
+ await expect(cache.get("a")).resolves.toBe(1);
39
+ await expect(cache.get("b")).resolves.toBe(2);
40
+ });
41
+
42
+ it("returns undefined once the entry is older than ttlMs", async () => {
43
+ const cache = new DiskCache({ dir, ttlMs: 1 });
44
+ await cache.set("stale", { v: 1 });
45
+ // Wait long enough to outlast the 1 ms TTL.
46
+ await new Promise((resolve) => setTimeout(resolve, 25));
47
+ await expect(cache.get("stale")).resolves.toBeUndefined();
48
+ });
49
+
50
+ it("returns undefined when the cache file contains invalid JSON", async () => {
51
+ const cache = new DiskCache({ dir });
52
+ const path = cache.pathFor("broken");
53
+ await writeFile(path, "{not json", "utf8");
54
+ await expect(cache.get("broken")).resolves.toBeUndefined();
55
+ });
56
+
57
+ it("derives different paths for different keys", () => {
58
+ const cache = new DiskCache({ dir });
59
+ expect(cache.pathFor("a")).not.toBe(cache.pathFor("b"));
60
+ });
61
+
62
+ it("refuses to remove directories outside the test cache parent", async () => {
63
+ await expect(removeTestCacheDir(process.cwd())).rejects.toThrow(
64
+ /non-test cache directory/,
65
+ );
66
+ });
67
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tests for the Azure Retail Prices HTTP client.
3
+ *
4
+ * The client is exercised against an in-process fake `fetch` so the suite
5
+ * never reaches the real `prices.azure.com` endpoint. A temp directory is
6
+ * used as cache so each test starts from a cold state.
7
+ */
8
+
9
+ import { readFile } from "node:fs/promises";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import type { FetchLike } from "../client.js";
13
+
14
+ import { DiskCache } from "../cache.js";
15
+ import { PricingClient } from "../client.js";
16
+ import { makeTestCacheDir, removeTestCacheDir } from "../test-cache-dir.js";
17
+
18
+ const FIXTURE_PATH = new URL(
19
+ "./fixtures/vm-b2s-westeurope.json",
20
+ import.meta.url,
21
+ );
22
+
23
+ async function loadFixture(): Promise<unknown> {
24
+ const raw = await readFile(FIXTURE_PATH, "utf8");
25
+ return JSON.parse(raw);
26
+ }
27
+
28
+ function makeOkFetch(body: unknown): FetchLike {
29
+ return vi.fn(async () => ({
30
+ json: async () => body,
31
+ ok: true,
32
+ status: 200,
33
+ statusText: "OK",
34
+ }));
35
+ }
36
+
37
+ describe("PricingClient.query", () => {
38
+ let dir: string;
39
+ beforeEach(async () => {
40
+ dir = await makeTestCacheDir("client");
41
+ });
42
+ afterEach(async () => {
43
+ await removeTestCacheDir(dir);
44
+ });
45
+
46
+ it("returns the items from a single page response", async () => {
47
+ const body = await loadFixture();
48
+ const fetchImpl = makeOkFetch(body);
49
+ const client = new PricingClient({
50
+ cache: new DiskCache({ dir }),
51
+ fetch: fetchImpl,
52
+ });
53
+
54
+ const items = await client.query("serviceName eq 'Virtual Machines'");
55
+
56
+ expect(items).toHaveLength(2);
57
+ expect(items[0].armSkuName).toBe("Standard_B2s");
58
+ expect(items[0].unitPrice).toBe(0.057);
59
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it("includes the filter, currency, and api-version in the request URL", async () => {
63
+ const body = await loadFixture();
64
+ const fetchImpl = makeOkFetch(body);
65
+ const client = new PricingClient({
66
+ cache: new DiskCache({ dir }),
67
+ currencyCode: "EUR",
68
+ fetch: fetchImpl,
69
+ });
70
+
71
+ await client.query("serviceName eq 'Virtual Machines'");
72
+
73
+ const url = (fetchImpl as ReturnType<typeof vi.fn>).mock
74
+ .calls[0][0] as string;
75
+ expect(url).toContain("prices.azure.com/api/retail/prices");
76
+ expect(url).toContain("currencyCode=EUR");
77
+ expect(url).toContain("api-version=");
78
+ // URLSearchParams encodes spaces as `+` (form-urlencoded), so we
79
+ // convert them back before decoding the filter expression.
80
+ const decoded = decodeURIComponent(url.replace(/\+/g, " "));
81
+ expect(decoded).toContain("$filter=serviceName eq 'Virtual Machines'");
82
+ });
83
+
84
+ it("follows NextPageLink across multiple pages", async () => {
85
+ const page1 = {
86
+ Count: 1,
87
+ Items: [
88
+ {
89
+ currencyCode: "EUR",
90
+ retailPrice: 1,
91
+ type: "Consumption",
92
+ unitOfMeasure: "1 Hour",
93
+ unitPrice: 1,
94
+ },
95
+ ],
96
+ NextPageLink: "https://prices.azure.com/api/retail/prices?next=2",
97
+ };
98
+ const page2 = {
99
+ Count: 1,
100
+ Items: [
101
+ {
102
+ currencyCode: "EUR",
103
+ retailPrice: 2,
104
+ type: "Consumption",
105
+ unitOfMeasure: "1 Hour",
106
+ unitPrice: 2,
107
+ },
108
+ ],
109
+ NextPageLink: null,
110
+ };
111
+ const fetchImpl = vi
112
+ .fn<FetchLike>()
113
+ .mockResolvedValueOnce({
114
+ json: async () => page1,
115
+ ok: true,
116
+ status: 200,
117
+ statusText: "OK",
118
+ })
119
+ .mockResolvedValueOnce({
120
+ json: async () => page2,
121
+ ok: true,
122
+ status: 200,
123
+ statusText: "OK",
124
+ });
125
+
126
+ const client = new PricingClient({
127
+ cache: new DiskCache({ dir }),
128
+ fetch: fetchImpl,
129
+ });
130
+ const items = await client.query("anything");
131
+
132
+ expect(items.map((i) => i.unitPrice)).toEqual([1, 2]);
133
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
134
+ });
135
+
136
+ it("serves a cached response without hitting the network on the second call", async () => {
137
+ const body = await loadFixture();
138
+ const fetchImpl = makeOkFetch(body);
139
+ const cache = new DiskCache({ dir });
140
+ const client = new PricingClient({ cache, fetch: fetchImpl });
141
+
142
+ const first = await client.query("filter-a");
143
+ const second = await client.query("filter-a");
144
+
145
+ expect(first).toEqual(second);
146
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ it("keeps cache entries scoped by currency", async () => {
150
+ const body = await loadFixture();
151
+ const fetchImpl = makeOkFetch(body);
152
+ const cache = new DiskCache({ dir });
153
+ const eur = new PricingClient({
154
+ cache,
155
+ currencyCode: "EUR",
156
+ fetch: fetchImpl,
157
+ });
158
+ const usd = new PricingClient({
159
+ cache,
160
+ currencyCode: "USD",
161
+ fetch: fetchImpl,
162
+ });
163
+
164
+ await eur.query("same-filter");
165
+ await usd.query("same-filter");
166
+
167
+ // Two distinct cache keys → two API round-trips, no cross-contamination.
168
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it("throws a descriptive error on a non-2xx response", async () => {
172
+ const fetchImpl: FetchLike = vi.fn(async () => ({
173
+ json: async () => ({}),
174
+ ok: false,
175
+ status: 503,
176
+ statusText: "Service Unavailable",
177
+ }));
178
+ const client = new PricingClient({
179
+ cache: new DiskCache({ dir }),
180
+ fetch: fetchImpl,
181
+ });
182
+
183
+ await expect(client.query("x")).rejects.toThrow(/503/);
184
+ });
185
+
186
+ it("throws when the response body fails schema validation", async () => {
187
+ const fetchImpl = makeOkFetch({ wrong: "shape" });
188
+ const client = new PricingClient({
189
+ cache: new DiskCache({ dir }),
190
+ fetch: fetchImpl,
191
+ });
192
+
193
+ await expect(client.query("x")).rejects.toThrow();
194
+ });
195
+
196
+ it("throws a descriptive error when the response body is not JSON", async () => {
197
+ const fetchImpl: FetchLike = vi.fn(async () => ({
198
+ json: async () => {
199
+ throw new SyntaxError("Unexpected token");
200
+ },
201
+ ok: true,
202
+ status: 200,
203
+ statusText: "OK",
204
+ }));
205
+ const client = new PricingClient({
206
+ cache: new DiskCache({ dir }),
207
+ fetch: fetchImpl,
208
+ });
209
+
210
+ await expect(client.query("x")).rejects.toThrow(/invalid JSON/);
211
+ });
212
+ });
@@ -0,0 +1,51 @@
1
+ {
2
+ "BillingCurrency": "EUR",
3
+ "Count": 2,
4
+ "Items": [
5
+ {
6
+ "currencyCode": "EUR",
7
+ "tierMinimumUnits": 0,
8
+ "retailPrice": 0.057,
9
+ "unitPrice": 0.057,
10
+ "armRegionName": "westeurope",
11
+ "location": "EU West",
12
+ "effectiveStartDate": "2024-01-01T00:00:00Z",
13
+ "meterId": "00000000-0000-0000-0000-000000000001",
14
+ "meterName": "B2s",
15
+ "productId": "DZH318Z0BPVW",
16
+ "skuId": "DZH318Z0BPVW/0001",
17
+ "productName": "Virtual Machines BS Series",
18
+ "skuName": "B2s",
19
+ "serviceName": "Virtual Machines",
20
+ "serviceId": "DZH318Z0BPVW",
21
+ "serviceFamily": "Compute",
22
+ "unitOfMeasure": "1 Hour",
23
+ "type": "Consumption",
24
+ "isPrimaryMeterRegion": true,
25
+ "armSkuName": "Standard_B2s"
26
+ },
27
+ {
28
+ "currencyCode": "EUR",
29
+ "tierMinimumUnits": 0,
30
+ "retailPrice": 0.0285,
31
+ "unitPrice": 0.0285,
32
+ "armRegionName": "westeurope",
33
+ "location": "EU West",
34
+ "effectiveStartDate": "2024-01-01T00:00:00Z",
35
+ "meterId": "00000000-0000-0000-0000-000000000002",
36
+ "meterName": "B2s Low Priority",
37
+ "productId": "DZH318Z0BPVW",
38
+ "skuId": "DZH318Z0BPVW/0002",
39
+ "productName": "Virtual Machines BS Series",
40
+ "skuName": "B2s Spot",
41
+ "serviceName": "Virtual Machines",
42
+ "serviceId": "DZH318Z0BPVW",
43
+ "serviceFamily": "Compute",
44
+ "unitOfMeasure": "1 Hour",
45
+ "type": "Consumption",
46
+ "isPrimaryMeterRegion": true,
47
+ "armSkuName": "Standard_B2s"
48
+ }
49
+ ],
50
+ "NextPageLink": null
51
+ }