Files
PlantAI-rag-new/bioflow_service/tests/test_extension_image.py
adminandClaude Fable 5 79b4c7c52c 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>
2026-09-14 13:36:18 +08:00

292 lines
12 KiB
Python

"""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"] == []