From 03dc81bdb31770989e93cea1f8175a5e1ed1733e Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 8 Dec 2024 14:42:00 +0200 Subject: [PATCH 1/4] added reservation functions --- src/sempy_labs/_capacities.py | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/sempy_labs/_capacities.py b/src/sempy_labs/_capacities.py index 6a2f1ba8..7b451c9a 100644 --- a/src/sempy_labs/_capacities.py +++ b/src/sempy_labs/_capacities.py @@ -688,3 +688,159 @@ def create_resource_group( print( f"{icons.green_dot} The '{resource_group}' resource group has been created within the '{region}' region within the '{azure_subscription_id}' Azure subscription." ) + + +def list_reservation_orders(token_provider: str) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/reserved-vm-instances/reservation-order/list?view=rest-reserved-vm-instances-2022-11-01 + + url = "https://management.azure.com/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" + + headers = { + "Authorization": f"Bearer {token}", # How to generate headers here? + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + df = pd.DataFrame( + columns=[ + "Order Id", + "Order Type", + "Order Name", + "Etag", + "Display Name", + "Request Date Time", + "Created Date Time", + "Benefit Start Time", + "Expiry Date", + "Expiry Date Time", + "Term", + "Billing Plan", + "Provisioning State", + "Reservations", + "Original Quantity", + ] + ) + + for v in response.json().get("value"): + p = v.get("properties", {}) + new_data = { + "Order Id": v.get("id"), + "Order Type": v.get("type"), + "Order Name": v.get("name"), + "Etag": v.get("etag"), + "Display Name": p.get("displayName"), + "Request Date Time": p.get("requestDateTime"), + "Created Date Time": p.get("createdDateTime"), + "Benefit Start Time": p.get("benefitStartTime"), + "Expiry Date": p.get("expiryDate"), + "Expiry Date Time": p.get("expiryDateTime"), + "Term": p.get("term"), + "Billing Plan": p.get("billingPlan"), + "Provisioning State": p.get("provisioningState"), + "Reservations": [ + reservation.get("id") for reservation in p.get("reservations", []) + ], + "Original Quantity": p.get("originalQuanitity"), + } + + df = pd.concat([df, pd.DataFrame(new_data, index=[0])], ignore_index=True) + + df["Request Date Time"] = pd.to_datetime(df["Request Date Time"]) + df["Created Date Time"] = pd.to_datetime(df["Created Date Time"]) + df["Benefit Start Time"] = pd.to_datetime(df["Benefit Start Time"]) + df["Expiry Date Time"] = pd.to_datetime(df["Expiry Date Time"]) + + return df + + +def list_reservation_transactions(billing_account_id: str) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/consumption/reservation-transactions/list?view=rest-consumption-2024-08-01 + + url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{billing_account_id}/providers/Microsoft.Consumption/reservationTransactions?api-version=2024-08-01" + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + +def list_reservations( + reservation_id: str, + reservation_order_id: str, + grain="monthly", + filter: Optional[str] = None, +) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/consumption/reservations-summaries/list-by-reservation-order-and-reservation?view=rest-consumption-2024-08-01 + + grain_options = ["monthly", "daily"] + if grain not in grain_options: + raise ValueError( + f"{icons.red_dot} Invalid grain. Valid options: {grain_options}." + ) + + if filter is None and grain == "daily": + raise ValueError( + f"{icons.red_dot} The 'filter' parameter is required for daily grain." + ) + + url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationorders/{reservation_order_id}/reservations/{reservation_id}/providers/Microsoft.Consumption/reservationSummaries?grain={grain}" + + if filter is not None: + url += f"&$filter={filter}" + + url += "&api-version=2024-08-01" + + df = pd.DataFrame(columns=[]) + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + for v in response.json().get("value", []): + p = v.get("properties", {}) + new_data = { + "Reservation Summary Id": v.get("id"), + "Reservation Summary Name": v.get("name"), + "Type": v.get("type"), + "Tags": v.get("tags"), + "Reservation Order Id": p.get("reservationOrderId"), + "Reservation Id": p.get("reservationId"), + "Sku Name": p.get("skuName"), + "Kind": p.get("kind"), + "Reserved Hours": p.get("reservedHours"), + "Usage Date": p.get("usageDate"), + "Used Hours": p.get("usedHours"), + "Min Utilization Percentage": p.get("minUtilizationPercentage"), + "Avg Utilization Percentage": p.get("avgUtilizationPercentage"), + "Max Utilization Percentage": p.get("maxUtilizationPercentage"), + "Purchased Quantity": p.get("purchasedQuantity"), + "Remaining Quantity": p.get("remainingQuantity"), + "Total Reserved Quantity": p.get("totalReservedQuantity"), + "Used Quantity": p.get("usedQuantity"), + "Utilized Percentage": p.get("utilizedPercentage"), + } + + df = pd.concat([df, pd.DataFrame(new_data, index=[0])], ignore_index=True) + + int_cols = [ + "Reserved Hours", + "Used Hours", + "Min Utilization Percentage", + "Avg Utilization Percentage", + "Max Utilization Percentage", + "Purchased Quantity", + "Remaining Quanity", + "Total Reserved Quantity", + "Used Quantity", + "Utilized Percentage", + ] + + df[int_cols] = df[int_cols].astype(int) From 557cb5b732f3d913f15b53cefe4fd708030427e4 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 8 Dec 2024 14:50:31 +0200 Subject: [PATCH 2/4] buy_reservation --- src/sempy_labs/_capacities.py | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/sempy_labs/_capacities.py b/src/sempy_labs/_capacities.py index 7b451c9a..2716bb7f 100644 --- a/src/sempy_labs/_capacities.py +++ b/src/sempy_labs/_capacities.py @@ -844,3 +844,43 @@ def list_reservations( ] df[int_cols] = df[int_cols].astype(int) + + +def buy_reservation( + reservation_order_id: str, + name: str, + sku: str, + region: str, + billing_scope_id: str, + term: str, + billing_plan: str, + quantity: int, +): + + # https://learn.microsoft.com/en-us/rest/api/reserved-vm-instances/reservation-order/purchase?view=rest-reserved-vm-instances-2022-11-01&tabs=HTTP + + url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/{reservation_order_id}?api-version=2022-11-01" + + payload = { + "sku": { + "name": sku, + }, + "location": region, + "properties": { + "reservedResourceType": "VirtualMachines", + "billingScopeId": billing_scope_id, + "term": term, + "billingPlan": billing_plan, + "quantity": quantity, + "displayName": name, + "appliedScopes": None, + "appliedScopeType": "Shared", + "reservedResourceProperties": {"instanceFlexibility": "On"}, + "renew": False, + }, + } + + response = requests.put(url, headers=headers, json=payload) + + if response.status_code not in [200, 202]: + raise FabricHTTPException(response) From d78fa725f8d64b0de335147fa1d62bb6ca6bac30 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 8 Dec 2024 16:43:38 +0200 Subject: [PATCH 3/4] moved to _reservations.py --- src/sempy_labs/_reservations.py | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/sempy_labs/_reservations.py diff --git a/src/sempy_labs/_reservations.py b/src/sempy_labs/_reservations.py new file mode 100644 index 00000000..7333ee4b --- /dev/null +++ b/src/sempy_labs/_reservations.py @@ -0,0 +1,201 @@ +from typing import Optional +import sempy_labs._icons as icons +from sempy.fabric.exceptions import FabricHTTPException +import requests +import pandas as pd + + +def list_reservation_orders(token_provider: str) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/reserved-vm-instances/reservation-order/list?view=rest-reserved-vm-instances-2022-11-01 + + url = "https://management.azure.com/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" + + headers = { + "Authorization": f"Bearer {token}", # How to generate headers here? + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + df = pd.DataFrame( + columns=[ + "Order Id", + "Order Type", + "Order Name", + "Etag", + "Display Name", + "Request Date Time", + "Created Date Time", + "Benefit Start Time", + "Expiry Date", + "Expiry Date Time", + "Term", + "Billing Plan", + "Provisioning State", + "Reservations", + "Original Quantity", + ] + ) + + for v in response.json().get("value"): + p = v.get("properties", {}) + new_data = { + "Order Id": v.get("id"), + "Order Type": v.get("type"), + "Order Name": v.get("name"), + "Etag": v.get("etag"), + "Display Name": p.get("displayName"), + "Request Date Time": p.get("requestDateTime"), + "Created Date Time": p.get("createdDateTime"), + "Benefit Start Time": p.get("benefitStartTime"), + "Expiry Date": p.get("expiryDate"), + "Expiry Date Time": p.get("expiryDateTime"), + "Term": p.get("term"), + "Billing Plan": p.get("billingPlan"), + "Provisioning State": p.get("provisioningState"), + "Reservations": [ + reservation.get("id") for reservation in p.get("reservations", []) + ], + "Original Quantity": p.get("originalQuanitity"), + } + + df = pd.concat([df, pd.DataFrame(new_data, index=[0])], ignore_index=True) + + df["Request Date Time"] = pd.to_datetime(df["Request Date Time"]) + df["Created Date Time"] = pd.to_datetime(df["Created Date Time"]) + df["Benefit Start Time"] = pd.to_datetime(df["Benefit Start Time"]) + df["Expiry Date Time"] = pd.to_datetime(df["Expiry Date Time"]) + + return df + + +def list_reservation_transactions(billing_account_id: str) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/consumption/reservation-transactions/list?view=rest-consumption-2024-08-01 + + url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{billing_account_id}/providers/Microsoft.Consumption/reservationTransactions?api-version=2024-08-01" + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + +def list_reservations( + reservation_id: str, + reservation_order_id: str, + grain="monthly", + filter: Optional[str] = None, +) -> pd.DataFrame: + + # https://learn.microsoft.com/rest/api/consumption/reservations-summaries/list-by-reservation-order-and-reservation?view=rest-consumption-2024-08-01 + + grain_options = ["monthly", "daily"] + if grain not in grain_options: + raise ValueError( + f"{icons.red_dot} Invalid grain. Valid options: {grain_options}." + ) + + if filter is None and grain == "daily": + raise ValueError( + f"{icons.red_dot} The 'filter' parameter is required for daily grain." + ) + + url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationorders/{reservation_order_id}/reservations/{reservation_id}/providers/Microsoft.Consumption/reservationSummaries?grain={grain}" + + if filter is not None: + url += f"&$filter={filter}" + + url += "&api-version=2024-08-01" + + df = pd.DataFrame(columns=[]) + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise FabricHTTPException(response) + + for v in response.json().get("value", []): + p = v.get("properties", {}) + new_data = { + "Reservation Summary Id": v.get("id"), + "Reservation Summary Name": v.get("name"), + "Type": v.get("type"), + "Tags": v.get("tags"), + "Reservation Order Id": p.get("reservationOrderId"), + "Reservation Id": p.get("reservationId"), + "Sku Name": p.get("skuName"), + "Kind": p.get("kind"), + "Reserved Hours": p.get("reservedHours"), + "Usage Date": p.get("usageDate"), + "Used Hours": p.get("usedHours"), + "Min Utilization Percentage": p.get("minUtilizationPercentage"), + "Avg Utilization Percentage": p.get("avgUtilizationPercentage"), + "Max Utilization Percentage": p.get("maxUtilizationPercentage"), + "Purchased Quantity": p.get("purchasedQuantity"), + "Remaining Quantity": p.get("remainingQuantity"), + "Total Reserved Quantity": p.get("totalReservedQuantity"), + "Used Quantity": p.get("usedQuantity"), + "Utilized Percentage": p.get("utilizedPercentage"), + } + + df = pd.concat([df, pd.DataFrame(new_data, index=[0])], ignore_index=True) + + int_cols = [ + "Reserved Hours", + "Used Hours", + "Min Utilization Percentage", + "Avg Utilization Percentage", + "Max Utilization Percentage", + "Purchased Quantity", + "Remaining Quanity", + "Total Reserved Quantity", + "Used Quantity", + "Utilized Percentage", + ] + + df[int_cols] = df[int_cols].astype(int) + + +def buy_reservation( + reservation_order_id: str, + name: str, + sku: str, + region: str, + billing_scope_id: str, + term: str, + billing_plan: str, + quantity: int, +): + + # https://learn.microsoft.com/en-us/rest/api/reserved-vm-instances/reservation-order/purchase?view=rest-reserved-vm-instances-2022-11-01&tabs=HTTP + + url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/{reservation_order_id}?api-version=2022-11-01" + + payload = { + "sku": { + "name": sku, + }, + "location": region, + "properties": { + "reservedResourceType": "VirtualMachines", + "billingScopeId": billing_scope_id, + "term": term, + "billingPlan": billing_plan, + "quantity": quantity, + "displayName": name, + "appliedScopes": None, + "appliedScopeType": "Shared", + "reservedResourceProperties": {"instanceFlexibility": "On"}, + "renew": False, + }, + } + + response = requests.put(url, headers=headers, json=payload) + + if response.status_code not in [200, 202]: + raise FabricHTTPException(response) From bc13ed4f84e10ae12e36fad1d415738f5b948f1b Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 5 Jan 2025 10:25:44 +0200 Subject: [PATCH 4/4] updated functions to include token provider --- src/sempy_labs/_authentication.py | 19 +++++++++++++ src/sempy_labs/_reservations.py | 45 ++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/sempy_labs/_authentication.py b/src/sempy_labs/_authentication.py index 340eecff..2a20ab77 100644 --- a/src/sempy_labs/_authentication.py +++ b/src/sempy_labs/_authentication.py @@ -106,3 +106,22 @@ def __call__(self, audience: Literal["pbi", "storage"] = "pbi") -> str: return self.credential.get_token("https://storage.azure.com/.default").token else: raise NotImplementedError + + +def _get_headers( + token_provider: str, audience: Literal["pbi", "storage", "azure", "graph"] = "azure" +): + """ + Generates headers for an API request. + """ + + token = token_provider(audience=audience) + + headers = {"Authorization": f"Bearer {token}"} + + if audience == "graph": + headers["ConsistencyLevel"] = "eventual" + else: + headers["Content-Type"] = "application/json" + + return headers diff --git a/src/sempy_labs/_reservations.py b/src/sempy_labs/_reservations.py index 7333ee4b..597bf32a 100644 --- a/src/sempy_labs/_reservations.py +++ b/src/sempy_labs/_reservations.py @@ -3,19 +3,16 @@ from sempy.fabric.exceptions import FabricHTTPException import requests import pandas as pd +from sempy.fabric._token_provider import TokenProvider +from sempy_labs._authentication import _get_headers -def list_reservation_orders(token_provider: str) -> pd.DataFrame: +def list_reservation_orders(token_provider: TokenProvider) -> pd.DataFrame: # https://learn.microsoft.com/rest/api/reserved-vm-instances/reservation-order/list?view=rest-reserved-vm-instances-2022-11-01 url = "https://management.azure.com/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" - - headers = { - "Authorization": f"Bearer {token}", # How to generate headers here? - "Content-Type": "application/json", - } - + headers = _get_headers(token_provider=token_provider, audience="azure") response = requests.get(url, headers=headers) if response.status_code != 200: @@ -73,21 +70,29 @@ def list_reservation_orders(token_provider: str) -> pd.DataFrame: return df -def list_reservation_transactions(billing_account_id: str) -> pd.DataFrame: +def list_reservation_transactions( + billing_account_id: str, token_provider: TokenProvider +) -> pd.DataFrame: # https://learn.microsoft.com/rest/api/consumption/reservation-transactions/list?view=rest-consumption-2024-08-01 url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{billing_account_id}/providers/Microsoft.Consumption/reservationTransactions?api-version=2024-08-01" + headers = _get_headers(token_provider=token_provider, audience="azure") response = requests.get(url, headers=headers) if response.status_code != 200: raise FabricHTTPException(response) + df = pd.DataFrame(columns=[]) + + return df + def list_reservations( reservation_id: str, reservation_order_id: str, + token_provider: TokenProvider, grain="monthly", filter: Optional[str] = None, ) -> pd.DataFrame: @@ -105,15 +110,16 @@ def list_reservations( f"{icons.red_dot} The 'filter' parameter is required for daily grain." ) - url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationorders/{reservation_order_id}/reservations/{reservation_id}/providers/Microsoft.Consumption/reservationSummaries?grain={grain}" + url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationorders/{reservation_order_id}/reservations/{reservation_id}/providers/Microsoft.Consumption/reservationSummaries?grain={grain}&" if filter is not None: - url += f"&$filter={filter}" + url += f"$filter={filter}&" - url += "&api-version=2024-08-01" + url += "api-version=2024-08-01" df = pd.DataFrame(columns=[]) + headers = _get_headers(token_provider=token_provider, audience="azure") response = requests.get(url, headers=headers) if response.status_code != 200: @@ -160,6 +166,8 @@ def list_reservations( df[int_cols] = df[int_cols].astype(int) + return df + def buy_reservation( reservation_order_id: str, @@ -170,9 +178,13 @@ def buy_reservation( term: str, billing_plan: str, quantity: int, + token_provider: TokenProvider, + renew: bool = False, + applied_scope_type: str = "Shared", + instance_flexibility: str = "On", ): - # https://learn.microsoft.com/en-us/rest/api/reserved-vm-instances/reservation-order/purchase?view=rest-reserved-vm-instances-2022-11-01&tabs=HTTP + # https://learn.microsoft.com/rest/api/reserved-vm-instances/reservation-order/purchase?view=rest-reserved-vm-instances-2022-11-01&tabs=HTTP url = f"https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/{reservation_order_id}?api-version=2022-11-01" @@ -189,13 +201,14 @@ def buy_reservation( "quantity": quantity, "displayName": name, "appliedScopes": None, - "appliedScopeType": "Shared", - "reservedResourceProperties": {"instanceFlexibility": "On"}, - "renew": False, + "appliedScopeType": applied_scope_type, + "reservedResourceProperties": {"instanceFlexibility": instance_flexibility}, + "renew": renew, }, } - response = requests.put(url, headers=headers, json=payload) + headers = _get_headers(token_provider=token_provider, audience="azure") + response = requests.get(url, headers=headers, json=payload) if response.status_code not in [200, 202]: raise FabricHTTPException(response)