init
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "tao.chen", email = "93983997+taochen-ct@users.noreply.github.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.140.0",
|
||||||
|
"loguru>=0.7.3",
|
||||||
|
"pydantic>=2.13.4",
|
||||||
|
"uvicorn>=0.51.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
backend = "backend:main"
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.31,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
def main() -> None:
|
||||||
|
print("Hello from backend!")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
@Time :2026/7/27
|
||||||
|
@Author :tao.chen
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request, Response, HTTPException, Depends, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
app = FastAPI(title="Jupyter Auth & Router Backend")
|
||||||
|
|
||||||
|
RUNTIME_BASE_URL = "http://127.0.0.1:8001"
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeClient:
|
||||||
|
"""与 Runtime 管理服务交互"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_workspace(workspace_id: str) -> Optional[dict]:
|
||||||
|
"""通过 action: get 查询单个 workspace 进程信息"""
|
||||||
|
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/jupyter",
|
||||||
|
json={"action": "get", "workspace_id": workspace_id},
|
||||||
|
timeout=3.0
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return None
|
||||||
|
except httpx.RequestError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def start_workspace(workspace_id: str) -> dict:
|
||||||
|
"""通过 action: start 启动 workspace 子进程"""
|
||||||
|
async with httpx.AsyncClient(base_url=RUNTIME_BASE_URL) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/jupyter",
|
||||||
|
json={"action": "start", "workspace_id": workspace_id},
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to start Jupyter process")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_workspace_from_db(workspace_id: str):
|
||||||
|
"""查 MySQL 数据库校验 workspace 状态与权限(模拟)"""
|
||||||
|
mock_db = {
|
||||||
|
"test1234": {"workspace_id": "test1234", "is_locked": False, "owner_id": "user_001"}
|
||||||
|
}
|
||||||
|
return mock_db.get(workspace_id)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_token(token: str) -> str:
|
||||||
|
"""校验用户 JWT"""
|
||||||
|
if token == "invalid-token":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid JWT")
|
||||||
|
return "user_001"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Auth & Dynamic Route 核心接口
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@app.get("/api/v1/auth/jupyter")
|
||||||
|
async def verify_jupyter_access(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
auth: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
|
):
|
||||||
|
workspace_id = request.headers.get("X-Original-Workspace-Id")
|
||||||
|
cookie_token = request.cookies.get("access_token")
|
||||||
|
bearer_token = auth.credentials if auth else None
|
||||||
|
|
||||||
|
token = bearer_token or cookie_token
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing Authentication Token")
|
||||||
|
|
||||||
|
if not workspace_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing Workspace ID")
|
||||||
|
|
||||||
|
# 1. 校验用户身份
|
||||||
|
current_user_id = verify_jwt_token(token)
|
||||||
|
|
||||||
|
# 2. 查 MySQL:校验权限与锁定状态
|
||||||
|
ws = await get_workspace_from_db(workspace_id)
|
||||||
|
if not ws:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
if ws["is_locked"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Workspace is locked")
|
||||||
|
|
||||||
|
# 3. 使用 action: get 查询子进程
|
||||||
|
ws_info = await RuntimeClient.get_workspace(workspace_id)
|
||||||
|
|
||||||
|
# 4. 若未运行/不存在,主动触发 start
|
||||||
|
if not ws_info or ws_info.get("status") != "running":
|
||||||
|
ws_info = await RuntimeClient.start_workspace(workspace_id)
|
||||||
|
|
||||||
|
target_port = ws_info.get("port")
|
||||||
|
jupyter_token = ws_info.get("token")
|
||||||
|
|
||||||
|
if not target_port:
|
||||||
|
raise HTTPException(status_code=500, detail="Jupyter instance missing port configuration")
|
||||||
|
|
||||||
|
# 5. 向 Nginx 返回 Upstream 地址与 Token
|
||||||
|
response.headers["X-Upstream-Addr"] = f"http://127.0.0.1:{target_port}"
|
||||||
|
response.headers["X-Jupyter-Internal-Token"] = jupyter_token or ""
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[project]
|
||||||
|
name = "common"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "tao.chen", email = "93983997+taochen-ct@users.noreply.github.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
common = "common:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.31,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
def main() -> None:
|
||||||
|
print("Hello from common!")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "runtime"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "tao.chen", email = "93983997+taochen-ct@users.noreply.github.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.140.0",
|
||||||
|
"loguru>=0.7.3",
|
||||||
|
"notebook>=7.6.1",
|
||||||
|
"pydantic>=2.13.4",
|
||||||
|
"uvicorn>=0.51.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.31,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
def main() -> None:
|
||||||
|
print("Hello from runtime!")
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
@Time :2026/7/27
|
||||||
|
@Author :tao.chen
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# 全局内存字典:记录运行中的 Jupyter 进程信息
|
||||||
|
JUPYTER_PROCESSES: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
WORKSPACES_ROOT = os.getenv("WORKSPACES_ROOT", "/app/workspaces")
|
||||||
|
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def get_free_port() -> int:
|
||||||
|
"""利用操作系统 socket 特性,动态获取当前闲置的可用端口"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.listen(1)
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
# 统一请求 Model
|
||||||
|
class JupyterActionRequest(BaseModel):
|
||||||
|
action: str = Field(
|
||||||
|
..., description="操作类型: 'start' | 'stop' | 'list'"
|
||||||
|
)
|
||||||
|
workspace_id: Optional[str] = Field(
|
||||||
|
None, description="Workspace ID (start 和 stop 操作时必填)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 辅助处理函数:启动逻辑
|
||||||
|
def start_process(cmd, workspace_path, log_dir="/tmp/process_logs"):
|
||||||
|
log_dir = Path(log_dir)
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
start_time = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 临时日志文件
|
||||||
|
temp_log = log_dir / f"process_start_{start_time}.log"
|
||||||
|
|
||||||
|
log_file = open(temp_log, "a", buffering=1)
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=workspace_path,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=subprocess.STDOUT, # stderr 合并到 stdout
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 根据真实 pid 重命名
|
||||||
|
final_log = log_dir / f"process_{process.pid}_{start_time}.log"
|
||||||
|
log_file.close()
|
||||||
|
|
||||||
|
temp_log.rename(final_log)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"process started pid={process.pid}, log={final_log}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_start(ws_id: str):
|
||||||
|
workspace_path = Path(f"./test/workspaces/{ws_id}")
|
||||||
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. 如果已存在,校验进程状态并复用
|
||||||
|
if ws_id in JUPYTER_PROCESSES:
|
||||||
|
p_info = JUPYTER_PROCESSES[ws_id]
|
||||||
|
if p_info["process"].poll() is None:
|
||||||
|
logger.info(f"Workspace {ws_id} already running.")
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"workspace_id": ws_id,
|
||||||
|
"port": p_info["port"],
|
||||||
|
"full_url": p_info["full_url"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
|
||||||
|
# 2. 动态申请端口与 Token
|
||||||
|
port = get_free_port()
|
||||||
|
token = secrets.token_hex(16)
|
||||||
|
base_path = f"/jupyter/{ws_id}/"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"jupyter",
|
||||||
|
"notebook",
|
||||||
|
f"--port={port}",
|
||||||
|
"--ip=0.0.0.0",
|
||||||
|
"--no-browser",
|
||||||
|
"--allow-root",
|
||||||
|
f"--ServerApp.token={token}",
|
||||||
|
f"--ServerApp.base_url={base_path}",
|
||||||
|
"--notebook-dir=.",
|
||||||
|
# 适用于现代 Jupyter Server / JupyterLab
|
||||||
|
"--ServerApp.terminals_enabled=False",
|
||||||
|
# 兼容经典 Notebook / 旧版配置项
|
||||||
|
"--NotebookApp.terminals_enabled=False",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = start_process(cmd, workspace_path.as_posix())
|
||||||
|
|
||||||
|
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||||
|
|
||||||
|
JUPYTER_PROCESSES[ws_id] = {
|
||||||
|
"process": process,
|
||||||
|
"base_url": PUBLIC_BASE_URL,
|
||||||
|
"port": port,
|
||||||
|
"token": token,
|
||||||
|
"full_url": full_url,
|
||||||
|
"started_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Started Jupyter for workspace {ws_id} on port {port}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"pid": process.pid,
|
||||||
|
"base_url": PUBLIC_BASE_URL,
|
||||||
|
"status": "success",
|
||||||
|
"workspace_id": ws_id,
|
||||||
|
"port": port,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Jupyter for {ws_id}: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to start Jupyter: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 辅助处理函数:停止逻辑
|
||||||
|
def _handle_stop(ws_id: str):
|
||||||
|
if ws_id not in JUPYTER_PROCESSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
p_info = JUPYTER_PROCESSES[ws_id]
|
||||||
|
process: subprocess.Popen = p_info["process"]
|
||||||
|
|
||||||
|
if process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=3)
|
||||||
|
logger.info(
|
||||||
|
f"Gracefully stopped Jupyter for workspace {ws_id}"
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning(
|
||||||
|
f"Jupyter for {ws_id} did not stop gracefully. Force killing..."
|
||||||
|
)
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"workspace_id": ws_id,
|
||||||
|
"message": "Jupyter process terminated and port released.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 辅助处理函数:列表查询逻辑
|
||||||
|
def _handle_list():
|
||||||
|
active_instances = {}
|
||||||
|
for ws_id, info in list(JUPYTER_PROCESSES.items()):
|
||||||
|
is_alive = info["process"].poll() is None
|
||||||
|
active_instances[ws_id] = {
|
||||||
|
"port": info["port"],
|
||||||
|
"full_url": info["full_url"],
|
||||||
|
"is_alive": is_alive,
|
||||||
|
}
|
||||||
|
return {"status": "success", "instances": active_instances}
|
||||||
|
|
||||||
|
def _handle_get(ws_id: str):
|
||||||
|
"""【新增】获取指定 Workspace 的 Jupyter 运行状态与完整 URL"""
|
||||||
|
if ws_id not in JUPYTER_PROCESSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No active Jupyter process found for workspace '{ws_id}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
p_info = JUPYTER_PROCESSES[ws_id]
|
||||||
|
is_alive = p_info["process"].poll() is None
|
||||||
|
|
||||||
|
if not is_alive:
|
||||||
|
# 进程如果挂了,清理内存字典并报 404
|
||||||
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Jupyter process for workspace '{ws_id}' has terminated unexpectedly.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"pid": p_info["process"].pid,
|
||||||
|
"base_url": PUBLIC_BASE_URL,
|
||||||
|
"workspace_id": ws_id,
|
||||||
|
"port": p_info["port"],
|
||||||
|
"token": p_info["token"],
|
||||||
|
"started_at": p_info["started_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== FastAPI Lifespan 定义 ====================
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# ---------------- 1. 启动阶段 (Startup) ----------------
|
||||||
|
logger.info(
|
||||||
|
f"Initializing workspace processes from root: '{WORKSPACES_ROOT}'..."
|
||||||
|
)
|
||||||
|
os.makedirs(WORKSPACES_ROOT, exist_ok=True)
|
||||||
|
|
||||||
|
# 遍历总路径下的每一个子目录(每个子目录名即 workspace_id)
|
||||||
|
for entry in os.listdir(WORKSPACES_ROOT):
|
||||||
|
full_path = os.path.join(WORKSPACES_ROOT, entry)
|
||||||
|
if os.path.isdir(full_path):
|
||||||
|
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
||||||
|
try:
|
||||||
|
_handle_start(entry)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(
|
||||||
|
f"Startup failed for workspace '{entry}': {err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield # 服务在此处保持正常运行,等待 API 请求
|
||||||
|
|
||||||
|
# ---------------- 2. 停止阶段 (Shutdown) ----------------
|
||||||
|
logger.info(
|
||||||
|
"Service is shutting down. Terminating all active Jupyter sub-processes..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 复制 key 列表,防止字典在迭代清理过程中因大小改变报错
|
||||||
|
active_workspaces = list(JUPYTER_PROCESSES.keys())
|
||||||
|
for ws_id in active_workspaces:
|
||||||
|
try:
|
||||||
|
_handle_stop(ws_id)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(
|
||||||
|
f"Error terminating Jupyter process for '{ws_id}': {err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("All Jupyter sub-processes have been clean terminated.")
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
# ---------------- 统一入口 POST 接口 ----------------
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/jupyter")
|
||||||
|
def handle_jupyter_action(req: JupyterActionRequest):
|
||||||
|
action = req.action.lower()
|
||||||
|
|
||||||
|
# 1. 启动操作
|
||||||
|
if action == "start":
|
||||||
|
if not req.workspace_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="'workspace_id' is required when action='start'",
|
||||||
|
)
|
||||||
|
return _handle_start(req.workspace_id)
|
||||||
|
|
||||||
|
# 2. 停止操作
|
||||||
|
elif action == "stop":
|
||||||
|
if not req.workspace_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="'workspace_id' is required when action='stop'",
|
||||||
|
)
|
||||||
|
return _handle_stop(req.workspace_id)
|
||||||
|
|
||||||
|
# 3. 列表操作
|
||||||
|
elif action == "list":
|
||||||
|
return _handle_list()
|
||||||
|
|
||||||
|
elif action == "get":
|
||||||
|
if not req.workspace_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="'workspace_id' is required for action='get'",
|
||||||
|
)
|
||||||
|
return _handle_get(req.workspace_id)
|
||||||
|
|
||||||
|
# 未知操作
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid action '{req.action}'. Supported actions: 'start', 'stop', 'list'",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user