feat: 工作环境管理(沙箱本地/远程切换 + 目录迁移)
- work_env.py: get/set_work_env(用户/机构维度,缺省local)、rsync目录迁移、远程SSH+bwrap沙箱、远程bwrap部署 - run_in_work_env: 按mode分发本地bwrap/远程SSH+bwrap - sd_work_envs 表 DDL
This commit is contained in:
parent
81cc0b3344
commit
262d678e2a
@ -42,4 +42,14 @@ from .deploy_account import (
|
||||
sanitize_username,
|
||||
)
|
||||
|
||||
# 工作环境管理(沙箱本地/远程切换 + 目录迁移)
|
||||
from .work_env import (
|
||||
get_work_env,
|
||||
set_work_env,
|
||||
migrate_work_dir,
|
||||
run_remote_sandbox,
|
||||
run_in_work_env,
|
||||
ensure_remote_bwrap,
|
||||
)
|
||||
|
||||
__version__ = "3.5.0"
|
||||
|
||||
@ -648,6 +648,18 @@ def load_pipeline_service():
|
||||
"UNIQUE KEY uk_user_id (user_id), KEY idx_status (status)"
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
|
||||
_debug("sd_deploy_accounts table ready")
|
||||
# 工作环境表(沙箱本地/远程切换)
|
||||
await sor.sqlExe(
|
||||
"CREATE TABLE IF NOT EXISTS sd_work_envs ("
|
||||
"id varchar(32) NOT NULL, owner_type varchar(10) NOT NULL,"
|
||||
"owner_id varchar(32) NOT NULL, mode varchar(10) NOT NULL DEFAULT 'local',"
|
||||
"remote_host varchar(200), remote_port int DEFAULT 22,"
|
||||
"remote_user varchar(100), remote_key_path varchar(500), remote_dir varchar(500),"
|
||||
"created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,"
|
||||
"updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
|
||||
"PRIMARY KEY (id), UNIQUE KEY uk_owner (owner_type, owner_id)"
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
|
||||
_debug("sd_work_envs table ready")
|
||||
# agent_config columns
|
||||
try:
|
||||
await sor.sqlExe("ALTER TABLE pipelines ADD COLUMN agent_config text", {})
|
||||
|
||||
313
pipeline_service/work_env.py
Normal file
313
pipeline_service/work_env.py
Normal file
@ -0,0 +1,313 @@
|
||||
"""
|
||||
pipeline_service/work_env.py - 工作环境管理(沙箱的本地/远程切换 + 目录迁移)
|
||||
|
||||
工作环境以「用户 / 机构」为单位组织,缺省本地模式(bwrap 沙箱)。
|
||||
远程模式 = 免密 SSH 到其他主机,远程主机同样部署 bwrap 走沙箱。
|
||||
|
||||
目录层级约定:项目目录位于「工作目录」(逻辑账号 deploy_dir)之下,
|
||||
所以迁移只需同步工作目录顶层,即可覆盖所有项目内容。
|
||||
|
||||
- get_work_env: 查询用户/机构工作环境(user 优先 → org 兜底 → 缺省 local)
|
||||
- set_work_env: 设置工作环境(切换 mode 时触发目录迁移)
|
||||
- migrate_work_dir: 目录迁移(rsync 本地 deploy_dir ↔ 远程 remote_dir)
|
||||
- run_remote_sandbox: 远程免密 SSH + bwrap 沙箱执行
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("pipeline.work_env")
|
||||
|
||||
from .deploy_account import (
|
||||
BWRAP, deploy_dir_for, sanitize_username, account_name_for,
|
||||
)
|
||||
|
||||
# rsync 可执行文件(迁移必需)
|
||||
RSYNC = shutil.which("rsync")
|
||||
|
||||
|
||||
def _row_to_env(row) -> dict:
|
||||
"""DB 行 → 工作环境 dict。"""
|
||||
return {
|
||||
"mode": getattr(row, "mode", "local") or "local",
|
||||
"remote_host": getattr(row, "remote_host", "") or "",
|
||||
"remote_port": getattr(row, "remote_port", 22) or 22,
|
||||
"remote_user": getattr(row, "remote_user", "") or "",
|
||||
"remote_key_path": getattr(row, "remote_key_path", "") or "",
|
||||
"remote_dir": getattr(row, "remote_dir", "") or "",
|
||||
}
|
||||
|
||||
|
||||
async def get_work_env(sor, user_id: str, org_id: str = "") -> dict:
|
||||
"""获取工作环境。优先级:user 级 → org 级 → 缺省 local。"""
|
||||
if user_id:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT * FROM sd_work_envs WHERE owner_type='user' AND owner_id=${o}$ LIMIT 1",
|
||||
{"o": user_id})
|
||||
if recs:
|
||||
return _row_to_env(recs[0])
|
||||
if org_id:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT * FROM sd_work_envs WHERE owner_type='org' AND owner_id=${o}$ LIMIT 1",
|
||||
{"o": org_id})
|
||||
if recs:
|
||||
return _row_to_env(recs[0])
|
||||
return {"mode": "local", "remote_host": "", "remote_port": 22,
|
||||
"remote_user": "", "remote_key_path": "", "remote_dir": ""}
|
||||
|
||||
|
||||
def _ssh_target(env: dict) -> str:
|
||||
"""构造 scp/rsync 的 user@host 目标。"""
|
||||
user = env.get("remote_user", "")
|
||||
host = env.get("remote_host", "")
|
||||
if user:
|
||||
return f"{user}@{host}"
|
||||
return host
|
||||
|
||||
|
||||
def _ssh_common_args(env: dict) -> list:
|
||||
"""ssh/scp/rsync 共用的 SSH 参数。"""
|
||||
args = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10"]
|
||||
key = env.get("remote_key_path", "")
|
||||
if key:
|
||||
args += ["-i", key]
|
||||
port = env.get("remote_port", 22) or 22
|
||||
args += ["-p", str(port)]
|
||||
return args
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 目录迁移
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def migrate_work_dir(account_name: str, from_mode: str, to_mode: str, env: dict) -> dict:
|
||||
"""把工作目录内容从旧环境迁移到新环境。
|
||||
|
||||
from_mode/to_mode ∈ {'local', 'remote'}
|
||||
- local → remote: rsync 本地 deploy_dir → remote_dir
|
||||
- remote → local: rsync remote_dir → 本地 deploy_dir
|
||||
"""
|
||||
local_dir = deploy_dir_for(account_name)
|
||||
if to_mode == "local" and not os.path.isdir(local_dir):
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
|
||||
if not RSYNC:
|
||||
return {"ok": False, "error": "rsync 不可用,无法迁移"}
|
||||
|
||||
# 构造 rsync 命令
|
||||
try:
|
||||
if from_mode == "local" and to_mode == "remote":
|
||||
# 本地 → 远程
|
||||
remote_dir = env.get("remote_dir", "")
|
||||
if not remote_dir:
|
||||
return {"ok": False, "error": "缺少远程目录 remote_dir"}
|
||||
target = _ssh_target(env) + ":" + remote_dir
|
||||
cmd = ["rsync", "-az", "--delete", "-e",
|
||||
"ssh " + " ".join(_ssh_common_args(env)),
|
||||
local_dir.rstrip("/") + "/", target.rstrip("/") + "/"]
|
||||
elif from_mode == "remote" and to_mode == "local":
|
||||
# 远程 → 本地
|
||||
remote_dir = env.get("remote_dir", "")
|
||||
if not remote_dir:
|
||||
return {"ok": False, "error": "缺少远程目录 remote_dir"}
|
||||
src = _ssh_target(env) + ":" + remote_dir
|
||||
cmd = ["rsync", "-az", "--delete", "-e",
|
||||
"ssh " + " ".join(_ssh_common_args(env)),
|
||||
src.rstrip("/") + "/", local_dir.rstrip("/") + "/"]
|
||||
else:
|
||||
# 同模式或无效,无需迁移
|
||||
return {"ok": True, "migrated": False}
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
if proc.returncode != 0:
|
||||
return {"ok": False, "error": "迁移失败: " + stderr.decode("utf-8", "replace")[-1000:]}
|
||||
return {"ok": True, "migrated": True,
|
||||
"detail": stdout.decode("utf-8", "replace")[-500:]}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "迁移超时(300s)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:500]}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 设置工作环境
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def set_work_env(sor, owner_type: str, owner_id: str, mode: str,
|
||||
remote_config: dict = None, account_name: str = None) -> dict:
|
||||
"""设置工作环境。切换 mode 时触发目录迁移。
|
||||
|
||||
Returns: {"ok": bool, "mode": str, "migrated": {...}}
|
||||
"""
|
||||
if owner_type not in ("user", "org"):
|
||||
return {"ok": False, "error": "owner_type 必须是 user 或 org"}
|
||||
if mode not in ("local", "remote"):
|
||||
return {"ok": False, "error": "mode 必须是 local 或 remote"}
|
||||
|
||||
remote_config = remote_config or {}
|
||||
if mode == "remote":
|
||||
if not remote_config.get("remote_host"):
|
||||
return {"ok": False, "error": "远程模式需要 remote_host"}
|
||||
if not remote_config.get("remote_user"):
|
||||
return {"ok": False, "error": "远程模式需要 remote_user"}
|
||||
if not remote_config.get("remote_dir"):
|
||||
return {"ok": False, "error": "远程模式需要 remote_dir"}
|
||||
|
||||
# 查旧环境(用于判断是否需要迁移)
|
||||
old_recs = await sor.sqlExe(
|
||||
"SELECT mode FROM sd_work_envs WHERE owner_type=${t}$ AND owner_id=${o}$ LIMIT 1",
|
||||
{"t": owner_type, "o": owner_id})
|
||||
old_mode = getattr(old_recs[0], "mode", "local") if old_recs else "local"
|
||||
|
||||
# upsert
|
||||
from appPublic.uniqueID import getID
|
||||
data = {
|
||||
"owner_type": owner_type,
|
||||
"owner_id": owner_id,
|
||||
"mode": mode,
|
||||
"remote_host": remote_config.get("remote_host", ""),
|
||||
"remote_port": int(remote_config.get("remote_port", 22) or 22),
|
||||
"remote_user": remote_config.get("remote_user", ""),
|
||||
"remote_key_path": remote_config.get("remote_key_path", ""),
|
||||
"remote_dir": remote_config.get("remote_dir", ""),
|
||||
}
|
||||
if old_recs:
|
||||
await sor.sqlExe(
|
||||
"UPDATE sd_work_envs SET mode=${mode}$, remote_host=${h}$, remote_port=${p}$, "
|
||||
"remote_user=${u}$, remote_key_path=${k}$, remote_dir=${d}$ "
|
||||
"WHERE owner_type=${t}$ AND owner_id=${o}$",
|
||||
{"mode": mode, "h": data["remote_host"], "p": data["remote_port"],
|
||||
"u": data["remote_user"], "k": data["remote_key_path"],
|
||||
"d": data["remote_dir"], "t": owner_type, "o": owner_id})
|
||||
else:
|
||||
data["id"] = getID()
|
||||
await sor.C("sd_work_envs", data)
|
||||
|
||||
# 触发迁移(模式切换 + 提供了 account_name 时)
|
||||
migrated = {"migrated": False}
|
||||
if account_name and old_mode != mode:
|
||||
env = _row_to_env_from_dict(data)
|
||||
migrated = await migrate_work_dir(account_name, old_mode, mode, env)
|
||||
|
||||
return {"ok": True, "mode": mode, "old_mode": old_mode, "migration": migrated}
|
||||
|
||||
|
||||
def _row_to_env_from_dict(data: dict) -> dict:
|
||||
return {
|
||||
"mode": data.get("mode", "local"),
|
||||
"remote_host": data.get("remote_host", ""),
|
||||
"remote_port": data.get("remote_port", 22),
|
||||
"remote_user": data.get("remote_user", ""),
|
||||
"remote_key_path": data.get("remote_key_path", ""),
|
||||
"remote_dir": data.get("remote_dir", ""),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 远程沙箱执行
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _remote_bwrap_cmd(env: dict, deploy_dir: str, command: str, workdir: str = "") -> list:
|
||||
"""构造远程主机的 bwrap 沙箱命令(通过 SSH 执行)。"""
|
||||
# 远程主机上 bwrap 的路径(假设部署在 ~/bin/bwrap 或系统 PATH)
|
||||
remote_bwrap = "bwrap"
|
||||
parts = [
|
||||
remote_bwrap,
|
||||
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
|
||||
"--die-with-parent",
|
||||
"--ro-bind", "/usr", "/usr",
|
||||
"--ro-bind", "/bin", "/bin",
|
||||
"--ro-bind", "/sbin", "/sbin",
|
||||
"--ro-bind", "/lib", "/lib",
|
||||
"--ro-bind", "/lib64", "/lib64",
|
||||
"--ro-bind", "/etc", "/etc",
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
"--tmpfs", "/tmp",
|
||||
"--bind", deploy_dir, "/home",
|
||||
"--chdir", "/home",
|
||||
"--setenv", "HOME", "/home",
|
||||
"--", "bash", "-c", command,
|
||||
]
|
||||
return parts
|
||||
|
||||
|
||||
async def run_remote_sandbox(env: dict, deploy_dir: str, command: str,
|
||||
workdir: str = "", timeout: int = 120) -> dict:
|
||||
"""远程免密 SSH + bwrap 沙箱执行命令。"""
|
||||
ssh_args = _ssh_common_args(env)
|
||||
target = _ssh_target(env)
|
||||
|
||||
bwrap_parts = _remote_bwrap_cmd(env, deploy_dir, command, workdir)
|
||||
inner = " ".join(shlex.quote(p) for p in bwrap_parts)
|
||||
|
||||
ssh_cmd = ["ssh"] + ssh_args + [target, inner]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout + 30)
|
||||
except asyncio.TimeoutError:
|
||||
return {"rc": -1, "stdout": "", "stderr": f"远程命令超时({timeout}s)", "sandbox": True, "remote": True}
|
||||
except Exception as e:
|
||||
return {"rc": -1, "stdout": "", "stderr": f"SSH 连接失败: {str(e)[:300]}", "sandbox": True, "remote": True}
|
||||
|
||||
return {
|
||||
"rc": proc.returncode or 0,
|
||||
"stdout": stdout.decode("utf-8", "replace")[-8000:],
|
||||
"stderr": stderr.decode("utf-8", "replace")[-4000:],
|
||||
"sandbox": True,
|
||||
"remote": True,
|
||||
}
|
||||
|
||||
|
||||
async def run_in_work_env(sor, account_name: str, user_id: str, org_id: str,
|
||||
command: str, workdir: str = "", timeout: int = 120,
|
||||
network: str = "shared") -> dict:
|
||||
"""按工作环境 mode 分发执行:本地 bwrap 或远程 SSH+bwrap。"""
|
||||
env = await get_work_env(sor, user_id, org_id)
|
||||
if env.get("mode") == "remote":
|
||||
remote_dir = env.get("remote_dir", "")
|
||||
if not remote_dir:
|
||||
return {"rc": -1, "stdout": "", "stderr": "远程模式缺少 remote_dir",
|
||||
"sandbox": True, "remote": True}
|
||||
return await run_remote_sandbox(env, remote_dir, command, workdir, timeout)
|
||||
from .deploy_account import run_in_sandbox
|
||||
return await run_in_sandbox(sor, account_name, command, workdir, timeout, network)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 远程 bwrap 部署(远程主机装 bwrap,零 root)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
async def ensure_remote_bwrap(env: dict) -> dict:
|
||||
"""确保远程主机已安装 bwrap(零 root:apt download + dpkg -x)。"""
|
||||
ssh_args = _ssh_common_args(env)
|
||||
target = _ssh_target(env)
|
||||
|
||||
script = (
|
||||
'mkdir -p ~/bin && '
|
||||
'if ! command -v bwrap >/dev/null 2>&1 && [ ! -x ~/bin/bwrap ]; then '
|
||||
'tmpd=$(mktemp -d) && (cd "$tmpd" && apt download bubblewrap >/dev/null 2>&1 '
|
||||
'&& dpkg -x bubblewrap*.deb . && cp usr/bin/bwrap ~/bin/bwrap) 2>/dev/null; '
|
||||
'rm -rf "$tmpd"; fi; '
|
||||
'if command -v bwrap >/dev/null 2>&1; then echo PATH_BWRAP; '
|
||||
'elif [ -x ~/bin/bwrap ]; then echo HOME_BWRAP; '
|
||||
'else echo NO_BWRAP; fi'
|
||||
)
|
||||
ssh_cmd = ["ssh"] + ssh_args + [target, script]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
out = stdout.decode("utf-8", "replace").strip()
|
||||
if "NO_BWRAP" in out:
|
||||
return {"ok": False, "error": "远程主机无法获取 bwrap: " + stderr.decode("utf-8", "replace")[-300:]}
|
||||
return {"ok": True, "location": out.strip().splitlines()[-1] if out else "unknown"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:300]}
|
||||
Loading…
x
Reference in New Issue
Block a user