@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/analyzer.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
createDefaultSubscriptionAnalyzers,
|
|
55
55
|
type SubscriptionAnalyzer,
|
|
56
56
|
} from "./analyzers/index.js";
|
|
57
|
+
import { PricingClient, PricingService } from "./pricing/index.js";
|
|
57
58
|
import { matchesTags, type MetricsCache } from "./utils.js";
|
|
58
59
|
|
|
59
60
|
const DEFAULT_CONCURRENCY = 8;
|
|
@@ -95,6 +96,10 @@ export async function analyzeAzureResources(
|
|
|
95
96
|
: [];
|
|
96
97
|
const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
|
|
97
98
|
|
|
99
|
+
// Build a single PricingService for the whole run when enabled, so the
|
|
100
|
+
// disk cache and in-memory memo are shared across subscriptions.
|
|
101
|
+
const pricing = buildPricingService(config);
|
|
102
|
+
|
|
98
103
|
// Normalise concurrency the same way p-limit does to keep maxInFlight
|
|
99
104
|
// consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
|
|
100
105
|
// either deadlock or silently disable backpressure.
|
|
@@ -140,6 +145,7 @@ export async function analyzeAzureResources(
|
|
|
140
145
|
limit,
|
|
141
146
|
logger,
|
|
142
147
|
maxInFlight,
|
|
148
|
+
pricing,
|
|
143
149
|
reports: allReports,
|
|
144
150
|
reportsById,
|
|
145
151
|
runCache,
|
|
@@ -214,6 +220,7 @@ export async function analyzeResource(
|
|
|
214
220
|
timespanDays: number,
|
|
215
221
|
thresholds: Thresholds,
|
|
216
222
|
verbose = false,
|
|
223
|
+
pricing?: PricingService,
|
|
217
224
|
): Promise<AnalysisResult> {
|
|
218
225
|
let result: AnalysisResult = {
|
|
219
226
|
costRisk: "low",
|
|
@@ -231,6 +238,7 @@ export async function analyzeResource(
|
|
|
231
238
|
clients,
|
|
232
239
|
metricsCache,
|
|
233
240
|
preferredLocation,
|
|
241
|
+
pricing,
|
|
234
242
|
resource,
|
|
235
243
|
thresholds,
|
|
236
244
|
timespanDays,
|
|
@@ -262,6 +270,50 @@ export async function analyzeResource(
|
|
|
262
270
|
return { ...result, reason: result.reason.trim() };
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Inserts a `Finding` into the right report entry, creating a stub
|
|
275
|
+
* resource entry on the fly when the finding refers to a resource that
|
|
276
|
+
* the per-resource pass did not analyze.
|
|
277
|
+
*/
|
|
278
|
+
export function mergeFinding(
|
|
279
|
+
finding: Finding,
|
|
280
|
+
reports: AzureDetailedResourceReport[],
|
|
281
|
+
reportsById: Map<string, AzureDetailedResourceReport>,
|
|
282
|
+
): void {
|
|
283
|
+
const idKey = finding.resourceId.toLowerCase();
|
|
284
|
+
const existing = reportsById.get(idKey);
|
|
285
|
+
if (existing) {
|
|
286
|
+
existing.findings = [...(existing.findings ?? []), finding];
|
|
287
|
+
const added = analysisFromFinding(finding);
|
|
288
|
+
// Use max costRisk (not last-wins) and join reasons with a space so we
|
|
289
|
+
// don't produce "Sentence one.Sentence two." when the existing reason is
|
|
290
|
+
// already trimmed (i.e. has no trailing separator space).
|
|
291
|
+
existing.analysis = {
|
|
292
|
+
costRisk:
|
|
293
|
+
RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
|
|
294
|
+
? existing.analysis.costRisk
|
|
295
|
+
: added.costRisk,
|
|
296
|
+
estimatedMonthlySavings: existing.analysis.estimatedMonthlySavings,
|
|
297
|
+
reason:
|
|
298
|
+
existing.analysis.reason && added.reason
|
|
299
|
+
? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
|
|
300
|
+
: existing.analysis.reason || added.reason,
|
|
301
|
+
suspectedUnused:
|
|
302
|
+
existing.analysis.suspectedUnused || added.suspectedUnused,
|
|
303
|
+
};
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const stub = buildResourceStub(finding.resourceId);
|
|
308
|
+
const report: AzureDetailedResourceReport = {
|
|
309
|
+
analysis: analysisFromFinding(finding),
|
|
310
|
+
findings: [finding],
|
|
311
|
+
resource: stub,
|
|
312
|
+
};
|
|
313
|
+
reports.push(report);
|
|
314
|
+
reportsById.set(idKey, report);
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
export function shouldIncludeAdvisorFindingForTags(
|
|
266
318
|
finding: Finding,
|
|
267
319
|
taggedResourceIds: ReadonlySet<string>,
|
|
@@ -295,6 +347,18 @@ function analysisFromFinding(finding: Finding): AnalysisResult {
|
|
|
295
347
|
};
|
|
296
348
|
}
|
|
297
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Builds the shared `PricingService` for the run when pricing enrichment
|
|
352
|
+
* is enabled. Returns `undefined` when the CLI disables pricing via
|
|
353
|
+
* `--no-pricing`; otherwise the client/cache use internal defaults.
|
|
354
|
+
*/
|
|
355
|
+
function buildPricingService(config: AzureConfig): PricingService | undefined {
|
|
356
|
+
if (config.pricing?.enabled === false) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return new PricingService(new PricingClient());
|
|
360
|
+
}
|
|
361
|
+
|
|
298
362
|
/**
|
|
299
363
|
* Builds a minimal `GenericResource` from a resource ID. Used when a
|
|
300
364
|
* subscription-level analyzer surfaces a resource the per-resource pass
|
|
@@ -383,49 +447,6 @@ function isSubscriptionScopedReport(r: AzureDetailedResourceReport): boolean {
|
|
|
383
447
|
return r.resource.type === "Microsoft.Subscription";
|
|
384
448
|
}
|
|
385
449
|
|
|
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
450
|
function normalizeResourceId(resourceId: string | undefined): string {
|
|
430
451
|
return (resourceId ?? "").trim().toLowerCase();
|
|
431
452
|
}
|
|
@@ -443,6 +464,7 @@ async function runPerResourceAnalysis(args: {
|
|
|
443
464
|
limit: ReturnType<typeof pLimit>;
|
|
444
465
|
logger: ReturnType<typeof getLogger>;
|
|
445
466
|
maxInFlight: number;
|
|
467
|
+
pricing?: PricingService;
|
|
446
468
|
reports: AzureDetailedResourceReport[];
|
|
447
469
|
reportsById: Map<string, AzureDetailedResourceReport>;
|
|
448
470
|
runCache: MetricsCache;
|
|
@@ -458,6 +480,7 @@ async function runPerResourceAnalysis(args: {
|
|
|
458
480
|
limit,
|
|
459
481
|
logger,
|
|
460
482
|
maxInFlight,
|
|
483
|
+
pricing,
|
|
461
484
|
reports,
|
|
462
485
|
reportsById,
|
|
463
486
|
runCache,
|
|
@@ -500,6 +523,7 @@ async function runPerResourceAnalysis(args: {
|
|
|
500
523
|
config.timespanDays,
|
|
501
524
|
thresholds,
|
|
502
525
|
config.verbose || false,
|
|
526
|
+
pricing,
|
|
503
527
|
);
|
|
504
528
|
|
|
505
529
|
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;
|
|
@@ -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
|
+
}
|