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

298 lines
8.7 KiB
Python

import subprocess
import time
from typing import List
import pytest
from pytest_docker_tools import container, fetch, network, volume, wrappers
import docker
from ray._common.network_utils import build_address
# If you need to debug tests using fixtures in this file,
# comment in the volume
# mounts in the head node and worker node containers below and use
# the repro-ci.py script to spin up an instance. The test
# setup is a little intricate, as it uses docker-in-docker.
# You need to ssh into the host machine, find the
# docker-in-docker container with
#
# docker ps
#
# Log into the container with
#
# docker exec -it <dind-daemon container id> sh
#
# And run
#
# mkdir -p /tmp/ray
# chmod 777 /tmp/ray
#
# Now you can re-run the test and the logs will show
# up in /tmp/ray in the docker-in-docker container.
# Good luck!
class Container(wrappers.Container):
def ready(self):
self._container.reload()
if self.status == "exited":
from pytest_docker_tools.exceptions import ContainerFailed
raise ContainerFailed(
self,
f"Container {self.name} has already exited before "
"we noticed it was ready",
)
if self.status != "running":
return False
networks = self._container.attrs["NetworkSettings"]["Networks"]
for (_, n) in networks.items():
if not n["IPAddress"]:
return False
if "Ray runtime started" in super().logs():
return True
return False
def client(self):
from http.client import HTTPConnection
port = self.ports["8000/tcp"][0]
return HTTPConnection(f"localhost:{port}")
def print_logs(self):
for (name, content) in self.get_files("/tmp"):
print(f"===== log start: {name} ====")
print(content.decode())
# This allows us to assign static ips to docker containers
ipam_config = docker.types.IPAMConfig(
pool_configs=[
docker.types.IPAMPool(subnet="192.168.52.0/24", gateway="192.168.52.254")
]
)
gcs_network = network(driver="bridge", ipam=ipam_config)
redis_image = fetch(repository="redis:latest")
redis = container(
image="{redis_image.id}",
network="{gcs_network.name}",
command=("redis-server --save 60 1 --loglevel warning"),
)
head_node_vol = volume()
worker_node_vol = volume()
head_node_container_name = "gcs" + str(int(time.time()))
def gen_head_node(envs):
return container(
image="rayproject/ray:ha_integration",
name=head_node_container_name,
network="{gcs_network.name}",
command=[
"ray",
"start",
"--head",
"--block",
"--num-cpus",
"0",
# Fix the port of raylet to make sure raylet restarts at the same
# ip:port is treated as a different raylet.
"--node-manager-port",
"9379",
"--dashboard-host",
"0.0.0.0",
],
volumes={"{head_node_vol.name}": {"bind": "/tmp", "mode": "rw"}},
environment=envs,
wrapper_class=Container,
ports={
"8000/tcp": None,
},
# volumes={
# "/tmp/ray/": {"bind": "/tmp/ray/", "mode": "rw"}
# },
)
def gen_worker_node(envs, num_cpus):
return container(
image="rayproject/ray:ha_integration",
network="{gcs_network.name}",
command=[
"ray",
"start",
"--address",
build_address(head_node_container_name, 6379),
"--block",
# Fix the port of raylet to make sure raylet restarts at the same
# ip:port is treated as a different raylet.
"--node-manager-port",
"9379",
"--num-cpus",
f"{num_cpus}",
],
volumes={"{worker_node_vol.name}": {"bind": "/tmp", "mode": "rw"}},
environment=envs,
wrapper_class=Container,
ports={
"8000/tcp": None,
},
# volumes={
# "/tmp/ray/": {"bind": "/tmp/ray/", "mode": "rw"}
# },
)
head_node = gen_head_node(
{
"RAY_REDIS_ADDRESS": "{redis.ips.primary}:6379",
"RAY_raylet_client_num_connect_attempts": "10",
"RAY_raylet_client_connect_timeout_milliseconds": "100",
}
)
worker_node = gen_worker_node(
envs={
"RAY_REDIS_ADDRESS": "{redis.ips.primary}:6379",
"RAY_raylet_client_num_connect_attempts": "10",
"RAY_raylet_client_connect_timeout_milliseconds": "100",
},
num_cpus=8,
)
@pytest.fixture
def docker_cluster(head_node, worker_node):
yield (head_node, worker_node)
def run_in_container(cmds: List[List[str]], container_id: str):
"""Run a list of commands in the specified container.
Checks that each docker command executed without error.
Returns the output from each command as a list.
"""
outputs = []
for cmd in cmds:
docker_cmd = ["docker", "exec", container_id] + cmd
print(f"Executing command: {docker_cmd}", time.time())
try:
resp = subprocess.check_output(docker_cmd, stderr=subprocess.STDOUT)
output = resp.decode("utf-8").strip()
print(f"Output: {output}")
outputs.append(output)
except subprocess.CalledProcessError as e:
error_output = e.output.decode("utf-8") if e.output else "No output"
print(f"Command failed with return code {e.returncode}")
print(f"Full error output:\n{error_output}")
raise
return outputs
IMAGE_NAME = "rayproject/ray:runtime_env_container"
# After `docker save` / `podman load`, Podman typically tags the image as below (not the
# Docker daemon name). Use that ref for `podman create` so resolution stays local.
PODMAN_BASE_IMAGE = "localhost/runtime_env_container:latest"
NESTED_IMAGE_NAME = "localhost/runtime_env_container_nested:latest"
@pytest.fixture(scope="session")
def podman_docker_cluster():
start_container_command = [
"docker",
"run",
"-d",
"--privileged",
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
"-v",
"/var/lib/containers:/var/lib/containers",
# For testing environment variables
"--env",
"RAY_TEST_ABC=1",
"--env",
"TEST_ABC=1",
IMAGE_NAME,
"tail",
"-f",
"/dev/null",
]
try:
container_id = subprocess.check_output(
start_container_command, stderr=subprocess.STDOUT
).decode("utf-8")
except subprocess.CalledProcessError as e:
error_output = e.output.decode("utf-8") if e.output else "No output"
print(f"Command failed with return code {e.returncode}")
print(f"Full error output:\n{error_output}")
raise
container_id = container_id.strip()
# Get group id that owns the docker socket file. Add user `ray` to
# group to get necessary permissions for pulling an image from
# docker's local storage into podman
docker_group_id = run_in_container(
[["stat", "-c", "%g", "/var/run/docker.sock"]], container_id
)[0]
run_in_container(
[
["id"],
["sudo", "groupadd", "-g", docker_group_id, "docker"],
["sudo", "usermod", "-aG", "docker", "ray"],
[
"bash",
"-c",
f"docker save {IMAGE_NAME} | podman load",
],
],
container_id,
)
# Add custom file to new image tagged `runtime_env_container_nested`,
# which can be read by Ray actors / Serve deployments to verify the
# container runtime env plugin. Also add serve application that will
# be imported by the telemetry test.
serve_app = """
from ray import serve
@serve.deployment
class Model:
def __call__(self):
with open("file.txt") as f:
return f.read().strip()
app = Model.bind()
"""
run_in_container(
[
["bash", "-c", "echo helloworldalice >> /tmp/file.txt"],
["bash", "-c", f"echo '{serve_app}' >> /tmp/serve_application.py"],
["podman", "create", "--name", "tmp_container", PODMAN_BASE_IMAGE],
["podman", "cp", "/tmp/file.txt", "tmp_container:/home/ray/file.txt"],
[
"podman",
"cp",
"/tmp/serve_application.py",
"tmp_container:/home/ray/serve_application.py",
],
["podman", "commit", "tmp_container", NESTED_IMAGE_NAME],
],
container_id,
)
# For debugging
run_in_container([["podman", "image", "ls"]], container_id)
yield container_id
subprocess.check_call(["docker", "kill", container_id])