ZhengyangZhang commited on
Commit
9d4b6c6
·
verified ·
1 Parent(s): fd23669

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. lib/python3.12/site-packages/ray/autoscaler/__init__.py +8 -0
  2. lib/python3.12/site-packages/ray/autoscaler/__pycache__/__init__.cpython-312.pyc +0 -0
  3. lib/python3.12/site-packages/ray/autoscaler/__pycache__/batching_node_provider.cpython-312.pyc +0 -0
  4. lib/python3.12/site-packages/ray/autoscaler/__pycache__/command_runner.cpython-312.pyc +0 -0
  5. lib/python3.12/site-packages/ray/autoscaler/__pycache__/launch_and_verify_cluster.cpython-312.pyc +0 -0
  6. lib/python3.12/site-packages/ray/autoscaler/__pycache__/node_provider.cpython-312.pyc +0 -0
  7. lib/python3.12/site-packages/ray/autoscaler/_private/__init__.py +0 -0
  8. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/__init__.cpython-312.pyc +0 -0
  9. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/autoscaler.cpython-312.pyc +0 -0
  10. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cli_logger.cpython-312.pyc +0 -0
  11. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cli_logger_demoall.cpython-312.pyc +0 -0
  12. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cluster_dump.cpython-312.pyc +0 -0
  13. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/command_runner.cpython-312.pyc +0 -0
  14. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/commands.cpython-312.pyc +0 -0
  15. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/constants.cpython-312.pyc +0 -0
  16. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/docker.cpython-312.pyc +0 -0
  17. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/event_summarizer.cpython-312.pyc +0 -0
  18. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/event_system.cpython-312.pyc +0 -0
  19. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/legacy_info_string.cpython-312.pyc +0 -0
  20. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/load_metrics.cpython-312.pyc +0 -0
  21. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/loader.cpython-312.pyc +0 -0
  22. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/log_timer.cpython-312.pyc +0 -0
  23. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/monitor.cpython-312.pyc +0 -0
  24. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_launcher.cpython-312.pyc +0 -0
  25. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_provider_availability_tracker.cpython-312.pyc +0 -0
  26. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_tracker.cpython-312.pyc +0 -0
  27. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/prom_metrics.cpython-312.pyc +0 -0
  28. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/providers.cpython-312.pyc +0 -0
  29. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/resource_demand_scheduler.cpython-312.pyc +0 -0
  30. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/subprocess_output_util.cpython-312.pyc +0 -0
  31. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/updater.cpython-312.pyc +0 -0
  32. lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/util.cpython-312.pyc +0 -0
  33. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__init__.py +0 -0
  34. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/__init__.cpython-312.pyc +0 -0
  35. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/config.cpython-312.pyc +0 -0
  36. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/node_provider.cpython-312.pyc +0 -0
  37. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/azure-config-template.json +137 -0
  38. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/azure-vm-template.json +318 -0
  39. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/config.py +580 -0
  40. lib/python3.12/site-packages/ray/autoscaler/_private/_azure/node_provider.py +942 -0
  41. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__init__.py +0 -0
  42. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/__init__.cpython-312.pyc +0 -0
  43. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/config.cpython-312.pyc +0 -0
  44. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/node_provider.cpython-312.pyc +0 -0
  45. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/utils.cpython-312.pyc +0 -0
  46. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/config.py +116 -0
  47. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/node_provider.py +324 -0
  48. lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/utils.py +461 -0
  49. lib/python3.12/site-packages/ray/autoscaler/_private/autoscaler.py +1522 -0
  50. lib/python3.12/site-packages/ray/autoscaler/_private/aws/__init__.py +0 -0
lib/python3.12/site-packages/ray/autoscaler/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from ray.autoscaler import sdk
5
+
6
+ __all__ = ["sdk"]
7
+
8
+ AUTOSCALER_DIR_PATH = Path(os.path.abspath(os.path.dirname(__file__)))
lib/python3.12/site-packages/ray/autoscaler/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (525 Bytes). View file
 
lib/python3.12/site-packages/ray/autoscaler/__pycache__/batching_node_provider.cpython-312.pyc ADDED
Binary file (11.8 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/__pycache__/command_runner.cpython-312.pyc ADDED
Binary file (4.55 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/__pycache__/launch_and_verify_cluster.cpython-312.pyc ADDED
Binary file (21.1 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/__pycache__/node_provider.cpython-312.pyc ADDED
Binary file (13.1 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__init__.py ADDED
File without changes
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (202 Bytes). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/autoscaler.cpython-312.pyc ADDED
Binary file (62.8 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cli_logger.cpython-312.pyc ADDED
Binary file (31.9 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cli_logger_demoall.cpython-312.pyc ADDED
Binary file (2.69 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/cluster_dump.cpython-312.pyc ADDED
Binary file (26.9 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/command_runner.cpython-312.pyc ADDED
Binary file (40.2 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/commands.cpython-312.pyc ADDED
Binary file (60.7 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/constants.cpython-312.pyc ADDED
Binary file (3.45 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/docker.cpython-312.pyc ADDED
Binary file (5.16 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/event_summarizer.cpython-312.pyc ADDED
Binary file (4.12 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/event_system.cpython-312.pyc ADDED
Binary file (5.18 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/legacy_info_string.cpython-312.pyc ADDED
Binary file (1.95 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/load_metrics.cpython-312.pyc ADDED
Binary file (17.9 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/loader.cpython-312.pyc ADDED
Binary file (930 Bytes). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/log_timer.cpython-312.pyc ADDED
Binary file (1.78 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/monitor.cpython-312.pyc ADDED
Binary file (29.4 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_launcher.cpython-312.pyc ADDED
Binary file (8.64 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_provider_availability_tracker.cpython-312.pyc ADDED
Binary file (7.09 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/node_tracker.cpython-312.pyc ADDED
Binary file (4 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/prom_metrics.cpython-312.pyc ADDED
Binary file (8.44 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/providers.cpython-312.pyc ADDED
Binary file (11.1 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/resource_demand_scheduler.cpython-312.pyc ADDED
Binary file (38.6 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/subprocess_output_util.cpython-312.pyc ADDED
Binary file (13.2 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/updater.cpython-312.pyc ADDED
Binary file (24.4 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/__pycache__/util.cpython-312.pyc ADDED
Binary file (40.4 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__init__.py ADDED
File without changes
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (209 Bytes). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/config.cpython-312.pyc ADDED
Binary file (20.5 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/__pycache__/node_provider.cpython-312.pyc ADDED
Binary file (41.5 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/azure-config-template.json ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
3
+ "contentVersion": "1.0.0.0",
4
+ "parameters": {
5
+ "clusterId": {
6
+ "type": "string",
7
+ "metadata": {
8
+ "description": "Unique string appended to resource names to isolate resources from different ray clusters."
9
+ }
10
+ },
11
+ "subnet": {
12
+ "type": "string",
13
+ "metadata": {
14
+ "description": "Subnet parameters."
15
+ }
16
+ },
17
+ "msiName": {
18
+ "type": "string",
19
+ "metadata": {
20
+ "description": "Managed service identity."
21
+ }
22
+ },
23
+ "msiResourceGroup": {
24
+ "type": "string",
25
+ "metadata": {
26
+ "description": "Managed service identity resource group."
27
+ }
28
+ },
29
+ "createMsi": {
30
+ "type": "bool",
31
+ "defaultValue": "true"
32
+ },
33
+ "roleAssignmentGuid": {
34
+ "type": "string",
35
+ "metadata": {
36
+ "description": "Deterministic resource name for the MSI role assignment."
37
+ }
38
+ }
39
+ },
40
+ "variables": {
41
+ "contributor": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]",
42
+ "location": "[resourceGroup().location]",
43
+ "roleAssignmentName": "[concat('ray-', parameters('clusterId'), '-ra')]",
44
+ "nsgName": "[concat('ray-', parameters('clusterId'), '-nsg')]",
45
+ "nsg": "[resourceId('Microsoft.Network/networkSecurityGroups', variables('nsgName'))]",
46
+ "vnetName": "[concat('ray-', parameters('clusterId'), '-vnet')]",
47
+ "subnetName": "[concat('ray-', parameters('clusterId'), '-subnet')]"
48
+ },
49
+ "resources": [
50
+ {
51
+ "condition": "[parameters('createMsi')]",
52
+ "type": "Microsoft.ManagedIdentity/userAssignedIdentities",
53
+ "apiVersion": "2024-11-30",
54
+ "location": "[variables('location')]",
55
+ "name": "[parameters('msiName')]"
56
+ },
57
+ {
58
+ "type": "Microsoft.Authorization/roleAssignments",
59
+ "apiVersion": "2022-04-01",
60
+ "name": "[parameters('roleAssignmentGuid')]",
61
+ "properties": {
62
+ "principalId": "[reference(resourceId(parameters('msiResourceGroup'), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('msiName')), '2024-11-30').principalId]",
63
+ "roleDefinitionId": "[variables('contributor')]",
64
+ "scope": "[resourceGroup().id]",
65
+ "principalType": "ServicePrincipal",
66
+ "description": "[concat('Ray autoscaler cluster ', parameters('clusterId'))]"
67
+ },
68
+ "dependsOn": [
69
+ "[parameters('msiName')]"
70
+ ]
71
+ },
72
+ {
73
+ "type": "Microsoft.Network/networkSecurityGroups",
74
+ "apiVersion": "2024-10-01",
75
+ "name": "[variables('nsgName')]",
76
+ "location": "[variables('location')]",
77
+ "properties": {
78
+ "securityRules": [
79
+ {
80
+ "name": "SSH",
81
+ "properties": {
82
+ "priority": 1000,
83
+ "protocol": "Tcp",
84
+ "access": "Allow",
85
+ "direction": "Inbound",
86
+ "sourceAddressPrefix": "*",
87
+ "sourcePortRange": "*",
88
+ "destinationAddressPrefix": "*",
89
+ "destinationPortRange": "22"
90
+ }
91
+ }
92
+ ]
93
+ }
94
+ },
95
+ {
96
+ "type": "Microsoft.Network/virtualNetworks",
97
+ "apiVersion": "2024-10-01",
98
+ "name": "[variables('vnetName')]",
99
+ "location": "[variables('location')]",
100
+ "properties": {
101
+ "addressSpace": {
102
+ "addressPrefixes": [
103
+ "[parameters('subnet')]"
104
+ ]
105
+ },
106
+ "subnets": [
107
+ {
108
+ "name": "[variables('subnetName')]",
109
+ "properties": {
110
+ "addressPrefix": "[parameters('subnet')]",
111
+ "networkSecurityGroup": {
112
+ "id": "[variables('nsg')]"
113
+ }
114
+ }
115
+ }
116
+ ]
117
+ },
118
+ "dependsOn": [
119
+ "[variables('nsg')]"
120
+ ]
121
+ }
122
+ ],
123
+ "outputs": {
124
+ "subnet": {
125
+ "type": "string",
126
+ "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('vnetName'), variables('subnetName'))]"
127
+ },
128
+ "nsg": {
129
+ "type": "string",
130
+ "value": "[variables('nsg')]"
131
+ },
132
+ "msi": {
133
+ "type": "string",
134
+ "value": "[resourceId(parameters('msiResourceGroup'), 'Microsoft.ManagedIdentity/userAssignedIdentities', parameters('msiName'))]"
135
+ }
136
+ }
137
+ }
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/azure-vm-template.json ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
3
+ "contentVersion": "1.0.0.0",
4
+ "parameters": {
5
+ "vmName": {
6
+ "type": "string",
7
+ "metadata": {
8
+ "description": "The name of you Virtual Machine."
9
+ }
10
+ },
11
+ "adminUsername": {
12
+ "type": "string",
13
+ "metadata": {
14
+ "description": "Username for the Virtual Machine."
15
+ }
16
+ },
17
+ "publicKey": {
18
+ "type": "securestring",
19
+ "metadata": {
20
+ "description": "SSH Key for the Virtual Machine"
21
+ }
22
+ },
23
+ "imagePublisher": {
24
+ "type": "string",
25
+ "metadata": {
26
+ "description": "The publisher of the VM image"
27
+ },
28
+ "defaultValue": ""
29
+ },
30
+ "imageOffer": {
31
+ "type": "string",
32
+ "metadata": {
33
+ "description": "The offer of the VM image"
34
+ },
35
+ "defaultValue": ""
36
+ },
37
+ "imageSku": {
38
+ "type": "string",
39
+ "metadata": {
40
+ "description": "The sku of the VM image"
41
+ },
42
+ "defaultValue": ""
43
+ },
44
+ "imageVersion": {
45
+ "type": "string",
46
+ "metadata": {
47
+ "description": "The version of the VM image"
48
+ },
49
+ "defaultValue": ""
50
+ },
51
+ "imageId": {
52
+ "type": "string",
53
+ "metadata": {
54
+ "description": "The resource id of the VM image. If provided, it will override the imagePublisher, imageOffer, imageSku and imageVersion parameters."
55
+ },
56
+ "defaultValue": ""
57
+ },
58
+ "vmSize": {
59
+ "type": "string",
60
+ "metadata": {
61
+ "description": "The size of the VM"
62
+ }
63
+ },
64
+ "vmTags": {
65
+ "type": "object",
66
+ "metadata": {
67
+ "description": "Tags for the VM"
68
+ }
69
+ },
70
+ "vmCount": {
71
+ "type": "int",
72
+ "metadata": {
73
+ "description": "Number of VMs to deploy"
74
+ }
75
+ },
76
+ "osDiskSize": {
77
+ "type": "int",
78
+ "defaultValue": 0,
79
+ "metadata": {
80
+ "description": "Size of the OS disk in GB"
81
+ }
82
+ },
83
+ "provisionPublicIp": {
84
+ "type": "bool",
85
+ "defaultValue": true,
86
+ "metadata": {
87
+ "description": "If true creates a public ip"
88
+ }
89
+ },
90
+ "priority": {
91
+ "type": "string",
92
+ "defaultValue": "Regular",
93
+ "metadata": {
94
+ "description": "Specifies the priority for the virtual machine."
95
+ }
96
+ },
97
+ "evictionPolicy": {
98
+ "type": "string",
99
+ "defaultValue": "Delete",
100
+ "metadata": {
101
+ "description": "Specifies the eviction policy for the virtual machine."
102
+ }
103
+ },
104
+ "billingProfile": {
105
+ "type": "object",
106
+ "defaultValue": {},
107
+ "metadata": {
108
+ "description": "Specifies the maximum price to pay for Azure Spot VM."
109
+ }
110
+ },
111
+ "msi": {
112
+ "type": "string",
113
+ "metadata": {
114
+ "description": "Managed service identity resource id."
115
+ }
116
+ },
117
+ "nsg": {
118
+ "type": "string",
119
+ "metadata": {
120
+ "description": "Network security group resource id."
121
+ }
122
+ },
123
+ "subnet": {
124
+ "type": "string",
125
+ "metadata": {
126
+ "descriptions": "Subnet resource id."
127
+ }
128
+ },
129
+ "enableAcceleratedNetworking": {
130
+ "type": "bool",
131
+ "defaultValue": false,
132
+ "metadata": {
133
+ "descriptions": "Whether to enable accelerated networking."
134
+ }
135
+ },
136
+ "zones": {
137
+ "type": "array",
138
+ "defaultValue": [],
139
+ "metadata": {
140
+ "description": "Availability zones for the virtual machine. If empty, no zones will be specified."
141
+ }
142
+ }
143
+ },
144
+ "variables": {
145
+ "location": "[resourceGroup().location]",
146
+ "useZones": "[greater(length(parameters('zones')), 0)]",
147
+ "networkInterfaceNamePrivate": "[concat(parameters('vmName'), '-nic')]",
148
+ "networkInterfaceNamePublic": "[concat(parameters('vmName'), '-nic-public')]",
149
+ "networkInterfaceName": "[if(parameters('provisionPublicIp'), variables('networkInterfaceNamePublic'), variables('networkInterfaceNamePrivate'))]",
150
+ "networkIpConfig": "[guid(resourceGroup().id, parameters('vmName'))]",
151
+ "osDiskType": "Standard_LRS",
152
+ "publicIpAddressName": "[concat(parameters('vmName'), '-ip')]"
153
+ },
154
+ "resources": [
155
+ {
156
+ "type": "Microsoft.Network/networkInterfaces",
157
+ "apiVersion": "2024-07-01",
158
+ "name": "[concat(variables('networkInterfaceNamePublic'), copyIndex())]",
159
+ "location": "[variables('location')]",
160
+ "dependsOn": [
161
+ "[resourceId('Microsoft.Network/publicIpAddresses/', concat(variables('publicIpAddressName'), copyIndex()))]"
162
+ ],
163
+ "copy": {
164
+ "name": "NICPublicCopy",
165
+ "count": "[parameters('vmCount')]"
166
+ },
167
+ "properties": {
168
+ "ipConfigurations": [
169
+ {
170
+ "name": "[variables('networkIpConfig')]",
171
+ "properties": {
172
+ "subnet": {
173
+ "id": "[parameters('subnet')]"
174
+ },
175
+ "privateIPAllocationMethod": "Dynamic",
176
+ "publicIpAddress": {
177
+ "id": "[resourceId('Microsoft.Network/publicIPAddresses', concat(variables('publicIPAddressName'), copyIndex()))]"
178
+ }
179
+ }
180
+ }
181
+ ],
182
+ "networkSecurityGroup": {
183
+ "id": "[parameters('nsg')]"
184
+ },
185
+ "enableAcceleratedNetworking": "[parameters('enableAcceleratedNetworking')]"
186
+ },
187
+ "condition": "[parameters('provisionPublicIp')]"
188
+ },
189
+ {
190
+ "type": "Microsoft.Network/networkInterfaces",
191
+ "apiVersion": "2024-07-01",
192
+ "name": "[concat(variables('networkInterfaceNamePrivate'), copyIndex())]",
193
+ "location": "[variables('location')]",
194
+ "copy": {
195
+ "name": "NICPrivateCopy",
196
+ "count": "[parameters('vmCount')]"
197
+ },
198
+ "properties": {
199
+ "ipConfigurations": [
200
+ {
201
+ "name": "[variables('networkIpConfig')]",
202
+ "properties": {
203
+ "subnet": {
204
+ "id": "[parameters('subnet')]"
205
+ },
206
+ "privateIPAllocationMethod": "Dynamic"
207
+ }
208
+ }
209
+ ],
210
+ "networkSecurityGroup": {
211
+ "id": "[parameters('nsg')]"
212
+ },
213
+ "enableAcceleratedNetworking": "[parameters('enableAcceleratedNetworking')]"
214
+ },
215
+ "condition": "[not(parameters('provisionPublicIp'))]"
216
+ },
217
+ {
218
+ "type": "Microsoft.Network/publicIpAddresses",
219
+ "apiVersion": "2024-07-01",
220
+ "name": "[concat(variables('publicIpAddressName'), copyIndex())]",
221
+ "location": "[variables('location')]",
222
+ "properties": {
223
+ "publicIpAllocationMethod": "Static",
224
+ "publicIPAddressVersion": "IPv4"
225
+ },
226
+ "copy": {
227
+ "name": "PublicIpCopy",
228
+ "count": "[parameters('vmCount')]"
229
+ },
230
+ "zones": "[if(variables('useZones'), createArray(string(parameters('zones')[mod(copyIndex(), length(parameters('zones')))])), json('null'))]",
231
+ "sku": {
232
+ "name": "Standard",
233
+ "tier": "Regional"
234
+ },
235
+ "condition": "[parameters('provisionPublicIp')]"
236
+ },
237
+ {
238
+ "type": "Microsoft.Compute/virtualMachines",
239
+ "apiVersion": "2024-07-01",
240
+ "name": "[concat(parameters('vmName'), copyIndex())]",
241
+ "location": "[variables('location')]",
242
+ "dependsOn": [
243
+ "[resourceId('Microsoft.Network/networkInterfaces/', concat(variables('networkInterfaceName'), copyIndex()))]"
244
+ ],
245
+ "copy": {
246
+ "name": "VmCopy",
247
+ "count": "[parameters('vmCount')]"
248
+ },
249
+ "tags": "[parameters('vmTags')]",
250
+ "zones": "[if(variables('useZones'), createArray(string(parameters('zones')[mod(copyIndex(), length(parameters('zones')))])), json('null'))]",
251
+ "properties": {
252
+ "hardwareProfile": {
253
+ "vmSize": "[parameters('vmSize')]"
254
+ },
255
+ "storageProfile": {
256
+ "osDisk": {
257
+ "createOption": "fromImage",
258
+ "diskSizeGB": "[if(equals(parameters('osDiskSize'), 0), json('null'), parameters('osDiskSize'))]",
259
+ "managedDisk": {
260
+ "storageAccountType": "[variables('osDiskType')]"
261
+ }
262
+ },
263
+ "imageReference": "[if(equals(parameters('imageId'), ''), json(concat('{\"publisher\":\"', parameters('imagePublisher'), '\",\"offer\":\"', parameters('imageOffer'), '\",\"sku\":\"', parameters('imageSku'), '\",\"version\":\"', parameters('imageVersion'), '\"}')), json(concat('{\"id\":\"', parameters('imageId'), '\"}')))]"
264
+ },
265
+ "networkProfile": {
266
+ "networkInterfaces": [
267
+ {
268
+ "id": "[resourceId('Microsoft.Network/networkInterfaces', concat(variables('networkInterfaceName'), copyIndex()))]"
269
+ }
270
+ ]
271
+ },
272
+ "osProfile": {
273
+ "computerName": "[concat(parameters('vmName'), copyIndex())]",
274
+ "adminUsername": "[parameters('adminUsername')]",
275
+ "adminPassword": "[parameters('publicKey')]",
276
+ "linuxConfiguration": {
277
+ "disablePasswordAuthentication": true,
278
+ "ssh": {
279
+ "publicKeys": [
280
+ {
281
+ "path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]",
282
+ "keyData": "[parameters('publicKey')]"
283
+ }
284
+ ]
285
+ }
286
+ }
287
+ },
288
+ "priority": "[parameters('priority')]",
289
+ "evictionPolicy": "[if(equals(parameters('priority'), 'Spot'), parameters('evictionPolicy'), '')]",
290
+ "billingProfile": "[parameters('billingProfile')]"
291
+ },
292
+ "identity": {
293
+ "type": "UserAssigned",
294
+ "userAssignedIdentities": {
295
+ "[parameters('msi')]": {
296
+ }
297
+ }
298
+ }
299
+ }
300
+ ],
301
+ "outputs": {
302
+ "publicIp": {
303
+ "type": "array",
304
+ "copy": {
305
+ "count": "[parameters('vmCount')]",
306
+ "input": "[reference(concat(variables('publicIpAddressName'), copyIndex())).ipAddress]"
307
+ },
308
+ "condition": "[parameters('provisionPublicIp')]"
309
+ },
310
+ "privateIp": {
311
+ "type": "array",
312
+ "copy": {
313
+ "count": "[parameters('vmCount')]",
314
+ "input": "[reference(concat(variables('networkInterfaceName'), copyIndex())).ipConfigurations[0].properties.privateIPAddress]"
315
+ }
316
+ }
317
+ }
318
+ }
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/config.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import random
5
+ import time
6
+ from hashlib import md5, sha256
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+ from uuid import UUID
10
+
11
+ from azure.common.credentials import get_cli_profile
12
+ from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
13
+ from azure.identity import AzureCliCredential
14
+ from azure.mgmt.resource import ResourceManagementClient
15
+ from azure.mgmt.resource.resources.models import DeploymentMode
16
+
17
+ from ray.autoscaler._private.util import (
18
+ generate_rsa_key_pair,
19
+ generate_ssh_key_name,
20
+ generate_ssh_key_paths,
21
+ )
22
+
23
+ # Built-in Azure Contributor role definition ID used for role assignments.
24
+ CONTRIBUTOR_ROLE_DEFINITION_ID = "b24988ac-6180-42a0-ab88-20f7382dd24c"
25
+
26
+ UNIQUE_ID_LEN = 4
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def get_azure_sdk_function(client: Any, function_name: str) -> Callable:
32
+ """Retrieve a callable function from Azure SDK client object.
33
+
34
+ Newer versions of the various client SDKs renamed function names to
35
+ have a begin_ prefix. This function supports both the old and new
36
+ versions of the SDK by first trying the old name and falling back to
37
+ the prefixed new name.
38
+ """
39
+ func = getattr(
40
+ client, function_name, getattr(client, f"begin_{function_name}", None)
41
+ )
42
+ if func is None:
43
+ raise AttributeError(
44
+ "'{obj}' object has no {func} or begin_{func} attribute".format(
45
+ obj={client.__name__}, func=function_name
46
+ )
47
+ )
48
+ return func
49
+
50
+
51
+ def bootstrap_azure(config):
52
+ config = _configure_key_pair(config)
53
+ config = _configure_resource_group(config)
54
+ return config
55
+
56
+
57
+ def _configure_resource_group(config):
58
+ # TODO: look at availability sets
59
+ # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/tutorial-availability-sets
60
+ subscription_id = config["provider"].get("subscription_id")
61
+ if subscription_id is None:
62
+ subscription_id = get_cli_profile().get_subscription_id()
63
+ resource_client = ResourceManagementClient(AzureCliCredential(), subscription_id)
64
+ config["provider"]["subscription_id"] = subscription_id
65
+ logger.info("Using subscription id: %s", subscription_id)
66
+
67
+ assert (
68
+ "resource_group" in config["provider"]
69
+ ), "Provider config must include resource_group field"
70
+ resource_group = config["provider"]["resource_group"]
71
+
72
+ assert (
73
+ "location" in config["provider"]
74
+ ), "Provider config must include location field"
75
+ params = {"location": config["provider"]["location"]}
76
+
77
+ if "tags" in config["provider"]:
78
+ params["tags"] = config["provider"]["tags"]
79
+
80
+ logger.info("Creating/Updating resource group: %s", resource_group)
81
+ rg_create_or_update = get_azure_sdk_function(
82
+ client=resource_client.resource_groups, function_name="create_or_update"
83
+ )
84
+ rg_create_or_update(resource_group_name=resource_group, parameters=params)
85
+
86
+ # load the template file
87
+ current_path = Path(__file__).parent
88
+ template_path = current_path.joinpath("azure-config-template.json")
89
+ with open(template_path, "r") as template_fp:
90
+ template = json.load(template_fp)
91
+
92
+ logger.info("Using cluster name: %s", config["cluster_name"])
93
+
94
+ # set unique id for resources in this cluster
95
+ unique_id = config["provider"].get("unique_id")
96
+ if unique_id is None:
97
+ hasher = sha256()
98
+ hasher.update(config["provider"]["resource_group"].encode("utf-8"))
99
+ unique_id = hasher.hexdigest()[:UNIQUE_ID_LEN]
100
+ else:
101
+ unique_id = str(unique_id)
102
+ config["provider"]["unique_id"] = unique_id
103
+ logger.info("Using unique id: %s", unique_id)
104
+ cluster_id = "{}-{}".format(config["cluster_name"], unique_id)
105
+ role_assignment_name = f"ray-{cluster_id}-ra"
106
+ role_assignment_guid = _generate_arm_guid(role_assignment_name)
107
+ role_assignment_resource_id = (
108
+ f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers"
109
+ f"/Microsoft.Authorization/roleAssignments/{role_assignment_guid}"
110
+ )
111
+
112
+ subnet_mask = config["provider"].get("subnet_mask")
113
+ if subnet_mask is None:
114
+ # choose a random subnet, skipping most common value of 0
115
+ random.seed(unique_id)
116
+ subnet_mask = "10.{}.0.0/16".format(random.randint(1, 254))
117
+ logger.info("Using subnet mask: %s", subnet_mask)
118
+
119
+ # Copy over properties from existing subnet.
120
+ # Addresses issue (https://github.com/Azure/azure-quickstart-templates/issues/2786)
121
+ # where existing subnet properties will get overwritten unless explicitly specified
122
+ # during multiple deployments even if vnet/subnet do not change.
123
+ # May eventually be fixed by passing empty subnet list if they already exist:
124
+ # https://techcommunity.microsoft.com/t5/azure-networking-blog/azure-virtual-network-now-supports-updates-without-subnet/ba-p/4067952
125
+ list_by_rg = get_azure_sdk_function(
126
+ client=resource_client.resources, function_name="list_by_resource_group"
127
+ )
128
+ existing_vnets = list(
129
+ list_by_rg(
130
+ resource_group,
131
+ f"substringof('{unique_id}', name) and "
132
+ "resourceType eq 'Microsoft.Network/virtualNetworks'",
133
+ )
134
+ )
135
+ if len(existing_vnets) > 0:
136
+ vnid = existing_vnets[0].id
137
+ get_by_id = get_azure_sdk_function(
138
+ client=resource_client.resources, function_name="get_by_id"
139
+ )
140
+
141
+ # Query for supported API versions for Microsoft.Network/virtualNetworks
142
+ # because resource_client.DEFAULT_API_VERSION is not always supported.
143
+ # (Example: "2024-11-01" is the default at the time of this writing)
144
+ # Use "2024-10-01" as a fallback if we can't determine the latest stable version.
145
+ vnet_api_version = "2024-10-01"
146
+ try:
147
+ # Get supported API versions for Microsoft.Network provider
148
+ providers = resource_client.providers.get("Microsoft.Network")
149
+ vnet_resource_type = next(
150
+ (
151
+ rt
152
+ for rt in providers.resource_types
153
+ if rt.resource_type == "virtualNetworks"
154
+ ),
155
+ None,
156
+ )
157
+ if vnet_resource_type and vnet_resource_type.api_versions:
158
+ stable_versions = [
159
+ v for v in vnet_resource_type.api_versions if "preview" not in v
160
+ ]
161
+ versions_to_consider = (
162
+ stable_versions or vnet_resource_type.api_versions
163
+ )
164
+ vnet_api_version = sorted(versions_to_consider)[-1]
165
+ logger.info(
166
+ "Using API version: %s for virtualNetworks", vnet_api_version
167
+ )
168
+ else:
169
+ logger.warning(
170
+ "Could not determine supported API versions for virtualNetworks, using fallback version %s",
171
+ vnet_api_version,
172
+ )
173
+ except Exception as e:
174
+ logger.warning(
175
+ "Failed to query Microsoft.Network provider: %s. Using fallback API version 2024-10-01",
176
+ str(e),
177
+ )
178
+
179
+ subnet = get_by_id(vnid, vnet_api_version).properties["subnets"][0]
180
+ template_vnet = next(
181
+ (
182
+ rs
183
+ for rs in template["resources"]
184
+ if rs["type"] == "Microsoft.Network/virtualNetworks"
185
+ ),
186
+ None,
187
+ )
188
+ if template_vnet is not None:
189
+ template_subnets = template_vnet["properties"].get("subnets")
190
+ if template_subnets is not None:
191
+ template_subnets[0]["properties"].update(subnet["properties"])
192
+
193
+ # Get or create an MSI name and resource group.
194
+ # Defaults to current resource group if not provided.
195
+ use_existing_msi = (
196
+ "msi_name" in config["provider"] and "msi_resource_group" in config["provider"]
197
+ )
198
+ msi_resource_group = config["provider"].get("msi_resource_group", resource_group)
199
+ msi_name = config["provider"].get("msi_name", f"ray-{cluster_id}-msi")
200
+ logger.info(
201
+ "Using msi_name: %s from msi_resource_group: %s", msi_name, msi_resource_group
202
+ )
203
+
204
+ existing_principal_id = None
205
+ if not use_existing_msi:
206
+ # When creating a MSI for managing Azure resources, we first need to clean up
207
+ # any role assignments from the previous MSI's principal ID to avoid
208
+ # orphaned permissions when the MSI gets recreated with a new principal ID.
209
+ # Role assignments cannot be updated, only created/removed, so cleanup is required.
210
+ msi_resource_id = (
211
+ f"/subscriptions/{subscription_id}/resourceGroups/{msi_resource_group}"
212
+ f"/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{msi_name}"
213
+ )
214
+ try:
215
+ get_identity = get_azure_sdk_function(
216
+ client=resource_client.resources, function_name="get_by_id"
217
+ )
218
+ existing_msi = get_identity(msi_resource_id, "2023-01-31")
219
+ existing_principal_id = getattr(existing_msi, "properties", {}).get(
220
+ "principalId"
221
+ )
222
+ except ResourceNotFoundError:
223
+ existing_principal_id = None
224
+ except Exception as exc:
225
+ logger.warning(
226
+ "Failed to query MSI %s for existing principal: %s",
227
+ msi_name,
228
+ exc,
229
+ )
230
+
231
+ if existing_principal_id:
232
+ logger.info(
233
+ "Removing existing role assignments for MSI principal %s before recreation",
234
+ existing_principal_id,
235
+ )
236
+ _delete_role_assignments_for_principal(
237
+ resource_client,
238
+ resource_group,
239
+ existing_principal_id,
240
+ )
241
+
242
+ delete_role_assignment = get_azure_sdk_function(
243
+ client=resource_client.resources, function_name="delete_by_id"
244
+ )
245
+ get_role_assignment = get_azure_sdk_function(
246
+ client=resource_client.resources, function_name="get_by_id"
247
+ )
248
+
249
+ role_assignment_known_missing = False
250
+ initial_query_failed = False
251
+ try:
252
+ get_role_assignment(
253
+ role_assignment_resource_id,
254
+ "2022-04-01",
255
+ )
256
+ except ResourceNotFoundError:
257
+ role_assignment_known_missing = True
258
+ logger.debug(
259
+ "Role assignment %s not found before MSI creation; skipping deletion",
260
+ role_assignment_guid,
261
+ )
262
+ except Exception as exc:
263
+ logger.warning(
264
+ "Failed to query role assignment %s before deletion: %s",
265
+ role_assignment_guid,
266
+ exc,
267
+ )
268
+ initial_query_failed = True
269
+
270
+ if not role_assignment_known_missing:
271
+ try:
272
+ delete_lro = delete_role_assignment(
273
+ resource_id=role_assignment_resource_id,
274
+ api_version="2022-04-01",
275
+ )
276
+ if hasattr(delete_lro, "wait"):
277
+ delete_lro.wait()
278
+ logger.info(
279
+ "Deleted existing role assignment %s before recreating MSI",
280
+ role_assignment_guid,
281
+ )
282
+
283
+ if initial_query_failed:
284
+ logger.debug(
285
+ "Retrying verification for role assignment %s",
286
+ role_assignment_guid,
287
+ )
288
+
289
+ if not _wait_for_role_assignment_deletion(
290
+ get_role_assignment,
291
+ role_assignment_resource_id,
292
+ role_assignment_guid,
293
+ ):
294
+ logger.warning(
295
+ "Role assignment %s not confirmed deleted",
296
+ role_assignment_guid,
297
+ )
298
+ except ResourceNotFoundError:
299
+ logger.debug(
300
+ "Role assignment %s disappeared before deletion attempt; continuing",
301
+ role_assignment_guid,
302
+ )
303
+ except Exception as e:
304
+ logger.warning(
305
+ "Failed to delete role assignment %s before MSI creation: %s",
306
+ role_assignment_guid,
307
+ e,
308
+ )
309
+
310
+ parameters = {
311
+ "properties": {
312
+ "mode": DeploymentMode.incremental,
313
+ "template": template,
314
+ "parameters": {
315
+ "subnet": {"value": subnet_mask},
316
+ "clusterId": {"value": cluster_id},
317
+ "msiName": {"value": msi_name},
318
+ "msiResourceGroup": {"value": msi_resource_group},
319
+ "createMsi": {"value": not use_existing_msi},
320
+ "roleAssignmentGuid": {"value": role_assignment_guid},
321
+ },
322
+ }
323
+ }
324
+
325
+ create_or_update = get_azure_sdk_function(
326
+ client=resource_client.deployments, function_name="create_or_update"
327
+ )
328
+ outputs = (
329
+ create_or_update(
330
+ resource_group_name=resource_group,
331
+ deployment_name="ray-config",
332
+ parameters=parameters,
333
+ )
334
+ .result()
335
+ .properties.outputs
336
+ )
337
+
338
+ # append output resource ids to be used with vm creation
339
+ config["provider"]["msi"] = outputs["msi"]["value"]
340
+ config["provider"]["nsg"] = outputs["nsg"]["value"]
341
+ config["provider"]["subnet"] = outputs["subnet"]["value"]
342
+
343
+ return config
344
+
345
+
346
+ def _configure_key_pair(config):
347
+ """
348
+ Configure SSH keypair. Use user specified custom paths, otherwise,
349
+ generate Ray-specific keypair in this format: "ray-autoscaler_azure_{region}_{resource_group}_{ssh_user}_{index}"
350
+ """
351
+ ssh_user = config["auth"]["ssh_user"]
352
+ public_key = None
353
+
354
+ # Check if user specified custom SSH key paths
355
+ user_specified_private_key = "ssh_private_key" in config["auth"]
356
+ user_specified_public_key = "ssh_public_key" in config["auth"]
357
+
358
+ # Validate that the user either specfied both keys or none, but not just one
359
+ if user_specified_private_key != user_specified_public_key:
360
+ if user_specified_private_key:
361
+ missing_key, specified_key = "ssh_public_key", "ssh_private_key"
362
+ else:
363
+ missing_key, specified_key = "ssh_private_key", "ssh_public_key"
364
+ raise ValueError(
365
+ f"{specified_key} is specified but {missing_key} is missing. "
366
+ "Both SSH key paths must be specified together, or omit both from "
367
+ "your config to use auto-generated keys."
368
+ )
369
+
370
+ if user_specified_private_key and user_specified_public_key:
371
+ # User specified custom paths
372
+ private_key_path = Path(config["auth"]["ssh_private_key"]).expanduser()
373
+ public_key_path = Path(config["auth"]["ssh_public_key"]).expanduser()
374
+
375
+ # Validate that user-specified keys exist
376
+ missing_keys = []
377
+ if not private_key_path.is_file():
378
+ missing_keys.append(f"ssh_private_key: {private_key_path}")
379
+ if not public_key_path.is_file():
380
+ missing_keys.append(f"ssh_public_key: {public_key_path}")
381
+
382
+ if missing_keys:
383
+ raise ValueError(
384
+ "SSH key files from config do not exist: {}. "
385
+ "Please create the keys or remove the custom paths from your config "
386
+ "to use auto-generated keys.".format(", ".join(missing_keys))
387
+ )
388
+ logger.info(
389
+ "Using specified SSH keys from config: {} and {}".format(
390
+ private_key_path, public_key_path
391
+ )
392
+ )
393
+
394
+ with open(public_key_path, "r") as f:
395
+ public_key = f.read()
396
+ else:
397
+ # Generate Ray-specific keys
398
+ region = config["provider"]["location"]
399
+ resource_group = config["provider"]["resource_group"]
400
+
401
+ # Generate single deterministic key name for this configuration
402
+ key_name = generate_ssh_key_name(
403
+ "azure", None, region, resource_group, ssh_user
404
+ )
405
+ public_key_path, private_key_path = generate_ssh_key_paths(key_name)
406
+
407
+ # Check if this key pair already exists
408
+ if os.path.exists(private_key_path) and os.path.exists(public_key_path):
409
+ logger.info(
410
+ "Using existing Ray-specific SSH keys: {} and {}".format(
411
+ private_key_path, public_key_path
412
+ )
413
+ )
414
+ with open(public_key_path, "r") as f:
415
+ public_key = f.read()
416
+ else:
417
+ # Create a key pair since it doesn't exist locally
418
+ logger.info(
419
+ "Generating new Ray-specific SSH key pair at {} and {}".format(
420
+ private_key_path, public_key_path
421
+ )
422
+ )
423
+ os.makedirs(os.path.dirname(private_key_path), exist_ok=True)
424
+ public_key, private_key = generate_rsa_key_pair()
425
+ with open(
426
+ private_key_path,
427
+ "w",
428
+ opener=lambda path, flags: os.open(path, flags, 0o600),
429
+ ) as f:
430
+ f.write(private_key)
431
+ with open(public_key_path, "w") as f:
432
+ f.write(public_key)
433
+
434
+ assert os.path.exists(
435
+ private_key_path
436
+ ), "Private key file {} not found for user {}".format(
437
+ private_key_path, ssh_user
438
+ )
439
+
440
+ config["auth"]["ssh_private_key"] = str(private_key_path)
441
+ # Remove public key path because bootstrap config must only contain paths that exist on head node
442
+ config["auth"].pop("ssh_public_key", None)
443
+
444
+ for node_type in config["available_node_types"].values():
445
+ azure_arm_parameters = node_type["node_config"].setdefault(
446
+ "azure_arm_parameters", {}
447
+ )
448
+ azure_arm_parameters["adminUsername"] = ssh_user
449
+ azure_arm_parameters["publicKey"] = public_key
450
+
451
+ return config
452
+
453
+
454
+ def _delete_role_assignments_for_principal(
455
+ resource_client: ResourceManagementClient,
456
+ resource_group: str,
457
+ principal_id: str,
458
+ ) -> None:
459
+ """Delete all role assignments in the resource group for the given principal.
460
+
461
+ Uses the generic ResourceManagementClient so we avoid depending on
462
+ azure-mgmt-authorization. All role assignments associated with the
463
+ provided principal ID are removed.
464
+ """
465
+
466
+ if not principal_id:
467
+ return
468
+
469
+ list_by_rg = get_azure_sdk_function(
470
+ client=resource_client.resources, function_name="list_by_resource_group"
471
+ )
472
+ delete_role_assignment = get_azure_sdk_function(
473
+ client=resource_client.resources, function_name="delete_by_id"
474
+ )
475
+
476
+ try:
477
+ assignments = list(
478
+ list_by_rg(
479
+ resource_group,
480
+ "resourceType eq 'Microsoft.Authorization/roleAssignments'",
481
+ )
482
+ )
483
+ logger.debug(
484
+ "Found %d role assignments in resource group %s while cleaning up principal %s",
485
+ len(assignments),
486
+ resource_group,
487
+ principal_id,
488
+ )
489
+ except HttpResponseError as exc:
490
+ logger.warning(
491
+ "Failed to enumerate role assignments for resource group %s: %s",
492
+ resource_group,
493
+ exc,
494
+ )
495
+ return
496
+
497
+ for assignment in assignments:
498
+ properties = getattr(assignment, "properties", {}) or {}
499
+ logger.debug(
500
+ "Inspecting role assignment %s with principalId=%s roleDefinitionId=%s",
501
+ getattr(assignment, "name", "<unknown>"),
502
+ properties.get("principalId"),
503
+ properties.get("roleDefinitionId"),
504
+ )
505
+ if properties.get("principalId") != principal_id:
506
+ continue
507
+
508
+ try:
509
+ delete_lro = delete_role_assignment(
510
+ resource_id=assignment.id,
511
+ api_version="2022-04-01",
512
+ )
513
+ if hasattr(delete_lro, "wait"):
514
+ delete_lro.wait()
515
+ logger.info(
516
+ "Deleted existing role assignment %s for principal %s",
517
+ assignment.name,
518
+ principal_id,
519
+ )
520
+ except ResourceNotFoundError:
521
+ logger.debug(
522
+ "Role assignment %s not found while processing principal %s",
523
+ assignment.name,
524
+ principal_id,
525
+ )
526
+ except Exception as exc:
527
+ logger.warning(
528
+ "Failed to delete role assignment %s for principal %s: %s",
529
+ assignment.name,
530
+ principal_id,
531
+ exc,
532
+ )
533
+
534
+
535
+ def _wait_for_role_assignment_deletion(
536
+ get_role_assignment: Callable[..., Any],
537
+ resource_id: str,
538
+ role_assignment_guid: str,
539
+ *,
540
+ max_attempts: int = 10,
541
+ delay_seconds: int = 2,
542
+ ) -> bool:
543
+ """Poll until a role assignment disappears after deletion.
544
+
545
+ Returns True if the assignment is confirmed deleted, False otherwise.
546
+ Logs detailed progress to aid troubleshooting when transient errors occur.
547
+ """
548
+
549
+ for attempt in range(1, max_attempts + 1):
550
+ try:
551
+ get_role_assignment(resource_id, "2022-04-01")
552
+ except ResourceNotFoundError:
553
+ return True
554
+ except Exception as exc: # noqa: BLE001
555
+ logger.debug(
556
+ "Attempt %d/%d to verify removal of role assignment %s failed: %s",
557
+ attempt,
558
+ max_attempts,
559
+ role_assignment_guid,
560
+ exc,
561
+ )
562
+ else:
563
+ logger.debug(
564
+ "Role assignment %s still present after deletion (attempt %d/%d)",
565
+ role_assignment_guid,
566
+ attempt,
567
+ max_attempts,
568
+ )
569
+
570
+ if attempt < max_attempts:
571
+ time.sleep(delay_seconds)
572
+
573
+ return False
574
+
575
+
576
+ def _generate_arm_guid(*values: Any) -> str:
577
+ """Replicates ARM template guid() function for creating deterministic IDs."""
578
+
579
+ concatenated = "".join(str(v) for v in values)
580
+ return str(UUID(md5(concatenated.encode("utf-8")).hexdigest()))
lib/python3.12/site-packages/ray/autoscaler/_private/_azure/node_provider.py ADDED
@@ -0,0 +1,942 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import time
5
+ from concurrent.futures import Future, ThreadPoolExecutor
6
+ from pathlib import Path
7
+ from threading import RLock
8
+ from typing import List, Optional
9
+ from uuid import uuid4
10
+
11
+ from azure.common.credentials import get_cli_profile
12
+ from azure.core.exceptions import ResourceNotFoundError
13
+ from azure.identity import DefaultAzureCredential
14
+ from azure.mgmt.compute import ComputeManagementClient
15
+ from azure.mgmt.network import NetworkManagementClient
16
+ from azure.mgmt.resource import ResourceManagementClient
17
+ from azure.mgmt.resource.resources.models import DeploymentMode
18
+
19
+ from ray._common.usage.usage_lib import get_cloud_from_metadata_requests
20
+ from ray.autoscaler._private._azure.config import (
21
+ _delete_role_assignments_for_principal,
22
+ _generate_arm_guid,
23
+ bootstrap_azure,
24
+ get_azure_sdk_function,
25
+ )
26
+ from ray.autoscaler._private.constants import (
27
+ AUTOSCALER_NODE_START_WAIT_S,
28
+ AUTOSCALER_NODE_TERMINATE_WAIT_S,
29
+ MAX_PARALLEL_SHUTDOWN_WORKERS,
30
+ )
31
+ from ray.autoscaler.node_provider import NodeProvider
32
+ from ray.autoscaler.tags import (
33
+ NODE_KIND_HEAD,
34
+ TAG_RAY_CLUSTER_NAME,
35
+ TAG_RAY_LAUNCH_CONFIG,
36
+ TAG_RAY_NODE_KIND,
37
+ TAG_RAY_NODE_NAME,
38
+ TAG_RAY_USER_NODE_TYPE,
39
+ )
40
+
41
+ VM_NAME_MAX_LEN = 64
42
+ UNIQUE_ID_LEN = 4
43
+
44
+ logger = logging.getLogger(__name__)
45
+ azure_logger = logging.getLogger("azure.core.pipeline.policies.http_logging_policy")
46
+ azure_logger.setLevel(logging.WARNING)
47
+
48
+
49
+ def synchronized(f):
50
+ def wrapper(self, *args, **kwargs):
51
+ self.lock.acquire()
52
+ try:
53
+ return f(self, *args, **kwargs)
54
+ finally:
55
+ self.lock.release()
56
+
57
+ return wrapper
58
+
59
+
60
+ class AzureNodeProvider(NodeProvider):
61
+ """Node Provider for Azure
62
+
63
+ This provider assumes Azure credentials are set by running ``az login``
64
+ and the default subscription is configured through ``az account``
65
+ or set in the ``provider`` field of the autoscaler configuration.
66
+
67
+ Nodes may be in one of three states: {pending, running, terminated}. Nodes
68
+ appear immediately once started by ``create_node``, and transition
69
+ immediately to terminated when ``terminate_node`` is called.
70
+ """
71
+
72
+ def __init__(self, provider_config, cluster_name):
73
+ NodeProvider.__init__(self, provider_config, cluster_name)
74
+ subscription_id = provider_config.get("subscription_id")
75
+ if subscription_id is None:
76
+ # Get subscription from logged in azure profile
77
+ # if it isn't provided in the provider_config
78
+ # so operations like `get-head-ip` will work
79
+ subscription_id = get_cli_profile().get_subscription_id()
80
+ logger.info(
81
+ "subscription_id not found in provider config, falling back "
82
+ f"to subscription_id from the logged in azure profile: {subscription_id}"
83
+ )
84
+ self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
85
+
86
+ # Detect cloud environment to optimize Azure credential chain.
87
+ # On non-Azure clouds (AWS, GCP), skip Azure-specific auth methods
88
+ # (managed identity, workload identity) to avoid IMDS timeout delays / failures.
89
+ detected_cloud = get_cloud_from_metadata_requests()
90
+ on_azure = detected_cloud == "azure"
91
+
92
+ if on_azure:
93
+ logger.info(
94
+ "Initializing Azure node provider for Azure infrastructure "
95
+ "running on Azure cloud environment"
96
+ )
97
+ else:
98
+ logger.info(
99
+ f"Initializing Azure node provider for Azure infrastructure "
100
+ f"but detected this is running on a '{detected_cloud}' environment. "
101
+ f"Skipping Azure-specific authentication methods to avoid timeouts."
102
+ )
103
+
104
+ credential = DefaultAzureCredential(
105
+ exclude_shared_token_cache_credential=True,
106
+ exclude_managed_identity_credential=not on_azure,
107
+ exclude_workload_identity_credential=not on_azure,
108
+ )
109
+ self.compute_client = ComputeManagementClient(credential, subscription_id)
110
+ self.network_client = NetworkManagementClient(credential, subscription_id)
111
+ self.resource_client = ResourceManagementClient(credential, subscription_id)
112
+
113
+ self.lock = RLock()
114
+
115
+ # cache node objects
116
+ self.cached_nodes = {}
117
+
118
+ # Cache terminating node operations
119
+ self.terminating_nodes: dict[str, Future] = {}
120
+ self.termination_executor = ThreadPoolExecutor(
121
+ max_workers=MAX_PARALLEL_SHUTDOWN_WORKERS
122
+ )
123
+
124
+ @synchronized
125
+ def _get_filtered_nodes(self, tag_filters):
126
+ # add cluster name filter to only get nodes from this cluster
127
+ cluster_tag_filters = {**tag_filters, TAG_RAY_CLUSTER_NAME: self.cluster_name}
128
+
129
+ def match_tags(tags):
130
+ for k, v in cluster_tag_filters.items():
131
+ if tags.get(k) != v:
132
+ return False
133
+ return True
134
+
135
+ vms = self.compute_client.virtual_machines.list(
136
+ resource_group_name=self.provider_config["resource_group"]
137
+ )
138
+
139
+ nodes = [self._extract_metadata(vm) for vm in vms]
140
+ self.cached_nodes = {node["name"]: node for node in nodes}
141
+
142
+ # Update terminating nodes list by removing nodes that
143
+ # have finished termination.
144
+ self.terminating_nodes = {
145
+ k: v for k, v in self.terminating_nodes.items() if not v.done()
146
+ }
147
+
148
+ return {
149
+ k: v
150
+ for k, v in self.cached_nodes.items()
151
+ if v["tags"] is not None and match_tags(v["tags"])
152
+ }
153
+
154
+ def _extract_metadata(self, vm):
155
+ # get tags
156
+ metadata = {"name": vm.name, "tags": vm.tags, "status": ""}
157
+
158
+ # get status
159
+ resource_group = self.provider_config["resource_group"]
160
+ try:
161
+ instance = self.compute_client.virtual_machines.instance_view(
162
+ resource_group_name=resource_group, vm_name=vm.name
163
+ ).as_dict()
164
+ except ResourceNotFoundError:
165
+ return metadata
166
+
167
+ for status in instance["statuses"]:
168
+ # If ProvisioningState is "failed" (e.g.,
169
+ # ProvisioningState/failed/RetryableError), we can get a third
170
+ # string here, so we need to limit to the first two outputs.
171
+ code, state = status["code"].split("/")[:2]
172
+ # skip provisioning status
173
+ if code == "PowerState":
174
+ metadata["status"] = state
175
+ break
176
+
177
+ # get ip data
178
+ nic_id = vm.network_profile.network_interfaces[0].id
179
+ metadata["nic_name"] = nic_id.split("/")[-1]
180
+ nic = self.network_client.network_interfaces.get(
181
+ resource_group_name=resource_group,
182
+ network_interface_name=metadata["nic_name"],
183
+ )
184
+ ip_config = nic.ip_configurations[0]
185
+
186
+ # Get public IP if not using internal IPs or if this is the
187
+ # head node and use_external_head_ip is True
188
+ if not self.provider_config.get("use_internal_ips", False) or (
189
+ self.provider_config.get("use_external_head_ip", False)
190
+ and metadata["tags"][TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
191
+ ):
192
+ public_ip_id = ip_config.public_ip_address.id
193
+ metadata["public_ip_name"] = public_ip_id.split("/")[-1]
194
+ public_ip = self.network_client.public_ip_addresses.get(
195
+ resource_group_name=resource_group,
196
+ public_ip_address_name=metadata["public_ip_name"],
197
+ )
198
+ metadata["external_ip"] = public_ip.ip_address
199
+
200
+ metadata["internal_ip"] = ip_config.private_ip_address
201
+
202
+ return metadata
203
+
204
+ def _get_zones_for_vm_size(self, vm_size, location):
205
+ """Get usable availability zones for a given VM size in a specific location."""
206
+ try:
207
+ # Note: Azure ResourceSKUs API filters don't work reliably(?), so we query all SKUs
208
+ # and filter in code. Each SKU object represents one location for the VM size.
209
+ skus = self.compute_client.resource_skus.list()
210
+
211
+ for sku in skus:
212
+ if sku.name == vm_size and sku.location_info:
213
+ # Each SKU object represents one location, check if it matches our target
214
+ for location_info in sku.location_info:
215
+ if location_info.location.lower() == location.lower():
216
+ zones = location_info.zones if location_info.zones else []
217
+ logger.debug(
218
+ f"Found {vm_size} in {location} with zones: {zones}"
219
+ )
220
+ return sorted(zones)
221
+
222
+ logger.warning(f"No zones found for {vm_size} in {location}")
223
+ return [] # No zones available for this VM size
224
+ except Exception as e:
225
+ logger.warning(
226
+ f"Failed to get zones for VM size {vm_size} in location {location}: {str(e)}"
227
+ )
228
+ return []
229
+
230
+ def _parse_availability_zones(
231
+ self, availability_zone_config: Optional[str]
232
+ ) -> Optional[List[str]]:
233
+ """Parse availability_zone configuration from comma-separated string format.
234
+
235
+ Args:
236
+ availability_zone_config: Can be:
237
+ - String: comma-separated zones like "1,2,3"
238
+ - "none": explicitly disable zones
239
+ - "auto": let Azure automatically pick zones
240
+ - None: no zones specified (defaults to letting Azure pick)
241
+
242
+ Returns:
243
+ List of zone strings, or None if zones explicitly disabled, or [] if auto/unspecified
244
+ """
245
+ if availability_zone_config is None:
246
+ return [] # Auto - let Azure pick
247
+
248
+ # Handle string format (AWS-style comma-separated)
249
+ if isinstance(availability_zone_config, str):
250
+ # Strip whitespace and split by comma
251
+ zones = [zone.strip() for zone in availability_zone_config.split(",")]
252
+
253
+ # Handle special cases (case-insensitive)
254
+ if len(zones) == 1:
255
+ zone_lower = zones[0].lower()
256
+ if zone_lower in ["none", "null"]:
257
+ return None # Explicitly disabled
258
+ elif zone_lower == "auto":
259
+ return [] # Auto - let Azure pick
260
+
261
+ # Handle empty string or whitespace-only
262
+ if not zones or all(not zone for zone in zones):
263
+ return [] # Auto - let Azure pick
264
+ return zones
265
+
266
+ # Unsupported format
267
+ raise ValueError(
268
+ f"availability_zone must be a string, got {type(availability_zone_config).__name__}: {availability_zone_config!r}"
269
+ )
270
+
271
+ def _validate_zones_for_node_pool(self, zones, location, vm_size):
272
+ """Validate that the specified zones are available for the given VM size in the location."""
273
+ # Special case: zones=None means explicitly disabled availability zones
274
+ if zones is None:
275
+ logger.info(
276
+ "Zones explicitly disabled with 'none' - will create VM without an availability zone"
277
+ )
278
+ return None # Special return value to indicate "no zones by choice"
279
+
280
+ vm_zones = self._get_zones_for_vm_size(vm_size, location)
281
+
282
+ available_zones = set(vm_zones)
283
+ if not available_zones:
284
+ logger.warning("No zones available for this VM size and location")
285
+ return []
286
+
287
+ if zones:
288
+ requested_zones = {str(z) for z in zones}
289
+ intersection = sorted(available_zones.intersection(requested_zones))
290
+ return intersection
291
+
292
+ return sorted(available_zones)
293
+
294
+ def stopped_nodes(self, tag_filters):
295
+ """Return a list of stopped node ids filtered by the specified tags dict."""
296
+ nodes = self._get_filtered_nodes(tag_filters=tag_filters)
297
+ return [k for k, v in nodes.items() if v["status"].startswith("deallocat")]
298
+
299
+ def non_terminated_nodes(self, tag_filters):
300
+ """Return a list of node ids filtered by the specified tags dict.
301
+
302
+ This list must not include terminated nodes. For performance reasons,
303
+ providers are allowed to cache the result of a call to nodes() to
304
+ serve single-node queries (e.g. is_running(node_id)). This means that
305
+ nodes() must be called again to refresh results.
306
+
307
+ Examples:
308
+ >>> from ray.autoscaler.tags import TAG_RAY_NODE_KIND
309
+ >>> provider = ... # doctest: +SKIP
310
+ >>> provider.non_terminated_nodes( # doctest: +SKIP
311
+ ... {TAG_RAY_NODE_KIND: "worker"})
312
+ ["node-1", "node-2"]
313
+ """
314
+ nodes = self._get_filtered_nodes(tag_filters=tag_filters)
315
+ return [
316
+ k
317
+ for k, v in nodes.items()
318
+ if not v["status"].startswith("deallocat") or k in self.terminating_nodes
319
+ ]
320
+
321
+ def is_running(self, node_id):
322
+ """Return whether the specified node is running."""
323
+ # always get current status
324
+ node = self._get_node(node_id=node_id)
325
+ return node["status"] == "running"
326
+
327
+ def is_terminated(self, node_id):
328
+ """Return whether the specified node is terminated."""
329
+ # always get current status
330
+ node = self._get_node(node_id=node_id)
331
+ return node["status"].startswith("deallocat")
332
+
333
+ def node_tags(self, node_id):
334
+ """Returns the tags of the given node (string dict)."""
335
+ return self._get_cached_node(node_id=node_id)["tags"]
336
+
337
+ def external_ip(self, node_id):
338
+ """Returns the external ip of the given node."""
339
+ ip = (
340
+ self._get_cached_node(node_id=node_id)["external_ip"]
341
+ or self._get_node(node_id=node_id)["external_ip"]
342
+ )
343
+ return ip
344
+
345
+ def internal_ip(self, node_id):
346
+ """Returns the internal ip (Ray ip) of the given node."""
347
+ ip = (
348
+ self._get_cached_node(node_id=node_id)["internal_ip"]
349
+ or self._get_node(node_id=node_id)["internal_ip"]
350
+ )
351
+ return ip
352
+
353
+ def create_node(self, node_config, tags, count):
354
+ resource_group = self.provider_config["resource_group"]
355
+
356
+ if self.cache_stopped_nodes:
357
+ VALIDITY_TAGS = [
358
+ TAG_RAY_CLUSTER_NAME,
359
+ TAG_RAY_NODE_KIND,
360
+ TAG_RAY_LAUNCH_CONFIG,
361
+ TAG_RAY_USER_NODE_TYPE,
362
+ ]
363
+ filters = {tag: tags[tag] for tag in VALIDITY_TAGS if tag in tags}
364
+ reuse_nodes = self.stopped_nodes(filters)[:count]
365
+ logger.info(
366
+ f"Reusing nodes {list(reuse_nodes)}. "
367
+ "To disable reuse, set `cache_stopped_nodes: False` "
368
+ "under `provider` in the cluster configuration.",
369
+ )
370
+ start = get_azure_sdk_function(
371
+ client=self.compute_client.virtual_machines, function_name="start"
372
+ )
373
+ for node_id in reuse_nodes:
374
+ start(resource_group_name=resource_group, vm_name=node_id).wait()
375
+ self.set_node_tags(node_id, tags)
376
+ count -= len(reuse_nodes)
377
+
378
+ if count:
379
+ self._create_node(node_config, tags, count)
380
+
381
+ def _create_node(self, node_config, tags, count):
382
+ """Creates a number of nodes within the namespace."""
383
+ resource_group = self.provider_config["resource_group"]
384
+ location = self.provider_config["location"]
385
+ vm_size = node_config["azure_arm_parameters"]["vmSize"]
386
+
387
+ # Determine availability zones with precedence: node-level > provider-level
388
+ # Check for "availability_zone" field in node config first
389
+ node_availability_zone = node_config.get("azure_arm_parameters", {}).get(
390
+ "availability_zone"
391
+ )
392
+ # Then check provider-level "availability_zone"
393
+ provider_availability_zone = self.provider_config.get("availability_zone")
394
+
395
+ requested_zones = []
396
+ zone_source = "default"
397
+
398
+ # Precedence: node availability_zone > provider availability_zone
399
+ if node_availability_zone is not None:
400
+ requested_zones = self._parse_availability_zones(node_availability_zone)
401
+ zone_source = "node config availability_zone"
402
+ elif provider_availability_zone is not None:
403
+ requested_zones = self._parse_availability_zones(provider_availability_zone)
404
+ zone_source = "provider availability_zone"
405
+
406
+ logger.info(f"Requested zones from {zone_source}: {requested_zones}")
407
+
408
+ # Get actually available zones for this VM size
409
+ available_zones = self._validate_zones_for_node_pool(
410
+ requested_zones, location, vm_size
411
+ )
412
+
413
+ # Handle explicit zone disabling
414
+ zones_explicitly_disabled = available_zones is None
415
+
416
+ if requested_zones and not zones_explicitly_disabled and not available_zones:
417
+ raise ValueError(
418
+ f"No available zones for VM size {vm_size} in {location}. "
419
+ f"Requested: {requested_zones}, but none are available for this VM size."
420
+ )
421
+
422
+ # load the template file
423
+ current_path = Path(__file__).parent
424
+ template_path = current_path.joinpath("azure-vm-template.json")
425
+ with open(template_path, "r") as template_fp:
426
+ template = json.load(template_fp)
427
+
428
+ # get the tags
429
+ config_tags = node_config.get("tags", {}).copy()
430
+ config_tags.update(tags)
431
+ config_tags[TAG_RAY_CLUSTER_NAME] = self.cluster_name
432
+
433
+ deployment_name = "{node}-{unique_id}-{vm_id}".format(
434
+ node=config_tags.get(TAG_RAY_NODE_NAME, "node"),
435
+ unique_id=self.provider_config["unique_id"],
436
+ vm_id=uuid4().hex[:UNIQUE_ID_LEN],
437
+ )[:VM_NAME_MAX_LEN]
438
+
439
+ template_params = node_config["azure_arm_parameters"].copy()
440
+ # Remove availability_zone from template params since ARM template expects "zones"
441
+ template_params.pop("availability_zone", None)
442
+ # Use deployment_name for the vmName template parameter since
443
+ # the template will append copyIndex() for each VM that gets created
444
+ # to guarantee uniqueness.
445
+ template_params["vmName"] = deployment_name
446
+ # Provision public IP if not using internal IPs or if this is the
447
+ # head node and use_external_head_ip is True
448
+ template_params["provisionPublicIp"] = not self.provider_config.get(
449
+ "use_internal_ips", False
450
+ ) or (
451
+ self.provider_config.get("use_external_head_ip", False)
452
+ and config_tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
453
+ )
454
+ template_params["vmTags"] = config_tags
455
+ template_params["vmCount"] = count
456
+ template_params["msi"] = self.provider_config["msi"]
457
+ template_params["nsg"] = self.provider_config["nsg"]
458
+ template_params["subnet"] = self.provider_config["subnet"]
459
+
460
+ # Add zone information based on availability and requested zones
461
+ if zones_explicitly_disabled:
462
+ # User explicitly disabled zones with ["None"]
463
+ template_params["zones"] = []
464
+ logger.info(
465
+ f"Creating {count} VMs with zones explicitly disabled (no availability zone)"
466
+ )
467
+ elif available_zones:
468
+ # Pass the list of available zones to the template
469
+ template_params["zones"] = available_zones
470
+ logger.info(
471
+ f"Creating {count} VMs, distributed across availability zones: {available_zones}"
472
+ )
473
+ else:
474
+ # For non-zonal deployments (no zones available), use empty array
475
+ template_params["zones"] = []
476
+ logger.info(f"Creating {count} VMs without specific availability zone")
477
+
478
+ parameters = {
479
+ "properties": {
480
+ "mode": DeploymentMode.incremental,
481
+ "template": template,
482
+ "parameters": {
483
+ key: {"value": value} for key, value in template_params.items()
484
+ },
485
+ }
486
+ }
487
+
488
+ # TODO: we could get the private/public ips back directly
489
+ create_or_update = get_azure_sdk_function(
490
+ client=self.resource_client.deployments,
491
+ function_name="create_or_update",
492
+ )
493
+ create_or_update(
494
+ resource_group_name=resource_group,
495
+ deployment_name=deployment_name,
496
+ parameters=parameters,
497
+ ).wait(timeout=AUTOSCALER_NODE_START_WAIT_S)
498
+
499
+ @synchronized
500
+ def set_node_tags(self, node_id, tags):
501
+ """Sets the tag values (string dict) for the specified node."""
502
+ node_tags = self._get_cached_node(node_id)["tags"]
503
+ node_tags.update(tags)
504
+ update = get_azure_sdk_function(
505
+ client=self.compute_client.virtual_machines, function_name="update"
506
+ )
507
+ update(
508
+ resource_group_name=self.provider_config["resource_group"],
509
+ vm_name=node_id,
510
+ parameters={"tags": node_tags},
511
+ )
512
+ self.cached_nodes[node_id]["tags"] = node_tags
513
+
514
+ def terminate_node(self, node_id):
515
+ """Terminates the specified node. This will delete the VM and
516
+ associated resources (NIC, IP, Storage) for the specified node."""
517
+
518
+ resource_group = self.provider_config["resource_group"]
519
+
520
+ if self.cache_stopped_nodes:
521
+ try:
522
+ # stop machine and leave all resources
523
+ logger.info(
524
+ f"Stopping instance {node_id}"
525
+ "(to fully terminate instead, "
526
+ "set `cache_stopped_nodes: False` "
527
+ "under `provider` in the cluster configuration)"
528
+ )
529
+ stop = get_azure_sdk_function(
530
+ client=self.compute_client.virtual_machines,
531
+ function_name="deallocate",
532
+ )
533
+ stop(resource_group_name=resource_group, vm_name=node_id)
534
+ except Exception as e:
535
+ logger.warning("Failed to stop VM: {}".format(e))
536
+
537
+ # If node_id is in terminating nodes dict, it's already terminating
538
+ # Otherwise, kick off termination and add it to the dict
539
+ elif node_id not in self.terminating_nodes:
540
+ self.terminating_nodes[node_id] = self.termination_executor.submit(
541
+ self._delete_node_and_resources, resource_group, node_id
542
+ )
543
+
544
+ def _delete_node_and_resources(self, resource_group, node_id):
545
+ try:
546
+ vm = self.compute_client.virtual_machines.get(
547
+ resource_group_name=resource_group, vm_name=node_id
548
+ )
549
+ except ResourceNotFoundError as e:
550
+ # Node no longer exists
551
+ logger.warning("Failed to delete VM: {}".format(e))
552
+ return
553
+
554
+ # Gather dependent disks
555
+ disks = set()
556
+ if vm.storage_profile is not None and vm.storage_profile.data_disks is not None:
557
+ for d in vm.storage_profile.data_disks:
558
+ if d.name is not None:
559
+ disks.add(d.name)
560
+ if (
561
+ vm.storage_profile is not None
562
+ and vm.storage_profile.os_disk is not None
563
+ and vm.storage_profile.os_disk.name is not None
564
+ ):
565
+ disks.add(vm.storage_profile.os_disk.name)
566
+
567
+ # Gather dependent NICs and public IPs
568
+ nics = set()
569
+ ips = set()
570
+ if (
571
+ vm.network_profile is not None
572
+ and vm.network_profile.network_interfaces is not None
573
+ ):
574
+ for nint in vm.network_profile.network_interfaces:
575
+ if nint.id is not None:
576
+ nic_name = nint.id.split("/")[-1]
577
+ nics.add(nic_name)
578
+ # Get public IP if not using internal IPs or if this is the
579
+ # head node and use_external_head_ip is True
580
+ if not self.provider_config.get("use_internal_ips", False) or (
581
+ self.provider_config.get("use_external_head_ip", False)
582
+ and vm.tags[TAG_RAY_NODE_KIND] == NODE_KIND_HEAD
583
+ ):
584
+ nic = self.network_client.network_interfaces.get(
585
+ resource_group_name=resource_group,
586
+ network_interface_name=nic_name,
587
+ )
588
+ if nic.ip_configurations is not None:
589
+ for ipc in nic.ip_configurations:
590
+ if ipc.public_ip_address.id is not None:
591
+ ips.add(ipc.public_ip_address.id.split("/")[-1])
592
+
593
+ # Delete VM
594
+ st = time.monotonic()
595
+ delete = get_azure_sdk_function(
596
+ client=self.compute_client.virtual_machines,
597
+ function_name="delete",
598
+ )
599
+ try:
600
+ delete(resource_group_name=resource_group, vm_name=node_id).wait(
601
+ timeout=AUTOSCALER_NODE_TERMINATE_WAIT_S
602
+ )
603
+ except Exception as e:
604
+ logger.warning("Failed to delete VM: {}".format(e))
605
+
606
+ # Delete disks (no need to wait for these, but gather the LROs for end)
607
+ disk_lros = []
608
+ delete = get_azure_sdk_function(
609
+ client=self.compute_client.disks, function_name="delete"
610
+ )
611
+ for d in disks:
612
+ try:
613
+ disk_lros.append(
614
+ delete(
615
+ resource_group_name=resource_group,
616
+ disk_name=d,
617
+ )
618
+ )
619
+ except Exception as e:
620
+ logger.warning("Failed to delete disk: {}".format(e))
621
+
622
+ # Delete NICs
623
+ nic_lros = []
624
+ delete = get_azure_sdk_function(
625
+ client=self.network_client.network_interfaces, function_name="delete"
626
+ )
627
+ for n in nics:
628
+ try:
629
+ nic_lros.append(
630
+ delete(
631
+ resource_group_name=resource_group,
632
+ network_interface_name=n,
633
+ )
634
+ )
635
+ except Exception as e:
636
+ logger.warning("Failed to delete NIC: {}".format(e))
637
+
638
+ while (
639
+ not all(nlro.done() for nlro in nic_lros)
640
+ and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
641
+ ):
642
+ time.sleep(0.1)
643
+
644
+ # Delete Public IPs
645
+ delete = get_azure_sdk_function(
646
+ client=self.network_client.public_ip_addresses,
647
+ function_name="delete",
648
+ )
649
+ ip_lros = []
650
+ for ip in ips:
651
+ try:
652
+ ip_lros.append(
653
+ delete(
654
+ resource_group_name=resource_group,
655
+ public_ip_address_name=ip,
656
+ )
657
+ )
658
+ except Exception as e:
659
+ logger.warning("Failed to delete public IP: {}".format(e))
660
+
661
+ while (
662
+ not all(dlro.done() for dlro in disk_lros)
663
+ and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
664
+ ):
665
+ time.sleep(0.1)
666
+ while (
667
+ not all(iplro.done() for iplro in ip_lros)
668
+ and (time.monotonic() - st) < AUTOSCALER_NODE_TERMINATE_WAIT_S
669
+ ):
670
+ time.sleep(0.1)
671
+
672
+ def cleanup_cluster_resources(self):
673
+ """Delete shared cluster infrastructure (MSI, NSG, Subnet, VNet)."""
674
+
675
+ resource_group = self.provider_config["resource_group"]
676
+
677
+ msi_principal_id = self._cleanup_managed_identity(
678
+ resource_group, self.provider_config.get("msi")
679
+ )
680
+
681
+ subnet_id = self.provider_config.get("subnet")
682
+ vnet_name = self._cleanup_subnet(resource_group, subnet_id)
683
+
684
+ nsg_id = self.provider_config.get("nsg")
685
+ self._cleanup_nsg(resource_group, nsg_id)
686
+
687
+ self._cleanup_vnet(resource_group, subnet_id, vnet_name)
688
+
689
+ self._cleanup_role_assignments(resource_group, msi_principal_id)
690
+
691
+ self._prune_provider_config_entries()
692
+ self._cleanup_config_cache()
693
+
694
+ @staticmethod
695
+ def _get_resource_name_from_id(resource_id: Optional[str]) -> Optional[str]:
696
+ if resource_id:
697
+ return resource_id.split("/")[-1]
698
+ return None
699
+
700
+ @staticmethod
701
+ def _retry_delete(delete_fn, max_retries: int = 5, initial_delay: int = 2):
702
+ """Retry a delete operation with exponential backoff."""
703
+
704
+ delay = initial_delay
705
+ for attempt in range(max_retries):
706
+ try:
707
+ return delete_fn()
708
+ except Exception as exc: # noqa: BLE001
709
+ error_msg = str(exc)
710
+ if "InUse" in error_msg and attempt < max_retries - 1:
711
+ logger.info(
712
+ "Resource still in use, retrying in %ss (attempt %s/%s)...",
713
+ delay,
714
+ attempt + 1,
715
+ max_retries,
716
+ )
717
+ time.sleep(delay)
718
+ delay *= 2
719
+ else:
720
+ raise
721
+
722
+ def _cleanup_managed_identity(
723
+ self, resource_group: str, msi_id: Optional[str]
724
+ ) -> Optional[str]:
725
+ if not msi_id:
726
+ return None
727
+
728
+ msi_name = self._get_resource_name_from_id(msi_id)
729
+ if not msi_name:
730
+ return None
731
+
732
+ msi_principal_id: Optional[str] = None
733
+ try:
734
+ get_identity = get_azure_sdk_function(
735
+ client=self.resource_client.resources,
736
+ function_name="get_by_id",
737
+ )
738
+ existing_msi = get_identity(msi_id, "2023-01-31")
739
+ msi_principal_id = getattr(existing_msi, "properties", {}).get(
740
+ "principalId"
741
+ )
742
+ except ResourceNotFoundError:
743
+ msi_principal_id = None
744
+ except Exception as exc: # noqa: BLE001
745
+ logger.warning(
746
+ "Failed to query MSI %s for principal ID prior to deletion: %s",
747
+ msi_name,
748
+ exc,
749
+ )
750
+
751
+ try:
752
+ logger.info("Deleting Managed Service Identity: %s", msi_name)
753
+ delete = get_azure_sdk_function(
754
+ client=self.resource_client.resources,
755
+ function_name="delete_by_id",
756
+ )
757
+ delete(resource_id=msi_id, api_version="2023-01-31").wait()
758
+ logger.info("Successfully deleted MSI: %s", msi_name)
759
+ except ResourceNotFoundError:
760
+ logger.info("MSI %s not found, may have been already deleted", msi_name)
761
+ except Exception as exc: # noqa: BLE001
762
+ logger.warning("Failed to delete MSI %s: %s", msi_name, exc)
763
+
764
+ return msi_principal_id
765
+
766
+ def _cleanup_subnet(
767
+ self, resource_group: str, subnet_id: Optional[str]
768
+ ) -> Optional[str]:
769
+ if not subnet_id:
770
+ return None
771
+
772
+ subnet_name = self._get_resource_name_from_id(subnet_id)
773
+ vnet_name: Optional[str] = None
774
+
775
+ if subnet_id and "/virtualNetworks/" in subnet_id:
776
+ parts = subnet_id.split("/")
777
+ vnet_idx = parts.index("virtualNetworks")
778
+ if vnet_idx + 1 < len(parts):
779
+ vnet_name = parts[vnet_idx + 1]
780
+
781
+ if not subnet_name or not vnet_name:
782
+ return None
783
+
784
+ try:
785
+ logger.info("Deleting Subnet: %s in VNet: %s", subnet_name, vnet_name)
786
+
787
+ def delete_subnet():
788
+ delete = get_azure_sdk_function(
789
+ client=self.network_client.subnets, function_name="delete"
790
+ )
791
+ delete(
792
+ resource_group_name=resource_group,
793
+ virtual_network_name=vnet_name,
794
+ subnet_name=subnet_name,
795
+ ).wait()
796
+
797
+ self._retry_delete(delete_subnet)
798
+ logger.info("Successfully deleted Subnet: %s", subnet_name)
799
+ except ResourceNotFoundError:
800
+ logger.info(
801
+ "Subnet %s not found, may have been already deleted", subnet_name
802
+ )
803
+ except Exception as exc: # noqa: BLE001
804
+ logger.warning("Failed to delete Subnet %s: %s", subnet_name, exc)
805
+
806
+ return vnet_name
807
+
808
+ def _cleanup_nsg(self, resource_group: str, nsg_id: Optional[str]) -> None:
809
+ if not nsg_id:
810
+ return
811
+
812
+ nsg_name = self._get_resource_name_from_id(nsg_id)
813
+ if not nsg_name:
814
+ return
815
+
816
+ try:
817
+ logger.info("Deleting Network Security Group: %s", nsg_name)
818
+
819
+ def delete_nsg():
820
+ delete = get_azure_sdk_function(
821
+ client=self.network_client.network_security_groups,
822
+ function_name="delete",
823
+ )
824
+ delete(
825
+ resource_group_name=resource_group,
826
+ network_security_group_name=nsg_name,
827
+ ).wait()
828
+
829
+ self._retry_delete(delete_nsg)
830
+ logger.info("Successfully deleted NSG: %s", nsg_name)
831
+ except ResourceNotFoundError:
832
+ logger.info("NSG %s not found, may have been already deleted", nsg_name)
833
+ except Exception as exc: # noqa: BLE001
834
+ logger.warning("Failed to delete NSG %s: %s", nsg_name, exc)
835
+
836
+ def _cleanup_vnet(
837
+ self,
838
+ resource_group: str,
839
+ subnet_id: Optional[str],
840
+ vnet_name: Optional[str],
841
+ ) -> None:
842
+ if not subnet_id or not vnet_name:
843
+ return
844
+
845
+ try:
846
+ logger.info("Deleting Virtual Network: %s", vnet_name)
847
+
848
+ def delete_vnet():
849
+ delete = get_azure_sdk_function(
850
+ client=self.network_client.virtual_networks,
851
+ function_name="delete",
852
+ )
853
+ delete(
854
+ resource_group_name=resource_group,
855
+ virtual_network_name=vnet_name,
856
+ ).wait()
857
+
858
+ self._retry_delete(delete_vnet)
859
+ logger.info("Successfully deleted VNet: %s", vnet_name)
860
+ except ResourceNotFoundError:
861
+ logger.info("VNet %s not found, may have been already deleted", vnet_name)
862
+ except Exception as exc: # noqa: BLE001
863
+ logger.warning("Failed to delete VNet %s: %s", vnet_name, exc)
864
+
865
+ def _cleanup_role_assignments(
866
+ self, resource_group: str, msi_principal_id: Optional[str]
867
+ ) -> None:
868
+ subscription_id = self.provider_config.get("subscription_id")
869
+ unique_id = self.provider_config.get("unique_id")
870
+ if not subscription_id or not unique_id:
871
+ return
872
+
873
+ cluster_id = f"{self.cluster_name}-{unique_id}"
874
+ role_assignment_name = f"ray-{cluster_id}-ra"
875
+ role_assignment_guid = _generate_arm_guid(role_assignment_name)
876
+ role_assignment_id = (
877
+ f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers"
878
+ f"/Microsoft.Authorization/roleAssignments/{role_assignment_guid}"
879
+ )
880
+
881
+ if msi_principal_id:
882
+ _delete_role_assignments_for_principal(
883
+ self.resource_client, resource_group, msi_principal_id
884
+ )
885
+
886
+ delete_role_assignment = get_azure_sdk_function(
887
+ client=self.resource_client.resources, function_name="delete_by_id"
888
+ )
889
+ try:
890
+ delete_lro = delete_role_assignment(
891
+ resource_id=role_assignment_id,
892
+ api_version="2022-04-01",
893
+ )
894
+ if hasattr(delete_lro, "wait"):
895
+ delete_lro.wait()
896
+ logger.info(
897
+ "Deleted role assignment %s for cluster %s",
898
+ role_assignment_guid,
899
+ self.cluster_name,
900
+ )
901
+ except ResourceNotFoundError:
902
+ logger.debug(
903
+ "Role assignment %s not found during cleanup", role_assignment_guid
904
+ )
905
+ except Exception as exc: # noqa: BLE001
906
+ logger.warning(
907
+ "Failed to delete role assignment %s: %s",
908
+ role_assignment_guid,
909
+ exc,
910
+ )
911
+
912
+ def _prune_provider_config_entries(self) -> None:
913
+ for key in ("msi", "nsg", "subnet"):
914
+ self.provider_config.pop(key, None)
915
+
916
+ def _cleanup_config_cache(self) -> None:
917
+ cache_path = self.provider_config.get("_config_cache_path")
918
+ if not cache_path:
919
+ return
920
+
921
+ try:
922
+ if os.path.exists(cache_path):
923
+ os.remove(cache_path)
924
+ logger.info(
925
+ "Deleted cached Ray config at %s after resource cleanup",
926
+ cache_path,
927
+ )
928
+ except Exception as exc: # noqa: BLE001
929
+ logger.warning("Failed to delete cached Ray config %s: %s", cache_path, exc)
930
+ finally:
931
+ self.provider_config.pop("_config_cache_path", None)
932
+
933
+ def _get_node(self, node_id):
934
+ self._get_filtered_nodes({}) # Side effect: updates cache
935
+ return self.cached_nodes[node_id]
936
+
937
+ def _get_cached_node(self, node_id):
938
+ return self.cached_nodes.get(node_id) or self._get_node(node_id=node_id)
939
+
940
+ @staticmethod
941
+ def bootstrap_config(cluster_config):
942
+ return bootstrap_azure(cluster_config)
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__init__.py ADDED
File without changes
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (209 Bytes). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/config.cpython-312.pyc ADDED
Binary file (5.06 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/node_provider.cpython-312.pyc ADDED
Binary file (16.2 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/__pycache__/utils.cpython-312.pyc ADDED
Binary file (22 kB). View file
 
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/config.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import stat
4
+
5
+ from ray.autoscaler._private.aliyun.utils import AcsClient
6
+
7
+ # instance status
8
+ PENDING = "Pending"
9
+ RUNNING = "Running"
10
+ STARTING = "Starting"
11
+ STOPPING = "Stopping"
12
+ STOPPED = "Stopped"
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def bootstrap_aliyun(config):
18
+ # print(config["provider"])
19
+ # create vpc
20
+ _get_or_create_vpc(config)
21
+ # create security group id
22
+ _get_or_create_security_group(config)
23
+ # create vswitch
24
+ _get_or_create_vswitch(config)
25
+ # create key pair
26
+ _get_or_import_key_pair(config)
27
+ # print(config["provider"])
28
+ return config
29
+
30
+
31
+ def _client(config):
32
+ return AcsClient(
33
+ access_key=config["provider"].get("access_key"),
34
+ access_key_secret=config["provider"].get("access_key_secret"),
35
+ region_id=config["provider"]["region"],
36
+ max_retries=1,
37
+ )
38
+
39
+
40
+ def _get_or_create_security_group(config):
41
+ cli = _client(config)
42
+ security_groups = cli.describe_security_groups(vpc_id=config["provider"]["vpc_id"])
43
+ if security_groups is not None and len(security_groups) > 0:
44
+ config["provider"]["security_group_id"] = security_groups[0]["SecurityGroupId"]
45
+ return config
46
+
47
+ security_group_id = cli.create_security_group(vpc_id=config["provider"]["vpc_id"])
48
+
49
+ for rule in config["provider"].get("security_group_rule", {}):
50
+ cli.authorize_security_group(
51
+ security_group_id=security_group_id,
52
+ port_range=rule["port_range"],
53
+ source_cidr_ip=rule["source_cidr_ip"],
54
+ ip_protocol=rule["ip_protocol"],
55
+ )
56
+ config["provider"]["security_group_id"] = security_group_id
57
+ return
58
+
59
+
60
+ def _get_or_create_vpc(config):
61
+ cli = _client(config)
62
+ vpcs = cli.describe_vpcs()
63
+ if vpcs is not None and len(vpcs) > 0:
64
+ config["provider"]["vpc_id"] = vpcs[0].get("VpcId")
65
+ return
66
+
67
+ vpc_id = cli.create_vpc()
68
+ if vpc_id is not None:
69
+ config["provider"]["vpc_id"] = vpc_id
70
+
71
+
72
+ def _get_or_create_vswitch(config):
73
+ cli = _client(config)
74
+ vswitches = cli.describe_v_switches(vpc_id=config["provider"]["vpc_id"])
75
+ if vswitches is not None and len(vswitches) > 0:
76
+ config["provider"]["v_switch_id"] = vswitches[0].get("VSwitchId")
77
+ return
78
+
79
+ v_switch_id = cli.create_v_switch(
80
+ vpc_id=config["provider"]["vpc_id"],
81
+ zone_id=config["provider"]["zone_id"],
82
+ cidr_block=config["provider"]["cidr_block"],
83
+ )
84
+
85
+ if v_switch_id is not None:
86
+ config["provider"]["v_switch_id"] = v_switch_id
87
+
88
+
89
+ def _get_or_import_key_pair(config):
90
+ cli = _client(config)
91
+ key_name = config["provider"].get("key_name", "ray")
92
+ key_path = os.path.expanduser("~/.ssh/{}".format(key_name))
93
+ keypairs = cli.describe_key_pairs(key_pair_name=key_name)
94
+
95
+ if keypairs is not None and len(keypairs) > 0:
96
+ if "ssh_private_key" not in config["auth"]:
97
+ logger.info(
98
+ "{} keypair exists, use {} as local ssh key".format(key_name, key_path)
99
+ )
100
+ config["auth"]["ssh_private_key"] = key_path
101
+ else:
102
+ if "ssh_private_key" not in config["auth"]:
103
+ # create new keypair
104
+ resp = cli.create_key_pair(key_pair_name=key_name)
105
+ if resp is not None:
106
+ with open(key_path, "w+") as f:
107
+ f.write(resp.get("PrivateKeyBody"))
108
+ os.chmod(key_path, stat.S_IRUSR)
109
+ config["auth"]["ssh_private_key"] = key_path
110
+ else:
111
+ public_key_file = config["auth"]["ssh_private_key"] + ".pub"
112
+ # create new keypair, from local file
113
+ with open(public_key_file) as f:
114
+ public_key = f.readline().strip("\n")
115
+ cli.import_key_pair(key_pair_name=key_name, public_key_body=public_key)
116
+ return
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/node_provider.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import random
3
+ import threading
4
+ import time
5
+ from collections import defaultdict
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ray.autoscaler._private.aliyun.config import (
9
+ PENDING,
10
+ RUNNING,
11
+ STOPPED,
12
+ STOPPING,
13
+ bootstrap_aliyun,
14
+ )
15
+ from ray.autoscaler._private.aliyun.utils import AcsClient
16
+ from ray.autoscaler._private.cli_logger import cli_logger
17
+ from ray.autoscaler._private.constants import BOTO_MAX_RETRIES
18
+ from ray.autoscaler._private.log_timer import LogTimer
19
+ from ray.autoscaler.node_provider import NodeProvider
20
+ from ray.autoscaler.tags import (
21
+ TAG_RAY_CLUSTER_NAME,
22
+ TAG_RAY_LAUNCH_CONFIG,
23
+ TAG_RAY_NODE_KIND,
24
+ TAG_RAY_NODE_NAME,
25
+ TAG_RAY_NODE_STATUS,
26
+ TAG_RAY_USER_NODE_TYPE,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ TAG_BATCH_DELAY = 1
32
+ STOPPING_NODE_DELAY = 1
33
+
34
+
35
+ class AliyunNodeProvider(NodeProvider):
36
+ def __init__(self, provider_config, cluster_name):
37
+ NodeProvider.__init__(self, provider_config, cluster_name)
38
+ self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
39
+ self.acs = AcsClient(
40
+ access_key=provider_config["access_key"],
41
+ access_key_secret=provider_config["access_key_secret"],
42
+ region_id=provider_config["region"],
43
+ max_retries=BOTO_MAX_RETRIES,
44
+ )
45
+
46
+ # Try availability zones round-robin, starting from random offset
47
+ self.subnet_idx = random.randint(0, 100)
48
+
49
+ # Tags that we believe to actually be on the node.
50
+ self.tag_cache = {}
51
+ # Tags that we will soon upload.
52
+ self.tag_cache_pending = defaultdict(dict)
53
+ # Number of threads waiting for a batched tag update.
54
+ self.batch_thread_count = 0
55
+ self.batch_update_done = threading.Event()
56
+ self.batch_update_done.set()
57
+ self.ready_for_new_batch = threading.Event()
58
+ self.ready_for_new_batch.set()
59
+ self.tag_cache_lock = threading.Lock()
60
+ self.count_lock = threading.Lock()
61
+
62
+ # Cache of node objects from the last nodes() call. This avoids
63
+ # excessive DescribeInstances requests.
64
+ self.cached_nodes = {}
65
+
66
+ def non_terminated_nodes(self, tag_filters: Dict[str, str]) -> List[str]:
67
+ tags = [
68
+ {
69
+ "Key": TAG_RAY_CLUSTER_NAME,
70
+ "Value": self.cluster_name,
71
+ },
72
+ ]
73
+ for k, v in tag_filters.items():
74
+ tags.append(
75
+ {
76
+ "Key": k,
77
+ "Value": v,
78
+ }
79
+ )
80
+
81
+ instances = self.acs.describe_instances(tags=tags)
82
+ non_terminated_instance = []
83
+ for instance in instances:
84
+ if instance.get("Status") == RUNNING or instance.get("Status") == PENDING:
85
+ non_terminated_instance.append(instance.get("InstanceId"))
86
+ self.cached_nodes[instance.get("InstanceId")] = instance
87
+ return non_terminated_instance
88
+
89
+ def is_running(self, node_id: str) -> bool:
90
+ instances = self.acs.describe_instances(instance_ids=[node_id])
91
+ if instances is not None:
92
+ instance = instances[0]
93
+ return instance.get("Status") == "Running"
94
+ cli_logger.error("Invalid node id: %s", node_id)
95
+ return False
96
+
97
+ def is_terminated(self, node_id: str) -> bool:
98
+ instances = self.acs.describe_instances(instance_ids=[node_id])
99
+ if instances is not None:
100
+ assert len(instances) == 1
101
+ instance = instances[0]
102
+ return instance.get("Status") == "Stopped"
103
+ cli_logger.error("Invalid node id: %s", node_id)
104
+ return False
105
+
106
+ def node_tags(self, node_id: str) -> Dict[str, str]:
107
+ instances = self.acs.describe_instances(instance_ids=[node_id])
108
+ if instances is not None:
109
+ assert len(instances) == 1
110
+ instance = instances[0]
111
+ if instance.get("Tags") is not None:
112
+ node_tags = dict()
113
+ for tag in instance.get("Tags").get("Tag"):
114
+ node_tags[tag.get("TagKey")] = tag.get("TagValue")
115
+ return node_tags
116
+ return dict()
117
+
118
+ def external_ip(self, node_id: str) -> str:
119
+ while True:
120
+ instances = self.acs.describe_instances(instance_ids=[node_id])
121
+ if instances is not None:
122
+ assert len(instances)
123
+ instance = instances[0]
124
+ if (
125
+ instance.get("PublicIpAddress") is not None
126
+ and instance.get("PublicIpAddress").get("IpAddress") is not None
127
+ ):
128
+ if len(instance.get("PublicIpAddress").get("IpAddress")) > 0:
129
+ return instance.get("PublicIpAddress").get("IpAddress")[0]
130
+ cli_logger.error("PublicIpAddress attribute is not exist. %s" % instance)
131
+ time.sleep(STOPPING_NODE_DELAY)
132
+
133
+ def internal_ip(self, node_id: str) -> str:
134
+ while True:
135
+ instances = self.acs.describe_instances(instance_ids=[node_id])
136
+ if instances is not None:
137
+ assert len(instances) == 1
138
+ instance = instances[0]
139
+ if (
140
+ instance.get("VpcAttributes") is not None
141
+ and instance.get("VpcAttributes").get("PrivateIpAddress")
142
+ is not None
143
+ and len(
144
+ instance.get("VpcAttributes")
145
+ .get("PrivateIpAddress")
146
+ .get("IpAddress")
147
+ )
148
+ > 0
149
+ ):
150
+ return (
151
+ instance.get("VpcAttributes")
152
+ .get("PrivateIpAddress")
153
+ .get("IpAddress")[0]
154
+ )
155
+ cli_logger.error("InnerIpAddress attribute is not exist. %s" % instance)
156
+ time.sleep(STOPPING_NODE_DELAY)
157
+
158
+ def set_node_tags(self, node_id: str, tags: Dict[str, str]) -> None:
159
+ is_batching_thread = False
160
+ with self.tag_cache_lock:
161
+ if not self.tag_cache_pending:
162
+ is_batching_thread = True
163
+ # Wait for threads in the last batch to exit
164
+ self.ready_for_new_batch.wait()
165
+ self.ready_for_new_batch.clear()
166
+ self.batch_update_done.clear()
167
+ self.tag_cache_pending[node_id].update(tags)
168
+
169
+ if is_batching_thread:
170
+ time.sleep(TAG_BATCH_DELAY)
171
+ with self.tag_cache_lock:
172
+ self._update_node_tags()
173
+ self.batch_update_done.set()
174
+
175
+ with self.count_lock:
176
+ self.batch_thread_count += 1
177
+ self.batch_update_done.wait()
178
+
179
+ with self.count_lock:
180
+ self.batch_thread_count -= 1
181
+ if self.batch_thread_count == 0:
182
+ self.ready_for_new_batch.set()
183
+
184
+ def _update_node_tags(self):
185
+ batch_updates = defaultdict(list)
186
+
187
+ for node_id, tags in self.tag_cache_pending.items():
188
+ for x in tags.items():
189
+ batch_updates[x].append(node_id)
190
+ self.tag_cache[node_id] = tags
191
+
192
+ self.tag_cache_pending = defaultdict(dict)
193
+
194
+ self._create_tags(batch_updates)
195
+
196
+ def _create_tags(self, batch_updates):
197
+
198
+ for (k, v), node_ids in batch_updates.items():
199
+ m = "Set tag {}={} on {}".format(k, v, node_ids)
200
+ with LogTimer("AliyunNodeProvider: {}".format(m)):
201
+ if k == TAG_RAY_NODE_NAME:
202
+ k = "Name"
203
+
204
+ self.acs.tag_resource(node_ids, [{"Key": k, "Value": v}])
205
+
206
+ def create_node(
207
+ self, node_config: Dict[str, Any], tags: Dict[str, str], count: int
208
+ ) -> Optional[Dict[str, Any]]:
209
+ filter_tags = [
210
+ {
211
+ "Key": TAG_RAY_CLUSTER_NAME,
212
+ "Value": self.cluster_name,
213
+ },
214
+ {"Key": TAG_RAY_NODE_KIND, "Value": tags[TAG_RAY_NODE_KIND]},
215
+ {"Key": TAG_RAY_USER_NODE_TYPE, "Value": tags[TAG_RAY_USER_NODE_TYPE]},
216
+ {"Key": TAG_RAY_LAUNCH_CONFIG, "Value": tags[TAG_RAY_LAUNCH_CONFIG]},
217
+ {"Key": TAG_RAY_NODE_NAME, "Value": tags[TAG_RAY_NODE_NAME]},
218
+ ]
219
+
220
+ reused_nodes_dict = {}
221
+ if self.cache_stopped_nodes:
222
+ reuse_nodes_candidate = self.acs.describe_instances(tags=filter_tags)
223
+ if reuse_nodes_candidate:
224
+ with cli_logger.group("Stopping instances to reuse"):
225
+ reuse_node_ids = []
226
+ for node in reuse_nodes_candidate:
227
+ node_id = node.get("InstanceId")
228
+ status = node.get("Status")
229
+ if status != STOPPING and status != STOPPED:
230
+ continue
231
+ if status == STOPPING:
232
+ # wait for node stopped
233
+ while (
234
+ self.acs.describe_instances(instance_ids=[node_id])[
235
+ 0
236
+ ].get("Status")
237
+ == STOPPING
238
+ ):
239
+ logging.info("wait for %s stop" % node_id)
240
+ time.sleep(STOPPING_NODE_DELAY)
241
+ # logger.info("reuse %s" % node_id)
242
+ reuse_node_ids.append(node_id)
243
+ reused_nodes_dict[node.get("InstanceId")] = node
244
+ self.acs.start_instance(node_id)
245
+ self.tag_cache[node_id] = node.get("Tags")
246
+ self.set_node_tags(node_id, tags)
247
+ if len(reuse_node_ids) == count:
248
+ break
249
+ count -= len(reuse_node_ids)
250
+
251
+ created_nodes_dict = {}
252
+ if count > 0:
253
+ filter_tags.append(
254
+ {"Key": TAG_RAY_NODE_STATUS, "Value": tags[TAG_RAY_NODE_STATUS]}
255
+ )
256
+ instance_id_sets = self.acs.run_instances(
257
+ instance_type=node_config["InstanceType"],
258
+ image_id=node_config["ImageId"],
259
+ tags=filter_tags,
260
+ amount=count,
261
+ vswitch_id=self.provider_config["v_switch_id"],
262
+ security_group_id=self.provider_config["security_group_id"],
263
+ key_pair_name=self.provider_config["key_name"],
264
+ )
265
+ instances = self.acs.describe_instances(instance_ids=instance_id_sets)
266
+
267
+ if instances is not None:
268
+ for instance in instances:
269
+ created_nodes_dict[instance.get("InstanceId")] = instance
270
+
271
+ all_created_nodes = reused_nodes_dict
272
+ all_created_nodes.update(created_nodes_dict)
273
+ return all_created_nodes
274
+
275
+ def terminate_node(self, node_id: str) -> None:
276
+ logger.info("terminate node: %s" % node_id)
277
+ if self.cache_stopped_nodes:
278
+ logger.info(
279
+ "Stopping instance {} (to terminate instead, "
280
+ "set `cache_stopped_nodes: False` "
281
+ "under `provider` in the cluster configuration)"
282
+ ).format(node_id)
283
+ self.acs.stop_instance(node_id)
284
+ else:
285
+ self.acs.delete_instance(node_id)
286
+
287
+ def terminate_nodes(self, node_ids: List[str]) -> None:
288
+ if not node_ids:
289
+ return
290
+ if self.cache_stopped_nodes:
291
+ logger.info(
292
+ "Stopping instances {} (to terminate instead, "
293
+ "set `cache_stopped_nodes: False` "
294
+ "under `provider` in the cluster configuration)".format(node_ids)
295
+ )
296
+
297
+ self.acs.stop_instances(node_ids)
298
+ else:
299
+ self.acs.delete_instances(node_ids)
300
+
301
+ def _get_node(self, node_id):
302
+ """Refresh and get info for this node, updating the cache."""
303
+ self.non_terminated_nodes({}) # Side effect: updates cache
304
+
305
+ if node_id in self.cached_nodes:
306
+ return self.cached_nodes[node_id]
307
+
308
+ # Node not in {pending, running} -- retry with a point query. This
309
+ # usually means the node was recently preempted or terminated.
310
+ matches = self.acs.describe_instances(instance_ids=[node_id])
311
+
312
+ assert len(matches) == 1, "Invalid instance id {}".format(node_id)
313
+ return matches[0]
314
+
315
+ def _get_cached_node(self, node_id):
316
+ """Return node info from cache if possible, otherwise fetches it."""
317
+ if node_id in self.cached_nodes:
318
+ return self.cached_nodes[node_id]
319
+
320
+ return self._get_node(node_id)
321
+
322
+ @staticmethod
323
+ def bootstrap_config(cluster_config):
324
+ return bootstrap_aliyun(cluster_config)
lib/python3.12/site-packages/ray/autoscaler/_private/aliyun/utils.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+
4
+ from aliyunsdkcore import client
5
+ from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
6
+ from aliyunsdkecs.request.v20140526.AllocatePublicIpAddressRequest import (
7
+ AllocatePublicIpAddressRequest,
8
+ )
9
+ from aliyunsdkecs.request.v20140526.AuthorizeSecurityGroupRequest import (
10
+ AuthorizeSecurityGroupRequest,
11
+ )
12
+ from aliyunsdkecs.request.v20140526.CreateInstanceRequest import CreateInstanceRequest
13
+ from aliyunsdkecs.request.v20140526.CreateKeyPairRequest import CreateKeyPairRequest
14
+ from aliyunsdkecs.request.v20140526.CreateSecurityGroupRequest import (
15
+ CreateSecurityGroupRequest,
16
+ )
17
+ from aliyunsdkecs.request.v20140526.CreateVpcRequest import CreateVpcRequest
18
+ from aliyunsdkecs.request.v20140526.CreateVSwitchRequest import CreateVSwitchRequest
19
+ from aliyunsdkecs.request.v20140526.DeleteInstanceRequest import DeleteInstanceRequest
20
+ from aliyunsdkecs.request.v20140526.DeleteInstancesRequest import DeleteInstancesRequest
21
+ from aliyunsdkecs.request.v20140526.DeleteKeyPairsRequest import DeleteKeyPairsRequest
22
+ from aliyunsdkecs.request.v20140526.DescribeInstancesRequest import (
23
+ DescribeInstancesRequest,
24
+ )
25
+ from aliyunsdkecs.request.v20140526.DescribeKeyPairsRequest import (
26
+ DescribeKeyPairsRequest,
27
+ )
28
+ from aliyunsdkecs.request.v20140526.DescribeSecurityGroupsRequest import (
29
+ DescribeSecurityGroupsRequest,
30
+ )
31
+ from aliyunsdkecs.request.v20140526.DescribeVpcsRequest import DescribeVpcsRequest
32
+ from aliyunsdkecs.request.v20140526.DescribeVSwitchesRequest import (
33
+ DescribeVSwitchesRequest,
34
+ )
35
+ from aliyunsdkecs.request.v20140526.ImportKeyPairRequest import ImportKeyPairRequest
36
+ from aliyunsdkecs.request.v20140526.RunInstancesRequest import RunInstancesRequest
37
+ from aliyunsdkecs.request.v20140526.StartInstanceRequest import StartInstanceRequest
38
+ from aliyunsdkecs.request.v20140526.StopInstanceRequest import StopInstanceRequest
39
+ from aliyunsdkecs.request.v20140526.StopInstancesRequest import StopInstancesRequest
40
+ from aliyunsdkecs.request.v20140526.TagResourcesRequest import TagResourcesRequest
41
+
42
+
43
+ class AcsClient:
44
+ """
45
+ A wrapper around Aliyun SDK. We use this wrapper in aliyun node provider.
46
+
47
+ Parameters:
48
+ access_key: The AccessKey ID of your aliyun account.
49
+ access_key_secret: The AccessKey secret of your aliyun account.
50
+ region_id: A region is a geographic area where a data center resides.
51
+ Region_id is the ID of region (e.g., cn-hangzhou,
52
+ us-west-1, etc.)
53
+ max_retries: The maximum number of retries each connection.
54
+ """
55
+
56
+ def __init__(self, access_key, access_key_secret, region_id, max_retries):
57
+ self.cli = client.AcsClient(
58
+ ak=access_key,
59
+ secret=access_key_secret,
60
+ max_retry_time=max_retries,
61
+ region_id=region_id,
62
+ )
63
+
64
+ def describe_instances(self, tags=None, instance_ids=None):
65
+ """Query the details of one or more Elastic Compute Service (ECS) instances.
66
+
67
+ :param tags: The tags of the instance.
68
+ :param instance_ids: The IDs of ECS instances
69
+ :return: ECS instance list
70
+ """
71
+ request = DescribeInstancesRequest()
72
+ if tags is not None:
73
+ request.set_Tags(tags)
74
+ if instance_ids is not None:
75
+ request.set_InstanceIds(instance_ids)
76
+ response = self._send_request(request)
77
+ if response is not None:
78
+ instance_list = response.get("Instances").get("Instance")
79
+ return instance_list
80
+ return None
81
+
82
+ def create_instance(
83
+ self,
84
+ instance_type,
85
+ image_id,
86
+ tags,
87
+ key_pair_name,
88
+ optimized="optimized",
89
+ instance_charge_type="PostPaid",
90
+ spot_strategy="SpotWithPriceLimit",
91
+ internet_charge_type="PayByTraffic",
92
+ internet_max_bandwidth_out=5,
93
+ ):
94
+ """Create a subscription or pay-as-you-go ECS instance.
95
+
96
+ :param instance_type: The instance type of the ECS.
97
+ :param image_id: The ID of the image used to create the instance.
98
+ :param tags: The tags of the instance.
99
+ :param key_pair_name: The name of the key pair to be bound to
100
+ the instance.
101
+ :param optimized: Specifies whether the instance is I/O optimized
102
+ :param instance_charge_type: The billing method of the instance.
103
+ Default value: PostPaid.
104
+ :param spot_strategy: The preemption policy for the pay-as-you-go
105
+ instance.
106
+ :param internet_charge_type: The billing method for network usage.
107
+ Default value: PayByTraffic.
108
+ :param internet_max_bandwidth_out: The maximum inbound public
109
+ bandwidth. Unit: Mbit/s.
110
+ :return: The created instance ID.
111
+ """
112
+ request = CreateInstanceRequest()
113
+ request.set_InstanceType(instance_type)
114
+ request.set_ImageId(image_id)
115
+ request.set_IoOptimized(optimized)
116
+ request.set_InstanceChargeType(instance_charge_type)
117
+ request.set_SpotStrategy(spot_strategy)
118
+ request.set_InternetChargeType(internet_charge_type)
119
+ request.set_InternetMaxBandwidthOut(internet_max_bandwidth_out)
120
+ request.set_KeyPairName(key_pair_name)
121
+ request.set_Tags(tags)
122
+
123
+ response = self._send_request(request)
124
+ if response is not None:
125
+ instance_id = response.get("InstanceId")
126
+ logging.info("instance %s created task submit successfully.", instance_id)
127
+ return instance_id
128
+ logging.error("instance created failed.")
129
+ return None
130
+
131
+ def run_instances(
132
+ self,
133
+ instance_type,
134
+ image_id,
135
+ tags,
136
+ security_group_id,
137
+ vswitch_id,
138
+ key_pair_name,
139
+ amount=1,
140
+ optimized="optimized",
141
+ instance_charge_type="PostPaid",
142
+ spot_strategy="SpotWithPriceLimit",
143
+ internet_charge_type="PayByTraffic",
144
+ internet_max_bandwidth_out=1,
145
+ ):
146
+ """Create one or more pay-as-you-go or subscription
147
+ Elastic Compute Service (ECS) instances
148
+
149
+ :param instance_type: The instance type of the ECS.
150
+ :param image_id: The ID of the image used to create the instance.
151
+ :param tags: The tags of the instance.
152
+ :param security_group_id: The ID of the security group to which to
153
+ assign the instance. Instances in the same
154
+ security group can communicate with
155
+ each other.
156
+ :param vswitch_id: The ID of the vSwitch to which to connect
157
+ the instance.
158
+ :param key_pair_name: The name of the key pair to be bound to
159
+ the instance.
160
+ :param amount: The number of instances that you want to create.
161
+ :param optimized: Specifies whether the instance is I/O optimized
162
+ :param instance_charge_type: The billing method of the instance.
163
+ Default value: PostPaid.
164
+ :param spot_strategy: The preemption policy for the pay-as-you-go
165
+ instance.
166
+ :param internet_charge_type: The billing method for network usage.
167
+ Default value: PayByTraffic.
168
+ :param internet_max_bandwidth_out: The maximum inbound public
169
+ bandwidth. Unit: Mbit/s.
170
+ :return: The created instance IDs.
171
+ """
172
+ request = RunInstancesRequest()
173
+ request.set_InstanceType(instance_type)
174
+ request.set_ImageId(image_id)
175
+ request.set_IoOptimized(optimized)
176
+ request.set_InstanceChargeType(instance_charge_type)
177
+ request.set_SpotStrategy(spot_strategy)
178
+ request.set_InternetChargeType(internet_charge_type)
179
+ request.set_InternetMaxBandwidthOut(internet_max_bandwidth_out)
180
+ request.set_Tags(tags)
181
+ request.set_Amount(amount)
182
+ request.set_SecurityGroupId(security_group_id)
183
+ request.set_VSwitchId(vswitch_id)
184
+ request.set_KeyPairName(key_pair_name)
185
+
186
+ response = self._send_request(request)
187
+ if response is not None:
188
+ instance_ids = response.get("InstanceIdSets").get("InstanceIdSet")
189
+ return instance_ids
190
+ logging.error("instance created failed.")
191
+ return None
192
+
193
+ def create_security_group(self, vpc_id):
194
+ """Create a security group
195
+
196
+ :param vpc_id: The ID of the VPC in which to create
197
+ the security group.
198
+ :return: The created security group ID.
199
+ """
200
+ request = CreateSecurityGroupRequest()
201
+ request.set_VpcId(vpc_id)
202
+ response = self._send_request(request)
203
+ if response is not None:
204
+ security_group_id = response.get("SecurityGroupId")
205
+ return security_group_id
206
+ return None
207
+
208
+ def describe_security_groups(self, vpc_id=None, tags=None):
209
+ """Query basic information of security groups.
210
+
211
+ :param vpc_id: The ID of the VPC to which the security group belongs.
212
+ :param tags: The tags of the security group.
213
+ :return: Security group list.
214
+ """
215
+ request = DescribeSecurityGroupsRequest()
216
+ if vpc_id is not None:
217
+ request.set_VpcId(vpc_id)
218
+ if tags is not None:
219
+ request.set_Tags(tags)
220
+ response = self._send_request(request)
221
+ if response is not None:
222
+ security_groups = response.get("SecurityGroups").get("SecurityGroup")
223
+ return security_groups
224
+ logging.error("describe security group failed.")
225
+ return None
226
+
227
+ def authorize_security_group(
228
+ self, ip_protocol, port_range, security_group_id, source_cidr_ip
229
+ ):
230
+ """Create an inbound security group rule.
231
+
232
+ :param ip_protocol: The transport layer protocol.
233
+ :param port_range: The range of destination ports relevant to
234
+ the transport layer protocol.
235
+ :param security_group_id: The ID of the destination security group.
236
+ :param source_cidr_ip: The range of source IPv4 addresses.
237
+ CIDR blocks and IPv4 addresses are supported.
238
+ """
239
+ request = AuthorizeSecurityGroupRequest()
240
+ request.set_IpProtocol(ip_protocol)
241
+ request.set_PortRange(port_range)
242
+ request.set_SecurityGroupId(security_group_id)
243
+ request.set_SourceCidrIp(source_cidr_ip)
244
+ self._send_request(request)
245
+
246
+ def create_v_switch(self, vpc_id, zone_id, cidr_block):
247
+ """Create vSwitches to divide the VPC into one or more subnets
248
+
249
+ :param vpc_id: The ID of the VPC to which the VSwitch belongs.
250
+ :param zone_id: The ID of the zone to which
251
+ the target VSwitch belongs.
252
+ :param cidr_block: The CIDR block of the VSwitch.
253
+ :return:
254
+ """
255
+ request = CreateVSwitchRequest()
256
+ request.set_ZoneId(zone_id)
257
+ request.set_VpcId(vpc_id)
258
+ request.set_CidrBlock(cidr_block)
259
+ response = self._send_request(request)
260
+ if response is not None:
261
+ return response.get("VSwitchId")
262
+ else:
263
+ logging.error("create_v_switch vpc_id %s failed.", vpc_id)
264
+ return None
265
+
266
+ def create_vpc(self):
267
+ """Creates a virtual private cloud (VPC).
268
+
269
+ :return: The created VPC ID.
270
+ """
271
+ request = CreateVpcRequest()
272
+ response = self._send_request(request)
273
+ if response is not None:
274
+ return response.get("VpcId")
275
+ return None
276
+
277
+ def describe_vpcs(self):
278
+ """Queries one or more VPCs in a region.
279
+
280
+ :return: VPC list.
281
+ """
282
+ request = DescribeVpcsRequest()
283
+ response = self._send_request(request)
284
+ if response is not None:
285
+ return response.get("Vpcs").get("Vpc")
286
+ return None
287
+
288
+ def tag_resource(self, resource_ids, tags, resource_type="instance"):
289
+ """Create and bind tags to specified ECS resources.
290
+
291
+ :param resource_ids: The IDs of N resources.
292
+ :param tags: The tags of the resource.
293
+ :param resource_type: The type of the resource.
294
+ """
295
+ request = TagResourcesRequest()
296
+ request.set_Tags(tags)
297
+ request.set_ResourceType(resource_type)
298
+ request.set_ResourceIds(resource_ids)
299
+ response = self._send_request(request)
300
+ if response is not None:
301
+ logging.info("instance %s create tag successfully.", resource_ids)
302
+ else:
303
+ logging.error("instance %s create tag failed.", resource_ids)
304
+
305
+ def start_instance(self, instance_id):
306
+ """Start an ECS instance.
307
+
308
+ :param instance_id: The Ecs instance ID.
309
+ """
310
+ request = StartInstanceRequest()
311
+ request.set_InstanceId(instance_id)
312
+ response = self._send_request(request)
313
+
314
+ if response is not None:
315
+ logging.info("instance %s start successfully.", instance_id)
316
+ else:
317
+ logging.error("instance %s start failed.", instance_id)
318
+
319
+ def stop_instance(self, instance_id, force_stop=False):
320
+ """Stop an ECS instance that is in the Running state.
321
+
322
+ :param instance_id: The Ecs instance ID.
323
+ :param force_stop: Specifies whether to forcibly stop the instance.
324
+ :return:
325
+ """
326
+ request = StopInstanceRequest()
327
+ request.set_InstanceId(instance_id)
328
+ request.set_ForceStop(force_stop)
329
+ logging.info("Stop %s command submit successfully.", instance_id)
330
+ self._send_request(request)
331
+
332
+ def stop_instances(self, instance_ids, stopped_mode="StopCharging"):
333
+ """Stop one or more ECS instances that are in the Running state.
334
+
335
+ :param instance_ids: The IDs of instances.
336
+ :param stopped_mode: Specifies whether billing for the instance
337
+ continues after the instance is stopped.
338
+ """
339
+ request = StopInstancesRequest()
340
+ request.set_InstanceIds(instance_ids)
341
+ request.set_StoppedMode(stopped_mode)
342
+ response = self._send_request(request)
343
+ if response is None:
344
+ logging.error("stop_instances failed")
345
+
346
+ def delete_instance(self, instance_id):
347
+ """Release a pay-as-you-go instance or
348
+ an expired subscription instance.
349
+
350
+ :param instance_id: The ID of the instance that you want to release.
351
+ """
352
+ request = DeleteInstanceRequest()
353
+ request.set_InstanceId(instance_id)
354
+ request.set_Force(True)
355
+ logging.info("Delete %s command submit successfully", instance_id)
356
+ self._send_request(request)
357
+
358
+ def delete_instances(self, instance_ids):
359
+ """Release one or more pay-as-you-go instances or
360
+ expired subscription instances.
361
+
362
+ :param instance_ids: The IDs of instances that you want to release.
363
+ """
364
+ request = DeleteInstancesRequest()
365
+ request.set_Force(True)
366
+ request.set_InstanceIds(instance_ids)
367
+ self._send_request(request)
368
+
369
+ def allocate_public_address(self, instance_id):
370
+ """Assign a public IP address to an ECS instance.
371
+
372
+ :param instance_id: The ID of the instance to which you want to
373
+ assign a public IP address.
374
+ :return: The assigned ip.
375
+ """
376
+ request = AllocatePublicIpAddressRequest()
377
+ request.set_InstanceId(instance_id)
378
+ response = self._send_request(request)
379
+ if response is not None:
380
+ return response.get("IpAddress")
381
+
382
+ def create_key_pair(self, key_pair_name):
383
+ """Create an SSH key pair.
384
+
385
+ :param key_pair_name: The name of the key pair.
386
+ :return: The created keypair data.
387
+ """
388
+ request = CreateKeyPairRequest()
389
+ request.set_KeyPairName(key_pair_name)
390
+ response = self._send_request(request)
391
+ if response is not None:
392
+ logging.info("Create Key Pair %s Successfully", response.get("KeyPairId"))
393
+ return response
394
+ else:
395
+ logging.error("Create Key Pair Failed")
396
+ return None
397
+
398
+ def import_key_pair(self, key_pair_name, public_key_body):
399
+ """Import the public key of an RSA-encrypted key pair
400
+ that is generated by a third-party tool.
401
+
402
+ :param key_pair_name: The name of the key pair.
403
+ :param public_key_body: The public key of the key pair.
404
+ """
405
+ request = ImportKeyPairRequest()
406
+ request.set_KeyPairName(key_pair_name)
407
+ request.set_PublicKeyBody(public_key_body)
408
+ self._send_request(request)
409
+
410
+ def delete_key_pairs(self, key_pair_names):
411
+ """Delete one or more SSH key pairs.
412
+
413
+ :param key_pair_names: The name of the key pair.
414
+ :return:
415
+ """
416
+ request = DeleteKeyPairsRequest()
417
+ request.set_KeyPairNames(key_pair_names)
418
+ self._send_request(request)
419
+
420
+ def describe_key_pairs(self, key_pair_name=None):
421
+ """Query one or more key pairs.
422
+
423
+ :param key_pair_name: The name of the key pair.
424
+ :return:
425
+ """
426
+ request = DescribeKeyPairsRequest()
427
+ if key_pair_name is not None:
428
+ request.set_KeyPairName(key_pair_name)
429
+ response = self._send_request(request)
430
+ if response is not None:
431
+ return response.get("KeyPairs").get("KeyPair")
432
+ else:
433
+ return None
434
+
435
+ def describe_v_switches(self, vpc_id=None):
436
+ """Queries one or more VSwitches.
437
+
438
+ :param vpc_id: The ID of the VPC to which the VSwitch belongs.
439
+ :return: VSwitch list.
440
+ """
441
+ request = DescribeVSwitchesRequest()
442
+ if vpc_id is not None:
443
+ request.set_VpcId(vpc_id)
444
+ response = self._send_request(request)
445
+ if response is not None:
446
+ return response.get("VSwitches").get("VSwitch")
447
+ else:
448
+ logging.error("Describe VSwitches Failed.")
449
+ return None
450
+
451
+ def _send_request(self, request):
452
+ """send open api request"""
453
+ request.set_accept_format("json")
454
+ try:
455
+ response_str = self.cli.do_action_with_exception(request)
456
+ response_detail = json.loads(response_str)
457
+ return response_detail
458
+ except (ClientException, ServerException) as e:
459
+ logging.error(request.get_action_name())
460
+ logging.error(e)
461
+ return None
lib/python3.12/site-packages/ray/autoscaler/_private/autoscaler.py ADDED
@@ -0,0 +1,1522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import logging
3
+ import math
4
+ import operator
5
+ import os
6
+ import queue
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from collections import Counter, defaultdict, namedtuple
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Callable, Dict, FrozenSet, List, Optional, Set, Tuple, Union
14
+
15
+ import yaml
16
+
17
+ import ray
18
+ from ray._common.utils import PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME
19
+ from ray.autoscaler._private.constants import (
20
+ AUTOSCALER_HEARTBEAT_TIMEOUT_S,
21
+ AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
22
+ AUTOSCALER_MAX_LAUNCH_BATCH,
23
+ AUTOSCALER_MAX_NUM_FAILURES,
24
+ AUTOSCALER_STATUS_LOG,
25
+ AUTOSCALER_UPDATE_INTERVAL_S,
26
+ DISABLE_LAUNCH_CONFIG_CHECK_KEY,
27
+ DISABLE_NODE_UPDATERS_KEY,
28
+ FOREGROUND_NODE_LAUNCH_KEY,
29
+ WORKER_LIVENESS_CHECK_KEY,
30
+ )
31
+ from ray.autoscaler._private.event_summarizer import EventSummarizer
32
+ from ray.autoscaler._private.legacy_info_string import legacy_log_info_string
33
+ from ray.autoscaler._private.load_metrics import LoadMetrics
34
+ from ray.autoscaler._private.local.node_provider import (
35
+ LocalNodeProvider,
36
+ record_local_head_state_if_needed,
37
+ )
38
+ from ray.autoscaler._private.node_launcher import BaseNodeLauncher, NodeLauncher
39
+ from ray.autoscaler._private.node_provider_availability_tracker import (
40
+ NodeAvailabilitySummary,
41
+ NodeProviderAvailabilityTracker,
42
+ )
43
+ from ray.autoscaler._private.node_tracker import NodeTracker
44
+ from ray.autoscaler._private.prom_metrics import AutoscalerPrometheusMetrics
45
+ from ray.autoscaler._private.providers import _get_node_provider
46
+ from ray.autoscaler._private.resource_demand_scheduler import (
47
+ ResourceDemandScheduler,
48
+ ResourceDict,
49
+ get_bin_pack_residual,
50
+ placement_groups_to_resource_demands,
51
+ )
52
+ from ray.autoscaler._private.updater import NodeUpdaterThread
53
+ from ray.autoscaler._private.util import (
54
+ ConcurrentCounter,
55
+ NodeCount,
56
+ NodeID,
57
+ NodeIP,
58
+ NodeType,
59
+ NodeTypeConfigDict,
60
+ format_info_string,
61
+ hash_launch_conf,
62
+ hash_runtime_conf,
63
+ validate_config,
64
+ with_head_node_ip,
65
+ )
66
+ from ray.autoscaler.node_provider import NodeProvider
67
+ from ray.autoscaler.tags import (
68
+ NODE_KIND_HEAD,
69
+ NODE_KIND_UNMANAGED,
70
+ NODE_KIND_WORKER,
71
+ STATUS_UP_TO_DATE,
72
+ STATUS_UPDATE_FAILED,
73
+ TAG_RAY_FILE_MOUNTS_CONTENTS,
74
+ TAG_RAY_LAUNCH_CONFIG,
75
+ TAG_RAY_NODE_KIND,
76
+ TAG_RAY_NODE_STATUS,
77
+ TAG_RAY_RUNTIME_CONFIG,
78
+ TAG_RAY_USER_NODE_TYPE,
79
+ )
80
+ from ray.exceptions import RpcError
81
+
82
+ logger = logging.getLogger(__name__)
83
+
84
+ # Status of a node e.g. "up-to-date", see ray/autoscaler/tags.py
85
+ NodeStatus = str
86
+
87
+ # Tuple of modified fields for the given node_id returned by should_update
88
+ # that will be passed into a NodeUpdaterThread.
89
+ UpdateInstructions = namedtuple(
90
+ "UpdateInstructions",
91
+ ["node_id", "setup_commands", "ray_start_commands", "docker_config"],
92
+ )
93
+
94
+ NodeLaunchData = Tuple[NodeTypeConfigDict, NodeCount, Optional[NodeType]]
95
+
96
+
97
+ @dataclass
98
+ class AutoscalerSummary:
99
+ active_nodes: Dict[NodeType, int]
100
+ idle_nodes: Optional[Dict[NodeType, int]]
101
+ pending_nodes: List[Tuple[NodeIP, NodeType, NodeStatus]]
102
+ pending_launches: Dict[NodeType, int]
103
+ failed_nodes: List[Tuple[NodeIP, NodeType]]
104
+ node_availability_summary: NodeAvailabilitySummary = field(
105
+ default_factory=lambda: NodeAvailabilitySummary({})
106
+ )
107
+ # A dictionary of node IP to a list of reasons the node is not idle.
108
+ node_activities: Optional[Dict[str, Tuple[NodeIP, List[str]]]] = None
109
+ pending_resources: Dict[str, int] = field(default_factory=lambda: {})
110
+ # A mapping from node name (the same key as `usage_by_node`) to node type.
111
+ # Optional for deployment modes which have the concept of node types and
112
+ # backwards compatibility.
113
+ node_type_mapping: Optional[Dict[str, str]] = None
114
+ # Whether the autoscaler summary is v1 or v2.
115
+ legacy: bool = False
116
+
117
+
118
+ class NonTerminatedNodes:
119
+ """Class to extract and organize information on non-terminated nodes."""
120
+
121
+ def __init__(self, provider: NodeProvider):
122
+ start_time = time.time()
123
+ # All non-terminated nodes
124
+ self.all_node_ids = provider.non_terminated_nodes({})
125
+
126
+ # Managed worker nodes (node kind "worker"):
127
+ self.worker_ids: List[NodeID] = []
128
+ # The head node (node kind "head")
129
+ self.head_id: Optional[NodeID] = None
130
+
131
+ for node in self.all_node_ids:
132
+ node_kind = provider.node_tags(node)[TAG_RAY_NODE_KIND]
133
+ if node_kind == NODE_KIND_WORKER:
134
+ self.worker_ids.append(node)
135
+ elif node_kind == NODE_KIND_HEAD:
136
+ self.head_id = node
137
+
138
+ # Note: For typical use-cases, self.all_node_ids == self.worker_ids +
139
+ # [self.head_id]. The difference being in the case of unmanaged nodes.
140
+
141
+ # Record the time of the non_terminated nodes call. This typically
142
+ # translates to a "describe" or "list" call on most cluster managers
143
+ # which can be quite expensive. Note that we include the processing
144
+ # time because on some clients, there may be pagination and the
145
+ # underlying api calls may be done lazily.
146
+ self.non_terminated_nodes_time = time.time() - start_time
147
+ logger.info(
148
+ f"The autoscaler took {round(self.non_terminated_nodes_time, 3)}"
149
+ " seconds to fetch the list of non-terminated nodes."
150
+ )
151
+
152
+ def remove_terminating_nodes(self, terminating_nodes: List[NodeID]) -> None:
153
+ """Remove nodes we're in the process of terminating from internal
154
+ state."""
155
+
156
+ def not_terminating(node):
157
+ return node not in terminating_nodes
158
+
159
+ self.worker_ids = list(filter(not_terminating, self.worker_ids))
160
+ self.all_node_ids = list(filter(not_terminating, self.all_node_ids))
161
+
162
+
163
+ # Whether a worker should be kept based on the min_workers and
164
+ # max_workers constraints.
165
+ #
166
+ # keep: should keep the worker
167
+ # terminate: should terminate the worker
168
+ # decide_later: the worker can be terminated if needed
169
+ KeepOrTerminate = Enum("KeepOrTerminate", "keep terminate decide_later")
170
+
171
+
172
+ class StandardAutoscaler:
173
+ """The autoscaling control loop for a Ray cluster.
174
+
175
+ There are two ways to start an autoscaling cluster: manually by running
176
+ `ray start --head --autoscaling-config=/path/to/config.yaml` on a instance
177
+ that has permission to launch other instances, or you can also use `ray up
178
+ /path/to/config.yaml` from your laptop, which will configure the right
179
+ AWS/Cloud roles automatically. See the Ray documentation
180
+ (https://docs.ray.io/en/latest/) for a full definition of autoscaling behavior.
181
+ StandardAutoscaler's `update` method is periodically called in
182
+ `monitor.py`'s monitoring loop.
183
+
184
+ StandardAutoscaler is also used to bootstrap clusters (by adding workers
185
+ until the cluster size that can handle the resource demand is met).
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ # TODO(ekl): require config reader to be a callable always.
191
+ config_reader: Union[str, Callable[[], dict]],
192
+ load_metrics: LoadMetrics,
193
+ gcs_client: "ray._raylet.GcsClient",
194
+ session_name: Optional[str] = None,
195
+ max_launch_batch: int = AUTOSCALER_MAX_LAUNCH_BATCH,
196
+ max_concurrent_launches: int = AUTOSCALER_MAX_CONCURRENT_LAUNCHES,
197
+ max_failures: int = AUTOSCALER_MAX_NUM_FAILURES,
198
+ process_runner: Any = subprocess,
199
+ update_interval_s: int = AUTOSCALER_UPDATE_INTERVAL_S,
200
+ prefix_cluster_info: bool = False,
201
+ event_summarizer: Optional[EventSummarizer] = None,
202
+ prom_metrics: Optional[AutoscalerPrometheusMetrics] = None,
203
+ ):
204
+ """Create a StandardAutoscaler.
205
+
206
+ Args:
207
+ config_reader: Path to a Ray Autoscaler YAML, or a function to read
208
+ and return the latest config.
209
+ load_metrics: Provides metrics for the Ray cluster.
210
+ session_name: The current Ray session name when this autoscaler
211
+ is deployed.
212
+ max_launch_batch: Max number of nodes to launch in one request.
213
+ max_concurrent_launches: Max number of nodes that can be
214
+ concurrently launched. This value and `max_launch_batch`
215
+ determine the number of batches that are used to launch nodes.
216
+ max_failures: Number of failures that the autoscaler will tolerate
217
+ before exiting.
218
+ process_runner: Subproc-like interface used by the CommandRunner.
219
+ update_interval_s: Seconds between running the autoscaling loop.
220
+ prefix_cluster_info: Whether to add the cluster name to info strs.
221
+ event_summarizer: Utility to consolidate duplicated messages.
222
+ prom_metrics: Prometheus metrics for autoscaler-related operations.
223
+ gcs_client: client for interactions with the GCS. Used to drain nodes
224
+ before termination.
225
+ """
226
+
227
+ if isinstance(config_reader, str):
228
+ # Auto wrap with file reader.
229
+ def read_fn():
230
+ with open(config_reader) as f:
231
+ new_config = yaml.safe_load(f.read())
232
+ return new_config
233
+
234
+ self.config_reader = read_fn
235
+ else:
236
+ self.config_reader = config_reader
237
+
238
+ self.node_provider_availability_tracker = NodeProviderAvailabilityTracker()
239
+ # Prefix each line of info string with cluster name if True
240
+ self.prefix_cluster_info = prefix_cluster_info
241
+ # Keep this before self.reset (self.provider needs to be created
242
+ # exactly once).
243
+ self.provider = None
244
+ # Keep this before self.reset (if an exception occurs in reset
245
+ # then prom_metrics must be instantitiated to increment the
246
+ # exception counter)
247
+ self.prom_metrics = prom_metrics or AutoscalerPrometheusMetrics(
248
+ session_name=session_name
249
+ ) # noqa
250
+ self.resource_demand_scheduler = None
251
+ self.reset(errors_fatal=True)
252
+ self.load_metrics = load_metrics
253
+
254
+ self.max_failures = max_failures
255
+ self.max_launch_batch = max_launch_batch
256
+ self.max_concurrent_launches = max_concurrent_launches
257
+ self.process_runner = process_runner
258
+ self.event_summarizer = event_summarizer or EventSummarizer()
259
+
260
+ # Map from node_id to NodeUpdater threads
261
+ self.updaters: Dict[NodeID, NodeUpdaterThread] = {}
262
+ self.num_failed_updates: Dict[NodeID, int] = defaultdict(int)
263
+ self.num_successful_updates: Dict[NodeID, int] = defaultdict(int)
264
+ self.num_failures = 0
265
+ self.last_update_time = 0.0
266
+ self.update_interval_s = update_interval_s
267
+
268
+ # Keeps track of pending and running nodes
269
+ self.non_terminated_nodes: Optional[NonTerminatedNodes] = None
270
+
271
+ # Tracks nodes scheduled for termination
272
+ self.nodes_to_terminate: List[NodeID] = []
273
+
274
+ # Disable NodeUpdater threads if true.
275
+ # Should be set to true in situations where another component, such as
276
+ # a Kubernetes operator, is responsible for Ray setup on nodes.
277
+ self.disable_node_updaters = self.config["provider"].get(
278
+ DISABLE_NODE_UPDATERS_KEY, False
279
+ )
280
+ logger.info(f"{DISABLE_NODE_UPDATERS_KEY}:{self.disable_node_updaters}")
281
+
282
+ # Disable launch configuration checking if set to true.
283
+ # This setting is used in scenarios where there is no meaningful node type
284
+ # to enforce, such as in fake multinode situations. When this option is enabled,
285
+ # outdated nodes will not be terminated.
286
+ self.disable_launch_config_check = self.config["provider"].get(
287
+ DISABLE_LAUNCH_CONFIG_CHECK_KEY, False
288
+ )
289
+ logger.info(
290
+ f"{DISABLE_LAUNCH_CONFIG_CHECK_KEY}:{self.disable_launch_config_check}"
291
+ )
292
+
293
+ # By default, the autoscaler launches nodes in batches asynchronously in
294
+ # background threads.
295
+ # When the following flag is set, that behavior is disabled, so that nodes
296
+ # are launched in the main thread, all in one batch, blocking until all
297
+ # NodeProvider.create_node calls have returned.
298
+ self.foreground_node_launch = self.config["provider"].get(
299
+ FOREGROUND_NODE_LAUNCH_KEY, False
300
+ )
301
+ logger.info(f"{FOREGROUND_NODE_LAUNCH_KEY}:{self.foreground_node_launch}")
302
+
303
+ # By default, the autoscaler kills and/or tries to recover
304
+ # a worker node if it hasn't produced a resource heartbeat in the last 30
305
+ # seconds. The worker_liveness_check flag allows disabling this behavior in
306
+ # settings where another component, such as a Kubernetes operator, is
307
+ # responsible for healthchecks.
308
+ self.worker_liveness_check = self.config["provider"].get(
309
+ WORKER_LIVENESS_CHECK_KEY, True
310
+ )
311
+ logger.info(f"{WORKER_LIVENESS_CHECK_KEY}:{self.worker_liveness_check}")
312
+
313
+ # Node launchers
314
+ self.foreground_node_launcher: Optional[BaseNodeLauncher] = None
315
+ self.launch_queue: Optional[queue.Queue[NodeLaunchData]] = None
316
+ self.pending_launches = ConcurrentCounter()
317
+ if self.foreground_node_launch:
318
+ self.foreground_node_launcher = BaseNodeLauncher(
319
+ provider=self.provider,
320
+ pending=self.pending_launches,
321
+ event_summarizer=self.event_summarizer,
322
+ node_provider_availability_tracker=self.node_provider_availability_tracker, # noqa: E501 Flake and black disagree how to format this.
323
+ session_name=session_name,
324
+ node_types=self.available_node_types,
325
+ prom_metrics=self.prom_metrics,
326
+ )
327
+ else:
328
+ self.launch_queue = queue.Queue()
329
+ max_batches = math.ceil(max_concurrent_launches / float(max_launch_batch))
330
+ for i in range(int(max_batches)):
331
+ node_launcher = NodeLauncher(
332
+ provider=self.provider,
333
+ queue=self.launch_queue,
334
+ index=i,
335
+ pending=self.pending_launches,
336
+ event_summarizer=self.event_summarizer,
337
+ node_provider_availability_tracker=self.node_provider_availability_tracker, # noqa: E501 Flake and black disagreee how to format this.
338
+ session_name=session_name,
339
+ node_types=self.available_node_types,
340
+ prom_metrics=self.prom_metrics,
341
+ )
342
+ node_launcher.daemon = True
343
+ node_launcher.start()
344
+
345
+ # NodeTracker maintains soft state to track the number of recently
346
+ # failed nodes. It is best effort only.
347
+ self.node_tracker = NodeTracker()
348
+
349
+ # Expand local file_mounts to allow ~ in the paths. This can't be done
350
+ # earlier when the config is written since we might be on different
351
+ # platform and the expansion would result in wrong path.
352
+ self.config["file_mounts"] = {
353
+ remote: os.path.expanduser(local)
354
+ for remote, local in self.config["file_mounts"].items()
355
+ }
356
+
357
+ self.gcs_client = gcs_client
358
+
359
+ for local_path in self.config["file_mounts"].values():
360
+ assert os.path.exists(local_path)
361
+ logger.info("StandardAutoscaler: {}".format(self.config))
362
+
363
+ @property
364
+ def all_node_types(self) -> Set[str]:
365
+ return self.config["available_node_types"].keys()
366
+
367
+ def update(self):
368
+ try:
369
+ self.reset(errors_fatal=False)
370
+ self._update()
371
+ except Exception as e:
372
+ self.prom_metrics.update_loop_exceptions.inc()
373
+ logger.exception("StandardAutoscaler: Error during autoscaling.")
374
+ self.num_failures += 1
375
+ if self.num_failures > self.max_failures:
376
+ logger.critical("StandardAutoscaler: Too many errors, abort.")
377
+ raise e
378
+
379
+ def _update(self):
380
+ # For type checking, assert that these objects have been instantitiated.
381
+ assert self.provider
382
+ assert self.resource_demand_scheduler
383
+
384
+ now = time.time()
385
+ # Throttle autoscaling updates to this interval to avoid exceeding
386
+ # rate limits on API calls.
387
+ if now - self.last_update_time < self.update_interval_s:
388
+ return
389
+
390
+ self.last_update_time = now
391
+
392
+ # Query the provider to update the list of non-terminated nodes
393
+ self.non_terminated_nodes = NonTerminatedNodes(self.provider)
394
+
395
+ # Back off the update if the provider says it's not safe to proceed.
396
+ if not self.provider.safe_to_scale():
397
+ logger.info(
398
+ "Backing off of autoscaler update."
399
+ f" Will try again in {self.update_interval_s} seconds."
400
+ )
401
+ return
402
+
403
+ # This will accumulate the nodes we need to terminate.
404
+ self.nodes_to_terminate = []
405
+
406
+ # Update status strings
407
+ if AUTOSCALER_STATUS_LOG:
408
+ logger.info(self.info_string())
409
+ legacy_log_info_string(self, self.non_terminated_nodes.worker_ids)
410
+
411
+ if not self.provider.is_readonly():
412
+ self.terminate_nodes_to_enforce_config_constraints(now)
413
+
414
+ if self.disable_node_updaters:
415
+ # Don't handle unhealthy nodes if the liveness check is disabled.
416
+ # self.worker_liveness_check is True by default.
417
+ if self.worker_liveness_check:
418
+ self.terminate_unhealthy_nodes(now)
419
+ else:
420
+ self.process_completed_updates()
421
+ self.update_nodes()
422
+ # Don't handle unhealthy nodes if the liveness check is disabled.
423
+ # self.worker_liveness_check is True by default.
424
+ if self.worker_liveness_check:
425
+ self.attempt_to_recover_unhealthy_nodes(now)
426
+ self.set_prometheus_updater_data()
427
+
428
+ # Update running nodes gauge
429
+ num_workers = len(self.non_terminated_nodes.worker_ids)
430
+ self.prom_metrics.running_workers.set(num_workers)
431
+
432
+ # Remove IPs from LoadMetrics that are not known to the NodeProvider.
433
+ self.load_metrics.prune_active_ips(
434
+ active_ips=[
435
+ self.provider.internal_ip(node_id)
436
+ for node_id in self.non_terminated_nodes.all_node_ids
437
+ ]
438
+ )
439
+
440
+ # Dict[NodeType, int], List[ResourceDict]
441
+ to_launch, unfulfilled = self.resource_demand_scheduler.get_nodes_to_launch(
442
+ self.non_terminated_nodes.all_node_ids,
443
+ self.pending_launches.breakdown(),
444
+ self.load_metrics.get_resource_demand_vector(),
445
+ self.load_metrics.get_resource_utilization(),
446
+ self.load_metrics.get_pending_placement_groups(),
447
+ self.load_metrics.get_static_node_resources_by_ip(),
448
+ ensure_min_cluster_size=self.load_metrics.get_resource_requests(),
449
+ node_availability_summary=self.node_provider_availability_tracker.summary(),
450
+ )
451
+ self._report_pending_infeasible(unfulfilled)
452
+
453
+ if not self.provider.is_readonly():
454
+ self.launch_required_nodes(to_launch)
455
+
456
+ # Execute optional end-of-update logic.
457
+ # Keep this method call at the end of autoscaler._update().
458
+ self.provider.post_process()
459
+
460
+ # Record the amount of time the autoscaler took for
461
+ # this _update() iteration.
462
+ update_time = time.time() - self.last_update_time
463
+ logger.info(
464
+ f"The autoscaler took {round(update_time, 3)}"
465
+ " seconds to complete the update iteration."
466
+ )
467
+ self.prom_metrics.update_time.observe(update_time)
468
+
469
+ def terminate_nodes_to_enforce_config_constraints(self, now: float):
470
+ """Terminates nodes to enforce constraints defined by the autoscaling
471
+ config.
472
+
473
+ (1) Terminates nodes in excess of `max_workers`.
474
+ (2) Terminates nodes idle for longer than `idle_timeout_minutes`.
475
+ (3) Terminates outdated nodes,
476
+ namely nodes whose configs don't match `node_config` for the
477
+ relevant node type.
478
+
479
+ Avoids terminating non-outdated nodes required by
480
+ autoscaler.sdk.request_resources().
481
+ """
482
+ # For type checking, assert that these objects have been instantitiated.
483
+ assert self.non_terminated_nodes
484
+ assert self.provider
485
+
486
+ last_used = self.load_metrics.ray_nodes_last_used_time_by_ip
487
+
488
+ idle_timeout_s = 60 * self.config["idle_timeout_minutes"]
489
+
490
+ last_used_cutoff = now - idle_timeout_s
491
+
492
+ # Sort based on last used to make sure to keep min_workers that
493
+ # were most recently used. Otherwise, _keep_min_workers_of_node_type
494
+ # might keep a node that should be terminated.
495
+ sorted_node_ids = self._sort_based_on_last_used(
496
+ self.non_terminated_nodes.worker_ids, last_used
497
+ )
498
+
499
+ # Don't terminate nodes needed by request_resources()
500
+ nodes_not_allowed_to_terminate = self._get_nodes_needed_for_request_resources(
501
+ sorted_node_ids
502
+ )
503
+
504
+ # Tracks counts of nodes we intend to keep for each node type.
505
+ node_type_counts = defaultdict(int)
506
+
507
+ def keep_node(node_id: NodeID) -> None:
508
+ assert self.provider
509
+ # Update per-type counts.
510
+ tags = self.provider.node_tags(node_id)
511
+ if TAG_RAY_USER_NODE_TYPE in tags:
512
+ node_type = tags[TAG_RAY_USER_NODE_TYPE]
513
+ node_type_counts[node_type] += 1
514
+
515
+ # Nodes that we could terminate, if needed.
516
+ nodes_we_could_terminate: List[NodeID] = []
517
+
518
+ for node_id in sorted_node_ids:
519
+ # Make sure to not kill idle node types if the number of workers
520
+ # of that type is lower/equal to the min_workers of that type
521
+ # or it is needed for request_resources().
522
+ should_keep_or_terminate, reason = self._keep_worker_of_node_type(
523
+ node_id, node_type_counts
524
+ )
525
+ if should_keep_or_terminate == KeepOrTerminate.terminate:
526
+ self.schedule_node_termination(node_id, reason, logger.info)
527
+ continue
528
+ if (
529
+ should_keep_or_terminate == KeepOrTerminate.keep
530
+ or node_id in nodes_not_allowed_to_terminate
531
+ ) and self.launch_config_ok(node_id):
532
+ keep_node(node_id)
533
+ continue
534
+
535
+ node_ip = self.provider.internal_ip(node_id)
536
+
537
+ if node_ip in last_used and last_used[node_ip] < last_used_cutoff:
538
+ self.schedule_node_termination(node_id, "idle", logger.info)
539
+ # Get the local time of the node's last use as a string.
540
+ formatted_last_used_time = time.asctime(
541
+ time.localtime(last_used[node_ip])
542
+ )
543
+ logger.info(f"Node last used: {formatted_last_used_time}.")
544
+ # Note that the current time will appear in the log prefix.
545
+ elif not self.launch_config_ok(node_id):
546
+ self.schedule_node_termination(node_id, "outdated", logger.info)
547
+ else:
548
+ keep_node(node_id)
549
+ nodes_we_could_terminate.append(node_id)
550
+
551
+ # Terminate nodes if there are too many
552
+ num_workers = len(self.non_terminated_nodes.worker_ids)
553
+ num_extra_nodes_to_terminate = (
554
+ num_workers - len(self.nodes_to_terminate) - self.config["max_workers"]
555
+ )
556
+
557
+ if num_extra_nodes_to_terminate > len(nodes_we_could_terminate):
558
+ logger.warning(
559
+ "StandardAutoscaler: trying to terminate "
560
+ f"{num_extra_nodes_to_terminate} nodes, while only "
561
+ f"{len(nodes_we_could_terminate)} are safe to terminate."
562
+ " Inconsistent config is likely."
563
+ )
564
+ num_extra_nodes_to_terminate = len(nodes_we_could_terminate)
565
+
566
+ # If num_extra_nodes_to_terminate is negative or zero,
567
+ # we would have less than max_workers nodes after terminating
568
+ # nodes_to_terminate and we do not need to terminate anything else.
569
+ if num_extra_nodes_to_terminate > 0:
570
+ extra_nodes_to_terminate = nodes_we_could_terminate[
571
+ -num_extra_nodes_to_terminate:
572
+ ]
573
+ for node_id in extra_nodes_to_terminate:
574
+ self.schedule_node_termination(node_id, "max workers", logger.info)
575
+
576
+ self.terminate_scheduled_nodes()
577
+
578
+ def schedule_node_termination(
579
+ self, node_id: NodeID, reason_opt: Optional[str], logger_method: Callable
580
+ ) -> None:
581
+ # For type checking, assert that this object has been instantitiated.
582
+ assert self.provider
583
+
584
+ if reason_opt is None:
585
+ raise Exception("reason should be not None.")
586
+ reason: str = reason_opt
587
+ node_ip = self.provider.internal_ip(node_id)
588
+ # Log, record an event, and add node_id to nodes_to_terminate.
589
+ logger_method(
590
+ "StandardAutoscaler: "
591
+ f"Terminating the node with id {node_id}"
592
+ f" and ip {node_ip}."
593
+ f" ({reason})"
594
+ )
595
+ self.event_summarizer.add(
596
+ "Removing {} nodes of type "
597
+ + self._get_node_type(node_id)
598
+ + " ({}).".format(reason),
599
+ quantity=1,
600
+ aggregate=operator.add,
601
+ )
602
+ self.nodes_to_terminate.append(node_id)
603
+
604
+ def terminate_scheduled_nodes(self):
605
+ """Terminate scheduled nodes and clean associated autoscaler state."""
606
+ # For type checking, assert that these objects have been instantitiated.
607
+ assert self.provider
608
+ assert self.non_terminated_nodes
609
+
610
+ if not self.nodes_to_terminate:
611
+ return
612
+
613
+ # Drain the nodes
614
+ self.drain_nodes_via_gcs(self.nodes_to_terminate)
615
+ # Terminate the nodes
616
+ self.provider.terminate_nodes(self.nodes_to_terminate)
617
+ for node in self.nodes_to_terminate:
618
+ self.node_tracker.untrack(node)
619
+ self.prom_metrics.stopped_nodes.inc()
620
+
621
+ # Update internal node lists
622
+ self.non_terminated_nodes.remove_terminating_nodes(self.nodes_to_terminate)
623
+
624
+ self.nodes_to_terminate = []
625
+
626
+ def drain_nodes_via_gcs(self, provider_node_ids_to_drain: List[NodeID]):
627
+ """Send an RPC request to the GCS to drain (prepare for termination)
628
+ the nodes with the given node provider ids.
629
+
630
+ note: The current implementation of DrainNode on the GCS side is to
631
+ de-register and gracefully shut down the Raylets. In the future,
632
+ the behavior may change to better reflect the name "Drain."
633
+ See https://github.com/ray-project/ray/pull/19350.
634
+ """
635
+ # For type checking, assert that this object has been instantitiated.
636
+ assert self.provider
637
+
638
+ # The GCS expects Node ids in the request, rather than NodeProvider
639
+ # ids. To get the Node ids of the nodes to we're draining, we make
640
+ # the following translations of identifiers:
641
+ # node provider node id -> ip -> node id
642
+
643
+ # Convert node provider node ids to ips.
644
+ node_ips = set()
645
+ failed_ip_fetch = False
646
+ for provider_node_id in provider_node_ids_to_drain:
647
+ # If the provider's call to fetch ip fails, the exception is not
648
+ # fatal. Log the exception and proceed.
649
+ try:
650
+ ip = self.provider.internal_ip(provider_node_id)
651
+ node_ips.add(ip)
652
+ except Exception:
653
+ logger.exception(
654
+ "Failed to get ip of node with id"
655
+ f" {provider_node_id} during scale-down."
656
+ )
657
+ failed_ip_fetch = True
658
+ if failed_ip_fetch:
659
+ self.prom_metrics.drain_node_exceptions.inc()
660
+
661
+ # Only attempt to drain connected nodes, i.e. nodes with ips in
662
+ # LoadMetrics.
663
+ connected_node_ips = node_ips & self.load_metrics.node_id_by_ip.keys()
664
+
665
+ # Convert ips to Node ids.
666
+ # (The assignment ip->node_id is well-defined under current
667
+ # assumptions. See "use_node_id_as_ip" in monitor.py)
668
+ node_ids_to_drain = {
669
+ self.load_metrics.node_id_by_ip[ip] for ip in connected_node_ips
670
+ }
671
+
672
+ if not node_ids_to_drain:
673
+ return
674
+
675
+ logger.info(f"Draining {len(node_ids_to_drain)} raylet(s).")
676
+ try:
677
+ # A successful response indicates that the GCS has marked the
678
+ # desired nodes as "drained." The cloud provider can then terminate
679
+ # the nodes without the GCS printing an error.
680
+ # Check if we succeeded in draining all of the intended nodes by
681
+ # looking at the RPC response.
682
+ drained_node_ids = set(
683
+ self.gcs_client.drain_nodes(node_ids_to_drain, timeout=5)
684
+ )
685
+ failed_to_drain = node_ids_to_drain - drained_node_ids
686
+ if failed_to_drain:
687
+ self.prom_metrics.drain_node_exceptions.inc()
688
+ logger.error(f"Failed to drain {len(failed_to_drain)} raylet(s).")
689
+ # If we get a gRPC error with an UNIMPLEMENTED code, fail silently.
690
+ # This error indicates that the GCS is using Ray version < 1.8.0,
691
+ # for which DrainNode is not implemented.
692
+ except RpcError as e:
693
+ # If the code is UNIMPLEMENTED, pass.
694
+ if e.rpc_code == ray._raylet.GRPC_STATUS_CODE_UNIMPLEMENTED:
695
+ pass
696
+ # Otherwise, it's a plain old gRPC error and we should log it.
697
+ else:
698
+ self.prom_metrics.drain_node_exceptions.inc()
699
+ logger.exception("Failed to drain Ray nodes. Traceback follows.")
700
+ except Exception:
701
+ # We don't need to interrupt the autoscaler update with an
702
+ # exception, but we should log what went wrong and record the
703
+ # failure in Prometheus.
704
+ self.prom_metrics.drain_node_exceptions.inc()
705
+ logger.exception("Failed to drain Ray nodes. Traceback follows.")
706
+
707
+ def launch_required_nodes(self, to_launch: Dict[NodeType, int]) -> None:
708
+ if to_launch:
709
+ for node_type, count in to_launch.items():
710
+ self.launch_new_node(count, node_type=node_type)
711
+
712
+ def update_nodes(self):
713
+ """Run NodeUpdaterThreads to run setup commands, sync files,
714
+ and/or start Ray.
715
+ """
716
+ # Update nodes with out-of-date files.
717
+ # TODO(edoakes): Spawning these threads directly seems to cause
718
+ # problems. They should at a minimum be spawned as daemon threads.
719
+ # See https://github.com/ray-project/ray/pull/5903 for more info.
720
+ T = []
721
+ for node_id, setup_commands, ray_start_commands, docker_config in (
722
+ self.should_update(node_id)
723
+ for node_id in self.non_terminated_nodes.worker_ids
724
+ ):
725
+ if node_id is not None:
726
+ resources = self._node_resources(node_id)
727
+ labels = self._node_labels(node_id)
728
+ logger.debug(f"{node_id}: Starting new thread runner.")
729
+ T.append(
730
+ threading.Thread(
731
+ target=self.spawn_updater,
732
+ args=(
733
+ node_id,
734
+ setup_commands,
735
+ ray_start_commands,
736
+ resources,
737
+ labels,
738
+ docker_config,
739
+ ),
740
+ )
741
+ )
742
+ for t in T:
743
+ t.start()
744
+ for t in T:
745
+ t.join()
746
+
747
+ def process_completed_updates(self):
748
+ """Clean up completed NodeUpdaterThreads."""
749
+ completed_nodes = []
750
+ for node_id, updater in self.updaters.items():
751
+ if not updater.is_alive():
752
+ completed_nodes.append(node_id)
753
+ if completed_nodes:
754
+ failed_nodes = []
755
+ for node_id in completed_nodes:
756
+ updater = self.updaters[node_id]
757
+ if updater.exitcode == 0:
758
+ self.num_successful_updates[node_id] += 1
759
+ self.prom_metrics.successful_updates.inc()
760
+ if updater.for_recovery:
761
+ self.prom_metrics.successful_recoveries.inc()
762
+ if updater.update_time:
763
+ self.prom_metrics.worker_update_time.observe(
764
+ updater.update_time
765
+ )
766
+ # Mark the node as active to prevent the node recovery
767
+ # logic immediately trying to restart Ray on the new node.
768
+ self.load_metrics.mark_active(self.provider.internal_ip(node_id))
769
+ else:
770
+ failed_nodes.append(node_id)
771
+ self.num_failed_updates[node_id] += 1
772
+ self.prom_metrics.failed_updates.inc()
773
+ if updater.for_recovery:
774
+ self.prom_metrics.failed_recoveries.inc()
775
+ self.node_tracker.untrack(node_id)
776
+ del self.updaters[node_id]
777
+
778
+ if failed_nodes:
779
+ # Some nodes in failed_nodes may already have been terminated
780
+ # during an update (for being idle after missing a heartbeat).
781
+
782
+ # Update the list of non-terminated workers.
783
+ for node_id in failed_nodes:
784
+ # Check if the node has already been terminated.
785
+ if node_id in self.non_terminated_nodes.worker_ids:
786
+ self.schedule_node_termination(
787
+ node_id, "launch failed", logger.error
788
+ )
789
+ else:
790
+ logger.warning(
791
+ f"StandardAutoscaler: {node_id}:"
792
+ " Failed to update node."
793
+ " Node has already been terminated."
794
+ )
795
+ self.terminate_scheduled_nodes()
796
+
797
+ def set_prometheus_updater_data(self):
798
+ """Record total number of active NodeUpdaterThreads and how many of
799
+ these are being run to recover nodes.
800
+ """
801
+ self.prom_metrics.updating_nodes.set(len(self.updaters))
802
+ num_recovering = 0
803
+ for updater in self.updaters.values():
804
+ if updater.for_recovery:
805
+ num_recovering += 1
806
+ self.prom_metrics.recovering_nodes.set(num_recovering)
807
+
808
+ def _report_pending_infeasible(self, unfulfilled: List[ResourceDict]):
809
+ """Emit event messages for infeasible or unschedulable tasks.
810
+
811
+ This adds messages to the event summarizer for warning on infeasible
812
+ or "cluster full" resource requests.
813
+
814
+ Args:
815
+ unfulfilled: List of resource demands that would be unfulfilled
816
+ even after full scale-up.
817
+ """
818
+ # For type checking, assert that this object has been instantitiated.
819
+ assert self.resource_demand_scheduler
820
+ pending = []
821
+ infeasible = []
822
+ for bundle in unfulfilled:
823
+ placement_group = any(
824
+ "_group_" in k or k == PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME
825
+ for k in bundle
826
+ )
827
+ if placement_group:
828
+ continue
829
+ if self.resource_demand_scheduler.is_feasible(bundle):
830
+ pending.append(bundle)
831
+ else:
832
+ infeasible.append(bundle)
833
+ if pending:
834
+ if self.load_metrics.cluster_full_of_actors_detected:
835
+ for request in pending:
836
+ self.event_summarizer.add_once_per_interval(
837
+ "Warning: The following resource request cannot be "
838
+ "scheduled right now: {}. This is likely due to all "
839
+ "cluster resources being claimed by actors. Consider "
840
+ "creating fewer actors or adding more nodes "
841
+ "to this Ray cluster.".format(request),
842
+ key="pending_{}".format(sorted(request.items())),
843
+ interval_s=30,
844
+ )
845
+ if infeasible:
846
+ for request in infeasible:
847
+ self.event_summarizer.add_once_per_interval(
848
+ "Error: No available node types can fulfill resource "
849
+ "request {}. Add suitable node types to this cluster to "
850
+ "resolve this issue.".format(request),
851
+ key="infeasible_{}".format(sorted(request.items())),
852
+ interval_s=30,
853
+ )
854
+
855
+ def _sort_based_on_last_used(
856
+ self, nodes: List[NodeID], last_used: Dict[str, float]
857
+ ) -> List[NodeID]:
858
+ """Sort the nodes based on the last time they were used.
859
+
860
+ The first item in the return list is the most recently used.
861
+ """
862
+ last_used_copy = copy.deepcopy(last_used)
863
+ # Add the unconnected nodes as the least recently used (the end of
864
+ # list). This prioritizes connected nodes.
865
+ least_recently_used = -1
866
+
867
+ def last_time_used(node_id: NodeID):
868
+ assert self.provider
869
+ node_ip = self.provider.internal_ip(node_id)
870
+ if node_ip not in last_used_copy:
871
+ return least_recently_used
872
+ else:
873
+ return last_used_copy[node_ip]
874
+
875
+ return sorted(nodes, key=last_time_used, reverse=True)
876
+
877
+ def _get_nodes_needed_for_request_resources(
878
+ self, sorted_node_ids: List[NodeID]
879
+ ) -> FrozenSet[NodeID]:
880
+ # TODO(ameer): try merging this with resource_demand_scheduler
881
+ # code responsible for adding nodes for get_resource_requests() and get_pending_placement_groups().
882
+ """Returns the nodes NOT allowed to terminate due to get_resource_requests() and get_pending_placement_groups().
883
+
884
+ Args:
885
+ sorted_node_ids: the node ids sorted based on last used (LRU last).
886
+
887
+ Returns:
888
+ FrozenSet[NodeID]: a set of nodes (node ids) that
889
+ we should NOT terminate.
890
+ """
891
+ # For type checking, assert that this object has been instantitiated.
892
+ assert self.provider
893
+
894
+ nodes_not_allowed_to_terminate: Set[NodeID] = set()
895
+
896
+ resource_demands, strict_spreads = placement_groups_to_resource_demands(
897
+ self.load_metrics.get_pending_placement_groups()
898
+ )
899
+ resource_demands.extend(self.load_metrics.get_resource_requests())
900
+ if not resource_demands and not strict_spreads:
901
+ return frozenset(nodes_not_allowed_to_terminate)
902
+
903
+ static_node_resources: Dict[
904
+ NodeIP, ResourceDict
905
+ ] = self.load_metrics.get_static_node_resources_by_ip()
906
+
907
+ head_node_resources: ResourceDict = copy.deepcopy(
908
+ self.available_node_types[self.config["head_node_type"]]["resources"]
909
+ )
910
+ # TODO(ameer): this is somewhat duplicated in
911
+ # resource_demand_scheduler.py.
912
+ if not head_node_resources:
913
+ # Legacy yaml might include {} in the resources field.
914
+ head_node_ip = self.provider.internal_ip(self.non_terminated_nodes.head_id)
915
+ head_node_resources = static_node_resources.get(head_node_ip, {})
916
+
917
+ node_total_resources: List[ResourceDict] = [head_node_resources]
918
+ resource_demand_vector_worker_node_ids = []
919
+ # Get max resources on all the non terminated nodes.
920
+ for node_id in sorted_node_ids:
921
+ tags = self.provider.node_tags(node_id)
922
+ if TAG_RAY_USER_NODE_TYPE in tags:
923
+ node_type = tags[TAG_RAY_USER_NODE_TYPE]
924
+ node_resources: ResourceDict = copy.deepcopy(
925
+ self.available_node_types[node_type]["resources"]
926
+ )
927
+ if not node_resources:
928
+ # Legacy yaml might include {} in the resources field.
929
+ node_ip = self.provider.internal_ip(node_id)
930
+ node_resources = static_node_resources.get(node_ip, {})
931
+ node_total_resources.append(node_resources)
932
+ resource_demand_vector_worker_node_ids.append(node_id)
933
+ # Since it is sorted based on last used, we "keep" nodes that are
934
+ # most recently used when we binpack. We assume get_bin_pack_residual
935
+ # is following the given order here.
936
+ node_remaining_resources = copy.deepcopy(node_total_resources)
937
+
938
+ for strict_spread in strict_spreads:
939
+ unfulfilled, updated_node_remaining_resources = get_bin_pack_residual(
940
+ node_remaining_resources, strict_spread, strict_spread=True
941
+ )
942
+ if unfulfilled:
943
+ continue
944
+ node_remaining_resources = updated_node_remaining_resources
945
+
946
+ _, node_remaining_resources = get_bin_pack_residual(
947
+ node_remaining_resources, resource_demands
948
+ )
949
+ # Remove the first entry (the head node).
950
+ node_total_resources.pop(0)
951
+ # Remove the first entry (the head node).
952
+ node_remaining_resources.pop(0)
953
+ for i, node_id in enumerate(resource_demand_vector_worker_node_ids):
954
+ if (
955
+ node_remaining_resources[i] == node_total_resources[i]
956
+ and node_total_resources[i]
957
+ ):
958
+ # No resources of the node were needed for request_resources().
959
+ # node_total_resources[i] is an empty dict for legacy yamls
960
+ # before the node is connected.
961
+ pass
962
+ else:
963
+ nodes_not_allowed_to_terminate.add(node_id)
964
+ return frozenset(nodes_not_allowed_to_terminate)
965
+
966
+ def _keep_worker_of_node_type(
967
+ self, node_id: NodeID, node_type_counts: Dict[NodeType, int]
968
+ ) -> Tuple[KeepOrTerminate, Optional[str]]:
969
+ """Determines if a worker should be kept based on the min_workers
970
+ and max_workers constraint of the worker's node_type.
971
+
972
+ Returns KeepOrTerminate.keep when both of the following hold:
973
+ (a) The worker's node_type is present among the keys of the current
974
+ config's available_node_types dict.
975
+ (b) Deleting the node would violate the min_workers constraint for that
976
+ worker's node_type.
977
+
978
+ Returns KeepOrTerminate.terminate when both the following hold:
979
+ (a) The worker's node_type is not present among the keys of the current
980
+ config's available_node_types dict.
981
+ (b) Keeping the node would violate the max_workers constraint for that
982
+ worker's node_type.
983
+
984
+ Return KeepOrTerminate.decide_later otherwise.
985
+
986
+ Args:
987
+ node_type_counts(Dict[NodeType, int]): The non_terminated node
988
+ types counted so far.
989
+ Returns:
990
+ KeepOrTerminate: keep if the node should be kept, terminate if the
991
+ node should be terminated, decide_later if we are allowed
992
+ to terminate it, but do not have to.
993
+ Optional[str]: reason for termination. Not None on
994
+ KeepOrTerminate.terminate, None otherwise.
995
+ """
996
+ # For type checking, assert that this object has been instantitiated.
997
+ assert self.provider
998
+
999
+ tags = self.provider.node_tags(node_id)
1000
+ if TAG_RAY_USER_NODE_TYPE in tags:
1001
+ node_type = tags[TAG_RAY_USER_NODE_TYPE]
1002
+
1003
+ min_workers = self.available_node_types.get(node_type, {}).get(
1004
+ "min_workers", 0
1005
+ )
1006
+ max_workers = self.available_node_types.get(node_type, {}).get(
1007
+ "max_workers", 0
1008
+ )
1009
+ if node_type not in self.available_node_types:
1010
+ # The node type has been deleted from the cluster config.
1011
+ # Allow terminating it if needed.
1012
+ available_node_types = list(self.available_node_types.keys())
1013
+ return (
1014
+ KeepOrTerminate.terminate,
1015
+ f"not in available_node_types: {available_node_types}",
1016
+ )
1017
+ new_count = node_type_counts[node_type] + 1
1018
+ if new_count <= min(min_workers, max_workers):
1019
+ return KeepOrTerminate.keep, None
1020
+ if new_count > max_workers:
1021
+ return KeepOrTerminate.terminate, "max_workers_per_type"
1022
+
1023
+ return KeepOrTerminate.decide_later, None
1024
+
1025
+ def _node_resources(self, node_id):
1026
+ node_type = self.provider.node_tags(node_id).get(TAG_RAY_USER_NODE_TYPE)
1027
+ if self.available_node_types:
1028
+ return self.available_node_types.get(node_type, {}).get("resources", {})
1029
+ else:
1030
+ return {}
1031
+
1032
+ def _node_labels(self, node_id):
1033
+ node_type = self.provider.node_tags(node_id).get(TAG_RAY_USER_NODE_TYPE)
1034
+ if self.available_node_types:
1035
+ return self.available_node_types.get(node_type, {}).get("labels", {})
1036
+ else:
1037
+ return {}
1038
+
1039
+ def reset(self, errors_fatal=False):
1040
+ sync_continuously = False
1041
+ if hasattr(self, "config"):
1042
+ sync_continuously = self.config.get("file_mounts_sync_continuously", False)
1043
+ try:
1044
+ new_config = self.config_reader()
1045
+ if new_config != getattr(self, "config", None):
1046
+ try:
1047
+ validate_config(new_config)
1048
+ except Exception as e:
1049
+ self.prom_metrics.config_validation_exceptions.inc()
1050
+ logger.debug(
1051
+ "Cluster config validation failed. The version of "
1052
+ "the ray CLI you launched this cluster with may "
1053
+ "be higher than the version of ray being run on "
1054
+ "the cluster. Some new features may not be "
1055
+ "available until you upgrade ray on your cluster.",
1056
+ exc_info=e,
1057
+ )
1058
+ logger.debug(
1059
+ f"New config after validation: {new_config},"
1060
+ f" of type: {type(new_config)}"
1061
+ )
1062
+ (new_runtime_hash, new_file_mounts_contents_hash) = hash_runtime_conf(
1063
+ new_config["file_mounts"],
1064
+ new_config["cluster_synced_files"],
1065
+ [
1066
+ new_config["worker_setup_commands"],
1067
+ new_config["worker_start_ray_commands"],
1068
+ ],
1069
+ generate_file_mounts_contents_hash=sync_continuously,
1070
+ )
1071
+ self.config = new_config
1072
+ self.runtime_hash = new_runtime_hash
1073
+ self.file_mounts_contents_hash = new_file_mounts_contents_hash
1074
+ if not self.provider:
1075
+ self.provider = _get_node_provider(
1076
+ self.config["provider"], self.config["cluster_name"]
1077
+ )
1078
+
1079
+ # If using the LocalNodeProvider, make sure the head node is marked
1080
+ # non-terminated.
1081
+ if isinstance(self.provider, LocalNodeProvider):
1082
+ record_local_head_state_if_needed(self.provider)
1083
+
1084
+ self.available_node_types = self.config["available_node_types"]
1085
+ upscaling_speed = self.config.get("upscaling_speed")
1086
+ aggressive = self.config.get("autoscaling_mode") == "aggressive"
1087
+ target_utilization_fraction = self.config.get("target_utilization_fraction")
1088
+ if upscaling_speed:
1089
+ upscaling_speed = float(upscaling_speed)
1090
+ # TODO(ameer): consider adding (if users ask) an option of
1091
+ # initial_upscaling_num_workers.
1092
+ elif aggressive:
1093
+ upscaling_speed = 99999
1094
+ logger.warning(
1095
+ "Legacy aggressive autoscaling mode "
1096
+ "detected. Replacing it by setting upscaling_speed to "
1097
+ "99999."
1098
+ )
1099
+ elif target_utilization_fraction:
1100
+ upscaling_speed = 1 / max(target_utilization_fraction, 0.001) - 1
1101
+ logger.warning(
1102
+ "Legacy target_utilization_fraction config "
1103
+ "detected. Replacing it by setting upscaling_speed to "
1104
+ + "1 / target_utilization_fraction - 1."
1105
+ )
1106
+ else:
1107
+ upscaling_speed = 1.0
1108
+ if self.resource_demand_scheduler:
1109
+ # The node types are autofilled internally for legacy yamls,
1110
+ # overwriting the class will remove the inferred node resources
1111
+ # for legacy yamls.
1112
+ self.resource_demand_scheduler.reset_config(
1113
+ self.provider,
1114
+ self.available_node_types,
1115
+ self.config["max_workers"],
1116
+ self.config["head_node_type"],
1117
+ upscaling_speed,
1118
+ )
1119
+ else:
1120
+ self.resource_demand_scheduler = ResourceDemandScheduler(
1121
+ self.provider,
1122
+ self.available_node_types,
1123
+ self.config["max_workers"],
1124
+ self.config["head_node_type"],
1125
+ upscaling_speed,
1126
+ )
1127
+
1128
+ except Exception as e:
1129
+ self.prom_metrics.reset_exceptions.inc()
1130
+ if errors_fatal:
1131
+ raise e
1132
+ else:
1133
+ logger.exception("StandardAutoscaler: Error parsing config.")
1134
+
1135
+ def launch_config_ok(self, node_id):
1136
+ if self.disable_launch_config_check:
1137
+ return True
1138
+ node_tags = self.provider.node_tags(node_id)
1139
+ tag_launch_conf = node_tags.get(TAG_RAY_LAUNCH_CONFIG)
1140
+ node_type = node_tags.get(TAG_RAY_USER_NODE_TYPE)
1141
+ if node_type not in self.available_node_types:
1142
+ # The node type has been deleted from the cluster config.
1143
+ # Don't keep the node.
1144
+ return False
1145
+
1146
+ # The `worker_nodes` field is deprecated in favor of per-node-type
1147
+ # node_configs. We allow it for backwards-compatibility.
1148
+ launch_config = copy.deepcopy(self.config.get("worker_nodes", {}))
1149
+ if node_type:
1150
+ launch_config.update(
1151
+ self.config["available_node_types"][node_type]["node_config"]
1152
+ )
1153
+ calculated_launch_hash = hash_launch_conf(launch_config, self.config["auth"])
1154
+
1155
+ if calculated_launch_hash != tag_launch_conf:
1156
+ return False
1157
+ return True
1158
+
1159
+ def files_up_to_date(self, node_id):
1160
+ node_tags = self.provider.node_tags(node_id)
1161
+ applied_config_hash = node_tags.get(TAG_RAY_RUNTIME_CONFIG)
1162
+ applied_file_mounts_contents_hash = node_tags.get(TAG_RAY_FILE_MOUNTS_CONTENTS)
1163
+ if applied_config_hash != self.runtime_hash or (
1164
+ self.file_mounts_contents_hash is not None
1165
+ and self.file_mounts_contents_hash != applied_file_mounts_contents_hash
1166
+ ):
1167
+ logger.info(
1168
+ "StandardAutoscaler: "
1169
+ "{}: Runtime state is ({},{}), want ({},{})".format(
1170
+ node_id,
1171
+ applied_config_hash,
1172
+ applied_file_mounts_contents_hash,
1173
+ self.runtime_hash,
1174
+ self.file_mounts_contents_hash,
1175
+ )
1176
+ )
1177
+ return False
1178
+ return True
1179
+
1180
+ def heartbeat_on_time(self, node_id: NodeID, now: float) -> bool:
1181
+ """Determine whether we've received a heartbeat from a node within the
1182
+ last AUTOSCALER_HEARTBEAT_TIMEOUT_S seconds.
1183
+ """
1184
+ # For type checking, assert that this object has been instantitiated.
1185
+ assert self.provider
1186
+
1187
+ key = self.provider.internal_ip(node_id)
1188
+
1189
+ if key in self.load_metrics.last_heartbeat_time_by_ip:
1190
+ last_heartbeat_time = self.load_metrics.last_heartbeat_time_by_ip[key]
1191
+ delta = now - last_heartbeat_time
1192
+ if delta < AUTOSCALER_HEARTBEAT_TIMEOUT_S:
1193
+ return True
1194
+ return False
1195
+
1196
+ def terminate_unhealthy_nodes(self, now: float):
1197
+ """Terminated nodes for which we haven't received a heartbeat on time.
1198
+ These nodes are subsequently terminated.
1199
+ """
1200
+ # For type checking, assert that these objects have been instantitiated.
1201
+ assert self.provider
1202
+ assert self.non_terminated_nodes
1203
+
1204
+ for node_id in self.non_terminated_nodes.worker_ids:
1205
+ node_status = self.provider.node_tags(node_id)[TAG_RAY_NODE_STATUS]
1206
+ # We're not responsible for taking down
1207
+ # nodes with pending or failed status:
1208
+ if not node_status == STATUS_UP_TO_DATE:
1209
+ continue
1210
+ # This node is up-to-date. If it hasn't had the chance to produce
1211
+ # a heartbeat, fake the heartbeat now (see logic for completed node
1212
+ # updaters).
1213
+ ip = self.provider.internal_ip(node_id)
1214
+ if ip not in self.load_metrics.last_heartbeat_time_by_ip:
1215
+ self.load_metrics.mark_active(ip)
1216
+ # Heartbeat indicates node is healthy:
1217
+ if self.heartbeat_on_time(node_id, now):
1218
+ continue
1219
+ self.schedule_node_termination(
1220
+ node_id, "lost contact with raylet", logger.warning
1221
+ )
1222
+ self.terminate_scheduled_nodes()
1223
+
1224
+ def attempt_to_recover_unhealthy_nodes(self, now):
1225
+ for node_id in self.non_terminated_nodes.worker_ids:
1226
+ self.recover_if_needed(node_id, now)
1227
+
1228
+ def recover_if_needed(self, node_id, now):
1229
+ if not self.can_update(node_id):
1230
+ return
1231
+ if self.heartbeat_on_time(node_id, now):
1232
+ return
1233
+
1234
+ logger.warning(
1235
+ "StandardAutoscaler: "
1236
+ "{}: No recent heartbeat, "
1237
+ "restarting Ray to recover...".format(node_id)
1238
+ )
1239
+ self.event_summarizer.add(
1240
+ "Restarting {} nodes of type "
1241
+ + self._get_node_type(node_id)
1242
+ + " (lost contact with raylet).",
1243
+ quantity=1,
1244
+ aggregate=operator.add,
1245
+ )
1246
+ head_node_ip = self.provider.internal_ip(self.non_terminated_nodes.head_id)
1247
+ updater = NodeUpdaterThread(
1248
+ node_id=node_id,
1249
+ provider_config=self.config["provider"],
1250
+ provider=self.provider,
1251
+ auth_config=self.config["auth"],
1252
+ cluster_name=self.config["cluster_name"],
1253
+ file_mounts={},
1254
+ initialization_commands=[],
1255
+ setup_commands=[],
1256
+ ray_start_commands=with_head_node_ip(
1257
+ self.config["worker_start_ray_commands"], head_node_ip
1258
+ ),
1259
+ runtime_hash=self.runtime_hash,
1260
+ file_mounts_contents_hash=self.file_mounts_contents_hash,
1261
+ process_runner=self.process_runner,
1262
+ use_internal_ip=True,
1263
+ is_head_node=False,
1264
+ docker_config=self._get_node_specific_docker_config(node_id),
1265
+ node_resources=self._node_resources(node_id),
1266
+ node_labels=self._node_labels(node_id),
1267
+ for_recovery=True,
1268
+ )
1269
+ updater.start()
1270
+ self.updaters[node_id] = updater
1271
+
1272
+ def _get_node_type(self, node_id: str) -> str:
1273
+ # For type checking, assert that this object has been instantitiated.
1274
+ assert self.provider
1275
+
1276
+ node_tags = self.provider.node_tags(node_id)
1277
+ if TAG_RAY_USER_NODE_TYPE in node_tags:
1278
+ return node_tags[TAG_RAY_USER_NODE_TYPE]
1279
+ else:
1280
+ return "unknown_node_type"
1281
+
1282
+ def _get_node_type_specific_fields(self, node_id: str, fields_key: str) -> Any:
1283
+ # For type checking, assert that this object has been instantitiated.
1284
+ assert self.provider
1285
+
1286
+ fields = self.config[fields_key]
1287
+ node_tags = self.provider.node_tags(node_id)
1288
+ if TAG_RAY_USER_NODE_TYPE in node_tags:
1289
+ node_type = node_tags[TAG_RAY_USER_NODE_TYPE]
1290
+ if node_type not in self.available_node_types:
1291
+ raise ValueError(f"Unknown node type tag: {node_type}.")
1292
+ node_specific_config = self.available_node_types[node_type]
1293
+ if fields_key in node_specific_config:
1294
+ fields = node_specific_config[fields_key]
1295
+ return fields
1296
+
1297
+ def _get_node_specific_docker_config(self, node_id):
1298
+ if "docker" not in self.config:
1299
+ return {}
1300
+ docker_config = copy.deepcopy(self.config.get("docker", {}))
1301
+ node_specific_docker = self._get_node_type_specific_fields(node_id, "docker")
1302
+ docker_config.update(node_specific_docker)
1303
+ return docker_config
1304
+
1305
+ def should_update(self, node_id):
1306
+ if not self.can_update(node_id):
1307
+ return UpdateInstructions(None, None, None, None) # no update
1308
+
1309
+ status = self.provider.node_tags(node_id).get(TAG_RAY_NODE_STATUS)
1310
+ if status == STATUS_UP_TO_DATE and self.files_up_to_date(node_id):
1311
+ return UpdateInstructions(None, None, None, None) # no update
1312
+
1313
+ successful_updated = self.num_successful_updates.get(node_id, 0) > 0
1314
+ if successful_updated and self.config.get("restart_only", False):
1315
+ setup_commands = []
1316
+ ray_start_commands = self.config["worker_start_ray_commands"]
1317
+ elif successful_updated and self.config.get("no_restart", False):
1318
+ setup_commands = self._get_node_type_specific_fields(
1319
+ node_id, "worker_setup_commands"
1320
+ )
1321
+ ray_start_commands = []
1322
+ else:
1323
+ setup_commands = self._get_node_type_specific_fields(
1324
+ node_id, "worker_setup_commands"
1325
+ )
1326
+ ray_start_commands = self.config["worker_start_ray_commands"]
1327
+
1328
+ docker_config = self._get_node_specific_docker_config(node_id)
1329
+ return UpdateInstructions(
1330
+ node_id=node_id,
1331
+ setup_commands=setup_commands,
1332
+ ray_start_commands=ray_start_commands,
1333
+ docker_config=docker_config,
1334
+ )
1335
+
1336
+ def spawn_updater(
1337
+ self,
1338
+ node_id,
1339
+ setup_commands,
1340
+ ray_start_commands,
1341
+ node_resources,
1342
+ node_labels,
1343
+ docker_config,
1344
+ ):
1345
+ logger.info(
1346
+ f"Creating new (spawn_updater) updater thread for node" f" {node_id}."
1347
+ )
1348
+ ip = self.provider.internal_ip(node_id)
1349
+ node_type = self._get_node_type(node_id)
1350
+ self.node_tracker.track(node_id, ip, node_type)
1351
+ head_node_ip = self.provider.internal_ip(self.non_terminated_nodes.head_id)
1352
+ updater = NodeUpdaterThread(
1353
+ node_id=node_id,
1354
+ provider_config=self.config["provider"],
1355
+ provider=self.provider,
1356
+ auth_config=self.config["auth"],
1357
+ cluster_name=self.config["cluster_name"],
1358
+ file_mounts=self.config["file_mounts"],
1359
+ initialization_commands=with_head_node_ip(
1360
+ self._get_node_type_specific_fields(node_id, "initialization_commands"),
1361
+ head_node_ip,
1362
+ ),
1363
+ setup_commands=with_head_node_ip(setup_commands, head_node_ip),
1364
+ ray_start_commands=with_head_node_ip(ray_start_commands, head_node_ip),
1365
+ runtime_hash=self.runtime_hash,
1366
+ file_mounts_contents_hash=self.file_mounts_contents_hash,
1367
+ is_head_node=False,
1368
+ cluster_synced_files=self.config["cluster_synced_files"],
1369
+ rsync_options={
1370
+ "rsync_exclude": self.config.get("rsync_exclude"),
1371
+ "rsync_filter": self.config.get("rsync_filter"),
1372
+ },
1373
+ process_runner=self.process_runner,
1374
+ use_internal_ip=True,
1375
+ docker_config=docker_config,
1376
+ node_resources=node_resources,
1377
+ node_labels=node_labels,
1378
+ )
1379
+ updater.start()
1380
+ self.updaters[node_id] = updater
1381
+
1382
+ def can_update(self, node_id):
1383
+ if self.disable_node_updaters:
1384
+ return False
1385
+ if node_id in self.updaters:
1386
+ return False
1387
+ if not self.launch_config_ok(node_id):
1388
+ return False
1389
+ if self.num_failed_updates.get(node_id, 0) > 0: # TODO(ekl) retry?
1390
+ return False
1391
+ logger.debug(
1392
+ f"{node_id} is not being updated and "
1393
+ "passes config check (can_update=True)."
1394
+ )
1395
+ return True
1396
+
1397
+ def launch_new_node(self, count: int, node_type: str) -> None:
1398
+ logger.info("StandardAutoscaler: Queue {} new nodes for launch".format(count))
1399
+ self.pending_launches.inc(node_type, count)
1400
+ config = copy.deepcopy(self.config)
1401
+ if self.foreground_node_launch:
1402
+ assert self.foreground_node_launcher is not None
1403
+ # Launch in the main thread and block.
1404
+ self.foreground_node_launcher.launch_node(config, count, node_type)
1405
+ else:
1406
+ assert self.launch_queue is not None
1407
+ # Split into individual launch requests of the max batch size.
1408
+ while count > 0:
1409
+ # Enqueue launch data for the background NodeUpdater threads.
1410
+ self.launch_queue.put(
1411
+ (config, min(count, self.max_launch_batch), node_type)
1412
+ )
1413
+ count -= self.max_launch_batch
1414
+
1415
+ def kill_workers(self):
1416
+ logger.error("StandardAutoscaler: kill_workers triggered")
1417
+ nodes = self.workers()
1418
+ if nodes:
1419
+ self.provider.terminate_nodes(nodes)
1420
+ for node in nodes:
1421
+ self.node_tracker.untrack(node)
1422
+ self.prom_metrics.stopped_nodes.inc()
1423
+ logger.error("StandardAutoscaler: terminated {} node(s)".format(len(nodes)))
1424
+
1425
+ def summary(self) -> Optional[AutoscalerSummary]:
1426
+ """Summarizes the active, pending, and failed node launches.
1427
+
1428
+ An active node is a node whose raylet is actively reporting heartbeats.
1429
+ A pending node is non-active node whose node tag is uninitialized,
1430
+ waiting for ssh, syncing files, or setting up.
1431
+ If a node is not pending or active, it is failed.
1432
+
1433
+ Returns:
1434
+ AutoscalerSummary: The summary.
1435
+ """
1436
+ # For type checking, assert that this object has been instantitiated.
1437
+ assert self.provider
1438
+
1439
+ if not self.non_terminated_nodes:
1440
+ return None
1441
+ active_nodes: Dict[NodeType, int] = Counter()
1442
+ pending_nodes = []
1443
+ failed_nodes = []
1444
+ non_failed = set()
1445
+
1446
+ node_type_mapping = {}
1447
+ now = time.time()
1448
+ for node_id in self.non_terminated_nodes.all_node_ids:
1449
+ ip = self.provider.internal_ip(node_id)
1450
+ node_tags = self.provider.node_tags(node_id)
1451
+
1452
+ if not all(
1453
+ tag in node_tags
1454
+ for tag in (
1455
+ TAG_RAY_NODE_KIND,
1456
+ TAG_RAY_USER_NODE_TYPE,
1457
+ TAG_RAY_NODE_STATUS,
1458
+ )
1459
+ ):
1460
+ # In some node providers, creation of a node and tags is not
1461
+ # atomic, so just skip it.
1462
+ continue
1463
+
1464
+ if node_tags[TAG_RAY_NODE_KIND] == NODE_KIND_UNMANAGED:
1465
+ continue
1466
+ node_type = node_tags[TAG_RAY_USER_NODE_TYPE]
1467
+
1468
+ node_type_mapping[ip] = node_type
1469
+
1470
+ is_active = self.heartbeat_on_time(node_id, now)
1471
+ if is_active:
1472
+ active_nodes[node_type] += 1
1473
+ non_failed.add(node_id)
1474
+ else:
1475
+ status = node_tags[TAG_RAY_NODE_STATUS]
1476
+ completed_states = [STATUS_UP_TO_DATE, STATUS_UPDATE_FAILED]
1477
+ is_pending = status not in completed_states
1478
+ if is_pending:
1479
+ pending_nodes.append((node_id, ip, node_type, status))
1480
+ non_failed.add(node_id)
1481
+
1482
+ failed_nodes = self.node_tracker.get_all_failed_node_info(non_failed)
1483
+
1484
+ # The concurrent counter leaves some 0 counts in, so we need to
1485
+ # manually filter those out.
1486
+ pending_launches = {}
1487
+ for node_type, count in self.pending_launches.breakdown().items():
1488
+ if count:
1489
+ pending_launches[node_type] = count
1490
+
1491
+ pending_resources = {}
1492
+ for node_resources in self.resource_demand_scheduler.calculate_node_resources(
1493
+ nodes=[node_id for node_id, _, _, _ in pending_nodes],
1494
+ pending_nodes=pending_launches,
1495
+ # We don't fill this field out because we're intentionally only
1496
+ # passing pending nodes (which aren't tracked by load metrics
1497
+ # anyways).
1498
+ unused_resources_by_ip={},
1499
+ )[0]:
1500
+ for key, value in node_resources.items():
1501
+ pending_resources[key] = value + pending_resources.get(key, 0)
1502
+
1503
+ return AutoscalerSummary(
1504
+ # Convert active_nodes from counter to dict for later serialization
1505
+ active_nodes=dict(active_nodes),
1506
+ idle_nodes=None,
1507
+ pending_nodes=[
1508
+ (ip, node_type, status) for _, ip, node_type, status in pending_nodes
1509
+ ],
1510
+ pending_launches=pending_launches,
1511
+ failed_nodes=failed_nodes,
1512
+ node_availability_summary=self.node_provider_availability_tracker.summary(),
1513
+ pending_resources=pending_resources,
1514
+ node_type_mapping=node_type_mapping,
1515
+ legacy=True,
1516
+ )
1517
+
1518
+ def info_string(self):
1519
+ lm_summary = self.load_metrics.summary()
1520
+ autoscaler_summary = self.summary()
1521
+ assert autoscaler_summary
1522
+ return "\n" + format_info_string(lm_summary, autoscaler_summary)
lib/python3.12/site-packages/ray/autoscaler/_private/aws/__init__.py ADDED
File without changes