diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index d56f5087a..ff121f3a5 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -255,6 +255,7 @@ export class HttpHeaders { public static partitionKey: string = "x-ms-documentdb-partitionkey"; public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput"; public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot"; + public static xAPIKey: string = "X-API-Key"; } export class ContentType { diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 061f25286..05d859368 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -42,6 +42,10 @@ export interface ConfigContext { ARM_API_VERSION: string; GRAPH_ENDPOINT: string; GRAPH_API_VERSION: string; + // This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP + CATALOG_ENDPOINT: string; + CATALOG_API_VERSION: string; + CATALOG_API_KEY: string; ARCADIA_ENDPOINT: string; ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string; BACKEND_ENDPOINT?: string; @@ -93,6 +97,9 @@ let configContext: Readonly = { ARM_API_VERSION: "2016-06-01", GRAPH_ENDPOINT: "https://graph.microsoft.com", GRAPH_API_VERSION: "1.6", + CATALOG_ENDPOINT: "https://catalogapi.azure.com/", + CATALOG_API_VERSION: "2023-05-01-preview", + CATALOG_API_KEY: "", ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net", ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net", GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306 diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index af69452f1..04afc10bb 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -425,6 +425,7 @@ export interface SelfServeFrameInputs { authorizationToken: string; csmEndpoint: string; flights?: readonly string[]; + catalogAPIKey: string; } export class MonacoEditorSettings { diff --git a/src/SelfServe/SelfServe.tsx b/src/SelfServe/SelfServe.tsx index f663a3ef9..932951b34 100644 --- a/src/SelfServe/SelfServe.tsx +++ b/src/SelfServe/SelfServe.tsx @@ -117,6 +117,7 @@ const handleMessage = async (event: MessageEvent): Promise => { updateConfigContext({ ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT), + CATALOG_API_KEY: inputs.catalogAPIKey, }); updateUserContext({ diff --git a/src/SelfServe/SqlX/SqlX.rp.ts b/src/SelfServe/SqlX/SqlX.rp.ts index 9cb430971..66675f7a6 100644 --- a/src/SelfServe/SqlX/SqlX.rp.ts +++ b/src/SelfServe/SqlX/SqlX.rp.ts @@ -1,11 +1,15 @@ import { configContext } from "../../ConfigContext"; import { userContext } from "../../UserContext"; -import { armRequestWithoutPolling } from "../../Utils/arm/request"; +import { get } from "../../Utils/arm/generatedClients/cosmos/locations"; +import { armRequestWithoutPolling, getOfferingIdsRequest } from "../../Utils/arm/request"; import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor"; import { RefreshResult } from "../SelfServeTypes"; import SqlX from "./SqlX"; import { FetchPricesResponse, + GetOfferingIdsResponse, + OfferingIdMap, + OfferingIdRequest, PriceMapAndCurrencyCode, RegionItem, RegionsResponse, @@ -166,11 +170,21 @@ export const getRegions = async (): Promise> => { } }; +export const getRegionShortName = async (regionDisplayName: string): Promise => { + const locationsList = await get(userContext.subscriptionId, regionDisplayName); + + if ("id" in locationsList) { + const locationId = locationsList.id; + return locationId.substring(locationId.lastIndexOf("/") + 1); + } + return undefined; +}; + const getFetchPricesPathForRegion = (subscriptionId: string): string => { return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`; }; -export const getPriceMapAndCurrencyCode = async (regions: Array): Promise => { +export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise => { const telemetryData = { feature: "Calculate approximate cost", function: "getPriceMapAndCurrencyCode", @@ -181,39 +195,94 @@ export const getPriceMapAndCurrencyCode = async (regions: Array): Pr try { const priceMap = new Map>(); - let currencyCode; - for (const regionItem of regions) { + let billingCurrency; + for (const region of map.keys()) { const regionPriceMap = new Map(); + const regionShortName = await getRegionShortName(region); + const requestBody: OfferingIdRequest = { + location: regionShortName, + ids: Array.from(map.get(region).keys()), + }; const response = await armRequestWithoutPolling({ host: configContext.ARM_ENDPOINT, path: getFetchPricesPathForRegion(userContext.subscriptionId), method: "POST", - apiVersion: "2020-01-01-preview", - queryParams: { - filter: - "armRegionName eq '" + - regionItem.locationName.split(" ").join("").toLowerCase() + - "' and serviceFamily eq 'Databases' and productName eq 'Azure Cosmos DB Dedicated Gateway - General Purpose'", - }, + apiVersion: "2023-04-01-preview", + body: requestBody, }); - for (const item of response.result.Items) { - if (currencyCode === undefined) { - currencyCode = item.currencyCode; - } else if (item.currencyCode !== currencyCode) { + for (const item of response.result) { + if (item.error) { + continue; + } + + if (billingCurrency === undefined) { + billingCurrency = item.billingCurrency; + } else if (item.billingCurrency !== billingCurrency) { throw Error("Currency Code Mismatch: Currency code not same for all regions / skus."); } - regionPriceMap.set(item.skuName, item.retailPrice); + + const offeringId = item.id; + const skuName = map.get(region).get(offeringId); + const unitPriceinBillingCurrency = item.prices.find((x) => x.type === "Consumption") + ?.unitPriceinBillingCurrency; + regionPriceMap.set(skuName, unitPriceinBillingCurrency); } - priceMap.set(regionItem.locationName, regionPriceMap); + priceMap.set(region, regionPriceMap); } selfServeTraceSuccess(telemetryData, getPriceMapAndCurrencyCodeTimestamp); - return { priceMap: priceMap, currencyCode: currencyCode }; + return { priceMap: priceMap, billingCurrency: billingCurrency }; } catch (err) { const failureTelemetry = { err, selfServeClassName: SqlX.name }; selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp); - return { priceMap: undefined, currencyCode: undefined }; + return { priceMap: undefined, billingCurrency: undefined }; + } +}; + +const getOfferingIdPathForRegion = (): string => { + return `/skus?serviceFamily=Databases&service=Azure Cosmos DB`; +}; + +export const getOfferingIds = async (regions: Array): Promise => { + const telemetryData = { + feature: "Get Offering Ids to calculate approximate cost", + function: "getOfferingIds", + description: "fetch offering ids API call", + selfServeClassName: SqlX.name, + }; + const getOfferingIdsCodeTimestamp = selfServeTraceStart(telemetryData); + + try { + const offeringIdMap = new Map>(); + for (const regionItem of regions) { + const regionOfferingIdMap = new Map(); + const regionShortName = await getRegionShortName(regionItem.locationName); + + const response = await getOfferingIdsRequest({ + host: configContext.CATALOG_ENDPOINT, + path: getOfferingIdPathForRegion(), + method: "GET", + apiVersion: "2023-05-01-preview", + queryParams: { + filter: "armRegionName eq '" + regionShortName + "'", + }, + }); + + for (const item of response.result.items) { + if (item.offeringProperties?.length > 0) { + regionOfferingIdMap.set(item.offeringProperties[0].offeringId, item.skuName); + } + } + offeringIdMap.set(regionItem.locationName, regionOfferingIdMap); + } + + selfServeTraceSuccess(telemetryData, getOfferingIdsCodeTimestamp); + return offeringIdMap; + } catch (err) { + const failureTelemetry = { err, selfServeClassName: SqlX.name }; + selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp); + return undefined; } }; diff --git a/src/SelfServe/SqlX/SqlX.tsx b/src/SelfServe/SqlX/SqlX.tsx index 8f2954865..cbac4ef04 100644 --- a/src/SelfServe/SqlX/SqlX.tsx +++ b/src/SelfServe/SqlX/SqlX.tsx @@ -24,6 +24,7 @@ import { BladeType, generateBladeLink } from "../SelfServeUtils"; import { deleteDedicatedGatewayResource, getCurrentProvisioningState, + getOfferingIds, getPriceMapAndCurrencyCode, getRegions, refreshDedicatedGatewayProvisioning, @@ -370,9 +371,10 @@ export default class SqlX extends SelfServeBaseClass { }); regions = await getRegions(); - const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(regions); + const offeringIdMap = await getOfferingIds(regions); + const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(offeringIdMap); priceMap = priceMapAndCurrencyCode.priceMap; - currencyCode = priceMapAndCurrencyCode.currencyCode; + currencyCode = priceMapAndCurrencyCode.billingCurrency; const response = await getCurrentProvisioningState(); if (response.status && response.status !== "Deleting") { diff --git a/src/SelfServe/SqlX/SqlxTypes.ts b/src/SelfServe/SqlX/SqlxTypes.ts index 3360df734..d06b1844d 100644 --- a/src/SelfServe/SqlX/SqlxTypes.ts +++ b/src/SelfServe/SqlX/SqlxTypes.ts @@ -30,23 +30,51 @@ export type UpdateDedicatedGatewayRequestProperties = { serviceType: string; }; -export type FetchPricesResponse = { - Items: Array; - NextPageLink: string | undefined; - Count: number; +export type FetchPricesResponse = Array; + +export type PriceItem = { + prices: Array; + id: string; + billingCurrency: string; + error: PriceError; +}; + +export type PriceType = { + type: string; + unitPriceinBillingCurrency: number; +}; + +export type PriceError = { + type: string; + description: string; }; export type PriceMapAndCurrencyCode = { priceMap: Map>; - currencyCode: string; + billingCurrency: string; }; -export type PriceItem = { - retailPrice: number; - skuName: string; - currencyCode: string; +export type GetOfferingIdsResponse = { + items: Array; + nextPageLink: string | undefined; }; +export type OfferingIdItem = { + skuName: string; + offeringProperties: Array; +}; + +export type OfferingProperties = { + offeringId: string; +}; + +export type OfferingIdRequest = { + ids: Array; + location: string; +}; + +export type OfferingIdMap = Map>; + export type RegionsResponse = { properties: RegionsProperties; }; diff --git a/src/Utils/arm/request.ts b/src/Utils/arm/request.ts index 69391201b..399900ca3 100644 --- a/src/Utils/arm/request.ts +++ b/src/Utils/arm/request.ts @@ -160,3 +160,52 @@ async function getOperationStatus(operationStatusUrl: string) { } throw new Error(`Operation Response: ${JSON.stringify(body)}. Retrying.`); } + +export async function getOfferingIdsRequest({ + host, + path, + apiVersion, + method, + body: requestBody, + queryParams, +}: Options): Promise<{ result: T; operationStatusUrl: string }> { + const url = new URL(path, host); + url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion); + if (queryParams) { + queryParams.filter && url.searchParams.append("$filter", queryParams.filter); + queryParams.metricNames && url.searchParams.append("metricnames", queryParams.metricNames); + } + + if (!configContext.CATALOG_API_KEY) { + throw new Error("No catalog API key provided"); + } + + const response = await window.fetch(url.href, { + method, + headers: { + [HttpHeaders.xAPIKey]: configContext.CATALOG_API_KEY, + }, + body: requestBody ? JSON.stringify(requestBody) : undefined, + }); + if (!response.ok) { + let error: ARMError; + try { + const errorResponse = (await response.json()) as ParsedErrorResponse; + if ("error" in errorResponse) { + error = new ARMError(errorResponse.error.message); + error.code = errorResponse.error.code; + } else { + error = new ARMError(errorResponse.message); + error.code = errorResponse.code; + } + } catch (error) { + throw new Error(await response.text()); + } + + throw error; + } + + const operationStatusUrl = (response.headers && response.headers.get("location")) || ""; + const responseBody = (await response.json()) as T; + return { result: responseBody, operationStatusUrl: operationStatusUrl }; +}