953 lines
37 KiB
Python
953 lines
37 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from threading import RLock
|
|
from typing import Dict, List, Optional
|
|
from uuid import uuid4
|
|
|
|
from azure.common.credentials import get_cli_profile
|
|
from azure.core.exceptions import ResourceNotFoundError
|
|
from azure.identity import DefaultAzureCredential
|
|
from azure.mgmt.compute import ComputeManagementClient
|
|
from azure.mgmt.network import NetworkManagementClient
|
|
from azure.mgmt.resource import ResourceManagementClient
|
|
from azure.mgmt.resource.resources.models import DeploymentMode
|
|
|
|
from ray._common.usage.usage_lib import get_cloud_from_metadata_requests
|
|
from ray.autoscaler._private._azure.config import (
|
|
_delete_role_assignments_for_principal,
|
|
_generate_arm_guid,
|
|
_is_shared_msi,
|
|
bootstrap_azure,
|
|
get_azure_sdk_function,
|
|
)
|
|
from ray.autoscaler._private.constants import (
|
|
AUTOSCALER_NODE_START_WAIT_S,
|
|
AUTOSCALER_NODE_TERMINATE_WAIT_S,
|
|
MAX_PARALLEL_SHUTDOWN_WORKERS,
|
|
)
|
|
from ray.autoscaler.node_provider import NodeProvider
|
|
from ray.autoscaler.tags import (
|
|
NODE_KIND_HEAD,
|
|
TAG_RAY_CLUSTER_NAME,
|
|
TAG_RAY_LAUNCH_CONFIG,
|
|
TAG_RAY_NODE_KIND,
|
|
TAG_RAY_NODE_NAME,
|
|
TAG_RAY_USER_NODE_TYPE,
|
|
)
|
|
|
|
VM_NAME_MAX_LEN = 64
|
|
UNIQUE_ID_LEN = 4
|
|
|
|
logger = logging.getLogger(__name__)
|
|
azure_logger = logging.getLogger("azure.core.pipeline.policies.http_logging_policy")
|
|
azure_logger.setLevel(logging.WARNING)
|
|
|
|
|
|
def synchronized(f):
|
|
def wrapper(self, *args, **kwargs):
|
|
self.lock.acquire()
|
|
try:
|
|
return f(self, *args, **kwargs)
|
|
finally:
|
|
self.lock.release()
|
|
|
|
return wrapper
|
|
|
|
|
|
class AzureNodeProvider(NodeProvider):
|
|
"""Node Provider for Azure
|
|
|
|
This provider assumes Azure credentials are set by running ``az login``
|
|
and the default subscription is configured through ``az account``
|
|
or set in the ``provider`` field of the autoscaler configuration.
|
|
|
|
Nodes may be in one of three states: {pending, running, terminated}. Nodes
|
|
appear immediately once started by ``create_node``, and transition
|
|
immediately to terminated when ``terminate_node`` is called.
|
|
"""
|
|
|
|
def __init__(self, provider_config, cluster_name):
|
|
NodeProvider.__init__(self, provider_config, cluster_name)
|
|
subscription_id = provider_config.get("subscription_id")
|
|
if subscription_id is None:
|
|
# Get subscription from logged in azure profile
|
|
# if it isn't provided in the provider_config
|
|
# so operations like `get-head-ip` will work
|
|
subscription_id = get_cli_profile().get_subscription_id()
|
|
logger.info(
|
|
"subscription_id not found in provider config, falling back "
|
|
f"to subscription_id from the logged in azure profile: {subscription_id}"
|
|
)
|
|
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
|
|
|
|
# Detect cloud environment to optimize Azure credential chain.
|
|
# On non-Azure clouds (AWS, GCP), skip Azure-specific auth methods
|
|
# (managed identity, workload identity) to avoid IMDS timeout delays / failures.
|
|
detected_cloud = get_cloud_from_metadata_requests()
|
|
on_azure = detected_cloud == "azure"
|
|
|
|
if on_azure:
|
|
logger.info(
|
|
"Initializing Azure node provider for Azure infrastructure "
|
|
"running on Azure cloud environment"
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"Initializing Azure node provider for Azure infrastructure "
|
|
f"but detected this is running on a '{detected_cloud}' environment. "
|
|
f"Skipping Azure-specific authentication methods to avoid timeouts."
|
|
)
|
|
|
|
credential = DefaultAzureCredential(
|
|
exclude_shared_token_cache_credential=True,
|
|
exclude_managed_identity_credential=not on_azure,
|
|
exclude_workload_identity_credential=not on_azure,
|
|
)
|
|
self.compute_client = ComputeManagementClient(credential, subscription_id)
|
|
self.network_client = NetworkManagementClient(credential, subscription_id)
|
|
self.resource_client = ResourceManagementClient(credential, subscription_id)
|
|
|
|
self.lock = RLock()
|
|
|
|
# cache node objects
|
|
self.cached_nodes = {}
|
|
|
|
# Cache terminating node operations
|
|
self.terminating_nodes: dict[str, Future] = {}
|
|
self.termination_executor = ThreadPoolExecutor(
|
|
max_workers=MAX_PARALLEL_SHUTDOWN_WORKERS
|
|
)
|
|
|
|
@synchronized
|
|
def _get_filtered_nodes(self, tag_filters):
|
|
# add cluster name filter to only get nodes from this cluster
|
|
cluster_tag_filters = {**tag_filters, TAG_RAY_CLUSTER_NAME: self.cluster_name}
|
|
|
|
def match_tags(tags):
|
|
for k, v in cluster_tag_filters.items():
|
|
if tags.get(k) != v:
|
|
return False
|
|
return True
|
|
|
|
vms = self.compute_client.virtual_machines.list(
|
|
resource_group_name=self.provider_config["resource_group"]
|
|
)
|
|
|
|
nodes = [self._extract_metadata(vm) for vm in vms]
|
|
self.cached_nodes = {node["name"]: node for node in nodes}
|
|
|
|
# Update terminating nodes list by removing nodes that
|
|
# have finished termination.
|
|
self.terminating_nodes = {
|
|
k: v for k, v in self.terminating_nodes.items() if not v.done()
|
|
}
|
|
|
|
return {
|
|
k: v
|
|
for k, v in self.cached_nodes.items()
|
|
if v["tags"] is not None and match_tags(v["tags"])
|
|
}
|
|
|
|
def _extract_metadata(self, vm):
|
|
# get tags
|
|
metadata = {"name": vm.name, "tags": vm.tags, "status": ""}
|
|
|
|
# get status
|
|
resource_group = self.provider_config["resource_group"]
|
|
try:
|
|
instance = self.compute_client.virtual_machines.instance_view(
|
|
resource_group_name=resource_group, vm_name=vm.name
|
|
).as_dict()
|
|
except ResourceNotFoundError:
|
|
return metadata
|
|
|
|
for status in instance["statuses"]:
|
|
# If ProvisioningState is "failed" (e.g.,
|
|
# ProvisioningState/failed/RetryableError), we can get a third
|
|
# string here, so we need to limit to the first two outputs.
|
|
code, state = status["code"].split("/")[:2]
|
|
# skip provisioning status
|
|
if code == "PowerState":
|
|
metadata["status"] = state
|
|
break
|
|
|
|
# get ip data
|
|
nic_id = vm.network_profile.network_interfaces[0].id
|
|
metadata["nic_name"] = nic_id.split("/")[-1]
|
|
nic = self.network_client.network_interfaces.get(
|
|
resource_group_name=resource_group,
|
|
network_interface_name=metadata["nic_name"],
|
|
)
|
|
ip_config = nic.ip_configurations[0]
|
|
|
|
# Get public IP if not using internal IPs or if this is the
|
|
# head node and use_external_head_ip is True
|
|
if not self.provider_config.get("use_internal_ips", False) or (
|
|
self.provider_config.get("use_external_head_ip", False)
|
|
and metadata["tags"][TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
|
|
):
|
|
public_ip_id = ip_config.public_ip_address.id
|
|
metadata["public_ip_name"] = public_ip_id.split("/")[-1]
|
|
public_ip = self.network_client.public_ip_addresses.get(
|
|
resource_group_name=resource_group,
|
|
public_ip_address_name=metadata["public_ip_name"],
|
|
)
|
|
metadata["external_ip"] = public_ip.ip_address
|
|
|
|
metadata["internal_ip"] = ip_config.private_ip_address
|
|
|
|
return metadata
|
|
|
|
def _get_zones_for_vm_size(self, vm_size, location):
|
|
"""Get usable availability zones for a given VM size in a specific location."""
|
|
try:
|
|
# Note: Azure ResourceSKUs API filters don't work reliably(?), so we query all SKUs
|
|
# and filter in code. Each SKU object represents one location for the VM size.
|
|
skus = self.compute_client.resource_skus.list()
|
|
|
|
for sku in skus:
|
|
if sku.name == vm_size and sku.location_info:
|
|
# Each SKU object represents one location, check if it matches our target
|
|
for location_info in sku.location_info:
|
|
if location_info.location.lower() == location.lower():
|
|
zones = location_info.zones if location_info.zones else []
|
|
logger.debug(
|
|
f"Found {vm_size} in {location} with zones: {zones}"
|
|
)
|
|
return sorted(zones)
|
|
|
|
logger.warning(f"No zones found for {vm_size} in {location}")
|
|
return [] # No zones available for this VM size
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to get zones for VM size {vm_size} in location {location}: {str(e)}"
|
|
)
|
|
return []
|
|
|
|
def _parse_availability_zones(
|
|
self, availability_zone_config: Optional[str]
|
|
) -> Optional[List[str]]:
|
|
"""Parse availability_zone configuration from comma-separated string format.
|
|
|
|
Args:
|
|
availability_zone_config: Can be:
|
|
- String: comma-separated zones like "1,2,3"
|
|
- "none": explicitly disable zones
|
|
- "auto": let Azure automatically pick zones
|
|
- None: no zones specified (defaults to letting Azure pick)
|
|
|
|
Returns:
|
|
List of zone strings, or None if zones explicitly disabled, or [] if auto/unspecified
|
|
"""
|
|
if availability_zone_config is None:
|
|
return [] # Auto - let Azure pick
|
|
|
|
# Handle string format (AWS-style comma-separated)
|
|
if isinstance(availability_zone_config, str):
|
|
# Strip whitespace and split by comma
|
|
zones = [zone.strip() for zone in availability_zone_config.split(",")]
|
|
|
|
# Handle special cases (case-insensitive)
|
|
if len(zones) == 1:
|
|
zone_lower = zones[0].lower()
|
|
if zone_lower in ["none", "null"]:
|
|
return None # Explicitly disabled
|
|
elif zone_lower == "auto":
|
|
return [] # Auto - let Azure pick
|
|
|
|
# Handle empty string or whitespace-only
|
|
if not zones or all(not zone for zone in zones):
|
|
return [] # Auto - let Azure pick
|
|
return zones
|
|
|
|
# Unsupported format
|
|
raise ValueError(
|
|
f"availability_zone must be a string, got {type(availability_zone_config).__name__}: {availability_zone_config!r}"
|
|
)
|
|
|
|
def _validate_zones_for_node_pool(self, zones, location, vm_size):
|
|
"""Validate that the specified zones are available for the given VM size in the location."""
|
|
# Special case: zones=None means explicitly disabled availability zones
|
|
if zones is None:
|
|
logger.info(
|
|
"Zones explicitly disabled with 'none' - will create VM without an availability zone"
|
|
)
|
|
return None # Special return value to indicate "no zones by choice"
|
|
|
|
vm_zones = self._get_zones_for_vm_size(vm_size, location)
|
|
|
|
available_zones = set(vm_zones)
|
|
if not available_zones:
|
|
logger.warning("No zones available for this VM size and location")
|
|
return []
|
|
|
|
if zones:
|
|
requested_zones = {str(z) for z in zones}
|
|
intersection = sorted(available_zones.intersection(requested_zones))
|
|
return intersection
|
|
|
|
return sorted(available_zones)
|
|
|
|
def stopped_nodes(self, tag_filters):
|
|
"""Return a list of stopped node ids filtered by the specified tags dict."""
|
|
nodes = self._get_filtered_nodes(tag_filters=tag_filters)
|
|
return [k for k, v in nodes.items() if v["status"].startswith("deallocat")]
|
|
|
|
def non_terminated_nodes(self, tag_filters: Dict[str, str]) -> List[str]:
|
|
"""Return a list of node ids filtered by the specified tags dict.
|
|
|
|
This list must not include terminated nodes. For performance reasons,
|
|
providers are allowed to cache the result of a call to nodes() to
|
|
serve single-node queries (e.g. is_running(node_id)). This means that
|
|
nodes() must be called again to refresh results.
|
|
|
|
Args:
|
|
tag_filters: Tags to filter nodes by.
|
|
|
|
Returns:
|
|
List of non-terminated node ids matching the tag filters.
|
|
|
|
Examples:
|
|
>>> from ray.autoscaler.tags import TAG_RAY_NODE_KIND
|
|
>>> provider = ... # doctest: +SKIP
|
|
>>> provider.non_terminated_nodes( # doctest: +SKIP
|
|
... {TAG_RAY_NODE_KIND: "worker"})
|
|
["node-1", "node-2"]
|
|
"""
|
|
nodes = self._get_filtered_nodes(tag_filters=tag_filters)
|
|
return [
|
|
k
|
|
for k, v in nodes.items()
|
|
if not v["status"].startswith("deallocat") or k in self.terminating_nodes
|
|
]
|
|
|
|
def is_running(self, node_id):
|
|
"""Return whether the specified node is running."""
|
|
# always get current status
|
|
node = self._get_node(node_id=node_id)
|
|
return node["status"] == "running"
|
|
|
|
def is_terminated(self, node_id):
|
|
"""Return whether the specified node is terminated."""
|
|
# always get current status
|
|
node = self._get_node(node_id=node_id)
|
|
return node["status"].startswith("deallocat")
|
|
|
|
def node_tags(self, node_id):
|
|
"""Returns the tags of the given node (string dict)."""
|
|
return self._get_cached_node(node_id=node_id)["tags"]
|
|
|
|
def external_ip(self, node_id):
|
|
"""Returns the external ip of the given node."""
|
|
ip = (
|
|
self._get_cached_node(node_id=node_id)["external_ip"]
|
|
or self._get_node(node_id=node_id)["external_ip"]
|
|
)
|
|
return ip
|
|
|
|
def internal_ip(self, node_id):
|
|
"""Returns the internal ip (Ray ip) of the given node."""
|
|
ip = (
|
|
self._get_cached_node(node_id=node_id)["internal_ip"]
|
|
or self._get_node(node_id=node_id)["internal_ip"]
|
|
)
|
|
return ip
|
|
|
|
def create_node(self, node_config, tags, count):
|
|
resource_group = self.provider_config["resource_group"]
|
|
|
|
if self.cache_stopped_nodes:
|
|
VALIDITY_TAGS = [
|
|
TAG_RAY_CLUSTER_NAME,
|
|
TAG_RAY_NODE_KIND,
|
|
TAG_RAY_LAUNCH_CONFIG,
|
|
TAG_RAY_USER_NODE_TYPE,
|
|
]
|
|
filters = {tag: tags[tag] for tag in VALIDITY_TAGS if tag in tags}
|
|
reuse_nodes = self.stopped_nodes(filters)[:count]
|
|
logger.info(
|
|
f"Reusing nodes {list(reuse_nodes)}. "
|
|
"To disable reuse, set `cache_stopped_nodes: False` "
|
|
"under `provider` in the cluster configuration.",
|
|
)
|
|
start = get_azure_sdk_function(
|
|
client=self.compute_client.virtual_machines, function_name="start"
|
|
)
|
|
for node_id in reuse_nodes:
|
|
start(resource_group_name=resource_group, vm_name=node_id).wait()
|
|
self.set_node_tags(node_id, tags)
|
|
count -= len(reuse_nodes)
|
|
|
|
if count:
|
|
self._create_node(node_config, tags, count)
|
|
|
|
def _create_node(self, node_config, tags, count):
|
|
"""Creates a number of nodes within the namespace."""
|
|
resource_group = self.provider_config["resource_group"]
|
|
location = self.provider_config["location"]
|
|
vm_size = node_config["azure_arm_parameters"]["vmSize"]
|
|
|
|
# Determine availability zones with precedence: node-level > provider-level
|
|
# Check for "availability_zone" field in node config first
|
|
node_availability_zone = node_config.get("azure_arm_parameters", {}).get(
|
|
"availability_zone"
|
|
)
|
|
# Then check provider-level "availability_zone"
|
|
provider_availability_zone = self.provider_config.get("availability_zone")
|
|
|
|
requested_zones = []
|
|
zone_source = "default"
|
|
|
|
# Precedence: node availability_zone > provider availability_zone
|
|
if node_availability_zone is not None:
|
|
requested_zones = self._parse_availability_zones(node_availability_zone)
|
|
zone_source = "node config availability_zone"
|
|
elif provider_availability_zone is not None:
|
|
requested_zones = self._parse_availability_zones(provider_availability_zone)
|
|
zone_source = "provider availability_zone"
|
|
|
|
logger.info(f"Requested zones from {zone_source}: {requested_zones}")
|
|
|
|
# Get actually available zones for this VM size
|
|
available_zones = self._validate_zones_for_node_pool(
|
|
requested_zones, location, vm_size
|
|
)
|
|
|
|
# Handle explicit zone disabling
|
|
zones_explicitly_disabled = available_zones is None
|
|
|
|
if requested_zones and not zones_explicitly_disabled and not available_zones:
|
|
raise ValueError(
|
|
f"No available zones for VM size {vm_size} in {location}. "
|
|
f"Requested: {requested_zones}, but none are available for this VM size."
|
|
)
|
|
|
|
# load the template file
|
|
current_path = Path(__file__).parent
|
|
template_path = current_path.joinpath("azure-vm-template.json")
|
|
with open(template_path, "r") as template_fp:
|
|
template = json.load(template_fp)
|
|
|
|
# get the tags
|
|
config_tags = node_config.get("tags", {}).copy()
|
|
config_tags.update(tags)
|
|
config_tags[TAG_RAY_CLUSTER_NAME] = self.cluster_name
|
|
|
|
deployment_name = "{node}-{unique_id}-{vm_id}".format(
|
|
node=config_tags.get(TAG_RAY_NODE_NAME, "node"),
|
|
unique_id=self.provider_config["unique_id"],
|
|
vm_id=uuid4().hex[:UNIQUE_ID_LEN],
|
|
)[:VM_NAME_MAX_LEN]
|
|
|
|
template_params = node_config["azure_arm_parameters"].copy()
|
|
# Remove availability_zone from template params since ARM template expects "zones"
|
|
template_params.pop("availability_zone", None)
|
|
# Use deployment_name for the vmName template parameter since
|
|
# the template will append copyIndex() for each VM that gets created
|
|
# to guarantee uniqueness.
|
|
template_params["vmName"] = deployment_name
|
|
# Provision public IP if not using internal IPs or if this is the
|
|
# head node and use_external_head_ip is True
|
|
template_params["provisionPublicIp"] = not self.provider_config.get(
|
|
"use_internal_ips", False
|
|
) or (
|
|
self.provider_config.get("use_external_head_ip", False)
|
|
and config_tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
|
|
)
|
|
template_params["vmTags"] = config_tags
|
|
template_params["vmCount"] = count
|
|
template_params["msi"] = self.provider_config["msi"]
|
|
template_params["nsg"] = self.provider_config["nsg"]
|
|
template_params["subnet"] = self.provider_config["subnet"]
|
|
|
|
# Add zone information based on availability and requested zones
|
|
if zones_explicitly_disabled:
|
|
# User explicitly disabled zones with ["None"]
|
|
template_params["zones"] = []
|
|
logger.info(
|
|
f"Creating {count} VMs with zones explicitly disabled (no availability zone)"
|
|
)
|
|
elif available_zones:
|
|
# Pass the list of available zones to the template
|
|
template_params["zones"] = available_zones
|
|
logger.info(
|
|
f"Creating {count} VMs, distributed across availability zones: {available_zones}"
|
|
)
|
|
else:
|
|
# For non-zonal deployments (no zones available), use empty array
|
|
template_params["zones"] = []
|
|
logger.info(f"Creating {count} VMs without specific availability zone")
|
|
|
|
parameters = {
|
|
"properties": {
|
|
"mode": DeploymentMode.incremental,
|
|
"template": template,
|
|
"parameters": {
|
|
key: {"value": value} for key, value in template_params.items()
|
|
},
|
|
}
|
|
}
|
|
|
|
# TODO: we could get the private/public ips back directly
|
|
create_or_update = get_azure_sdk_function(
|
|
client=self.resource_client.deployments,
|
|
function_name="create_or_update",
|
|
)
|
|
create_or_update(
|
|
resource_group_name=resource_group,
|
|
deployment_name=deployment_name,
|
|
parameters=parameters,
|
|
).wait(timeout=AUTOSCALER_NODE_START_WAIT_S)
|
|
|
|
@synchronized
|
|
def set_node_tags(self, node_id, tags):
|
|
"""Sets the tag values (string dict) for the specified node."""
|
|
node_tags = self._get_cached_node(node_id)["tags"]
|
|
node_tags.update(tags)
|
|
update = get_azure_sdk_function(
|
|
client=self.compute_client.virtual_machines, function_name="update"
|
|
)
|
|
update(
|
|
resource_group_name=self.provider_config["resource_group"],
|
|
vm_name=node_id,
|
|
parameters={"tags": node_tags},
|
|
)
|
|
self.cached_nodes[node_id]["tags"] = node_tags
|
|
|
|
def terminate_node(self, node_id):
|
|
"""Terminates the specified node. This will delete the VM and
|
|
associated resources (NIC, IP, Storage) for the specified node."""
|
|
|
|
resource_group = self.provider_config["resource_group"]
|
|
|
|
if self.cache_stopped_nodes:
|
|
try:
|
|
# stop machine and leave all resources
|
|
logger.info(
|
|
f"Stopping instance {node_id}"
|
|
"(to fully terminate instead, "
|
|
"set `cache_stopped_nodes: False` "
|
|
"under `provider` in the cluster configuration)"
|
|
)
|
|
stop = get_azure_sdk_function(
|
|
client=self.compute_client.virtual_machines,
|
|
function_name="deallocate",
|
|
)
|
|
stop(resource_group_name=resource_group, vm_name=node_id)
|
|
except Exception as e:
|
|
logger.warning("Failed to stop VM: {}".format(e))
|
|
|
|
# If node_id is in terminating nodes dict, it's already terminating
|
|
# Otherwise, kick off termination and add it to the dict
|
|
elif node_id not in self.terminating_nodes:
|
|
self.terminating_nodes[node_id] = self.termination_executor.submit(
|
|
self._delete_node_and_resources, resource_group, node_id
|
|
)
|
|
|
|
def _delete_node_and_resources(self, resource_group, node_id):
|
|
try:
|
|
vm = self.compute_client.virtual_machines.get(
|
|
resource_group_name=resource_group, vm_name=node_id
|
|
)
|
|
except ResourceNotFoundError as e:
|
|
# Node no longer exists
|
|
logger.warning("Failed to delete VM: {}".format(e))
|
|
return
|
|
|
|
# Gather dependent disks
|
|
disks = set()
|
|
if vm.storage_profile is not None and vm.storage_profile.data_disks is not None:
|
|
for d in vm.storage_profile.data_disks:
|
|
if d.name is not None:
|
|
disks.add(d.name)
|
|
if (
|
|
vm.storage_profile is not None
|
|
and vm.storage_profile.os_disk is not None
|
|
and vm.storage_profile.os_disk.name is not None
|
|
):
|
|
disks.add(vm.storage_profile.os_disk.name)
|
|
|
|
# Gather dependent NICs and public IPs
|
|
nics = set()
|
|
ips = set()
|
|
if (
|
|
vm.network_profile is not None
|
|
and vm.network_profile.network_interfaces is not None
|
|
):
|
|
for nint in vm.network_profile.network_interfaces:
|
|
if nint.id is not None:
|
|
nic_name = nint.id.split("/")[-1]
|
|
nics.add(nic_name)
|
|
# Get public IP if not using internal IPs or if this is the
|
|
# head node and use_external_head_ip is True
|
|
if not self.provider_config.get("use_internal_ips", False) or (
|
|
self.provider_config.get("use_external_head_ip", False)
|
|
and vm.tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
|
|
):
|
|
nic = self.network_client.network_interfaces.get(
|
|
resource_group_name=resource_group,
|
|
network_interface_name=nic_name,
|
|
)
|
|
if nic.ip_configurations is not None:
|
|
for ipc in nic.ip_configurations:
|
|
if ipc.public_ip_address.id is not None:
|
|
ips.add(ipc.public_ip_address.id.split("/")[-1])
|
|
|
|
# Delete VM
|
|
st = time.monotonic()
|
|
delete = get_azure_sdk_function(
|
|
client=self.compute_client.virtual_machines,
|
|
function_name="delete",
|
|
)
|
|
try:
|
|
delete(resource_group_name=resource_group, vm_name=node_id).wait(
|
|
timeout=AUTOSCALER_NODE_TERMINATE_WAIT_S
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to delete VM: {}".format(e))
|
|
|
|
# Delete disks (no need to wait for these, but gather the LROs for end)
|
|
disk_lros = []
|
|
delete = get_azure_sdk_function(
|
|
client=self.compute_client.disks, function_name="delete"
|
|
)
|
|
for d in disks:
|
|
try:
|
|
disk_lros.append(
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
disk_name=d,
|
|
)
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to delete disk: {}".format(e))
|
|
|
|
# Delete NICs
|
|
nic_lros = []
|
|
delete = get_azure_sdk_function(
|
|
client=self.network_client.network_interfaces, function_name="delete"
|
|
)
|
|
for n in nics:
|
|
try:
|
|
nic_lros.append(
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
network_interface_name=n,
|
|
)
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to delete NIC: {}".format(e))
|
|
|
|
while (
|
|
not all(nlro.done() for nlro in nic_lros)
|
|
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
|
|
):
|
|
time.sleep(0.1)
|
|
|
|
# Delete Public IPs
|
|
delete = get_azure_sdk_function(
|
|
client=self.network_client.public_ip_addresses,
|
|
function_name="delete",
|
|
)
|
|
ip_lros = []
|
|
for ip in ips:
|
|
try:
|
|
ip_lros.append(
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
public_ip_address_name=ip,
|
|
)
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to delete public IP: {}".format(e))
|
|
|
|
while (
|
|
not all(dlro.done() for dlro in disk_lros)
|
|
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
|
|
):
|
|
time.sleep(0.1)
|
|
while (
|
|
not all(iplro.done() for iplro in ip_lros)
|
|
and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
|
|
):
|
|
time.sleep(0.1)
|
|
|
|
def cleanup_cluster_resources(self):
|
|
"""Delete shared cluster infrastructure (MSI, NSG, Subnet, VNet)."""
|
|
|
|
resource_group = self.provider_config["resource_group"]
|
|
|
|
msi_principal_id = self._cleanup_managed_identity(
|
|
resource_group, self.provider_config.get("msi")
|
|
)
|
|
|
|
subnet_id = self.provider_config.get("subnet")
|
|
vnet_name = self._cleanup_subnet(resource_group, subnet_id)
|
|
|
|
nsg_id = self.provider_config.get("nsg")
|
|
self._cleanup_nsg(resource_group, nsg_id)
|
|
|
|
self._cleanup_vnet(resource_group, subnet_id, vnet_name)
|
|
|
|
self._cleanup_role_assignments(resource_group, msi_principal_id)
|
|
|
|
self._prune_provider_config_entries()
|
|
self._cleanup_config_cache()
|
|
|
|
@staticmethod
|
|
def _get_resource_name_from_id(resource_id: Optional[str]) -> Optional[str]:
|
|
if resource_id:
|
|
return resource_id.split("/")[-1]
|
|
return None
|
|
|
|
@staticmethod
|
|
def _retry_delete(delete_fn, max_retries: int = 5, initial_delay: int = 2):
|
|
"""Retry a delete operation with exponential backoff."""
|
|
|
|
delay = initial_delay
|
|
for attempt in range(max_retries):
|
|
try:
|
|
return delete_fn()
|
|
except Exception as exc: # noqa: BLE001
|
|
error_msg = str(exc)
|
|
if "InUse" in error_msg and attempt < max_retries - 1:
|
|
logger.info(
|
|
"Resource still in use, retrying in %ss (attempt %s/%s)...",
|
|
delay,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
time.sleep(delay)
|
|
delay *= 2
|
|
else:
|
|
raise
|
|
|
|
def _cleanup_managed_identity(
|
|
self, resource_group: str, msi_id: Optional[str]
|
|
) -> Optional[str]:
|
|
if not msi_id:
|
|
return None
|
|
|
|
msi_name = self._get_resource_name_from_id(msi_id)
|
|
if not msi_name:
|
|
return None
|
|
|
|
msi_principal_id: Optional[str] = None
|
|
try:
|
|
get_identity = get_azure_sdk_function(
|
|
client=self.resource_client.resources,
|
|
function_name="get_by_id",
|
|
)
|
|
existing_msi = get_identity(msi_id, "2023-01-31")
|
|
msi_principal_id = getattr(existing_msi, "properties", {}).get(
|
|
"principalId"
|
|
)
|
|
except ResourceNotFoundError:
|
|
msi_principal_id = None
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(
|
|
"Failed to query MSI %s for principal ID prior to deletion: %s",
|
|
msi_name,
|
|
exc,
|
|
)
|
|
|
|
if _is_shared_msi(self.provider_config):
|
|
return msi_principal_id
|
|
|
|
try:
|
|
logger.info("Deleting Managed Service Identity: %s", msi_name)
|
|
delete = get_azure_sdk_function(
|
|
client=self.resource_client.resources,
|
|
function_name="delete_by_id",
|
|
)
|
|
delete(resource_id=msi_id, api_version="2023-01-31").wait()
|
|
logger.info("Successfully deleted MSI: %s", msi_name)
|
|
except ResourceNotFoundError:
|
|
logger.info("MSI %s not found, may have been already deleted", msi_name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to delete MSI %s: %s", msi_name, exc)
|
|
|
|
return msi_principal_id
|
|
|
|
def _cleanup_subnet(
|
|
self, resource_group: str, subnet_id: Optional[str]
|
|
) -> Optional[str]:
|
|
if not subnet_id:
|
|
return None
|
|
|
|
subnet_name = self._get_resource_name_from_id(subnet_id)
|
|
vnet_name: Optional[str] = None
|
|
|
|
if subnet_id and "/virtualNetworks/" in subnet_id:
|
|
parts = subnet_id.split("/")
|
|
vnet_idx = parts.index("virtualNetworks")
|
|
if vnet_idx + 1 < len(parts):
|
|
vnet_name = parts[vnet_idx + 1]
|
|
|
|
if not subnet_name or not vnet_name:
|
|
return None
|
|
|
|
try:
|
|
logger.info("Deleting Subnet: %s in VNet: %s", subnet_name, vnet_name)
|
|
|
|
def delete_subnet():
|
|
delete = get_azure_sdk_function(
|
|
client=self.network_client.subnets, function_name="delete"
|
|
)
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
virtual_network_name=vnet_name,
|
|
subnet_name=subnet_name,
|
|
).wait()
|
|
|
|
self._retry_delete(delete_subnet)
|
|
logger.info("Successfully deleted Subnet: %s", subnet_name)
|
|
except ResourceNotFoundError:
|
|
logger.info(
|
|
"Subnet %s not found, may have been already deleted", subnet_name
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to delete Subnet %s: %s", subnet_name, exc)
|
|
|
|
return vnet_name
|
|
|
|
def _cleanup_nsg(self, resource_group: str, nsg_id: Optional[str]) -> None:
|
|
if not nsg_id:
|
|
return
|
|
|
|
nsg_name = self._get_resource_name_from_id(nsg_id)
|
|
if not nsg_name:
|
|
return
|
|
|
|
try:
|
|
logger.info("Deleting Network Security Group: %s", nsg_name)
|
|
|
|
def delete_nsg():
|
|
delete = get_azure_sdk_function(
|
|
client=self.network_client.network_security_groups,
|
|
function_name="delete",
|
|
)
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
network_security_group_name=nsg_name,
|
|
).wait()
|
|
|
|
self._retry_delete(delete_nsg)
|
|
logger.info("Successfully deleted NSG: %s", nsg_name)
|
|
except ResourceNotFoundError:
|
|
logger.info("NSG %s not found, may have been already deleted", nsg_name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to delete NSG %s: %s", nsg_name, exc)
|
|
|
|
def _cleanup_vnet(
|
|
self,
|
|
resource_group: str,
|
|
subnet_id: Optional[str],
|
|
vnet_name: Optional[str],
|
|
) -> None:
|
|
if not subnet_id or not vnet_name:
|
|
return
|
|
|
|
try:
|
|
logger.info("Deleting Virtual Network: %s", vnet_name)
|
|
|
|
def delete_vnet():
|
|
delete = get_azure_sdk_function(
|
|
client=self.network_client.virtual_networks,
|
|
function_name="delete",
|
|
)
|
|
delete(
|
|
resource_group_name=resource_group,
|
|
virtual_network_name=vnet_name,
|
|
).wait()
|
|
|
|
self._retry_delete(delete_vnet)
|
|
logger.info("Successfully deleted VNet: %s", vnet_name)
|
|
except ResourceNotFoundError:
|
|
logger.info("VNet %s not found, may have been already deleted", vnet_name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to delete VNet %s: %s", vnet_name, exc)
|
|
|
|
def _cleanup_role_assignments(
|
|
self, resource_group: str, msi_principal_id: Optional[str]
|
|
) -> None:
|
|
subscription_id = self.provider_config.get("subscription_id")
|
|
unique_id = self.provider_config.get("unique_id")
|
|
if not subscription_id or not unique_id:
|
|
return
|
|
|
|
cluster_id = f"{self.cluster_name}-{unique_id}"
|
|
role_assignment_name = f"ray-{cluster_id}-ra"
|
|
role_assignment_guid = _generate_arm_guid(role_assignment_name)
|
|
role_assignment_id = (
|
|
f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers"
|
|
f"/Microsoft.Authorization/roleAssignments/{role_assignment_guid}"
|
|
)
|
|
|
|
if msi_principal_id:
|
|
_delete_role_assignments_for_principal(
|
|
self.resource_client, resource_group, msi_principal_id
|
|
)
|
|
|
|
delete_role_assignment = get_azure_sdk_function(
|
|
client=self.resource_client.resources, function_name="delete_by_id"
|
|
)
|
|
try:
|
|
delete_lro = delete_role_assignment(
|
|
resource_id=role_assignment_id,
|
|
api_version="2022-04-01",
|
|
)
|
|
if hasattr(delete_lro, "wait"):
|
|
delete_lro.wait()
|
|
logger.info(
|
|
"Deleted role assignment %s for cluster %s",
|
|
role_assignment_guid,
|
|
self.cluster_name,
|
|
)
|
|
except ResourceNotFoundError:
|
|
logger.debug(
|
|
"Role assignment %s not found during cleanup", role_assignment_guid
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(
|
|
"Failed to delete role assignment %s: %s",
|
|
role_assignment_guid,
|
|
exc,
|
|
)
|
|
|
|
def _prune_provider_config_entries(self) -> None:
|
|
for key in ("msi", "nsg", "subnet"):
|
|
self.provider_config.pop(key, None)
|
|
|
|
def _cleanup_config_cache(self) -> None:
|
|
cache_path = self.provider_config.get("_config_cache_path")
|
|
if not cache_path:
|
|
return
|
|
|
|
try:
|
|
if os.path.exists(cache_path):
|
|
os.remove(cache_path)
|
|
logger.info(
|
|
"Deleted cached Ray config at %s after resource cleanup",
|
|
cache_path,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("Failed to delete cached Ray config %s: %s", cache_path, exc)
|
|
finally:
|
|
self.provider_config.pop("_config_cache_path", None)
|
|
|
|
def _get_node(self, node_id):
|
|
self._get_filtered_nodes({}) # Side effect: updates cache
|
|
return self.cached_nodes[node_id]
|
|
|
|
def _get_cached_node(self, node_id):
|
|
return self.cached_nodes.get(node_id) or self._get_node(node_id=node_id)
|
|
|
|
@staticmethod
|
|
def bootstrap_config(cluster_config):
|
|
return bootstrap_azure(cluster_config)
|