@pagopa/dx-savemoney 0.3.3 → 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 +1 -1
- 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,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 @@
|
|
|
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 @@
|
|
|
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":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAW7C,OAAO,EACL,kBAAkB,EAClB,iCAAiC,EACjC,uBAAuB,EACvB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sBAAsB,CAAC;AAE9B,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC;AAE5D,MAAM,OAAO,cAAc;IAII;IAH7B,8DAA8D;IAC7C,IAAI,GAAG,IAAI,GAAG,EAAsC,CAAC;IAEtE,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAEtD;;;OAGG;IACH,KAAK,CAAC,qBAAqB,CACzB,KAAiC;QAEjC,MAAM,GAAG,GAAG,oBAAoB,kBAAkB,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,EAAE,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,IAAI,CAAC,CAAC,EAAE,CAAC;QACrJ,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAC5B,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,CACpB,iCAAiC,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CACtD,CACF,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,WAAW,CAAC,KAAuB;QACvC,MAAM,GAAG,GAAG,QAAQ,kBAAkB,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,GAAG,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC;QAChG,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAC5B,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,uBAAuB,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CACpE,CAAC;IACJ,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,eAAe,CACnB,KAA2B;QAE3B,MAAM,GAAG,GAAG,aAAa,kBAAkB,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,GAAG,IAAI,KAAK,CAAC,UAAU,EAAE,CAAC;QACpG,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAC5B,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,2BAA2B,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CACxE,CAAC;IACJ,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,SAAS,CAAC,KAAqB;QACnC,MAAM,GAAG,GAAG,MAAM,kBAAkB,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,EAAE,IAAI,OAAO,EAAE,CAAC;QACvG,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAC5B,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,qBAAqB,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAClE,CAAC;IACJ,CAAC;IAEO,OAAO,CACb,GAAW,EACX,OAAyC;QAEzC,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAC1B,MAAM,OAAO,GAAG,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE;YACvC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,gEAAgE;gBAChE,8DAA8D;gBAC9D,0DAA0D;gBAC1D,gCAAgC;gBAChC,MAAM,CAAC,KAAK,CAAC,wCAAwC,GAAG,EAAE,CAAC,CAAC;YAC9D,CAAC;YACD,OAAO,KAAK,CAAC;QACf,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAC5B,OAAO,OAAO,CAAC;IACjB,CAAC;IAEO,KAAK,CAAC,WAAW,CACvB,OAAyC;QAEzC,IAAI,CAAC;YACH,OAAO,MAAM,OAAO,EAAE,CAAC;QACzB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,CAAC,IAAI,CACT,0BAA0B,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CACnF,CAAC;YACF,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;CACF"}
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"disk.test.d.ts","sourceRoot":"","sources":["../../../../../src/azure/pricing/resolvers/__tests__/disk.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
|