feat: phase 6 webbridge + generic workflow (resource slots, security, bellhop/kraken/ram assets)

这个提交包含在:
2026-01-21 15:47:01 +08:00
父节点 7477acbdfa
当前提交 e30cd6d93f
共修改 5 个文件,包含 734 行新增和 33 行删除
+256 -31
查看文件
@@ -108,6 +108,8 @@ class CppBridge(AlgorithmBridge):
preprocessing: Optional[List[Dict[str, Any]]] = None,
requirements: Optional[Dict[str, Any]] = None,
output_specs: Optional[Dict[str, Any]] = None,
expected_sha256: Optional[str] = None,
isolate: bool = False,
is_core: bool = False):
super().__init__(name, is_core)
self.lib_path = lib_path
@@ -120,26 +122,45 @@ class CppBridge(AlgorithmBridge):
self.algo_type = "cpp"
self._lib = None
self._func = None
self.expected_sha256 = expected_sha256
self.isolate = isolate
def _validate_path(self, path: str):
def _validate_path(self, path: str) -> str:
"""Security Whitelisting: Ensure path is within the project's 'src' or 'data' directory"""
abs_path = os.path.abspath(path)
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # project root
if not abs_path.startswith(os.path.join(base_dir, "src")) and \
not abs_path.startswith(os.path.join(base_dir, "data")):
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # project root
abs_path = os.path.realpath(path if os.path.isabs(path) else os.path.join(base_dir, path))
allowed_dirs = [
os.path.join(base_dir, "src"),
os.path.join(base_dir, "data"),
]
if not any(os.path.commonpath([abs_path, d]) == d for d in allowed_dirs):
raise PermissionError(f"Security Block: Path {path} is outside allowed directories (src/data).")
return abs_path
def _load_lib(self):
"""延迟加载动态库并安全配置 ABI"""
if self._lib is None:
self._validate_path(self.lib_path)
if not os.path.exists(self.lib_path):
raise FileNotFoundError(f"C++ library not found: {self.lib_path}")
abs_lib_path = self._validate_path(self.lib_path)
if not os.path.exists(abs_lib_path):
raise FileNotFoundError(f"C++ library not found: {abs_lib_path}")
if self.expected_sha256:
from src.integrity import ensure_file_sha256
ensure_file_sha256(abs_lib_path, self.expected_sha256, label=f"cpp library {self.name}")
self._lib = ctypes.CDLL(self.lib_path)
self._lib = ctypes.CDLL(abs_lib_path)
self._func = getattr(self._lib, self.function_name)
# Phase 5: ABI Hardening
# 默认签名(统一约定):void (double*, int, double*, int)
self._func.argtypes = [
ctypes.POINTER(ctypes.c_double),
ctypes.c_int,
ctypes.POINTER(ctypes.c_double),
ctypes.c_int,
]
self._func.restype = None
# 如果 requirements 中定义了签名,则应用它
if "argtypes" in self.requirements:
# 简单映射: 目前支持 "double_ptr", "int"
@@ -149,10 +170,55 @@ class CppBridge(AlgorithmBridge):
mapping = {"int": ctypes.c_int, "void": None}
self._func.restype = mapping.get(self.requirements["restype"], None)
def _call_isolated(self, in_data: np.ndarray) -> np.ndarray:
import json
import subprocess
import sys
abs_lib_path = self._validate_path(self.lib_path)
if self.expected_sha256:
from src.integrity import ensure_file_sha256
ensure_file_sha256(abs_lib_path, self.expected_sha256, label=f"cpp library {self.name}")
cfg = {
"lib_path": abs_lib_path,
"function_name": self.function_name,
"expected_sha256": self.expected_sha256,
}
if isinstance(self.requirements, dict):
if isinstance(self.requirements.get("argtypes"), list):
cfg["argtypes"] = self.requirements.get("argtypes")
if isinstance(self.requirements.get("restype"), str):
cfg["restype"] = self.requirements.get("restype")
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
env = os.environ.copy()
env["PYTHONPATH"] = base_dir + os.pathsep + env.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory(prefix="cpp_bridge_") as tmp_dir:
cfg_path = os.path.join(tmp_dir, "config.json")
in_path = os.path.join(tmp_dir, "in.npy")
out_path = os.path.join(tmp_dir, "out.npy")
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False)
np.save(in_path, np.ascontiguousarray(in_data, dtype=np.float64), allow_pickle=False)
cmd = [sys.executable, "-m", "src.cpp_isolated_runner", "--config", cfg_path, "--input", in_path, "--output", out_path]
proc = subprocess.run(cmd, cwd=base_dir, env=env, capture_output=True, text=True)
if proc.returncode != 0:
stderr = (proc.stderr or "").strip()
stdout = (proc.stdout or "").strip()
details = stderr or stdout or f"returncode={proc.returncode}"
raise RuntimeError(f"isolated C++ call failed: {details}")
if not os.path.exists(out_path):
raise RuntimeError("isolated C++ call produced no output file")
return np.load(out_path, allow_pickle=False)
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
try:
water_data.add_log(self.name, "Start C++ Execution")
self._load_lib()
# 1. 准备输入
# 为了简化 Phase 3.3,我们假设目前只支持 1进1出 (double array)
@@ -165,18 +231,22 @@ class CppBridge(AlgorithmBridge):
# 确保数据连续且为 float64 (double)
in_data = np.ascontiguousarray(in_data, dtype=np.float64)
in_ptr = in_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
in_len = ctypes.c_int(in_data.size)
if self.isolate:
out_data = self._call_isolated(in_data)
else:
self._load_lib()
in_ptr = in_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
in_len = ctypes.c_int(in_data.size)
# 2. 准备输出空间
# 同样简化为与输入等长
out_data = np.zeros_like(in_data)
out_ptr = out_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
out_len = ctypes.c_int(out_data.size)
# 2. 准备输出空间
# 同样简化为与输入等长
out_data = np.zeros_like(in_data)
out_ptr = out_data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
out_len = ctypes.c_int(out_data.size)
# 3. 调用 C++ 函数
# 默认签名: void (double*, int, double*, int)
self._func(in_ptr, in_len, out_ptr, out_len)
# 3. 调用 C++ 函数
# 默认签名: void (double*, int, double*, int)
self._func(in_ptr, in_len, out_ptr, out_len)
# 4. 映射结果回 standardization 数据
out_attr = list(self.output_mapping.values())[0]
@@ -198,6 +268,7 @@ class MatlabBridge(AlgorithmBridge):
preprocessing: Optional[List[Dict[str, Any]]] = None,
requirements: Optional[Dict[str, Any]] = None,
output_specs: Optional[Dict[str, Any]] = None,
expected_sha256: Optional[str] = None,
is_core: bool = False):
super().__init__(name, is_core)
self.m_path = m_path # .m 文件所在目录
@@ -209,22 +280,40 @@ class MatlabBridge(AlgorithmBridge):
self.output_specs = output_specs or {}
self.algo_type = "matlab"
self._engine = None
self.expected_sha256 = expected_sha256
def _validate_path(self, path: str):
def _validate_path(self, path: str) -> str:
"""Security Whitelisting: Ensure MATLAB path is within the project's 'src' or 'data' directory"""
abs_path = os.path.abspath(path)
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # project root
if not abs_path.startswith(os.path.join(base_dir, "src")) and \
not abs_path.startswith(os.path.join(base_dir, "data")):
base_dir = os.path.realpath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # project root
abs_path = os.path.realpath(path if os.path.isabs(path) else os.path.join(base_dir, path))
allowed_dirs = [
os.path.join(base_dir, "src"),
os.path.join(base_dir, "data"),
]
if not any(os.path.commonpath([abs_path, d]) == d for d in allowed_dirs):
raise PermissionError(f"Security Block: MATLAB path {path} is outside allowed directories.")
return abs_path
def _verify_entrypoint_integrity(self):
if not self.expected_sha256:
return
m_dir = self._validate_path(self.m_path)
entry_file = self._validate_path(os.path.join(m_dir, f"{self.function_name}.m"))
if not os.path.exists(entry_file):
raise FileNotFoundError(f"MATLAB entry file not found: {entry_file}")
from src.integrity import ensure_file_sha256
ensure_file_sha256(entry_file, self.expected_sha256, label=f"matlab entry {self.name}")
def _call_via_engine(self, inputs: Dict[str, Any]) -> Any:
"""方式 1: 使用官方 matlab.engine"""
import matlab.engine
self._validate_path(self.m_path)
import matlab
m_dir = self._validate_path(self.m_path)
if self._engine is None:
self._engine = matlab.engine.start_matlab()
self._engine.addpath(self.m_path)
self._engine.addpath(m_dir)
func = getattr(self._engine, self.function_name)
# 将 numpy 数组转为 matlab 类型 (这里简化处理)
@@ -234,8 +323,8 @@ class MatlabBridge(AlgorithmBridge):
def _call_via_oct2py(self, inputs: Dict[str, Any]) -> Any:
"""方式 2: 使用开源 oct2py (Octave)"""
from oct2py import octave
self._validate_path(self.m_path)
octave.addpath(self.m_path)
m_dir = self._validate_path(self.m_path)
octave.addpath(m_dir)
func = getattr(octave, self.function_name)
return func(*inputs.values())
@@ -243,7 +332,7 @@ class MatlabBridge(AlgorithmBridge):
"""方式 3: 文件兜底 (使用 .mat 文件交换)"""
from scipy.io import savemat, loadmat
import subprocess
self._validate_path(self.m_path)
m_dir = self._validate_path(self.m_path)
with tempfile.TemporaryDirectory(prefix="matlab_bridge_") as tmp_dir:
in_file = os.path.join(tmp_dir, "tmp_in.mat")
@@ -251,7 +340,7 @@ class MatlabBridge(AlgorithmBridge):
savemat(in_file, inputs)
# 构造执行脚本:加入 m_path,加载输入,调用函数,保存输出
m_path = os.path.abspath(self.m_path).replace("'", "''")
m_path = os.path.abspath(m_dir).replace("'", "''")
in_file_escaped = in_file.replace("'", "''")
out_file_escaped = out_file.replace("'", "''")
arglist = ",".join(inputs.keys())
@@ -280,6 +369,7 @@ class MatlabBridge(AlgorithmBridge):
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
try:
water_data.add_log(self.name, "Start MATLAB Execution")
self._verify_entrypoint_integrity()
# 准备输入
algo_inputs = {}
@@ -313,3 +403,138 @@ class MatlabBridge(AlgorithmBridge):
water_data.set_error(f"MATLAB Algorithm {self.name} failed: {str(e)}")
return water_data
import requests
class WebBridge(AlgorithmBridge):
"""HTTP/HTTPS 远程服务桥接(通用工作流组件)"""
def __init__(self, name: str, endpoint_url: str,
input_mapping: Dict[str, str], # WSD 字段路径 -> HTTP 请求字段
output_mapping: Dict[str, str], # HTTP 响应字段 -> WSD 字段路径
method: str = "POST",
timeout_sec: int = 300,
slot_key: Optional[str] = None, # 资源槽位 Key(同 Key 串行执行)
fixed_params: Optional[Dict[str, Any]] = None, # 固定参数(如 modelType)
sanitize_output: bool = True, # 是否清洗输出参数
is_core: bool = False):
super().__init__(name, is_core)
self.endpoint_url = endpoint_url
self.input_mapping = input_mapping
self.output_mapping = output_mapping
self.method = method.upper()
self.timeout_sec = timeout_sec
self.slot_key = slot_key
self.fixed_params = fixed_params or {}
self.sanitize_output = sanitize_output
self.algo_type = "web"
def _get_nested(self, obj: Any, path: str) -> Any:
"""支持 'sea_env.modeling.freq_hz' 这种嵌套取值"""
for key in path.split('.'):
if obj is None:
return None
if isinstance(obj, dict):
obj = obj.get(key)
else:
obj = getattr(obj, key, None)
return obj
def _set_nested(self, obj: Any, path: str, value: Any) -> None:
"""支持 'vis_data.tl_result_path' 这种嵌套写入"""
keys = path.split('.')
for key in keys[:-1]:
if isinstance(obj, dict):
if key not in obj:
obj[key] = {}
obj = obj[key]
else:
if not hasattr(obj, key):
setattr(obj, key, {})
next_obj = getattr(obj, key)
if not isinstance(next_obj, dict):
setattr(obj, key, {})
next_obj = getattr(obj, key)
obj = next_obj
final_key = keys[-1]
if isinstance(obj, dict):
obj[final_key] = value
else:
setattr(obj, final_key, value)
def call_algo(self, water_data: WaterSoundData) -> WaterSoundData:
from src.resource_slots import slot_context
from src.security import (
validate_endpoint_host, sanitize_output_params,
generate_run_id, version_tracker
)
try:
water_data.add_log(self.name, "Start Web Execution", f"Endpoint: {self.endpoint_url}")
# 1. 安全检查:Host 白名单
validate_endpoint_host(self.endpoint_url)
# 2. 构建请求参数
request_params = dict(self.fixed_params)
for http_field, wsd_path in self.input_mapping.items():
value = self._get_nested(water_data, wsd_path)
if value is not None:
request_params[http_field] = value
# 3. 清洗输出参数(生成唯一 run_id)
run_id = generate_run_id()
if self.sanitize_output:
request_params = sanitize_output_params(request_params, run_id)
water_data.add_log(self.name, "Request Prepared", f"run_id={run_id}")
# 4. 获取资源槽位锁并发起请求
with slot_context(self.slot_key):
if self.method == "POST":
response = requests.post(
self.endpoint_url,
json=request_params,
timeout=self.timeout_sec
)
elif self.method == "GET":
response = requests.get(
self.endpoint_url,
params=request_params,
timeout=self.timeout_sec
)
else:
raise ValueError(f"Unsupported HTTP method: {self.method}")
response.raise_for_status()
result = response.json()
# 5. 记录服务版本
if "version" in result:
version_tracker.record(self.endpoint_url, result["version"])
water_data.add_log(self.name, "Service Version", result["version"])
# 6. 检查执行结果
if not result.get("success", True):
error_msg = result.get("error", result.get("message", "Unknown error"))
raise RuntimeError(f"Remote service returned failure: {error_msg}")
# 7. 映射响应到 WSD
response_data = result.get("data", result)
for http_field, wsd_path in self.output_mapping.items():
value = self._get_nested(response_data, http_field)
if value is not None:
self._set_nested(water_data, wsd_path, value)
water_data.add_log(self.name, "Finished Web Execution")
except requests.exceptions.Timeout:
water_data.set_error(f"WebBridge {self.name} timeout after {self.timeout_sec}s")
except requests.exceptions.RequestException as e:
water_data.set_error(f"WebBridge {self.name} request failed: {str(e)}")
except Exception as e:
water_data.set_error(f"WebBridge {self.name} failed: {str(e)}")
return water_data