277 lines
13 KiB
Python
277 lines
13 KiB
Python
import os
|
|
import os.path as osp
|
|
import subprocess
|
|
from constant import BASE_IMAGES, AI_USER, GITHUB_AI_TOKEN
|
|
import time
|
|
import socket
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
wd = Path(__file__).parent.resolve()
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, Union, Dict
|
|
from functools import update_wrapper
|
|
from inspect import signature
|
|
@dataclass
|
|
class DockerConfig:
|
|
container_name: str
|
|
workplace_name: str
|
|
communication_port: int # 12345
|
|
conda_path: str # /root/miniconda3
|
|
test_pull_name: str = field(default='main')
|
|
task_name: Optional[str] = field(default=None)
|
|
git_clone: bool = field(default=False)
|
|
setup_package: Optional[str] = field(default=None)
|
|
local_root: str = field(default=os.getcwd())
|
|
|
|
|
|
class DockerEnv:
|
|
def __init__(self, config: Union[DockerConfig, Dict]):
|
|
if isinstance(config, Dict):
|
|
config = DockerConfig(**config)
|
|
self.workplace_name = config.workplace_name
|
|
self.local_workplace = osp.join(config.local_root, config.workplace_name)
|
|
self.docker_workplace = f"/{config.workplace_name}"
|
|
self.container_name = config.container_name
|
|
self.test_pull_name = config.test_pull_name
|
|
self.task_name = config.task_name
|
|
self.git_clone = config.git_clone
|
|
self.setup_package = config.setup_package
|
|
self.communication_port = config.communication_port
|
|
self.conda_path = config.conda_path
|
|
|
|
def init_container(self):
|
|
container_check_command = ["docker", "ps", "-a", "--filter", f"name={self.container_name}", "--format", "{{.Names}}"]
|
|
existing_container = subprocess.run(container_check_command, capture_output=True, text=True)
|
|
os.makedirs(self.local_workplace, exist_ok=True)
|
|
|
|
if not osp.exists(osp.join(self.local_workplace, 'tcp_server.py')):
|
|
shutil.copy(osp.join(wd, 'tcp_server.py'), self.local_workplace)
|
|
assert osp.exists(osp.join(self.local_workplace, 'tcp_server.py')), "Failed to copy tcp_server.py to the local workplace"
|
|
if self.setup_package is not None:
|
|
unzip_command = ["tar", "-xzvf", f"packages/{self.setup_package}.tar.gz", "-C", self.local_workplace]
|
|
subprocess.run(unzip_command)
|
|
if self.git_clone:
|
|
if not os.path.exists(os.path.join(self.local_workplace, 'AutoAgent')):
|
|
git_command = ["cd", self.local_workplace, "&&", "git", "clone", "-b", self.test_pull_name, f"https://{AI_USER}:{GITHUB_AI_TOKEN}@github.com/HKUDS/AutoAgent.git"]
|
|
print(git_command)
|
|
git_command = " ".join(git_command)
|
|
|
|
result = subprocess.run(git_command, shell=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to clone the repository. The error is: {result.stdout}")
|
|
copy_env_command = f"cp .env {self.local_workplace}/AutoAgent"
|
|
result = subprocess.run(copy_env_command, shell=True, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to copy .env file to the AutoAgent directory. Error: {result.stderr}")
|
|
# create a new branch
|
|
new_branch_name = f"{self.test_pull_name}_{self.task_name}"
|
|
create_branch_command = f"cd {self.local_workplace}/AutoAgent && git checkout -b {new_branch_name}"
|
|
result = subprocess.run(create_branch_command, shell=True, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
print(Exception(f"Failed to create and switch to new branch. Error: {result.stderr}"))
|
|
switch_branch_command = f"cd {self.local_workplace}/AutoAgent && git checkout {new_branch_name}"
|
|
result = subprocess.run(switch_branch_command, shell=True, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to switch to new branch. Error: {result.stderr}")
|
|
else:
|
|
print(f"Successfully switched to new branch: {new_branch_name}")
|
|
else:
|
|
print(f"Successfully created and switched to new branch: {new_branch_name}")
|
|
|
|
if existing_container.stdout.strip() == self.container_name:
|
|
# check if the container is running
|
|
running_check_command = ["docker", "ps", "--filter", f"name={self.container_name}", "--format", "{{.Names}}"]
|
|
running_container = subprocess.run(running_check_command, capture_output=True, text=True)
|
|
|
|
if running_container.stdout.strip() == self.container_name:
|
|
print(f"Container '{self.container_name}' is already running. Skipping creation.")
|
|
return # container is already running, skip creation
|
|
else:
|
|
# container exists but is not running, start it
|
|
start_command = ["docker", "start", self.container_name]
|
|
subprocess.run(start_command)
|
|
print(f"Container '{self.container_name}' has been started.")
|
|
return
|
|
|
|
# if the container does not exist, create and start a new container
|
|
docker_command = [
|
|
"docker", "run", "-d", "--name", self.container_name, "--user", "root",
|
|
"-v", f"{self.local_workplace}:{self.docker_workplace}",
|
|
"-w", f"{self.docker_workplace}", "-p", f"{self.communication_port}:{self.communication_port}", BASE_IMAGES,
|
|
"/bin/bash", "-c",
|
|
f"python3 {self.docker_workplace}/tcp_server.py --workplace {self.workplace_name} --conda_path {self.conda_path} --port {self.communication_port}"
|
|
]
|
|
# execute the docker command
|
|
result = subprocess.run(docker_command, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to start container: {result.stderr}")
|
|
if self.wait_for_container_ready(timeout=60):
|
|
print(f"Container '{self.container_name}' has been created and started.")
|
|
def wait_for_container_ready(self, timeout=30):
|
|
"""using subprocess to check if the container is running"""
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < timeout:
|
|
result = subprocess.run(
|
|
["docker", "inspect", "--format", "{{.State.Running}}", self.container_name],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0 and "true" in result.stdout.lower():
|
|
# 额外检查 tcp_server 是否运行
|
|
try:
|
|
port_info = check_container_ports(self.container_name)
|
|
assert port_info and (port_info[0] == port_info[1])
|
|
available_port = port_info[0]
|
|
self.communication_port = available_port
|
|
result = self.run_command('ps aux')
|
|
if "tcp_server.py" in result['result']:
|
|
return True
|
|
except Exception as e:
|
|
pass
|
|
|
|
time.sleep(1)
|
|
|
|
raise TimeoutError(f"Container {self.container_name} failed to start within {timeout} seconds")
|
|
def stop_container(self):
|
|
stop_command = ["docker", "stop", self.container_name]
|
|
result = subprocess.run(stop_command, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to stop container: {result.stderr}")
|
|
|
|
def run_command(self, command, stream_callback=None):
|
|
"""
|
|
communicate with docker container and execute command, support stream output
|
|
|
|
Args:
|
|
command: the command to execute
|
|
stream_callback: optional callback function, for handling stream output
|
|
the function signature should be callback(text: str)
|
|
|
|
Returns:
|
|
dict: the complete JSON result returned by the docker container
|
|
"""
|
|
hostname = 'localhost'
|
|
port = self.communication_port
|
|
buffer_size = 4096
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.connect((hostname, port))
|
|
s.sendall(command.encode())
|
|
|
|
partial_line = ""
|
|
while True:
|
|
chunk = s.recv(buffer_size)
|
|
if not chunk:
|
|
break
|
|
|
|
# add new received data to the unfinished data
|
|
data = partial_line + chunk.decode('utf-8')
|
|
lines = data.split('\n')
|
|
|
|
# except the last line, process all complete lines
|
|
for line in lines[:-1]:
|
|
if line:
|
|
try:
|
|
response = json.loads(line)
|
|
if response['type'] == 'chunk':
|
|
# process stream output
|
|
if stream_callback:
|
|
stream_callback(response['data'])
|
|
elif response['type'] == 'final':
|
|
# return the final result
|
|
return {
|
|
'status': response['status'],
|
|
'result': response['result']
|
|
}
|
|
except json.JSONDecodeError:
|
|
print(f"Invalid JSON: {line}")
|
|
|
|
# save the possibly unfinished last line
|
|
partial_line = lines[-1]
|
|
|
|
# if the loop ends normally without receiving a final response
|
|
return {
|
|
'status': -1,
|
|
'result': 'Connection closed without final response'
|
|
}
|
|
|
|
def with_env(env: DockerEnv):
|
|
"""将env注入到工具函数中的装饰器"""
|
|
def decorator(func):
|
|
def wrapped(*args, **kwargs):
|
|
return func(env=env, *args, **kwargs)
|
|
|
|
# 保留原始函数的所有属性
|
|
update_wrapper(wrapped, func)
|
|
# 修改signature,移除env参数
|
|
wrapped.__signature__ = signature(func).replace(
|
|
parameters=[p for p in signature(func).parameters.values() if p.name != 'env']
|
|
)
|
|
if func.__doc__:
|
|
try:
|
|
if '{docker_workplace}' in func.__doc__:
|
|
wrapped.__doc__ = func.__doc__.format(docker_workplace=env.docker_workplace)
|
|
else:
|
|
wrapped.__doc__ = func.__doc__
|
|
if '{local_workplace}' in func.__doc__:
|
|
wrapped.__doc__ = func.__doc__.format(local_workplace=env.local_workplace)
|
|
else:
|
|
wrapped.__doc__ = func.__doc__
|
|
except (KeyError, IndexError, ValueError):
|
|
# 如果格式化失败(没有占位符),保持原始文档
|
|
wrapped.__doc__ = func.__doc__
|
|
return wrapped
|
|
return decorator
|
|
|
|
def check_container_ports(container_name: str):
|
|
"""
|
|
check if the container has port mapping
|
|
return format:
|
|
- if the container exists and has port mapping: '0.0.0.0:12345->12345/tcp'
|
|
- if the container does not exist or does not have port mapping: None
|
|
"""
|
|
# use docker ps to check the container and get the port information
|
|
container_check_command = [
|
|
"docker", "ps", "-a",
|
|
"--filter", f"name={container_name}",
|
|
"--format", "{{.Ports}}"
|
|
]
|
|
|
|
result = subprocess.run(container_check_command, capture_output=True, text=True)
|
|
ports_info = result.stdout.strip()
|
|
|
|
if not ports_info:
|
|
return None
|
|
|
|
# only process the mapped ports
|
|
for mapping in ports_info.split(','):
|
|
mapping = mapping.strip()
|
|
if '->' in mapping:
|
|
# parse '0.0.0.0:12345->12345/tcp' to (12345, 12345)
|
|
host_part, container_part = mapping.split('->')
|
|
host_port = host_part.split(':')[1] # get '12345' from '0.0.0.0:12345'
|
|
container_port = container_part.split('/')[0] # get '12345' from '12345/tcp'
|
|
return (int(host_port), int(container_port)) # convert to integers
|
|
return None
|
|
|
|
def check_container_exist(container_name: str):
|
|
container_check_command = [
|
|
"docker", "ps", "-a",
|
|
"--filter", f"name={container_name}",
|
|
"--format", "{{.Names}}"
|
|
]
|
|
result = subprocess.run(container_check_command, capture_output=True, text=True)
|
|
return container_name in result.stdout.strip()
|
|
|
|
def check_container_running(container_name: str):
|
|
container_check_command = [
|
|
"docker", "ps",
|
|
"--filter", f"name={container_name}",
|
|
"--format", "{{.Names}}"
|
|
]
|
|
result = subprocess.run(container_check_command, capture_output=True, text=True)
|
|
return container_name in result.stdout.strip()
|