Skip to content

Commit 1baa863

Browse files
committed
NSOL-6228: fix: replace az CLI auth with DefaultAzureCredential and clean up ANF/GCNV modules
- client.py: swap AzureCliCredential + az subprocess for DefaultAzureCredential + SubscriptionClient; fix ANFClientManager cache invalidation (track _subscription_id, log on re-init) - replication_management.py: remove DEFAULT_SERVICE_LEVEL; resolve service level from pool when not provided; deduplicate throughput calculation via _calculate_min_throughput_mibps() in base.py - volume_management.py: replace inline throughput calc with _calculate_min_throughput_mibps() from base.py - base.py, config.py, snapshot_management.py: add trailing newlines; raise InvalidConfigError instead of bare Exception - netapp_dataops_anf_mcp.py: remove StandardZRS from service level docstrings; update destination_service_level description - setup.cfg: add azure-mgmt-resource-subscriptions to azure extras - setup_gcnv_auth.py: remove unnecessary f-strings and stray blank line
1 parent 12d4706 commit 1baa863

10 files changed

Lines changed: 119 additions & 123 deletions

File tree

netapp_dataops_traditional/netapp_dataops/mcp_server/netapp_dataops_anf_mcp.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async def create_volume_tool(
8686
Optional. The name of a delegated Azure subnet to construct the Azure Resource ID for a delegated subnet.
8787
Will use config default if not provided, otherwise defaults to "default".
8888
service_level (str):
89-
Optional. Service level (Standard, Premium, Ultra, StandardZRS, Flexible).
89+
Optional. Service level (Standard, Premium, Ultra, Flexible).
9090
Defaults to Premium.
9191
tags (Dict[str, str]):
9292
Optional. Resource tags.
@@ -303,7 +303,7 @@ async def clone_volume_tool(
303303
Optional. The name of a delegated Azure subnet to construct the Azure Resource ID for a delegated subnet.
304304
Will use config default if not provided, otherwise defaults to "default".
305305
service_level (str):
306-
Optional. Service level (Standard, Premium, Ultra, StandardZRS, Flexible).
306+
Optional. Service level (Standard, Premium, Ultra, Flexible).
307307
Defaults to Premium.
308308
protocol_types (List[str]):
309309
Optional. List of protocol types (NFSv3, NFSv4.1, CIFS). Will use config default if not provided.
@@ -680,8 +680,9 @@ async def create_replication_tool(
680680
destination_subnet_name (str):
681681
Optional. Destination subnet name. Defaults to "default".
682682
destination_service_level (str):
683-
Optional. Destination service level (Standard/Premium/Ultra).
684-
Defaults to "Premium".
683+
Optional. Destination service level (Standard/Premium/Ultra/Flexible).
684+
If not provided, it will be retrieved from the destination capacity pool.
685+
Must be one of: Standard, Premium, Ultra, Flexible.
685686
destination_zones (list):
686687
Optional. Availability zones for destination volume. If None, defaults to ["1"].
687688

netapp_dataops_traditional/netapp_dataops/traditional/anf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
"list_snapshots",
2424
"create_replication",
2525
"create_anf_config"
26-
]
26+
]

netapp_dataops_traditional/netapp_dataops/traditional/anf/base.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@
77
from typing import Any, Dict
88

99

10+
def _calculate_min_throughput_mibps(service_level: str, usage_threshold_bytes: int) -> float:
11+
"""
12+
Calculate the minimum throughput in MiB/s for a manual QoS volume based on
13+
service level and size.
14+
15+
Args:
16+
service_level (str): One of Standard, Premium, Ultra, Flexible.
17+
usage_threshold_bytes (int): Volume quota in bytes.
18+
19+
Returns:
20+
float: Minimum throughput in MiB/s.
21+
"""
22+
size_gib = usage_threshold_bytes / (1024 * 1024 * 1024)
23+
level = service_level.lower()
24+
if level == "standard":
25+
return max(16.0, size_gib * 0.016)
26+
elif level == "premium":
27+
return max(64.0, size_gib * 0.064)
28+
elif level == "ultra":
29+
return max(128.0, size_gib * 0.128)
30+
else:
31+
return 64.0
32+
33+
1034
def _validate_required_params(**params) -> None:
1135
"""Validate that all required parameters are provided.
1236
@@ -116,4 +140,4 @@ def _get_clean_error_message(exception: Exception) -> str:
116140
return str(msg)
117141

118142
# Fallback to full string representation for other exceptions
119-
return error_str
143+
return error_str
Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,96 @@
11
"""
22
NetApp DataOps Toolkit - Azure NetApp Files (ANF) Client Management
33
This module handles Azure client authentication and management for ANF operations.
4+
5+
Authentication is handled via DefaultAzureCredential, which automatically chains
6+
through the following methods (in order) without requiring environment variables:
7+
1. Managed Identity (production: Azure VMs, AKS, App Service, etc.)
8+
2. Workload Identity (Kubernetes with Azure AD Workload Identity)
9+
3. Azure CLI ('az login' for local development)
10+
4. Azure Developer CLI, VS Code, PowerShell, and others
11+
12+
No secrets or environment variables are required. For local development, run:
13+
az login --tenant <TENANT_ID>
14+
az account set --subscription <SUBSCRIPTION_ID>
415
"""
516

617
from typing import Tuple, Optional
7-
from azure.identity import AzureCliCredential
18+
from azure.identity import DefaultAzureCredential
819
from azure.mgmt.netapp import NetAppManagementClient
20+
from azure.mgmt.resource.subscriptions import SubscriptionClient
921
from azure.core.exceptions import ClientAuthenticationError
1022

1123
from netapp_dataops.logging_utils import setup_logger
1224

1325
logger = setup_logger(__name__)
1426

27+
1528
class ANFClientManager:
1629
"""Singleton class for managing Azure NetApp Files client connections."""
17-
30+
1831
_instance: Optional['ANFClientManager'] = None
1932
_client: Optional[NetAppManagementClient] = None
20-
33+
_subscription_id: Optional[str] = None
34+
2135
def __new__(cls) -> 'ANFClientManager':
2236
if cls._instance is None:
2337
cls._instance = super().__new__(cls)
2438
return cls._instance
25-
26-
def get_client(self, subscription_id: str, print_output: Optional[bool] = False) -> NetAppManagementClient:
39+
40+
def get_client(self, subscription_id: str, credential: DefaultAzureCredential, print_output: Optional[bool] = False) -> NetAppManagementClient:
2741
"""
28-
Get or create an authenticated NetApp Management Client.
42+
Get or create an authenticated NetApp Management Client using DefaultAzureCredential.
43+
Re-creates the client if the subscription ID has changed.
2944
"""
30-
if self._client is None or self._client._config.subscription_id != subscription_id:
31-
# Use AzureCliCredential to specifically respect 'az login' and 'az account set'
32-
credential = AzureCliCredential()
45+
if self._client is None or self._subscription_id != subscription_id:
46+
if self._client is not None and self._subscription_id != subscription_id:
47+
if print_output:
48+
logger.info(f"Subscription changed from '{self._subscription_id}' to '{subscription_id}'. Re-initializing ANF client.")
3349
try:
3450
self._client = NetAppManagementClient(credential, subscription_id)
51+
self._subscription_id = subscription_id
3552
except Exception as e:
3653
raise ClientAuthenticationError(f"Failed to initialize ANF client: {str(e)}")
37-
54+
3855
return self._client
3956

4057

4158
def get_anf_client(print_output: bool = False) -> Tuple[NetAppManagementClient, str]:
4259
"""
4360
Convenience function to get an authenticated ANF client.
44-
Automatically fetches the active Subscription ID from the 'az account show' context.
61+
62+
Uses DefaultAzureCredential for authentication and SubscriptionClient to
63+
resolve the active subscription ID — no dependency on the az CLI at runtime.
4564
"""
46-
import subprocess
47-
import json
48-
4965
try:
50-
# Get the currently active subscription from Azure CLI
51-
# This respects 'az account set --subscription <id>'
52-
result = subprocess.run(
53-
['az', 'account', 'show'],
54-
capture_output=True,
55-
text=True,
56-
check=True
57-
)
58-
59-
account_info = json.loads(result.stdout)
66+
credential = DefaultAzureCredential()
67+
68+
# Resolve the active subscription via the Azure SDK (no az CLI required)
69+
subscription_client = SubscriptionClient(credential)
70+
subscriptions = list(subscription_client.subscriptions.list())
6071

61-
if 'id' not in account_info:
72+
if not subscriptions:
6273
raise ClientAuthenticationError(
63-
"Azure CLI output did not contain a subscription 'id'. "
64-
"Please run: az login --tenant <TENANT_ID>"
74+
"No Azure subscriptions found for the authenticated identity. "
75+
"Ensure the credential has access to at least one subscription."
6576
)
6677

67-
subscription_id = account_info['id']
68-
78+
# Use the first available subscription (respects az account set via CLI credential)
79+
subscription = subscriptions[0]
80+
subscription_id = subscription.subscription_id
81+
6982
if print_output:
70-
subscription_name = account_info.get('name', 'unknown')
71-
logger.info(f"Connected to ANF via Active Subscription: {subscription_name} ({subscription_id})")
72-
73-
except subprocess.CalledProcessError as e:
74-
logger.error(f"Azure CLI returned error: {e.stderr}")
75-
raise ClientAuthenticationError(
76-
f"Failed to get active subscription from Azure CLI: {e.stderr}\n"
77-
"Please run: az login --tenant <TENANT_ID>"
78-
)
79-
except json.JSONDecodeError as e:
80-
logger.error(f"Azure CLI output not valid JSON: {str(e)}")
81-
raise ClientAuthenticationError(f"Failed to parse Azure CLI output: {str(e)}")
82-
except FileNotFoundError:
83-
logger.error("Azure CLI (az) not found in PATH")
83+
logger.info(f"Connected to ANF via Active Subscription: {subscription.display_name} ({subscription_id})")
84+
85+
except ClientAuthenticationError:
86+
raise
87+
except Exception as e:
8488
raise ClientAuthenticationError(
85-
"Azure CLI (az) not found. Please install it from: "
86-
"https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
89+
f"Failed to authenticate with Azure: {str(e)}\n"
90+
"Please run: az login or az login --tenant <TENANT_ID>\n"
8791
)
88-
89-
# Use AzureCliCredential with the detected subscription
9092
manager = ANFClientManager()
91-
client = manager.get_client(subscription_id, print_output=print_output)
92-
93+
client = manager.get_client(subscription_id, credential, print_output=print_output)
94+
9395
return client, subscription_id
96+

netapp_dataops_traditional/netapp_dataops/traditional/anf/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ def _get_service_level_from_pool(
447447
str: The service level of the capacity pool (Standard, Premium, or Ultra).
448448
449449
Raises:
450-
Exception: If there's an error retrieving the capacity pool information.
450+
InvalidConfigError: If there's an error retrieving the capacity pool information.
451451
"""
452452
try:
453453
# Import here to avoid circular dependency
@@ -480,4 +480,4 @@ def _get_service_level_from_pool(
480480
error_msg = f"Failed to retrieve service level from capacity pool '{pool_name}': {str(e)}"
481481
if print_output:
482482
logger.error(error_msg)
483-
raise Exception(error_msg)
483+
raise InvalidConfigError(error_msg)

netapp_dataops_traditional/netapp_dataops/traditional/anf/replication_management.py

Lines changed: 26 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from typing import Dict, List, Any
88
from azure.mgmt.netapp.models import AuthorizeRequest
99
from azure.core.exceptions import ResourceNotFoundError
10-
from .client import get_anf_client
11-
from .base import _serialize, _validate_required_params, _get_clean_error_message
10+
from .client import _get_anf_client
11+
from .base import _serialize, _validate_required_params, _get_clean_error_message, _calculate_min_throughput_mibps
1212
from .config import _retrieve_anf_config, _get_config_value, InvalidConfigError
1313

1414
from netapp_dataops.logging_utils import setup_logger
@@ -18,7 +18,6 @@
1818
# Constants for validation
1919
VALID_PROTOCOL_TYPES = ["NFSv3", "NFSv4.1", "CIFS"]
2020
VALID_SERVICE_LEVELS = ["Standard", "Premium", "Ultra", "Flexible"]
21-
DEFAULT_SERVICE_LEVEL = "Premium"
2221

2322
def create_replication(
2423
# Source volume parameters
@@ -78,8 +77,9 @@ def create_replication(
7877
destination_subnet_name (str):
7978
Optional. Destination subnet name. Defaults to "default".
8079
destination_service_level (str):
81-
Optional. Destination service level (Standard/Premium/Ultra).
82-
Defaults to "Premium".
80+
Optional. Destination service level (Standard/Premium/Ultra/Flexible).
81+
If not provided, it will be retrieved from the destination capacity pool.
82+
Must be one of: Standard, Premium, Ultra, Flexible.
8383
destination_zones (List[str]):
8484
Optional. Availability zones for destination volume. If None, defaults to ["1"].
8585
destination_throughput_mibps (float):
@@ -145,23 +145,13 @@ def create_replication(
145145
destination_virtual_network_name=destination_virtual_network_name
146146
)
147147

148-
# Set default service level if not provided
149-
if destination_service_level is None:
150-
destination_service_level = DEFAULT_SERVICE_LEVEL
151-
152148
# Validate protocol types
153149
invalid_protocols = [pt for pt in destination_protocol_types if pt not in VALID_PROTOCOL_TYPES]
154150
if invalid_protocols:
155151
error_message = f"Invalid protocol types: {invalid_protocols}. Valid options are: {VALID_PROTOCOL_TYPES}"
156152
logger.error(error_message)
157153
return {"status": "error", "details": error_message}
158154

159-
# Validate service level
160-
if destination_service_level not in VALID_SERVICE_LEVELS:
161-
error_message = f"Invalid service level: '{destination_service_level}'. Valid options are: {VALID_SERVICE_LEVELS}"
162-
logger.error(error_message)
163-
return {"status": "error", "details": error_message}
164-
165155
# Get ANF client and subscription ID (automatically retrieved from Azure CLI)
166156
client, subscription_id = get_anf_client(print_output=print_output)
167157

@@ -195,12 +185,22 @@ def create_replication(
195185
)
196186

197187
# If service level not provided, get it from destination pool
198-
if destination_service_level == DEFAULT_SERVICE_LEVEL:
188+
if destination_service_level is None:
199189
if hasattr(destination_pool, 'service_level') and destination_pool.service_level:
200-
destination_service_level = destination_pool.service_level
190+
destination_service_level = str(destination_pool.service_level).split('.')[-1].capitalize()
201191
if print_output:
202192
logger.info(f"Using service level from destination pool: {destination_service_level}")
203193

194+
# Validate service level after pool-lookup override
195+
if destination_service_level is None:
196+
error_message = "destination_service_level is required and could not be retrieved from the destination pool"
197+
logger.error(error_message)
198+
return {"status": "error", "details": error_message}
199+
if destination_service_level not in VALID_SERVICE_LEVELS:
200+
error_message = f"Invalid service level: '{destination_service_level}'. Valid options are: {VALID_SERVICE_LEVELS}"
201+
logger.error(error_message)
202+
return {"status": "error", "details": error_message}
203+
204204
destination_qos_type = destination_pool.qos_type
205205

206206
# Extract QoS type value (e.g., "manual" from "QosType.MANUAL")
@@ -227,30 +227,16 @@ def create_replication(
227227
logger.info(f"Using throughput_mibps from source volume: {destination_throughput_mibps}")
228228
else:
229229
# Calculate minimum throughput based on service level and size
230-
size_gib = destination_usage_threshold / (1024 * 1024 * 1024)
231-
if destination_service_level.lower() == "standard":
232-
destination_throughput_mibps = max(16.0, size_gib * 0.016)
233-
elif destination_service_level.lower() == "premium":
234-
destination_throughput_mibps = max(64.0, size_gib * 0.064)
235-
elif destination_service_level.lower() == "ultra":
236-
destination_throughput_mibps = max(128.0, size_gib * 0.128)
237-
else:
238-
destination_throughput_mibps = 64.0
239-
230+
destination_throughput_mibps = _calculate_min_throughput_mibps(
231+
destination_service_level, destination_usage_threshold
232+
)
240233
if print_output:
241234
logger.info(f"Calculated destination throughput_mibps for manual QoS: {destination_throughput_mibps}")
242235
except Exception as e:
243236
# If we can't get source volume info, calculate based on destination size
244-
size_gib = destination_usage_threshold / (1024 * 1024 * 1024)
245-
if destination_service_level.lower() == "standard":
246-
destination_throughput_mibps = max(16.0, size_gib * 0.016)
247-
elif destination_service_level.lower() == "premium":
248-
destination_throughput_mibps = max(64.0, size_gib * 0.064)
249-
elif destination_service_level.lower() == "ultra":
250-
destination_throughput_mibps = max(128.0, size_gib * 0.128)
251-
else:
252-
destination_throughput_mibps = 64.0
253-
237+
destination_throughput_mibps = _calculate_min_throughput_mibps(
238+
destination_service_level, destination_usage_threshold
239+
)
254240
if print_output:
255241
logger.info(f"Calculated destination throughput_mibps for manual QoS (source unavailable): {destination_throughput_mibps}")
256242

@@ -266,16 +252,9 @@ def create_replication(
266252
# If we can't get pool info and throughput is still None, set a safe default
267253
# This is important for manual QoS pools
268254
if destination_throughput_mibps is None:
269-
size_gib = destination_usage_threshold / (1024 * 1024 * 1024)
270-
if destination_service_level.lower() == "standard":
271-
destination_throughput_mibps = max(16.0, size_gib * 0.016)
272-
elif destination_service_level.lower() == "premium":
273-
destination_throughput_mibps = max(64.0, size_gib * 0.064)
274-
elif destination_service_level.lower() == "ultra":
275-
destination_throughput_mibps = max(128.0, size_gib * 0.128)
276-
else:
277-
destination_throughput_mibps = 64.0
278-
255+
destination_throughput_mibps = _calculate_min_throughput_mibps(
256+
destination_service_level, destination_usage_threshold
257+
)
279258
if print_output:
280259
logger.info(f"Using calculated throughput_mibps (pool QoS type unavailable): {destination_throughput_mibps}")
281260

netapp_dataops_traditional/netapp_dataops/traditional/anf/snapshot_management.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
logger = setup_logger(__name__)
1717

18-
1918
def create_snapshot(
2019
snapshot_name: str,
2120
volume_name: str,

0 commit comments

Comments
 (0)