@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.
- package/README.md +12 -0
- package/dist/__tests__/finding.test.js +22 -0
- package/dist/__tests__/finding.test.js.map +1 -1
- package/dist/azure/__tests__/analyzer-tags.test.js +28 -1
- package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -1
- package/dist/azure/__tests__/config.test.js +10 -0
- package/dist/azure/__tests__/config.test.js.map +1 -1
- package/dist/azure/__tests__/report.test.js +71 -0
- package/dist/azure/__tests__/report.test.js.map +1 -1
- package/dist/azure/analyzer.d.ts +8 -1
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +55 -37
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/registry.d.ts.map +1 -1
- package/dist/azure/analyzers/registry.js +4 -4
- package/dist/azure/analyzers/registry.js.map +1 -1
- package/dist/azure/analyzers/types.d.ts +6 -0
- package/dist/azure/analyzers/types.d.ts.map +1 -1
- package/dist/azure/pricing/__tests__/cache.test.d.ts +8 -0
- package/dist/azure/pricing/__tests__/cache.test.d.ts.map +1 -0
- package/dist/azure/pricing/__tests__/cache.test.js +56 -0
- package/dist/azure/pricing/__tests__/cache.test.js.map +1 -0
- package/dist/azure/pricing/__tests__/client.test.d.ts +9 -0
- package/dist/azure/pricing/__tests__/client.test.d.ts.map +1 -0
- package/dist/azure/pricing/__tests__/client.test.js +181 -0
- package/dist/azure/pricing/__tests__/client.test.js.map +1 -0
- package/dist/azure/pricing/__tests__/pricing-service.test.d.ts +6 -0
- package/dist/azure/pricing/__tests__/pricing-service.test.d.ts.map +1 -0
- package/dist/azure/pricing/__tests__/pricing-service.test.js +283 -0
- package/dist/azure/pricing/__tests__/pricing-service.test.js.map +1 -0
- package/dist/azure/pricing/cache.d.ts +45 -0
- package/dist/azure/pricing/cache.d.ts.map +1 -0
- package/dist/azure/pricing/cache.js +79 -0
- package/dist/azure/pricing/cache.js.map +1 -0
- package/dist/azure/pricing/client.d.ts +67 -0
- package/dist/azure/pricing/client.d.ts.map +1 -0
- package/dist/azure/pricing/client.js +96 -0
- package/dist/azure/pricing/client.js.map +1 -0
- package/dist/azure/pricing/index.d.ts +6 -0
- package/dist/azure/pricing/index.d.ts.map +1 -0
- package/dist/azure/pricing/index.js +6 -0
- package/dist/azure/pricing/index.js.map +1 -0
- package/dist/azure/pricing/pricing-service.d.ts +46 -0
- package/dist/azure/pricing/pricing-service.d.ts.map +1 -0
- package/dist/azure/pricing/pricing-service.js +84 -0
- package/dist/azure/pricing/pricing-service.js.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts +5 -0
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.js +137 -0
- package/dist/azure/pricing/resolvers/__tests__/app-service.test.js.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts +5 -0
- package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/disk.test.js +160 -0
- package/dist/azure/pricing/resolvers/__tests__/disk.test.js.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts +5 -0
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js +113 -0
- package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts +9 -0
- package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/__tests__/vm.test.js +204 -0
- package/dist/azure/pricing/resolvers/__tests__/vm.test.js.map +1 -0
- package/dist/azure/pricing/resolvers/app-service.d.ts +27 -0
- package/dist/azure/pricing/resolvers/app-service.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/app-service.js +67 -0
- package/dist/azure/pricing/resolvers/app-service.js.map +1 -0
- package/dist/azure/pricing/resolvers/disk.d.ts +59 -0
- package/dist/azure/pricing/resolvers/disk.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/disk.js +140 -0
- package/dist/azure/pricing/resolvers/disk.js.map +1 -0
- package/dist/azure/pricing/resolvers/index.d.ts +12 -0
- package/dist/azure/pricing/resolvers/index.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/index.js +8 -0
- package/dist/azure/pricing/resolvers/index.js.map +1 -0
- package/dist/azure/pricing/resolvers/public-ip.d.ts +32 -0
- package/dist/azure/pricing/resolvers/public-ip.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/public-ip.js +56 -0
- package/dist/azure/pricing/resolvers/public-ip.js.map +1 -0
- package/dist/azure/pricing/resolvers/vm.d.ts +51 -0
- package/dist/azure/pricing/resolvers/vm.d.ts.map +1 -0
- package/dist/azure/pricing/resolvers/vm.js +89 -0
- package/dist/azure/pricing/resolvers/vm.js.map +1 -0
- package/dist/azure/pricing/schema.d.ts +88 -0
- package/dist/azure/pricing/schema.d.ts.map +1 -0
- package/dist/azure/pricing/schema.js +64 -0
- package/dist/azure/pricing/schema.js.map +1 -0
- package/dist/azure/pricing/test-cache-dir.d.ts +3 -0
- package/dist/azure/pricing/test-cache-dir.d.ts.map +1 -0
- package/dist/azure/pricing/test-cache-dir.js +17 -0
- package/dist/azure/pricing/test-cache-dir.js.map +1 -0
- package/dist/azure/report.d.ts.map +1 -1
- package/dist/azure/report.js +57 -18
- package/dist/azure/report.js.map +1 -1
- package/dist/azure/resources/__tests__/app-service.test.d.ts +5 -0
- package/dist/azure/resources/__tests__/app-service.test.d.ts.map +1 -0
- package/dist/azure/resources/__tests__/app-service.test.js +105 -0
- package/dist/azure/resources/__tests__/app-service.test.js.map +1 -0
- package/dist/azure/resources/app-service.d.ts +8 -3
- package/dist/azure/resources/app-service.d.ts.map +1 -1
- package/dist/azure/resources/app-service.js +35 -2
- package/dist/azure/resources/app-service.js.map +1 -1
- package/dist/azure/resources/disk.d.ts +3 -1
- package/dist/azure/resources/disk.d.ts.map +1 -1
- package/dist/azure/resources/disk.js +35 -3
- package/dist/azure/resources/disk.js.map +1 -1
- package/dist/azure/resources/public-ip.d.ts +3 -1
- package/dist/azure/resources/public-ip.d.ts.map +1 -1
- package/dist/azure/resources/public-ip.js +41 -3
- package/dist/azure/resources/public-ip.js.map +1 -1
- package/dist/azure/resources/vm.d.ts +4 -1
- package/dist/azure/resources/vm.d.ts.map +1 -1
- package/dist/azure/resources/vm.js +49 -6
- package/dist/azure/resources/vm.js.map +1 -1
- package/dist/azure/types.d.ts +13 -0
- package/dist/azure/types.d.ts.map +1 -1
- package/dist/finding.d.ts +3 -3
- package/dist/finding.d.ts.map +1 -1
- package/dist/finding.js +7 -0
- package/dist/finding.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +9 -9
- package/src/__tests__/finding.test.ts +24 -0
- package/src/azure/__tests__/analyzer-tags.test.ts +36 -1
- package/src/azure/__tests__/config.test.ts +12 -0
- package/src/azure/__tests__/report.test.ts +78 -0
- package/src/azure/analyzer.ts +67 -43
- package/src/azure/analyzers/registry.ts +8 -2
- package/src/azure/analyzers/types.ts +6 -0
- package/src/azure/pricing/__tests__/cache.test.ts +67 -0
- package/src/azure/pricing/__tests__/client.test.ts +212 -0
- package/src/azure/pricing/__tests__/fixtures/vm-b2s-westeurope.json +51 -0
- package/src/azure/pricing/__tests__/pricing-service.test.ts +315 -0
- package/src/azure/pricing/cache.ts +92 -0
- package/src/azure/pricing/client.ts +137 -0
- package/src/azure/pricing/index.ts +6 -0
- package/src/azure/pricing/pricing-service.ts +125 -0
- package/src/azure/pricing/resolvers/__tests__/app-service.test.ts +154 -0
- package/src/azure/pricing/resolvers/__tests__/disk.test.ts +189 -0
- package/src/azure/pricing/resolvers/__tests__/public-ip.test.ts +130 -0
- package/src/azure/pricing/resolvers/__tests__/vm.test.ts +232 -0
- package/src/azure/pricing/resolvers/app-service.ts +102 -0
- package/src/azure/pricing/resolvers/disk.ts +170 -0
- package/src/azure/pricing/resolvers/index.ts +31 -0
- package/src/azure/pricing/resolvers/public-ip.ts +87 -0
- package/src/azure/pricing/resolvers/vm.ts +125 -0
- package/src/azure/pricing/schema.ts +69 -0
- package/src/azure/pricing/test-cache-dir.ts +26 -0
- package/src/azure/report.ts +86 -21
- package/src/azure/resources/__tests__/app-service.test.ts +165 -0
- package/src/azure/resources/app-service.ts +65 -4
- package/src/azure/resources/disk.ts +46 -4
- package/src/azure/resources/public-ip.ts +61 -2
- package/src/azure/resources/vm.ts +89 -11
- package/src/azure/types.ts +14 -0
- package/src/finding.ts +10 -3
- package/src/index.ts +3 -3
- package/src/types.ts +12 -0
package/src/azure/report.ts
CHANGED
|
@@ -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
|
|
166
|
-
* label that ships next to each lint line.
|
|
167
|
-
* the
|
|
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
|
|
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
|
|
179
|
-
* monthly
|
|
180
|
-
* fall back to splitting `analysis.reason`
|
|
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
|
|
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
|
|
196
|
-
sourceCounts: {}
|
|
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
|
-
|
|
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:
|
|
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 } =
|
|
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:
|
|
34
|
-
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 (
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|