Files
2026-07-13 13:17:40 +08:00

156 lines
6.3 KiB
Python

import abc
import time
from typing import TYPE_CHECKING, Any, Dict, Optional
from ray_release.anyscale_util import get_project_name
from ray_release.aws import (
RELEASE_AWS_RESOURCE_TYPES_TO_TRACK_FOR_BILLING,
add_tags_to_aws_config,
)
from ray_release.config import DEFAULT_AUTOSUSPEND_MINS, DEFAULT_MAXIMUM_UPTIME_MINS
from ray_release.test import Test
from ray_release.util import dict_hash, get_anyscale_sdk
if TYPE_CHECKING:
from anyscale.sdk.anyscale_client.sdk import AnyscaleSDK
class ClusterManager(abc.ABC):
def __init__(
self,
test: Test,
project_id: str,
sdk: Optional["AnyscaleSDK"] = None,
smoke_test: bool = False,
):
self.sdk = sdk or get_anyscale_sdk()
self.test = test
self.smoke_test = smoke_test
self.project_id = project_id
self.project_name = get_project_name(self.project_id, self.sdk)
self.cluster_name = (
f"{test.get_name()}{'-smoke-test' if smoke_test else ''}_{int(time.time())}"
)
self.cluster_env = None
self.cluster_env_name = None
self.cluster_env_id: Optional[str] = None
self.cluster_env_build_id: Optional[str] = None
self.cluster_compute = None
self.cluster_compute_name = None
self.cluster_compute_id = None
self.cloud_provider = test.get_cloud_env()
self.autosuspend_minutes = DEFAULT_AUTOSUSPEND_MINS
self.maximum_uptime_minutes = DEFAULT_MAXIMUM_UPTIME_MINS
def set_cluster_env(self):
byod_image_name_normalized = (
self.test.get_anyscale_byod_image()
.replace("/", "_")
.replace(":", "_")
.replace(".", "_")
)
self.cluster_env_name = byod_image_name_normalized
def set_cluster_compute(
self,
cluster_compute: Dict[str, Any],
extra_tags: Optional[Dict[str, str]] = None,
):
extra_tags = extra_tags or {}
self.cluster_compute = cluster_compute
self.cluster_compute.setdefault(
"idle_termination_minutes", self.autosuspend_minutes
)
self.cluster_compute.setdefault(
"maximum_uptime_minutes", self.maximum_uptime_minutes
)
is_new_schema = self.test.uses_anyscale_sdk_2026()
self.cluster_compute = self._annotate_cluster_compute(
self.cluster_compute,
extra_tags=extra_tags,
is_new_schema=is_new_schema,
)
self.cluster_compute_name = (
f"{self.project_name}_{self.project_id[4:8]}"
f"__compute__{self.test.get_name()}__"
f"{dict_hash(self.cluster_compute)}"
)
def _annotate_cluster_compute(
self,
cluster_compute: Dict[str, Any],
extra_tags: Dict[str, str],
is_new_schema: bool = False,
) -> Dict[str, Any]:
if not extra_tags or self.cloud_provider != "aws":
return cluster_compute
cluster_compute = cluster_compute.copy()
if is_new_schema:
# Anyscale picks an effective advanced_instance_config per node
# group: when a per-group spec is set, it replaces (does not
# merge with) the cluster-level base. So billing tags must land
# wherever the effective spec actually comes from. Rules:
# - No advanced_instance_config anywhere -> tag base only
# (auto-create base spec to hold tags).
# - Base only -> tag base only.
# - Per-group only (head and/or workers, no base) -> tag those
# per-group specs only. Do NOT auto-create a base spec; that
# would tag a base that no node group uses and could
# confuse readers.
# - Base AND per-group -> tag everywhere. Both are
# load-bearing: the base covers node groups without an
# override, the per-group covers node groups with one.
# Normalize missing/null head_node and worker_nodes to empty
# containers so the downstream checks don't need to special-case
# them. When head is missing/null the resulting `head` is a
# fresh `{}` we never mutate (has_head_aic is False); when
# head_node is a real dict we mutate that dict in place below.
head = cluster_compute.get("head_node") or {}
workers = cluster_compute.get("worker_nodes") or []
has_head_aic = "advanced_instance_config" in head
has_worker_aic = any(
isinstance(w, dict) and "advanced_instance_config" in w for w in workers
)
has_base_aic = "advanced_instance_config" in cluster_compute
should_tag_base = has_base_aic or not (has_head_aic or has_worker_aic)
if should_tag_base:
aws = cluster_compute.get("advanced_instance_config", {})
cluster_compute["advanced_instance_config"] = add_tags_to_aws_config(
aws, extra_tags, RELEASE_AWS_RESOURCE_TYPES_TO_TRACK_FOR_BILLING
)
if has_head_aic:
head["advanced_instance_config"] = add_tags_to_aws_config(
head["advanced_instance_config"],
extra_tags,
RELEASE_AWS_RESOURCE_TYPES_TO_TRACK_FOR_BILLING,
)
for worker in workers:
if isinstance(worker, dict) and "advanced_instance_config" in worker:
worker["advanced_instance_config"] = add_tags_to_aws_config(
worker["advanced_instance_config"],
extra_tags,
RELEASE_AWS_RESOURCE_TYPES_TO_TRACK_FOR_BILLING,
)
else:
if "aws" in cluster_compute:
raise ValueError(
"aws field is invalid in compute config, "
"use advanced_configurations_json instead"
)
aws = cluster_compute.get("advanced_configurations_json", {})
cluster_compute["advanced_configurations_json"] = add_tags_to_aws_config(
aws, extra_tags, RELEASE_AWS_RESOURCE_TYPES_TO_TRACK_FOR_BILLING
)
return cluster_compute
def build_configs(self, timeout: float = 30.0):
raise NotImplementedError