zcbot/web/routers/compute_nodes.py

163 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Windows Node MVP 的注册、管理与长连接端点。"""
from __future__ import annotations
import asyncio
from uuid import UUID
from fastapi import Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from core.compute_nodes import (
ComputeNodeError,
authenticate_node,
create_enrollment,
delete_node,
enroll_node,
list_nodes,
mark_node_offline,
set_node_disabled,
update_node_runtime,
)
from web.schemas import (
ComputeEnrollmentCreateRequest,
ComputeNodeDisableRequest,
ComputeNodeEnrollRequest,
)
class NodeConnectionManager:
def __init__(self) -> None:
self._connections: dict[UUID, WebSocket] = {}
self._lock = asyncio.Lock()
async def activate(self, node_id: UUID, websocket: WebSocket) -> None:
async with self._lock:
old = self._connections.get(node_id)
self._connections[node_id] = websocket
if old is not None and old is not websocket:
await old.close(code=4001, reason="replaced by a newer connection")
async def remove(self, node_id: UUID, websocket: WebSocket) -> bool:
async with self._lock:
if self._connections.get(node_id) is websocket:
self._connections.pop(node_id, None)
return True
return False
async def close(self, node_id: UUID) -> None:
async with self._lock:
websocket = self._connections.pop(node_id, None)
if websocket is not None:
await websocket.close(code=4003, reason="node disabled")
node_connections = NodeConnectionManager()
def _bearer(authorization: str | None) -> str:
scheme, _, token = (authorization or "").partition(" ")
if scheme.lower() != "bearer" or not token:
raise ComputeNodeError("missing node bearer token")
return token
def register_compute_node_routes(app, *, require_admin) -> None:
@app.post(
"/v1/compute/nodes/enroll",
tags=["compute-nodes"],
status_code=status.HTTP_201_CREATED,
)
def node_enroll(body: ComputeNodeEnrollRequest):
try:
return enroll_node(**body.model_dump())
except ComputeNodeError as exc:
raise HTTPException(400, str(exc)) from exc
@app.websocket("/v1/compute/nodes/connect")
async def node_connect(websocket: WebSocket):
try:
node_id = UUID(websocket.headers.get("x-node-id", ""))
token = _bearer(websocket.headers.get("authorization"))
identity = await asyncio.to_thread(authenticate_node, node_id, token)
except (ValueError, ComputeNodeError):
# 握手前 close 会被 ASGI 统一表现为 HTTP 403客户端无法区分
# “凭据无效”和“代理/路由没有正确转发 WebSocket”。先升级再用
# 应用关闭码给已持有 Node ID/Token 的节点返回明确诊断。
await websocket.accept()
await websocket.close(code=4003, reason="invalid node credentials")
return
await websocket.accept()
await node_connections.activate(node_id, websocket)
try:
await websocket.send_json({"type": "connected", "heartbeat_seconds": 15})
while True:
message = await websocket.receive_json()
message_type = message.get("type")
payload = message.get("payload") or {}
if message_type not in {"hello", "heartbeat"} or not isinstance(
payload, dict
):
await websocket.send_json(
{"type": "error", "code": "unsupported_message"}
)
continue
if payload.get("install_id") and payload["install_id"] != str(
identity["install_id"]
):
await websocket.close(code=1008, reason="install identity mismatch")
return
await asyncio.to_thread(
update_node_runtime,
node_id,
status="online",
runtime=payload,
)
await websocket.send_json(
{"type": "ack", "message_id": message.get("message_id")}
)
except (ComputeNodeError, WebSocketDisconnect, RuntimeError, ValueError):
pass
finally:
if await node_connections.remove(node_id, websocket):
await asyncio.to_thread(mark_node_offline, node_id)
@app.post("/v1/admin/compute-node-enrollments", tags=["admin"])
def admin_create_compute_enrollment(
body: ComputeEnrollmentCreateRequest,
user_id: UUID = Depends(require_admin), # noqa: B008
):
try:
return create_enrollment(user_id, **body.model_dump())
except ComputeNodeError as exc:
raise HTTPException(400, str(exc)) from exc
@app.get("/v1/admin/compute-nodes", tags=["admin"])
def admin_compute_nodes(user_id: UUID = Depends(require_admin)): # noqa: B008
return {"results": list_nodes()}
@app.patch("/v1/admin/compute-nodes/{node_id}", tags=["admin"])
async def admin_disable_compute_node(
node_id: UUID,
body: ComputeNodeDisableRequest,
user_id: UUID = Depends(require_admin), # noqa: B008
):
if not await asyncio.to_thread(set_node_disabled, node_id, body.disabled):
raise HTTPException(404, "compute node not found")
if body.disabled:
await node_connections.close(node_id)
return {
"node_id": str(node_id),
"status": "disabled" if body.disabled else "offline",
}
@app.delete("/v1/admin/compute-nodes/{node_id}", tags=["admin"])
async def admin_delete_compute_node(
node_id: UUID,
user_id: UUID = Depends(require_admin), # noqa: B008
):
# 先撤掉在线连接,避免删除后的旧 socket 继续上报运行态。
await node_connections.close(node_id)
if not await asyncio.to_thread(delete_node, node_id):
raise HTTPException(404, "compute node not found")
return {"node_id": str(node_id), "status": "deleted"}