This commit is contained in:
tao.chen
2026-07-27 15:04:31 +08:00
parent 4f9bb9fd66
commit e56bf0e56b
17 changed files with 528 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
+1
View File
@@ -0,0 +1 @@
3.12
View File
+24
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
def main() -> None:
print("Hello from runtime!")
+310
View File
@@ -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'",
)