194 lines
10 KiB
Python
194 lines
10 KiB
Python
"""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", "profile_id": profile["id"], "interpreter": profile["interpreter"],
|
|
"user": profile["user"], "cwd": profile["cwd"], "description": "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。",
|
|
"impact": profile["impact"], "parameters": profile.get("parameters", []),
|
|
"interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT,
|
|
"notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮 GPU/长稳业务验收"}
|
|
|
|
|
|
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", [])]]
|
|
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", [])]
|
|
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([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)
|
|
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 " 未监听,请先准备原服务"))
|
|
# 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)
|
|
if name not in credential_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", "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,
|
|
"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}
|