Skip to content

Commit

Permalink
handle unsupported azure subscriptions in cost reporting (#2823)
Browse files Browse the repository at this point in the history
* handle unsupported azure subscriptions in cost reporting
  • Loading branch information
guybartal authored Nov 7, 2022
1 parent 7e0c1ea commit d35ca3a
Show file tree
Hide file tree
Showing 9 changed files with 74 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ BUG FIXES:
* Show the correct createdBy value for airlock requests in UI and in API queries ([#2779](https://github.com/microsoft/AzureTRE/pull/2779))
* Fix deployment of Airlock Notifier ([#2745](https://github.com/microsoft/AzureTRE/pull/2745))
* Fix Nexus bootstrapping firewall race condition ([#2811](https://github.com/microsoft/AzureTRE/pull/2811))
* Handle unsupported azure subscriptions in cost reporting ([#2823](https://github.com/microsoft/AzureTRE/pull/2823))

COMPONENTS:

Expand Down
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.11"
__version__ = "0.5.12"
11 changes: 8 additions & 3 deletions api_app/api/routes/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from models.domain.costs import CostReport, GranularityEnum, WorkspaceCostReport
from resources import strings
from services.authentication import get_current_admin_user, get_current_workspace_owner_or_tre_admin
from services.cost_service import CostService, WorkspaceDoesNotExist
from services.cost_service import CostService, SubscriptionNotSupported, WorkspaceDoesNotExist

costs_core_router = APIRouter(dependencies=[Depends(get_current_admin_user)])
costs_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_tre_admin)])
Expand Down Expand Up @@ -57,8 +57,11 @@ async def costs(
shared_services_repo=Depends(get_repository(SharedServiceRepository))) -> CostReport:

validate_report_period(params.from_date, params.to_date)
return cost_service.query_tre_costs(
config.TRE_ID, params.granularity, params.from_date, params.to_date, workspace_repo, shared_services_repo)
try:
return cost_service.query_tre_costs(
config.TRE_ID, params.granularity, params.from_date, params.to_date, workspace_repo, shared_services_repo)
except SubscriptionNotSupported:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strings.API_GET_COSTS_SUBSCRIPTION_NOT_SUPPORTED)


@costs_workspace_router.get("/workspaces/{workspace_id}/costs", response_model=WorkspaceCostReport,
Expand All @@ -78,3 +81,5 @@ async def workspace_costs(workspace_id: UUID4, params: CostsQueryParams = Depend
workspace_repo, workspace_services_repo, user_resource_repo)
except WorkspaceDoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strings.WORKSPACE_DOES_NOT_EXIST)
except SubscriptionNotSupported:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strings.API_GET_COSTS_SUBSCRIPTION_NOT_SUPPORTED)
1 change: 1 addition & 0 deletions api_app/resources/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
API_GET_COSTS_MAX_TIME_PERIOD = "The time period for pulling the data cannot exceed 1 year"
API_GET_COSTS_TO_DATE_NEED_TO_BE_LATER_THEN_FROM_DATE = "to_date needs to be later than from_date"
API_GET_COSTS_FROM_DATE_NEED_TO_BE_BEFORE_TO_DATE = "from_date needs to be before to_date"
API_GET_COSTS_SUBSCRIPTION_NOT_SUPPORTED = "Azure subscription doesn't support cost management"

# State store status
OK = "OK"
Expand Down
20 changes: 18 additions & 2 deletions api_app/services/cost_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from enum import Enum
from typing import Dict, Optional
import pandas as pd
import logging

from azure.mgmt.costmanagement import CostManagementClient
from azure.mgmt.costmanagement.models import QueryGrouping, QueryAggregation, QueryDataset, QueryDefinition, \
TimeframeType, ExportType, QueryTimePeriod, QueryFilter, QueryComparisonExpression, QueryResult
from azure.core.exceptions import ResourceNotFoundError

from azure.mgmt.resource import ResourceManagementClient

Expand Down Expand Up @@ -39,6 +41,10 @@ class WorkspaceDoesNotExist(Exception):
"""Raised when the workspace is not found by provided id"""


class SubscriptionNotSupported(Exception):
"""Raised when subscription does not support cost management"""


class CostService:
scope: str
client: CostManagementClient
Expand All @@ -52,7 +58,7 @@ class CostService:

def __init__(self):
self.scope = "/subscriptions/{}".format(config.SUBSCRIPTION_ID)
self.client = CostManagementClient(credential=credentials.get_credential())
self.client = CostManagementClient(credential=credentials.get_credential(), base_url="https://7b51abd8-dca0-4c5e-9f07-36ff74023532.mock.pstmn.io")
self.resource_client = ResourceManagementClient(credentials.get_credential(), config.SUBSCRIPTION_ID)

def query_tre_costs(self, tre_id, granularity: GranularityEnum, from_date: datetime, to_date: datetime,
Expand Down Expand Up @@ -225,7 +231,17 @@ def query_costs(self, tag_name: str, tag_value: str,
resource_groups: list) -> QueryResult:
query_definition = self.build_query_definition(granularity, from_date, to_date, tag_name, tag_value, resource_groups)

return self.client.query.usage(self.scope, query_definition)
try:
return self.client.query.usage(self.scope, query_definition)
except ResourceNotFoundError as e:
# when cost management API returns 404 with an message:
# Given subscription {subscription_id} doesn't have valid WebDirect/AIRS offer type.
# it means that the Azure subscription deosn't support cost management
if "doesn't have valid WebDirect/AIRS" in e.message:
logging.error("Subscription doesn't support cost mangement", exc_info=e)
raise SubscriptionNotSupported(e)
else:
raise e

def build_query_definition(self, granularity: GranularityEnum, from_date: Optional[datetime],
to_date: Optional[datetime], tag_name: str, tag_value: str, resource_groups: list):
Expand Down
30 changes: 29 additions & 1 deletion api_app/tests_ma/test_services/test_cost_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from models.domain.user_resource import UserResource
from models.domain.workspace import Workspace
from models.domain.workspace_service import WorkspaceService
from services.cost_service import CostService
from services.cost_service import CostService, SubscriptionNotSupported
from datetime import date, datetime, timedelta
from azure.mgmt.costmanagement.models import QueryResult, TimeframeType, QueryDefinition, QueryColumn
from azure.core.exceptions import ResourceNotFoundError


@patch('db.repositories.workspaces.WorkspaceRepository')
Expand Down Expand Up @@ -187,6 +188,33 @@ def test_query_tre_costs_with_granularity_none_and_missing_costs_data_returns_em
assert len(cost_report.workspaces[1].costs) == 0


@patch('db.repositories.workspaces.WorkspaceRepository')
@patch('db.repositories.shared_services.SharedServiceRepository')
@patch('services.cost_service.CostManagementClient')
@patch('services.cost_service.CostService.get_resource_groups_by_tag')
def test_query_tre_costs_for_unsupported_subscription_raises_subscription_not_supported_exception(get_resource_groups_by_tag_mock,
client_mock,
shared_service_repo_mock,
workspace_repo_mock):

client_mock.return_value.query.usage.side_effect = ResourceNotFoundError({
"error": {
"code": "NotFound",
"message": "Given subscription xxx doesn't have valid WebDirect/AIRS offer type. (Request ID: 12daa3b6-8a53-4759-97ba-511ece1ac95b)"
}
})

__set_shared_service_repo_mock_return_value(shared_service_repo_mock)
__set_workspace_repo_mock_get_active_workspaces_return_value(workspace_repo_mock)
__set_resource_group_by_tag_return_value(get_resource_groups_by_tag_mock)

cost_service = CostService()

with pytest.raises(SubscriptionNotSupported):
cost_service.query_tre_costs(
"guy22", GranularityEnum.none, datetime.now(), datetime.now(), workspace_repo_mock, shared_service_repo_mock)


@patch('db.repositories.workspaces.WorkspaceRepository')
@patch('db.repositories.shared_services.SharedServiceRepository')
@patch('services.cost_service.CostManagementClient')
Expand Down
5 changes: 4 additions & 1 deletion docs/azure-tre-overview/cost-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ GET /api/workspaces/{workspace_id}/costs

* Once cost data becomes available in Cost Management, it will be retained for at least seven years. Only the last 13 months is available from the TRE Cost API and Azure Portal. For historical data before 13 months, please use [Exports](https://docs.microsoft.com/en-us/azure/cost-management-billing/costs/tutorial-export-acm-data?tabs=azure-portal) or the [UsageDetails API](https://docs.microsoft.com/en-us/rest/api/consumption/usage-details/list?tabs=HTTP).

* For more information please refer to [Azure Cost Management](https://docs.microsoft.com/en-us/azure/cost-management-billing/costs/understand-cost-mgt-data) and Cost API swagger docs.
* There are several Azure Offers that currently are not supported yet, Azure Offers are types of Azure subscriptions, for full list of supported and unspported Azure Offers please refer to [Supported Microsoft Azure offers](https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/understand-cost-mgt-data#supported-microsoft-azure-offers),
Azure TRE will not display costs for unsupported Azure subscriptions.

* For more information please refer to [Understand Cost Management data](https://docs.microsoft.com/en-us/azure/cost-management-billing/costs/understand-cost-mgt-data) and Cost API swagger docs.


## Azure Resources Tagging
Expand Down
12 changes: 10 additions & 2 deletions ui/app/src/components/root/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { APIError } from '../../models/exceptions';
import { ExceptionLayout } from '../shared/ExceptionLayout';
import { AppRolesContext } from '../../contexts/AppRolesContext';
import { CostsContext } from '../../contexts/CostsContext';
import config from "../../config.json";

export const RootLayout: React.FunctionComponent = () => {
const [workspaces, setWorkspaces] = useState([] as Array<Workspace>);
Expand Down Expand Up @@ -59,9 +60,16 @@ export const RootLayout: React.FunctionComponent = () => {
setLoadingCostState(LoadingState.Ok);
}
catch (e:any) {
e.userMessage = 'Error retrieving costs';
if (e instanceof APIError && e.status === 404) {
config.debug && console.warn(e.message);
setLoadingCostState(LoadingState.NotSupported);
}
else {
e.userMessage = 'Error retrieving costs';
setLoadingCostState(LoadingState.Error);
}

setCostApiError(e);
setLoadingCostState(LoadingState.Error);
}
};

Expand Down
3 changes: 2 additions & 1 deletion ui/app/src/models/loadingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export enum LoadingState {
Ok = 'ok',
Error = 'error',
Loading = 'loading',
AccessDenied = "access-denied"
AccessDenied = "access-denied",
NotSupported = "not-supported"
}

0 comments on commit d35ca3a

Please sign in to comment.