"""Utilities for e2e tests of KubeRay/Ray integration. For consistency, all K8s interactions use kubectl through subprocess calls. """ import atexit import contextlib import logging import os import pathlib import subprocess import tempfile import time from typing import Any, Dict, Generator, List, Optional import yaml logger = logging.getLogger(__name__) SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent / "scripts" TEST_CR_PATH = ( pathlib.Path(__file__).resolve().parent / "setup" / "raycluster_test.yaml" ) TEST_CLUSTER_NAME = "raycluster-test" # Parent directory of Ray repository RAY_PARENT = str(pathlib.Path(__file__).resolve().parents[5]) RAYCLUSTERS_QUALIFIED = "rayclusters.ray.io" LOG_FORMAT = "[%(levelname)s %(asctime)s] %(filename)s: %(lineno)d %(message)s" def switch_to_ray_parent_dir(): # Switch to parent of Ray repo, because that's what the doc examples do. logger.info("Switching to parent of Ray directory.") os.chdir(RAY_PARENT) def setup_kuberay_operator(): """Set up KubeRay operator and Ray autoscaler RBAC.""" switch_to_ray_parent_dir() logger.info("Cloning KubeRay and setting up KubeRay configuration.") # For faster run-time when triggering the test locally, don't run the init # script if it has already been run. subprocess.check_call( [ "bash", "-c", ( "ls ray/python/ray/autoscaler/kuberay/config ||" " ./ray/python/ray/autoscaler/kuberay/init-config.sh" ), ] ) logger.info("Creating KubeRay operator.") subprocess.check_call( [ "kubectl", "create", "-k", "ray/python/ray/autoscaler/kuberay/config/default", ] ) def teardown_kuberay_operator(): logger.info("Switching to parent of Ray directory.") os.chdir(RAY_PARENT) logger.info("Deleting operator.") subprocess.check_call( [ "kubectl", "delete", "--ignore-not-found", "-k", "ray/python/ray/autoscaler/kuberay/config/default", ] ) logger.info("Double-checking no pods left over in namespace ray-system.") wait_for_pods(goal_num_pods=0, namespace="ray-system") def wait_for_raycluster_crd(tries=60, backoff_s=5): """CRD creation can take a bit of time after the client request. This function waits until the crd with the provided name is registered. """ switch_to_ray_parent_dir() logger.info("Making sure RayCluster CRD has been registered.") for i in range(tries): get_crd_output = subprocess.check_output(["kubectl", "get", "crd"]).decode() if RAYCLUSTERS_QUALIFIED in get_crd_output: logger.info("Confirmed existence of RayCluster CRD.") break elif i < tries - 1: logger.info("Still waiting to register RayCluster CRD.") time.sleep(backoff_s) else: raise Exception("Failed to register RayCluster CRD.") # Create a test RayCluster CR to make sure that the CRD is fully registered. for i in range(tries): try: subprocess.check_call(["kubectl", "apply", "-f", TEST_CR_PATH]) break except subprocess.CalledProcessError as e: logger.info("Can't create RayCluster CR.") if i < tries - 1: logger.info("Retrying.") time.sleep(backoff_s) else: logger.info("Giving up.") raise e from None # Confirm the test RayCluster exists. out = subprocess.check_output(["kubectl", "get", RAYCLUSTERS_QUALIFIED]).decode() assert TEST_CLUSTER_NAME in out, out # Delete the test RayCluster. subprocess.check_call(["kubectl", "delete", "-f", TEST_CR_PATH]) # Make sure the associated resources are gone before proceeding. wait_for_pods(goal_num_pods=0, namespace="default") def wait_for_pods(goal_num_pods: int, namespace: str, tries=60, backoff_s=5) -> None: """Wait for the number of pods in the `namespace` to be exactly `num_pods`. Raise an exception after exceeding `tries` attempts with `backoff_s` second waits. """ for i in range(tries): cur_num_pods = _get_num_pods(namespace) if cur_num_pods == goal_num_pods: logger.info(f"Confirmed {goal_num_pods} pod(s) in namespace {namespace}.") return elif i < tries - 1: logger.info( f"The number of pods in namespace {namespace} is {cur_num_pods}." f" Waiting until the number of pods is {goal_num_pods}." f"{get_pod_names(namespace)}" ) time.sleep(backoff_s) else: raise Exception( f"Failed to scale to {goal_num_pods} pod(s) in namespace {namespace}." ) def _get_num_pods(namespace: str) -> int: return len(get_pod_names(namespace)) def get_pod_names(namespace: str) -> List[str]: """Get the list of pod names in the namespace.""" get_pods_output = ( subprocess.check_output( [ "kubectl", "-n", namespace, "get", "pods", "-o", "custom-columns=POD:metadata.name", "--no-headers", ] ) .decode() .strip() ) # If there aren't any pods, the output is any empty string. if not get_pods_output: return [] else: return get_pods_output.split("\n") def wait_for_pod_to_start( pod_name_filter: str, namespace: str, tries=60, backoff_s=5 ) -> None: """Waits for a pod to have Running status.phase. More precisely, waits until there is a pod with name containing `pod_name_filter` and the pod has Running status.phase.""" for i in range(tries): pod = get_pod(pod_name_filter=pod_name_filter, namespace=namespace) if not pod: # We didn't get a matching pod. continue pod_status = ( subprocess.check_output( [ "kubectl", "-n", namespace, "get", "pod", pod, "-o", "custom-columns=POD:status.phase", "--no-headers", ] ) .decode() .strip() ) # "not found" is part of the kubectl output if the pod's not there. if "not found" in pod_status: raise Exception(f"Pod {pod} not found.") elif pod_status == "Running": logger.info(f"Confirmed pod {pod} is Running.") return elif i < tries - 1: logger.info( f"Pod {pod} has status {pod_status}. Waiting for the pod to enter " "Running status." ) time.sleep(backoff_s) else: raise Exception(f"Timed out waiting for pod {pod} to enter Running status.") def wait_for_ray_health( pod_name_filter: str, namespace: str, tries=60, backoff_s=5, ray_container="ray-head", ) -> None: """Waits until a Ray pod passes `ray health-check`. More precisely, waits until a Ray pod whose name includes the string `pod_name_filter` passes `ray health-check`. (Ensures Ray has completely started in the pod.) Use case: Wait until there is a Ray head pod with Ray running on it. """ for i in range(tries): try: pod = get_pod(pod_name_filter=pod_name_filter, namespace="default") assert pod, f"Couldn't find a pod matching {pod_name_filter}." # `ray health-check` yields 0 exit status iff it succeeds kubectl_exec( ["ray", "health-check"], pod, namespace, container=ray_container ) logger.info(f"ray health check passes for pod {pod}") return except subprocess.CalledProcessError as e: logger.info(f"Failed ray health check for pod {pod}.") if i < tries - 1: logger.info("Trying again.") time.sleep(backoff_s) else: logger.info("Giving up.") raise e from None def get_pod(pod_name_filter: str, namespace: str) -> Optional[str]: """Gets pods in the `namespace`. Returns the first pod that has `pod_name_filter` as a substring of its name. Returns None if there are no matches. """ pod_names = get_pod_names(namespace) matches = [pod_name for pod_name in pod_names if pod_name_filter in pod_name] if not matches: logger.warning(f"No match for `{pod_name_filter}` in namespace `{namespace}`.") return None return matches[0] def kubectl_exec( command: List[str], pod: str, namespace: str, container: Optional[str] = None, ) -> str: """kubectl exec the `command` in the given `pod` in the given `namespace`. If a `container` is specified, will specify that container for kubectl. Prints and return kubectl's output as a string. """ container_option = ["-c", container] if container else [] kubectl_exec_command = ( ["kubectl", "exec", "-it", pod] + container_option + ["--"] + command ) # Print for debugging convenience. try: out = subprocess.check_output(kubectl_exec_command).decode().strip() except subprocess.CalledProcessError as e: logger.error(f"Error running command {kubectl_exec_command}.") logger.error(f"Output: {e.output.decode()}") raise e from None print(out) return out def kubectl_logs( pod: str, namespace: str, container: Optional[str] = None, ) -> str: """Wrapper for kubectl logs. Returns the logs as a string. """ container_option = ["-c", container] if container else [] kubectl_logs_command = ["kubectl", "logs", pod] + container_option out = subprocess.check_output(kubectl_logs_command).decode().strip() return out def kubectl_exec_python_script( script_name: str, pod: str, namespace: str, container: Optional[str] = None, ) -> str: """ Runs a python script in a container via `kubectl exec`. Scripts live in `tests/kuberay/scripts`. Prints and return kubectl's output as a string. """ script_path = SCRIPTS_DIR / script_name with open(script_path) as script_file: script_string = script_file.read() return kubectl_exec(["python", "-c", script_string], pod, namespace, container) def get_raycluster(raycluster: str, namespace: str) -> Dict[str, Any]: """Gets the Ray CR with name `raycluster` in namespace `namespace`. Returns the CR as a nested Dict. """ get_raycluster_output = ( subprocess.check_output( ["kubectl", "-n", namespace, "get", "raycluster", raycluster, "-o", "yaml"] ) .decode() .strip() ) return yaml.safe_load(get_raycluster_output) def _get_service_port(service: str, namespace: str, target_port: int) -> int: """Given a K8s service and a port targetted by the service, returns the corresponding port exposed by the service. Args: service: Name of a K8s service. namespace: Namespace to which the service belongs. target_port: Port targeted by the service. Returns: service_port: The port exposed by the service. """ service_str = ( subprocess.check_output( ["kubectl", "-n", namespace, "get", "service", service, "-o", "yaml"] ) .decode() .strip() ) service_dict = yaml.safe_load(service_str) service_ports: List = service_dict["spec"]["ports"] matching_ports = [ port for port in service_ports if port["targetPort"] == target_port ] assert matching_ports service_port = matching_ports[0]["port"] return service_port @contextlib.contextmanager def _kubectl_port_forward( service: str, namespace: str, target_port: int, local_port: Optional[int] = None ) -> Generator[int, None, None]: """Context manager which creates a kubectl port-forward process targeting a K8s service. Terminates the port-forwarding process upon exit. Args: service: Name of a K8s service. namespace: Namespace to which the service belongs. target_port: The port targeted by the service. local_port: Forward from this port. Optional. By default, uses the port exposed by the service. Yields: int: The local port. The service can then be accessed at 127.0.0.1:. """ # First, figure out which port the service exposes for the given target port. service_port = _get_service_port(service, namespace, target_port) if not local_port: local_port = service_port process = subprocess.Popen( [ "kubectl", "-n", namespace, "port-forward", f"service/{service}", f"{local_port}:{service_port}", ] ) def terminate_process(): process.terminate() # Wait 10 seconds for the process to terminate. # This cleans up the zombie entry from the process table. # 10 seconds is a deliberately excessive amount of time to wait. process.wait(timeout=10) # Ensure clean-up in case of interrupt. atexit.register(terminate_process) # terminate_process is ok to execute multiple times. try: yield local_port finally: terminate_process() def kubectl_patch( kind: str, name: str, namespace: str, patch: Dict[str, Any], patch_type: str = "strategic", ): """Wrapper for kubectl patch. Args: kind: Kind of the K8s resource (e.g. pod) name: Name of the K8s resource. namespace: Namespace of the K8s resource. patch: The patch to apply, as a dict. patch_type: json, merge, or strategic """ with tempfile.NamedTemporaryFile("w") as patch_file: yaml.dump(patch, patch_file) patch_file.flush() subprocess.check_call( [ "kubectl", "-n", f"{namespace}", "patch", f"{kind}", f"{name}", "--patch-file", f"{patch_file.name}", "--type", f"{patch_type}", ] ) def kubectl_delete(kind: str, name: str, namespace: str, wait: bool = True): """Wrapper for kubectl delete. Args: kind: Kind of the K8s resource (e.g. pod) name: Name of the K8s resource. namespace: Namespace of the K8s resource. wait: Whether to pass ``--wait=true`` so ``kubectl`` blocks until the resource is fully removed. """ wait_str = "true" if wait else "false" subprocess.check_output( [ "kubectl", "-n", f"{namespace}", "delete", f"{kind}", f"{name}", f"--wait={wait_str}", ] )