Files

298 lines
14 KiB
Python

"""LAN-only HTTP interface; does not spawn processes or access Docker."""
import hashlib
import ipaddress
from pathlib import Path
from typing import Annotated
from urllib.parse import urlsplit
import uuid
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field
from . import catalog
from . import test_catalog
from . import native_plans, native_io
from .settings import Settings
from .store import Store, TERMINAL, now
class ConfigPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
args: list[str] | None = None
cwd: str | None = None
output_paths: list[str] | None = None
timeout_seconds: Annotated[int, Field(strict=True)] | None = None
native_options: dict[str, str | int | float | bool] | None = None
class ScriptPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=4000)
enabled: bool | None = None
config: ConfigPatch | None = None
class RunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: uuid.UUID
plan_token: str | None = Field(default=None, max_length=64)
impact_acknowledged: bool = False
class NativeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str = Field(max_length=1024)
def create_app(settings=None):
settings = settings or Settings.from_env()
store = Store(settings)
app = FastAPI(title="XAS 测试控制台", docs_url=None, redoc_url=None, openapi_url=None)
app.state.store = store
static = Path(__file__).parent / "static"
@app.middleware("http")
async def boundaries(request, call_next):
host = request.headers.get("host", "")
try:
address = ipaddress.ip_address(request.client.host)
allowed = any(address in ipaddress.ip_network(n) for n in settings.allowed_networks)
except ValueError:
allowed = False
if host not in settings.allowed_hosts or not allowed:
return JSONResponse({"detail": "仅允许公司内网访问"}, status_code=403)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
origin = request.headers.get("origin")
valid_origin = not origin or origin == f"{request.url.scheme}://{host}"
if (not valid_origin or request.headers.get("x-xas-console") != "1"
or request.headers.get("sec-fetch-site") == "cross-site"):
return JSONResponse({"detail": "请求来源校验失败,请从控制台页面操作"}, status_code=403)
try:
size = int(request.headers.get("content-length", "0"))
except ValueError:
size = -1
if size < 0 or size > settings.max_upload + 65536 or "transfer-encoding" in request.headers:
return JSONResponse({"detail": "请求超过大小限制"}, status_code=413)
response = await call_next(request)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
return response
@app.exception_handler(ValueError)
async def value_error(request, exc):
return JSONResponse({"detail": str(exc)}, status_code=400)
@app.exception_handler(KeyError)
async def missing(request, exc):
return JSONResponse({"detail": str(exc.args[0])}, status_code=404)
@app.get("/api/summary")
def summary():
inventory, runs = store.scripts(), store.runs(limit=10000)
scripts = test_catalog.tests(settings, inventory)
# No directory recursion on every page refresh; size derives from recorded artifacts.
size = sum(a["size"] for r in runs for a in r.get("artifacts", []))
return {"scripts": len(scripts), "source_files": len(inventory),
"examples": len(test_catalog.tests(settings, inventory, True)) - len(scripts),
"ready": sum(s["enabled"] for s in scripts), "runs": len(runs),
"active": sum(r["state"] in {"queued", "running"} for r in runs), "worker": store.health(),
"storage": {"run_bytes": size}, "limits": {"upload_bytes": settings.max_upload,
"log_bytes": settings.max_log, "concurrency": 1, "timeout_seconds": 7200}}
@app.get("/api/scripts")
def scripts(include_examples: bool = False):
return {"items": test_catalog.tests(settings, store.scripts(), include_examples)}
@app.post("/api/scripts/upload")
async def new_script(file: UploadFile, name: Annotated[str, Form()] = "", description: Annotated[str, Form()] = ""):
try:
item = catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, name, description)
return store.script(item["id"], versions=True)
finally:
await file.close()
@app.get("/api/scripts/{identity}")
def script(identity: str):
return test_catalog.describe(settings, store.script(identity, versions=True), store.scripts())
@app.get("/api/scripts/{identity}/files")
def script_files(identity: str):
return {"items": test_catalog.members(settings, store.script(identity), store.scripts())}
@app.post("/api/scripts/{identity}/versions")
async def new_version(identity: str, file: UploadFile):
try:
catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, identity=identity)
return store.script(identity, versions=True)
finally:
await file.close()
def source_version(identity, version):
item = store.script(identity)
if item["kind"] == "managed" and version:
v = store.version(identity, version)
item.update(source_path=v["path"], sha256=v["sha256"])
elif version and version != item["version"]:
raise ValueError("原位置脚本仅展示当前文件,历史运行请查看运行归档")
return item
@app.get("/api/scripts/{identity}/code")
def code(identity: str, version: int | None = None):
return catalog.read_code(settings, source_version(identity, version))
@app.get("/api/scripts/{identity}/download")
def download_source(identity: str, version: int | None = None):
item = source_version(identity, version)
source = catalog.read_code(settings, item)
if source["redacted"]:
raise ValueError("包含疑似凭据的原文件不能下载")
return FileResponse(catalog.script_path(settings, item), filename=source["filename"], media_type="text/plain")
@app.patch("/api/scripts/{identity}")
def update_script(identity: str, patch: ScriptPatch):
# Serialize with version uploads to avoid restoring an obsolete current-version pointer.
import json
with store.connect(write=True) as db:
row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone()
if not row:
raise KeyError("脚本不存在")
item = json.loads(row[0])
if not test_catalog.is_entry(settings, item):
raise ValueError("此文件属于内部源码,请从测试项目入口配置和运行")
values = patch.model_dump(exclude_none=True)
if "config" in values:
config_patch = values.pop("config")
if native_plans.profile_for(settings, item) and set(config_patch) - {"native_options", "timeout_seconds"}:
raise ValueError("原环境入口、目录和输出规则由已登记方案管理")
item["config"] = catalog.validate_config(settings, item, {**item["config"], **config_patch})
if values.get("enabled"):
native = native_plans.profile_for(settings, item)
blocked = not native.get("available", True) if native else item.get("blocked")
if blocked or item["status"] == "missing":
raise ValueError(item["reason"] or "此脚本暂不能启用")
catalog.script_path(settings, item)
item["config"] = catalog.validate_config(settings, item, item["config"])
item.update(values, updated_at=now())
item["status"] = "ready" if item["enabled"] else "needs_configuration"
item["reason"] = "" if item["enabled"] else (item.get("reason") or "已停用")
db.execute("UPDATE scripts SET data=? WHERE id=?", (json.dumps(item), identity))
return store.script(identity, versions=True)
@app.post("/api/discover")
def discover():
result = catalog.discover(store)
result["tests"] = len(test_catalog.tests(settings, store.scripts()))
return result
@app.post("/api/scripts/{identity}/runs")
def run_script(identity: str, body: RunRequest, request: Request):
script = test_catalog.describe(settings, store.script(identity), store.scripts())
if not script["is_test"]:
raise ValueError("此文件属于内部源码,不能作为独立测试运行")
if not script["enabled"] or script["status"] != "ready":
raise ValueError(script["reason"] or "请先配置并启用脚本")
if native_plans.profile_for(settings, script):
return native_io.request(settings, {"op": "queue", "script_id": identity,
"request_id": str(body.request_id), "plan_token": body.plan_token,
"impact_acknowledged": body.impact_acknowledged, "source_ip": request.client.host})
source = catalog.script_path(settings, script).read_bytes()
if hashlib.sha256(source).hexdigest() != script["sha256"]:
raise ValueError("原脚本已变动,请刷新目录并重新确认配置")
if catalog.SECRET.search(catalog.clean_source(source)):
raise ValueError("源文件包含疑似凭据,暂不执行")
return store.queue(script, str(body.request_id), request.client.host)
@app.post("/api/scripts/{identity}/preflight")
def preflight(identity: str):
item = store.script(identity)
if not native_plans.profile_for(settings, item):
raise ValueError("此项目使用隔离容器,无原环境启动方案")
return native_io.request(settings, {"op": "preflight", "script_id": identity})
@app.post("/api/runs/{identity}/input")
def native_input(identity: str, body: NativeInput):
return native_io.request(settings, {"op": "input", "run_id": identity, "text": body.text})
@app.get("/api/runs")
def runs(script_id: str | None = None, limit: int = Query(default=100, ge=1, le=500)):
return {"items": store.runs(script_id, limit)}
@app.get("/api/runs/{identity}")
def run(identity: str):
return store.run(identity)
@app.post("/api/runs/{identity}/cancel")
def cancel(identity: str):
return store.cancel(identity)
def run_path(identity, filename):
store.run(identity)
root = catalog.safe_path(settings.runs / identity, settings.runs, must_exist=False)
return catalog.safe_path(root / filename, root, must_exist=False)
@app.get("/api/runs/{identity}/log")
def log(identity: str, offset: int = Query(default=0, ge=0)):
item = store.run(identity)
path = run_path(identity, "console.log")
data = b""
at_eof = True
if path.exists():
with path.open("rb") as handle:
handle.seek(min(offset, path.stat().st_size))
offset = handle.tell()
data = handle.read(65536)
at_eof = offset + len(data) >= path.stat().st_size
# Keep any incomplete UTF-8 tail for the next poll.
import codecs
decoder = codecs.getincrementaldecoder("utf-8")("replace")
text = decoder.decode(data, final=item["state"] in TERMINAL and at_eof)
pending = len(decoder.getstate()[0])
else:
text, pending = "", 0
return {"text": text, "next_offset": offset + len(data) - pending,
"done": item["state"] in TERMINAL and len(data) < 65536,
"truncated": path.exists() and path.stat().st_size >= settings.max_log}
@app.get("/api/runs/{identity}/log/download")
def log_download(identity: str):
path = run_path(identity, "console.log")
if not path.is_file():
raise KeyError("日志尚未生成")
return FileResponse(path, filename=f"{identity}.log", media_type="text/plain")
def artifact_path(identity, artifact_id):
item = store.run(identity)
artifact = next((a for a in item.get("artifacts", []) if a["id"] == artifact_id), None)
if not artifact:
raise KeyError("结果文件不存在")
return run_path(identity, "artifacts/" + artifact["id"]), artifact
@app.get("/api/runs/{identity}/artifacts/{artifact_id}/preview")
def artifact_preview(identity: str, artifact_id: str):
path, artifact = artifact_path(identity, artifact_id)
if artifact["kind"] not in {"text", "json"}:
raise ValueError("此文件只支持下载")
with path.open("rb") as handle:
data = handle.read(262145)
return {"text": data[:262144].decode("utf-8", errors="replace"), "truncated": len(data) > 262144}
@app.get("/api/runs/{identity}/artifacts/{artifact_id}")
def artifact_download(identity: str, artifact_id: str):
path, artifact = artifact_path(identity, artifact_id)
return FileResponse(path, filename=artifact["name"], media_type="application/octet-stream")
@app.get("/")
def index():
return FileResponse(static / "index.html")
@app.get("/project/xas-test-library/{legacy_path:path}")
def old_bookmark(legacy_path: str):
return RedirectResponse("/#/scripts", status_code=307)
if static.exists():
app.mount("/static", StaticFiles(directory=static), name="static")
return app