410 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""远程工作环境自动初始化:技能同步 + 依赖软件自动安装2026-08-28
设计:
- 技能同步:本地技能树(公共全局 / 产线 / 项目通用 / 技能集 / 本机构组织技能)
rsync 到远程 ~/<remote_dir>/skills/。沙箱把该目录挂成 /home技能在沙箱内
路径为 /home/skills/agent 可直接读取。
- 依赖安装:解析同步技能 SKILL.md frontmatter 的 dependencies: 字段,
机器可解析的 pip 规格(含版本约束)装进远程 ~/<remote_dir>/.venv-skills
(沙箱内 /home/.venv-skills技能脚本用该 venv 的 python
非机器可解析依赖(自然语言)不猜,跳过并汇报。
- 重量级依赖torch/训练框架等,几 GB 且常需 GPU默认跳过避免远程机
被撑爆;跳过名单可通过 appbase 参数 remote_env_heavy_skip 扩展。
- pip 源appbase 参数 remote_env_pip_index默认阿里源。
- 全部失败不抛异常:以结构化报告返回,由调用方决定是否阻断/提示。
"""
import base64
import json
import os
import re
import shlex
import asyncio
import logging
logger = logging.getLogger("pipeline.remote_env_init")
from .work_env import (
_ssh_common_args, _ssh_target, _rel_remote_dir,
_validate_remote_dir, _remote_mkdir,
)
# 重量级 pip 包默认跳过(远程部署机通常无 GPUtorch 系单包 2GB+)。
# 名单可通过 appbase 参数 remote_env_heavy_skip 覆盖/扩展(逗号分隔)。
_DEFAULT_HEAVY = {
"torch", "torchvision", "torchaudio", "tensorflow", "jax",
"vllm", "unsloth", "deepspeed", "flash-attn", "bitsandbytes",
"llama-cpp-python", "llama_cpp_python", "transformers", "accelerate",
"peft", "trl", "diffusers", "axolotl", "lm-eval", "lm_eval",
"stable-diffusion", "comfyui", "openai-whisper", "whisper",
"segment-anything", "modal",
}
# pip 规格:名字(可带 extras+ 可选版本约束,拒绝路径/URL注入面
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_SPEC_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*(\[[^\]]+\])?"
r"(\s*(==|>=|<=|~=|!=|>|<)\s*[A-Za-z0-9.*+_-]+)*$")
# Python 标准库模块:技能 frontmatter 里常把它们列进 dependencies
# 但它们不是 pip 包,装会失败(且 venv 里自带)。
_STDLIB_SKIP = {
"asyncio", "typing", "json", "os", "sys", "re", "math", "time",
"datetime", "subprocess", "threading", "multiprocessing", "sqlite3",
"urllib", "http", "socket", "ssl", "hashlib", "hmac", "base64",
"uuid", "random", "collections", "itertools", "functools", "pathlib",
"shutil", "tempfile", "logging", "unittest", "argparse", "csv",
"struct", "ctypes", "dataclasses", "abc", "io", "string", "traceback",
"inspect", "pickle", "gzip", "zipfile", "tarfile", "email", "xml",
"html", "http.client", "socketserver", "secrets", "contextlib",
"operator", "copy", "weakref", "enum", "numbers", "decimal", "fractions",
}
# ── frontmatter 依赖解析(不引 yaml手写轻量解析──────────────
def _parse_skill_deps(content: str):
"""解析 SKILL.md frontmatter 的 dependencies: 列表。
支持两种写法:块式(- item 多行)与内联([a, b])。
只在 frontmatter 区域内解析,避免正文的散文列表混入。
返回依赖字符串列表。
"""
if not content.startswith("---"):
return []
end = content.find("\n---", 3)
if end == -1:
return []
fm = content[3:end]
lines = fm.split("\n")
deps, in_deps = [], False
for line in lines:
stripped = line.strip()
if stripped.startswith("dependencies:"):
rest = stripped.split(":", 1)[1].strip()
if rest.startswith("["):
inner = rest.strip("[]")
return [x.strip().strip("'\"") for x in inner.split(",") if x.strip()]
in_deps = True
continue
if in_deps:
if stripped.startswith("- "):
deps.append(stripped[2:].strip().strip("'\""))
elif stripped == "":
continue
else:
in_deps = False
return deps
def _pip_installable(dep: str) -> bool:
"""判断依赖是否为机器可解析的 pip 规格(名字合法 + 规格形态合法 + 非标准库)。"""
dep = (dep or "").strip()
if not dep or "/" in dep or ":" in dep:
return False
name = re.split(r"[\[<>=!~\s]", dep, 1)[0]
if name.lower() in _STDLIB_SKIP:
return False
return bool(_NAME_RE.match(name)) and bool(_SPEC_RE.match(dep))
def _specs_from_scope(scope_dir: str):
"""收集一个技能目录(含子目录)下所有技能的 pip 依赖规格。"""
out = {}
if not os.path.isdir(scope_dir):
return out
for root, _dirs, files in os.walk(scope_dir):
if "SKILL.md" not in files:
continue
try:
with open(os.path.join(root, "SKILL.md"), encoding="utf-8") as f:
content = f.read()
except Exception:
continue
for dep in _parse_skill_deps(content):
if _pip_installable(dep):
name = re.split(r"[\[<>=!~\s]", dep, 1)[0].lower()
# 带版本约束的规格优先于裸名字
if name not in out or any(c in dep for c in "=<>~"):
out[name] = dep
return out
# ── 配置appbase params禁硬编码──────────────────────────
def _cfg_defaults() -> dict:
return {"pip_index": "https://mirrors.aliyun.com/pypi/simple/",
"heavy_skip": ",".join(sorted(_DEFAULT_HEAVY)),
"install_timeout": 1200}
async def _load_cfg(sor=None) -> dict:
cfg = dict(_cfg_defaults())
if sor is None:
return cfg
try:
from .workspace import get_param
idx = await get_param(sor, "remote_env_pip_index", "")
if idx:
cfg["pip_index"] = idx.strip()
heavy = await get_param(sor, "remote_env_heavy_skip", "")
if heavy:
cfg["heavy_skip"] = heavy.strip()
timeout = await get_param(sor, "remote_env_install_timeout", "")
if timeout:
cfg["install_timeout"] = int(float(timeout))
except Exception as e:
logger.warning("remote_env_init: 读取 appbase 参数失败,用默认值: %s", e)
return cfg
# ── 技能同步 ──────────────────────────────────────────────────
def _skill_scopes(skills_base: str, org_id: str):
"""要同步的技能范围(相对技能根的本地子目录)列表。
动态枚举技能根顶层目录(除 orgs/ 外全部同步,覆盖公共/产线/项目/技能集
及未来新增的范围),再加本机构组织技能(含其下项目私有技能)。
orgs/ 其他机构不同步(隔离)。
"""
scopes = []
if os.path.isdir(skills_base):
for name in sorted(os.listdir(skills_base)):
if name == "orgs" or name.startswith("."):
continue
if os.path.isdir(os.path.join(skills_base, name)):
scopes.append(name)
if org_id:
scopes.append(os.path.join("orgs", str(org_id)))
return scopes
async def sync_skills_to_remote(env: dict, org_id: str = "", skills_base: str = "") -> dict:
"""把公共/产线/项目/技能集/本机构组织技能同步到远程 ~/<remote_dir>/skills/。
幂等:--delete 覆盖。返回 {ok, synced:[...], skipped:[...], error}
"""
err = _validate_remote_dir(env.get("remote_dir", ""))
if err:
return {"ok": False, "error": err}
if not skills_base:
from pipeline_core.skill_pack import get_skills_base
skills_base = get_skills_base()
if not os.path.isdir(skills_base):
return {"ok": False, "error": f"本地技能树不存在: {skills_base}"}
rel = _rel_remote_dir(env.get("remote_dir", ""))
remote_skills = rel + "/skills"
if not await _remote_mkdir(env, remote_skills):
return {"ok": False, "error": "远程创建技能目录失败"}
target_base = _ssh_target(env) + ":~/" + remote_skills
ssh_opts = " ".join(_ssh_common_args(env))
synced, skipped = [], []
for sub in _skill_scopes(skills_base, org_id):
src = os.path.join(skills_base, sub)
if not os.path.isdir(src):
skipped.append(sub)
continue
# rsync -R + "./" 锚点:保留相对子路径(如 orgs/<id>),远程结构与本地一致
cmd = ["rsync", "-az", "--delete", "-R",
"-e", "ssh " + ssh_opts,
os.path.join(skills_base, "./" + sub + "/"),
target_base + "/"]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
_out, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
if proc.returncode != 0:
return {"ok": False, "error": f"技能同步失败({sub}): "
+ stderr.decode('utf-8', 'replace')[-500:]}
synced.append(sub)
except asyncio.TimeoutError:
return {"ok": False, "error": f"技能同步超时({sub})"}
return {"ok": True, "synced": synced, "skipped": skipped,
"remote_path": "~/" + remote_skills}
# ── 依赖收集与安装 ────────────────────────────────────────────
def collect_skill_deps(skills_base: str, org_id: str, heavy_skip: str) -> dict:
"""收集各范围技能的 pip 依赖,剔除重量级。返回 {specs, heavy_skipped, unparseable}。"""
skip = {s.strip().lower() for s in (heavy_skip or "").split(",") if s.strip()}
specs, heavy_skipped = {}, []
for sub in _skill_scopes(skills_base, org_id):
for name, spec in _specs_from_scope(os.path.join(skills_base, sub)).items():
if name in skip:
if spec not in heavy_skipped:
heavy_skipped.append(spec)
else:
specs[name] = spec
return {"specs": sorted(specs.values()), "heavy_skipped": sorted(heavy_skipped)}
async def launch_remote_install(env: dict, specs: list, pip_index: str, rel: str) -> bool:
"""在远程以 nohup 分离进程启动逐包安装(脱离 SSH 会话,不怕断连/重启)。
进度写 ~/<rel>/.venv-skills-install.progressOK:/FAILED: 行),
完成写 ~/<rel>/.venv-skills-install.done汇总
本调用秒级返回,是否成功启动由返回值给出。
"""
payload = base64.b64encode(json.dumps(specs).encode()).decode()
index_arg = ('-i ' + shlex.quote(pip_index) + ' ') if pip_index else ''
# 分离脚本:建 venv → 逐包安装 → 写进度/完成标记。
# 用 set -f 防 pip 规格里 * / [...] 被远程 shell 展开。
inner = (
'RD="$HOME/' + rel + '"; VENV="$RD/.venv-skills"; '
'PROG="$RD/.venv-skills-install.progress"; DONE="$RD/.venv-skills-install.done"; '
'if [ ! -x "$VENV/bin/python" ]; then '
'python3 -m venv "$VENV" || { echo "VENV_FAIL" > "$DONE"; exit 4; }; fi; '
'set -f; : > "$PROG"; '
'echo "' + payload + '" | base64 -d | python3 -c '
'"import json,sys;print(chr(10).join(json.load(sys.stdin)))" | '
'while IFS= read -r pkg; do '
'[ -n "$pkg" ] || continue; '
'if "$VENV/bin/pip" install --no-input --disable-pip-version-check '
+ index_arg + '"$pkg" >/dev/null 2>&1; then echo "OK:$pkg" >> "$PROG"; '
'else echo "FAILED:$pkg" >> "$PROG"; fi; done; '
'OKC=$(grep -c "^OK:" "$PROG" 2>/dev/null || echo 0); '
'FC=$(grep -c "^FAILED:" "$PROG" 2>/dev/null || echo 0); '
'echo "OK=$OKC FAILED=$FC" > "$DONE"'
)
script = (
'RD="$HOME/' + rel + '"; '
'command -v python3 >/dev/null || { echo "NO_PYTHON3"; exit 3; }; '
'mkdir -p "$RD"; '
'nohup bash -c ' + shlex.quote(inner) +
' </dev/null >"$RD/.venv-skills-install.log" 2>&1 & echo LAUNCHED'
)
ssh_cmd = ["ssh"] + _ssh_common_args(env) + [_ssh_target(env), 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)
except Exception as e:
logger.warning("launch_remote_install 启动失败: %s", e)
return False
out = stdout.decode("utf-8", "replace")
if "NO_PYTHON3" in out:
return False
return "LAUNCHED" in out
async def poll_remote_install(env: dict, rel: str) -> dict:
"""轮询远程安装进度。短 SSH 调用。
返回 {done:bool, ok:bool, installed:[], failed:[], venv, ...}
"""
script = (
'RD="$HOME/' + rel + '"; DONE="$RD/.venv-skills-install.done"; '
'PROG="$RD/.venv-skills-install.progress"; '
'if [ -f "$DONE" ]; then echo "STATE=done"; cat "$DONE"; echo "===PROG==="; '
'cat "$PROG" 2>/dev/null; '
'else echo "STATE=running"; cat "$PROG" 2>/dev/null; fi'
)
ssh_cmd = ["ssh"] + _ssh_common_args(env) + [_ssh_target(env), 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=30)
except Exception as e:
return {"done": False, "error": "轮询失败: " + str(e)[:200]}
out = stdout.decode("utf-8", "replace")
done = "STATE=done" in out
body = out.split("STATE=", 1)[1] if "STATE=" in out else out
if "===PROG===" in body:
summary, prog = body.split("===PROG===", 1)
else:
summary, prog = body, ""
installed, failed = [], []
for line in prog.splitlines():
line = line.strip()
if line.startswith("OK:"):
installed.append(line[3:])
elif line.startswith("FAILED:"):
failed.append(line[7:])
result = {
"done": done,
"ok": done and len(failed) == 0 and len(installed) > 0,
"installed": installed,
"failed": failed,
"venv": "~/" + rel + "/.venv-skills",
"mode": "per-package-detached",
}
if done:
result["summary"] = summary.strip()
return result
# ── 总编排 ────────────────────────────────────────────────────
async def init_remote_env(env: dict, org_id: str = "", sor=None,
skills_base: str = "", skip_bwrap: bool = False) -> dict:
"""远程环境一键初始化bwrap → 目录 → 技能同步 → 依赖安装。
bwrap 缺失即中止(沙箱执行的前提);技能/依赖失败以报告返回不中止后续步骤。
"""
from .work_env import ensure_remote_bwrap
report = {"ok": True, "steps": {}}
# 1. bwrap前提失败中止
if not skip_bwrap:
bw = await ensure_remote_bwrap(env)
report["steps"]["bwrap"] = bw
if not bw.get("ok"):
report["ok"] = False
report["error"] = "远程 bwrap 部署失败: " + str(bw.get("error", ""))
return report
# 2. 远程目录
rel = _rel_remote_dir(env.get("remote_dir", ""))
if rel:
ok = await _remote_mkdir(env, rel)
report["steps"]["mkdir"] = {"ok": ok}
if not ok:
report["ok"] = False
report["error"] = "远程工作目录创建失败"
return report
# 3. 技能同步
sk = await sync_skills_to_remote(env, org_id, skills_base)
report["steps"]["skills"] = sk
if not sk.get("ok"):
report["ok"] = False
report["error"] = "技能同步失败: " + str(sk.get("error", ""))
return report
# 4. 依赖解析(快)+ 启动远程分离式安装nohup脱离 SSH 会话,
# 进度落远程文件,轮询无状态——服务重启/断连都不影响)
cfg = await _load_cfg(sor)
deps = collect_skill_deps(skills_base or _default_skills_base(), org_id,
cfg["heavy_skip"])
report["steps"]["deps_found"] = deps
launched = await launch_remote_install(env, deps["specs"], cfg["pip_index"], rel)
report["steps"]["deps_launch"] = {"ok": launched, "count": len(deps["specs"])}
if not launched:
report["warning"] = "依赖安装进程启动失败(远程可能缺 python3请重新运行初始化"
return report
# ── 依赖安装状态查询(无状态,轮询远程进度文件)────────────────
# 安装进程由远程 nohup 托管,进度写 ~/<remote_dir>/.venv-skills-install.*。
# 查询只需 SSH 读文件,不依赖本进程内存,服务重启后仍可查。
async def get_deps_install_status(sor, owner_type: str, owner_id: str) -> dict:
"""查询远程依赖安装进度。从 sd_work_envs 取远程配置后轮询进度文件。"""
from .work_env import get_work_env
env = await get_work_env(
sor, owner_id if owner_type == "user" else "",
owner_id if owner_type == "org" else "")
if env.get("mode") != "remote" or not env.get("remote_dir"):
return {"ok": False, "error": "当前非远程模式,无安装任务"}
rel = _rel_remote_dir(env.get("remote_dir", ""))
return await poll_remote_install(env, rel)
def _default_skills_base() -> str:
from pipeline_core.skill_pack import get_skills_base
return get_skills_base()