@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
@@ -38,7 +38,19 @@ const RISK_COLOR = {
38
38
  } as const;
39
39
 
40
40
  type Summary = {
41
+ /**
42
+ * Per-resource cost (i.e. the monthly bill the resource is currently
43
+ * generating) summed across resources flagged as suspected unused by
44
+ * custom analyzers. Represents the UPPER BOUND of what could be saved
45
+ * by removing the resource, NOT the saving of any individual finding.
46
+ */
47
+ costAtRiskByCurrency: Map<string, number>;
41
48
  counts: { high: number; low: number; medium: number };
49
+ /**
50
+ * Sum of Advisor's per-finding `extendedProperties.savingsAmount`
51
+ * values. These are authoritative point estimates from Azure ("apply
52
+ * this recommendation and save €X"), so they aggregate cleanly.
53
+ */
42
54
  savingsByCurrency: Map<string, number>;
43
55
  sourceCounts: Record<string, number>;
44
56
  };
@@ -111,6 +123,7 @@ export async function generateReport(
111
123
  */
112
124
  function computeSummary(report: AzureDetailedResourceReport[]): Summary {
113
125
  const summary: Summary = {
126
+ costAtRiskByCurrency: new Map(),
114
127
  counts: { high: 0, low: 0, medium: 0 },
115
128
  savingsByCurrency: new Map(),
116
129
  sourceCounts: {},
@@ -131,7 +144,7 @@ function computeSummary(report: AzureDetailedResourceReport[]): Summary {
131
144
  summary.counts[line.severity]++;
132
145
  summary.sourceCounts[line.source] =
133
146
  (summary.sourceCounts[line.source] ?? 0) + 1;
134
- if (line.savings) {
147
+ if (line.source === "advisor" && line.savings) {
135
148
  summary.savingsByCurrency.set(
136
149
  line.savings.currency,
137
150
  (summary.savingsByCurrency.get(line.savings.currency) ?? 0) +
@@ -139,6 +152,16 @@ function computeSummary(report: AzureDetailedResourceReport[]): Summary {
139
152
  );
140
153
  }
141
154
  }
155
+ // Per-resource cost: counted ONCE per resource (not per finding) so
156
+ // that a resource with N findings doesn't inflate the total by Nx.
157
+ const costAtRisk = entry.analysis.estimatedMonthlySavings;
158
+ if (costAtRisk) {
159
+ summary.costAtRiskByCurrency.set(
160
+ costAtRisk.currency,
161
+ (summary.costAtRiskByCurrency.get(costAtRisk.currency) ?? 0) +
162
+ costAtRisk.amount,
163
+ );
164
+ }
142
165
  }
143
166
  return summary;
144
167
  }
@@ -162,48 +185,79 @@ function formatAmount(amount: number, currency: string): string {
162
185
  }
163
186
 
164
187
  /**
165
- * Renders an estimated monthly savings value as the short "(€ 12.50/mo)"
166
- * label that ships next to each lint line. Returns an empty string when
167
- * the analyzer didn't provide an estimate.
188
+ * Renders an Advisor per-finding monthly saving as the short
189
+ * "(saving: € 12.50/mo)" label that ships next to each lint line.
190
+ * Returns an empty string when the finding doesn't carry an estimate.
191
+ *
192
+ * Only Advisor populates this today: Microsoft attaches an authoritative
193
+ * per-recommendation saving to each recommendation, so it aggregates
194
+ * cleanly. Custom analyzers do NOT propagate the resource's monthly
195
+ * bill down to each finding — see the per-resource "cost at risk"
196
+ * header rendered by `generateLintReport` instead.
168
197
  */
169
- function formatSavings(savings: Money | undefined): string {
198
+ function formatPerFindingSavings(savings?: Money): string {
170
199
  if (!savings) return "";
171
- return `(${formatAmount(savings.amount, savings.currency)}/mo)`;
200
+ return `(saving: ${formatAmount(savings.amount, savings.currency)}/mo)`;
172
201
  }
173
202
 
174
203
  /**
175
204
  * Renders a linter-style report to stdout, grouping findings by resource.
176
205
  *
177
206
  * When `Finding[]` is attached to a report (Phase 1+), each finding is
178
- * rendered with its `source` badge (e.g. `[advisor]`) and the estimated
179
- * monthly savings, when known. For older payloads without `findings` we
180
- * fall back to splitting `analysis.reason` like before so the format stays
181
- * backward compatible.
207
+ * rendered with its `source` badge (e.g. `[advisor]`) and its own
208
+ * estimated monthly saving (when populated by Advisor). For older
209
+ * payloads without `findings` we fall back to splitting `analysis.reason`
210
+ * like before so the format stays backward compatible.
211
+ *
212
+ * When a custom analyzer populated `analysis.estimatedMonthlySavings`,
213
+ * the value is printed ONCE per resource as a header annotation labelled
214
+ * "cost at risk" — it represents the monthly bill of the resource (an
215
+ * upper bound on what could be recovered by removing it), NOT the saving
216
+ * of any individual finding sentence.
182
217
  *
183
218
  * Example output:
184
219
  *
185
- * /subscriptions/.../virtualMachines/my-vm
186
- * ✖ HIGH [advisor] Right-size your VM. (€ 12.50/mo)
220
+ * /subscriptions/.../virtualMachines/my-vm (cost at risk: € 29.94/mo)
187
221
  * ✖ HIGH [custom] No tags found.
222
+ * ✖ HIGH [custom] VM is deallocated.
223
+ *
224
+ * /subscriptions/.../virtualMachines/other-vm
225
+ * ✖ HIGH [advisor] Right-size your VM. (saving: € 12.50/mo)
188
226
  *
189
227
  * Summary: 3 issues found (2 high, 0 medium, 1 low)
190
- * Estimated monthly savings: € 12.50
228
+ * Estimated monthly cost at risk (custom): € 29.94
229
+ * Estimated monthly savings (advisor): € 12.50
191
230
  */
192
231
  function generateLintReport(report: AzureDetailedResourceReport[]): void {
193
- const summary = {
232
+ const summary: Summary = {
233
+ costAtRiskByCurrency: new Map(),
194
234
  counts: { high: 0, low: 0, medium: 0 },
195
- savingsByCurrency: new Map<string, number>(),
196
- sourceCounts: {} as Record<string, number>,
235
+ savingsByCurrency: new Map(),
236
+ sourceCounts: {},
197
237
  };
198
238
 
199
239
  for (const entry of report) {
200
240
  const resourceId =
201
241
  entry.resource.id ?? `unknown/${entry.resource.name ?? "unknown"}`;
202
- console.log(`${BOLD}${resourceId}${RESET}`);
242
+ const costAtRisk = entry.analysis.estimatedMonthlySavings;
243
+ const header = costAtRisk
244
+ ? `${BOLD}${resourceId}${RESET} ${GREEN}(cost at risk: ${formatAmount(costAtRisk.amount, costAtRisk.currency)}/mo)${RESET}`
245
+ : `${BOLD}${resourceId}${RESET}`;
246
+ console.log(header);
247
+ if (costAtRisk) {
248
+ summary.costAtRiskByCurrency.set(
249
+ costAtRisk.currency,
250
+ (summary.costAtRiskByCurrency.get(costAtRisk.currency) ?? 0) +
251
+ costAtRisk.amount,
252
+ );
253
+ }
203
254
 
204
255
  const lines = entry.findings?.length
205
256
  ? entry.findings.map((f) => ({
206
- extra: formatSavings(f.estimatedMonthlySavings),
257
+ extra:
258
+ f.source === "advisor"
259
+ ? formatPerFindingSavings(f.estimatedMonthlySavings)
260
+ : "",
207
261
  severity: f.severity,
208
262
  source: f.source,
209
263
  text: f.reason,
@@ -231,7 +285,7 @@ function generateLintReport(report: AzureDetailedResourceReport[]): void {
231
285
 
232
286
  if (entry.findings) {
233
287
  for (const f of entry.findings) {
234
- if (f.estimatedMonthlySavings) {
288
+ if (f.source === "advisor" && f.estimatedMonthlySavings) {
235
289
  const { amount, currency } = f.estimatedMonthlySavings;
236
290
  summary.savingsByCurrency.set(
237
291
  currency,
@@ -259,7 +313,8 @@ function generateLintReport(report: AzureDetailedResourceReport[]): void {
259
313
  * out of scope for this tool.
260
314
  */
261
315
  function printSummary(summary: Summary): void {
262
- const { counts, savingsByCurrency, sourceCounts } = summary;
316
+ const { costAtRiskByCurrency, counts, savingsByCurrency, sourceCounts } =
317
+ summary;
263
318
  const total = counts.high + counts.medium + counts.low;
264
319
  const summaryLine =
265
320
  `${BOLD}Summary:${RESET} ${total} issue${total !== 1 ? "s" : ""} found` +
@@ -276,13 +331,23 @@ function printSummary(summary: Summary): void {
276
331
  console.log(`${BOLD}Sources:${RESET} ${sourceBreakdown}`);
277
332
  }
278
333
 
334
+ if (costAtRiskByCurrency.size > 0) {
335
+ const parts = [...costAtRiskByCurrency.entries()].map(
336
+ ([currency, amount]) =>
337
+ `${GREEN}${formatAmount(amount, currency)}${RESET}`,
338
+ );
339
+ console.log(
340
+ `${BOLD}Estimated monthly cost at risk (custom):${RESET} ${parts.join(", ")}`,
341
+ );
342
+ }
343
+
279
344
  if (savingsByCurrency.size > 0) {
280
345
  const parts = [...savingsByCurrency.entries()].map(
281
346
  ([currency, amount]) =>
282
347
  `${GREEN}${formatAmount(amount, currency)}${RESET}`,
283
348
  );
284
349
  console.log(
285
- `${BOLD}Estimated monthly savings:${RESET} ${parts.join(", ")}`,
350
+ `${BOLD}Estimated monthly savings (advisor):${RESET} ${parts.join(", ")}`,
286
351
  );
287
352
  }
288
353
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Tests for analyzeAppServicePlan() pricing enrichment.
3
+ */
4
+
5
+ import type { GenericResource } from "@azure/arm-resources";
6
+
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import type { MonitorClientLike } from "../../utils.js";
10
+ import type { AppServicePlanClientLike } from "../app-service.js";
11
+
12
+ import { DEFAULT_THRESHOLDS } from "../../../types.js";
13
+
14
+ vi.mock("../../utils.js", () => ({
15
+ getMetric: vi.fn(),
16
+ verboseLog: vi.fn(),
17
+ verboseLogAnalysisResult: vi.fn(),
18
+ verboseLogResourceStart: vi.fn(),
19
+ }));
20
+
21
+ import { getMetric } from "../../utils.js";
22
+ import { analyzeAppServicePlan } from "../app-service.js";
23
+
24
+ const RESOURCE: GenericResource = {
25
+ id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Web/serverfarms/plan1",
26
+ location: "westeurope",
27
+ name: "plan1",
28
+ type: "Microsoft.Web/serverfarms",
29
+ };
30
+
31
+ const mockGetMetric = vi.mocked(getMetric);
32
+
33
+ function makeMonitorClient() {
34
+ return {
35
+ metrics: { list: vi.fn() },
36
+ } satisfies MonitorClientLike;
37
+ }
38
+
39
+ function makePricingService() {
40
+ return {
41
+ resolveAppServicePlan: vi.fn().mockResolvedValue({
42
+ amount: 121.72,
43
+ currency: "EUR",
44
+ }),
45
+ };
46
+ }
47
+
48
+ function makeWebSiteClient(planDetails: {
49
+ numberOfSites?: number;
50
+ reserved?: boolean;
51
+ sku?: {
52
+ capacity?: number;
53
+ name?: string;
54
+ tier?: string;
55
+ };
56
+ }) {
57
+ const client = {
58
+ appServicePlans: {
59
+ get: vi.fn().mockResolvedValue({
60
+ location: "westeurope",
61
+ ...planDetails,
62
+ }),
63
+ },
64
+ } satisfies AppServicePlanClientLike;
65
+ return client;
66
+ }
67
+
68
+ describe("analyzeAppServicePlan", () => {
69
+ beforeEach(() => {
70
+ mockGetMetric.mockReset();
71
+ mockGetMetric.mockResolvedValue(50);
72
+ });
73
+
74
+ it("enriches suspected unused plans with App Service Plan pricing", async () => {
75
+ const pricing = makePricingService();
76
+
77
+ const result = await analyzeAppServicePlan(
78
+ RESOURCE,
79
+ makeWebSiteClient({
80
+ numberOfSites: 0,
81
+ reserved: true,
82
+ sku: {
83
+ capacity: 2,
84
+ name: "S1",
85
+ tier: "Standard",
86
+ },
87
+ }),
88
+ makeMonitorClient(),
89
+ 30,
90
+ DEFAULT_THRESHOLDS,
91
+ false,
92
+ undefined,
93
+ pricing,
94
+ );
95
+
96
+ expect(result.estimatedMonthlySavings).toEqual({
97
+ amount: 121.72,
98
+ currency: "EUR",
99
+ });
100
+ expect(pricing.resolveAppServicePlan).toHaveBeenCalledWith({
101
+ armRegionName: "westeurope",
102
+ os: "linux",
103
+ skuName: "S1",
104
+ workerCount: 2,
105
+ });
106
+ });
107
+
108
+ it("does not price Premium underutilization as full removable cost", async () => {
109
+ mockGetMetric.mockResolvedValue(1);
110
+ const pricing = makePricingService();
111
+
112
+ const result = await analyzeAppServicePlan(
113
+ RESOURCE,
114
+ makeWebSiteClient({
115
+ numberOfSites: 2,
116
+ reserved: false,
117
+ sku: {
118
+ capacity: 3,
119
+ name: "P1v3",
120
+ tier: "PremiumV3",
121
+ },
122
+ }),
123
+ makeMonitorClient(),
124
+ 30,
125
+ DEFAULT_THRESHOLDS,
126
+ false,
127
+ undefined,
128
+ pricing,
129
+ );
130
+
131
+ expect(result.suspectedUnused).toBe(true);
132
+ expect(result.reason).toContain(
133
+ "Premium tier with low resource utilization",
134
+ );
135
+ expect(result.estimatedMonthlySavings).toBeUndefined();
136
+ expect(pricing.resolveAppServicePlan).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("does not treat missing site count as an empty plan", async () => {
140
+ mockGetMetric.mockResolvedValue(50);
141
+ const pricing = makePricingService();
142
+
143
+ const result = await analyzeAppServicePlan(
144
+ RESOURCE,
145
+ makeWebSiteClient({
146
+ reserved: true,
147
+ sku: {
148
+ capacity: 2,
149
+ name: "S1",
150
+ tier: "Standard",
151
+ },
152
+ }),
153
+ makeMonitorClient(),
154
+ 30,
155
+ DEFAULT_THRESHOLDS,
156
+ false,
157
+ undefined,
158
+ pricing,
159
+ );
160
+
161
+ expect(result.suspectedUnused).toBe(false);
162
+ expect(result.estimatedMonthlySavings).toBeUndefined();
163
+ expect(pricing.resolveAppServicePlan).not.toHaveBeenCalled();
164
+ });
165
+ });
@@ -3,22 +3,29 @@
3
3
  */
4
4
 
5
5
  import type { WebSiteManagementClient } from "@azure/arm-appservice";
6
- import type { MonitorClient } from "@azure/arm-monitor";
7
6
 
8
7
  import * as armResources from "@azure/arm-resources";
9
8
  import { getLogger } from "@logtape/logtape";
10
9
 
11
10
  import type { AnalysisResult, Thresholds } from "../../types.js";
11
+ import type { PricingService } from "../pricing/pricing-service.js";
12
12
 
13
13
  import { DEFAULT_THRESHOLDS } from "../../types.js";
14
14
  import {
15
15
  getMetric,
16
16
  type MetricsCache,
17
+ type MonitorClientLike,
17
18
  verboseLog,
18
19
  verboseLogAnalysisResult,
19
20
  verboseLogResourceStart,
20
21
  } from "../utils.js";
21
22
 
23
+ export type AppServicePlanClientLike = {
24
+ appServicePlans: Pick<WebSiteManagementClient["appServicePlans"], "get">;
25
+ };
26
+
27
+ type AppServicePlanPricing = Pick<PricingService, "resolveAppServicePlan">;
28
+
22
29
  /**
23
30
  * Analyzes an Azure App Service Plan for potential cost optimization.
24
31
  *
@@ -30,12 +37,13 @@ import {
30
37
  */
31
38
  export async function analyzeAppServicePlan(
32
39
  resource: armResources.GenericResource,
33
- webSiteClient: WebSiteManagementClient,
34
- monitorClient: MonitorClient,
40
+ webSiteClient: AppServicePlanClientLike,
41
+ monitorClient: MonitorClientLike,
35
42
  timespanDays: number,
36
43
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
37
44
  verbose = false,
38
45
  cache?: MetricsCache,
46
+ pricing?: AppServicePlanPricing,
39
47
  ): Promise<AnalysisResult> {
40
48
  verboseLogResourceStart(
41
49
  verbose,
@@ -69,8 +77,10 @@ export async function analyzeAppServicePlan(
69
77
 
70
78
  verboseLog(verbose, "App Service Plan API details:", planDetails);
71
79
 
80
+ const hasNoApps = planDetails.numberOfSites === 0;
81
+
72
82
  // Check if the plan has no apps
73
- if (!planDetails.numberOfSites || planDetails.numberOfSites === 0) {
83
+ if (hasNoApps) {
74
84
  reason += "App Service Plan has no apps deployed. ";
75
85
  }
76
86
 
@@ -115,6 +125,17 @@ export async function analyzeAppServicePlan(
115
125
  ) {
116
126
  reason += "Premium tier with low resource utilization. ";
117
127
  }
128
+
129
+ const suspectedUnused = reason.length > 0;
130
+ const result = await enrichWithPricing(
131
+ { costRisk, reason: reason.trim(), suspectedUnused },
132
+ planDetails,
133
+ resource,
134
+ hasNoApps,
135
+ pricing,
136
+ );
137
+ verboseLogAnalysisResult(verbose, result);
138
+ return result;
118
139
  } catch (error) {
119
140
  const logger = getLogger(["savemoney", "azure"]);
120
141
  logger.warn(
@@ -128,3 +149,43 @@ export async function analyzeAppServicePlan(
128
149
  verboseLogAnalysisResult(verbose, result);
129
150
  return result;
130
151
  }
152
+
153
+ async function enrichWithPricing(
154
+ result: AnalysisResult,
155
+ planDetails: Awaited<
156
+ ReturnType<AppServicePlanClientLike["appServicePlans"]["get"]>
157
+ >,
158
+ resource: armResources.GenericResource,
159
+ hasNoApps: boolean,
160
+ pricing?: AppServicePlanPricing,
161
+ ): Promise<AnalysisResult> {
162
+ // Underutilized plans need right-sizing analysis, not a full removable
163
+ // cost-at-risk.
164
+ if (!pricing || !result.suspectedUnused || !hasNoApps) {
165
+ return result;
166
+ }
167
+
168
+ try {
169
+ const skuName = planDetails.sku?.name;
170
+ const armRegionName = planDetails.location ?? resource.location;
171
+ if (!skuName || !armRegionName) {
172
+ return result;
173
+ }
174
+
175
+ const estimatedMonthlySavings = await pricing.resolveAppServicePlan({
176
+ armRegionName,
177
+ os: planDetails.reserved === true ? "linux" : "windows",
178
+ skuName,
179
+ workerCount: planDetails.sku?.capacity ?? planDetails.numberOfWorkers,
180
+ });
181
+ return estimatedMonthlySavings
182
+ ? { ...result, estimatedMonthlySavings }
183
+ : result;
184
+ } catch (error) {
185
+ const logger = getLogger(["savemoney", "azure"]);
186
+ logger.warn(
187
+ `Failed to enrich App Service Plan ${resource.name ?? "unknown"} with pricing: ${error instanceof Error ? error.message : error}`,
188
+ );
189
+ return result;
190
+ }
191
+ }
@@ -2,13 +2,17 @@
2
2
  * Azure Managed Disk analysis
3
3
  */
4
4
 
5
- import type { ComputeManagementClient } from "@azure/arm-compute";
5
+ import type { ComputeManagementClient, Disk } from "@azure/arm-compute";
6
6
 
7
7
  import * as armResources from "@azure/arm-resources";
8
8
  import { getLogger } from "@logtape/logtape";
9
9
 
10
+ import type { Money } from "../../finding.js";
10
11
  import type { AnalysisResult } from "../../types.js";
12
+ import type { PricingService } from "../pricing/pricing-service.js";
13
+ import type { DiskSku } from "../pricing/resolvers/index.js";
11
14
 
15
+ import { DiskSkuSchema } from "../pricing/resolvers/index.js";
12
16
  import {
13
17
  verboseLog,
14
18
  verboseLogAnalysisResult,
@@ -21,12 +25,14 @@ import {
21
25
  * @param resource - The Azure resource object
22
26
  * @param computeClient - Azure Compute client for disk details
23
27
  * @param verbose - Whether verbose logging is enabled
28
+ * @param pricing - Optional pricing service for monetary enrichment
24
29
  * @returns Analysis result with cost risk and reason
25
30
  */
26
31
  export async function analyzeDisk(
27
32
  resource: armResources.GenericResource,
28
33
  computeClient: ComputeManagementClient,
29
34
  verbose = false,
35
+ pricing?: PricingService,
30
36
  ): Promise<AnalysisResult> {
31
37
  verboseLogResourceStart(
32
38
  verbose,
@@ -64,13 +70,14 @@ export async function analyzeDisk(
64
70
  diskDetails.diskState?.toLowerCase() === "unattached" ||
65
71
  !diskDetails.managedBy
66
72
  ) {
67
- const result = {
73
+ const result: AnalysisResult = {
68
74
  costRisk,
69
75
  reason: "Disk is unattached. ",
70
76
  suspectedUnused: true,
71
77
  };
72
- verboseLogAnalysisResult(verbose, result);
73
- return result;
78
+ const enriched = await enrichWithPricing(result, diskDetails, pricing);
79
+ verboseLogAnalysisResult(verbose, enriched);
80
+ return enriched;
74
81
  }
75
82
  } catch (error) {
76
83
  const logger = getLogger(["savemoney", "azure"]);
@@ -86,3 +93,38 @@ export async function analyzeDisk(
86
93
  verboseLogAnalysisResult(verbose, result);
87
94
  return result;
88
95
  }
96
+
97
+ /**
98
+ * Best-effort: looks up the monthly price for the disk's `(sku, size,
99
+ * region)` triple and attaches it as `estimatedMonthlySavings`. Any
100
+ * failure (unsupported SKU, missing fields, resolver error) leaves the
101
+ * original result unchanged.
102
+ */
103
+ async function enrichWithPricing(
104
+ result: AnalysisResult,
105
+ diskDetails: Disk,
106
+ pricing: PricingService | undefined,
107
+ ): Promise<AnalysisResult> {
108
+ if (!pricing) return result;
109
+
110
+ const sku = toSupportedSku(diskDetails.sku?.name);
111
+ const diskSizeGiB = diskDetails.diskSizeGB;
112
+ const armRegionName = diskDetails.location;
113
+ if (!sku || !diskSizeGiB || !armRegionName) return result;
114
+
115
+ const money: Money | undefined = await pricing.resolveDisk({
116
+ armRegionName,
117
+ diskSizeGiB,
118
+ sku,
119
+ });
120
+ return money ? { ...result, estimatedMonthlySavings: money } : result;
121
+ }
122
+
123
+ /**
124
+ * Narrows the SDK's `sku.name` string to the resolver-supported subset
125
+ * (excludes `UltraSSD_LRS` and any future tiers we don't price yet).
126
+ */
127
+ function toSupportedSku(name: string | undefined): DiskSku | undefined {
128
+ const parsed = DiskSkuSchema.safeParse(name);
129
+ return parsed.success ? parsed.data : undefined;
130
+ }
@@ -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 {
@@ -26,6 +27,7 @@ import {
26
27
  * @param networkClient - Azure Network client for Public IP details
27
28
  * @param monitorClient - Azure Monitor client for metrics
28
29
  * @param timespanDays - Number of days to analyze metrics
30
+ * @param pricing - Optional pricing facade for estimated monthly cost
29
31
  * @returns Analysis result with cost risk and reason
30
32
  */
31
33
  export async function analyzePublicIp(
@@ -36,6 +38,7 @@ export async function analyzePublicIp(
36
38
  thresholds: Thresholds = DEFAULT_THRESHOLDS,
37
39
  verbose = false,
38
40
  cache?: MetricsCache,
41
+ pricing?: PricingService,
39
42
  ): Promise<AnalysisResult> {
40
43
  verboseLogResourceStart(
41
44
  verbose,
@@ -60,9 +63,15 @@ export async function analyzePublicIp(
60
63
  const resourceGroupName = resourceParts[4];
61
64
  const publicIpName = resourceParts[8];
62
65
 
66
+ // Capture details outside the try so the pricing enrichment below can
67
+ // reuse them without an extra API round-trip.
68
+ let publicIpDetails:
69
+ | Awaited<ReturnType<typeof networkClient.publicIPAddresses.get>>
70
+ | undefined;
71
+
63
72
  try {
64
73
  // Get detailed Public IP information
65
- const publicIpDetails = await networkClient.publicIPAddresses.get(
74
+ publicIpDetails = await networkClient.publicIPAddresses.get(
66
75
  resourceGroupName,
67
76
  publicIpName,
68
77
  );
@@ -104,7 +113,57 @@ export async function analyzePublicIp(
104
113
  }
105
114
 
106
115
  const suspectedUnused = reason.length > 0;
107
- const result = { costRisk, reason: reason.trim(), suspectedUnused };
116
+ const baseResult = { costRisk, reason: reason.trim(), suspectedUnused };
117
+ const result = await enrichWithPricing(
118
+ baseResult,
119
+ resource,
120
+ publicIpDetails,
121
+ pricing,
122
+ );
108
123
  verboseLogAnalysisResult(verbose, result);
109
124
  return result;
110
125
  }
126
+
127
+ /**
128
+ * Best-effort enrichment of a Public IP analysis result with the
129
+ * estimated monthly cost recoverable by removing the resource.
130
+ */
131
+ async function enrichWithPricing(
132
+ result: AnalysisResult,
133
+ resource: armResources.GenericResource,
134
+ publicIpDetails:
135
+ | Awaited<ReturnType<NetworkManagementClient["publicIPAddresses"]["get"]>>
136
+ | undefined,
137
+ pricing?: PricingService,
138
+ ): Promise<AnalysisResult> {
139
+ if (!pricing || !result.suspectedUnused || !publicIpDetails) {
140
+ return result;
141
+ }
142
+ try {
143
+ const skuName = publicIpDetails.sku?.name?.toLowerCase();
144
+ const allocation = publicIpDetails.publicIPAllocationMethod?.toLowerCase();
145
+ const armRegionName = publicIpDetails.location ?? resource.location;
146
+ if (
147
+ !armRegionName ||
148
+ (skuName !== "basic" && skuName !== "standard") ||
149
+ (allocation !== "static" && allocation !== "dynamic")
150
+ ) {
151
+ return result;
152
+ }
153
+ const estimatedMonthlySavings = await pricing.resolvePublicIp({
154
+ allocation,
155
+ armRegionName,
156
+ sku: skuName,
157
+ });
158
+ if (!estimatedMonthlySavings) {
159
+ return result;
160
+ }
161
+ return { ...result, estimatedMonthlySavings };
162
+ } catch (error) {
163
+ const logger = getLogger(["savemoney", "azure"]);
164
+ logger.warn(
165
+ `Failed to enrich Public IP with pricing: ${error instanceof Error ? error.message : error}`,
166
+ );
167
+ return result;
168
+ }
169
+ }