@pagopa/dx-savemoney 0.3.2 → 0.4.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 (163) 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 +10 -0
  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 +55 -37
  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/pricing/__tests__/cache.test.d.ts +8 -0
  20. package/dist/azure/pricing/__tests__/cache.test.d.ts.map +1 -0
  21. package/dist/azure/pricing/__tests__/cache.test.js +56 -0
  22. package/dist/azure/pricing/__tests__/cache.test.js.map +1 -0
  23. package/dist/azure/pricing/__tests__/client.test.d.ts +9 -0
  24. package/dist/azure/pricing/__tests__/client.test.d.ts.map +1 -0
  25. package/dist/azure/pricing/__tests__/client.test.js +181 -0
  26. package/dist/azure/pricing/__tests__/client.test.js.map +1 -0
  27. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts +6 -0
  28. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts.map +1 -0
  29. package/dist/azure/pricing/__tests__/pricing-service.test.js +283 -0
  30. package/dist/azure/pricing/__tests__/pricing-service.test.js.map +1 -0
  31. package/dist/azure/pricing/cache.d.ts +45 -0
  32. package/dist/azure/pricing/cache.d.ts.map +1 -0
  33. package/dist/azure/pricing/cache.js +79 -0
  34. package/dist/azure/pricing/cache.js.map +1 -0
  35. package/dist/azure/pricing/client.d.ts +67 -0
  36. package/dist/azure/pricing/client.d.ts.map +1 -0
  37. package/dist/azure/pricing/client.js +96 -0
  38. package/dist/azure/pricing/client.js.map +1 -0
  39. package/dist/azure/pricing/index.d.ts +6 -0
  40. package/dist/azure/pricing/index.d.ts.map +1 -0
  41. package/dist/azure/pricing/index.js +6 -0
  42. package/dist/azure/pricing/index.js.map +1 -0
  43. package/dist/azure/pricing/pricing-service.d.ts +46 -0
  44. package/dist/azure/pricing/pricing-service.d.ts.map +1 -0
  45. package/dist/azure/pricing/pricing-service.js +84 -0
  46. package/dist/azure/pricing/pricing-service.js.map +1 -0
  47. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts +5 -0
  48. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts.map +1 -0
  49. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js +137 -0
  50. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js.map +1 -0
  51. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts +5 -0
  52. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts.map +1 -0
  53. package/dist/azure/pricing/resolvers/__tests__/disk.test.js +160 -0
  54. package/dist/azure/pricing/resolvers/__tests__/disk.test.js.map +1 -0
  55. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts +5 -0
  56. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts.map +1 -0
  57. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js +113 -0
  58. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js.map +1 -0
  59. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts +9 -0
  60. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts.map +1 -0
  61. package/dist/azure/pricing/resolvers/__tests__/vm.test.js +204 -0
  62. package/dist/azure/pricing/resolvers/__tests__/vm.test.js.map +1 -0
  63. package/dist/azure/pricing/resolvers/app-service.d.ts +27 -0
  64. package/dist/azure/pricing/resolvers/app-service.d.ts.map +1 -0
  65. package/dist/azure/pricing/resolvers/app-service.js +67 -0
  66. package/dist/azure/pricing/resolvers/app-service.js.map +1 -0
  67. package/dist/azure/pricing/resolvers/disk.d.ts +59 -0
  68. package/dist/azure/pricing/resolvers/disk.d.ts.map +1 -0
  69. package/dist/azure/pricing/resolvers/disk.js +140 -0
  70. package/dist/azure/pricing/resolvers/disk.js.map +1 -0
  71. package/dist/azure/pricing/resolvers/index.d.ts +12 -0
  72. package/dist/azure/pricing/resolvers/index.d.ts.map +1 -0
  73. package/dist/azure/pricing/resolvers/index.js +8 -0
  74. package/dist/azure/pricing/resolvers/index.js.map +1 -0
  75. package/dist/azure/pricing/resolvers/public-ip.d.ts +32 -0
  76. package/dist/azure/pricing/resolvers/public-ip.d.ts.map +1 -0
  77. package/dist/azure/pricing/resolvers/public-ip.js +56 -0
  78. package/dist/azure/pricing/resolvers/public-ip.js.map +1 -0
  79. package/dist/azure/pricing/resolvers/vm.d.ts +51 -0
  80. package/dist/azure/pricing/resolvers/vm.d.ts.map +1 -0
  81. package/dist/azure/pricing/resolvers/vm.js +89 -0
  82. package/dist/azure/pricing/resolvers/vm.js.map +1 -0
  83. package/dist/azure/pricing/schema.d.ts +88 -0
  84. package/dist/azure/pricing/schema.d.ts.map +1 -0
  85. package/dist/azure/pricing/schema.js +64 -0
  86. package/dist/azure/pricing/schema.js.map +1 -0
  87. package/dist/azure/pricing/test-cache-dir.d.ts +3 -0
  88. package/dist/azure/pricing/test-cache-dir.d.ts.map +1 -0
  89. package/dist/azure/pricing/test-cache-dir.js +17 -0
  90. package/dist/azure/pricing/test-cache-dir.js.map +1 -0
  91. package/dist/azure/report.d.ts.map +1 -1
  92. package/dist/azure/report.js +57 -18
  93. package/dist/azure/report.js.map +1 -1
  94. package/dist/azure/resources/__tests__/app-service.test.d.ts +5 -0
  95. package/dist/azure/resources/__tests__/app-service.test.d.ts.map +1 -0
  96. package/dist/azure/resources/__tests__/app-service.test.js +105 -0
  97. package/dist/azure/resources/__tests__/app-service.test.js.map +1 -0
  98. package/dist/azure/resources/app-service.d.ts +8 -3
  99. package/dist/azure/resources/app-service.d.ts.map +1 -1
  100. package/dist/azure/resources/app-service.js +35 -2
  101. package/dist/azure/resources/app-service.js.map +1 -1
  102. package/dist/azure/resources/disk.d.ts +3 -1
  103. package/dist/azure/resources/disk.d.ts.map +1 -1
  104. package/dist/azure/resources/disk.js +35 -3
  105. package/dist/azure/resources/disk.js.map +1 -1
  106. package/dist/azure/resources/public-ip.d.ts +3 -1
  107. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  108. package/dist/azure/resources/public-ip.js +41 -3
  109. package/dist/azure/resources/public-ip.js.map +1 -1
  110. package/dist/azure/resources/vm.d.ts +4 -1
  111. package/dist/azure/resources/vm.d.ts.map +1 -1
  112. package/dist/azure/resources/vm.js +49 -6
  113. package/dist/azure/resources/vm.js.map +1 -1
  114. package/dist/azure/types.d.ts +13 -0
  115. package/dist/azure/types.d.ts.map +1 -1
  116. package/dist/finding.d.ts +3 -3
  117. package/dist/finding.d.ts.map +1 -1
  118. package/dist/finding.js +7 -0
  119. package/dist/finding.js.map +1 -1
  120. package/dist/index.d.ts +2 -2
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js.map +1 -1
  123. package/dist/types.d.ts +8 -0
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +9 -9
  128. package/src/__tests__/finding.test.ts +24 -0
  129. package/src/azure/__tests__/analyzer-tags.test.ts +36 -1
  130. package/src/azure/__tests__/config.test.ts +12 -0
  131. package/src/azure/__tests__/report.test.ts +78 -0
  132. package/src/azure/analyzer.ts +67 -43
  133. package/src/azure/analyzers/registry.ts +8 -2
  134. package/src/azure/analyzers/types.ts +6 -0
  135. package/src/azure/pricing/__tests__/cache.test.ts +67 -0
  136. package/src/azure/pricing/__tests__/client.test.ts +212 -0
  137. package/src/azure/pricing/__tests__/fixtures/vm-b2s-westeurope.json +51 -0
  138. package/src/azure/pricing/__tests__/pricing-service.test.ts +315 -0
  139. package/src/azure/pricing/cache.ts +92 -0
  140. package/src/azure/pricing/client.ts +137 -0
  141. package/src/azure/pricing/index.ts +6 -0
  142. package/src/azure/pricing/pricing-service.ts +125 -0
  143. package/src/azure/pricing/resolvers/__tests__/app-service.test.ts +154 -0
  144. package/src/azure/pricing/resolvers/__tests__/disk.test.ts +189 -0
  145. package/src/azure/pricing/resolvers/__tests__/public-ip.test.ts +130 -0
  146. package/src/azure/pricing/resolvers/__tests__/vm.test.ts +232 -0
  147. package/src/azure/pricing/resolvers/app-service.ts +102 -0
  148. package/src/azure/pricing/resolvers/disk.ts +170 -0
  149. package/src/azure/pricing/resolvers/index.ts +31 -0
  150. package/src/azure/pricing/resolvers/public-ip.ts +87 -0
  151. package/src/azure/pricing/resolvers/vm.ts +125 -0
  152. package/src/azure/pricing/schema.ts +69 -0
  153. package/src/azure/pricing/test-cache-dir.ts +26 -0
  154. package/src/azure/report.ts +86 -21
  155. package/src/azure/resources/__tests__/app-service.test.ts +165 -0
  156. package/src/azure/resources/app-service.ts +65 -4
  157. package/src/azure/resources/disk.ts +46 -4
  158. package/src/azure/resources/public-ip.ts +61 -2
  159. package/src/azure/resources/vm.ts +89 -11
  160. package/src/azure/types.ts +14 -0
  161. package/src/finding.ts +10 -3
  162. package/src/index.ts +3 -3
  163. package/src/types.ts +12 -0
@@ -9,6 +9,7 @@ import * as armResources from "@azure/arm-resources";
9
9
  import { getLogger } from "@logtape/logtape";
10
10
 
11
11
  import type { AnalysisResult, Thresholds } from "../../types.js";
12
+ import type { PricingService } from "../pricing/pricing-service.js";
12
13
 
13
14
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
15
  import {
@@ -27,6 +28,8 @@ import {
27
28
  * @param computeClient - Azure Compute client for VM details
28
29
  * @param timespanDays - Number of days to analyze metrics
29
30
  * @param verbose - Whether verbose logging is enabled
31
+ * @param cache - Run-scoped metrics cache
32
+ * @param pricing - Optional pricing facade for estimated monthly cost
30
33
  * @returns Analysis result with cost risk and reason
31
34
  */
32
35
  export async function analyzeVM(
@@ -37,6 +40,7 @@ export async function analyzeVM(
37
40
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
38
41
  verbose = false,
39
42
  cache?: MetricsCache,
43
+ pricing?: PricingService,
40
44
  ): Promise<AnalysisResult> {
41
45
  verboseLogResourceStart(
42
46
  verbose,
@@ -76,21 +80,35 @@ export async function analyzeVM(
76
80
  );
77
81
 
78
82
  if (vmStatus?.code === "PowerState/deallocated") {
79
- const result = {
80
- costRisk,
81
- reason: "VM is deallocated. ",
82
- suspectedUnused: true,
83
- };
83
+ const result = await enrichWithPricing(
84
+ {
85
+ costRisk,
86
+ reason: "VM is deallocated. ",
87
+ suspectedUnused: true,
88
+ },
89
+ resource,
90
+ computeClient,
91
+ resourceGroupName,
92
+ vmName,
93
+ pricing,
94
+ );
84
95
  verboseLogAnalysisResult(verbose, result);
85
96
  return result;
86
97
  }
87
98
 
88
99
  if (vmStatus?.code === "PowerState/stopped") {
89
- const result = {
90
- costRisk,
91
- reason: "VM is stopped. ",
92
- suspectedUnused: true,
93
- };
100
+ const result = await enrichWithPricing(
101
+ {
102
+ costRisk,
103
+ reason: "VM is stopped. ",
104
+ suspectedUnused: true,
105
+ },
106
+ resource,
107
+ computeClient,
108
+ resourceGroupName,
109
+ vmName,
110
+ pricing,
111
+ );
94
112
  verboseLogAnalysisResult(verbose, result);
95
113
  return result;
96
114
  }
@@ -127,7 +145,67 @@ export async function analyzeVM(
127
145
  reason += `Low network traffic (${(networkIn / 1024 / 1024).toFixed(2)} MB/day avg). `;
128
146
  }
129
147
 
130
- const result = { costRisk, reason, suspectedUnused: reason.length > 0 };
148
+ const result = await enrichWithPricing(
149
+ { costRisk, reason, suspectedUnused: reason.length > 0 },
150
+ resource,
151
+ computeClient,
152
+ resourceGroupName,
153
+ vmName,
154
+ pricing,
155
+ );
131
156
  verboseLogAnalysisResult(verbose, result);
132
157
  return result;
133
158
  }
159
+
160
+ /**
161
+ * Best-effort enrichment of a VM analysis result with the estimated
162
+ * monthly cost recoverable by removing/right-sizing the resource.
163
+ *
164
+ * No-op when:
165
+ * - the result is not flagged as suspected unused
166
+ * - no pricing facade was provided
167
+ * - the VM SKU or region cannot be determined
168
+ * - the Retail Prices lookup returns no matching meter
169
+ *
170
+ * Any error along the way is logged at warn level and swallowed so the
171
+ * main analysis never fails because of a pricing problem.
172
+ */
173
+ async function enrichWithPricing(
174
+ result: AnalysisResult,
175
+ resource: armResources.GenericResource,
176
+ computeClient: ComputeManagementClient,
177
+ resourceGroupName: string,
178
+ vmName: string,
179
+ pricing?: PricingService,
180
+ ): Promise<AnalysisResult> {
181
+ if (!pricing || !result.suspectedUnused) {
182
+ return result;
183
+ }
184
+ try {
185
+ const details = await computeClient.virtualMachines.get(
186
+ resourceGroupName,
187
+ vmName,
188
+ );
189
+ const armSkuName = details.hardwareProfile?.vmSize;
190
+ const armRegionName = details.location ?? resource.location;
191
+ if (!armSkuName || !armRegionName) {
192
+ return result;
193
+ }
194
+ const os = details.storageProfile?.osDisk?.osType?.toLowerCase();
195
+ const estimatedMonthlySavings = await pricing.resolveVm({
196
+ armRegionName,
197
+ armSkuName,
198
+ os: os === "windows" ? "windows" : "linux",
199
+ });
200
+ if (!estimatedMonthlySavings) {
201
+ return result;
202
+ }
203
+ return { ...result, estimatedMonthlySavings };
204
+ } catch (error) {
205
+ const logger = getLogger(["savemoney", "azure"]);
206
+ logger.warn(
207
+ `Failed to enrich VM ${vmName} with pricing: ${error instanceof Error ? error.message : error}`,
208
+ );
209
+ return result;
210
+ }
211
+ }
@@ -27,6 +27,12 @@ export type AzureConfig = BaseConfig & {
27
27
  * If omitted, all resources are analyzed.
28
28
  */
29
29
  filterTags?: Map<string, string>;
30
+ /**
31
+ * Runtime-only pricing enrichment switch. This is intentionally not exposed
32
+ * in YAML config: the Retail Prices lookup uses internal defaults unless the
33
+ * CLI disables it for offline/debug runs.
34
+ */
35
+ pricing?: PricingConfig;
30
36
  /**
31
37
  * Which finding sources to include in the run.
32
38
  * - `"custom"` → enables the per-resource analyzer plugins
@@ -83,3 +89,11 @@ export type AzureResourceReport = {
83
89
  * filter for Azure runs and would silently produce an empty report.
84
90
  */
85
91
  export type AzureSource = "advisor" | "custom";
92
+
93
+ export type PricingConfig = {
94
+ /**
95
+ * Runtime switch used by the CLI `--no-pricing` flag. Pricing otherwise
96
+ * stays enabled with internal defaults for currency and cache settings.
97
+ */
98
+ enabled?: boolean;
99
+ };
package/src/finding.ts CHANGED
@@ -32,9 +32,9 @@ export type Finding = {
32
32
  */
33
33
  code: string;
34
34
  /**
35
- * Estimated monthly cost that could be recovered by acting on this
36
- * finding. Populated by Advisor and (in later phases) by the Retail
37
- * Prices integration. Absent when the analyzer cannot estimate it.
35
+ * Estimated monthly saving for this specific finding. Populated by Advisor
36
+ * recommendations; custom Retail Prices estimates stay at resource level
37
+ * because they represent cost at risk, not a per-finding saving.
38
38
  */
39
39
  estimatedMonthlySavings?: Money;
40
40
  /**
@@ -128,6 +128,13 @@ export function findingsFromAnalysisResult(args: {
128
128
  if (sentences.length === 0) {
129
129
  return [];
130
130
  }
131
+ // We intentionally do NOT propagate any monetary estimate down to the
132
+ // individual findings: a per-resource cost (e.g. €29.94/mo for a VM)
133
+ // attached to a sentence like "No tags found" would falsely suggest
134
+ // "add the missing tags to save €29.94". The resource-level cost is
135
+ // carried instead by `AnalysisResult.estimatedMonthlySavings` and
136
+ // surfaced by the report layer as a per-resource header — see
137
+ // `azure/report.ts`.
131
138
  return sentences.map((sentence) => ({
132
139
  category: "cost" as const,
133
140
  code: code ?? "custom.unknown",
package/src/index.ts CHANGED
@@ -21,14 +21,13 @@ export {
21
21
  } from "./azure/analyzers/index.js";
22
22
 
23
23
  // Export common types
24
- export type { AzureConfig, AzureSource } from "./azure/types.js";
24
+ export type { AzureConfig, AzureSource, PricingConfig } from "./azure/types.js";
25
+ export { type MetricsCache, type MonitorClientLike } from "./azure/utils.js";
25
26
 
26
27
  // Export Azure module
27
28
  import * as azureModule from "./azure/index.js";
28
29
  export const azure = azureModule;
29
30
 
30
- export { type MetricsCache, type MonitorClientLike } from "./azure/utils.js";
31
-
32
31
  // Phase 0: unified Finding model and analyzer plugin layer
33
32
  export {
34
33
  type Finding,
@@ -38,6 +37,7 @@ export {
38
37
  type Money,
39
38
  type ResourceReport,
40
39
  } from "./finding.js";
40
+
41
41
  export * from "./types.js";
42
42
 
43
43
  import type { AzureConfig } from "./azure/types.js";
package/src/types.ts CHANGED
@@ -2,10 +2,19 @@
2
2
  * Common types shared across all cloud providers
3
3
  */
4
4
 
5
+ import type { Money } from "./finding.js";
6
+
5
7
  import { ThresholdsSchema } from "./schema.js";
6
8
 
7
9
  export type AnalysisResult = {
8
10
  costRisk: CostRisk;
11
+ /**
12
+ * Estimated monthly resource cost at risk. Custom pricing-enriched
13
+ * analyzers populate it when a Retail Prices lookup succeeds; the report
14
+ * renders it once per resource instead of treating it as a per-finding
15
+ * saving.
16
+ */
17
+ estimatedMonthlySavings?: Money;
9
18
  reason: string;
10
19
  suspectedUnused: boolean;
11
20
  };
@@ -41,6 +50,9 @@ export function mergeResults(
41
50
  ): AnalysisResult {
42
51
  return {
43
52
  costRisk: specificResult.costRisk,
53
+ estimatedMonthlySavings:
54
+ specificResult.estimatedMonthlySavings ??
55
+ baseResult.estimatedMonthlySavings,
44
56
  reason: baseResult.reason + specificResult.reason,
45
57
  suspectedUnused:
46
58
  baseResult.suspectedUnused || specificResult.suspectedUnused,