Files
xas-test-library/console/native_plans.py
T

215 lines
12 KiB
Python
Raw Normal View History

"""Trusted original-environment recipes; user input never supplies executable code."""
import hashlib
import json
import math
import os
from pathlib import Path
import re
import shlex
import socket
from urllib.parse import urlsplit
from .catalog import script_path, safe_path
MAX_TIMEOUT = 72 * 3600
PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
def profile_for(settings, script):
if script["kind"] != "external":
return None
source = next((s for s in settings.sources if s["id"] == script["source_group"]), None)
if not source:
return None
expected = Path(script["source_path"])
for profile in settings.native_catalog.get("profiles", []):
if profile["source_group"] == source["id"] and Path(source["path"]) / profile["entry"] == expected:
return profile
return None
def validate_options(profile, options):
if not isinstance(options, dict):
raise ValueError("原环境参数必须是对象")
fields = {f["key"]: f for f in profile.get("parameters", [])}
if set(options) - set(fields):
raise ValueError("包含启动方案未登记的参数")
result = {}
for key, field in fields.items():
value = options.get(key, field.get("default"))
label = field.get("label", key)
if value is None or value == "":
if field.get("required", True):
raise ValueError(f"请填写{label}")
result[key] = ""
continue
kind = field["type"]
if kind in {"integer", "number"}:
valid = type(value) is int if kind == "integer" else type(value) in (int, float)
if not valid or not math.isfinite(value) or value < field.get("min", 0) or value > field.get("max", 10000000):
raise ValueError(f"{label}超出允许范围或类型不正确")
elif kind == "select":
if value not in [o["value"] for o in field["options"]]:
raise ValueError(f"{label}必须从登记选项中选择")
elif kind == "text":
if not isinstance(value, str) or len(value) > 1024 or re.search(r"[\x00-\x1f;$`|&<>\\]", value):
raise ValueError(f"{label}格式不正确")
if "pattern" in field and not re.fullmatch(field["pattern"], value):
raise ValueError(f"{label}格式不正确")
else:
raise ValueError("启动方案包含未知参数类型")
result[key] = value
return result
def resolve_target(target):
target = {**target, "environment": dict(target.get("environment", {}))}
path = target.get("config_file")
if not path:
return target
values = {}
for line in Path(path).read_text().splitlines():
match = re.match(r"^(PORT|SERVED|CT_OFF|CT_ON)=(.*)$", line.strip())
if not match:continue
parts = shlex.split(match[2], comments=True)
if len(parts) != 1 or "$" in parts[0] or "`" in parts[0]:
raise ValueError("原配置的模型/端口包含动态表达式,需管理员核对")
values[match[1]] = parts[0]
if "PORT" in values:
port = int(values["PORT"])
if not 1 <= port <= 65535:raise ValueError("原模型端口不合法")
target["url"] = "http://127.0.0.1:" + str(port)
if "SERVED" in values:target["model"] = values["SERVED"]
for key in ("CT_OFF", "CT_ON"):
if key in values:target["environment"][key] = values[key]
return target
def public_execution(settings, script):
profile = profile_for(settings, script)
if profile is None:
return {"mode": "container", "interactive": False, "max_timeout_seconds": 7200}
return {"mode": "native", "runtime": "docker" if profile.get("docker") else "host", "profile_id": profile["id"], "interpreter": profile["interpreter"],
"user": profile["user"], "cwd": profile["cwd"], "description": "复用原依赖镜像和原始入口,只读挂载源文件,结果写入本次独立目录。" if profile.get("docker") else "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。",
"impact": profile["impact"], "parameters": profile.get("parameters", []),
"interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT,
"notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮业务测试验收" if profile.get("available", True) else "原环境方案待配置:" + profile.get("unavailable_reason", "请核对依赖与运行条件")}
def validate_config(settings, script, values):
profile = profile_for(settings, script)
if not profile:
raise ValueError("未登记原环境方案")
config = dict(values)
config["native_options"] = validate_options(profile, config.get("native_options", {}))
timeout = config.get("timeout_seconds", profile.get("timeout_seconds", 3600))
if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT:
raise ValueError("原环境时限须为 5 秒至 72 小时")
config.update(timeout_seconds=timeout, cwd=profile["cwd"], args=[], output_paths=[], profile="native")
return config
def build_plan(settings, script, run_id=None, check_host=True):
profile = profile_for(settings, script)
if not profile:
raise ValueError("该入口未登记原环境方案")
source = script_path(settings, script)
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
if source_hash != script["sha256"]:
raise ValueError("原入口已变更,请刷新来源并重新确认后运行")
options = validate_options(profile, script["config"].get("native_options", {}))
target_id = options.get("target")
target = next((t for t in settings.native_catalog.get("targets", []) if t["id"] == target_id), {})
if target_id and not target:
raise ValueError("目标服务未登记")
if target:
target = resolve_target(target)
if run_id and not re.fullmatch(r"[a-f0-9]{32}", run_id):
raise ValueError("运行编号格式错误")
identity = run_id or "{run_id}"
directory = settings.runs / identity
variables = {**options, "run_id": identity, "run_label": "console-" + identity,
"run_dir": str(directory), "output_file": str(Path(profile["cwd"]) / "logs" / f"console-{identity}-{profile['id']}.json"),
"target_url": target.get("url", ""), "target_model": target.get("model", ""),
"target_port": urlsplit(target.get("url", "")).port or "", "target_config": target.get("config_name", "")}
def render(value):
def replace(match):
if match[1] not in variables:
raise ValueError("原环境方案包含未定义的占位参数")
return str(variables[match[1]])
return PLACEHOLDER.sub(replace, str(value))
argv = [profile["interpreter"], str(source), *[render(a) for a in profile.get("arguments", [])]]
if profile.get("docker"):
from .native_container import command
argv = command(profile, source, argv[2:], directory, identity)
environment = {**target.get("environment", {}), **{k: render(v) for k, v in profile.get("environment", {}).items()}}
credential_files = list(dict.fromkeys(profile.get("credential_files", []) + target.get("credential_files", [])))
setup_files = [render(p) for p in profile.get("env_setup_files", [])]
required_files = [render(p) for p in profile.get("required_files", [])]
required_files.extend(table["path"] for table in profile.get("secret_tables", []))
if target.get("config_file"):
required_files.append(target["config_file"])
timeout = script["config"].get("timeout_seconds", profile.get("timeout_seconds", 3600))
if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT:
raise ValueError("原环境时限不合法")
checks = []
def check(name, ok, message):
checks.append({"name": name, "ok": bool(ok), "message": message})
check("启动方案", profile.get("available", True), profile.get("unavailable_reason", "已登记原环境入口"))
if check_host:
check("原工作目录", Path(profile["cwd"]).is_dir(), profile["cwd"])
tools = list(dict.fromkeys(["/usr/bin/docker" if profile.get("docker") else profile["interpreter"], "/usr/bin/systemd-run", *profile.get("required_tools", [])]))
for tool in tools:
check("工具", Path(tool).is_file() and os.access(tool, os.X_OK), tool)
for path in required_files + credential_files + setup_files:
check("原配置或素材", Path(path).is_file(), path)
if profile.get("docker"):
from .native_container import checks as container_checks
checks.extend(container_checks(profile))
if profile.get("secret_tables"):
from .native_io import read_secret_tables
try:
read_secret_tables(profile["secret_tables"])
check("测试身份引用", True, "已找到原测试身份,凭据仅在内存中读取")
except (OSError, ValueError):
check("测试身份引用", False, "原凭据表缺失、格式不支持或没有登记的测试身份")
for value in profile.get("check_ports", []):
port = int(render(value))
if not 1 <= port <= 65535:
raise ValueError("原服务端口不合法")
try:
with socket.create_connection(("127.0.0.1", port), timeout=.3):pass
available = True
except OSError:available = False
check("目标服务端口", available, f"127.0.0.1:{port}" + (" 可连接" if available else " 未监听,请先准备原服务"))
if target.get("verify_model"):
if all(c["ok"] for c in checks):
from .model_preflight import check_model
checks.append(check_model(target, credential_files))
else:
check("模型身份", False, "先满足上方原环境条件,再核对服务提供的模型")
# Binds acknowledgment to saved configuration, source and recipe, not to a mutable browser flag.
dependency_hashes = {}
for name in required_files + setup_files:
path = Path(name)
secret_paths = [table["path"] for table in profile.get("secret_tables", [])]
if name not in credential_files + secret_paths + profile.get("mutable_files", []) and path.is_file() and path.stat().st_size <= 16 * 1024**2:
dependency_hashes[name] = hashlib.sha256(path.read_bytes()).hexdigest()
fingerprint = {"profile": profile, "target": target, "options": options, "source": source_hash,
"dependencies": dependency_hashes,
"script_id": script["id"], "timeout_seconds": timeout}
token = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
return {"mode": "native", "runtime": "docker" if profile.get("docker") else "host", "profile_id": profile["id"], "plan_token": token,
"ready": all(c["ok"] for c in checks), "checks": checks, "argv": argv, "command_preview": shlex.join(argv),
"cwd": profile["cwd"], "user": profile["user"], "environment": environment,
"credential_files": credential_files, "env_setup_files": setup_files, "secret_tables": profile.get("secret_tables", []),
"impact": profile["impact"], "interactive": profile.get("interactive", False),
"timeout_seconds": timeout, "source_sha256": source_hash, "dependency_hashes": dependency_hashes,
"output_paths": [render(p) for p in profile.get("output_paths", [])],
"notes": profile.get("notes", []) + ([f"目标服务:{target['url']};模型:{target['model']}(原配置已复核)"] if target else [])}
def public_plan(plan):
keys = ("mode", "profile_id", "plan_token", "ready", "checks", "command_preview", "cwd", "user",
"impact", "interactive", "output_paths", "notes", "timeout_seconds", "source_sha256")
return {**{key: plan[key] for key in keys}, "runtime": plan.get("runtime", "host")}