"""Per-user Docker container manager for main agent.""" from __future__ import annotations import json import re import shutil import subprocess import threading import time from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Optional from config import ( MAX_ACTIVE_USER_CONTAINERS, OUTPUT_FORMATS, TERMINAL_SANDBOX_BIN, TERMINAL_SANDBOX_BINDS, TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_ENV, TERMINAL_SANDBOX_IMAGE, TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_MOUNT_PATH, TERMINAL_SANDBOX_NAME_PREFIX, TERMINAL_SANDBOX_NETWORK, TERMINAL_SANDBOX_REQUIRE, LOGS_DIR, ) from modules.container_monitor import collect_stats, inspect_state @dataclass class ContainerHandle: """Lightweight record describing a user workspace container.""" username: str mode: str workspace_path: str mount_path: str container_name: Optional[str] = None container_id: Optional[str] = None sandbox_bin: Optional[str] = None created_at: float = field(default_factory=time.time) last_active: float = field(default_factory=time.time) def touch(self): self.last_active = time.time() def to_dict(self) -> Dict: return { "username": self.username, "mode": self.mode, "workspace_path": self.workspace_path, "mount_path": self.mount_path, "container_name": self.container_name, "container_id": self.container_id, "created_at": self.created_at, "last_active": self.last_active, } class UserContainerManager: """Create and track long-lived containers for each logged-in user.""" def __init__( self, sandbox_mode: Optional[str] = None, max_containers: int = MAX_ACTIVE_USER_CONTAINERS, ): self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower() self.max_containers = max_containers self.image = TERMINAL_SANDBOX_IMAGE self.mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace" self.network = TERMINAL_SANDBOX_NETWORK self.cpus = TERMINAL_SANDBOX_CPUS self.memory = TERMINAL_SANDBOX_MEMORY self.binds = list(TERMINAL_SANDBOX_BINDS) self.sandbox_bin = TERMINAL_SANDBOX_BIN or "docker" self.name_prefix = TERMINAL_SANDBOX_NAME_PREFIX or "agent-user" self.require = bool(TERMINAL_SANDBOX_REQUIRE) self.extra_env = dict(TERMINAL_SANDBOX_ENV) self._containers: Dict[str, ContainerHandle] = {} self._lock = threading.Lock() self._stats_log_path = Path(LOGS_DIR).expanduser().resolve() / "container_stats.log" self._stats_log_path.parent.mkdir(parents=True, exist_ok=True) if not self._stats_log_path.exists(): self._stats_log_path.touch() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def ensure_container(self, username: str, workspace_path: str) -> ContainerHandle: username = self._normalize_username(username) workspace = str(Path(workspace_path).expanduser().resolve()) Path(workspace).mkdir(parents=True, exist_ok=True) with self._lock: handle = self._containers.get(username) if handle: if handle.mode == "docker" and not self._is_container_running(handle): self._containers.pop(username, None) self._kill_container(handle.container_name, handle.sandbox_bin) handle = None else: handle.workspace_path = workspace handle.touch() return handle if not self._has_capacity(username): raise RuntimeError("资源繁忙:容器配额已用尽,请稍候再试。") handle = self._create_handle(username, workspace) self._containers[username] = handle return handle def release_container(self, username: str, reason: str = "logout"): username = self._normalize_username(username) with self._lock: handle = self._containers.pop(username, None) if not handle: return if handle.mode == "docker" and handle.container_name: self._kill_container(handle.container_name, handle.sandbox_bin) print(f"{OUTPUT_FORMATS['info']} 容器已释放: {handle.container_name} ({reason})") def has_capacity(self, username: Optional[str] = None) -> bool: username = self._normalize_username(username) if username else None with self._lock: if username and username in self._containers: return True if self.max_containers <= 0: return True return len(self._containers) < self.max_containers def get_handle(self, username: str) -> Optional[ContainerHandle]: username = self._normalize_username(username) with self._lock: handle = self._containers.get(username) if handle: handle.touch() return handle def list_containers(self) -> Dict[str, Dict]: with self._lock: return {user: handle.to_dict() for user, handle in self._containers.items()} def get_container_status(self, username: str, include_stats: bool = True) -> Dict: username = self._normalize_username(username) with self._lock: handle = self._containers.get(username) if not handle: return {"username": username, "mode": "host"} info = { "username": username, "mode": handle.mode, "workspace_path": handle.workspace_path, "mount_path": handle.mount_path, "container_name": handle.container_name, "created_at": handle.created_at, "last_active": handle.last_active, } if handle.mode == "docker" and include_stats: stats = collect_stats(handle.container_name, handle.sandbox_bin) state = inspect_state(handle.container_name, handle.sandbox_bin) if stats: info["stats"] = stats self._log_stats(username, stats) if state: info["state"] = state return info # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _has_capacity(self, username: str) -> bool: if self.max_containers <= 0: return True existing = 1 if username in self._containers else 0 return (len(self._containers) - existing) < self.max_containers def _create_handle(self, username: str, workspace: str) -> ContainerHandle: if self.sandbox_mode != "docker": return self._host_handle(username, workspace) docker_path = shutil.which(self.sandbox_bin or "docker") if not docker_path: message = f"未找到容器运行时 {self.sandbox_bin}" if self.require: raise RuntimeError(message) print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机执行。") return self._host_handle(username, workspace) if not self.image: raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置,无法启动容器。") container_name = self._build_container_name(username) self._kill_container(container_name, docker_path) cmd = [ docker_path, "run", "-d", "--name", container_name, "-w", self.mount_path, "-v", f"{workspace}:{self.mount_path}", ] if self.network: cmd += ["--network", self.network] if self.cpus: cmd += ["--cpus", str(self.cpus)] if self.memory: cmd += ["--memory", str(self.memory)] for bind in self.binds: chunk = bind.strip() if chunk: cmd += ["-v", chunk] envs = { "PYTHONIOENCODING": "utf-8", "TERM": "xterm-256color", } envs.update({k: v for k, v in self.extra_env.items() if v is not None}) for key, value in envs.items(): cmd += ["-e", f"{key}={value}"] cmd.append(self.image) cmd += ["tail", "-f", "/dev/null"] result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) if result.returncode != 0: message = result.stderr.strip() or result.stdout.strip() or "容器启动失败" if self.require: raise RuntimeError(message) print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机。") return self._host_handle(username, workspace) container_id = result.stdout.strip() or None print(f"{OUTPUT_FORMATS['success']} 启动用户容器: {container_name} ({username})") return ContainerHandle( username=username, mode="docker", workspace_path=workspace, mount_path=self.mount_path, container_name=container_name, container_id=container_id, sandbox_bin=docker_path, ) def _host_handle(self, username: str, workspace: str) -> ContainerHandle: return ContainerHandle( username=username, mode="host", workspace_path=workspace, mount_path=workspace, ) def _kill_container(self, container_name: Optional[str], docker_bin: Optional[str]): if not container_name or not docker_bin: return subprocess.run( [docker_bin, "rm", "-f", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) def _is_container_running(self, handle: ContainerHandle) -> bool: if handle.mode != "docker" or not handle.container_name or not handle.sandbox_bin: return True try: result = subprocess.run( [ handle.sandbox_bin, "inspect", "-f", "{{.State.Running}}", handle.container_name, ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=3, check=False, ) except (OSError, subprocess.SubprocessError): return False return result.returncode == 0 and result.stdout.strip().lower() == "true" def _build_container_name(self, username: str) -> str: slug = re.sub(r"[^a-z0-9\-]", "-", username.lower()).strip("-") if not slug: slug = "user" return f"{self.name_prefix}-{slug}" def _log_stats(self, username: str, stats: Dict): try: record = { "username": username, "timestamp": time.time(), "stats": stats, } with self._stats_log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") except Exception: pass @staticmethod def _normalize_username(username: Optional[str]) -> str: return (username or "").strip().lower()