@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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the PricingService façade — covers memoization and error
|
|
3
|
+
* swallowing. The underlying resolvers are exercised in their own tests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
import type { FetchLike } from "../client.js";
|
|
9
|
+
import type { PricingResponse } from "../schema.js";
|
|
10
|
+
|
|
11
|
+
import { DiskCache } from "../cache.js";
|
|
12
|
+
import { PricingClient } from "../client.js";
|
|
13
|
+
import { PricingService } from "../pricing-service.js";
|
|
14
|
+
import { makeTestCacheDir, removeTestCacheDir } from "../test-cache-dir.js";
|
|
15
|
+
|
|
16
|
+
function makeFetch(body: PricingResponse): {
|
|
17
|
+
fetch: FetchLike;
|
|
18
|
+
spy: ReturnType<typeof vi.fn>;
|
|
19
|
+
} {
|
|
20
|
+
const spy = vi.fn<FetchLike>().mockResolvedValue({
|
|
21
|
+
json: async () => body,
|
|
22
|
+
ok: true,
|
|
23
|
+
status: 200,
|
|
24
|
+
statusText: "OK",
|
|
25
|
+
});
|
|
26
|
+
return { fetch: spy, spy };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let dir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
dir = await makeTestCacheDir("pricing-service");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await removeTestCacheDir(dir);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("PricingService", () => {
|
|
40
|
+
it("memoizes identical VM lookups within the same instance", async () => {
|
|
41
|
+
const body: PricingResponse = {
|
|
42
|
+
Items: [
|
|
43
|
+
{
|
|
44
|
+
armRegionName: "westeurope",
|
|
45
|
+
armSkuName: "Standard_B2s",
|
|
46
|
+
currencyCode: "EUR",
|
|
47
|
+
productName: "Virtual Machines BS Series",
|
|
48
|
+
retailPrice: 0.05,
|
|
49
|
+
skuName: "B2s",
|
|
50
|
+
type: "Consumption",
|
|
51
|
+
unitOfMeasure: "1 Hour",
|
|
52
|
+
unitPrice: 0.05,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
NextPageLink: null,
|
|
56
|
+
};
|
|
57
|
+
const { fetch, spy } = makeFetch(body);
|
|
58
|
+
const client = new PricingClient({
|
|
59
|
+
cache: new DiskCache({ dir }),
|
|
60
|
+
fetch,
|
|
61
|
+
});
|
|
62
|
+
const service = new PricingService(client);
|
|
63
|
+
|
|
64
|
+
const [a, b] = await Promise.all([
|
|
65
|
+
service.resolveVm({
|
|
66
|
+
armRegionName: "westeurope",
|
|
67
|
+
armSkuName: "Standard_B2s",
|
|
68
|
+
}),
|
|
69
|
+
service.resolveVm({
|
|
70
|
+
armRegionName: "westeurope",
|
|
71
|
+
armSkuName: "Standard_B2s",
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
expect(a).toEqual(b);
|
|
76
|
+
// Only one underlying fetch despite two concurrent calls.
|
|
77
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined and does not throw when the resolver errors", async () => {
|
|
81
|
+
const client = new PricingClient({
|
|
82
|
+
cache: new DiskCache({ dir }),
|
|
83
|
+
fetch: vi.fn<FetchLike>().mockRejectedValue(new Error("boom")),
|
|
84
|
+
});
|
|
85
|
+
const service = new PricingService(client);
|
|
86
|
+
|
|
87
|
+
const result = await service.resolveVm({
|
|
88
|
+
armRegionName: "westeurope",
|
|
89
|
+
armSkuName: "Standard_B2s",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("scopes the in-memory memo by os hint", async () => {
|
|
96
|
+
const body: PricingResponse = {
|
|
97
|
+
Items: [
|
|
98
|
+
{
|
|
99
|
+
armRegionName: "westeurope",
|
|
100
|
+
armSkuName: "Standard_B2s",
|
|
101
|
+
currencyCode: "EUR",
|
|
102
|
+
productName: "Virtual Machines BS Series",
|
|
103
|
+
retailPrice: 0.05,
|
|
104
|
+
skuName: "B2s",
|
|
105
|
+
type: "Consumption",
|
|
106
|
+
unitOfMeasure: "1 Hour",
|
|
107
|
+
unitPrice: 0.05,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
armRegionName: "westeurope",
|
|
111
|
+
armSkuName: "Standard_B2s",
|
|
112
|
+
currencyCode: "EUR",
|
|
113
|
+
productName: "Virtual Machines BS Series Windows",
|
|
114
|
+
retailPrice: 0.09,
|
|
115
|
+
skuName: "B2s",
|
|
116
|
+
type: "Consumption",
|
|
117
|
+
unitOfMeasure: "1 Hour",
|
|
118
|
+
unitPrice: 0.09,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
NextPageLink: null,
|
|
122
|
+
};
|
|
123
|
+
const { fetch, spy } = makeFetch(body);
|
|
124
|
+
const client = new PricingClient({
|
|
125
|
+
cache: new DiskCache({ dir }),
|
|
126
|
+
fetch,
|
|
127
|
+
});
|
|
128
|
+
const service = new PricingService(client);
|
|
129
|
+
|
|
130
|
+
const linux = await service.resolveVm({
|
|
131
|
+
armRegionName: "westeurope",
|
|
132
|
+
armSkuName: "Standard_B2s",
|
|
133
|
+
os: "linux",
|
|
134
|
+
});
|
|
135
|
+
const windows = await service.resolveVm({
|
|
136
|
+
armRegionName: "westeurope",
|
|
137
|
+
armSkuName: "Standard_B2s",
|
|
138
|
+
os: "windows",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(linux?.amount).toBeLessThan(windows?.amount ?? 0);
|
|
142
|
+
// Same underlying filter, served from DiskCache — one fetch only.
|
|
143
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("PricingService disk memoization", () => {
|
|
148
|
+
it("memoizes identical disk lookups within the same instance", async () => {
|
|
149
|
+
const body: PricingResponse = {
|
|
150
|
+
Items: [
|
|
151
|
+
{
|
|
152
|
+
armRegionName: "westeurope",
|
|
153
|
+
currencyCode: "EUR",
|
|
154
|
+
productName: "Premium SSD Managed Disks",
|
|
155
|
+
retailPrice: 19.71,
|
|
156
|
+
skuName: "P10 LRS",
|
|
157
|
+
type: "Consumption",
|
|
158
|
+
unitOfMeasure: "1/Month",
|
|
159
|
+
unitPrice: 19.71,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
NextPageLink: null,
|
|
163
|
+
};
|
|
164
|
+
const { fetch, spy } = makeFetch(body);
|
|
165
|
+
const client = new PricingClient({
|
|
166
|
+
cache: new DiskCache({ dir }),
|
|
167
|
+
fetch,
|
|
168
|
+
});
|
|
169
|
+
const service = new PricingService(client);
|
|
170
|
+
|
|
171
|
+
const [a, b] = await Promise.all([
|
|
172
|
+
service.resolveDisk({
|
|
173
|
+
armRegionName: "westeurope",
|
|
174
|
+
diskSizeGiB: 128,
|
|
175
|
+
sku: "Premium_LRS",
|
|
176
|
+
}),
|
|
177
|
+
service.resolveDisk({
|
|
178
|
+
armRegionName: "westeurope",
|
|
179
|
+
diskSizeGiB: 128,
|
|
180
|
+
sku: "Premium_LRS",
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
expect(a).toEqual(b);
|
|
185
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("memoizes disk lookups with display and ARM region names as the same key", async () => {
|
|
189
|
+
const body: PricingResponse = {
|
|
190
|
+
Items: [
|
|
191
|
+
{
|
|
192
|
+
armRegionName: "westeurope",
|
|
193
|
+
currencyCode: "EUR",
|
|
194
|
+
productName: "Premium SSD Managed Disks",
|
|
195
|
+
retailPrice: 19.71,
|
|
196
|
+
skuName: "P10 LRS",
|
|
197
|
+
type: "Consumption",
|
|
198
|
+
unitOfMeasure: "1/Month",
|
|
199
|
+
unitPrice: 19.71,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
NextPageLink: null,
|
|
203
|
+
};
|
|
204
|
+
const { fetch, spy } = makeFetch(body);
|
|
205
|
+
const client = new PricingClient({
|
|
206
|
+
cache: new DiskCache({ dir }),
|
|
207
|
+
fetch,
|
|
208
|
+
});
|
|
209
|
+
const service = new PricingService(client);
|
|
210
|
+
|
|
211
|
+
const [a, b] = await Promise.all([
|
|
212
|
+
service.resolveDisk({
|
|
213
|
+
armRegionName: "West Europe",
|
|
214
|
+
diskSizeGiB: 128,
|
|
215
|
+
sku: "Premium_LRS",
|
|
216
|
+
}),
|
|
217
|
+
service.resolveDisk({
|
|
218
|
+
armRegionName: "westeurope",
|
|
219
|
+
diskSizeGiB: 128,
|
|
220
|
+
sku: "Premium_LRS",
|
|
221
|
+
}),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
expect(a).toEqual(b);
|
|
225
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("PricingService App Service Plan memoization", () => {
|
|
230
|
+
it("memoizes identical App Service Plan lookups within the same instance", async () => {
|
|
231
|
+
const body: PricingResponse = {
|
|
232
|
+
Items: [
|
|
233
|
+
{
|
|
234
|
+
armRegionName: "westeurope",
|
|
235
|
+
currencyCode: "EUR",
|
|
236
|
+
meterName: "S1 App",
|
|
237
|
+
productName: "Azure App Service Standard Plan - Linux",
|
|
238
|
+
retailPrice: 0.08337,
|
|
239
|
+
skuName: "S1",
|
|
240
|
+
type: "Consumption",
|
|
241
|
+
unitOfMeasure: "1 Hour",
|
|
242
|
+
unitPrice: 0.08337,
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
NextPageLink: null,
|
|
246
|
+
};
|
|
247
|
+
const { fetch, spy } = makeFetch(body);
|
|
248
|
+
const client = new PricingClient({
|
|
249
|
+
cache: new DiskCache({ dir }),
|
|
250
|
+
fetch,
|
|
251
|
+
});
|
|
252
|
+
const service = new PricingService(client);
|
|
253
|
+
|
|
254
|
+
const [a, b] = await Promise.all([
|
|
255
|
+
service.resolveAppServicePlan({
|
|
256
|
+
armRegionName: "westeurope",
|
|
257
|
+
os: "linux",
|
|
258
|
+
skuName: "S1",
|
|
259
|
+
workerCount: 1,
|
|
260
|
+
}),
|
|
261
|
+
service.resolveAppServicePlan({
|
|
262
|
+
armRegionName: "westeurope",
|
|
263
|
+
os: "linux",
|
|
264
|
+
skuName: "S1",
|
|
265
|
+
workerCount: 1,
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
expect(a).toEqual(b);
|
|
270
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("memoizes App Service Plan lookups by canonical region and worker count", async () => {
|
|
274
|
+
const body: PricingResponse = {
|
|
275
|
+
Items: [
|
|
276
|
+
{
|
|
277
|
+
armRegionName: "westeurope",
|
|
278
|
+
currencyCode: "EUR",
|
|
279
|
+
meterName: "S1 App",
|
|
280
|
+
productName: "Azure App Service Standard Plan - Linux",
|
|
281
|
+
retailPrice: 0.08337,
|
|
282
|
+
skuName: "S1",
|
|
283
|
+
type: "Consumption",
|
|
284
|
+
unitOfMeasure: "1 Hour",
|
|
285
|
+
unitPrice: 0.08337,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
NextPageLink: null,
|
|
289
|
+
};
|
|
290
|
+
const { fetch, spy } = makeFetch(body);
|
|
291
|
+
const client = new PricingClient({
|
|
292
|
+
cache: new DiskCache({ dir }),
|
|
293
|
+
fetch,
|
|
294
|
+
});
|
|
295
|
+
const service = new PricingService(client);
|
|
296
|
+
|
|
297
|
+
const [a, b] = await Promise.all([
|
|
298
|
+
service.resolveAppServicePlan({
|
|
299
|
+
armRegionName: "West Europe",
|
|
300
|
+
os: "linux",
|
|
301
|
+
skuName: "S1",
|
|
302
|
+
workerCount: 1.2,
|
|
303
|
+
}),
|
|
304
|
+
service.resolveAppServicePlan({
|
|
305
|
+
armRegionName: "westeurope",
|
|
306
|
+
os: "linux",
|
|
307
|
+
skuName: "S1",
|
|
308
|
+
workerCount: 2,
|
|
309
|
+
}),
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
expect(a).toEqual(b);
|
|
313
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-backed cache for Azure Retail Prices API responses.
|
|
3
|
+
*
|
|
4
|
+
* Retail prices change rarely (Azure publishes a new snapshot at most once
|
|
5
|
+
* a month) so a long TTL is safe and dramatically cuts cold-start latency
|
|
6
|
+
* for `dx savemoney`. Each cache entry is a JSON file whose name is the
|
|
7
|
+
* SHA-256 of the request key (the OData `$filter` string); the entry is
|
|
8
|
+
* considered fresh while `now - mtime <= ttl`.
|
|
9
|
+
*
|
|
10
|
+
* The cache directory is created lazily on first write. Read/write
|
|
11
|
+
* failures never throw: a corrupted or unreadable entry simply behaves
|
|
12
|
+
* like a miss so the caller refetches from the API. This matches the
|
|
13
|
+
* "best-effort cache" semantics expected by HTTP-level caches.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Defaults documented in the package README. */
|
|
22
|
+
export const DEFAULT_CACHE_DIR = join(
|
|
23
|
+
process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"),
|
|
24
|
+
"dx-savemoney",
|
|
25
|
+
"pricing",
|
|
26
|
+
);
|
|
27
|
+
export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export type DiskCacheOptions = {
|
|
30
|
+
/** Absolute path where cache entries are stored. */
|
|
31
|
+
dir?: string;
|
|
32
|
+
/** Maximum age of an entry before it is treated as a miss. */
|
|
33
|
+
ttlMs?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort JSON cache. Keys are arbitrary strings; values are anything
|
|
38
|
+
* serialisable via `JSON.stringify`.
|
|
39
|
+
*/
|
|
40
|
+
export class DiskCache {
|
|
41
|
+
readonly dir: string;
|
|
42
|
+
readonly ttlMs: number;
|
|
43
|
+
|
|
44
|
+
constructor(options: DiskCacheOptions = {}) {
|
|
45
|
+
this.dir = options.dir ?? DEFAULT_CACHE_DIR;
|
|
46
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the cached value for `key` if present and still fresh.
|
|
51
|
+
* Returns `undefined` on miss, expiry, or any I/O / parse error.
|
|
52
|
+
*/
|
|
53
|
+
async get<T>(key: string): Promise<T | undefined> {
|
|
54
|
+
const path = this.pathFor(key);
|
|
55
|
+
try {
|
|
56
|
+
const file = await open(path, "r");
|
|
57
|
+
try {
|
|
58
|
+
const stats = await file.stat();
|
|
59
|
+
if (Date.now() - stats.mtimeMs > this.ttlMs) return undefined;
|
|
60
|
+
const raw = await file.readFile("utf8");
|
|
61
|
+
return JSON.parse(raw) as T;
|
|
62
|
+
} finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Absolute filesystem path used to store `key`. Exposed for tests. */
|
|
71
|
+
pathFor(key: string): string {
|
|
72
|
+
const hash = createHash("sha256").update(key).digest("hex");
|
|
73
|
+
return join(this.dir, `${hash}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Persists `value` under `key`. Silently swallows write errors so a
|
|
78
|
+
* read-only filesystem cannot break the calling pipeline.
|
|
79
|
+
*/
|
|
80
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
81
|
+
const path = this.pathFor(key);
|
|
82
|
+
try {
|
|
83
|
+
await mkdir(this.dir, { mode: 0o700, recursive: true });
|
|
84
|
+
await writeFile(path, JSON.stringify(value), {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
// Best-effort: cache failures must not surface to callers.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Azure Retail Prices REST API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: https://prices.azure.com/api/retail/prices
|
|
5
|
+
*
|
|
6
|
+
* The API is **public and unauthenticated** — no `TokenCredential`
|
|
7
|
+
* required — but it is rate-limited per source IP. To stay polite we:
|
|
8
|
+
* 1. Cache the full paginated result for each `$filter` on disk
|
|
9
|
+
* (`DiskCache`, 24h TTL by default) and return cached items
|
|
10
|
+
* immediately on hit.
|
|
11
|
+
* 2. Follow `NextPageLink` sequentially (no parallel page fetches).
|
|
12
|
+
*
|
|
13
|
+
* The client is constructed with an optional `fetch` implementation to
|
|
14
|
+
* make it trivially mockable in tests; in production it uses the global
|
|
15
|
+
* `fetch` available since Node 18.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getLogger } from "@logtape/logtape";
|
|
19
|
+
|
|
20
|
+
import type { PriceItem, PricingResponse } from "./schema.js";
|
|
21
|
+
|
|
22
|
+
import { DiskCache } from "./cache.js";
|
|
23
|
+
import { PricingResponseSchema } from "./schema.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BASE_URL = "https://prices.azure.com/api/retail/prices";
|
|
26
|
+
/** Hard cap on pages to follow — protects against runaway loops. */
|
|
27
|
+
const MAX_PAGES = 100;
|
|
28
|
+
|
|
29
|
+
export type FetchLike = (
|
|
30
|
+
input: string,
|
|
31
|
+
init?: { signal?: AbortSignal },
|
|
32
|
+
) => Promise<{
|
|
33
|
+
json(): Promise<unknown>;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
status: number;
|
|
36
|
+
statusText: string;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
export type PricingClientOptions = {
|
|
40
|
+
/** Override the base URL. Useful for staging or fixtures. */
|
|
41
|
+
baseUrl?: string;
|
|
42
|
+
/** Disk cache instance. Defaults to the user's cache directory. */
|
|
43
|
+
cache?: DiskCache;
|
|
44
|
+
/**
|
|
45
|
+
* Currency to ask the API for. The response items always carry their
|
|
46
|
+
* own `currencyCode`, but specifying it here returns localised prices
|
|
47
|
+
* where available. Defaults to "EUR".
|
|
48
|
+
*/
|
|
49
|
+
currencyCode?: string;
|
|
50
|
+
/** Inject a custom `fetch` (used by tests). Defaults to the global one. */
|
|
51
|
+
fetch?: FetchLike;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Thin wrapper around the Retail Prices endpoint.
|
|
56
|
+
*
|
|
57
|
+
* Use `query(filter)` with an OData `$filter` expression, e.g.:
|
|
58
|
+
* serviceName eq 'Virtual Machines' and armRegionName eq 'westeurope' and armSkuName eq 'Standard_B2s'
|
|
59
|
+
*/
|
|
60
|
+
export class PricingClient {
|
|
61
|
+
private readonly baseUrl: string;
|
|
62
|
+
private readonly cache: DiskCache;
|
|
63
|
+
private readonly currencyCode: string;
|
|
64
|
+
private readonly fetchImpl: FetchLike;
|
|
65
|
+
private readonly logger = getLogger(["savemoney", "azure", "pricing"]);
|
|
66
|
+
|
|
67
|
+
constructor(options: PricingClientOptions = {}) {
|
|
68
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
69
|
+
this.cache = options.cache ?? new DiskCache();
|
|
70
|
+
this.currencyCode = options.currencyCode ?? "EUR";
|
|
71
|
+
// The global `fetch` exists in Node ≥ 18 / 24 but TypeScript types it
|
|
72
|
+
// loosely; we narrow to `FetchLike` to keep the surface intentional.
|
|
73
|
+
this.fetchImpl =
|
|
74
|
+
options.fetch ??
|
|
75
|
+
((input, init) => fetch(input, init) as ReturnType<FetchLike>);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns every meter that matches the given OData `$filter`, following
|
|
80
|
+
* pagination transparently. Results are cached for `cache.ttlMs`.
|
|
81
|
+
*
|
|
82
|
+
* Throws when the API returns a non-2xx status or a response that fails
|
|
83
|
+
* schema validation: pricing lookups are advisory (callers should treat
|
|
84
|
+
* "no data" as "skip the savings estimate") but a malformed payload
|
|
85
|
+
* signals a real upstream change that deserves attention.
|
|
86
|
+
*/
|
|
87
|
+
async query(filter: string): Promise<PriceItem[]> {
|
|
88
|
+
const cacheKey = `${this.currencyCode}|${filter}`;
|
|
89
|
+
const cached = await this.cache.get<PriceItem[]>(cacheKey);
|
|
90
|
+
if (cached) {
|
|
91
|
+
this.logger.debug(
|
|
92
|
+
`pricing cache hit (${cached.length} items): ${filter}`,
|
|
93
|
+
);
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const items: PriceItem[] = [];
|
|
98
|
+
const firstUrl = this.buildUrl(filter);
|
|
99
|
+
let nextUrl: string | undefined = firstUrl;
|
|
100
|
+
for (let page = 0; nextUrl && page < MAX_PAGES; page++) {
|
|
101
|
+
const response: PricingResponse = await this.fetchPage(nextUrl);
|
|
102
|
+
items.push(...response.Items);
|
|
103
|
+
nextUrl = response.NextPageLink ?? undefined;
|
|
104
|
+
}
|
|
105
|
+
if (nextUrl) {
|
|
106
|
+
this.logger.warn(
|
|
107
|
+
`pricing query truncated at ${MAX_PAGES} pages: ${filter}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
await this.cache.set(cacheKey, items);
|
|
111
|
+
return items;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private buildUrl(filter: string): string {
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
$filter: filter,
|
|
117
|
+
"api-version": "2023-01-01-preview",
|
|
118
|
+
currencyCode: this.currencyCode,
|
|
119
|
+
});
|
|
120
|
+
return `${this.baseUrl}?${params.toString()}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async fetchPage(url: string): Promise<PricingResponse> {
|
|
124
|
+
const res = await this.fetchImpl(url);
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Azure Retail Prices API returned ${res.status} ${res.statusText}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const body = await res.json().catch((error: unknown) => {
|
|
131
|
+
throw new Error("Azure Retail Prices API returned invalid JSON", {
|
|
132
|
+
cause: error,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return PricingResponseSchema.parse(body);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level facade that exposes per-resource pricing resolvers backed by
|
|
3
|
+
* a shared `PricingClient` (and therefore a shared disk cache).
|
|
4
|
+
*
|
|
5
|
+
* Two responsibilities:
|
|
6
|
+
* 1. Dispatch the right resolver for the right resource kind.
|
|
7
|
+
* 2. Memoize identical lookups within a single run so concurrent
|
|
8
|
+
* analyzers asking for the same `(sku, region, …)` only hit the
|
|
9
|
+
* network (or disk cache) once.
|
|
10
|
+
* 3. Swallow resolver errors and return `undefined` — the savings
|
|
11
|
+
* estimate is best-effort and should never break the main analysis
|
|
12
|
+
* flow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getLogger } from "@logtape/logtape";
|
|
16
|
+
|
|
17
|
+
import type { Money } from "../../finding.js";
|
|
18
|
+
import type { PricingClient } from "./client.js";
|
|
19
|
+
import type {
|
|
20
|
+
ResolveAppServicePlanInput,
|
|
21
|
+
ResolveDiskInput,
|
|
22
|
+
ResolvePublicIpInput,
|
|
23
|
+
ResolveVmInput,
|
|
24
|
+
} from "./resolvers/index.js";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
normalizeArmRegion,
|
|
28
|
+
resolveAppServicePlanMonthlyPrice,
|
|
29
|
+
resolveDiskMonthlyPrice,
|
|
30
|
+
resolvePublicIpMonthlyPrice,
|
|
31
|
+
resolveVmMonthlyPrice,
|
|
32
|
+
} from "./resolvers/index.js";
|
|
33
|
+
|
|
34
|
+
const logger = getLogger(["savemoney", "azure", "pricing"]);
|
|
35
|
+
|
|
36
|
+
export class PricingService {
|
|
37
|
+
/** In-memory memo keyed by the canonicalised lookup input. */
|
|
38
|
+
private readonly memo = new Map<string, Promise<Money | undefined>>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly client: PricingClient) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves the estimated monthly cost of an App Service Plan SKU.
|
|
44
|
+
* Returns `undefined` on no-match or on error.
|
|
45
|
+
*/
|
|
46
|
+
async resolveAppServicePlan(
|
|
47
|
+
input: ResolveAppServicePlanInput,
|
|
48
|
+
): Promise<Money | undefined> {
|
|
49
|
+
const key = `app-service-plan|${normalizeArmRegion(input.armRegionName)}|${input.skuName}|${input.os ?? "any"}|${Math.ceil(input.workerCount ?? 1)}`;
|
|
50
|
+
return this.memoize(key, () =>
|
|
51
|
+
this.safeResolve(() =>
|
|
52
|
+
resolveAppServicePlanMonthlyPrice(this.client, input),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves the estimated monthly cost of a Managed Disk. Returns
|
|
59
|
+
* `undefined` on no-match (e.g. unsupported SKU like UltraSSD) or on
|
|
60
|
+
* error.
|
|
61
|
+
*/
|
|
62
|
+
async resolveDisk(input: ResolveDiskInput): Promise<Money | undefined> {
|
|
63
|
+
const key = `disk|${normalizeArmRegion(input.armRegionName)}|${input.sku}|${input.diskSizeGiB}`;
|
|
64
|
+
return this.memoize(key, () =>
|
|
65
|
+
this.safeResolve(() => resolveDiskMonthlyPrice(this.client, input)),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the estimated monthly cost of a Public IP. Returns
|
|
71
|
+
* `undefined` on no-match or on error.
|
|
72
|
+
*/
|
|
73
|
+
async resolvePublicIp(
|
|
74
|
+
input: ResolvePublicIpInput,
|
|
75
|
+
): Promise<Money | undefined> {
|
|
76
|
+
const key = `public-ip|${normalizeArmRegion(input.armRegionName)}|${input.sku}|${input.allocation}`;
|
|
77
|
+
return this.memoize(key, () =>
|
|
78
|
+
this.safeResolve(() => resolvePublicIpMonthlyPrice(this.client, input)),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolves the estimated monthly cost of a Virtual Machine SKU.
|
|
84
|
+
* Returns `undefined` on no-match or on error.
|
|
85
|
+
*/
|
|
86
|
+
async resolveVm(input: ResolveVmInput): Promise<Money | undefined> {
|
|
87
|
+
const key = `vm|${normalizeArmRegion(input.armRegionName)}|${input.armSkuName}|${input.os ?? "linux"}`;
|
|
88
|
+
return this.memoize(key, () =>
|
|
89
|
+
this.safeResolve(() => resolveVmMonthlyPrice(this.client, input)),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private memoize(
|
|
94
|
+
key: string,
|
|
95
|
+
factory: () => Promise<Money | undefined>,
|
|
96
|
+
): Promise<Money | undefined> {
|
|
97
|
+
const cached = this.memo.get(key);
|
|
98
|
+
if (cached) return cached;
|
|
99
|
+
const promise = factory().then((money) => {
|
|
100
|
+
if (money === undefined) {
|
|
101
|
+
// Debug-level so it shows up under `-v` but doesn't clutter the
|
|
102
|
+
// default output. Helps diagnosing "I see no savings" reports
|
|
103
|
+
// when the Retail Prices API has no entry for the queried
|
|
104
|
+
// (region, sku, …) combination.
|
|
105
|
+
logger.debug(`Pricing lookup returned no match for ${key}`);
|
|
106
|
+
}
|
|
107
|
+
return money;
|
|
108
|
+
});
|
|
109
|
+
this.memo.set(key, promise);
|
|
110
|
+
return promise;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async safeResolve(
|
|
114
|
+
factory: () => Promise<Money | undefined>,
|
|
115
|
+
): Promise<Money | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
return await factory();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.warn(
|
|
120
|
+
`Pricing lookup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|