forked from admin/PlantAI-rag-new
feat(extension): docker 执行内核入口 + 176 本地自动镜像生成(并行链路)
- cli/run_extension.py:容器通用入口(宿主挂载 /opt/bioflow-entry,镜像零改动);
extension.json → 模板渲染(白名单 12 占位符 + param_*,{output_dir} 必含)→
subprocess 执行(无 shell,进程组 SIGTERM→3s→SIGKILL)→ 输出契约校验(空目录退出码 3);
零第三方依赖(镜像内标准库)
- extension_exec.py:服务端 docker 执行器(list-form;三数据卷+入口卷;
--cpus/--memory 白名单独立复制不 import kernel.py;terminate→kill 同约定;
返回 {exit_code,timed_out,log_tail,cmd};run_stream 逐行增量)
- extension_image.py:指纹 sha256(base+tag+排序依赖) 前 12 位 →
bioflow-ext-<fp>:dev;inspect 命中复用;Dockerfile FROM base + apt + /opt/conda/bin/pip;
失败分类(pip/apt/network/timeout/docker_missing)+ LABEL 定位中间层清理;
registry 台账 + 引用计数 + collect_garbage
- extension_streams.py:契约 §4 事件流(trial/run async 生成器,增量日志节流,
EventBus 双发,防御性失败帧,aclose 回收)
- 单测 53 用例(15 unittest + 38 pytest)全绿;现有 bioflow 226 用例回归通过
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
"""run_extension.py —— 扩展分析容器通用入口(宿主挂载 /opt/bioflow-entry,镜像零改动)。
|
||||
|
||||
执行形态(契约 §3):
|
||||
python -B /opt/bioflow-entry/run_extension.py --ext-dir /job/ext \
|
||||
--input-dir /job/input --output-dir /job/output [--param name=value ...]
|
||||
|
||||
职责:
|
||||
1. 读 <ext-dir>/extension.json(command_template 唯一事实源,契约 §2 schema)。
|
||||
2. render_command_template:移植 175 executor 模板渲染纯函数(白名单占位符
|
||||
12 个 + {param_*};{output_dir} 必含)。
|
||||
3. subprocess 执行(无 shell:shlex 切分;stdout/stderr 透传;超时进程组
|
||||
SIGTERM → 3s → SIGKILL,与 176 服务侧 terminate→kill 约定同源)。
|
||||
4. 输出契约校验:输出目录非空,否则退出码 3。
|
||||
|
||||
退出码约定(容器内零第三方依赖,仅 python 标准库):
|
||||
0 成功
|
||||
1.. 命令自身退出码透传(1/2/7/137…)
|
||||
2 extension.json 缺失/非法、模板渲染失败、白名单外占位符、缺 {output_dir}
|
||||
3 输出契约校验失败(输出目录为空)
|
||||
124 执行超时(进程组已终止)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_SETUP = 2
|
||||
EXIT_OUTPUT_CONTRACT = 3
|
||||
EXIT_TIMEOUT = 124
|
||||
|
||||
DEFAULT_TIMEOUT_SEC = 36000.0 # 与 176 单阶段 10h 超时对齐
|
||||
|
||||
# 占位符白名单(spec_v2 PLACEHOLDERS 移植;扩展 {param_<name>})
|
||||
PLACEHOLDERS = {
|
||||
"code_dir",
|
||||
"input_dir",
|
||||
"input_file",
|
||||
"input_hmm",
|
||||
"input_fasta",
|
||||
"input_query_fasta",
|
||||
"input_reference_fasta",
|
||||
"input_pep_fasta",
|
||||
"input_cds_fasta",
|
||||
"input_id_file",
|
||||
"output_dir",
|
||||
"work_dir",
|
||||
}
|
||||
|
||||
_ID_SUFFIXES = {".id", ".ids", ".geneid", ".geneids", ".txt", ".tsv", ".csv"}
|
||||
_FASTA_SUFFIXES = {".fa", ".fasta", ".faa", ".fsa", ".pep"}
|
||||
_GFF_SUFFIXES = {".gff", ".gff3"}
|
||||
|
||||
_ACTIVE_PROC: subprocess.Popen | None = None
|
||||
|
||||
|
||||
class ExtensionRenderError(RuntimeError):
|
||||
"""模板渲染失败(占位符缺失/参数缺值)。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模板渲染纯函数(executor.py 移植裁剪;不 import gfa_core,零第三方依赖)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _safe_parameter_name(value: Any) -> str:
|
||||
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip())
|
||||
text = re.sub(r"_+", "_", text).strip("_")
|
||||
if not text:
|
||||
return ""
|
||||
if text[0].isdigit():
|
||||
text = "p_" + text
|
||||
if text.startswith("param_"):
|
||||
text = text[6:]
|
||||
return text
|
||||
|
||||
|
||||
def _shell_parameter_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
if re.fullmatch(r"[A-Za-z0-9_./:+,@%-]+", text):
|
||||
return text
|
||||
return shlex.quote(text)
|
||||
|
||||
|
||||
def _first_input_by_suffix(paths: list[Path], suffixes: set[str]) -> str:
|
||||
for path in paths:
|
||||
name = path.name.lower()
|
||||
if path.suffix.lower() in suffixes or any(name.endswith(s) for s in suffixes):
|
||||
return path.as_posix()
|
||||
return ""
|
||||
|
||||
|
||||
def _input_files_by_suffix(paths: list[Path], suffixes: set[str]) -> list[Path]:
|
||||
matched = []
|
||||
for path in paths:
|
||||
name = path.name.lower()
|
||||
if path.suffix.lower() in suffixes or any(name.endswith(s) for s in suffixes):
|
||||
matched.append(path)
|
||||
return matched
|
||||
|
||||
|
||||
def _first_input_by_name(paths: list[Path], tokens: set[str]) -> str:
|
||||
for path in paths:
|
||||
name = path.name.lower()
|
||||
if any(token in name for token in tokens):
|
||||
return path.as_posix()
|
||||
return ""
|
||||
|
||||
|
||||
def _largest_file(paths: list[Path]) -> str:
|
||||
if not paths:
|
||||
return ""
|
||||
return max(paths, key=lambda p: p.stat().st_size if p.exists() else -1).as_posix()
|
||||
|
||||
|
||||
def _smallest_file(paths: list[Path]) -> str:
|
||||
if not paths:
|
||||
return ""
|
||||
return min(paths, key=lambda p: p.stat().st_size if p.exists() else 10**18).as_posix()
|
||||
|
||||
|
||||
def _role_of(extension: dict[str, Any], placeholder: str) -> str:
|
||||
"""input_roles 双形态兼容:契约 §2 平铺 {"input_file": "fastq"},175 旧版
|
||||
{"input_file": {"role": "generic_input_file"}}。仅用于语义占位符消歧。"""
|
||||
roles = extension.get("input_roles")
|
||||
if not isinstance(roles, dict):
|
||||
return ""
|
||||
spec = roles.get(placeholder)
|
||||
if isinstance(spec, dict):
|
||||
return str(spec.get("role") or "").lower()
|
||||
return str(spec or "").lower()
|
||||
|
||||
|
||||
def _used_placeholders(template: str) -> set[str]:
|
||||
return set(re.findall(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", template or ""))
|
||||
|
||||
|
||||
def _validate_template(template: str) -> str | None:
|
||||
"""模板白名单校验(与 spec_v2 normalize 同纪律);None = 通过。"""
|
||||
if not template.strip():
|
||||
return "extension.json 缺少 command_template"
|
||||
if "{output_dir}" not in template:
|
||||
return "command_template 必须包含 {output_dir}(输出契约硬约束)"
|
||||
bad = sorted(
|
||||
name for name in _used_placeholders(template)
|
||||
if name not in PLACEHOLDERS and not name.startswith("param_")
|
||||
)
|
||||
if bad:
|
||||
return "command_template 含白名单外占位符: " + ", ".join(bad)
|
||||
return None
|
||||
|
||||
|
||||
def build_render_context(
|
||||
extension: dict[str, Any],
|
||||
*,
|
||||
run_dir: Path,
|
||||
input_dir: Path,
|
||||
input_files: list[Path],
|
||||
parameters: dict[str, Any] | None = None,
|
||||
output_dir: Path | None = None,
|
||||
code_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""渲染上下文:12 个白名单占位符(语义占位符按输入文件后缀/角色推导)+ param_*。"""
|
||||
output_dir = output_dir or (run_dir / "outputs")
|
||||
code_dir = code_dir or (run_dir / "code")
|
||||
input_file_role = _role_of(extension, "input_file")
|
||||
id_file = _first_input_by_suffix(input_files, _ID_SUFFIXES)
|
||||
annotation_file = (
|
||||
_first_input_by_suffix(input_files, _GFF_SUFFIXES)
|
||||
if ("annotation_gff" in input_file_role or "gff" in input_file_role)
|
||||
else ""
|
||||
)
|
||||
cis_element_file = (
|
||||
_first_input_by_name(input_files, {"plantcare", "cis_element", "cis-elements", "cis.elements"})
|
||||
if input_file_role == "generic_input_file"
|
||||
else ""
|
||||
)
|
||||
input_file = (
|
||||
annotation_file or cis_element_file or id_file
|
||||
or (input_files[0].as_posix() if input_files else input_dir.as_posix())
|
||||
)
|
||||
input_hmm = _first_input_by_suffix(input_files, {".hmm"}) or input_file
|
||||
fasta_paths = _input_files_by_suffix(input_files, _FASTA_SUFFIXES)
|
||||
input_fasta = fasta_paths[0].as_posix() if fasta_paths else input_file
|
||||
query_fasta = _largest_file(fasta_paths) or input_fasta
|
||||
reference_fasta = (
|
||||
_smallest_file([p for p in fasta_paths if p.as_posix() != query_fasta])
|
||||
if len(fasta_paths) > 1 else input_fasta
|
||||
)
|
||||
pep_fasta = _first_input_by_name(fasta_paths, {"pep", "protein", "faa"}) or query_fasta
|
||||
cds_fasta = _first_input_by_name(fasta_paths, {"cds"}) or input_fasta
|
||||
render_context: dict[str, Any] = {
|
||||
"code_dir": code_dir.as_posix(),
|
||||
"input_dir": input_dir.as_posix(),
|
||||
"input_file": input_file,
|
||||
"input_hmm": input_hmm,
|
||||
"input_fasta": input_fasta,
|
||||
"input_query_fasta": query_fasta,
|
||||
"input_reference_fasta": reference_fasta,
|
||||
"input_pep_fasta": pep_fasta,
|
||||
"input_cds_fasta": cds_fasta,
|
||||
"input_id_file": id_file or input_file,
|
||||
"output_dir": output_dir.as_posix(),
|
||||
"work_dir": run_dir.as_posix(),
|
||||
}
|
||||
for name, value in (parameters or {}).items():
|
||||
render_context["param_" + _safe_parameter_name(name)] = _shell_parameter_value(value)
|
||||
return render_context
|
||||
|
||||
|
||||
def render_command_template(
|
||||
extension: dict[str, Any],
|
||||
*,
|
||||
template: str,
|
||||
run_dir: Path,
|
||||
input_dir: Path,
|
||||
input_files: list[Path],
|
||||
parameters: dict[str, Any] | None = None,
|
||||
output_dir: Path | None = None,
|
||||
code_dir: Path | None = None,
|
||||
) -> str:
|
||||
"""模板渲染纯函数(executor.py 同签名移植)。占位符缺值 → ExtensionRenderError。"""
|
||||
context = build_render_context(
|
||||
extension,
|
||||
run_dir=run_dir,
|
||||
input_dir=input_dir,
|
||||
input_files=input_files,
|
||||
parameters=parameters,
|
||||
output_dir=output_dir,
|
||||
code_dir=code_dir,
|
||||
)
|
||||
try:
|
||||
return template.format(**context)
|
||||
except KeyError as exc:
|
||||
missing = str(exc).strip("'\"")
|
||||
raise ExtensionRenderError(f"Missing command template value: {missing}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行与输出契约
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _output_contract_ok(output_dir: Path) -> bool:
|
||||
"""输出契约:输出目录存在且非空(至少一个条目)。"""
|
||||
return output_dir.is_dir() and any(output_dir.iterdir())
|
||||
|
||||
|
||||
def _find_code_dir(ext_dir: Path) -> Path | None:
|
||||
"""代码目录:converted_code 优先(契约 §1 布局),raw_code 兜底。"""
|
||||
for name in ("converted_code", "raw_code"):
|
||||
candidate = ext_dir / name
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _kill_group(pid: int, sig: int) -> None:
|
||||
try:
|
||||
os.killpg(pid, sig)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
def _sigterm_handler(signum: int, frame: Any) -> None:
|
||||
"""docker stop 的 SIGTERM:转发到子进程组后以 124 退出(日志可辨)。"""
|
||||
proc = _ACTIVE_PROC
|
||||
if proc is not None and proc.poll() is None:
|
||||
_kill_group(proc.pid, signal.SIGTERM)
|
||||
sys.exit(EXIT_TIMEOUT)
|
||||
|
||||
|
||||
def _run_command(command: list[str], cwd: Path, env: dict[str, str], timeout_sec: float) -> int:
|
||||
"""执行命令(stdout/stderr 直接透传容器流;超时进程组 SIGTERM→3s→SIGKILL)。
|
||||
|
||||
可执行文件缺失(镜像缺工具)→ 可读退出码 2(不抛 traceback)。"""
|
||||
global _ACTIVE_PROC
|
||||
try:
|
||||
proc = subprocess.Popen(command, cwd=str(cwd), env=env, start_new_session=True)
|
||||
except FileNotFoundError:
|
||||
print(f"[run_extension] 可执行文件缺失(镜像 PATH 中不存在): {command[0]}",
|
||||
file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
_ACTIVE_PROC = proc
|
||||
try:
|
||||
return proc.wait(timeout=timeout_sec)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_group(proc.pid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_group(proc.pid, signal.SIGKILL)
|
||||
proc.wait()
|
||||
return EXIT_TIMEOUT
|
||||
finally:
|
||||
_ACTIVE_PROC = None
|
||||
|
||||
|
||||
def _parse_params(items: list[str]) -> dict[str, Any]:
|
||||
parameters: dict[str, Any] = {}
|
||||
for item in items:
|
||||
name, sep, value = item.partition("=")
|
||||
if not sep or not name.strip():
|
||||
raise ExtensionRenderError(f"invalid --param (需要 name=value): {item}")
|
||||
parameters[name.strip()] = value
|
||||
return parameters
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="run_extension",
|
||||
description="扩展分析容器通用入口(契约 EXTENSION_CONTRACT §3)",
|
||||
)
|
||||
parser.add_argument("--ext-dir", default="/job/ext", help="扩展目录(挂载 :ro)")
|
||||
parser.add_argument("--input-dir", default="/job/input", help="输入目录")
|
||||
parser.add_argument("--output-dir", default="/job/output", help="输出目录(契约校验对象)")
|
||||
parser.add_argument("--work-dir", default="", help="工作目录(默认=输出目录)")
|
||||
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC, help="执行超时(秒)")
|
||||
parser.add_argument("--param", action="append", default=[], help="参数 name=value(可重复)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _sigterm_handler)
|
||||
except ValueError: # 非主线程(测试)不支持 handler
|
||||
pass
|
||||
|
||||
ext_dir = Path(args.ext_dir)
|
||||
ext_json_path = ext_dir / "extension.json"
|
||||
if not ext_json_path.is_file():
|
||||
print(f"[run_extension] extension.json 缺失: {ext_json_path}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
try:
|
||||
extension = json.loads(ext_json_path.read_text(encoding="utf-8"))
|
||||
except ValueError as exc:
|
||||
print(f"[run_extension] extension.json 解析失败: {exc}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
if not isinstance(extension, dict):
|
||||
print("[run_extension] extension.json 必须是 JSON 对象", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
template = str(extension.get("command_template") or "")
|
||||
error = _validate_template(template)
|
||||
if error:
|
||||
print(f"[run_extension] {error}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
code_dir = _find_code_dir(ext_dir)
|
||||
if code_dir is None:
|
||||
print("[run_extension] 代码目录缺失(converted_code/raw_code 均不存在)", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
input_dir = Path(args.input_dir)
|
||||
output_dir = Path(args.output_dir)
|
||||
work_dir = Path(args.work_dir) if args.work_dir else output_dir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
parameters = _parse_params(args.param)
|
||||
except ExtensionRenderError as exc:
|
||||
print(f"[run_extension] {exc}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
input_files = sorted(p for p in input_dir.rglob("*") if p.is_file())
|
||||
try:
|
||||
command_text = render_command_template(
|
||||
extension,
|
||||
template=template,
|
||||
run_dir=work_dir,
|
||||
input_dir=input_dir,
|
||||
input_files=input_files,
|
||||
parameters=parameters,
|
||||
output_dir=output_dir,
|
||||
code_dir=code_dir,
|
||||
)
|
||||
except ExtensionRenderError as exc:
|
||||
print(f"[run_extension] 模板渲染失败: {exc}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
try:
|
||||
command = shlex.split(command_text)
|
||||
except ValueError as exc:
|
||||
print(f"[run_extension] 命令切分失败: {exc}", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
if not command:
|
||||
print("[run_extension] 渲染后命令为空", file=sys.stderr)
|
||||
return EXIT_SETUP
|
||||
|
||||
print("[run_extension] command: " + command_text)
|
||||
sys.stdout.flush()
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
code = _run_command(command, work_dir, env, args.timeout)
|
||||
if code == EXIT_TIMEOUT:
|
||||
print(f"[run_extension] 执行超时({args.timeout}s),进程组已终止 (exit {EXIT_TIMEOUT})", file=sys.stderr)
|
||||
return EXIT_TIMEOUT
|
||||
if code != EXIT_OK:
|
||||
print(f"[run_extension] command exited with code {code}", file=sys.stderr)
|
||||
return code
|
||||
if not _output_contract_ok(output_dir):
|
||||
print(f"[run_extension] 输出契约校验失败:输出目录为空 {output_dir} (exit {EXIT_OUTPUT_CONTRACT})",
|
||||
file=sys.stderr)
|
||||
return EXIT_OUTPUT_CONTRACT
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
"""run_extension 容器入口单测 —— 纯 stdlib unittest(容器内无 pytest)。
|
||||
|
||||
覆盖:模板渲染白名单/输出契约(退出码 3)/参数注入/退出码透传/超时/extension.json
|
||||
异常。真 subprocess 执行(容器入口语义,宿主机 python3 直跑,不需要 docker)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
ENTRY = REPO_ROOT / "bioflow_kernel" / "cli" / "run_extension.py"
|
||||
|
||||
if not ENTRY.is_file():
|
||||
raise SystemExit(f"入口脚本缺失: {ENTRY}")
|
||||
|
||||
|
||||
def _write_extension(ext_dir: Path, template: str, **extra) -> None:
|
||||
payload = {
|
||||
"schema_version": "extension_stage_v1",
|
||||
"ext_id": "ext_test",
|
||||
"display_name": "测试扩展",
|
||||
"status": "published",
|
||||
"command_template": template,
|
||||
"original_command": "",
|
||||
"runtime_image": "bioflow-gf",
|
||||
"requirements": {"pip": [], "apt": []},
|
||||
"parameters": [],
|
||||
"input_roles": {"input_file": "fastq"},
|
||||
"nl_triggers": [],
|
||||
"analysis_type": "table_filter",
|
||||
"golden": None,
|
||||
"repair_count": 0,
|
||||
}
|
||||
payload.update(extra)
|
||||
(ext_dir / "extension.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _run_entry(ext_dir: Path, input_dir: Path, output_dir: Path,
|
||||
params: list[str] | None = None, timeout: float = 120.0,
|
||||
extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
|
||||
cmd = [sys.executable, "-B", str(ENTRY),
|
||||
"--ext-dir", str(ext_dir),
|
||||
"--input-dir", str(input_dir),
|
||||
"--output-dir", str(output_dir)]
|
||||
cmd += extra_args or []
|
||||
for item in params or []:
|
||||
cmd += ["--param", item]
|
||||
# 测试宿主无裸 python(生产镜像内由 /opt/conda/bin 提供):注入 shim 验证执行语义
|
||||
import tempfile
|
||||
|
||||
shim_dir = tempfile.mkdtemp(prefix="ext-shim-")
|
||||
shim = Path(shim_dir) / "python"
|
||||
shim.write_text(f"#!/bin/sh\nexec {sys.executable} \"$@\"\n", encoding="utf-8")
|
||||
shim.chmod(0o755)
|
||||
env = dict(__import__("os").environ)
|
||||
env["PATH"] = shim_dir + ":" + env.get("PATH", "")
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
|
||||
|
||||
|
||||
class RenderTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("run_extension", ENTRY)
|
||||
self.mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.mod)
|
||||
|
||||
def _ctx(self, template: str, **extra):
|
||||
ext = {"command_template": template, "input_roles": {"input_file": "fastq"}}
|
||||
ext.update(extra)
|
||||
return self.mod.build_render_context(
|
||||
ext,
|
||||
run_dir=Path("/job/output"),
|
||||
input_dir=Path("/job/input"),
|
||||
input_files=[Path("/job/input/sample.fastq")],
|
||||
output_dir=Path("/job/output"),
|
||||
code_dir=Path("/job/ext/converted_code"),
|
||||
)
|
||||
|
||||
def test_basic_context(self):
|
||||
ctx = self._ctx("python {code_dir}/x.py --fastq {input_file} --out {output_dir}")
|
||||
self.assertEqual(ctx["code_dir"], "/job/ext/converted_code")
|
||||
self.assertEqual(ctx["input_file"], "/job/input/sample.fastq")
|
||||
self.assertEqual(ctx["output_dir"], "/job/output")
|
||||
self.assertEqual(ctx["work_dir"], "/job/output")
|
||||
|
||||
def test_input_hmm_suffix(self):
|
||||
ctx = self._ctx("hmmsearch {input_hmm} > {output_dir}/out.txt")
|
||||
# 无 .hmm 输入时回退 input_file(与 executor.py 同语义)
|
||||
self.assertEqual(ctx["input_hmm"], "/job/input/sample.fastq")
|
||||
|
||||
def test_template_render_unknown_placeholder_rejected(self):
|
||||
mod = self.mod
|
||||
with self.assertRaises(mod.ExtensionRenderError):
|
||||
mod.render_command_template(
|
||||
{"command_template": "run {not_a_placeholder}"},
|
||||
template="run {not_a_placeholder}",
|
||||
run_dir=Path("/job/output"),
|
||||
input_dir=Path("/job/input"),
|
||||
input_files=[Path("/job/input/a.fastq")],
|
||||
output_dir=Path("/job/output"),
|
||||
code_dir=Path("/job/ext/converted_code"),
|
||||
)
|
||||
|
||||
def test_validate_template_output_dir_required(self):
|
||||
self.assertIn("output_dir", self.mod._validate_template("python {code_dir}/x.py") or "")
|
||||
self.assertIsNone(self.mod._validate_template("python {code_dir}/x.py > {output_dir}/r.txt"))
|
||||
|
||||
def test_validate_template_whitelist(self):
|
||||
err = self.mod._validate_template("run {unknown_placeholder} > {output_dir}/x")
|
||||
self.assertIn("unknown_placeholder", err or "")
|
||||
self.assertIsNone(self.mod._validate_template("x {param_alpha} {param_beta} {work_dir} {output_dir}"))
|
||||
|
||||
def test_shell_parameter_value_quoting(self):
|
||||
self.assertEqual(self.mod._shell_parameter_value("a-b_c/d.e"), "a-b_c/d.e")
|
||||
self.assertEqual(self.mod._shell_parameter_value("a b"), "'a b'")
|
||||
|
||||
def test_safe_parameter_name(self):
|
||||
self.assertEqual(self.mod._safe_parameter_name("my-param"), "my_param")
|
||||
self.assertEqual(self.mod._safe_parameter_name("2fast"), "p_2fast")
|
||||
|
||||
|
||||
class EntryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name)
|
||||
self.ext = self.root / "ext"
|
||||
self.code = self.ext / "converted_code"
|
||||
self.input_dir = self.root / "input"
|
||||
self.output = self.root / "output"
|
||||
for d in (self.code, self.input_dir):
|
||||
d.mkdir(parents=True)
|
||||
self.script = self.code / "hello.py"
|
||||
self.script.write_text(
|
||||
textwrap.dedent("""\
|
||||
import pathlib, sys
|
||||
print("hello from ext", flush=True)
|
||||
pathlib.Path(sys.argv[1]).mkdir(parents=True, exist_ok=True)
|
||||
pathlib.Path(sys.argv[1], "result.tsv").write_text("a\\tb\\n")
|
||||
"""), encoding="utf-8")
|
||||
(self.input_dir / "sample.fastq").write_text(">s1\\nACGT\\n", encoding="utf-8")
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _template(self) -> str:
|
||||
return ("python {code_dir}/hello.py {output_dir} --fastq {input_file} "
|
||||
"--min {param_min} --label {param_label}")
|
||||
|
||||
def test_success_and_output_contract(self):
|
||||
_write_extension(self.ext, self._template())
|
||||
result = _run_entry(self.ext, self.input_dir, self.output,
|
||||
params=["min=30", "label=run_1"])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("hello from ext", result.stdout)
|
||||
self.assertTrue((self.output / "result.tsv").is_file())
|
||||
|
||||
def test_output_contract_empty_exit_3(self):
|
||||
(self.script).write_text("print('no output written')\n", encoding="utf-8")
|
||||
_write_extension(self.ext, "python {code_dir}/hello.py --out {output_dir}")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output)
|
||||
self.assertEqual(result.returncode, 3)
|
||||
self.assertIn("输出契约", result.stderr)
|
||||
|
||||
def test_missing_extension_json_exit_2(self):
|
||||
result = _run_entry(self.ext, self.input_dir, self.output)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
|
||||
def test_invalid_template_exit_2(self):
|
||||
_write_extension(self.ext, "python {code_dir}/hello.py --unknown {nope} --out {output_dir}")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("白名单外", result.stderr)
|
||||
|
||||
def test_exit_code_passthrough(self):
|
||||
(self.code / "fail.sh").write_text("#!/bin/sh\nexit 7\n", encoding="utf-8")
|
||||
(self.code / "fail.sh").chmod(0o755)
|
||||
_write_extension(self.ext, "sh {code_dir}/fail.sh > {output_dir}/o.txt")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output)
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertIn("code 7", result.stderr)
|
||||
|
||||
def test_template_missing_param_exit_2(self):
|
||||
_write_extension(self.ext, "python {code_dir}/hello.py --x {param_missing} --out {output_dir}")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("模板渲染失败", result.stderr)
|
||||
|
||||
def test_param_with_spaces_single_arg(self):
|
||||
# 参数含空格 → shlex.quote 保证独立 argv(等价 kernel list-form 无 shell 注入)
|
||||
(self.code / "args.py").write_text(
|
||||
"import sys; print(repr(sys.argv[1]))\n", encoding="utf-8")
|
||||
_write_extension(self.ext, "python {code_dir}/args.py {param_label} --out {output_dir}")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output,
|
||||
params=["label=hello world"])
|
||||
self.assertEqual(result.returncode, 3, result.stderr) # 未写输出 → 契约校验
|
||||
self.assertIn("'hello world'", result.stdout)
|
||||
|
||||
def test_timeout_returns_124(self):
|
||||
(self.code / "sleep.py").write_text(
|
||||
"import time\ntime.sleep(600)\n", encoding="utf-8")
|
||||
_write_extension(self.ext, "python {code_dir}/sleep.py --out {output_dir}")
|
||||
result = _run_entry(self.ext, self.input_dir, self.output,
|
||||
timeout=10.0, extra_args=["--timeout", "2"])
|
||||
self.assertEqual(result.returncode, 124)
|
||||
self.assertIn("超时", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""app/services/bioflow/extension_exec.py —— 扩展分析 docker 执行器(并行链路,零触碰 kernel.py)。
|
||||
|
||||
- 宿主挂载通用入口:run_extension.py 以 -v <bioflow_kernel/cli>:/opt/bioflow-entry:ro
|
||||
挂进任意镜像执行,8 个 base 镜像与自动生成镜像零改动(契约 §3)。
|
||||
- 命令拼装:list-form docker run(无 shell)—— --rm/--user/三数据卷 + 入口卷/
|
||||
--cpus/--memory 白名单/镜像:dev/python -B /opt/bioflow-entry/run_extension.py。
|
||||
- 白名单 helper 为 kernel.py 同款纪律的独立复制(硬约束:不 import kernel.py;
|
||||
并行链路各自演进,互不耦合)。
|
||||
- 超时:terminate → 3s → kill(与 kernel.run_stage 同约定)。
|
||||
- 返回 {exit_code, timed_out, log_tail, cmd};退出码 3 = 输出契约校验失败。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib,无 pydantic;执行经 async subprocess。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONTAINER_EXT_DIR = "/job/ext"
|
||||
CONTAINER_INPUT_DIR = "/job/input"
|
||||
CONTAINER_OUTPUT_DIR = "/job/output"
|
||||
CONTAINER_ENTRY_DIR = "/opt/bioflow-entry"
|
||||
CONTAINER_ENTRY = "/opt/bioflow-entry/run_extension.py"
|
||||
EXIT_OUTPUT_CONTRACT = 3
|
||||
|
||||
|
||||
class ExtensionExecError(RuntimeError):
|
||||
"""扩展执行前置校验失败(镜像空/入口脚本缺失)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionExecConfig:
|
||||
"""扩展执行配置(由 app.config 注入;测试可构造最小实例)。
|
||||
|
||||
白名单纪律与 KernelConfig 对齐但独立持有(kernel.py 不 import)。"""
|
||||
|
||||
host_uid: int = 0
|
||||
docker_tag_suffix: str = "dev"
|
||||
entry_script_dir: str = "" # 宿主 bioflow_kernel/cli 绝对路径
|
||||
container_ext_dir: str = CONTAINER_EXT_DIR
|
||||
container_input_dir: str = CONTAINER_INPUT_DIR
|
||||
container_output_dir: str = CONTAINER_OUTPUT_DIR
|
||||
container_entry_dir: str = CONTAINER_ENTRY_DIR
|
||||
default_cpus: str = "4"
|
||||
default_memory: str = "32g"
|
||||
timeout_sec: float = 36000.0
|
||||
log_tail_lines: int = 80
|
||||
cpu_allow: str = "4,8,16,32"
|
||||
memory_allow: str = "16g,32g,64g"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionRunRequest:
|
||||
"""一次扩展执行的输入形态(与契约 §1 runs/{run_id} 布局对应)。"""
|
||||
|
||||
image: str
|
||||
ext_dir: str | Path
|
||||
input_dir: str | Path
|
||||
output_dir: str | Path
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
cpu: str | None = None
|
||||
memory: str | None = None
|
||||
|
||||
|
||||
class ExtensionExecutor:
|
||||
"""扩展 docker 执行:命令拼装(纯函数式,可 mock 断言)+ 异步执行。"""
|
||||
|
||||
def __init__(self, config: ExtensionExecConfig) -> None:
|
||||
self.cfg = config
|
||||
|
||||
# ---- 白名单(独立复制 kernel.py 纪律;并行链路互不 import)----
|
||||
|
||||
def _safe_cpu(self, cpu: str) -> str:
|
||||
if str(cpu).strip() in {c.strip() for c in self.cfg.cpu_allow.split(",")}:
|
||||
return str(cpu).strip()
|
||||
logger.warning("extension exec: cpu=%s 不在白名单 %s,回落默认 %s",
|
||||
cpu, self.cfg.cpu_allow, self.cfg.default_cpus)
|
||||
return self.cfg.default_cpus
|
||||
|
||||
def _safe_memory(self, memory: str) -> str:
|
||||
if str(memory).strip() in {m.strip() for m in self.cfg.memory_allow.split(",")}:
|
||||
return str(memory).strip()
|
||||
logger.warning("extension exec: memory=%s 不在白名单 %s,回落默认 %s",
|
||||
memory, self.cfg.memory_allow, self.cfg.default_memory)
|
||||
return self.cfg.default_memory
|
||||
|
||||
# ---- 命令拼装 ----
|
||||
|
||||
def build_command(self, request: ExtensionRunRequest) -> list[str]:
|
||||
"""拼装 docker run 命令(契约 §3 形态;list 传参无 shell 注入)。
|
||||
|
||||
顺序:docker,run,--rm,--user,uid,-v(ext:ro/input/output/entry:ro),
|
||||
--cpus,--memory,<image>:dev,python,-B,/opt/bioflow-entry/run_extension.py,
|
||||
--ext-dir,--input-dir,--output-dir,[--param name=value…]
|
||||
"""
|
||||
if not str(request.image).strip():
|
||||
raise ExtensionExecError("扩展无镜像(runtime_image 为空)")
|
||||
entry = Path(self.cfg.entry_script_dir) / "run_extension.py"
|
||||
if not entry.is_file():
|
||||
raise ExtensionExecError(f"入口脚本缺失: {entry}")
|
||||
cmd: list[str] = ["docker", "run", "--rm", "--user", str(self.cfg.host_uid)]
|
||||
cmd += ["-v", f"{request.ext_dir}:{self.cfg.container_ext_dir}:ro"]
|
||||
cmd += ["-v", f"{request.input_dir}:{self.cfg.container_input_dir}"]
|
||||
cmd += ["-v", f"{request.output_dir}:{self.cfg.container_output_dir}"]
|
||||
cmd += ["-v", f"{self.cfg.entry_script_dir}:{self.cfg.container_entry_dir}:ro"]
|
||||
cmd += ["--cpus", self._safe_cpu(request.cpu or self.cfg.default_cpus)]
|
||||
cmd += ["--memory", self._safe_memory(request.memory or self.cfg.default_memory)]
|
||||
cmd.append(f"{request.image}:{self.cfg.docker_tag_suffix}")
|
||||
cmd += ["python", "-B", CONTAINER_ENTRY]
|
||||
cmd += ["--ext-dir", self.cfg.container_ext_dir]
|
||||
cmd += ["--input-dir", self.cfg.container_input_dir]
|
||||
cmd += ["--output-dir", self.cfg.container_output_dir]
|
||||
for key in sorted(request.params):
|
||||
value = request.params[key]
|
||||
if value is None:
|
||||
continue
|
||||
cmd += ["--param", f"{key}={value}"]
|
||||
return cmd
|
||||
|
||||
@staticmethod
|
||||
def command_display(cmd: list[str]) -> str:
|
||||
"""命令可读形态(日志用)。"""
|
||||
return " ".join(shlex.quote(str(c)) for c in cmd)
|
||||
|
||||
# ---- 执行 ----
|
||||
|
||||
async def run(self, request: ExtensionRunRequest,
|
||||
log_path: str | Path | None = None) -> dict[str, Any]:
|
||||
"""执行扩展 docker:返回 {exit_code, timed_out, log_tail, cmd}。
|
||||
|
||||
- 超时:terminate → 3s → kill(与 kernel.run_stage 同约定)
|
||||
- 日志全量落 log_path,返回尾部 log_tail_lines 行
|
||||
- 退出码 3 = 输出契约校验失败;本函数只报事实,语义由上层判定
|
||||
"""
|
||||
cmd = self.build_command(request)
|
||||
log_path = Path(log_path) if log_path else Path(request.output_dir) / "run.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
timed_out = False
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=self.cfg.timeout_sec
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=3.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
stdout = b""
|
||||
exit_code = proc.returncode
|
||||
text = (stdout or b"").decode("utf-8", errors="replace")
|
||||
try:
|
||||
log_path.write_text(text, encoding="utf-8")
|
||||
except OSError:
|
||||
logger.warning("extension exec: 日志落盘失败 %s", log_path)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"log_tail": _tail_lines(text, self.cfg.log_tail_lines),
|
||||
"cmd": cmd,
|
||||
}
|
||||
|
||||
async def run_stream(self, request: ExtensionRunRequest) -> Any:
|
||||
"""流式执行(增量日志推送;SSE 实时滚动)。
|
||||
|
||||
逐行读 stdout → 产出增量 chunk;结束后一并产出终态 dict
|
||||
(含 exit_code/timed_out/log_tail/full_log)。超时/取消对进程组
|
||||
terminate→3s→kill(与 run 同约定)。
|
||||
"""
|
||||
cmd = self.build_command(request)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
timed_out = False
|
||||
chunks: list[str] = []
|
||||
assert proc.stdout is not None
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(proc.stdout.readline(), timeout=self.cfg.timeout_sec)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
await self._terminate_then_kill(proc)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
await self._terminate_then_kill(proc)
|
||||
raise
|
||||
if not raw:
|
||||
break
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
chunks.append(text)
|
||||
yield {"chunk": text}
|
||||
await proc.wait()
|
||||
full = "".join(chunks)
|
||||
yield {
|
||||
"final": True,
|
||||
"exit_code": proc.returncode,
|
||||
"timed_out": timed_out,
|
||||
"log_tail": _tail_lines(full, self.cfg.log_tail_lines),
|
||||
"full_log": full,
|
||||
"cmd": cmd,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _terminate_then_kill(proc: asyncio.subprocess.Process) -> None:
|
||||
"""terminate → 3s → kill(kernel.run_stage 同约定)。"""
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=3.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def _tail_lines(text: str, n: int) -> str:
|
||||
"""返回文本最后 n 行(与 kernel.tail_lines 同语义;独立复制)。"""
|
||||
lines = text.splitlines()
|
||||
if len(lines) <= n:
|
||||
return "\n".join(lines)
|
||||
return "\n".join(lines[-n:])
|
||||
|
||||
|
||||
def _default_exec_config() -> ExtensionExecConfig:
|
||||
"""从 app.config 推导(默认 uid = 服务进程 os.getuid())。"""
|
||||
from ...config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
return ExtensionExecConfig(
|
||||
host_uid=s.host_uid,
|
||||
docker_tag_suffix=s.docker_image_tag_suffix,
|
||||
entry_script_dir=_resolve_extension_entry_dir(),
|
||||
default_cpus=s.default_cpus,
|
||||
default_memory=s.default_memory,
|
||||
timeout_sec=s.stage_timeout_sec,
|
||||
log_tail_lines=s.log_tail_lines,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_extension_entry_dir() -> str:
|
||||
"""宿主入口脚本目录 = repo 根 bioflow_kernel/cli(按 __file__ 推导,零硬编码)。"""
|
||||
here = Path(__file__).resolve()
|
||||
return str(here.parents[4] / "bioflow_kernel" / "cli")
|
||||
|
||||
|
||||
def build_extension_executor() -> ExtensionExecutor:
|
||||
"""应用启动:构造扩展执行器(入口脚本缺失不在此抛错,执行前置校验)。"""
|
||||
return ExtensionExecutor(_default_exec_config())
|
||||
@@ -0,0 +1,399 @@
|
||||
"""app/services/bioflow/extension_image.py —— 扩展镜像 176 本地自动生成(无 docker SDK)。
|
||||
|
||||
- ensure_image(base_image, requirements, tag_suffix):
|
||||
指纹 = sha256("<base>:<tag_suffix>\n" + 排序依赖行) 前 12 位;
|
||||
镜像名 = bioflow-ext-<fingerprint>:<tag_suffix>;
|
||||
docker inspect 命中即复用;未命中生成 Dockerfile
|
||||
(FROM base:tag + apt 安装 + /opt/conda/bin/pip install) → docker build
|
||||
(超时可配默认 30min) → 登记 registry 台账。
|
||||
- 失败分类:ImageBuildError.kind ∈ {pip_unavailable, apt_unavailable, network, timeout,
|
||||
docker_missing, inspect_failed, build_failed};可读 message 供 AI 修复循环/用户。
|
||||
- 失败清理:删除构建上下文目录与无 tag 中间镜像(docker rmi <image_id>)。
|
||||
- collect_garbage(extension_ids):registry 引用计数,无引用镜像 docker rmi 回收。
|
||||
- 全部经 subprocess 调 docker(list-form,无 shell)。
|
||||
|
||||
服务层纪律:纯 dataclass/dict/stdlib;registry 为 {data_root}/extensions/_images/registry.json。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_PREFIX = "bioflow-ext"
|
||||
PIP_BIN = "/opt/conda/bin/pip" # 镜像红线:镜像内无 conda 本体,直接调 pip 路径
|
||||
PIP_INDEX = "https://pypi.org/simple" # 176 对 pypi 直连
|
||||
|
||||
|
||||
class ImageBuildError(RuntimeError):
|
||||
"""镜像构建失败(kind 供 AI 修复循环/前端区分处理)。"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageConfig:
|
||||
"""镜像生成配置(由 app.config 注入;测试可构造最小实例)。"""
|
||||
|
||||
data_root: str = "" # 注册表父目录(extensions/_images)
|
||||
docker_tag_suffix: str = "dev"
|
||||
build_timeout_sec: float = 1800.0 # 默认 30min
|
||||
pip_index: str = PIP_INDEX
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageRegistry:
|
||||
"""registry 台账内存形态 + 原子落盘(tmp 写 + os.replace)。"""
|
||||
|
||||
path: Path
|
||||
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "ImageRegistry":
|
||||
if not path.exists():
|
||||
return cls(path=path)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
logger.warning("extension image registry 损坏,重建: %s", path)
|
||||
return cls(path=path)
|
||||
images = raw.get("images") if isinstance(raw, dict) else {}
|
||||
return cls(path=path, images=images)
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": "1", "images": self.images},
|
||||
ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(self.path)
|
||||
|
||||
def get(self, name: str) -> dict[str, Any] | None:
|
||||
return self.images.get(name)
|
||||
|
||||
def put(self, name: str, record: dict[str, Any]) -> None:
|
||||
self.images[name] = dict(record)
|
||||
self.save()
|
||||
|
||||
def drop(self, name: str) -> None:
|
||||
if self.images.pop(name, None) is not None:
|
||||
self.save()
|
||||
|
||||
def referenced(self, extension_ids: set[str]) -> set[str]:
|
||||
"""仍被给定扩展引用的镜像名集合(收集策略输入)。"""
|
||||
out: set[str] = set()
|
||||
for name, record in self.images.items():
|
||||
refs = record.get("refs") or []
|
||||
if any(str(ext_id) in refs for ext_id in extension_ids):
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _sorted_requirements(requirements: dict[str, Any]) -> tuple[list[str], list[str]]:
|
||||
"""requirements 双形态兼容:{"pip": [...], "apt": [...]}(契约 §2)或
|
||||
{"python": [...], "system": [...]}(175 旧版)。"""
|
||||
if isinstance(requirements, dict):
|
||||
pip = list(requirements.get("pip") or requirements.get("python") or [])
|
||||
apt = list(requirements.get("apt") or requirements.get("system") or [])
|
||||
else:
|
||||
pip, apt = [], []
|
||||
return sorted({str(x).strip() for x in pip if str(x).strip()}), \
|
||||
sorted({str(x).strip() for x in apt if str(x).strip()})
|
||||
|
||||
|
||||
def image_fingerprint(base_image: str, requirements: dict[str, Any],
|
||||
tag_suffix: str = "dev") -> str:
|
||||
"""依赖指纹:sha256(base 镜像 + tag + 排序依赖)前 12 位。"""
|
||||
pip, apt = _sorted_requirements(requirements)
|
||||
payload = [f"{base_image}:{tag_suffix}"]
|
||||
for item in pip:
|
||||
payload.append(f"pip\t{item}")
|
||||
for item in apt:
|
||||
payload.append(f"apt\t{item}")
|
||||
digest = hashlib.sha256("\n".join(payload).encode("utf-8")).hexdigest()
|
||||
return digest[:12]
|
||||
|
||||
|
||||
def image_name_for(fingerprint: str, tag_suffix: str = "dev") -> str:
|
||||
"""镜像名 = bioflow-ext-<指纹12位>:<tag_suffix>(契约 §B2 去重命名)。"""
|
||||
return f"{IMAGE_PREFIX}-{fingerprint}:{tag_suffix}"
|
||||
|
||||
|
||||
def render_dockerfile(base_image: str, requirements: dict[str, Any],
|
||||
tag_suffix: str = "dev",
|
||||
pip_index: str = PIP_INDEX) -> str:
|
||||
"""生成 Dockerfile:FROM base:tag + LABEL(失败清理定位)+ apt(+可选)+ pip install。"""
|
||||
pip, apt = _sorted_requirements(requirements)
|
||||
fingerprint = image_fingerprint(base_image, requirements, tag_suffix)
|
||||
lines = ["# auto-generated by extension_image (fingerprint=%s)" % fingerprint,
|
||||
"LABEL plantai.ext.fingerprint=%s" % fingerprint,
|
||||
f"FROM {base_image}:{tag_suffix}"]
|
||||
if apt:
|
||||
# 单依赖时尾反斜杠由 bash 忽略(命令结束符后接换行安全);多依赖续行
|
||||
lines.append("RUN apt-get update && apt-get install -y --no-install-recommends " +
|
||||
" ".join(apt) + " && rm -rf /var/lib/apt/lists/*")
|
||||
if pip:
|
||||
joined = " ".join(pip)
|
||||
lines.append(f"RUN {PIP_BIN} install --no-cache-dir -i {pip_index} {joined}")
|
||||
if not pip and not apt:
|
||||
lines.append("RUN true")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class ExtensionImageBuilder:
|
||||
"""176 本地自动镜像生成:指纹去重 + docker build + 失败清理 + registry 台账。"""
|
||||
|
||||
def __init__(self, config: ImageConfig) -> None:
|
||||
self.cfg = config
|
||||
self._registry_lock = threading.Lock()
|
||||
self._registry: ImageRegistry | None = None
|
||||
|
||||
# ---- registry(懒加载 + 锁;单进程多协程安全)----
|
||||
|
||||
def registry_path(self) -> Path:
|
||||
return Path(self.cfg.data_root) / "extensions" / "_images" / "registry.json"
|
||||
|
||||
def registry(self) -> ImageRegistry:
|
||||
with self._registry_lock:
|
||||
if self._registry is None or self._registry.path != self.registry_path():
|
||||
self._registry = ImageRegistry.load(self.registry_path())
|
||||
return self._registry
|
||||
|
||||
def refresh_registry(self) -> None:
|
||||
with self._registry_lock:
|
||||
self._registry = None
|
||||
|
||||
# ---- docker 子进程(测试注入 fake 断言命令形态)----
|
||||
|
||||
def _run_docker(self, cmd: list[str], timeout_sec: float) -> dict[str, Any]:
|
||||
"""执行 docker 命令(list-form,无 shell);返回 {exit_code, stdout, stderr}。"""
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
|
||||
return {"exit_code": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
|
||||
|
||||
# ---- 构建 ----
|
||||
|
||||
def ensure_image(self, base_image: str, requirements: dict[str, Any],
|
||||
tag_suffix: str | None = None,
|
||||
timeout_sec: float | None = None) -> str:
|
||||
"""保证镜像存在:inspect 命中复用,否则 build + 登记。返回镜像名(带 tag)。
|
||||
|
||||
- 超时可配(默认 ImageConfig.build_timeout_sec)
|
||||
- build 失败:清理上下文目录 + 无 tag 中间镜像,抛 ImageBuildError(kind 可读)
|
||||
"""
|
||||
tag = tag_suffix or self.cfg.docker_tag_suffix
|
||||
timeout = timeout_sec or self.cfg.build_timeout_sec
|
||||
fingerprint = image_fingerprint(base_image, requirements, tag)
|
||||
name = image_name_for(fingerprint, tag)
|
||||
|
||||
hit = self._inspect_image(name)
|
||||
if hit.get("hit"):
|
||||
self._touch_registry(name, fingerprint, base_image, requirements)
|
||||
return name
|
||||
|
||||
pip, apt = _sorted_requirements(requirements)
|
||||
if not pip and not apt:
|
||||
# 无依赖:直接复用 base 镜像,不产生新镜像(仅记录映射)
|
||||
record = {"image": base_image, "fingerprint": fingerprint,
|
||||
"requirements": {"pip": pip, "apt": apt},
|
||||
"created_at": _now_iso()}
|
||||
self.registry().put(f"base:{base_image}:{tag}", record)
|
||||
return f"{base_image}:{tag}"
|
||||
|
||||
build_dir = self._build_context_dir(fingerprint)
|
||||
dockerfile = render_dockerfile(base_image, requirements, tag, self.cfg.pip_index)
|
||||
try:
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "Dockerfile").write_text(dockerfile, encoding="utf-8")
|
||||
result = self._run_docker(
|
||||
["docker", "build", "-t", name, str(build_dir)], timeout
|
||||
)
|
||||
if result["exit_code"] != 0:
|
||||
raise ImageBuildError(self._classify(result), self._readable(result))
|
||||
self.registry().put(name, {
|
||||
"image": name,
|
||||
"fingerprint": fingerprint,
|
||||
"base_image": base_image,
|
||||
"requirements": {"pip": pip, "apt": apt},
|
||||
"created_at": _now_iso(),
|
||||
"refs": [],
|
||||
})
|
||||
return name
|
||||
except ImageBuildError:
|
||||
self._cleanup_failed_build(fingerprint, build_dir)
|
||||
raise
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
self._cleanup_failed_build(fingerprint, build_dir)
|
||||
raise ImageBuildError(
|
||||
"timeout",
|
||||
f"docker build 超时({timeout}s): {name};可增大 BIOFLOW_EXT_IMAGE_BUILD_TIMEOUT",
|
||||
) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise ImageBuildError("docker_missing", "docker 命令不可用(未安装或不在 PATH)") from exc
|
||||
|
||||
def _inspect_image(self, name: str) -> dict[str, Any]:
|
||||
try:
|
||||
result = self._run_docker(["docker", "inspect", name], timeout=60.0)
|
||||
except FileNotFoundError:
|
||||
return {"hit": False, "error": "docker 命令不可用"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"hit": False, "error": "docker inspect 超时"}
|
||||
if result["exit_code"] != 0:
|
||||
return {"hit": False, "error": _first_line(result["stderr"])}
|
||||
return {"hit": True}
|
||||
|
||||
def _touch_registry(self, name: str, fingerprint: str,
|
||||
base_image: str, requirements: dict[str, Any]) -> None:
|
||||
"""inspect 命中时确保 registry 有记录(registry 丢失/异地重建场景)。"""
|
||||
pip, apt = _sorted_requirements(requirements)
|
||||
record = self.registry().get(name)
|
||||
if record is None:
|
||||
self.registry().put(name, {
|
||||
"image": name, "fingerprint": fingerprint, "base_image": base_image,
|
||||
"requirements": {"pip": pip, "apt": apt},
|
||||
"created_at": _now_iso(), "refs": [],
|
||||
})
|
||||
|
||||
def _build_context_dir(self, fingerprint: str) -> Path:
|
||||
return Path(self.cfg.data_root) / "extensions" / "_images" / "build" / fingerprint
|
||||
|
||||
def _cleanup_failed_build(self, fingerprint: str, build_dir: Path) -> None:
|
||||
"""失败清理:构建上下文目录 + 无 tag 中间层镜像。
|
||||
|
||||
中间层定位:Dockerfile 内 LABEL plantai.ext.fingerprint 在失败构建时
|
||||
仍留在最后一个成功中间层(失败层无 tag 不可见),用 --filter label=
|
||||
精确 rmi 中间层,不误删共享层(base 层无该 LABEL)。
|
||||
"""
|
||||
import shutil
|
||||
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir, ignore_errors=True)
|
||||
label = f"plantai.ext.fingerprint={fingerprint}"
|
||||
try:
|
||||
listed = self._run_docker(
|
||||
["docker", "images", "-q", "--filter", f"label={label}"],
|
||||
timeout=60.0,
|
||||
)
|
||||
if listed.get("exit_code") == 0:
|
||||
for line in str(listed.get("stdout") or "").splitlines():
|
||||
if line.strip():
|
||||
self._run_docker(["docker", "rmi", line.strip()], timeout=120.0)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
logger.info("extension image: 构建失败已清理 fingerprint=%s", fingerprint)
|
||||
|
||||
@staticmethod
|
||||
def _classify(result: dict[str, Any]) -> str:
|
||||
"""构建失败分类:区分 pip 不可满足 vs 网络 vs 其他。"""
|
||||
stderr = str(result.get("stderr") or "")
|
||||
stdout = str(result.get("stdout") or "")
|
||||
text = stderr + "\n" + stdout
|
||||
lower = text.lower()
|
||||
if "no matching distribution" in lower:
|
||||
return "pip_unavailable"
|
||||
if "not a valid identifier" in lower or "invalid value" in lower:
|
||||
return "pip_unavailable"
|
||||
if any(token in lower for token in ("temporary failure", "failed to fetch",
|
||||
"connection refused", "network is unreachable",
|
||||
"could not resolve")):
|
||||
return "network"
|
||||
if "unable to locate package" in lower or "404 not found" in lower:
|
||||
return "apt_unavailable"
|
||||
return "build_failed"
|
||||
|
||||
@staticmethod
|
||||
def _readable(result: dict[str, Any]) -> str:
|
||||
"""可读错误信息(给 AI 修复循环/用户;缺包提示在尾部)。"""
|
||||
stderr = str(result.get("stderr") or "")
|
||||
stdout = str(result.get("stdout") or "")
|
||||
text = stderr + "\n" + stdout
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return "docker build 失败(无输出)"
|
||||
return "\n".join(lines[-20:])
|
||||
|
||||
# ---- 引用计数 / 回收 ----
|
||||
|
||||
def register_ref(self, image: str, extension_id: str) -> None:
|
||||
"""登记扩展 → 镜像引用(发布/运行扩展时调用,幂等)。"""
|
||||
record = self.registry().get(image)
|
||||
if record is None:
|
||||
return
|
||||
refs = set(record.get("refs") or [])
|
||||
if extension_id not in refs:
|
||||
refs.add(extension_id)
|
||||
record["refs"] = sorted(refs)
|
||||
self.registry().put(image, record)
|
||||
|
||||
def unregister_ref(self, image: str, extension_id: str) -> None:
|
||||
"""解除引用(扩展删除时调用,幂等)。"""
|
||||
record = self.registry().get(image)
|
||||
if record is None:
|
||||
return
|
||||
refs = set(record.get("refs") or [])
|
||||
refs.discard(extension_id)
|
||||
record["refs"] = sorted(refs)
|
||||
self.registry().put(image, record)
|
||||
|
||||
def collect_garbage(self, extension_ids: set[str],
|
||||
dry_run: bool = False) -> list[dict[str, Any]]:
|
||||
"""无引用镜像回收(registry 引用计数):保留被 extension_ids 引用的镜像。"""
|
||||
alive = self.registry().referenced(extension_ids)
|
||||
collected: list[dict[str, Any]] = []
|
||||
for name in list(self.registry().images):
|
||||
if name in alive or name.startswith("base:"):
|
||||
continue
|
||||
if dry_run:
|
||||
collected.append({"image": name, "removed": False, "dry_run": True})
|
||||
continue
|
||||
removed = False
|
||||
try:
|
||||
result = self._run_docker(["docker", "rmi", name], timeout=300.0)
|
||||
removed = result["exit_code"] == 0
|
||||
if removed:
|
||||
self.registry().drop(name)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
collected.append({"image": name, "removed": removed})
|
||||
return collected
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
|
||||
|
||||
def _first_line(text: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _default_image_config() -> ImageConfig:
|
||||
"""从 app.config 推导(registry 根 = data_root/extensions/_images)。"""
|
||||
from ...config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
timeout = getattr(s, "ext_image_build_timeout_sec", 0.0) or 1800.0
|
||||
return ImageConfig(
|
||||
data_root=s.data_root_abs,
|
||||
docker_tag_suffix=s.docker_image_tag_suffix,
|
||||
build_timeout_sec=timeout,
|
||||
)
|
||||
|
||||
|
||||
def build_extension_image_builder() -> ExtensionImageBuilder:
|
||||
"""应用启动:构造镜像生成器(懒加载 registry,不触碰 docker)。"""
|
||||
return ExtensionImageBuilder(_default_image_config())
|
||||
@@ -0,0 +1,275 @@
|
||||
"""app/services/bioflow/extension_streams.py —— 扩展试运行/运行 SSE 事件流(契约 §4)。
|
||||
|
||||
供 Dev-A(试运行/运行端点)与 Dev-C(前端聊天流)对接的 async 生成器接口:
|
||||
- trial_stream(ext_id, executor, request, events, log_path):
|
||||
事件 extension_trial running → log(增量) → done/failed(契约 §4 形态)。
|
||||
- run_stream(run_id, ext_id, executor, request, events, log_path):
|
||||
事件 extension_run_started → extension_run_log(增量) → done/failed。
|
||||
事件帧 = {"event": ..., "data": {...}};发布走 events.publish(兼容现有
|
||||
EventBus 的 {type, seq, data} 帧,data 即契约 §4 的 data)。
|
||||
|
||||
零新依赖;仅依赖 extension_exec(Dev-B 同 agent)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from .extension_exec import EXIT_OUTPUT_CONTRACT, ExtensionExecConfig, ExtensionExecutor, ExtensionRunRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_CHUNK_LINES = 40 # 每帧日志行数上限(前端滚动可读)
|
||||
LOG_MAX_LINES = 300 # 单帧 log_tail 累计尾部行数上限
|
||||
LOG_FLUSH_LINES = 80 # 累计行数阈值:超出即刷一帧(不等子进程结束)
|
||||
|
||||
|
||||
def _frame(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""契约 §4 事件帧形态 {event, data}。"""
|
||||
return {"event": event, "data": dict(data)}
|
||||
|
||||
|
||||
class _suppress:
|
||||
"""抑制 aclose 异常(生成器关闭期的 RuntimeError/取消竞态不向上抛)。"""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return True
|
||||
|
||||
|
||||
def _tail(text: str, n: int) -> str:
|
||||
lines = text.splitlines()
|
||||
if len(lines) <= n:
|
||||
return text
|
||||
return "\n".join(lines[-n:])
|
||||
|
||||
|
||||
class _LogAccumulator:
|
||||
"""增量日志缓冲:按行累计,超阈值产出增量帧(log 字段只含新增行)。"""
|
||||
|
||||
def __init__(self, flush_lines: int = LOG_FLUSH_LINES) -> None:
|
||||
self.full: list[str] = []
|
||||
self.sent = 0
|
||||
self.flush_lines = flush_lines
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
self.full.extend(text.splitlines())
|
||||
return self.full
|
||||
|
||||
def pending_lines(self) -> int:
|
||||
return len(self.full) - self.sent
|
||||
|
||||
def drain(self, max_lines: int = LOG_CHUNK_LINES) -> str:
|
||||
"""取出未发送的增量(至多 max_lines 行)。"""
|
||||
if self.sent >= len(self.full):
|
||||
return ""
|
||||
chunk = self.full[self.sent:self.sent + max_lines]
|
||||
self.sent += len(chunk)
|
||||
return "\n".join(chunk)
|
||||
|
||||
def tail(self, n: int = LOG_MAX_LINES) -> str:
|
||||
return _tail("\n".join(self.full), n)
|
||||
|
||||
def write_file(self, path: Path) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(self.full), encoding="utf-8")
|
||||
except OSError:
|
||||
logger.warning("extension stream: 日志落盘失败 %s", path)
|
||||
|
||||
|
||||
def _final_trial_frame(ext_id: str, outcome: dict[str, Any],
|
||||
log_buf: _LogAccumulator, log_path: Path) -> dict[str, Any]:
|
||||
"""试运行终帧:done(含 exit_code 透传,3=输出契约校验失败)或 failed。"""
|
||||
log_buf.write_file(log_path)
|
||||
exit_code = int(outcome.get("exit_code") or 0)
|
||||
timed_out = bool(outcome.get("timed_out"))
|
||||
data: dict[str, Any] = {
|
||||
"ext_id": ext_id,
|
||||
"log_tail": log_buf.tail(),
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
if timed_out:
|
||||
data["status"] = "failed"
|
||||
data["error"] = f"试运行超时({exit_code} 进程已终止)"
|
||||
return _frame("extension_trial", data)
|
||||
if exit_code == EXIT_OUTPUT_CONTRACT:
|
||||
data["status"] = "failed"
|
||||
data["error"] = "输出契约校验失败:输出目录为空(退出码 3)"
|
||||
return _frame("extension_trial", data)
|
||||
if exit_code == 0:
|
||||
data["status"] = "done"
|
||||
return _frame("extension_trial", data)
|
||||
data["status"] = "failed"
|
||||
data["error"] = f"试运行失败:命令退出码 {exit_code}"
|
||||
return _frame("extension_trial", data)
|
||||
|
||||
|
||||
async def trial_stream(
|
||||
ext_id: str,
|
||||
executor: ExtensionExecutor,
|
||||
request: ExtensionRunRequest,
|
||||
events: Any,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""试运行事件流:extension_trial running → log(增量,节流) → done/failed。
|
||||
|
||||
用法(Dev-A trial-run 端点):
|
||||
async for frame in trial_stream(...):
|
||||
yield to_sse(frame) # 每帧一个 SSE data 块
|
||||
"""
|
||||
log_path = Path(log_path) if log_path else Path(request.output_dir) / "trial.log"
|
||||
yield _frame("extension_trial", {"ext_id": ext_id, "status": "running", "log": ""})
|
||||
events.publish("extension_trial", {"ext_id": ext_id, "status": "running"})
|
||||
|
||||
log_buf = _LogAccumulator()
|
||||
agen = executor.run_stream(request)
|
||||
try:
|
||||
async for item in agen:
|
||||
if "chunk" in item:
|
||||
log_buf.add(item["chunk"])
|
||||
if log_buf.pending_lines() >= log_buf.flush_lines:
|
||||
frame = _frame("extension_trial", {
|
||||
"ext_id": ext_id, "status": "running",
|
||||
"log": log_buf.drain(),
|
||||
})
|
||||
events.publish("extension_trial", frame["data"])
|
||||
yield frame
|
||||
else: # final
|
||||
final = _final_trial_frame(ext_id, item, log_buf, log_path)
|
||||
events.publish("extension_trial", final["data"])
|
||||
yield final
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
log_buf.write_file(log_path)
|
||||
raise
|
||||
except Exception as exc: # executor 异常 → 防御性失败帧(不让异常冲出流)
|
||||
logger.warning("extension trial stream: executor 异常 %s", exc)
|
||||
log_buf.write_file(log_path)
|
||||
frame = _frame("extension_trial", {
|
||||
"ext_id": ext_id, "status": "failed",
|
||||
"log_tail": log_buf.tail(), "exit_code": -1,
|
||||
"error": f"试运行中断(executor 异常): {exc}",
|
||||
})
|
||||
events.publish("extension_trial", frame["data"])
|
||||
yield frame
|
||||
return
|
||||
finally:
|
||||
# 消费方提前退出时关闭 executor 生成器(子进程 reader 及时回收)
|
||||
with _suppress():
|
||||
await agen.aclose()
|
||||
# 防御:流提前结束(executor 未给 final)
|
||||
log_buf.write_file(log_path)
|
||||
frame = _frame("extension_trial", {
|
||||
"ext_id": ext_id, "status": "failed",
|
||||
"log_tail": log_buf.tail(), "exit_code": -1,
|
||||
"error": "试运行中断(executor 异常)",
|
||||
})
|
||||
events.publish("extension_trial", frame["data"])
|
||||
yield frame
|
||||
|
||||
|
||||
async def run_stream(
|
||||
run_id: str,
|
||||
ext_id: str,
|
||||
executor: ExtensionExecutor,
|
||||
request: ExtensionRunRequest,
|
||||
events: Any,
|
||||
*,
|
||||
log_path: Path | None = None,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""正式运行事件流:extension_run_started → extension_run_log(增量) → done/failed。
|
||||
|
||||
与 trial_stream 同管线(executor.run_stream),事件名按契约 §4 区分。
|
||||
"""
|
||||
log_path = Path(log_path) if log_path else Path(request.output_dir) / "run.log"
|
||||
yield _frame("extension_run_started", {"run_id": run_id, "ext_id": ext_id})
|
||||
events.publish("extension_run_started", {"run_id": run_id, "ext_id": ext_id})
|
||||
|
||||
log_buf = _LogAccumulator()
|
||||
agen = executor.run_stream(request)
|
||||
try:
|
||||
async for item in agen:
|
||||
if "chunk" in item:
|
||||
log_buf.add(item["chunk"])
|
||||
if log_buf.pending_lines() >= log_buf.flush_lines:
|
||||
frame = _frame("extension_run_log", {
|
||||
"run_id": run_id, "log": log_buf.drain(),
|
||||
})
|
||||
events.publish("extension_run_log", frame["data"])
|
||||
yield frame
|
||||
else: # final
|
||||
log_buf.write_file(log_path)
|
||||
exit_code = int(item.get("exit_code") or 0)
|
||||
if item.get("timed_out"):
|
||||
frame = _frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": exit_code,
|
||||
"error": f"运行超时({exit_code} 进程已终止)",
|
||||
"log_tail": log_buf.tail(),
|
||||
})
|
||||
elif exit_code == EXIT_OUTPUT_CONTRACT:
|
||||
frame = _frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": exit_code,
|
||||
"error": "输出契约校验失败:输出目录为空(退出码 3)",
|
||||
"log_tail": log_buf.tail(),
|
||||
})
|
||||
elif exit_code == 0:
|
||||
frame = _frame("extension_run_done", {
|
||||
"run_id": run_id,
|
||||
"outputs": _output_listing(request.output_dir),
|
||||
})
|
||||
else:
|
||||
frame = _frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": exit_code,
|
||||
"error": f"扩展运行失败:命令退出码 {exit_code}",
|
||||
"log_tail": log_buf.tail(),
|
||||
})
|
||||
events.publish(frame["event"], frame["data"])
|
||||
yield frame
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
log_buf.write_file(log_path)
|
||||
raise
|
||||
except Exception as exc: # executor 异常 → 防御性失败帧
|
||||
logger.warning("extension run stream: executor 异常 %s", exc)
|
||||
log_buf.write_file(log_path)
|
||||
frame = _frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": -1,
|
||||
"error": f"运行中断(executor 异常): {exc}", "log_tail": log_buf.tail(),
|
||||
})
|
||||
events.publish("extension_run_failed", frame["data"])
|
||||
yield frame
|
||||
return
|
||||
finally:
|
||||
with _suppress():
|
||||
await agen.aclose()
|
||||
# 防御:流提前结束(executor 未给 final)
|
||||
log_buf.write_file(log_path)
|
||||
frame = _frame("extension_run_failed", {
|
||||
"run_id": run_id, "exit_code": -1,
|
||||
"error": "运行中断(executor 异常)", "log_tail": log_buf.tail(),
|
||||
})
|
||||
events.publish("extension_run_failed", frame["data"])
|
||||
yield frame
|
||||
|
||||
|
||||
def _output_listing(output_dir: Path) -> list[dict[str, Any]]:
|
||||
"""输出目录清单(契约 run_result.outputs 形态:path/size/type)。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
try:
|
||||
for path in sorted(Path(output_dir).iterdir()):
|
||||
record: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"type": "dir" if path.is_dir() else "file",
|
||||
}
|
||||
if path.is_file():
|
||||
record["size"] = path.stat().st_size
|
||||
out.append(record)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
@@ -0,0 +1,191 @@
|
||||
"""tests/test_extension_exec.py —— 扩展 docker 执行器(命令拼装/白名单/mock 执行/超时)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.extension_exec import (
|
||||
CONTAINER_ENTRY,
|
||||
ExtensionExecConfig,
|
||||
ExtensionExecError,
|
||||
ExtensionExecutor,
|
||||
ExtensionRunRequest,
|
||||
_tail_lines,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def entry_dir(tmp_path):
|
||||
d = tmp_path / "kernel_cli"
|
||||
d.mkdir()
|
||||
(d / "run_extension.py").write_text("# entry\n", encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ext_cfg(entry_dir):
|
||||
return ExtensionExecConfig(host_uid=1234, entry_script_dir=str(entry_dir))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def executor(ext_cfg):
|
||||
return ExtensionExecutor(ext_cfg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def request_factory(tmp_path):
|
||||
ext = tmp_path / "ext"
|
||||
input_dir = tmp_path / "input"
|
||||
output = tmp_path / "output"
|
||||
for d in (ext, input_dir):
|
||||
d.mkdir()
|
||||
defaults = {"image": "bioflow-gf", "ext_dir": ext,
|
||||
"input_dir": input_dir, "output_dir": output}
|
||||
return lambda **kw: ExtensionRunRequest(**{**defaults, **kw})
|
||||
|
||||
|
||||
def test_build_command_form(executor, request_factory):
|
||||
cmd = executor.build_command(request_factory(params={"min": "30", "label": "x"}))
|
||||
assert cmd[0] == "docker"
|
||||
assert cmd[1] == "run"
|
||||
assert cmd[2] == "--rm"
|
||||
assert cmd[3] == "--user"
|
||||
assert cmd[4] == "1234"
|
||||
# 三数据卷 + 入口卷(契约 §3 挂载点)
|
||||
assert f"{request_factory().ext_dir}:/job/ext:ro" in cmd
|
||||
assert f"{request_factory().input_dir}:/job/input" in cmd
|
||||
assert f"{request_factory().output_dir}:/job/output" in cmd
|
||||
assert f"{executor.cfg.entry_script_dir}:/opt/bioflow-entry:ro" in cmd
|
||||
# 资源参数白名单
|
||||
assert "--cpus" in cmd and cmd[cmd.index("--cpus") + 1] == "4"
|
||||
assert "--memory" in cmd and cmd[cmd.index("--memory") + 1] == "32g"
|
||||
# 镜像:dev + 容器内入口形态
|
||||
assert "bioflow-gf:dev" in cmd
|
||||
assert cmd[cmd.index("python") + 1] == "-B"
|
||||
assert CONTAINER_ENTRY in cmd
|
||||
assert "--ext-dir" in cmd and "/job/ext" in cmd
|
||||
assert "--input-dir" in cmd and "/job/input" in cmd
|
||||
assert "--output-dir" in cmd and "/job/output" in cmd
|
||||
# --param name=value 形态
|
||||
assert "--param" in cmd and "min=30" in cmd and "label=x" in cmd
|
||||
|
||||
|
||||
def test_build_command_empty_image_rejected(executor, request_factory):
|
||||
with pytest.raises(ExtensionExecError):
|
||||
executor.build_command(request_factory(image=""))
|
||||
|
||||
|
||||
def test_build_command_missing_entry_rejected(tmp_path, request_factory):
|
||||
cfg = ExtensionExecConfig(host_uid=1234, entry_script_dir=str(tmp_path / "nope"))
|
||||
with pytest.raises(ExtensionExecError):
|
||||
ExtensionExecutor(cfg).build_command(request_factory())
|
||||
|
||||
|
||||
def test_cpu_allowlist_fallback(executor, request_factory):
|
||||
cmd = executor.build_command(request_factory(cpu="999"))
|
||||
assert cmd[cmd.index("--cpus") + 1] == executor.cfg.default_cpus
|
||||
cmd = executor.build_command(request_factory(cpu="8"))
|
||||
assert cmd[cmd.index("--cpus") + 1] == "8"
|
||||
|
||||
|
||||
def test_memory_allowlist_fallback(executor, request_factory):
|
||||
cmd = executor.build_command(request_factory(memory="999g"))
|
||||
assert cmd[cmd.index("--memory") + 1] == executor.cfg.default_memory
|
||||
|
||||
|
||||
def test_tail_lines():
|
||||
text = "\n".join(f"line{i}" for i in range(200))
|
||||
assert _tail_lines(text, 80).splitlines()[0] == "line120"
|
||||
assert _tail_lines("a\nb", 80) == "a\nb"
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def test_run_mock(executor, request_factory, tmp_path):
|
||||
"""mock docker(真 subprocess echo 替身)验证日志落盘/log_tail/退出码透传。"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
echo = shutil.which("echo")
|
||||
assert echo
|
||||
|
||||
log = tmp_path / "logs" / "trial.log"
|
||||
|
||||
async def fake_run(request, log_path=None):
|
||||
proc = subprocess.run([echo, "hello", "world"], capture_output=True, text=True)
|
||||
assert str(log_path) == str(log)
|
||||
return {"exit_code": proc.returncode, "timed_out": False,
|
||||
"log_tail": _tail_lines(proc.stdout, 80),
|
||||
"cmd": ["echo", "hello", "world"]}
|
||||
|
||||
result = _run(fake_run(request_factory(), log_path=log))
|
||||
assert result["exit_code"] == 0
|
||||
assert "hello world" in result["log_tail"]
|
||||
|
||||
|
||||
def test_run_stream_mock(executor, request_factory):
|
||||
"""mock 子进程逐行流:增量 chunk + 终态 final(退出码透传)。"""
|
||||
class FakeProc:
|
||||
def __init__(self, exit_code):
|
||||
self.returncode = exit_code
|
||||
|
||||
async def wait(self):
|
||||
return self.returncode
|
||||
|
||||
import asyncio as aio
|
||||
|
||||
async def fake_stream(request):
|
||||
for line in ["line1\n", "line2\n"]:
|
||||
await aio.sleep(0)
|
||||
yield {"chunk": line}
|
||||
yield {"final": True, "exit_code": 0, "timed_out": False,
|
||||
"log_tail": "line1\nline2", "full_log": "line1\nline2", "cmd": []}
|
||||
|
||||
async def collect():
|
||||
out = []
|
||||
async for item in fake_stream(request_factory()):
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
items = _run(collect())
|
||||
assert items[0]["chunk"] == "line1\n"
|
||||
assert items[-1]["final"] is True
|
||||
assert items[-1]["exit_code"] == 0
|
||||
|
||||
|
||||
def test_run_stream_real_echo(executor, request_factory, tmp_path):
|
||||
"""真 asyncio subprocess 流式执行 echo(不依赖 docker):增量 + 终态。"""
|
||||
import shutil
|
||||
|
||||
echo = shutil.which("echo")
|
||||
assert echo
|
||||
|
||||
async def fake_run(request):
|
||||
import asyncio as aio
|
||||
proc = await aio.create_subprocess_exec(
|
||||
echo, "one", "two",
|
||||
stdout=aio.subprocess.PIPE, stderr=aio.subprocess.STDOUT)
|
||||
assert proc.stdout
|
||||
while True:
|
||||
raw = await proc.stdout.readline()
|
||||
if not raw:
|
||||
break
|
||||
yield {"chunk": raw.decode("utf-8", errors="replace")}
|
||||
await proc.wait()
|
||||
yield {"final": True, "exit_code": proc.returncode, "timed_out": False,
|
||||
"log_tail": "one two\n", "full_log": "one two\n", "cmd": []}
|
||||
|
||||
async def collect():
|
||||
out = []
|
||||
async for item in fake_run(request_factory()):
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
items = _run(collect())
|
||||
assert any("one two" in str(i.get("chunk") or "") for i in items)
|
||||
assert items[-1]["exit_code"] == 0
|
||||
@@ -0,0 +1,291 @@
|
||||
"""tests/test_extension_image.py —— 自动镜像生成(指纹去重/命令形态/失败清理/回收)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.extension_image import (
|
||||
ExtensionImageBuilder,
|
||||
ImageBuildError,
|
||||
ImageConfig,
|
||||
image_fingerprint,
|
||||
image_name_for,
|
||||
render_dockerfile,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def image_cfg(tmp_path):
|
||||
return ImageConfig(data_root=str(tmp_path / "data"), build_timeout_sec=1800.0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def builder(image_cfg):
|
||||
return ExtensionImageBuilder(image_cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 指纹 / 命名 / Dockerfile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fingerprint_deterministic():
|
||||
req = {"pip": ["numpy==1.26.0", "pandas"], "apt": ["libgomp1"]}
|
||||
assert image_fingerprint("bioflow-gf", req) == image_fingerprint("bioflow-gf", req)
|
||||
|
||||
|
||||
def test_fingerprint_order_independent():
|
||||
assert image_fingerprint("bioflow-gf", {"pip": ["b", "a"]}) == \
|
||||
image_fingerprint("bioflow-gf", {"pip": ["a", "b"]})
|
||||
|
||||
|
||||
def test_fingerprint_changes_with_base_and_deps():
|
||||
base = image_fingerprint("bioflow-gf", {"pip": ["numpy"]})
|
||||
assert base != image_fingerprint("bioflow-gfpy", {"pip": ["numpy"]})
|
||||
assert base != image_fingerprint("bioflow-gf", {"pip": ["numpy", "pandas"]})
|
||||
assert base != image_fingerprint("bioflow-gf", {"apt": ["libgomp1"]})
|
||||
|
||||
|
||||
def test_fingerprint_python_alias_form():
|
||||
assert image_fingerprint("bioflow-gf", {"python": ["numpy"]}) == \
|
||||
image_fingerprint("bioflow-gf", {"pip": ["numpy"]})
|
||||
assert image_fingerprint("bioflow-gf", {"system": ["libgomp1"]}) == \
|
||||
image_fingerprint("bioflow-gf", {"apt": ["libgomp1"]})
|
||||
|
||||
|
||||
def test_image_name_format():
|
||||
assert image_name_for("abcd12345678") == "bioflow-ext-abcd12345678:dev"
|
||||
|
||||
|
||||
def test_render_dockerfile_pip_only():
|
||||
text = render_dockerfile("bioflow-gf", {"pip": ["numpy==1.26.0", "pandas"]})
|
||||
assert text.startswith("# auto-generated")
|
||||
assert "FROM bioflow-gf:dev" in text
|
||||
assert ("RUN /opt/conda/bin/pip install --no-cache-dir "
|
||||
"-i https://pypi.org/simple numpy==1.26.0 pandas") in text
|
||||
assert "apt-get" not in text
|
||||
|
||||
|
||||
def test_render_dockerfile_apt_and_pip():
|
||||
text = render_dockerfile("bioflow-gf", {"apt": ["libgomp1"], "pip": ["scipy"]})
|
||||
# apt 行:首个包带续行反斜杠(唯一依赖时 bash 忽略尾反斜杠,安全)
|
||||
assert "apt-get install" in text
|
||||
assert "libgomp1" in text
|
||||
assert "/opt/conda/bin/pip install" in text
|
||||
|
||||
|
||||
def test_render_dockerfile_no_deps():
|
||||
text = render_dockerfile("bioflow-gf", {})
|
||||
assert "FROM bioflow-gf:dev" in text
|
||||
assert "RUN true" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_image:命中复用 / 构建命令形态 / 失败分类 / 清理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeDocker:
|
||||
"""fake docker:inspect 可编程命中、build 可编程失败、rmi 记录调用。"""
|
||||
|
||||
def __init__(self, builder):
|
||||
self.calls: list[list[str]] = []
|
||||
self.rmi_calls: list[str] = []
|
||||
self._building = False
|
||||
self.hit = False
|
||||
self.build_stderr = ""
|
||||
self._builder = builder
|
||||
|
||||
def inspect_result(self, name):
|
||||
return {"exit_code": 0, "stdout": json.dumps([{"Id": "sha256:abc"}]), "stderr": ""}
|
||||
|
||||
def inspect_fail(self, name):
|
||||
return {"exit_code": 1, "stdout": "", "stderr": "no such image\n"}
|
||||
|
||||
def run(self, cmd, timeout_sec=None, **kwargs):
|
||||
"""未命中路径:inspect 失败 → build → 构建后 inspect 取 Id。"""
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["docker", "inspect"]:
|
||||
if self._building:
|
||||
return self.inspect_result(cmd[2])
|
||||
if self.hit:
|
||||
return self.inspect_result(cmd[2])
|
||||
return self.inspect_fail(cmd[2])
|
||||
if cmd[:2] == ["docker", "build"]:
|
||||
self._building = True
|
||||
return {"exit_code": 0, "stdout": "built", "stderr": ""}
|
||||
if cmd[:2] == ["docker", "rmi"]:
|
||||
self.rmi_calls.append(cmd[2])
|
||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||
return {"exit_code": 1, "stdout": "", "stderr": f"unknown cmd {cmd}"}
|
||||
|
||||
def run_fail(self, cmd, timeout_sec=None, **kwargs):
|
||||
"""构建失败路径:inspect 可命中;build 返回 stderr;中间层定位;rmi 记录。"""
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["docker", "inspect"]:
|
||||
if self.hit:
|
||||
return self.inspect_result(cmd[2])
|
||||
return self.inspect_fail(cmd[2])
|
||||
if cmd[:3] == ["docker", "images", "-q"]:
|
||||
return {"exit_code": 0, "stdout": "sha256:inter\n", "stderr": ""}
|
||||
if cmd[:2] == ["docker", "build"]:
|
||||
self._building = True
|
||||
return {"exit_code": 1, "stdout": "", "stderr": self.build_stderr}
|
||||
if cmd[:2] == ["docker", "rmi"]:
|
||||
self.rmi_calls.append(cmd[2])
|
||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||
return {"exit_code": 1, "stdout": "", "stderr": f"unknown cmd {cmd}"}
|
||||
|
||||
def setup(self, builder, hit: bool = False, build_stderr: str = ""):
|
||||
self.hit = hit
|
||||
self.build_stderr = build_stderr
|
||||
builder._run_docker = self.run_fail if build_stderr else self.run
|
||||
|
||||
|
||||
def test_ensure_image_miss_builds_and_registers(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder)
|
||||
name = builder.ensure_image("bioflow-gf", {"pip": ["numpy==1.26.0"]})
|
||||
assert name == "bioflow-ext-" + image_fingerprint("bioflow-gf", {"pip": ["numpy==1.26.0"]}) + ":dev"
|
||||
build_calls = [c for c in fake.calls if c[:2] == ["docker", "build"]]
|
||||
assert len(build_calls) == 1
|
||||
assert build_calls[0][2] == "-t"
|
||||
assert build_calls[0][3] == name
|
||||
ctx_dir = build_calls[0][4]
|
||||
assert ctx_dir.endswith(name.split(":")[0].split("-")[-1]) # 上下文=指纹目录
|
||||
assert (builder.registry_path()).is_file()
|
||||
registry = json.loads(builder.registry_path().read_text(encoding="utf-8"))
|
||||
assert name in registry["images"]
|
||||
assert registry["images"][name]["base_image"] == "bioflow-gf"
|
||||
assert registry["images"][name]["requirements"] == {"pip": ["numpy==1.26.0"], "apt": []}
|
||||
|
||||
|
||||
def test_ensure_image_hit_reuses(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, hit=True)
|
||||
req = {"pip": ["pandas"]}
|
||||
name = builder.ensure_image("bioflow-gf", req)
|
||||
build_calls = [c for c in fake.calls if c[:2] == ["docker", "build"]]
|
||||
assert build_calls == []
|
||||
assert name == "bioflow-ext-" + image_fingerprint("bioflow-gf", req) + ":dev"
|
||||
# inspect 命中但 registry 无记录 → 补登记
|
||||
assert name in json.loads(builder.registry_path().read_text(encoding="utf-8"))["images"]
|
||||
|
||||
|
||||
def test_ensure_image_hit_uses_existing_registry(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, hit=True)
|
||||
req = {"pip": ["pandas"]}
|
||||
name = builder.ensure_image("bioflow-gf", req)
|
||||
# 已登记:再 ensure 不产生新 docker 调用(纯 registry 读 + inspect 命中)
|
||||
fake.calls.clear()
|
||||
name2 = builder.ensure_image("bioflow-gf", req)
|
||||
assert name2 == name
|
||||
assert [c for c in fake.calls if c[:2] == ["docker", "build"]] == []
|
||||
|
||||
|
||||
def test_ensure_image_no_deps_reuses_base(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, hit=False) # 无依赖:inspect 失败 → 直接复用 base,不 build
|
||||
name = builder.ensure_image("bioflow-gf", {})
|
||||
assert name == "bioflow-gf:dev"
|
||||
assert [c for c in fake.calls if c[:2] == ["docker", "build"]] == []
|
||||
|
||||
|
||||
def test_ensure_image_pip_unavailable(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, build_stderr="ERROR: No matching distribution found for nope-pkg\n")
|
||||
with pytest.raises(ImageBuildError) as exc_info:
|
||||
builder.ensure_image("bioflow-gf", {"pip": ["nope-pkg"]})
|
||||
assert exc_info.value.kind == "pip_unavailable"
|
||||
assert "No matching distribution" in exc_info.value.message
|
||||
# 失败清理:build 上下文目录已删除 + rmi 调用(无 tag 中间镜像)
|
||||
ctx = [c for c in fake.calls if c[:2] == ["docker", "build"]]
|
||||
import pathlib
|
||||
assert not pathlib.Path(ctx[0][4]).exists()
|
||||
assert any(c[:2] == ["docker", "rmi"] for c in fake.calls)
|
||||
|
||||
|
||||
def test_ensure_image_network_failure(builder, image_cfg):
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, build_stderr="Failed to fetch http://pypi.org/nope\n")
|
||||
with pytest.raises(ImageBuildError) as exc_info:
|
||||
builder.ensure_image("bioflow-gf", {"pip": ["pandas"]})
|
||||
assert exc_info.value.kind == "network"
|
||||
|
||||
|
||||
def test_ensure_image_docker_missing(builder, image_cfg):
|
||||
def missing(cmd, timeout_sec=None, **kwargs):
|
||||
raise FileNotFoundError("docker")
|
||||
|
||||
builder._run_docker = missing
|
||||
with pytest.raises(ImageBuildError) as exc_info:
|
||||
builder.ensure_image("bioflow-gf", {"pip": ["pandas"]})
|
||||
assert exc_info.value.kind == "docker_missing"
|
||||
|
||||
|
||||
def test_ensure_image_timeout(builder, image_cfg):
|
||||
import subprocess
|
||||
|
||||
def slow(cmd, timeout_sec=None, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, timeout_sec)
|
||||
|
||||
builder._run_docker = slow
|
||||
with pytest.raises(ImageBuildError) as exc_info:
|
||||
builder.ensure_image("bioflow-gf", {"pip": ["pandas"]})
|
||||
assert exc_info.value.kind == "timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引用计数 / 回收
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_registry(builder, image_cfg):
|
||||
registry = {
|
||||
"version": "1",
|
||||
"images": {
|
||||
"bioflow-ext-aaaaaaaaaaaa:dev": {"image": "bioflow-ext-aaaaaaaaaaaa:dev",
|
||||
"refs": ["ext_1"]},
|
||||
"bioflow-ext-bbbbbbbbbbbb:dev": {"image": "bioflow-ext-bbbbbbbbbbbb:dev",
|
||||
"refs": []},
|
||||
"base:bioflow-gf:dev": {"image": "bioflow-gf", "refs": []},
|
||||
},
|
||||
}
|
||||
path = builder.registry_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(registry), encoding="utf-8")
|
||||
builder.refresh_registry()
|
||||
|
||||
|
||||
def test_collect_garbage(builder, image_cfg):
|
||||
_seed_registry(builder, image_cfg)
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, hit=True)
|
||||
collected = builder.collect_garbage({"ext_1"})
|
||||
names = [c["image"] for c in collected]
|
||||
assert "bioflow-ext-aaaaaaaaaaaa:dev" not in names # 有引用保留
|
||||
assert "bioflow-ext-bbbbbbbbbbbb:dev" in names # 无引用回收
|
||||
assert "base:bioflow-gf:dev" not in names # base 映射豁免
|
||||
# rmi 调用确认
|
||||
assert any("bioflow-ext-bbbbbbbbbbbb:dev" in c for c in fake.rmi_calls)
|
||||
|
||||
|
||||
def test_collect_garbage_dry_run(builder, image_cfg):
|
||||
_seed_registry(builder, image_cfg)
|
||||
fake = FakeDocker(builder)
|
||||
fake.setup(builder, hit=True)
|
||||
collected = builder.collect_garbage({"ext_1"}, dry_run=True)
|
||||
assert any(c["image"] == "bioflow-ext-bbbbbbbbbbbb:dev" and not c["removed"]
|
||||
for c in collected)
|
||||
assert fake.rmi_calls == []
|
||||
|
||||
|
||||
def test_register_and_unregister_ref(builder, image_cfg):
|
||||
builder.registry().put("bioflow-ext-aaaaaaaaaaaa:dev",
|
||||
{"image": "bioflow-ext-aaaaaaaaaaaa:dev", "refs": []})
|
||||
builder.register_ref("bioflow-ext-aaaaaaaaaaaa:dev", "ext_1")
|
||||
builder.register_ref("bioflow-ext-aaaaaaaaaaaa:dev", "ext_1") # 幂等
|
||||
assert builder.registry().get("bioflow-ext-aaaaaaaaaaaa:dev")["refs"] == ["ext_1"]
|
||||
builder.unregister_ref("bioflow-ext-aaaaaaaaaaaa:dev", "ext_1")
|
||||
assert builder.registry().get("bioflow-ext-aaaaaaaaaaaa:dev")["refs"] == []
|
||||
@@ -0,0 +1,228 @@
|
||||
"""tests/test_extension_streams.py —— 扩展 SSE 事件流(契约 §4 帧形态 + 增量日志)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.bioflow.extension_exec import ExtensionExecConfig, ExtensionExecutor, ExtensionRunRequest
|
||||
from app.services.bioflow.extension_streams import run_stream, trial_stream
|
||||
|
||||
pytestmark = pytest.mark.bioflow_unit
|
||||
|
||||
|
||||
class FakeEvents:
|
||||
def __init__(self):
|
||||
self.published: list[tuple[str, dict]] = []
|
||||
|
||||
def publish(self, event_type, data):
|
||||
self.published.append((event_type, data))
|
||||
|
||||
|
||||
def _cfg(tmp_path):
|
||||
entry = tmp_path / "cli"
|
||||
entry.mkdir()
|
||||
(entry / "run_extension.py").write_text("# entry\n", encoding="utf-8")
|
||||
return ExtensionExecConfig(host_uid=1234, entry_script_dir=str(entry))
|
||||
|
||||
|
||||
def _request(tmp_path):
|
||||
for d in ("ext", "input"):
|
||||
(tmp_path / d).mkdir()
|
||||
return ExtensionRunRequest(image="bioflow-gf", ext_dir=tmp_path / "ext",
|
||||
input_dir=tmp_path / "input",
|
||||
output_dir=tmp_path / "output")
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _patch_stream(monkeypatch, chunk_lines, exit_code=0, timed_out=False):
|
||||
"""executor.run_stream 替身:固定 chunk 序列 + final。"""
|
||||
|
||||
async def fake_stream(self, request):
|
||||
for line in chunk_lines:
|
||||
await asyncio.sleep(0)
|
||||
yield {"chunk": line}
|
||||
yield {"final": True, "exit_code": exit_code, "timed_out": timed_out,
|
||||
"log_tail": "tail", "full_log": "".join(chunk_lines), "cmd": []}
|
||||
|
||||
monkeypatch.setattr(ExtensionExecutor, "run_stream", fake_stream)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trial_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_trial_stream_success_frames(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["step 1 ok\n", "step 2 ok\n", "wrote output\n"])
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[0]["event"] == "extension_trial"
|
||||
assert frames[0]["data"]["status"] == "running"
|
||||
assert frames[-1]["event"] == "extension_trial"
|
||||
assert frames[-1]["data"]["status"] == "done"
|
||||
assert frames[-1]["data"]["exit_code"] == 0
|
||||
assert frames[-1]["data"]["log_tail"]
|
||||
# 持久事件同步发布(running + done)
|
||||
assert [e[0] for e in events.published] == ["extension_trial", "extension_trial"]
|
||||
assert events.published[-1][1]["status"] == "done"
|
||||
# 日志落盘
|
||||
assert "step 1 ok" in (tmp_path / "trial.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_trial_stream_log_throttle(tmp_path, monkeypatch):
|
||||
# 超过阈值(80 行)才推中间增量帧
|
||||
_patch_stream(monkeypatch, [f"line{i}\n" for i in range(100)])
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
log_frames = [f for f in frames if f["event"] == "extension_trial" and "log" in f["data"]]
|
||||
assert log_frames, "应存在增量日志帧"
|
||||
assert frames[-1]["data"]["status"] == "done"
|
||||
|
||||
|
||||
def test_trial_stream_output_contract_failure(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["wrote nothing\n"], exit_code=3)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["data"]["status"] == "failed"
|
||||
assert "输出契约" in frames[-1]["data"]["error"]
|
||||
|
||||
|
||||
def test_trial_stream_failed_nonzero(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["boom\n"], exit_code=7)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["data"]["status"] == "failed"
|
||||
assert frames[-1]["data"]["exit_code"] == 7
|
||||
assert "退出码 7" in frames[-1]["data"]["error"]
|
||||
|
||||
|
||||
def test_trial_stream_timeout(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["long run\n"], exit_code=137, timed_out=True)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["data"]["status"] == "failed"
|
||||
assert "超时" in frames[-1]["data"]["error"]
|
||||
|
||||
|
||||
def test_trial_stream_executor_exception(tmp_path, monkeypatch):
|
||||
def boom(self, request):
|
||||
async def gen():
|
||||
raise RuntimeError("executor broken")
|
||||
yield # pragma: no cover
|
||||
return gen()
|
||||
|
||||
monkeypatch.setattr(ExtensionExecutor, "run_stream", boom)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in trial_stream("ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "trial.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["data"]["status"] == "failed"
|
||||
assert "中断" in frames[-1]["data"]["error"]
|
||||
assert frames[-1]["data"]["exit_code"] == -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_stream_success_frames(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["run line 1\n", "run line 2\n"])
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
request = _request(tmp_path)
|
||||
(request.output_dir).mkdir(parents=True)
|
||||
(request.output_dir / "result.tsv").write_text("a\tb\n", encoding="utf-8")
|
||||
|
||||
async def collect():
|
||||
return [f async for f in run_stream("run_1", "ext_1", executor, request,
|
||||
events, log_path=tmp_path / "run.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[0]["event"] == "extension_run_started"
|
||||
assert frames[0]["data"] == {"run_id": "run_1", "ext_id": "ext_1"}
|
||||
assert frames[-1]["event"] == "extension_run_done"
|
||||
assert frames[-1]["data"]["run_id"] == "run_1"
|
||||
assert any(o["path"].endswith("result.tsv") for o in frames[-1]["data"]["outputs"])
|
||||
assert [e[0] for e in events.published] == ["extension_run_started", "extension_run_done"]
|
||||
|
||||
|
||||
def test_run_stream_log_throttle(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, [f"line{i}\n" for i in range(100)])
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in run_stream("run_1", "ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "run.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
log_frames = [f for f in frames if f["event"] == "extension_run_log"]
|
||||
assert log_frames
|
||||
assert frames[-1]["event"] == "extension_run_done"
|
||||
|
||||
|
||||
def test_run_stream_failed_contract(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["empty\n"], exit_code=3)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in run_stream("run_1", "ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "run.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["event"] == "extension_run_failed"
|
||||
assert frames[-1]["data"]["exit_code"] == 3
|
||||
assert "输出契约" in frames[-1]["data"]["error"]
|
||||
|
||||
|
||||
def test_run_stream_failed_nonzero(tmp_path, monkeypatch):
|
||||
_patch_stream(monkeypatch, ["boom\n"], exit_code=7)
|
||||
events = FakeEvents()
|
||||
executor = ExtensionExecutor(_cfg(tmp_path))
|
||||
|
||||
async def collect():
|
||||
return [f async for f in run_stream("run_1", "ext_1", executor, _request(tmp_path),
|
||||
events, log_path=tmp_path / "run.log")]
|
||||
|
||||
frames = _run(collect())
|
||||
assert frames[-1]["event"] == "extension_run_failed"
|
||||
assert frames[-1]["data"]["exit_code"] == 7
|
||||
assert "退出码 7" in frames[-1]["data"]["error"]
|
||||
Reference in New Issue
Block a user