v3.0 开发验证完成,满足webui和最小数据管理需求, 任务模版目前偏简单,可以扩展一下,再release开放使用
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +13,10 @@ from argus.ray.models import JobSpec, RayConfig
|
||||
|
||||
from .config import V2Config
|
||||
from .db import Db
|
||||
from .janitor import JobsJanitor
|
||||
from .scheduler import Scheduler
|
||||
from .sftpgo import SFTPGoAdminClient, SFTPGoError
|
||||
from .ui import register_ui_routes
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
@@ -38,11 +42,48 @@ def create_app(config_path: str) -> FastAPI:
|
||||
db.init()
|
||||
|
||||
scheduler = Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg)
|
||||
janitor = JobsJanitor(
|
||||
db=db,
|
||||
user_root=v2_cfg.data.user_root,
|
||||
trash_after_days=v2_cfg.data.retention.jobs_trash_after_days,
|
||||
purge_after_days=v2_cfg.data.retention.jobs_purge_after_days,
|
||||
interval_s=v2_cfg.data.retention.janitor_interval_s,
|
||||
)
|
||||
stop_flag = threading.Event()
|
||||
tool = scheduler.tool
|
||||
|
||||
app = FastAPI(title="mvp-v2", version="2.0")
|
||||
|
||||
def _user_home(user_id: str) -> str:
|
||||
base = v2_cfg.data.user_root.rstrip("/")
|
||||
return f"{base}/{user_id}"
|
||||
|
||||
def _ensure_user_dirs(user_id: str) -> None:
|
||||
home = _user_home(user_id)
|
||||
# Note: create a physical /common directory under each user's home so that
|
||||
# SFTPGo WebClient shows a "common" entry at the root. The actual shared
|
||||
# content is exposed via SFTPGo virtual folders under /common/{datasets,models}.
|
||||
for rel in ("datasets", "models", "code", "jobs", "trash/jobs", "common"):
|
||||
os.makedirs(f"{home}/{rel}", exist_ok=True)
|
||||
|
||||
def _sftpgo_enabled() -> bool:
|
||||
return bool(v2_cfg.data.sftpgo.enabled)
|
||||
|
||||
def _sftpgo_client() -> SFTPGoAdminClient:
|
||||
cfg = v2_cfg.data.sftpgo
|
||||
if not cfg.admin_api_base:
|
||||
raise HTTPException(status_code=500, detail="sftpgo enabled but data.sftpgo.admin_api_base is empty")
|
||||
pw = os.environ.get(cfg.admin_password_env, "")
|
||||
if not pw:
|
||||
raise HTTPException(status_code=500, detail=f"missing env: {cfg.admin_password_env}")
|
||||
shared_root = ray_cfg.shared_root.rstrip("/")
|
||||
return SFTPGoAdminClient(
|
||||
admin_api_base=str(cfg.admin_api_base),
|
||||
admin_user=str(cfg.admin_user),
|
||||
admin_password=pw,
|
||||
common_root=f"{shared_root}/common",
|
||||
)
|
||||
|
||||
def _auth(req: Request) -> dict[str, Any]:
|
||||
token_env = v2_cfg.auth.token_env
|
||||
admin_token = os.environ.get(token_env, "")
|
||||
@@ -74,6 +115,9 @@ def create_app(config_path: str) -> FastAPI:
|
||||
def _startup() -> None:
|
||||
t = threading.Thread(target=scheduler.run_forever, args=(stop_flag,), daemon=True)
|
||||
t.start()
|
||||
if int(janitor.interval_s) > 0:
|
||||
tj = threading.Thread(target=janitor.run_forever, args=(stop_flag,), daemon=True)
|
||||
tj.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def _shutdown() -> None:
|
||||
@@ -93,7 +137,42 @@ def create_app(config_path: str) -> FastAPI:
|
||||
row = db.create_user(user_id=user_id, display_name=str(display_name) if display_name is not None else None)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=409, detail=f"user create failed: {e!r}")
|
||||
return {"user_id": row.get("user_id", user_id), "state": row.get("state", "ACTIVE")}
|
||||
|
||||
# v3.0: create user dir structure (datasets/models/code/jobs/trash).
|
||||
try:
|
||||
_ensure_user_dirs(user_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"failed to create user dirs: {e!r}")
|
||||
|
||||
out: dict[str, Any] = {"user_id": row.get("user_id", user_id), "state": row.get("state", "ACTIVE")}
|
||||
|
||||
# v3.0: scheme A (password) SFTPGo integration.
|
||||
if _sftpgo_enabled():
|
||||
pw = secrets.token_urlsafe(12)
|
||||
try:
|
||||
_sftpgo_client().create_user(username=user_id, password=pw, home_dir=_user_home(user_id))
|
||||
except SFTPGoError as e:
|
||||
# Make create user idempotent for retries:
|
||||
# If the user already exists in SFTPGo, reset password and enable it.
|
||||
if "http error: 409" in str(e):
|
||||
try:
|
||||
_sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id))
|
||||
_sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id))
|
||||
except Exception as e2:
|
||||
raise HTTPException(status_code=502, detail=f"sftpgo upsert user failed: {e2!r}")
|
||||
else:
|
||||
raise HTTPException(status_code=502, detail=f"sftpgo create user failed: {e!r}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"sftpgo create user failed: {e!r}")
|
||||
out["sftp"] = {
|
||||
"username": user_id,
|
||||
"password": pw, # one-time return to admin; do not persist plaintext
|
||||
"host": v2_cfg.data.sftpgo.host,
|
||||
"port": int(v2_cfg.data.sftpgo.sftp_port),
|
||||
}
|
||||
return out
|
||||
|
||||
@app.post("/api/v2/users/{user_id}/tokens")
|
||||
async def issue_token(user_id: str, req: Request) -> dict[str, Any]:
|
||||
@@ -104,6 +183,12 @@ def create_app(config_path: str) -> FastAPI:
|
||||
token = db.issue_token(user_id=user_id)
|
||||
return {"user_id": user_id, "token": token}
|
||||
|
||||
@app.get("/api/v2/users")
|
||||
async def list_users(req: Request, limit: int = 200) -> dict[str, Any]:
|
||||
_require_admin(req)
|
||||
lim = max(1, min(int(limit), 1000))
|
||||
return {"users": db.list_users(limit=lim), "limit": lim}
|
||||
|
||||
@app.post("/api/v2/users/{user_id}:disable")
|
||||
async def disable_user(user_id: str, req: Request) -> dict[str, Any]:
|
||||
_require_admin(req)
|
||||
@@ -111,8 +196,122 @@ def create_app(config_path: str) -> FastAPI:
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
db.disable_user(user_id=user_id)
|
||||
if _sftpgo_enabled():
|
||||
try:
|
||||
_sftpgo_client().disable_user(username=user_id, home_dir=_user_home(user_id))
|
||||
except Exception:
|
||||
# Best-effort: DB state is source of truth for API auth; SFTPGo sync can lag.
|
||||
pass
|
||||
return {"user_id": user_id, "state": "DISABLED"}
|
||||
|
||||
@app.post("/api/v2/users/{user_id}/sftp:reset_password")
|
||||
async def reset_sftp_password(user_id: str, req: Request) -> dict[str, Any]:
|
||||
_require_admin(req)
|
||||
u = db.get_user(user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
if not _sftpgo_enabled():
|
||||
raise HTTPException(status_code=400, detail="sftpgo is not enabled")
|
||||
pw = secrets.token_urlsafe(12)
|
||||
try:
|
||||
_sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"sftpgo reset password failed: {e!r}")
|
||||
return {"user_id": user_id, "password": pw}
|
||||
|
||||
@app.post("/api/v2/me/sftp:reset_password")
|
||||
async def reset_my_sftp_password(req: Request) -> dict[str, Any]:
|
||||
"""
|
||||
v3.0 WebUI: allow a user to rotate their own SFTPGo password and copy it.
|
||||
Note: SFTPGo does not allow reading the current password, so this endpoint returns a new one-time password.
|
||||
"""
|
||||
subject = _auth(req)
|
||||
user_id = str(subject["user_id"])
|
||||
u = db.get_user(user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
if not _sftpgo_enabled():
|
||||
raise HTTPException(status_code=400, detail="sftpgo is not enabled")
|
||||
pw = secrets.token_urlsafe(12)
|
||||
try:
|
||||
_sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id))
|
||||
_sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"sftpgo reset password failed: {e!r}")
|
||||
return {"user_id": user_id, "password": pw}
|
||||
|
||||
@app.get("/api/v2/me")
|
||||
async def me(req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
user_id = str(subject["user_id"])
|
||||
try:
|
||||
_ensure_user_dirs(user_id)
|
||||
except Exception:
|
||||
# Best-effort: user may still be able to use API even if FS init fails.
|
||||
pass
|
||||
# Best-effort: reconcile SFTPGo user to include /common read-only mounts.
|
||||
# This makes the virtual folders visible without requiring a password reset.
|
||||
if _sftpgo_enabled() and not subject.get("is_admin"):
|
||||
try:
|
||||
_sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id))
|
||||
except Exception:
|
||||
pass
|
||||
home = _user_home(user_id)
|
||||
out: dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"is_admin": bool(subject.get("is_admin")),
|
||||
"paths": {
|
||||
"home": home,
|
||||
"datasets": f"{home}/datasets",
|
||||
"models": f"{home}/models",
|
||||
"code": f"{home}/code",
|
||||
"jobs": f"{home}/jobs",
|
||||
"trash_jobs": f"{home}/trash/jobs",
|
||||
},
|
||||
"retention": {
|
||||
"jobs_trash_after_days": int(v2_cfg.data.retention.jobs_trash_after_days),
|
||||
"jobs_purge_after_days": int(v2_cfg.data.retention.jobs_purge_after_days),
|
||||
},
|
||||
}
|
||||
if _sftpgo_enabled():
|
||||
out["sftp"] = {
|
||||
"host": v2_cfg.data.sftpgo.host,
|
||||
"port": int(v2_cfg.data.sftpgo.sftp_port),
|
||||
"username": user_id,
|
||||
}
|
||||
return out
|
||||
|
||||
@app.get("/api/v2/tasks")
|
||||
async def list_tasks(req: Request, limit: int = 200, offset: int = 0, states: str | None = None) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
lim = max(1, min(int(limit), 1000))
|
||||
off = max(0, int(offset))
|
||||
state_list: list[str] | None = None
|
||||
if states:
|
||||
raw = [s.strip() for s in str(states).split(",") if s.strip()]
|
||||
# Keep it strict to avoid typos silently returning empty results.
|
||||
allowed = {
|
||||
"QUEUED",
|
||||
"PENDING_RESOURCES",
|
||||
"SUBMITTING",
|
||||
"SUBMITTED",
|
||||
"RUNNING",
|
||||
"SUCCEEDED",
|
||||
"FAILED",
|
||||
"CANCELED",
|
||||
}
|
||||
for s in raw:
|
||||
if s not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"invalid state: {s}")
|
||||
state_list = raw or None
|
||||
if subject.get("is_admin"):
|
||||
tasks = db.list_tasks(user_id=None, states=state_list, limit=lim, offset=off)
|
||||
else:
|
||||
tasks = db.list_tasks(user_id=str(subject["user_id"]), states=state_list, limit=lim, offset=off)
|
||||
return {"tasks": tasks, "limit": lim, "offset": off, "has_more": bool(len(tasks) == lim)}
|
||||
|
||||
@app.post("/api/v2/tasks")
|
||||
async def submit_task(req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
@@ -126,13 +325,40 @@ def create_app(config_path: str) -> FastAPI:
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"invalid jobspec: {e!r}")
|
||||
|
||||
# v2.5 constraint: training inputs must come from /private/common (dev/prod统一)。
|
||||
common_prefix = ray_cfg.shared_root.rstrip("/") + "/common/"
|
||||
for k, v in (("code_path", spec.code_path), ("train_file", spec.train_file), ("val_file", spec.val_file)):
|
||||
# v3.0 path policy:
|
||||
# - code_path: only allow /private/common/...
|
||||
# - train/val: allow /private/common/datasets/... OR /private/users/<me>/datasets/...
|
||||
# - model_id: if it looks like a local path (/private/...), allow only models dirs:
|
||||
# /private/common/models/... OR /private/users/<me>/models/...
|
||||
root = ray_cfg.shared_root.rstrip("/")
|
||||
common_prefix = f"{root}/common/"
|
||||
user_prefix = f"{root}/users/{str(subject['user_id']).strip()}/"
|
||||
|
||||
common_datasets_prefix = f"{common_prefix}datasets/"
|
||||
user_datasets_prefix = f"{user_prefix}datasets/"
|
||||
common_models_prefix = f"{common_prefix}models/"
|
||||
user_models_prefix = f"{user_prefix}models/"
|
||||
|
||||
if not str(spec.code_path).startswith(common_prefix):
|
||||
raise HTTPException(status_code=400, detail=f"code_path must start with {common_prefix}")
|
||||
|
||||
for k, v in (("train_file", spec.train_file), ("val_file", spec.val_file)):
|
||||
if v is None:
|
||||
continue
|
||||
if not str(v).startswith(common_prefix):
|
||||
raise HTTPException(status_code=400, detail=f"{k} must start with {common_prefix}")
|
||||
sv = str(v)
|
||||
if not (sv.startswith(common_datasets_prefix) or sv.startswith(user_datasets_prefix)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{k} must start with {common_datasets_prefix} or {user_datasets_prefix}",
|
||||
)
|
||||
|
||||
model_id = str(spec.model_id)
|
||||
if model_id.startswith(f"{root}/"):
|
||||
if not (model_id.startswith(common_models_prefix) or model_id.startswith(user_models_prefix)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"model_id local path must start with {common_models_prefix} or {user_models_prefix}",
|
||||
)
|
||||
|
||||
task_id = new_task_id(spec.workload, user_id=str(subject["user_id"]))
|
||||
db.create_task_v25(
|
||||
@@ -257,4 +483,7 @@ def create_app(config_path: str) -> FastAPI:
|
||||
return db.list_queue()
|
||||
return db.list_queue(user_id=str(subject["user_id"]))
|
||||
|
||||
# v3.0: minimal WebUI (no server-side session; token stored in browser localStorage).
|
||||
register_ui_routes(app)
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user