From e56bf0e56bd2c4f14e0446aeb8c2a406527f548b Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:04:31 +0800 Subject: [PATCH] init --- backend/.gitignore | 10 ++ backend/.python-version | 1 + backend/README.md | 0 backend/pyproject.toml | 26 +++ backend/src/backend/__init__.py | 2 + backend/src/main.py | 112 ++++++++++++ common/.gitignore | 10 ++ common/.python-version | 1 + common/README.md | 0 common/pyproject.toml | 17 ++ common/src/common/__init__.py | 2 + runtime/.gitignore | 10 ++ runtime/.python-version | 1 + runtime/README.md | 0 runtime/pyproject.toml | 24 +++ runtime/src/runtime/__init__.py | 2 + runtime/src/runtime/main.py | 310 ++++++++++++++++++++++++++++++++ 17 files changed, 528 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/.python-version create mode 100644 backend/README.md create mode 100644 backend/pyproject.toml create mode 100644 backend/src/backend/__init__.py create mode 100644 backend/src/main.py create mode 100644 common/.gitignore create mode 100644 common/.python-version create mode 100644 common/README.md create mode 100644 common/pyproject.toml create mode 100644 common/src/common/__init__.py create mode 100644 runtime/.gitignore create mode 100644 runtime/.python-version create mode 100644 runtime/README.md create mode 100644 runtime/pyproject.toml create mode 100644 runtime/src/runtime/__init__.py create mode 100644 runtime/src/runtime/main.py diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c3890d4 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/src/backend/__init__.py b/backend/src/backend/__init__.py new file mode 100644 index 0000000..bc831a6 --- /dev/null +++ b/backend/src/backend/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from backend!") diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..1589cda --- /dev/null +++ b/backend/src/main.py @@ -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"} \ No newline at end of file diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/common/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/common/.python-version b/common/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/common/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..e69de29 diff --git a/common/pyproject.toml b/common/pyproject.toml new file mode 100644 index 0000000..2c7c0f1 --- /dev/null +++ b/common/pyproject.toml @@ -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" diff --git a/common/src/common/__init__.py b/common/src/common/__init__.py new file mode 100644 index 0000000..a130cb4 --- /dev/null +++ b/common/src/common/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from common!") diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/runtime/.python-version b/runtime/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/runtime/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/runtime/README.md b/runtime/README.md new file mode 100644 index 0000000..e69de29 diff --git a/runtime/pyproject.toml b/runtime/pyproject.toml new file mode 100644 index 0000000..fabfcc7 --- /dev/null +++ b/runtime/pyproject.toml @@ -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" diff --git a/runtime/src/runtime/__init__.py b/runtime/src/runtime/__init__.py new file mode 100644 index 0000000..504d779 --- /dev/null +++ b/runtime/src/runtime/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from runtime!") diff --git a/runtime/src/runtime/main.py b/runtime/src/runtime/main.py new file mode 100644 index 0000000..aecd30c --- /dev/null +++ b/runtime/src/runtime/main.py @@ -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'", + )