Dockerfile 运行阶段补齐 dws_dingtalk_scan(F12),并新增 runtime-scripts 门禁 做脚本依赖闭包正负例;就绪探测每次解析当前端点/绑定/凭据并区分 404、未配置、 不可达(F13);路由写入来源路径与父目录,历史空值仅按同连接同 item 的意图读时 补齐(F14);目标筛选显示短 ID 与当前绑定/历史并提供快捷筛选(F15);刷新终态 移除后台运行、展示失败时间/旧快照/脱敏原因/作业 ID 与重试(F16)。 验证:make verify 17 项全过(identity 本地按设计 skipped);一次性 schema 的 Monitor 目录/路由集成用例通过;8088 部署与真实 UI 验收仍由父会话执行。
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user