init
This commit is contained in:
@@ -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