chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,821 @@
import copy
import hashlib
import json
import logging
import os
import time
from enum import Enum
from typing import Any, Callable, Dict, List, Union
import botocore
from ray.autoscaler._private.aws.utils import client_cache, resource_cache
from ray.autoscaler.tags import NODE_KIND_HEAD, TAG_RAY_CLUSTER_NAME, TAG_RAY_NODE_KIND
logger = logging.getLogger(__name__)
RAY = "ray-autoscaler"
CLOUDWATCH_RAY_INSTANCE_PROFILE = RAY + "-cloudwatch-v1"
CLOUDWATCH_RAY_IAM_ROLE = RAY + "-cloudwatch-v1"
CLOUDWATCH_AGENT_INSTALLED_AMI_TAG = "T6Iq2faj"
CLOUDWATCH_AGENT_INSTALLED_TAG = "cloudwatch-agent-installed"
CLOUDWATCH_CONFIG_HASH_TAG_BASE = "cloudwatch-config-hash"
class CloudwatchConfigType(str, Enum):
AGENT = "agent"
DASHBOARD = "dashboard"
ALARM = "alarm"
class CloudwatchHelper:
def __init__(
self, provider_config: Dict[str, Any], node_id: str, cluster_name: str
) -> None:
self.node_id = node_id
self.cluster_name = cluster_name
self.provider_config = provider_config
region = provider_config["region"]
self.ec2_resource = resource_cache("ec2", region)
self.ec2_client = self.ec2_resource.meta.client
self.ssm_client = client_cache("ssm", region)
cloudwatch_resource = resource_cache("cloudwatch", region)
self.cloudwatch_client = cloudwatch_resource.meta.client
self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC: Dict[
str, Callable
] = {
CloudwatchConfigType.AGENT.value: self._replace_cwa_config_vars,
CloudwatchConfigType.DASHBOARD.value: self._replace_dashboard_config_vars,
CloudwatchConfigType.ALARM.value: self._load_config_file,
}
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_HEAD_NODE: Dict[str, Callable] = {
CloudwatchConfigType.AGENT.value: self._restart_cloudwatch_agent,
CloudwatchConfigType.DASHBOARD.value: self._put_cloudwatch_dashboard,
CloudwatchConfigType.ALARM.value: self._put_cloudwatch_alarm,
}
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_WORKER_NODE: Dict[str, Callable] = {
CloudwatchConfigType.AGENT.value: self._restart_cloudwatch_agent,
CloudwatchConfigType.ALARM.value: self._put_cloudwatch_alarm,
}
def update_from_config(self, is_head_node: bool) -> None:
"""Discovers and applies CloudWatch config updates as required.
Args:
is_head_node: whether this node is the head node.
"""
for config_type in CloudwatchConfigType:
if CloudwatchHelper.cloudwatch_config_exists(
self.provider_config, config_type.value
):
self._update_cloudwatch_config(config_type.value, is_head_node)
def _ec2_health_check_waiter(self, node_id: str) -> None:
# wait for all EC2 instance checks to complete
try:
logger.info(
"Waiting for EC2 instance health checks to complete before "
"configuring Unified Cloudwatch Agent. This may take a few "
"minutes..."
)
waiter = self.ec2_client.get_waiter("instance_status_ok")
waiter.wait(InstanceIds=[node_id])
except botocore.exceptions.WaiterError as e:
logger.error(
"Failed while waiting for EC2 instance checks to complete: {}".format(
e.message
)
)
raise e
def _update_cloudwatch_config(self, config_type: str, is_head_node: bool) -> None:
"""
check whether update operations are needed in
cloudwatch related configs
"""
cwa_installed = self._setup_cwa()
param_name = self._get_ssm_param_name(config_type)
if cwa_installed:
if is_head_node:
cw_config_ssm = self._set_cloudwatch_ssm_config_param(
param_name, config_type
)
cur_cw_config_hash = self._sha256_hash_file(config_type)
ssm_cw_config_hash = self._sha256_hash_json(cw_config_ssm)
# check if user updated cloudwatch related config files.
# if so, perform corresponding actions.
if cur_cw_config_hash != ssm_cw_config_hash:
logger.info(
"Cloudwatch {} config file has changed.".format(config_type)
)
self._upload_config_to_ssm_and_set_hash_tag(config_type)
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_HEAD_NODE.get(
config_type
)()
else:
head_node_hash = self._get_head_node_config_hash(config_type)
cur_node_hash = self._get_cur_node_config_hash(config_type)
if head_node_hash != cur_node_hash:
logger.info(
"Cloudwatch {} config file has changed.".format(config_type)
)
update_func = (
self.CLOUDWATCH_CONFIG_TYPE_TO_UPDATE_FUNC_WORKER_NODE.get(
config_type
)
)
if update_func:
update_func()
self._update_cloudwatch_hash_tag_value(
self.node_id, head_node_hash, config_type
)
def _put_cloudwatch_dashboard(self) -> Dict[str, Any]:
"""put dashboard to cloudwatch console"""
cloudwatch_config = self.provider_config["cloudwatch"]
dashboard_config = cloudwatch_config.get("dashboard", {})
dashboard_name_cluster = dashboard_config.get("name", self.cluster_name)
dashboard_name = self.cluster_name + "-" + dashboard_name_cluster
widgets = self._replace_dashboard_config_vars(
CloudwatchConfigType.DASHBOARD.value
)
response = self.cloudwatch_client.put_dashboard(
DashboardName=dashboard_name, DashboardBody=json.dumps({"widgets": widgets})
)
issue_count = len(response.get("DashboardValidationMessages", []))
if issue_count > 0:
for issue in response.get("DashboardValidationMessages"):
logging.error(
"Error in dashboard config: {} - {}".format(
issue["Message"], issue["DataPath"]
)
)
raise Exception(
"Errors in dashboard configuration: {} issues raised".format(
issue_count
)
)
else:
logger.info("Successfully put dashboard to CloudWatch console")
return response
def _put_cloudwatch_alarm(self) -> None:
"""put CloudWatch metric alarms read from config"""
param_name = self._get_ssm_param_name(CloudwatchConfigType.ALARM.value)
data = json.loads(self._get_ssm_param(param_name))
for item in data:
item_out = copy.deepcopy(item)
self._replace_all_config_variables(
item_out,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
self.cloudwatch_client.put_metric_alarm(**item_out)
logger.info("Successfully put alarms to CloudWatch console")
def _send_command_to_node(
self, document_name: str, parameters: Dict[str, List[str]], node_id: str
) -> Dict[str, Any]:
"""send SSM command to the given nodes"""
logger.debug(
"Sending SSM command to {} node(s). Document name: {}. "
"Parameters: {}.".format(node_id, document_name, parameters)
)
response = self.ssm_client.send_command(
InstanceIds=[node_id],
DocumentName=document_name,
Parameters=parameters,
MaxConcurrency="1",
MaxErrors="0",
)
return response
def _ssm_command_waiter(
self,
document_name: str,
parameters: Dict[str, List[str]],
node_id: str,
retry_failed: bool = True,
) -> Dict[str, Any]:
"""wait for SSM command to complete on all cluster nodes"""
# This waiter differs from the built-in SSM.Waiter by
# optimistically waiting for the command invocation to
# exist instead of failing immediately, and by resubmitting
# any failed command until all retry attempts are exhausted
# by default.
response = self._send_command_to_node(document_name, parameters, node_id)
command_id = response["Command"]["CommandId"]
cloudwatch_config = self.provider_config["cloudwatch"]
agent_retryer_config = cloudwatch_config.get(
CloudwatchConfigType.AGENT.value
).get("retryer", {})
max_attempts = agent_retryer_config.get("max_attempts", 120)
delay_seconds = agent_retryer_config.get("delay_seconds", 30)
num_attempts = 0
cmd_invocation_res = {}
while True:
num_attempts += 1
logger.debug(
"Listing SSM command ID {} invocations on node {}".format(
command_id, node_id
)
)
response = self.ssm_client.list_command_invocations(
CommandId=command_id,
InstanceId=node_id,
)
cmd_invocations = response["CommandInvocations"]
if not cmd_invocations:
logger.debug(
"SSM Command ID {} invocation does not exist. If "
"the command was just started, it may take a "
"few seconds to register.".format(command_id)
)
else:
if len(cmd_invocations) > 1:
logger.warning(
"Expected to find 1 SSM command invocation with "
"ID {} on node {} but found {}: {}".format(
command_id,
node_id,
len(cmd_invocations),
cmd_invocations,
)
)
cmd_invocation = cmd_invocations[0]
if cmd_invocation["Status"] == "Success":
logger.debug(
"SSM Command ID {} completed successfully.".format(command_id)
)
cmd_invocation_res[node_id] = True
break
if num_attempts >= max_attempts:
logger.error(
"Max attempts for command {} exceeded on node {}".format(
command_id, node_id
)
)
raise botocore.exceptions.WaiterError(
name="ssm_waiter",
reason="Max attempts exceeded",
last_response=cmd_invocation,
)
if cmd_invocation["Status"] == "Failed":
logger.debug(f"SSM Command ID {command_id} failed.")
if retry_failed:
logger.debug(f"Retrying in {delay_seconds} seconds.")
response = self._send_command_to_node(
document_name, parameters, node_id
)
command_id = response["Command"]["CommandId"]
logger.debug(
"Sent SSM command ID {} to node {}".format(
command_id, node_id
)
)
else:
logger.debug(f"Ignoring Command ID {command_id} failure.")
cmd_invocation_res[node_id] = False
break
time.sleep(delay_seconds)
return cmd_invocation_res
def _replace_config_variables(
self, string: str, node_id: str, cluster_name: str, region: str
) -> str:
"""
replace known config variable occurrences in the input string
does not replace variables with undefined or empty strings
"""
if node_id:
string = string.replace("{instance_id}", node_id)
if cluster_name:
string = string.replace("{cluster_name}", cluster_name)
if region:
string = string.replace("{region}", region)
return string
def _replace_all_config_variables(
self,
collection: Union[Dict[str, Any], str],
node_id: str,
cluster_name: str,
region: str,
) -> Union[str, Dict[str, Any]]:
"""
Replace known config variable occurrences in the input collection.
The input collection must be either a dict or list.
Returns a tuple consisting of the output collection and the number of
modified strings in the collection (which is not necessarily equal to
the number of variables replaced).
"""
for key in collection:
if type(collection) is dict:
value = collection.get(key)
index_key = key
elif type(collection) is list:
value = key
index_key = collection.index(key)
else:
raise ValueError(
f"Can't replace CloudWatch config variables "
f"in unsupported collection type: {type(collection)}."
f"Please check your CloudWatch JSON config files."
)
if type(value) is str:
collection[index_key] = self._replace_config_variables(
value, node_id, cluster_name, region
)
elif type(value) is dict or type(value) is list:
collection[index_key] = self._replace_all_config_variables(
value, node_id, cluster_name, region
)
return collection
def _load_config_file(self, config_type: str) -> Dict[str, Any]:
"""load JSON config file"""
cloudwatch_config = self.provider_config["cloudwatch"]
json_config_file_section = cloudwatch_config.get(config_type, {})
json_config_file_path = json_config_file_section.get("config", {})
json_config_path = os.path.abspath(json_config_file_path)
with open(json_config_path) as f:
data = json.load(f)
return data
def _set_cloudwatch_ssm_config_param(
self, parameter_name: str, config_type: str
) -> str:
"""
get cloudwatch config for the given param and config type from SSM
if it exists, put it in the SSM param store if not
"""
try:
parameter_value = self._get_ssm_param(parameter_name)
except botocore.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "ParameterNotFound":
logger.info(
"Cloudwatch {} config file is not found "
"at SSM parameter store. "
"Checking for Unified CloudWatch Agent installation".format(
config_type
)
)
return self._get_default_empty_config_file_hash()
else:
logger.info(
"Failed to fetch Unified CloudWatch Agent config from SSM "
"parameter store."
)
logger.error(e)
raise e
return parameter_value
def _get_default_empty_config_file_hash(self):
default_cw_config = "{}"
parameter_value = self._sha256_hash_json(default_cw_config)
return parameter_value
def _get_ssm_param(self, parameter_name: str) -> str:
"""
get the SSM parameter value associated with the given parameter name
"""
response = self.ssm_client.get_parameter(Name=parameter_name)
logger.info("Successfully fetch ssm parameter: {}".format(parameter_name))
res = response.get("Parameter", {})
cwa_parameter = res.get("Value", {})
return cwa_parameter
def _sha256_hash_json(self, value: str) -> str:
"""calculate the json string sha256 hash"""
sha256_hash = hashlib.new("sha256")
binary_value = value.encode("utf-8")
sha256_hash.update(binary_value)
sha256_res = sha256_hash.hexdigest()
return sha256_res
def _sha256_hash_file(self, config_type: str) -> str:
"""calculate the config file sha256 hash"""
config = self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC.get(
config_type
)(config_type)
value = json.dumps(config)
sha256_res = self._sha256_hash_json(value)
return sha256_res
def _upload_config_to_ssm_and_set_hash_tag(self, config_type: str):
data = self.CLOUDWATCH_CONFIG_TYPE_TO_CONFIG_VARIABLE_REPLACE_FUNC.get(
config_type
)(config_type)
sha256_hash_value = self._sha256_hash_file(config_type)
self._upload_config_to_ssm(data, config_type)
self._update_cloudwatch_hash_tag_value(
self.node_id, sha256_hash_value, config_type
)
def _add_cwa_installed_tag(self, node_id: str) -> None:
self.ec2_client.create_tags(
Resources=[node_id],
Tags=[{"Key": CLOUDWATCH_AGENT_INSTALLED_TAG, "Value": "True"}],
)
logger.info(
"Successfully add Unified CloudWatch Agent installed "
"tag on {}".format(node_id)
)
def _update_cloudwatch_hash_tag_value(
self, node_id: str, sha256_hash_value: str, config_type: str
):
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
self.ec2_client.create_tags(
Resources=[node_id],
Tags=[{"Key": hash_key_value, "Value": sha256_hash_value}],
)
logger.info(
"Successfully update cloudwatch {} hash tag on {}".format(
config_type, node_id
)
)
def _get_ssm_param_name(self, config_type: str) -> str:
"""return the parameter name for cloudwatch configs"""
ssm_config_param_name = "AmazonCloudWatch-" + "ray_{}_config_{}".format(
config_type, self.cluster_name
)
return ssm_config_param_name
def _put_ssm_param(self, parameter: Dict[str, Any], parameter_name: str) -> None:
"""upload cloudwatch config to the SSM parameter store"""
self.ssm_client.put_parameter(
Name=parameter_name,
Type="String",
Value=json.dumps(parameter),
Overwrite=True,
Tier="Intelligent-Tiering",
)
def _upload_config_to_ssm(self, param: Dict[str, Any], config_type: str):
param_name = self._get_ssm_param_name(config_type)
self._put_ssm_param(param, param_name)
def _replace_cwa_config_vars(self, config_type: str) -> Dict[str, Any]:
"""
replace {instance_id}, {region}, {cluster_name}
variable occurrences in Unified Cloudwatch Agent config file
"""
cwa_config = self._load_config_file(config_type)
self._replace_all_config_variables(
cwa_config,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
return cwa_config
def _replace_dashboard_config_vars(self, config_type: str) -> List[str]:
"""
replace known variable occurrences in CloudWatch Dashboard config file
"""
data = self._load_config_file(config_type)
widgets = []
for item in data:
item_out = self._replace_all_config_variables(
item,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
widgets.append(item_out)
return widgets
def _replace_alarm_config_vars(self, config_type: str) -> List[str]:
"""
replace {instance_id}, {region}, {cluster_name}
variable occurrences in cloudwatch alarm config file
"""
data = self._load_config_file(config_type)
param_data = []
for item in data:
item_out = copy.deepcopy(item)
self._replace_all_config_variables(
item_out,
self.node_id,
self.cluster_name,
self.provider_config["region"],
)
param_data.append(item_out)
return param_data
def _restart_cloudwatch_agent(self) -> None:
"""restart Unified CloudWatch Agent"""
cwa_param_name = self._get_ssm_param_name(CloudwatchConfigType.AGENT.value)
logger.info(
"Restarting Unified CloudWatch Agent package on node {}.".format(
self.node_id
)
)
self._stop_cloudwatch_agent()
self._start_cloudwatch_agent(cwa_param_name)
def _stop_cloudwatch_agent(self) -> None:
"""stop Unified CloudWatch Agent"""
logger.info(
"Stopping Unified CloudWatch Agent package on node {}.".format(self.node_id)
)
parameters_stop_cwa = {
"action": ["stop"],
"mode": ["ec2"],
}
# don't retry failed stop commands
# (there's not always an agent to stop)
self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent",
parameters_stop_cwa,
self.node_id,
False,
)
logger.info("Unified CloudWatch Agent stopped on node {}.".format(self.node_id))
def _start_cloudwatch_agent(self, cwa_param_name: str) -> None:
"""start Unified CloudWatch Agent"""
logger.info(
"Starting Unified CloudWatch Agent package on node {}.".format(self.node_id)
)
parameters_start_cwa = {
"action": ["configure"],
"mode": ["ec2"],
"optionalConfigurationSource": ["ssm"],
"optionalConfigurationLocation": [cwa_param_name],
"optionalRestart": ["yes"],
}
self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent", parameters_start_cwa, self.node_id
)
logger.info(
"Unified CloudWatch Agent started successfully on node {}.".format(
self.node_id
)
)
def _setup_cwa(self) -> bool:
cwa_installed = self._check_cwa_installed_ec2_tag()
if cwa_installed == "False":
res_cwa_installed = self._ensure_cwa_installed_ssm(self.node_id)
return res_cwa_installed
else:
return True
def _get_head_node_config_hash(self, config_type: str) -> str:
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
filters = copy.deepcopy(
self._get_current_cluster_session_nodes(self.cluster_name)
)
filters.append(
{
"Name": "tag:{}".format(TAG_RAY_NODE_KIND),
"Values": [NODE_KIND_HEAD],
}
)
try:
instance = list(self.ec2_resource.instances.filter(Filters=filters))
assert len(instance) == 1, "More than 1 head node found!"
for tag in instance[0].tags:
if tag["Key"] == hash_key_value:
return tag["Value"]
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting value of {} tag on head node".format(
e.response["Error"], hash_key_value
)
)
def _get_cur_node_config_hash(self, config_type: str) -> str:
hash_key_value = "-".join([CLOUDWATCH_CONFIG_HASH_TAG_BASE, config_type])
try:
response = self.ec2_client.describe_instances(InstanceIds=[self.node_id])
reservations = response["Reservations"]
message = "More than 1 response received from describing current node"
assert len(reservations) == 1, message
instances = reservations[0]["Instances"]
assert len(reservations) == 1, message
tags = instances[0]["Tags"]
hash_value = self._get_default_empty_config_file_hash()
for tag in tags:
if tag["Key"] == hash_key_value:
logger.info(
"Successfully get cloudwatch {} hash tag value from "
"node {}".format(config_type, self.node_id)
)
hash_value = tag["Value"]
return hash_value
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting hash tag {} tag".format(
e.response["Error"], hash_key_value
)
)
def _ensure_cwa_installed_ssm(self, node_id: str) -> bool:
"""
Check if Unified Cloudwatch Agent is installed via ssm run command.
If not, notify user to use an AMI with
the Unified CloudWatch Agent installed.
"""
logger.info(
"Checking Unified Cloudwatch Agent status on node {}".format(node_id)
)
parameters_status_cwa = {
"action": ["status"],
"mode": ["ec2"],
}
self._ec2_health_check_waiter(node_id)
cmd_invocation_res = self._ssm_command_waiter(
"AmazonCloudWatch-ManageAgent", parameters_status_cwa, node_id, False
)
cwa_installed = cmd_invocation_res.get(node_id, False)
if not cwa_installed:
logger.warning(
"Unified CloudWatch Agent not installed on {}. "
"Ray logs, metrics not picked up. "
"Please use an AMI with Unified CloudWatch Agent installed.".format(
node_id
)
)
return False
else:
return True
def _get_current_cluster_session_nodes(self, cluster_name: str) -> List[dict]:
filters = [
{
"Name": "instance-state-name",
"Values": ["pending", "running"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [cluster_name],
},
]
return filters
def _check_cwa_installed_ec2_tag(self) -> List[str]:
"""
Filtering all nodes to get nodes
without Unified CloudWatch Agent installed
"""
try:
response = self.ec2_client.describe_instances(InstanceIds=[self.node_id])
reservations = response["Reservations"]
message = "More than 1 response received from describing current node"
assert len(reservations) == 1, message
instances = reservations[0]["Instances"]
assert len(instances) == 1, message
tags = instances[0]["Tags"]
cwa_installed = str(False)
for tag in tags:
if tag["Key"] == CLOUDWATCH_AGENT_INSTALLED_TAG:
logger.info(
"Unified CloudWatch Agent is installed on "
"node {}".format(self.node_id)
)
cwa_installed = tag["Value"]
return cwa_installed
except botocore.exceptions.ClientError as e:
logger.warning(
"{} Error caught when getting Unified CloudWatch Agent "
"status based on {} tag".format(
e.response["Error"], CLOUDWATCH_AGENT_INSTALLED_TAG
)
)
@staticmethod
def resolve_instance_profile_name(
config: Dict[str, Any], default_instance_profile_name: str
) -> str:
"""Get default cloudwatch instance profile name.
Args:
config: provider section of cluster config file.
default_instance_profile_name: default ray instance profile name.
Returns:
default cloudwatch instance profile name if cloudwatch config file
exists.
default ray instance profile name if cloudwatch config file
doesn't exist.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
return (
CLOUDWATCH_RAY_INSTANCE_PROFILE
if cwa_cfg_exists
else default_instance_profile_name
)
@staticmethod
def resolve_iam_role_name(
config: Dict[str, Any], default_iam_role_name: str
) -> str:
"""Get default cloudwatch iam role name.
Args:
config: provider section of cluster config file.
default_iam_role_name: default ray iam role name.
Returns:
default cloudwatch iam role name if cloudwatch config file exists.
default ray iam role name if cloudwatch config file doesn't exist.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
return CLOUDWATCH_RAY_IAM_ROLE if cwa_cfg_exists else default_iam_role_name
@staticmethod
def resolve_policy_arns(
config: Dict[str, Any], iam: Any, default_policy_arns: List[str]
) -> List[str]:
"""Attach necessary AWS policies for CloudWatch related operations.
Args:
config: provider section of cluster config file.
iam: AWS iam resource.
default_policy_arns: List of default ray AWS policies.
Returns:
list of policy arns including additional policies for CloudWatch
related operations if cloudwatch agent config is specifed in
cluster config file.
"""
cwa_cfg_exists = CloudwatchHelper.cloudwatch_config_exists(
config, CloudwatchConfigType.AGENT.value
)
if cwa_cfg_exists:
cloudwatch_managed_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:SendCommand",
"ssm:ListCommandInvocations",
"iam:PassRole",
],
"Resource": "*",
}
],
}
iam_client = iam.meta.client
iam_client.create_policy(
PolicyName="CloudwatchManagedPolicies",
PolicyDocument=json.dumps(cloudwatch_managed_policy),
)
sts_client = client_cache("sts", config["region"])
account_id = sts_client.get_caller_identity().get("Account")
managed_policy_arn = (
"arn:aws:iam::{}:policy/CloudwatchManagedPolicies".format(account_id)
)
policy_waiter = iam_client.get_waiter("policy_exists")
policy_waiter.wait(
PolicyArn=managed_policy_arn,
WaiterConfig={"Delay": 2, "MaxAttempts": 200},
)
new_policy_arns = copy.copy(default_policy_arns)
new_policy_arns.extend(
[
"arn:aws:iam::aws:policy/CloudWatchAgentAdminPolicy",
"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
managed_policy_arn,
]
)
return new_policy_arns
else:
return default_policy_arns
@staticmethod
def cloudwatch_config_exists(config: Dict[str, Any], config_type: str) -> bool:
"""Check if CloudWatch configuration was specified by the user
in their cluster config file.
Specifically, this function checks if a CloudWatch config file is
specified by the user in their cluster config file.
Args:
config: provider section of cluster config file.
config_type: type of CloudWatch config file.
Returns:
True if config file is specified by user.
False if config file is not specified.
"""
cfg = config.get("cloudwatch", {}).get(config_type, {}).get("config")
return bool(cfg)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
import copy
import logging
import sys
import threading
import time
from collections import OrderedDict, defaultdict
from typing import Any, Dict, List
import botocore
from boto3.resources.base import ServiceResource
import ray
import ray._private.ray_constants as ray_constants
from ray.autoscaler._private.aws.cloudwatch.cloudwatch_helper import (
CLOUDWATCH_AGENT_INSTALLED_AMI_TAG,
CLOUDWATCH_AGENT_INSTALLED_TAG,
CloudwatchHelper,
)
from ray.autoscaler._private.aws.config import bootstrap_aws
from ray.autoscaler._private.aws.utils import (
boto_exception_handler,
client_cache,
resource_cache,
)
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.constants import BOTO_CREATE_MAX_RETRIES, BOTO_MAX_RETRIES
from ray.autoscaler._private.log_timer import LogTimer
from ray.autoscaler.node_launch_exception import NodeLaunchException
from ray.autoscaler.node_provider import NodeProvider
from ray.autoscaler.tags import (
TAG_RAY_CLUSTER_NAME,
TAG_RAY_LAUNCH_CONFIG,
TAG_RAY_NODE_KIND,
TAG_RAY_NODE_NAME,
TAG_RAY_USER_NODE_TYPE,
)
logger = logging.getLogger(__name__)
TAG_BATCH_DELAY = 1
LIST_RETRY_DELAY_SEC = 1
def to_aws_format(tags):
"""Convert the Ray node name tag to the AWS-specific 'Name' tag."""
if TAG_RAY_NODE_NAME in tags:
tags["Name"] = tags[TAG_RAY_NODE_NAME]
del tags[TAG_RAY_NODE_NAME]
return tags
def from_aws_format(tags):
"""Convert the AWS-specific 'Name' tag to the Ray node name tag."""
if "Name" in tags:
tags[TAG_RAY_NODE_NAME] = tags["Name"]
del tags["Name"]
return tags
def make_ec2_resource(region, max_retries, aws_credentials=None) -> ServiceResource:
"""Make client, retrying requests up to `max_retries`."""
aws_credentials = aws_credentials or {}
return resource_cache("ec2", region, max_retries, **aws_credentials)
def list_ec2_instances(
region: str, aws_credentials: Dict[str, Any] = None
) -> List[Dict[str, Any]]:
"""Get all instance-types/resources available in the user's AWS region.
Args:
region: the region of the AWS provider. e.g., "us-west-2".
aws_credentials: AWS credentials to use for the boto3 client.
Returns:
A list of instances. An example of one element in the list:
{'InstanceType': 'm5a.xlarge', 'ProcessorInfo':
{'SupportedArchitectures': ['x86_64'], 'SustainedClockSpeedInGhz':
2.5},'VCpuInfo': {'DefaultVCpus': 4, 'DefaultCores': 2,
'DefaultThreadsPerCore': 2, 'ValidCores': [2],
'ValidThreadsPerCore': [1, 2]}, 'MemoryInfo': {'SizeInMiB': 16384},
...}
"""
final_instance_types = []
aws_credentials = aws_credentials or {}
ec2 = client_cache("ec2", region, BOTO_MAX_RETRIES, **aws_credentials)
instance_types = ec2.describe_instance_types()
final_instance_types.extend(copy.deepcopy(instance_types["InstanceTypes"]))
while "NextToken" in instance_types:
instance_types = ec2.describe_instance_types(
NextToken=instance_types["NextToken"]
)
final_instance_types.extend(copy.deepcopy(instance_types["InstanceTypes"]))
return final_instance_types
class AWSNodeProvider(NodeProvider):
max_terminate_nodes = 1000
def __init__(self, provider_config, cluster_name):
NodeProvider.__init__(self, provider_config, cluster_name)
self.cache_stopped_nodes = provider_config.get("cache_stopped_nodes", True)
aws_credentials = provider_config.get("aws_credentials")
self.ec2 = make_ec2_resource(
region=provider_config["region"],
max_retries=BOTO_MAX_RETRIES,
aws_credentials=aws_credentials,
)
self.ec2_fail_fast = make_ec2_resource(
region=provider_config["region"],
max_retries=0,
aws_credentials=aws_credentials,
)
# Tags that we believe to actually be on EC2.
self.tag_cache = {}
# Tags that we will soon upload.
self.tag_cache_pending = defaultdict(dict)
# Number of threads waiting for a batched tag update.
self.batch_thread_count = 0
self.batch_update_done = threading.Event()
self.batch_update_done.set()
self.ready_for_new_batch = threading.Event()
self.ready_for_new_batch.set()
self.tag_cache_lock = threading.Lock()
self.count_lock = threading.Lock()
# Prevent concurrent create_node calls to get the same stopped/stopping node to reuse.
self._reuse_node_lock = threading.Lock()
# Cache of node objects from the last nodes() call. This avoids
# excessive DescribeInstances requests.
self.cached_nodes = {}
def non_terminated_nodes(self, tag_filters):
# Note that these filters are acceptable because they are set on
# node initialization, and so can never be sitting in the cache.
tag_filters = to_aws_format(tag_filters)
filters = [
{
"Name": "instance-state-name",
"Values": ["pending", "running"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [self.cluster_name],
},
]
for k, v in tag_filters.items():
filters.append(
{
"Name": "tag:{}".format(k),
"Values": [v],
}
)
with boto_exception_handler("Failed to fetch running instances from AWS."):
nodes = list(self.ec2.instances.filter(Filters=filters))
# Populate the tag cache with initial information if necessary
for node in nodes:
if node.id in self.tag_cache:
continue
self.tag_cache[node.id] = from_aws_format(
{x["Key"]: x["Value"] for x in node.tags}
)
self.cached_nodes = {node.id: node for node in nodes}
return [node.id for node in nodes]
def is_running(self, node_id):
node = self._get_cached_node(node_id)
return node.state["Name"] == "running"
def is_terminated(self, node_id):
node = self._get_cached_node(node_id)
state = node.state["Name"]
return state not in ["running", "pending"]
def node_tags(self, node_id):
with self.tag_cache_lock:
d1 = self.tag_cache[node_id]
d2 = self.tag_cache_pending.get(node_id, {})
return dict(d1, **d2)
def external_ip(self, node_id):
node = self._get_cached_node(node_id)
if node.public_ip_address is None:
node = self._get_node(node_id)
return node.public_ip_address
def internal_ip(self, node_id):
node = self._get_cached_node(node_id)
if node.private_ip_address is None:
node = self._get_node(node_id)
return node.private_ip_address
def set_node_tags(self, node_id, tags):
is_batching_thread = False
with self.tag_cache_lock:
if not self.tag_cache_pending:
is_batching_thread = True
# Wait for threads in the last batch to exit
self.ready_for_new_batch.wait()
self.ready_for_new_batch.clear()
self.batch_update_done.clear()
self.tag_cache_pending[node_id].update(tags)
if is_batching_thread:
time.sleep(TAG_BATCH_DELAY)
with self.tag_cache_lock:
self._update_node_tags()
self.batch_update_done.set()
with self.count_lock:
self.batch_thread_count += 1
self.batch_update_done.wait()
with self.count_lock:
self.batch_thread_count -= 1
if self.batch_thread_count == 0:
self.ready_for_new_batch.set()
def _update_node_tags(self):
batch_updates = defaultdict(list)
for node_id, tags in self.tag_cache_pending.items():
for x in tags.items():
batch_updates[x].append(node_id)
self.tag_cache[node_id].update(tags)
self.tag_cache_pending = defaultdict(dict)
self._create_tags(batch_updates)
def _create_tags(self, batch_updates):
for (k, v), node_ids in batch_updates.items():
m = "Set tag {}={} on {}".format(k, v, node_ids)
with LogTimer("AWSNodeProvider: {}".format(m)):
if k == TAG_RAY_NODE_NAME:
k = "Name"
self.ec2.meta.client.create_tags(
Resources=node_ids,
Tags=[{"Key": k, "Value": v}],
)
def create_node(self, node_config, tags, count) -> Dict[str, Any]:
"""Creates instances.
Returns dict mapping instance id to ec2.Instance object for the created
instances.
"""
# sort tags by key to support deterministic unit test stubbing
tags = OrderedDict(sorted(copy.deepcopy(tags).items()))
reused_nodes_dict = {}
# Try to reuse previously stopped nodes with compatible configs
if self.cache_stopped_nodes:
# TODO(ekl) this is breaking the abstraction boundary a little by
# peeking into the tag set.
filters = [
{
"Name": "instance-state-name",
"Values": ["stopped", "stopping"],
},
{
"Name": "tag:{}".format(TAG_RAY_CLUSTER_NAME),
"Values": [self.cluster_name],
},
{
"Name": "tag:{}".format(TAG_RAY_NODE_KIND),
"Values": [tags[TAG_RAY_NODE_KIND]],
},
{
"Name": "tag:{}".format(TAG_RAY_LAUNCH_CONFIG),
"Values": [tags[TAG_RAY_LAUNCH_CONFIG]],
},
]
# This tag may not always be present.
if TAG_RAY_USER_NODE_TYPE in tags:
filters.append(
{
"Name": "tag:{}".format(TAG_RAY_USER_NODE_TYPE),
"Values": [tags[TAG_RAY_USER_NODE_TYPE]],
}
)
with self._reuse_node_lock:
reuse_nodes = list(self.ec2.instances.filter(Filters=filters))[:count]
reuse_node_ids = [n.id for n in reuse_nodes]
reused_nodes_dict = {n.id: n for n in reuse_nodes}
if reuse_nodes:
cli_logger.print(
# todo: handle plural vs singular?
"Reusing nodes {}. "
"To disable reuse, set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration.",
cli_logger.render_list(reuse_node_ids),
)
# todo: timed?
with cli_logger.group("Stopping instances to reuse"):
for node in reuse_nodes:
self.tag_cache[node.id] = from_aws_format(
{x["Key"]: x["Value"] for x in node.tags}
)
if node.state["Name"] == "stopping":
cli_logger.print(
"Waiting for instance {} to stop", node.id
)
node.wait_until_stopped()
self.ec2.meta.client.start_instances(InstanceIds=reuse_node_ids)
for node_id in reuse_node_ids:
self.set_node_tags(node_id, tags)
count -= len(reuse_node_ids)
created_nodes_dict = {}
if count:
created_nodes_dict = self._create_node(node_config, tags, count)
all_created_nodes = reused_nodes_dict
all_created_nodes.update(created_nodes_dict)
return all_created_nodes
@staticmethod
def _merge_tag_specs(
tag_specs: List[Dict[str, Any]], user_tag_specs: List[Dict[str, Any]]
) -> None:
"""
Merges user-provided node config tag specifications into a base
list of node provider tag specifications. The base list of
node provider tag specs is modified in-place.
This allows users to add tags and override values of existing
tags with their own, and only applies to the resource type
"instance". All other resource types are appended to the list of
tag specs.
Args:
tag_specs: base node provider tag specs
user_tag_specs: user's node config tag specs
"""
for user_tag_spec in user_tag_specs:
if user_tag_spec["ResourceType"] == "instance":
for user_tag in user_tag_spec["Tags"]:
exists = False
for tag in tag_specs[0]["Tags"]:
if user_tag["Key"] == tag["Key"]:
exists = True
tag["Value"] = user_tag["Value"]
break
if not exists:
tag_specs[0]["Tags"] += [user_tag]
else:
tag_specs += [user_tag_spec]
def _create_node(self, node_config, tags, count):
created_nodes_dict = {}
tags = to_aws_format(tags)
conf = node_config.copy()
tag_pairs = [
{
"Key": TAG_RAY_CLUSTER_NAME,
"Value": self.cluster_name,
}
]
for k, v in tags.items():
tag_pairs.append(
{
"Key": k,
"Value": v,
}
)
if CloudwatchHelper.cloudwatch_config_exists(self.provider_config, "agent"):
cwa_installed = self._check_ami_cwa_installation(node_config)
if cwa_installed:
tag_pairs.extend(
[
{
"Key": CLOUDWATCH_AGENT_INSTALLED_TAG,
"Value": "True",
}
]
)
tag_specs = [
{
"ResourceType": "instance",
"Tags": tag_pairs,
}
]
user_tag_specs = conf.get("TagSpecifications", [])
AWSNodeProvider._merge_tag_specs(tag_specs, user_tag_specs)
# SubnetIds is not a real config key: we must resolve to a
# single SubnetId before invoking the AWS API.
subnet_ids = conf.pop("SubnetIds")
# update config with min/max node counts and tag specs
conf.update({"MinCount": 1, "MaxCount": count, "TagSpecifications": tag_specs})
# Try to always launch in the first listed subnet.
subnet_idx = 0
cli_logger_tags = {}
# NOTE: This ensures that we try ALL availability zones before
# throwing an error.
max_tries = max(BOTO_CREATE_MAX_RETRIES, len(subnet_ids))
for attempt in range(1, max_tries + 1):
try:
if "NetworkInterfaces" in conf:
net_ifs = conf["NetworkInterfaces"]
# remove security group IDs previously copied from network
# interfaces (create_instances call fails otherwise)
conf.pop("SecurityGroupIds", None)
cli_logger_tags["network_interfaces"] = str(net_ifs)
else:
subnet_id = subnet_ids[subnet_idx % len(subnet_ids)]
conf["SubnetId"] = subnet_id
cli_logger_tags["subnet_id"] = subnet_id
created = self.ec2_fail_fast.create_instances(**conf)
created_nodes_dict = {n.id: n for n in created}
# todo: timed?
# todo: handle plurality?
with cli_logger.group(
"Launched {} nodes", count, _tags=cli_logger_tags
):
for instance in created:
# NOTE(maximsmol): This is needed for mocking
# boto3 for tests. This is likely a bug in moto
# but AWS docs don't seem to say.
# You can patch moto/ec2/responses/instances.py
# to fix this (add <stateReason> to EC2_RUN_INSTANCES)
# The correct value is technically
# {"code": "0", "Message": "pending"}
state_reason = "pending"
if instance.state_reason:
state_reason = (
instance.state_reason["Message"] or state_reason
)
cli_logger.print(
"Launched instance {}",
instance.instance_id,
_tags=dict(
state=instance.state["Name"],
info=state_reason,
),
)
break
except botocore.exceptions.ClientError as exc:
# Launch failure may be due to instance type availability in
# the given AZ
subnet_idx += 1
if attempt == max_tries:
try:
exc = NodeLaunchException(
category=exc.response["Error"]["Code"],
description=exc.response["Error"]["Message"],
src_exc_info=sys.exc_info(),
)
except Exception:
# In theory, all ClientError's we expect to get should
# have these fields, but just in case we can't parse
# it, it's fine, just throw the original error.
logger.warning("Couldn't parse exception.", exc)
pass
cli_logger.abort(
"Failed to launch instances. Max attempts exceeded.",
exc=exc,
)
else:
cli_logger.warning(
"create_instances: Attempt failed with {}, retrying.", exc
)
return created_nodes_dict
def terminate_node(self, node_id):
node = self._get_cached_node(node_id)
if self.cache_stopped_nodes:
if node.spot_instance_request_id:
cli_logger.print(
"Terminating instance {} "
+ cf.dimmed("(cannot stop spot instances, only terminate)"),
node_id,
) # todo: show node name?
node.terminate()
else:
cli_logger.print(
"Stopping instance {} "
+ cf.dimmed(
"(to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
),
node_id,
) # todo: show node name?
node.stop()
else:
node.terminate()
# TODO (Alex): We are leaking the tag cache here. Naively, we would
# want to just remove the cache entry here, but terminating can be
# asyncrhonous or error, which would result in a use after free error.
# If this leak becomes bad, we can garbage collect the tag cache when
# the node cache is updated.
def _check_ami_cwa_installation(self, config):
response = self.ec2.meta.client.describe_images(ImageIds=[config["ImageId"]])
cwa_installed = False
images = response.get("Images")
if images:
assert len(images) == 1, (
f"Expected to find only 1 AMI with the given ID, "
f"but found {len(images)}."
)
image_name = images[0].get("Name", "")
if CLOUDWATCH_AGENT_INSTALLED_AMI_TAG in image_name:
cwa_installed = True
return cwa_installed
def terminate_nodes(self, node_ids):
if not node_ids:
return
terminate_instances_func = self.ec2.meta.client.terminate_instances
stop_instances_func = self.ec2.meta.client.stop_instances
# In some cases, this function stops some nodes, but terminates others.
# Each of these requires a different EC2 API call. So, we use the
# "nodes_to_terminate" dict below to keep track of exactly which API
# call will be used to stop/terminate which set of nodes. The key is
# the function to use, and the value is the list of nodes to terminate
# with that function.
nodes_to_terminate = {terminate_instances_func: [], stop_instances_func: []}
if self.cache_stopped_nodes:
spot_ids = []
on_demand_ids = []
for node_id in node_ids:
if self._get_cached_node(node_id).spot_instance_request_id:
spot_ids += [node_id]
else:
on_demand_ids += [node_id]
if on_demand_ids:
# todo: show node names?
cli_logger.print(
"Stopping instances {} "
+ cf.dimmed(
"(to terminate instead, "
"set `cache_stopped_nodes: False` "
"under `provider` in the cluster configuration)"
),
cli_logger.render_list(on_demand_ids),
)
if spot_ids:
cli_logger.print(
"Terminating instances {} "
+ cf.dimmed("(cannot stop spot instances, only terminate)"),
cli_logger.render_list(spot_ids),
)
nodes_to_terminate[stop_instances_func] = on_demand_ids
nodes_to_terminate[terminate_instances_func] = spot_ids
else:
nodes_to_terminate[terminate_instances_func] = node_ids
max_terminate_nodes = (
self.max_terminate_nodes
if self.max_terminate_nodes is not None
else len(node_ids)
)
for terminate_func, nodes in nodes_to_terminate.items():
for start in range(0, len(nodes), max_terminate_nodes):
terminate_func(InstanceIds=nodes[start : start + max_terminate_nodes])
def _get_node(self, node_id):
"""Refresh and get info for this node, updating the cache."""
self.non_terminated_nodes({}) # Side effect: updates cache
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
# Node not in {pending, running} -- retry with a point query. This
# usually means the node was recently preempted or terminated.
# The EC2 API is eventually consistent. This means that an instance
# might not be immediately visible. So we need to retry the query a few times.
# See: https://docs.aws.amazon.com/ec2/latest/devguide/eventual-consistency.html
# and https://github.com/ray-project/ray/issues/51861
for attempts in range(max(BOTO_MAX_RETRIES, 1)): # at least try once.
matches = list(self.ec2.instances.filter(InstanceIds=[node_id]))
if len(matches) == 1:
return matches[0]
cli_logger.warning(
"Attempt to fetch EC2 instances that have instance ID {}. Got {} matching EC2 instances. Will retry after {} second. This is retry number {}, and the maximum number of retries is {}.",
node_id,
len(matches),
LIST_RETRY_DELAY_SEC,
attempts + 1,
BOTO_MAX_RETRIES,
)
time.sleep(LIST_RETRY_DELAY_SEC)
raise AssertionError("Invalid instance id {}".format(node_id))
def _get_cached_node(self, node_id):
"""Return node info from cache if possible, otherwise fetches it."""
if node_id in self.cached_nodes:
return self.cached_nodes[node_id]
return self._get_node(node_id)
@staticmethod
def bootstrap_config(cluster_config):
return bootstrap_aws(cluster_config)
@staticmethod
def fillout_available_node_types_resources(
cluster_config: Dict[str, Any]
) -> Dict[str, Any]:
"""Fills out missing "resources" field for available_node_types."""
if "available_node_types" not in cluster_config:
return cluster_config
cluster_config = copy.deepcopy(cluster_config)
instances_list = list_ec2_instances(
cluster_config["provider"]["region"],
cluster_config["provider"].get("aws_credentials"),
)
instances_dict = {
instance["InstanceType"]: instance for instance in instances_list
}
available_node_types = cluster_config["available_node_types"]
head_node_type = cluster_config["head_node_type"]
for node_type in available_node_types:
instance_type = available_node_types[node_type]["node_config"][
"InstanceType"
]
if instance_type in instances_dict:
cpus = instances_dict[instance_type]["VCpuInfo"]["DefaultVCpus"]
autodetected_resources = {"CPU": cpus}
if node_type != head_node_type:
# we only autodetect worker node type memory resource
memory_total = instances_dict[instance_type]["MemoryInfo"][
"SizeInMiB"
]
memory_total = int(memory_total) * 1024 * 1024
prop = 1 - ray_constants.DEFAULT_OBJECT_STORE_MEMORY_PROPORTION
memory_resources = int(memory_total * prop)
autodetected_resources["memory"] = memory_resources
for (
accelerator_manager
) in ray._private.accelerators.get_all_accelerator_managers():
num_accelerators = (
accelerator_manager.get_ec2_instance_num_accelerators(
instance_type, instances_dict
)
)
accelerator_type = (
accelerator_manager.get_ec2_instance_accelerator_type(
instance_type, instances_dict
)
)
if num_accelerators:
autodetected_resources[
accelerator_manager.get_resource_name()
] = num_accelerators
if accelerator_type:
autodetected_resources[
f"accelerator_type:{accelerator_type}"
] = 1
autodetected_resources.update(
available_node_types[node_type].get("resources", {})
)
if autodetected_resources != available_node_types[node_type].get(
"resources", {}
):
available_node_types[node_type][
"resources"
] = autodetected_resources
logger.debug(
"Updating the resources of {} to {}.".format(
node_type, autodetected_resources
)
)
else:
raise ValueError(
"Instance type "
+ instance_type
+ " is not available in AWS region: "
+ cluster_config["provider"]["region"]
+ "."
)
return cluster_config
+181
View File
@@ -0,0 +1,181 @@
from collections import defaultdict
from functools import lru_cache
import boto3
from boto3.exceptions import ResourceNotExistsError
from boto3.resources.base import ServiceResource
from botocore.client import BaseClient
from botocore.config import Config
from ray.autoscaler._private.cli_logger import cf, cli_logger
from ray.autoscaler._private.constants import BOTO_MAX_RETRIES
class LazyDefaultDict(defaultdict):
"""
LazyDefaultDict(default_factory[, ...]) --> dict with default factory
The default factory is call with the key argument to produce
a new value when a key is not present, in __getitem__ only.
A LazyDefaultDict compares equal to a dict with the same items.
All remaining arguments are treated the same as if they were
passed to the dict constructor, including keyword arguments.
"""
def __missing__(self, key):
"""
__missing__(key) # Called by __getitem__ for missing key; pseudo-code:
if self.default_factory is None: raise KeyError((key,))
self[key] = value = self.default_factory(key)
return value
"""
self[key] = self.default_factory(key)
return self[key]
def handle_boto_error(exc, msg, *args, **kwargs):
error_code = None
error_info = None
# todo: not sure if these exceptions always have response
if hasattr(exc, "response"):
error_info = exc.response.get("Error", None)
if error_info is not None:
error_code = error_info.get("Code", None)
generic_message_args = [
"{}\nError code: {}",
msg.format(*args, **kwargs),
cf.bold(error_code),
]
# apparently
# ExpiredTokenException
# ExpiredToken
# RequestExpired
# are all the same pretty much
credentials_expiration_codes = [
"ExpiredTokenException",
"ExpiredToken",
"RequestExpired",
]
if error_code in credentials_expiration_codes:
# "An error occurred (ExpiredToken) when calling the
# GetInstanceProfile operation: The security token
# included in the request is expired"
# "An error occurred (RequestExpired) when calling the
# DescribeKeyPairs operation: Request has expired."
token_command = (
"aws sts get-session-token "
"--serial-number arn:aws:iam::"
+ cf.underlined("ROOT_ACCOUNT_ID")
+ ":mfa/"
+ cf.underlined("AWS_USERNAME")
+ " --token-code "
+ cf.underlined("TWO_FACTOR_AUTH_CODE")
)
secret_key_var = (
"export AWS_SECRET_ACCESS_KEY = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.SecretAccessKey"
)
session_token_var = (
"export AWS_SESSION_TOKEN = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.SessionToken"
)
access_key_id_var = (
"export AWS_ACCESS_KEY_ID = "
+ cf.underlined("REPLACE_ME")
+ " # found at Credentials.AccessKeyId"
)
# fixme: replace with a Github URL that points
# to our repo
aws_session_script_url = (
"https://gist.github.com/maximsmol/a0284e1d97b25d417bd9ae02e5f450cf"
)
cli_logger.verbose_error(*generic_message_args)
cli_logger.verbose(vars(exc))
cli_logger.panic("Your AWS session has expired.")
cli_logger.newline()
cli_logger.panic("You can request a new one using")
cli_logger.panic(cf.bold(token_command))
cli_logger.panic("then expose it to Ray by setting")
cli_logger.panic(cf.bold(secret_key_var))
cli_logger.panic(cf.bold(session_token_var))
cli_logger.panic(cf.bold(access_key_id_var))
cli_logger.newline()
cli_logger.panic("You can find a script that automates this at:")
cli_logger.panic(cf.underlined(aws_session_script_url))
# Do not re-raise the exception here because it looks awful
# and we already print all the info in verbose
cli_logger.abort()
# todo: any other errors that we should catch separately?
cli_logger.panic(*generic_message_args)
cli_logger.newline()
with cli_logger.verbatim_error_ctx("Boto3 error:"):
cli_logger.verbose("{}", str(vars(exc)))
cli_logger.panic("{}", str(exc))
cli_logger.abort()
def boto_exception_handler(msg, *args, **kwargs):
# todo: implement timer
class ExceptionHandlerContextManager:
def __enter__(self):
pass
def __exit__(self, type, value, tb):
import botocore
if type is botocore.exceptions.ClientError:
handle_boto_error(value, msg, *args, **kwargs)
return ExceptionHandlerContextManager()
@lru_cache()
def resource_cache(
name, region, max_retries=BOTO_MAX_RETRIES, **kwargs
) -> ServiceResource:
cli_logger.verbose(
"Creating AWS resource `{}` in `{}`", cf.bold(name), cf.bold(region)
)
kwargs.setdefault(
"config",
Config(retries={"max_attempts": max_retries}),
)
return boto3.resource(
name,
region,
**kwargs,
)
@lru_cache()
def client_cache(name, region, max_retries=BOTO_MAX_RETRIES, **kwargs) -> BaseClient:
try:
# try to re-use a client from the resource cache first
return resource_cache(name, region, max_retries, **kwargs).meta.client
except ResourceNotExistsError:
# fall back for clients without an associated resource
cli_logger.verbose(
"Creating AWS client `{}` in `{}`", cf.bold(name), cf.bold(region)
)
kwargs.setdefault(
"config",
Config(retries={"max_attempts": max_retries}),
)
return boto3.client(
name,
region,
**kwargs,
)