Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

handle unsupported azure subscriptions in cost reporting #2823

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
}