@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,79 @@
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
+ import { createHash } from "node:crypto";
16
+ import { mkdir, open, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ /** Defaults documented in the package README. */
20
+ export const DEFAULT_CACHE_DIR = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "dx-savemoney", "pricing");
21
+ export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
22
+ /**
23
+ * Best-effort JSON cache. Keys are arbitrary strings; values are anything
24
+ * serialisable via `JSON.stringify`.
25
+ */
26
+ export class DiskCache {
27
+ dir;
28
+ ttlMs;
29
+ constructor(options = {}) {
30
+ this.dir = options.dir ?? DEFAULT_CACHE_DIR;
31
+ this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
32
+ }
33
+ /**
34
+ * Returns the cached value for `key` if present and still fresh.
35
+ * Returns `undefined` on miss, expiry, or any I/O / parse error.
36
+ */
37
+ async get(key) {
38
+ const path = this.pathFor(key);
39
+ try {
40
+ const file = await open(path, "r");
41
+ try {
42
+ const stats = await file.stat();
43
+ if (Date.now() - stats.mtimeMs > this.ttlMs)
44
+ return undefined;
45
+ const raw = await file.readFile("utf8");
46
+ return JSON.parse(raw);
47
+ }
48
+ finally {
49
+ await file.close();
50
+ }
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ /** Absolute filesystem path used to store `key`. Exposed for tests. */
57
+ pathFor(key) {
58
+ const hash = createHash("sha256").update(key).digest("hex");
59
+ return join(this.dir, `${hash}.json`);
60
+ }
61
+ /**
62
+ * Persists `value` under `key`. Silently swallows write errors so a
63
+ * read-only filesystem cannot break the calling pipeline.
64
+ */
65
+ async set(key, value) {
66
+ const path = this.pathFor(key);
67
+ try {
68
+ await mkdir(this.dir, { mode: 0o700, recursive: true });
69
+ await writeFile(path, JSON.stringify(value), {
70
+ encoding: "utf8",
71
+ mode: 0o600,
72
+ });
73
+ }
74
+ catch {
75
+ // Best-effort: cache failures must not surface to callers.
76
+ }
77
+ }
78
+ }
79
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../../src/azure/pricing/cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,iDAAiD;AACjD,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CACnC,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,CAAC,EACvD,cAAc,EACd,SAAS,CACV,CAAC;AACF,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AASxD;;;GAGG;AACH,MAAM,OAAO,SAAS;IACX,GAAG,CAAS;IACZ,KAAK,CAAS;IAEvB,YAAY,UAA4B,EAAE;QACxC,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,iBAAiB,CAAC;QAC5C,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,oBAAoB,CAAC;IACrD,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,GAAG,CAAI,GAAW;QACtB,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;YACnC,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;gBAChC,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,KAAK;oBAAE,OAAO,SAAS,CAAC;gBAC9D,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;gBACxC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAC;YAC9B,CAAC;oBAAS,CAAC;gBACT,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;YACrB,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;IAED,uEAAuE;IACvE,OAAO,CAAC,GAAW;QACjB,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,OAAO,CAAC,CAAC;IACxC,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,GAAG,CAAI,GAAW,EAAE,KAAQ;QAChC,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACxD,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE;gBAC3C,QAAQ,EAAE,MAAM;gBAChB,IAAI,EAAE,KAAK;aACZ,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,2DAA2D;QAC7D,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,67 @@
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
+ import type { PriceItem } from "./schema.js";
18
+ import { DiskCache } from "./cache.js";
19
+ export type FetchLike = (input: string, init?: {
20
+ signal?: AbortSignal;
21
+ }) => Promise<{
22
+ json(): Promise<unknown>;
23
+ ok: boolean;
24
+ status: number;
25
+ statusText: string;
26
+ }>;
27
+ export type PricingClientOptions = {
28
+ /** Override the base URL. Useful for staging or fixtures. */
29
+ baseUrl?: string;
30
+ /** Disk cache instance. Defaults to the user's cache directory. */
31
+ cache?: DiskCache;
32
+ /**
33
+ * Currency to ask the API for. The response items always carry their
34
+ * own `currencyCode`, but specifying it here returns localised prices
35
+ * where available. Defaults to "EUR".
36
+ */
37
+ currencyCode?: string;
38
+ /** Inject a custom `fetch` (used by tests). Defaults to the global one. */
39
+ fetch?: FetchLike;
40
+ };
41
+ /**
42
+ * Thin wrapper around the Retail Prices endpoint.
43
+ *
44
+ * Use `query(filter)` with an OData `$filter` expression, e.g.:
45
+ * serviceName eq 'Virtual Machines' and armRegionName eq 'westeurope' and armSkuName eq 'Standard_B2s'
46
+ */
47
+ export declare class PricingClient {
48
+ private readonly baseUrl;
49
+ private readonly cache;
50
+ private readonly currencyCode;
51
+ private readonly fetchImpl;
52
+ private readonly logger;
53
+ constructor(options?: PricingClientOptions);
54
+ /**
55
+ * Returns every meter that matches the given OData `$filter`, following
56
+ * pagination transparently. Results are cached for `cache.ttlMs`.
57
+ *
58
+ * Throws when the API returns a non-2xx status or a response that fails
59
+ * schema validation: pricing lookups are advisory (callers should treat
60
+ * "no data" as "skip the savings estimate") but a malformed payload
61
+ * signals a real upstream change that deserves attention.
62
+ */
63
+ query(filter: string): Promise<PriceItem[]>;
64
+ private buildUrl;
65
+ private fetchPage;
66
+ }
67
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/azure/pricing/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAmB,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAOvC,MAAM,MAAM,SAAS,GAAG,CACtB,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,KAC5B,OAAO,CAAC;IACX,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CAAC;AAEH,MAAM,MAAM,oBAAoB,GAAG;IACjC,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB,CAAC;AAEF;;;;;GAKG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgD;gBAE3D,OAAO,GAAE,oBAAyB;IAW9C;;;;;;;;OAQG;IACG,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IA2BjD,OAAO,CAAC,QAAQ;YASF,SAAS;CAcxB"}
@@ -0,0 +1,96 @@
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
+ import { getLogger } from "@logtape/logtape";
18
+ import { DiskCache } from "./cache.js";
19
+ import { PricingResponseSchema } from "./schema.js";
20
+ const DEFAULT_BASE_URL = "https://prices.azure.com/api/retail/prices";
21
+ /** Hard cap on pages to follow — protects against runaway loops. */
22
+ const MAX_PAGES = 100;
23
+ /**
24
+ * Thin wrapper around the Retail Prices endpoint.
25
+ *
26
+ * Use `query(filter)` with an OData `$filter` expression, e.g.:
27
+ * serviceName eq 'Virtual Machines' and armRegionName eq 'westeurope' and armSkuName eq 'Standard_B2s'
28
+ */
29
+ export class PricingClient {
30
+ baseUrl;
31
+ cache;
32
+ currencyCode;
33
+ fetchImpl;
34
+ logger = getLogger(["savemoney", "azure", "pricing"]);
35
+ constructor(options = {}) {
36
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
37
+ this.cache = options.cache ?? new DiskCache();
38
+ this.currencyCode = options.currencyCode ?? "EUR";
39
+ // The global `fetch` exists in Node ≥ 18 / 24 but TypeScript types it
40
+ // loosely; we narrow to `FetchLike` to keep the surface intentional.
41
+ this.fetchImpl =
42
+ options.fetch ??
43
+ ((input, init) => fetch(input, init));
44
+ }
45
+ /**
46
+ * Returns every meter that matches the given OData `$filter`, following
47
+ * pagination transparently. Results are cached for `cache.ttlMs`.
48
+ *
49
+ * Throws when the API returns a non-2xx status or a response that fails
50
+ * schema validation: pricing lookups are advisory (callers should treat
51
+ * "no data" as "skip the savings estimate") but a malformed payload
52
+ * signals a real upstream change that deserves attention.
53
+ */
54
+ async query(filter) {
55
+ const cacheKey = `${this.currencyCode}|${filter}`;
56
+ const cached = await this.cache.get(cacheKey);
57
+ if (cached) {
58
+ this.logger.debug(`pricing cache hit (${cached.length} items): ${filter}`);
59
+ return cached;
60
+ }
61
+ const items = [];
62
+ const firstUrl = this.buildUrl(filter);
63
+ let nextUrl = firstUrl;
64
+ for (let page = 0; nextUrl && page < MAX_PAGES; page++) {
65
+ const response = await this.fetchPage(nextUrl);
66
+ items.push(...response.Items);
67
+ nextUrl = response.NextPageLink ?? undefined;
68
+ }
69
+ if (nextUrl) {
70
+ this.logger.warn(`pricing query truncated at ${MAX_PAGES} pages: ${filter}`);
71
+ }
72
+ await this.cache.set(cacheKey, items);
73
+ return items;
74
+ }
75
+ buildUrl(filter) {
76
+ const params = new URLSearchParams({
77
+ $filter: filter,
78
+ "api-version": "2023-01-01-preview",
79
+ currencyCode: this.currencyCode,
80
+ });
81
+ return `${this.baseUrl}?${params.toString()}`;
82
+ }
83
+ async fetchPage(url) {
84
+ const res = await this.fetchImpl(url);
85
+ if (!res.ok) {
86
+ throw new Error(`Azure Retail Prices API returned ${res.status} ${res.statusText}`);
87
+ }
88
+ const body = await res.json().catch((error) => {
89
+ throw new Error("Azure Retail Prices API returned invalid JSON", {
90
+ cause: error,
91
+ });
92
+ });
93
+ return PricingResponseSchema.parse(body);
94
+ }
95
+ }
96
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../../src/azure/pricing/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Internal pricing module surface used by the Azure orchestrator.
3
+ */
4
+ export { PricingClient, type PricingClientOptions } from "./client.js";
5
+ export { PricingService } from "./pricing-service.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/azure/pricing/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Internal pricing module surface used by the Azure orchestrator.
3
+ */
4
+ export { PricingClient } from "./client.js";
5
+ export { PricingService } from "./pricing-service.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/azure/pricing/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,aAAa,EAA6B,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,46 @@
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
+ import type { Money } from "../../finding.js";
15
+ import type { PricingClient } from "./client.js";
16
+ import type { ResolveAppServicePlanInput, ResolveDiskInput, ResolvePublicIpInput, ResolveVmInput } from "./resolvers/index.js";
17
+ export declare class PricingService {
18
+ private readonly client;
19
+ /** In-memory memo keyed by the canonicalised lookup input. */
20
+ private readonly memo;
21
+ constructor(client: PricingClient);
22
+ /**
23
+ * Resolves the estimated monthly cost of an App Service Plan SKU.
24
+ * Returns `undefined` on no-match or on error.
25
+ */
26
+ resolveAppServicePlan(input: ResolveAppServicePlanInput): Promise<Money | undefined>;
27
+ /**
28
+ * Resolves the estimated monthly cost of a Managed Disk. Returns
29
+ * `undefined` on no-match (e.g. unsupported SKU like UltraSSD) or on
30
+ * error.
31
+ */
32
+ resolveDisk(input: ResolveDiskInput): Promise<Money | undefined>;
33
+ /**
34
+ * Resolves the estimated monthly cost of a Public IP. Returns
35
+ * `undefined` on no-match or on error.
36
+ */
37
+ resolvePublicIp(input: ResolvePublicIpInput): Promise<Money | undefined>;
38
+ /**
39
+ * Resolves the estimated monthly cost of a Virtual Machine SKU.
40
+ * Returns `undefined` on no-match or on error.
41
+ */
42
+ resolveVm(input: ResolveVmInput): Promise<Money | undefined>;
43
+ private memoize;
44
+ private safeResolve;
45
+ }
46
+ //# sourceMappingURL=pricing-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing-service.d.ts","sourceRoot":"","sources":["../../../src/azure/pricing/pricing-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EACV,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,cAAc,EACf,MAAM,sBAAsB,CAAC;AAY9B,qBAAa,cAAc;IAIb,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHnC,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAiD;gBAEzC,MAAM,EAAE,aAAa;IAElD;;;OAGG;IACG,qBAAqB,CACzB,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC;IAS7B;;;;OAIG;IACG,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC;IAOtE;;;OAGG;IACG,eAAe,CACnB,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC;IAO7B;;;OAGG;IACG,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC;IAOlE,OAAO,CAAC,OAAO;YAoBD,WAAW;CAY1B"}
@@ -0,0 +1,84 @@
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
+ import { getLogger } from "@logtape/logtape";
15
+ import { normalizeArmRegion, resolveAppServicePlanMonthlyPrice, resolveDiskMonthlyPrice, resolvePublicIpMonthlyPrice, resolveVmMonthlyPrice, } from "./resolvers/index.js";
16
+ const logger = getLogger(["savemoney", "azure", "pricing"]);
17
+ export class PricingService {
18
+ client;
19
+ /** In-memory memo keyed by the canonicalised lookup input. */
20
+ memo = new Map();
21
+ constructor(client) {
22
+ this.client = client;
23
+ }
24
+ /**
25
+ * Resolves the estimated monthly cost of an App Service Plan SKU.
26
+ * Returns `undefined` on no-match or on error.
27
+ */
28
+ async resolveAppServicePlan(input) {
29
+ const key = `app-service-plan|${normalizeArmRegion(input.armRegionName)}|${input.skuName}|${input.os ?? "any"}|${Math.ceil(input.workerCount ?? 1)}`;
30
+ return this.memoize(key, () => this.safeResolve(() => resolveAppServicePlanMonthlyPrice(this.client, input)));
31
+ }
32
+ /**
33
+ * Resolves the estimated monthly cost of a Managed Disk. Returns
34
+ * `undefined` on no-match (e.g. unsupported SKU like UltraSSD) or on
35
+ * error.
36
+ */
37
+ async resolveDisk(input) {
38
+ const key = `disk|${normalizeArmRegion(input.armRegionName)}|${input.sku}|${input.diskSizeGiB}`;
39
+ return this.memoize(key, () => this.safeResolve(() => resolveDiskMonthlyPrice(this.client, input)));
40
+ }
41
+ /**
42
+ * Resolves the estimated monthly cost of a Public IP. Returns
43
+ * `undefined` on no-match or on error.
44
+ */
45
+ async resolvePublicIp(input) {
46
+ const key = `public-ip|${normalizeArmRegion(input.armRegionName)}|${input.sku}|${input.allocation}`;
47
+ return this.memoize(key, () => this.safeResolve(() => resolvePublicIpMonthlyPrice(this.client, input)));
48
+ }
49
+ /**
50
+ * Resolves the estimated monthly cost of a Virtual Machine SKU.
51
+ * Returns `undefined` on no-match or on error.
52
+ */
53
+ async resolveVm(input) {
54
+ const key = `vm|${normalizeArmRegion(input.armRegionName)}|${input.armSkuName}|${input.os ?? "linux"}`;
55
+ return this.memoize(key, () => this.safeResolve(() => resolveVmMonthlyPrice(this.client, input)));
56
+ }
57
+ memoize(key, factory) {
58
+ const cached = this.memo.get(key);
59
+ if (cached)
60
+ return cached;
61
+ const promise = factory().then((money) => {
62
+ if (money === undefined) {
63
+ // Debug-level so it shows up under `-v` but doesn't clutter the
64
+ // default output. Helps diagnosing "I see no savings" reports
65
+ // when the Retail Prices API has no entry for the queried
66
+ // (region, sku, …) combination.
67
+ logger.debug(`Pricing lookup returned no match for ${key}`);
68
+ }
69
+ return money;
70
+ });
71
+ this.memo.set(key, promise);
72
+ return promise;
73
+ }
74
+ async safeResolve(factory) {
75
+ try {
76
+ return await factory();
77
+ }
78
+ catch (error) {
79
+ logger.warn(`Pricing lookup failed: ${error instanceof Error ? error.message : String(error)}`);
80
+ return undefined;
81
+ }
82
+ }
83
+ }
84
+ //# sourceMappingURL=pricing-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing-service.js","sourceRoot":"","sources":["../../../src/azure/pricing/pricing-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Tests for the App Service Plan Retail Prices resolver.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=app-service.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-service.test.d.ts","sourceRoot":"","sources":["../../../../../src/azure/pricing/resolvers/__tests__/app-service.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for the App Service Plan Retail Prices resolver.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { DiskCache } from "../../cache.js";
6
+ import { PricingClient } from "../../client.js";
7
+ import { makeTestCacheDir, removeTestCacheDir } from "../../test-cache-dir.js";
8
+ import { resolveAppServicePlanMonthlyPrice } from "../app-service.js";
9
+ import { HOURS_PER_MONTH } from "../vm.js";
10
+ function makeFetch(body) {
11
+ return vi.fn().mockResolvedValue({
12
+ json: async () => body,
13
+ ok: true,
14
+ status: 200,
15
+ statusText: "OK",
16
+ });
17
+ }
18
+ let dir;
19
+ beforeEach(async () => {
20
+ dir = await makeTestCacheDir("app-service-resolver");
21
+ });
22
+ afterEach(async () => {
23
+ await removeTestCacheDir(dir);
24
+ });
25
+ describe("resolveAppServicePlanMonthlyPrice", () => {
26
+ it("returns the monthly cost for the matching Linux plan SKU and worker count", async () => {
27
+ const body = {
28
+ Items: [
29
+ {
30
+ armRegionName: "westeurope",
31
+ currencyCode: "EUR",
32
+ meterName: "S1 App",
33
+ productName: "Azure App Service Standard Plan",
34
+ retailPrice: 0.087758,
35
+ skuName: "S1",
36
+ type: "Consumption",
37
+ unitOfMeasure: "1 Hour",
38
+ unitPrice: 0.087758,
39
+ },
40
+ {
41
+ armRegionName: "westeurope",
42
+ currencyCode: "EUR",
43
+ meterName: "S1 App",
44
+ productName: "Azure App Service Standard Plan - Linux",
45
+ retailPrice: 0.08337,
46
+ skuName: "S1",
47
+ type: "Consumption",
48
+ unitOfMeasure: "1 Hour",
49
+ unitPrice: 0.08337,
50
+ },
51
+ ],
52
+ NextPageLink: null,
53
+ };
54
+ const client = new PricingClient({
55
+ cache: new DiskCache({ dir }),
56
+ fetch: makeFetch(body),
57
+ });
58
+ const money = await resolveAppServicePlanMonthlyPrice(client, {
59
+ armRegionName: "westeurope",
60
+ os: "linux",
61
+ skuName: "S1",
62
+ workerCount: 2,
63
+ });
64
+ expect(money).toEqual({
65
+ amount: Math.round(0.08337 * HOURS_PER_MONTH * 2 * 100) / 100,
66
+ currency: "EUR",
67
+ });
68
+ });
69
+ it("matches Retail Prices SKU names with inconsistent spacing", async () => {
70
+ const body = {
71
+ Items: [
72
+ {
73
+ armRegionName: "westeurope",
74
+ currencyCode: "EUR",
75
+ meterName: "P2 v3 App",
76
+ productName: "Azure App Service Premium v3 Plan",
77
+ retailPrice: 0.593243,
78
+ skuName: "P2 v3",
79
+ type: "Consumption",
80
+ unitOfMeasure: "1 Hour",
81
+ unitPrice: 0.593243,
82
+ },
83
+ ],
84
+ NextPageLink: null,
85
+ };
86
+ const client = new PricingClient({
87
+ cache: new DiskCache({ dir }),
88
+ fetch: makeFetch(body),
89
+ });
90
+ const money = await resolveAppServicePlanMonthlyPrice(client, {
91
+ armRegionName: "West Europe",
92
+ os: "windows",
93
+ skuName: "P2v3",
94
+ });
95
+ expect(money?.amount).toBeCloseTo(0.593243 * HOURS_PER_MONTH, 2);
96
+ });
97
+ it("ignores non-plan and non-consumption meters", async () => {
98
+ const body = {
99
+ Items: [
100
+ {
101
+ armRegionName: "westeurope",
102
+ currencyCode: "EUR",
103
+ meterName: "Standard Bandwidth Usage",
104
+ productName: "Static Web Apps",
105
+ retailPrice: 0.175516,
106
+ skuName: "Standard",
107
+ type: "Consumption",
108
+ unitOfMeasure: "1 GB",
109
+ unitPrice: 0.175516,
110
+ },
111
+ {
112
+ armRegionName: "westeurope",
113
+ currencyCode: "EUR",
114
+ meterName: "S1 App",
115
+ productName: "Azure App Service Standard Plan",
116
+ reservationTerm: "1 Year",
117
+ retailPrice: 640,
118
+ skuName: "S1",
119
+ type: "Reservation",
120
+ unitOfMeasure: "1 Hour",
121
+ unitPrice: 640,
122
+ },
123
+ ],
124
+ NextPageLink: null,
125
+ };
126
+ const client = new PricingClient({
127
+ cache: new DiskCache({ dir }),
128
+ fetch: makeFetch(body),
129
+ });
130
+ const money = await resolveAppServicePlanMonthlyPrice(client, {
131
+ armRegionName: "westeurope",
132
+ skuName: "S1",
133
+ });
134
+ expect(money).toBeUndefined();
135
+ });
136
+ });
137
+ //# sourceMappingURL=app-service.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-service.test.js","sourceRoot":"","sources":["../../../../../src/azure/pricing/resolvers/__tests__/app-service.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Tests for the Managed Disk Retail Prices resolver.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=disk.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disk.test.d.ts","sourceRoot":"","sources":["../../../../../src/azure/pricing/resolvers/__tests__/disk.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}