@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
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Resolver: estimated monthly cost of an Azure App Service Plan.
3
+ *
4
+ * App Service Plan meters are published as hourly prices per plan instance.
5
+ * The resolver looks up all App Service meters in the target region, then
6
+ * matches the ARM plan SKU locally because Retail Prices uses inconsistent
7
+ * spacing for some families (for example `P1v3` vs `P1 v3`).
8
+ */
9
+
10
+ import type { Money } from "../../../finding.js";
11
+ import type { PricingClient } from "../client.js";
12
+ import type { PriceItem } from "../schema.js";
13
+
14
+ import { HOURS_PER_MONTH, normalizeArmRegion } from "./vm.js";
15
+
16
+ export type AppServicePlanOs = "linux" | "windows";
17
+
18
+ export type ResolveAppServicePlanInput = {
19
+ /** ARM region name, e.g. `westeurope`. */
20
+ armRegionName: string;
21
+ /** Optional OS hint. When absent, the cheapest matching meter is used. */
22
+ os?: AppServicePlanOs;
23
+ /** App Service Plan SKU name, e.g. `S1`, `P1v3`, `P1mv3`. */
24
+ skuName: string;
25
+ /** Number of plan workers/instances to price. Defaults to 1. */
26
+ workerCount?: number;
27
+ };
28
+
29
+ /**
30
+ * Resolves the estimated monthly cost for an App Service Plan. Returns
31
+ * `undefined` if no matching consumption meter can be found.
32
+ */
33
+ export async function resolveAppServicePlanMonthlyPrice(
34
+ client: PricingClient,
35
+ input: ResolveAppServicePlanInput,
36
+ ): Promise<Money | undefined> {
37
+ const filter = [
38
+ "serviceName eq 'Azure App Service'",
39
+ `armRegionName eq '${normalizeArmRegion(input.armRegionName)}'`,
40
+ ].join(" and ");
41
+
42
+ const items = await client.query(filter);
43
+ const item = pickAppServicePlanItem(items, input);
44
+ if (!item) {
45
+ return undefined;
46
+ }
47
+
48
+ return {
49
+ amount: round2(
50
+ item.unitPrice *
51
+ HOURS_PER_MONTH *
52
+ normalizeWorkerCount(input.workerCount),
53
+ ),
54
+ currency: item.currencyCode,
55
+ };
56
+ }
57
+
58
+ function matchesOs(item: PriceItem, os: AppServicePlanOs | undefined): boolean {
59
+ if (!os) return true;
60
+ // Retail meters encode the OS variant in the product name, not in a
61
+ // dedicated field.
62
+ const product = item.productName?.toLowerCase() ?? "";
63
+ const isLinuxItem = product.includes("linux");
64
+ return os === "linux" ? isLinuxItem : !isLinuxItem;
65
+ }
66
+
67
+ function normalizeSkuName(value: string | undefined): string {
68
+ // ARM uses compact SKU names (`P1v3`), while Retail Prices sometimes
69
+ // inserts spaces (`P1 v3`).
70
+ return value?.toLowerCase().replace(/\s+/g, "") ?? "";
71
+ }
72
+
73
+ function normalizeWorkerCount(workerCount: number | undefined): number {
74
+ // App Service Plan billing is per whole worker instance; invalid or missing
75
+ // values fall back to the minimum billable instance.
76
+ if (!workerCount || workerCount < 1) return 1;
77
+ return Math.ceil(workerCount);
78
+ }
79
+
80
+ function pickAppServicePlanItem(
81
+ items: PriceItem[],
82
+ input: ResolveAppServicePlanInput,
83
+ ): PriceItem | undefined {
84
+ const skuName = normalizeSkuName(input.skuName);
85
+ const candidates = items.filter(
86
+ (it) =>
87
+ it.type === "Consumption" &&
88
+ it.unitOfMeasure === "1 Hour" &&
89
+ normalizeSkuName(it.skuName) === skuName &&
90
+ (it.productName?.toLowerCase().includes("app service") ?? false) &&
91
+ (it.productName?.toLowerCase().includes("plan") ?? false) &&
92
+ matchesOs(it, input.os),
93
+ );
94
+ if (candidates.length === 0) return undefined;
95
+ return candidates.reduce((cheapest, it) =>
96
+ it.unitPrice < cheapest.unitPrice ? it : cheapest,
97
+ );
98
+ }
99
+
100
+ function round2(value: number): number {
101
+ return Math.round(value * 100) / 100;
102
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Resolver: estimated monthly cost of an Azure Managed Disk.
3
+ *
4
+ * Azure prices managed disks per discrete capacity tier (P1…P80,
5
+ * E1…E80, S4…S80) rather than per actual `diskSizeGB`. The resolver:
6
+ *
7
+ * 1. Maps the disk SKU (`Premium_LRS`, `StandardSSD_ZRS`, …) and
8
+ * `diskSizeGB` to the corresponding billing tier (e.g. `P10 LRS`).
9
+ * 2. Queries the Azure Retail Prices API with the tier-derived
10
+ * `skuName` for the right service / region.
11
+ * 3. Returns the consumption price (already `1/Month` in the API) as
12
+ * `Money`, or `undefined` if no match (e.g. Ultra disks, which use
13
+ * a different per-GB / IOPS / throughput pricing model and are not
14
+ * yet supported).
15
+ *
16
+ * The mapping is intentionally encoded in a single Zod-validated
17
+ * lookup table so a malformed input fails fast and loud rather than
18
+ * silently producing a wrong tier.
19
+ */
20
+
21
+ import { z } from "zod";
22
+
23
+ import type { Money } from "../../../finding.js";
24
+ import type { PricingClient } from "../client.js";
25
+ import type { PriceItem } from "../schema.js";
26
+
27
+ import { normalizeArmRegion } from "./vm.js";
28
+
29
+ /**
30
+ * Replication mode supported by the resolver. Ultra disks (`UltraSSD_LRS`)
31
+ * are intentionally excluded because their pricing is multi-dimensional
32
+ * (capacity + IOPS + throughput) and would need a dedicated resolver.
33
+ */
34
+ export const DiskSkuSchema = z.enum([
35
+ "Premium_LRS",
36
+ "Premium_ZRS",
37
+ "StandardSSD_LRS",
38
+ "StandardSSD_ZRS",
39
+ "Standard_LRS",
40
+ ]);
41
+ export type DiskSku = z.infer<typeof DiskSkuSchema>;
42
+
43
+ /**
44
+ * Inclusive upper bound (GiB) → tier name. Order matters: the resolver
45
+ * picks the FIRST entry whose `maxSizeGiB` is greater-or-equal to the
46
+ * requested size.
47
+ *
48
+ * See https://learn.microsoft.com/azure/virtual-machines/disks-types for
49
+ * the canonical tier table.
50
+ */
51
+ const TIER_TABLE: readonly { maxSizeGiB: number; tier: number }[] = [
52
+ { maxSizeGiB: 4, tier: 1 },
53
+ { maxSizeGiB: 8, tier: 2 },
54
+ { maxSizeGiB: 16, tier: 3 },
55
+ { maxSizeGiB: 32, tier: 4 },
56
+ { maxSizeGiB: 64, tier: 6 },
57
+ { maxSizeGiB: 128, tier: 10 },
58
+ { maxSizeGiB: 256, tier: 15 },
59
+ { maxSizeGiB: 512, tier: 20 },
60
+ { maxSizeGiB: 1024, tier: 30 },
61
+ { maxSizeGiB: 2048, tier: 40 },
62
+ { maxSizeGiB: 4096, tier: 50 },
63
+ { maxSizeGiB: 8192, tier: 60 },
64
+ { maxSizeGiB: 16384, tier: 70 },
65
+ { maxSizeGiB: 32768, tier: 80 },
66
+ ];
67
+
68
+ const SKU_METADATA = {
69
+ Premium_LRS: {
70
+ prefix: "P",
71
+ productName: "Premium SSD Managed Disks",
72
+ replication: "LRS",
73
+ },
74
+ Premium_ZRS: {
75
+ prefix: "P",
76
+ productName: "Premium SSD Managed Disks",
77
+ replication: "ZRS",
78
+ },
79
+ Standard_LRS: {
80
+ prefix: "S",
81
+ productName: "Standard HDD Managed Disks",
82
+ replication: "LRS",
83
+ },
84
+ StandardSSD_LRS: {
85
+ prefix: "E",
86
+ productName: "Standard SSD Managed Disks",
87
+ replication: "LRS",
88
+ },
89
+ StandardSSD_ZRS: {
90
+ prefix: "E",
91
+ productName: "Standard SSD Managed Disks",
92
+ replication: "ZRS",
93
+ },
94
+ } as const satisfies Record<
95
+ DiskSku,
96
+ { prefix: string; productName: string; replication: string }
97
+ >;
98
+
99
+ export const ResolveDiskInputSchema = z.object({
100
+ /** ARM region name, e.g. `westeurope`. */
101
+ armRegionName: z.string().min(1),
102
+ /** Disk capacity in GiB as reported by the Compute API (`diskSizeGB`). */
103
+ diskSizeGiB: z.number().int().positive(),
104
+ /** Managed disk SKU. */
105
+ sku: DiskSkuSchema,
106
+ });
107
+ export type ResolveDiskInput = z.infer<typeof ResolveDiskInputSchema>;
108
+
109
+ /**
110
+ * Pure helper exposed for testing: resolves the size→tier mapping
111
+ * without touching the network.
112
+ */
113
+ export function pickTier(diskSizeGiB: number): number | undefined {
114
+ return TIER_TABLE.find((row) => diskSizeGiB <= row.maxSizeGiB)?.tier;
115
+ }
116
+
117
+ /**
118
+ * Resolves the estimated monthly cost for a Managed Disk. Returns
119
+ * `undefined` if the SKU/size combo is unsupported (e.g. exceeds the
120
+ * largest tier) or if no matching Retail Prices entry is found.
121
+ */
122
+ export async function resolveDiskMonthlyPrice(
123
+ client: PricingClient,
124
+ rawInput: ResolveDiskInput,
125
+ ): Promise<Money | undefined> {
126
+ const input = ResolveDiskInputSchema.parse(rawInput);
127
+ const tier = pickTier(input.diskSizeGiB);
128
+ if (tier === undefined) return undefined;
129
+
130
+ const { prefix, productName, replication } = SKU_METADATA[input.sku];
131
+ const skuName = `${prefix}${tier} ${replication}`;
132
+
133
+ const filter = [
134
+ "serviceName eq 'Storage'",
135
+ `armRegionName eq '${normalizeArmRegion(input.armRegionName)}'`,
136
+ `productName eq '${productName}'`,
137
+ `skuName eq '${skuName}'`,
138
+ ].join(" and ");
139
+
140
+ const items = await client.query(filter);
141
+ const item = pickDiskItem(items, skuName);
142
+ if (!item) return undefined;
143
+
144
+ return {
145
+ amount: round2(item.unitPrice),
146
+ currency: item.currencyCode,
147
+ };
148
+ }
149
+
150
+ function pickDiskItem(
151
+ items: PriceItem[],
152
+ skuName: string,
153
+ ): PriceItem | undefined {
154
+ // Managed Disk meters are reported as `1/Month`; we keep the filter
155
+ // explicit to avoid accidentally picking a per-hour or per-IOPS entry.
156
+ const candidates = items.filter(
157
+ (it) =>
158
+ it.type === "Consumption" &&
159
+ it.unitOfMeasure === "1/Month" &&
160
+ it.skuName === skuName,
161
+ );
162
+ if (candidates.length === 0) return undefined;
163
+ return candidates.reduce((cheapest, it) =>
164
+ it.unitPrice < cheapest.unitPrice ? it : cheapest,
165
+ );
166
+ }
167
+
168
+ function round2(value: number): number {
169
+ return Math.round(value * 100) / 100;
170
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Public exports for resolvers.
3
+ */
4
+
5
+ export type {
6
+ AppServicePlanOs,
7
+ ResolveAppServicePlanInput,
8
+ } from "./app-service.js";
9
+ export { resolveAppServicePlanMonthlyPrice } from "./app-service.js";
10
+
11
+ export type { DiskSku, ResolveDiskInput } from "./disk.js";
12
+ export {
13
+ DiskSkuSchema,
14
+ pickTier,
15
+ ResolveDiskInputSchema,
16
+ resolveDiskMonthlyPrice,
17
+ } from "./disk.js";
18
+
19
+ export type {
20
+ PublicIpAllocation,
21
+ PublicIpSku,
22
+ ResolvePublicIpInput,
23
+ } from "./public-ip.js";
24
+ export { resolvePublicIpMonthlyPrice } from "./public-ip.js";
25
+
26
+ export type { ResolveVmInput, VmOs } from "./vm.js";
27
+ export {
28
+ HOURS_PER_MONTH,
29
+ normalizeArmRegion,
30
+ resolveVmMonthlyPrice,
31
+ } from "./vm.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Resolver: estimated monthly cost of an Azure Public IP address.
3
+ *
4
+ * Uses the Azure Retail Prices API to look up the pay-as-you-go price for
5
+ * the given SKU + region + allocation method combination, then converts
6
+ * the hourly rate (when reported as `1 Hour`) to a monthly figure using
7
+ * the conventional 730-hours-per-month constant.
8
+ *
9
+ * Public IP pricing is published per `(sku, allocation)` pair. Both
10
+ * Standard SKU and a Static Basic SKU carry a billable hourly rate; a
11
+ * Dynamic Basic SKU is typically free when associated, and the lookup
12
+ * returns `undefined` in that case.
13
+ */
14
+
15
+ import type { Money } from "../../../finding.js";
16
+ import type { PricingClient } from "../client.js";
17
+ import type { PriceItem } from "../schema.js";
18
+
19
+ import { HOURS_PER_MONTH, normalizeArmRegion } from "./vm.js";
20
+
21
+ export type PublicIpAllocation = "dynamic" | "static";
22
+ export type PublicIpSku = "basic" | "standard";
23
+
24
+ export type ResolvePublicIpInput = {
25
+ /** Allocation method (`Static` / `Dynamic`). */
26
+ allocation: PublicIpAllocation;
27
+ /** ARM region name, e.g. `westeurope`. */
28
+ armRegionName: string;
29
+ /** SKU (`Basic` / `Standard`). */
30
+ sku: PublicIpSku;
31
+ };
32
+
33
+ /**
34
+ * Resolves the estimated monthly cost for a Public IP. Returns
35
+ * `undefined` when no matching consumption meter is found (e.g. the
36
+ * combination is genuinely free).
37
+ */
38
+ export async function resolvePublicIpMonthlyPrice(
39
+ client: PricingClient,
40
+ input: ResolvePublicIpInput,
41
+ ): Promise<Money | undefined> {
42
+ // Public IPs are billed under the "Virtual Network" service.
43
+ const filter = [
44
+ "serviceName eq 'Virtual Network'",
45
+ `armRegionName eq '${normalizeArmRegion(input.armRegionName)}'`,
46
+ `skuName eq '${capitalize(input.sku)}'`,
47
+ ].join(" and ");
48
+
49
+ const items = await client.query(filter);
50
+ const item = pickPublicIpItem(items, input.allocation);
51
+ if (!item) {
52
+ return undefined;
53
+ }
54
+
55
+ return {
56
+ amount: round2(item.unitPrice * HOURS_PER_MONTH),
57
+ currency: item.currencyCode,
58
+ };
59
+ }
60
+
61
+ function capitalize(value: string): string {
62
+ if (value.length === 0) return value;
63
+ return value[0].toUpperCase() + value.slice(1).toLowerCase();
64
+ }
65
+
66
+ function pickPublicIpItem(
67
+ items: PriceItem[],
68
+ allocation: PublicIpAllocation,
69
+ ): PriceItem | undefined {
70
+ const allocationToken = allocation === "static" ? "static" : "dynamic";
71
+ const candidates = items.filter(
72
+ (it) =>
73
+ it.type === "Consumption" &&
74
+ it.unitOfMeasure === "1 Hour" &&
75
+ (it.meterName?.toLowerCase().includes("ip address") ?? false) &&
76
+ (it.meterName?.toLowerCase().includes(allocationToken) ?? false),
77
+ );
78
+ if (candidates.length === 0) return undefined;
79
+ // Pick the cheapest entry as a conservative lower bound.
80
+ return candidates.reduce((cheapest, it) =>
81
+ it.unitPrice < cheapest.unitPrice ? it : cheapest,
82
+ );
83
+ }
84
+
85
+ function round2(value: number): number {
86
+ return Math.round(value * 100) / 100;
87
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Resolver: estimated monthly cost of an Azure Virtual Machine SKU.
3
+ *
4
+ * Uses the Azure Retail Prices API to look up the pay-as-you-go consumption
5
+ * price for the given `armSkuName` in the given region, then converts the
6
+ * hourly rate to a monthly figure assuming the conventional `730 hours`
7
+ * (365.25 * 24 / 12) for "always-on" workloads.
8
+ *
9
+ * The lookup is intentionally conservative:
10
+ * - It restricts results to `type === "Consumption"` (no Reservations,
11
+ * no Spot/Low Priority, no Savings Plans).
12
+ * - It defaults to the Linux variant (cheapest) when the resource OS is
13
+ * unknown, so the returned figure is a LOWER BOUND of the real cost.
14
+ * - It returns `undefined` whenever it cannot identify a single
15
+ * unambiguous price item, so callers can treat the savings estimate
16
+ * as best-effort.
17
+ */
18
+
19
+ import type { Money } from "../../../finding.js";
20
+ import type { PricingClient } from "../client.js";
21
+ import type { PriceItem } from "../schema.js";
22
+
23
+ /**
24
+ * Conventional hours-per-month used across Azure cost calculators
25
+ * (365.25 days × 24 h / 12 months ≈ 730).
26
+ */
27
+ export const HOURS_PER_MONTH = 730;
28
+
29
+ export type ResolveVmInput = {
30
+ /** ARM region name, e.g. `westeurope`. */
31
+ armRegionName: string;
32
+ /** ARM SKU name, e.g. `Standard_B2s`. */
33
+ armSkuName: string;
34
+ /** Optional OS hint. Defaults to `linux`. */
35
+ os?: VmOs;
36
+ };
37
+
38
+ /**
39
+ * Operating system hint used to disambiguate Windows vs. Linux meters.
40
+ * When unset (default) the cheapest Linux variant is picked.
41
+ */
42
+ export type VmOs = "linux" | "windows";
43
+
44
+ /**
45
+ * Normalises a region string to the `armRegionName` format expected by
46
+ * the Retail Prices API (lowercase, no spaces). The Azure SDK sometimes
47
+ * returns the display name (`"West Europe"`) instead of the ARM name
48
+ * (`"westeurope"`); without this normalisation the OData filter would
49
+ * silently miss every entry.
50
+ */
51
+ export function normalizeArmRegion(region: string): string {
52
+ return region.toLowerCase().replace(/\s+/g, "");
53
+ }
54
+
55
+ /**
56
+ * Resolves the estimated monthly cost for a VM SKU. Returns `undefined`
57
+ * if no matching consumption meter can be found.
58
+ */
59
+ export async function resolveVmMonthlyPrice(
60
+ client: PricingClient,
61
+ input: ResolveVmInput,
62
+ ): Promise<Money | undefined> {
63
+ const filter = [
64
+ "serviceName eq 'Virtual Machines'",
65
+ `armRegionName eq '${normalizeArmRegion(input.armRegionName)}'`,
66
+ `armSkuName eq '${input.armSkuName}'`,
67
+ ].join(" and ");
68
+
69
+ const items = await client.query(filter);
70
+ const item = pickVmItem(items, input.os ?? "linux");
71
+ if (!item) {
72
+ return undefined;
73
+ }
74
+
75
+ return {
76
+ amount: round2(item.unitPrice * HOURS_PER_MONTH),
77
+ currency: item.currencyCode,
78
+ };
79
+ }
80
+
81
+ function isSpotOrLowPriority(item: PriceItem): boolean {
82
+ const sku = item.skuName?.toLowerCase() ?? "";
83
+ const meter = item.meterName?.toLowerCase() ?? "";
84
+ return (
85
+ sku.includes("spot") ||
86
+ sku.includes("low priority") ||
87
+ meter.includes("spot") ||
88
+ meter.includes("low priority")
89
+ );
90
+ }
91
+
92
+ function matchesOs(item: PriceItem, os: VmOs): boolean {
93
+ const product = item.productName?.toLowerCase() ?? "";
94
+ const isWindowsItem = product.includes("windows");
95
+ return os === "windows" ? isWindowsItem : !isWindowsItem;
96
+ }
97
+
98
+ /**
99
+ * Picks the most appropriate Retail Prices entry for the given OS hint.
100
+ *
101
+ * Filters out:
102
+ * - non-Consumption entries (Reservations, Savings Plans, …)
103
+ * - Spot / Low Priority entries (variable pricing)
104
+ * - Windows entries when the hint is `linux` and vice versa
105
+ *
106
+ * Then prefers the entry with the lowest `unitPrice` to act as a
107
+ * conservative lower bound.
108
+ */
109
+ function pickVmItem(items: PriceItem[], os: VmOs): PriceItem | undefined {
110
+ const candidates = items.filter(
111
+ (it) =>
112
+ it.type === "Consumption" &&
113
+ it.unitOfMeasure === "1 Hour" &&
114
+ !isSpotOrLowPriority(it) &&
115
+ matchesOs(it, os),
116
+ );
117
+ if (candidates.length === 0) return undefined;
118
+ return candidates.reduce((cheapest, it) =>
119
+ it.unitPrice < cheapest.unitPrice ? it : cheapest,
120
+ );
121
+ }
122
+
123
+ function round2(value: number): number {
124
+ return Math.round(value * 100) / 100;
125
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Zod schemas for the Azure Retail Prices REST API.
3
+ *
4
+ * Endpoint: https://prices.azure.com/api/retail/prices
5
+ * Docs: https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices
6
+ *
7
+ * The API returns a flat list of `Items` (one per meter) plus an optional
8
+ * `NextPageLink` for pagination. Extra fields returned by Azure are tolerated,
9
+ * but the fields used by pricing logic are validated with narrow shapes.
10
+ */
11
+
12
+ import { z } from "zod";
13
+
14
+ const NonEmptyStringSchema = z.string().min(1);
15
+ const PriceTypeSchema = z.enum([
16
+ "Consumption",
17
+ "DevTestConsumption",
18
+ "Reservation",
19
+ ]);
20
+ const PriceValueSchema = z.number().finite().nonnegative();
21
+
22
+ /**
23
+ * A single priced meter returned by the Retail Prices API.
24
+ *
25
+ * Notable fields:
26
+ * - `unitPrice`: list price per `unitOfMeasure` (e.g. "1 Hour", "1 GB/Month")
27
+ * - `retailPrice`: typically equals `unitPrice` but kept distinct by Azure
28
+ * - `armSkuName`: ARM SKU identifier (e.g. "Standard_B2s") — present for
29
+ * VM/compute meters, absent for some services (Bandwidth, Support, …)
30
+ * - `reservationTerm`: present ONLY for Reservation meters; consumers that
31
+ * want on-demand pricing must filter by `type === "Consumption"`.
32
+ */
33
+ export const PriceItemSchema = z.object({
34
+ armRegionName: NonEmptyStringSchema.optional(),
35
+ armSkuName: NonEmptyStringSchema.optional(),
36
+ currencyCode: z.string().length(3),
37
+ effectiveStartDate: NonEmptyStringSchema.optional(),
38
+ isPrimaryMeterRegion: z.boolean().optional(),
39
+ location: NonEmptyStringSchema.optional(),
40
+ meterId: NonEmptyStringSchema.optional(),
41
+ meterName: NonEmptyStringSchema.optional(),
42
+ productId: NonEmptyStringSchema.optional(),
43
+ productName: NonEmptyStringSchema.optional(),
44
+ reservationTerm: NonEmptyStringSchema.optional(),
45
+ retailPrice: PriceValueSchema,
46
+ serviceFamily: NonEmptyStringSchema.optional(),
47
+ serviceId: NonEmptyStringSchema.optional(),
48
+ serviceName: NonEmptyStringSchema.optional(),
49
+ skuId: NonEmptyStringSchema.optional(),
50
+ skuName: NonEmptyStringSchema.optional(),
51
+ tierMinimumUnits: PriceValueSchema.optional(),
52
+ /** "Consumption" | "Reservation" | "DevTestConsumption". */
53
+ type: PriceTypeSchema,
54
+ unitOfMeasure: NonEmptyStringSchema,
55
+ unitPrice: PriceValueSchema,
56
+ });
57
+ export type PriceItem = z.infer<typeof PriceItemSchema>;
58
+
59
+ /**
60
+ * Top-level response envelope. `Count` reflects the number of items in the
61
+ * current page, not the grand total.
62
+ */
63
+ export const PricingResponseSchema = z.object({
64
+ BillingCurrency: z.string().length(3).optional(),
65
+ Count: z.number().int().nonnegative().optional(),
66
+ Items: z.array(PriceItemSchema),
67
+ NextPageLink: z.string().url().nullable().optional(),
68
+ });
69
+ export type PricingResponse = z.infer<typeof PricingResponseSchema>;
@@ -0,0 +1,26 @@
1
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+
4
+ const TEST_CACHE_PARENT = join(
5
+ process.cwd(),
6
+ "node_modules",
7
+ ".cache",
8
+ "dx-savemoney-tests",
9
+ );
10
+
11
+ export async function makeTestCacheDir(prefix: string): Promise<string> {
12
+ await mkdir(TEST_CACHE_PARENT, { recursive: true });
13
+ return mkdtemp(join(TEST_CACHE_PARENT, `${prefix}-`));
14
+ }
15
+
16
+ export async function removeTestCacheDir(dir: string): Promise<void> {
17
+ const relativePath = relative(TEST_CACHE_PARENT, dir);
18
+ if (
19
+ relativePath === "" ||
20
+ relativePath.startsWith("..") ||
21
+ isAbsolute(relativePath)
22
+ ) {
23
+ throw new Error(`Refusing to remove non-test cache directory: ${dir}`);
24
+ }
25
+ await rm(dir, { force: true, recursive: true });
26
+ }