- 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>
192 lines
6.2 KiB
Python
192 lines
6.2 KiB
Python
"""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
|