#!/usr/bin/env python3 """运行镜像必须带齐脚本闭包:Dockerfile COPY 漏一个模块 ⇒ 线上入口必败。 F12 的真实现象是 live_catalog_refresh 能导入、psql 在、根身份正常, 但 `from dws_dingtalk_scan import walk` 抛 ModuleNotFoundError。只检查主脚本 自己的 import 不够:被依赖的模块也可能再 import 兄弟模块。这里按源码算出 闭包,再与 Dockerfile 实际 COPY 进 /app/scripts 的文件比对。 纯静态检查:不起容器、不连库、不需要凭据。 """ from __future__ import annotations import ast import re import sys import unittest from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SCRIPTS = ROOT / "scripts" APP_SCRIPTS = "/app/scripts" def copied_scripts(dockerfile_text: str) -> set[str]: """Dockerfile 里 COPY 到 /app/scripts 的源文件名(含续行)。""" logical = re.sub(r"\\\r?\n\s*", " ", dockerfile_text) copied: set[str] = set() for line in logical.splitlines(): parts = line.split() targets = [part.rstrip("/") for part in parts] if not parts or parts[0].upper() != "COPY" or APP_SCRIPTS not in targets: continue sources = parts[1:targets.index(APP_SCRIPTS)] for source in sources: name = Path(source).name if name.endswith(".py"): copied.add(name) return copied def local_imports(path: Path, siblings: set[str]) -> set[str]: """该脚本 import 的同目录模块(只认 scripts/ 下真实存在的兄弟文件)。""" tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) found: set[str] = set() for node in ast.walk(tree): names: list[str] = [] if isinstance(node, ast.Import): names = [alias.name for alias in node.names] elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: names = [node.module] for name in names: top = name.split(".")[0] if f"{top}.py" in siblings: found.add(f"{top}.py") return found def dependency_closure(scripts_dir: Path, copied: set[str]) -> set[str]: siblings = {entry.name for entry in scripts_dir.glob("*.py")} needed = {name for name in copied if (scripts_dir / name).is_file()} pending = list(needed) while pending: current = pending.pop() for dependency in local_imports(scripts_dir / current, siblings) - needed: needed.add(dependency) pending.append(dependency) return needed def missing_from_image(scripts_dir: Path, dockerfile: Path) -> list[str]: copied = copied_scripts(dockerfile.read_text(encoding="utf-8")) return sorted(dependency_closure(scripts_dir, copied) - copied) class RuntimeScriptPackagingTests(unittest.TestCase): def test_worker_image_ships_every_local_dependency(self): missing = missing_from_image(SCRIPTS, ROOT / "Dockerfile") # RED 证据:F12 就是这一行缺失(dws_dingtalk_scan.py),不是宿主环境问题。 self.assertEqual(missing, [], f"运行镜像缺少脚本依赖:{missing}") def test_missing_module_negative_fixture_is_rejected(self): """去掉扫描模块的 COPY 行必须被判红——证明这个断言真的会因为漏拷而失败。""" dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") broken = dockerfile.replace(" scripts/dws_dingtalk_scan.py", "") self.assertIn("scripts/live_catalog_refresh.py", broken) self.assertNotIn("dws_dingtalk_scan.py", broken) with TemporaryDockerfile(broken) as path: self.assertEqual(missing_from_image(SCRIPTS, path), ["dws_dingtalk_scan.py"]) def test_third_party_and_runtime_paths_are_not_counted_as_shipped(self): siblings = {entry.name for entry in SCRIPTS.glob("*.py")} imported = local_imports(SCRIPTS / "artifact_fetch.py", siblings) self.assertTrue({"axls_to_xlsx.py", "dws_error_map.py"} <= imported) self.assertNotIn("requests.py", siblings) class TemporaryDockerfile: def __init__(self, content: str): self.content = content self.path = SCRIPTS / ".test-dockerfile-missing-module" def __enter__(self) -> Path: self.path.write_text(self.content, encoding="utf-8") return self.path def __exit__(self, *_exc: object) -> None: self.path.unlink(missing_ok=True) if __name__ == "__main__": unittest.main(verbosity=2)