v2.5 功能测试通过,待加上docker镜像

This commit is contained in:
2025-12-29 14:33:05 +08:00
parent ce8c2128b4
commit f02059126e
30 changed files with 1794 additions and 66 deletions

View File

@@ -15,3 +15,4 @@
快速开始:
- CLI 提交流程:`scripts/run_all_cli.sh`
- API 提交流程:`scripts/run_all_api.sh`
- v2.5(Stateless worker + user 隔离 jobs)E2E:`scripts/run_all_v25_api.sh`

View File

@@ -2,14 +2,27 @@ from __future__ import annotations
import secrets
from datetime import datetime
import re
def new_task_id(workload: str) -> str:
_USER_SAFE_RE = re.compile(r"[^a-z0-9_-]+")
def _normalize_user_id(user_id: str) -> str:
s = (user_id or "").strip().lower()
s = _USER_SAFE_RE.sub("-", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s[:24] if s else ""
def new_task_id(workload: str, *, user_id: str | None = None) -> str:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = secrets.token_hex(2)
u = _normalize_user_id(user_id or "")
if u:
return f"mvp2-{u}-{workload}-{ts}-{suffix}"
return f"mvp2-{workload}-{ts}-{suffix}"
def attempt_submission_id(task_id: str, attempt_no: int) -> str:
return f"{task_id}--a{attempt_no:02d}"

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _parse_utc(ts: str) -> datetime:
# Accept ISO8601 with trailing Z
s = ts.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s).astimezone(timezone.utc)
def _iso_z(dt: datetime) -> str:
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@dataclass(frozen=True)
class HeadRecord:
cluster_name: str
head_ip: str
gcs_port: int
dashboard_port: int
job_server_url: str
updated_at: str
expires_at: str
@staticmethod
def from_dict(d: dict[str, Any]) -> "HeadRecord":
return HeadRecord(
cluster_name=str(d["cluster_name"]),
head_ip=str(d["head_ip"]),
gcs_port=int(d.get("gcs_port", 6379)),
dashboard_port=int(d.get("dashboard_port", 8265)),
job_server_url=str(d.get("job_server_url") or f"http://{d['head_ip']}:{int(d.get('dashboard_port', 8265))}"),
updated_at=str(d["updated_at"]),
expires_at=str(d["expires_at"]),
)
def to_dict(self) -> dict[str, Any]:
return {
"cluster_name": self.cluster_name,
"head_ip": self.head_ip,
"gcs_port": self.gcs_port,
"dashboard_port": self.dashboard_port,
"job_server_url": self.job_server_url,
"updated_at": self.updated_at,
"expires_at": self.expires_at,
}
def head_addr(self) -> str:
return f"{self.head_ip}:{self.gcs_port}"
def build_head_record(
*,
cluster_name: str,
head_ip: str,
gcs_port: int = 6379,
dashboard_port: int = 8265,
ttl_s: int = 60,
now: datetime | None = None,
) -> HeadRecord:
now_dt = now or _utc_now()
expires = now_dt + timedelta(seconds=int(ttl_s))
updated_at = _iso_z(now_dt)
expires_at = _iso_z(expires)
return HeadRecord(
cluster_name=cluster_name,
head_ip=head_ip,
gcs_port=int(gcs_port),
dashboard_port=int(dashboard_port),
job_server_url=f"http://{head_ip}:{int(dashboard_port)}",
updated_at=updated_at,
expires_at=expires_at,
)
def load_head_record(path: str, *, now: datetime | None = None) -> HeadRecord | None:
p = Path(path)
if not p.exists():
return None
try:
obj = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
if not isinstance(obj, dict):
return None
try:
rec = HeadRecord.from_dict(obj)
expires = _parse_utc(rec.expires_at)
except Exception:
return None
now_dt = now or _utc_now()
if expires <= now_dt:
return None
return rec
def write_head_record_atomic(path: str, rec: HeadRecord) -> None:
p = Path(path)
os.makedirs(p.parent, exist_ok=True)
tmp = p.with_suffix(p.suffix + ".tmp")
tmp.write_text(json.dumps(rec.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(p)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import argparse
import time
from .discovery import build_head_record, write_head_record_atomic
def publish_once(
*,
cluster_name: str,
head_ip_file: str,
head_ip: str,
gcs_port: int,
dashboard_port: int,
ttl_s: int,
) -> None:
rec = build_head_record(
cluster_name=cluster_name,
head_ip=head_ip,
gcs_port=gcs_port,
dashboard_port=dashboard_port,
ttl_s=ttl_s,
)
write_head_record_atomic(head_ip_file, rec)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Publish Ray head address to shared storage (head.json).")
ap.add_argument("--cluster-name", required=True)
ap.add_argument("--head-ip-file", required=True)
ap.add_argument("--head-ip", required=True)
ap.add_argument("--gcs-port", type=int, default=6379)
ap.add_argument("--dashboard-port", type=int, default=8265)
ap.add_argument("--ttl-s", type=int, default=60)
ap.add_argument("--refresh-s", type=int, default=10)
ap.add_argument("--once", action="store_true", help="Write once then exit (for testing/debug).")
args = ap.parse_args(argv)
if args.once:
publish_once(
cluster_name=args.cluster_name,
head_ip_file=args.head_ip_file,
head_ip=args.head_ip,
gcs_port=args.gcs_port,
dashboard_port=args.dashboard_port,
ttl_s=args.ttl_s,
)
return 0
refresh_s = max(1, int(args.refresh_s))
while True:
publish_once(
cluster_name=args.cluster_name,
head_ip_file=args.head_ip_file,
head_ip=args.head_ip,
gcs_port=args.gcs_port,
dashboard_port=args.dashboard_port,
ttl_s=args.ttl_s,
)
time.sleep(refresh_s)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -73,9 +73,9 @@ class RayJobTool:
return {"env_vars": env_vars}
def submit(self, spec: JobSpec, no_wait: bool) -> str:
def submit(self, spec: JobSpec, no_wait: bool, job_dir: str | None = None) -> str:
submission_id = spec.submission_id or f"mvp11_{spec.workload}_{_ts()}_{os.getpid()}"
job_dir = self._job_dir(submission_id)
job_dir = job_dir or self._job_dir(submission_id)
built = build_training_argv(spec, submission_id=submission_id, job_dir=job_dir)
entrypoint_argv = [

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import argparse
import json
import subprocess
import time
from dataclasses import dataclass
from typing import Callable
from .discovery import HeadRecord, load_head_record
Runner = Callable[[list[str]], int]
def _default_runner(argv: list[str]) -> int:
proc = subprocess.run(argv, check=False)
return int(proc.returncode)
def _ray_stop_cmd() -> list[str]:
return ["ray", "stop", "--force"]
def _ray_start_cmd(*, head_addr: str, node_ip: str | None, resources_json: str) -> list[str]:
argv = ["ray", "start", f"--address={head_addr}", f"--resources={resources_json}", "--disable-usage-stats"]
if node_ip:
argv.append(f"--node-ip-address={node_ip}")
return argv
@dataclass
class Watchdog:
head_ip_file: str
node_ip: str | None
resources_json: str
poll_s: int
runner: Runner
_current_head_addr: str | None = None
def _restart_ray(self, desired: str) -> None:
# Best-effort stop then start.
self.runner(_ray_stop_cmd())
self.runner(_ray_start_cmd(head_addr=desired, node_ip=self.node_ip, resources_json=self.resources_json))
self._current_head_addr = desired
def tick_once(self) -> HeadRecord | None:
rec = load_head_record(self.head_ip_file)
if rec is None:
return None
desired = rec.head_addr()
if self._current_head_addr != desired:
self._restart_ray(desired)
return rec
def run_forever(self) -> None:
while True:
try:
self.tick_once()
except Exception:
# keep watchdog alive
pass
time.sleep(max(1, int(self.poll_s)))
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Stateless Ray worker watchdog (auto-connect + reconnect).")
ap.add_argument("--head-ip-file", required=True)
ap.add_argument("--node-ip", default=None)
ap.add_argument(
"--resources-kv",
action="append",
default=[],
help='Repeatable k=v resources, e.g. --resources-kv worker_node=100',
)
ap.add_argument("--poll-s", type=int, default=5)
ap.add_argument("--once", action="store_true", help="Run one tick then exit (for testing/debug).")
args = ap.parse_args(argv)
resources: dict[str, float] = {"worker_node": 100.0}
if args.resources_kv:
resources = {}
for item in args.resources_kv:
if "=" not in item:
raise SystemExit(f"invalid --resources-kv (expected k=v): {item!r}")
k, v = item.split("=", 1)
k = k.strip()
v = v.strip()
if not k:
raise SystemExit(f"invalid --resources-kv key: {item!r}")
resources[k] = float(v)
wd = Watchdog(
head_ip_file=args.head_ip_file,
node_ip=args.node_ip,
resources_json=json.dumps(resources),
poll_s=int(args.poll_s),
runner=_default_runner,
)
if args.once:
wd.tick_once()
return 0
wd.run_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -43,19 +43,32 @@ def create_app(config_path: str) -> FastAPI:
app = FastAPI(title="mvp-v2", version="2.0")
def _require_token(req: Request) -> None:
def _auth(req: Request) -> dict[str, Any]:
token_env = v2_cfg.auth.token_env
expected = os.environ.get(token_env, "")
if not expected:
# Misconfigured service; treat as server error.
admin_token = os.environ.get(token_env, "")
if not admin_token:
raise HTTPException(status_code=500, detail=f"missing token env: {token_env}")
auth = req.headers.get("authorization") or ""
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
got = auth.removeprefix("Bearer ").strip()
if got != expected:
if got == admin_token:
return {"user_id": "admin", "is_admin": True}
info = db.resolve_token(got)
if not info:
raise HTTPException(status_code=401, detail="invalid token")
if info.get("state") != "ACTIVE":
raise HTTPException(status_code=403, detail="user disabled")
return {"user_id": info["user_id"], "is_admin": False}
def _require_admin(req: Request) -> dict[str, Any]:
subject = _auth(req)
if not subject.get("is_admin"):
raise HTTPException(status_code=403, detail="admin required")
return subject
@app.on_event("startup")
def _startup() -> None:
@@ -66,9 +79,43 @@ def create_app(config_path: str) -> FastAPI:
def _shutdown() -> None:
stop_flag.set()
@app.post("/api/v2/users")
async def create_user(req: Request) -> dict[str, Any]:
_require_admin(req)
obj = await req.json()
if not isinstance(obj, dict):
raise HTTPException(status_code=400, detail="body must be a JSON object")
user_id = str(obj.get("user_id") or "").strip()
if not user_id:
raise HTTPException(status_code=400, detail="missing user_id")
display_name = obj.get("display_name")
try:
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")}
@app.post("/api/v2/users/{user_id}/tokens")
async def issue_token(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")
token = db.issue_token(user_id=user_id)
return {"user_id": user_id, "token": token}
@app.post("/api/v2/users/{user_id}:disable")
async def disable_user(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")
db.disable_user(user_id=user_id)
return {"user_id": user_id, "state": "DISABLED"}
@app.post("/api/v2/tasks")
async def submit_task(req: Request) -> dict[str, Any]:
_require_token(req)
subject = _auth(req)
body = (await req.body()).decode("utf-8")
obj = yaml.safe_load(body) or {}
if not isinstance(obj, dict):
@@ -79,9 +126,18 @@ def create_app(config_path: str) -> FastAPI:
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid jobspec: {e!r}")
task_id = new_task_id(spec.workload)
db.create_task(
# 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)):
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}")
task_id = new_task_id(spec.workload, user_id=str(subject["user_id"]))
db.create_task_v25(
task_id=task_id,
user_id=str(subject["user_id"]),
workload=spec.workload,
jobspec_yaml=body,
nnodes=spec.nnodes,
@@ -91,10 +147,13 @@ def create_app(config_path: str) -> FastAPI:
@app.get("/api/v2/tasks/{task_id}")
async def get_task(task_id: str, req: Request) -> dict[str, Any]:
_require_token(req)
subject = _auth(req)
row = db.get_task(task_id)
if not row:
raise HTTPException(status_code=404, detail="task not found")
if not subject.get("is_admin"):
if str(row.get("user_id") or "") != str(subject["user_id"]):
raise HTTPException(status_code=404, detail="task not found")
attempts = db.list_attempts(task_id)
latest_attempt = attempts[-1] if attempts else None
desired = {
@@ -125,18 +184,24 @@ def create_app(config_path: str) -> FastAPI:
@app.get("/api/v2/tasks/{task_id}/attempts")
async def get_attempts(task_id: str, req: Request) -> dict[str, Any]:
_require_token(req)
subject = _auth(req)
row = db.get_task(task_id)
if not row:
raise HTTPException(status_code=404, detail="task not found")
if not subject.get("is_admin"):
if str(row.get("user_id") or "") != str(subject["user_id"]):
raise HTTPException(status_code=404, detail="task not found")
return {"task_id": task_id, "attempts": db.list_attempts(task_id)}
@app.post("/api/v2/tasks/{task_id}:cancel")
async def cancel(task_id: str, req: Request) -> dict[str, Any]:
_require_token(req)
subject = _auth(req)
row = db.get_task(task_id)
if not row:
raise HTTPException(status_code=404, detail="task not found")
if not subject.get("is_admin"):
if str(row.get("user_id") or "") != str(subject["user_id"]):
raise HTTPException(status_code=404, detail="task not found")
state = str(row["state"])
if state in ("SUCCEEDED", "FAILED", "CANCELED"):
@@ -165,10 +230,13 @@ def create_app(config_path: str) -> FastAPI:
@app.get("/api/v2/tasks/{task_id}/logs")
async def logs(task_id: str, req: Request, tail: int = 2000, attempt: str = "latest") -> Response:
_require_token(req)
subject = _auth(req)
row = db.get_task(task_id)
if not row:
raise HTTPException(status_code=404, detail="task not found")
if not subject.get("is_admin"):
if str(row.get("user_id") or "") != str(subject["user_id"]):
raise HTTPException(status_code=404, detail="task not found")
attempts = db.list_attempts(task_id)
if not attempts:
raise HTTPException(status_code=404, detail="no attempts yet")
@@ -184,7 +252,9 @@ def create_app(config_path: str) -> FastAPI:
@app.get("/api/v2/queue")
async def queue(req: Request) -> dict[str, Any]:
_require_token(req)
return db.list_queue()
subject = _auth(req)
if subject.get("is_admin"):
return db.list_queue()
return db.list_queue(user_id=str(subject["user_id"]))
return app

View File

@@ -4,6 +4,8 @@ import os
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
import hashlib
import secrets
from typing import Any, Iterator
@@ -18,6 +20,10 @@ def _utc_now_iso() -> str:
class Db:
db_path: str
def _hash_token(self, token: str) -> str:
# Internal tokens only; store hash to avoid plaintext at rest.
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _connect(self) -> sqlite3.Connection:
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
conn = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
@@ -28,6 +34,27 @@ class Db:
def init(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
display_name TEXT,
state TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS tasks (
@@ -45,6 +72,12 @@ class Db:
)
"""
)
# Best-effort: add user_id column for forward compatibility (v2.5+).
# v2.0 tables may exist without it; SQLite supports ADD COLUMN.
try:
conn.execute("ALTER TABLE tasks ADD COLUMN user_id TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE TABLE IF NOT EXISTS attempts (
@@ -92,10 +125,10 @@ class Db:
with self.tx() as conn:
conn.execute(
"""
INSERT INTO tasks (task_id, workload, state, jobspec_yaml, nnodes, n_gpus_per_node, created_at, updated_at)
VALUES (?, ?, 'QUEUED', ?, ?, ?, ?, ?)
INSERT INTO tasks (task_id, workload, state, jobspec_yaml, nnodes, n_gpus_per_node, created_at, updated_at, user_id)
VALUES (?, ?, 'QUEUED', ?, ?, ?, ?, ?, ?)
""",
(task_id, workload, jobspec_yaml, nnodes, n_gpus_per_node, now, now),
(task_id, workload, jobspec_yaml, nnodes, n_gpus_per_node, now, now, None),
)
conn.execute(
"INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (?, ?, 'TASK_CREATED', ?)",
@@ -104,6 +137,97 @@ class Db:
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return dict(row) if row else {}
def create_task_v25(
self,
*,
task_id: str,
user_id: str,
workload: str,
jobspec_yaml: str,
nnodes: int,
n_gpus_per_node: int,
) -> dict[str, Any]:
now = _utc_now_iso()
with self.tx() as conn:
conn.execute(
"""
INSERT INTO tasks (task_id, user_id, workload, state, jobspec_yaml, nnodes, n_gpus_per_node, created_at, updated_at)
VALUES (?, ?, ?, 'QUEUED', ?, ?, ?, ?, ?)
""",
(task_id, user_id, workload, jobspec_yaml, nnodes, n_gpus_per_node, now, now),
)
conn.execute(
"INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (?, ?, 'TASK_CREATED', ?)",
(task_id, now, None),
)
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
return dict(row) if row else {}
def create_user(self, *, user_id: str, display_name: str | None = None) -> dict[str, Any]:
now = _utc_now_iso()
with self.tx() as conn:
conn.execute(
"""
INSERT INTO users (user_id, display_name, state, created_at)
VALUES (?, ?, 'ACTIVE', ?)
""",
(user_id, display_name, now),
)
conn.execute(
"INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'USER_CREATED', ?)",
(now, user_id),
)
row = conn.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone()
return dict(row) if row else {}
def disable_user(self, *, user_id: str) -> None:
now = _utc_now_iso()
with self.tx() as conn:
conn.execute("UPDATE users SET state = 'DISABLED' WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'USER_DISABLED', ?)",
(now, user_id),
)
def get_user(self, user_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def issue_token(self, *, user_id: str) -> str:
# Returns plaintext token once; stores hash only.
now = _utc_now_iso()
token = f"mvp_u_{user_id}_{secrets.token_urlsafe(18)}"
token_hash = self._hash_token(token)
with self.tx() as conn:
conn.execute(
"INSERT INTO api_tokens (token_hash, user_id, created_at) VALUES (?, ?, ?)",
(token_hash, user_id, now),
)
conn.execute(
"INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'TOKEN_ISSUED', ?)",
(now, user_id),
)
return token
def resolve_token(self, token: str) -> dict[str, Any] | None:
token_hash = self._hash_token(token)
with self.tx() as conn:
row = conn.execute(
"""
SELECT t.user_id, u.state
FROM api_tokens t
JOIN users u ON u.user_id = t.user_id
WHERE t.token_hash = ?
""",
(token_hash,),
).fetchone()
if not row:
return None
now = _utc_now_iso()
conn.execute("UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?", (now, token_hash))
return {"user_id": row["user_id"], "state": row["state"]}
def get_task(self, task_id: str) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
@@ -116,26 +240,33 @@ class Db:
).fetchall()
return [dict(r) for r in rows]
def list_queue(self) -> dict[str, list[dict[str, Any]]]:
def list_queue(self, *, user_id: str | None = None) -> dict[str, list[dict[str, Any]]]:
with self._connect() as conn:
pending = conn.execute(
"""
SELECT task_id, workload, state, nnodes, n_gpus_per_node, next_run_at, created_at, updated_at
FROM tasks
WHERE state IN ('QUEUED','PENDING_RESOURCES')
ORDER BY created_at ASC
LIMIT 200
"""
).fetchall()
running = conn.execute(
"""
SELECT task_id, workload, state, nnodes, n_gpus_per_node, latest_attempt_no, created_at, updated_at
FROM tasks
WHERE state IN ('SUBMITTING','SUBMITTED','RUNNING')
ORDER BY updated_at ASC
LIMIT 200
"""
).fetchall()
params: list[Any] = []
user_filter_sql = ""
if user_id is not None:
user_filter_sql = " AND user_id = ?"
params = [user_id]
pending_sql = (
"SELECT task_id, workload, state, nnodes, n_gpus_per_node, next_run_at, created_at, updated_at "
"FROM tasks "
"WHERE state IN ('QUEUED','PENDING_RESOURCES')"
f"{user_filter_sql} "
"ORDER BY created_at ASC "
"LIMIT 200"
)
running_sql = (
"SELECT task_id, workload, state, nnodes, n_gpus_per_node, latest_attempt_no, created_at, updated_at "
"FROM tasks "
"WHERE state IN ('SUBMITTING','SUBMITTED','RUNNING')"
f"{user_filter_sql} "
"ORDER BY updated_at ASC "
"LIMIT 200"
)
pending = conn.execute(pending_sql, tuple(params)).fetchall()
running = conn.execute(running_sql, tuple(params)).fetchall()
return {"pending": [dict(r) for r in pending], "running": [dict(r) for r in running]}
def count_running(self) -> int:

View File

@@ -37,6 +37,12 @@ class Scheduler:
def __post_init__(self) -> None:
self.tool = RayJobTool(self.ray_cfg)
def _job_dir_for_task(self, *, user_id: str | None, ray_submission_id: str) -> str:
root = self.ray_cfg.shared_root.rstrip("/")
if user_id:
return f"{root}/users/{user_id}/jobs/{ray_submission_id}"
return f"{root}/jobs/{ray_submission_id}"
def _resources_sufficient(self, *, nnodes: int, n_gpus_per_node: int) -> bool:
avail = get_cluster_available()
required = float(nnodes * n_gpus_per_node)
@@ -51,10 +57,13 @@ class Scheduler:
def _submit_one(self, task_row: dict[str, Any]) -> None:
task_id = str(task_row["task_id"])
jobspec_yaml = str(task_row["jobspec_yaml"])
user_id = task_row.get("user_id")
user_id_s = str(user_id) if user_id not in (None, "") else None
spec = self._parse_jobspec(jobspec_yaml)
attempt_no = int(task_row.get("latest_attempt_no", 0)) + 1
ray_sid = attempt_submission_id(task_id, attempt_no)
job_dir = self._job_dir_for_task(user_id=user_id_s, ray_submission_id=ray_sid)
# Record attempt first so that we can surface it even if submit crashes.
self.db.create_attempt(task_id=task_id, attempt_no=attempt_no, ray_submission_id=ray_sid)
@@ -66,7 +75,7 @@ class Scheduler:
spec2 = JobSpec.from_dict(d)
try:
submitted = self.tool.submit(spec2, no_wait=True)
submitted = self.tool.submit(spec2, no_wait=True, job_dir=job_dir)
# submitted should equal ray_sid; keep as source of truth.
self.db.update_attempt(task_id=task_id, attempt_no=attempt_no, ray_status="SUBMITTED")
self.db.set_task_state(task_id=task_id, state="SUBMITTED")

View File

@@ -12,7 +12,7 @@ def _write_config(tmp_path: Path) -> Path:
cfg = {
"ray": {
"address": "http://127.0.0.1:8265",
"shared_root": str(tmp_path),
"shared_root": "/private",
"entrypoint_resources": {"worker_node": 1},
"runtime_env": {"env_vars": {}},
},
@@ -54,7 +54,7 @@ def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch):
cfg_path = _write_config(tmp_path)
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1")
monkeypatch.setattr(app_mod, "new_task_id", lambda workload: "tid1")
monkeypatch.setattr(app_mod, "new_task_id", lambda workload, **kwargs: "tid1")
class _Tool:
def __init__(self):
@@ -82,7 +82,7 @@ def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch):
r = c.post(
"/api/v2/tasks",
headers=headers,
data="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n",
data="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\n",
)
assert r.status_code == 200
assert r.json()["task_id"] == "tid1"
@@ -111,7 +111,7 @@ def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch):
db.create_task(
task_id="tid2",
workload="ppo",
jobspec_yaml="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: t\n",
jobspec_yaml="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\n",
nnodes=2,
n_gpus_per_node=4,
)
@@ -163,4 +163,3 @@ def test_submit_rejects_invalid_jobspec(tmp_path: Path, monkeypatch):
with TestClient(app) as c:
r = c.post("/api/v2/tasks", headers={"authorization": "Bearer token1"}, data="workload: nope\n")
assert r.status_code == 400

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from argus.ray.discovery import build_head_record, load_head_record
def test_load_head_record_rejects_missing(tmp_path: Path):
assert load_head_record(str(tmp_path / "nope.json")) is None
def test_load_head_record_rejects_expired(tmp_path: Path):
p = tmp_path / "head.json"
now = datetime.now(timezone.utc).replace(microsecond=0)
rec = build_head_record(cluster_name="c", head_ip="1.2.3.4", ttl_s=1, now=now)
p.write_text(json.dumps(rec.to_dict()), encoding="utf-8")
assert load_head_record(str(p), now=now + timedelta(seconds=2)) is None
def test_load_head_record_accepts_fresh(tmp_path: Path):
p = tmp_path / "head.json"
now = datetime.now(timezone.utc).replace(microsecond=0)
rec = build_head_record(cluster_name="c", head_ip="1.2.3.4", ttl_s=60, now=now)
p.write_text(json.dumps(rec.to_dict()), encoding="utf-8")
got = load_head_record(str(p), now=now + timedelta(seconds=1))
assert got is not None
assert got.head_addr() == "1.2.3.4:6379"
def test_head_publisher_main_once_writes_file(tmp_path: Path):
from argus.ray import head_publisher as mod
p = tmp_path / "head.json"
rc = mod.main(
[
"--cluster-name",
"c",
"--head-ip-file",
str(p),
"--head-ip",
"9.9.9.9",
"--ttl-s",
"60",
"--once",
]
)
assert rc == 0
assert "9.9.9.9" in p.read_text(encoding="utf-8")

View File

@@ -20,9 +20,27 @@ def test_new_task_id_is_deterministic_with_patches(monkeypatch):
assert ids.new_task_id("ppo") == "mvp2-ppo-20250101-010203-abcd"
def test_new_task_id_includes_user_when_provided(monkeypatch):
import argus.core.ids as ids
class _FakeDatetime:
@staticmethod
def now():
class _DT:
def strftime(self, fmt: str) -> str:
assert fmt == "%Y%m%d-%H%M%S"
return "20250101-010203"
return _DT()
monkeypatch.setattr(ids, "datetime", _FakeDatetime)
monkeypatch.setattr(ids.secrets, "token_hex", lambda n: "abcd")
assert ids.new_task_id("ppo", user_id="Alice_01") == "mvp2-alice_01-ppo-20250101-010203-abcd"
def test_attempt_submission_id_format():
from argus.core.ids import attempt_submission_id
assert attempt_submission_id("t", 1) == "t--a01"
assert attempt_submission_id("t", 12) == "t--a12"

View File

@@ -14,7 +14,7 @@ def _mk_cfg(tmp_path: Path) -> tuple[RayConfig, V2Config]:
root = {
"ray": {
"address": "http://127.0.0.1:8265",
"shared_root": str(tmp_path),
"shared_root": "/private",
"entrypoint_resources": {"worker_node": 1},
"runtime_env": {"env_vars": {}},
},
@@ -32,10 +32,11 @@ def test_tick_submits_one_task(monkeypatch, tmp_path: Path):
ray_cfg, v2_cfg = _mk_cfg(tmp_path)
db = Db(v2_cfg.sqlite.db_path)
db.init()
db.create_task(
db.create_task_v25(
task_id="t1",
user_id="alice",
workload="ppo",
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}),
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/private/common/code/verl", "model_id": "m", "train_file": "/private/common/datasets/t"}),
nnodes=2,
n_gpus_per_node=4,
)
@@ -50,9 +51,11 @@ def test_tick_submits_one_task(monkeypatch, tmp_path: Path):
class _Tool:
def __init__(self, cfg):
self.submitted = []
self.job_dirs = []
def submit(self, spec, no_wait: bool):
def submit(self, spec, no_wait: bool, job_dir: str | None = None):
self.submitted.append(spec.submission_id)
self.job_dirs.append(job_dir)
return str(spec.submission_id)
def status(self, submission_id: str):
@@ -71,6 +74,7 @@ def test_tick_submits_one_task(monkeypatch, tmp_path: Path):
attempts = db.list_attempts("t1")
assert len(attempts) == 1
assert attempts[0]["ray_submission_id"] == "t1--a01"
assert s.tool.job_dirs[-1] == "/private/users/alice/jobs/t1--a01"
def test_tick_marks_pending_resources(monkeypatch, tmp_path: Path):
@@ -79,10 +83,11 @@ def test_tick_marks_pending_resources(monkeypatch, tmp_path: Path):
ray_cfg, v2_cfg = _mk_cfg(tmp_path)
db = Db(v2_cfg.sqlite.db_path)
db.init()
db.create_task(
db.create_task_v25(
task_id="t1",
user_id="alice",
workload="ppo",
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}),
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/private/common/code/verl", "model_id": "m", "train_file": "/private/common/datasets/t"}),
nnodes=2,
n_gpus_per_node=4,
)
@@ -108,10 +113,11 @@ def test_sync_failed_insufficient_resources(monkeypatch, tmp_path: Path):
ray_cfg, v2_cfg = _mk_cfg(tmp_path)
db = Db(v2_cfg.sqlite.db_path)
db.init()
db.create_task(
db.create_task_v25(
task_id="t1",
user_id="alice",
workload="ppo",
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}),
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/private/common/code/verl", "model_id": "m", "train_file": "/private/common/datasets/t"}),
nnodes=2,
n_gpus_per_node=4,
)
@@ -147,10 +153,11 @@ def test_sync_status_error_keeps_state(monkeypatch, tmp_path: Path):
ray_cfg, v2_cfg = _mk_cfg(tmp_path)
db = Db(v2_cfg.sqlite.db_path)
db.init()
db.create_task(
db.create_task_v25(
task_id="t1",
user_id="alice",
workload="ppo",
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/c", "model_id": "m", "train_file": "t"}),
jobspec_yaml=yaml.safe_dump({"workload": "ppo", "code_path": "/private/common/code/verl", "model_id": "m", "train_file": "/private/common/datasets/t"}),
nnodes=2,
n_gpus_per_node=4,
)

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from pathlib import Path
import yaml
from fastapi.testclient import TestClient
def _write_config(tmp_path: Path) -> Path:
cfg = {
"ray": {
"address": "http://127.0.0.1:8265",
"shared_root": "/private",
"entrypoint_resources": {"worker_node": 1},
"runtime_env": {"env_vars": {}},
},
"service": {
"api": {"host": "127.0.0.1", "port": 0},
"auth": {"token_env": "MVP_INTERNAL_TOKEN"},
"sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")},
"scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1},
},
}
p = tmp_path / "cfg.yaml"
p.write_text(yaml.safe_dump(cfg), encoding="utf-8")
return p
def test_db_user_token_lifecycle(tmp_path: Path):
from argus.service.db import Db
db = Db(str(tmp_path / "mvp.sqlite3"))
db.init()
db.create_user(user_id="alice", display_name="Alice")
tok = db.issue_token(user_id="alice")
info = db.resolve_token(tok)
assert info and info["user_id"] == "alice"
db.disable_user(user_id="alice")
info2 = db.resolve_token(tok)
assert info2 and info2["state"] == "DISABLED"
def test_admin_create_user_issue_token_and_disabled_rejected(tmp_path: Path, monkeypatch):
from argus.service import app as app_mod
cfg_path = _write_config(tmp_path)
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1")
class _Scheduler:
def __init__(self, **kwargs):
self.tool = object()
def run_forever(self, stop_flag):
return None
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
app = app_mod.create_app(str(cfg_path))
admin_headers = {"authorization": "Bearer adm1"}
with TestClient(app) as c:
r1 = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice", "display_name": "Alice"})
assert r1.status_code == 200
assert r1.json()["user_id"] == "alice"
r2 = c.post("/api/v2/users/alice/tokens", headers=admin_headers)
assert r2.status_code == 200
token = r2.json()["token"]
assert token
# non-admin token can access regular endpoints
r3 = c.get("/api/v2/queue", headers={"authorization": f"Bearer {token}"})
assert r3.status_code == 200
r4 = c.post("/api/v2/users/alice:disable", headers=admin_headers)
assert r4.status_code == 200
r5 = c.get("/api/v2/queue", headers={"authorization": f"Bearer {token}"})
assert r5.status_code == 403
def test_tasks_are_isolated_by_user(tmp_path: Path, monkeypatch):
from argus.service import app as app_mod
cfg_path = _write_config(tmp_path)
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1")
# Deterministic task ids
counter = {"n": 0}
def _new_id(workload: str, **kwargs) -> str:
counter["n"] += 1
return f"{workload}_t{counter['n']}"
monkeypatch.setattr(app_mod, "new_task_id", _new_id)
class _Scheduler:
def __init__(self, **kwargs):
self.tool = object()
def run_forever(self, stop_flag):
return None
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
app = app_mod.create_app(str(cfg_path))
admin_headers = {"authorization": "Bearer adm1"}
with TestClient(app) as c:
# Create users and tokens
assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200
assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "bob"}).status_code == 200
alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
bob_tok = c.post("/api/v2/users/bob/tokens", headers=admin_headers).json()["token"]
alice_headers = {"authorization": f"Bearer {alice_tok}"}
bob_headers = {"authorization": f"Bearer {bob_tok}"}
# Each user submits one task.
r1 = c.post(
"/api/v2/tasks",
headers=alice_headers,
data="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\n",
)
assert r1.status_code == 200
alice_tid = r1.json()["task_id"]
r2 = c.post(
"/api/v2/tasks",
headers=bob_headers,
data="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\n",
)
assert r2.status_code == 200
bob_tid = r2.json()["task_id"]
# Queue is scoped to user.
qa = c.get("/api/v2/queue", headers=alice_headers).json()
qb = c.get("/api/v2/queue", headers=bob_headers).json()
assert {t["task_id"] for t in qa["pending"]} == {alice_tid}
assert {t["task_id"] for t in qb["pending"]} == {bob_tid}
# Cross-user access returns 404 (no existence leak).
assert c.get(f"/api/v2/tasks/{bob_tid}", headers=alice_headers).status_code == 404
assert c.post(f"/api/v2/tasks/{bob_tid}:cancel", headers=alice_headers).status_code == 404
assert c.get(f"/api/v2/tasks/{bob_tid}/attempts", headers=alice_headers).status_code == 404
# Admin can see global queue.
qadm = c.get("/api/v2/queue", headers=admin_headers).json()
assert {t["task_id"] for t in qadm["pending"]} == {alice_tid, bob_tid}
def test_submit_rejects_non_common_inputs(tmp_path: Path, monkeypatch):
from argus.service import app as app_mod
cfg_path = _write_config(tmp_path)
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1")
class _Scheduler:
def __init__(self, **kwargs):
self.tool = object()
def run_forever(self, stop_flag):
return None
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
app = app_mod.create_app(str(cfg_path))
admin_headers = {"authorization": "Bearer adm1"}
with TestClient(app) as c:
assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200
alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
alice_headers = {"authorization": f"Bearer {alice_tok}"}
r = c.post(
"/api/v2/tasks",
headers=alice_headers,
data="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: /private/common/datasets/t\n",
)
assert r.status_code == 400
assert "code_path must start with /private/common/" in r.text

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
from argus.ray.discovery import build_head_record, write_head_record_atomic
from argus.ray.worker_watchdog import Watchdog
def test_watchdog_restarts_on_first_seen_and_on_change(tmp_path: Path):
head_file = tmp_path / "head.json"
calls: list[list[str]] = []
def runner(argv: list[str]) -> int:
calls.append(argv)
return 0
wd = Watchdog(
head_ip_file=str(head_file),
node_ip="10.0.0.2",
resources_json=json.dumps({"worker_node": 100}),
poll_s=1,
runner=runner,
)
write_head_record_atomic(str(head_file), build_head_record(cluster_name="c", head_ip="1.1.1.1"))
assert wd.tick_once() is not None
assert any("ray" in c[0] and c[1] == "start" for c in calls)
calls.clear()
# Same address -> no restart
write_head_record_atomic(str(head_file), build_head_record(cluster_name="c", head_ip="1.1.1.1"))
wd.tick_once()
assert calls == []
# Address change -> restart
write_head_record_atomic(str(head_file), build_head_record(cluster_name="c", head_ip="2.2.2.2"))
wd.tick_once()
assert any(c[1] == "stop" for c in calls)
assert any(c[1] == "start" for c in calls)
def test_watchdog_main_once_invokes_runner(monkeypatch, tmp_path: Path):
from argus.ray import worker_watchdog as mod
head_file = tmp_path / "head.json"
write_head_record_atomic(str(head_file), build_head_record(cluster_name="c", head_ip="1.1.1.1"))
calls: list[list[str]] = []
def runner(argv: list[str]) -> int:
calls.append(argv)
return 0
monkeypatch.setattr(mod, "_default_runner", runner)
rc = mod.main(["--head-ip-file", str(head_file), "--node-ip", "10.0.0.2", "--resources-kv", "worker_node=100", "--once"])
assert rc == 0
assert any(c[1] == "start" for c in calls)

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[host] cleanup v2 legacy containers/processes (best-effort)"
# Known historical container names from v1.1/v2 era
LEGACY=(
mvp11-ray-head
mvp11-ray-worker-0
mvp11-ray-worker-1
mvp2-ray-head
mvp2-ray-worker-0
mvp2-ray-worker-1
)
for c in "${LEGACY[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -qx "${c}"; then
echo "[host] removing legacy container: ${c}"
docker rm -f "${c}" >/dev/null 2>&1 || true
fi
done
echo "[host] legacy v2 cleanup done"

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
CLUSTER_NAME="${CLUSTER_NAME:-argus-ray}"
HEAD_IP_FILE="${HEAD_IP_FILE:-${SHARED_ROOT}/ray/discovery/${CLUSTER_NAME}/head.json}"
TTL_S="${TTL_S:-60}"
REFRESH_S="${REFRESH_S:-10}"
# PID file must be container-local to avoid conflicts if /private is shared across containers.
PID_PATH="${PID_PATH:-/tmp/argus_head_publisher.pid}"
LOG_PATH="${LOG_PATH:-${SHARED_ROOT}/common/logs/argus_head_publisher.log}"
HEAD_IP="$(container_ip "${HEAD_CONTAINER}")"
echo "[head] start discovery publisher (supervised): ${HEAD_IP_FILE} (head_ip=${HEAD_IP})"
# stop existing (best-effort)
dexec "${HEAD_CONTAINER}" bash -lc "if test -f '${PID_PATH}'; then pid=\$(cat '${PID_PATH}'); if kill -0 \"\${pid}\" >/dev/null 2>&1; then kill \"\${pid}\" || true; fi; rm -f '${PID_PATH}'; fi"
dexec "${HEAD_CONTAINER}" bash -lc "mkdir -p \"$(dirname "${LOG_PATH}")\" \"$(dirname "${HEAD_IP_FILE}")\""
# Supervisor loop: restart publisher if it exits.
docker exec -d "${HEAD_CONTAINER}" bash -lc "
nohup bash -lc '
export PYTHONPATH=/workspace/mvp/py
while true; do
python3 -m argus.ray.head_publisher \
--cluster-name \"${CLUSTER_NAME}\" \
--head-ip-file \"${HEAD_IP_FILE}\" \
--head-ip \"${HEAD_IP}\" \
--ttl-s \"${TTL_S}\" \
--refresh-s \"${REFRESH_S}\"
sleep 2
done
' >>'${LOG_PATH}' 2>&1 & echo \$! >'${PID_PATH}'
"
echo "[head] publisher pid stored in ${PID_PATH} (container-local)"
echo "[head] logs: ${LOG_PATH}"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
CLUSTER_NAME="${CLUSTER_NAME:-argus-ray}"
HEAD_IP_FILE="${HEAD_IP_FILE:-${SHARED_ROOT}/ray/discovery/${CLUSTER_NAME}/head.json}"
POLL_S="${POLL_S:-5}"
WORKER_NODE_RESOURCE="${WORKER_NODE_RESOURCE:-100}"
start_one() {
local worker="$1"
local ip
ip="$(container_ip "${worker}")"
local pid_path="/tmp/argus_worker_watchdog.pid"
local log_path="${SHARED_ROOT}/common/logs/argus_worker_watchdog.${worker}.log"
echo "[${worker}] start stateless watchdog (supervised): head_file=${HEAD_IP_FILE} node_ip=${ip}"
# stop existing watchdog (best-effort)
dexec "${worker}" bash -lc "if test -f '${pid_path}'; then pid=\$(cat '${pid_path}'); if kill -0 \"\${pid}\" >/dev/null 2>&1; then kill \"\${pid}\" || true; fi; rm -f '${pid_path}'; fi"
# stop any legacy ray process to avoid split-brain
dexec "${worker}" bash -lc "ray stop --force || true"
dexec "${worker}" bash -lc "mkdir -p \"$(dirname "${log_path}")\""
docker exec -d "${worker}" bash -lc "
nohup bash -lc '
export PYTHONPATH=/workspace/mvp/py
while true; do
python3 -m argus.ray.worker_watchdog \
--head-ip-file \"${HEAD_IP_FILE}\" \
--node-ip \"${ip}\" \
--resources-kv \"worker_node=${WORKER_NODE_RESOURCE}\" \
--poll-s \"${POLL_S}\"
sleep 2
done
' >>'${log_path}' 2>&1 & echo \$! >'${pid_path}'
"
echo "[${worker}] watchdog pid stored in ${pid_path} (container-local)"
echo "[${worker}] logs: ${log_path}"
}
start_one "${WORKER0_CONTAINER}"
start_one "${WORKER1_CONTAINER}"
echo "[head] ray status"
dexec "${HEAD_CONTAINER}" bash -lc "ray status || true"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
CLUSTER_NAME="${CLUSTER_NAME:-argus-ray}"
HEAD_IP_FILE="${HEAD_IP_FILE:-${SHARED_ROOT}/ray/discovery/${CLUSTER_NAME}/head.json}"
echo "[head] head.json (best-effort): ${HEAD_IP_FILE}"
dexec "${HEAD_CONTAINER}" bash -lc "if test -f '${HEAD_IP_FILE}'; then cat '${HEAD_IP_FILE}'; else echo 'missing'; fi"
echo
echo "[head] ray status"
dexec "${HEAD_CONTAINER}" bash -lc "ray status || true"

View File

@@ -11,10 +11,30 @@ PPO_DATA_DIR="${SHARED_ROOT}/datasets/gsm8k"
SFT_DATA_DIR="${SHARED_ROOT}/datasets/gsm8k_sft"
CODE_SNAPSHOT_DIR="${SHARED_ROOT}/common/code/verl/verl_repo"
COMMON_DIR="${SHARED_ROOT}/common"
COMMON_DATASETS_DIR="${SHARED_ROOT}/common/datasets"
COMMON_HF_DIR="${SHARED_ROOT}/common/hf"
echo "[head] ensure dataset dirs exist"
dexec "${HEAD_CONTAINER}" bash -lc "mkdir -p '${PPO_DATA_DIR}' '${SFT_DATA_DIR}'"
echo "[head] ensure v2.5 common links (idempotent)"
# In existing deployments, /private/common/{datasets,hf} may already exist as directories (not symlinks).
# For v2.5 taskspecs, we only require:
# /private/common/datasets/gsm8k -> /private/datasets/gsm8k
# /private/common/datasets/gsm8k_sft -> /private/datasets/gsm8k_sft
# /private/common/hf/{hub,transformers} -> /private/hf/{hub,transformers} (best-effort)
dexec "${HEAD_CONTAINER}" bash -lc "
set -euo pipefail
mkdir -p '${COMMON_DIR}' '${COMMON_DATASETS_DIR}' '${COMMON_HF_DIR}'
ln -sfn '${SHARED_ROOT}/datasets/gsm8k' '${COMMON_DATASETS_DIR}/gsm8k'
ln -sfn '${SHARED_ROOT}/datasets/gsm8k_sft' '${COMMON_DATASETS_DIR}/gsm8k_sft'
mkdir -p '${SHARED_ROOT}/hf/hub' '${SHARED_ROOT}/hf/transformers'
ln -sfn '${SHARED_ROOT}/hf/hub' '${COMMON_HF_DIR}/hub'
ln -sfn '${SHARED_ROOT}/hf/transformers' '${COMMON_HF_DIR}/transformers'
echo 'common_links_ok'
"
echo "[head] prepare PPO dataset (gsm8k RL parquet) -> ${PPO_DATA_DIR}"
dexec "${HEAD_CONTAINER}" bash -lc "if [[ -f '${PPO_DATA_DIR}/train.parquet' && -f '${PPO_DATA_DIR}/test.parquet' ]]; then echo 'ppo_dataset_exists: skip'; else python3 /workspace/verl/examples/data_preprocess/gsm8k.py --local_save_dir '${PPO_DATA_DIR}'; fi"

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
# E2E v2.5:
# - Clean legacy env
# - Start containers
# - Start ray head + discovery publisher
# - Start stateless worker watchdogs (auto-connect)
# - Prepare data/model/code (reuses existing downloads)
# - Start API
# - Create user + issue token
# - Submit PPO/GRPO/SFT via API and wait
API_ADDR="${API_ADDR:-http://127.0.0.1:8080}"
ADMIN_TOKEN="${MVP_INTERNAL_TOKEN:-}"
USER_ID="${USER_ID:-alice}"
RESET_DB="${RESET_DB:-1}"
if [[ -z "${ADMIN_TOKEN}" ]]; then
echo "ERROR: MVP_INTERNAL_TOKEN must be set in host env (admin token)" >&2
exit 1
fi
api_curl_admin() {
curl -sS -H "Authorization: Bearer ${ADMIN_TOKEN}" "$@"
}
api_wait_ready() {
local tries="${1:-60}"
for i in $(seq 1 "${tries}"); do
if curl -sS -m 2 "${API_ADDR}/docs" >/dev/null 2>&1; then
echo "[host] api_ready: ${API_ADDR}"
return 0
fi
echo "[host] waiting api... (${i}/${tries})"
sleep 2
done
echo "ERROR: api not ready: ${API_ADDR}" >&2
return 1
}
submit_taskspec() {
local token="$1"
local taskspec_path="$2"
echo "[host] submit via API (user=${USER_ID}): ${taskspec_path}" >&2
local resp
resp="$(curl -sS -H "Authorization: Bearer ${token}" -H "Content-Type: application/yaml" --data-binary @"${taskspec_path}" "${API_ADDR}/api/v2/tasks")"
echo "[host] submit_resp: ${resp}" >&2
printf '%s' "${resp}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["task_id"])'
}
wait_task() {
local token="$1"
local task_id="$2"
while true; do
local body state
body="$(curl -sS -H "Authorization: Bearer ${token}" "${API_ADDR}/api/v2/tasks/${task_id}")"
state="$(printf '%s' "${body}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["state"])')"
echo "[host] task ${task_id}: ${state}"
if [[ "${state}" == "SUCCEEDED" ]]; then
return 0
fi
if [[ "${state}" == "FAILED" || "${state}" == "CANCELED" ]]; then
echo "[host] terminal=${state}; tail logs (best-effort):" >&2
curl -sS -H "Authorization: Bearer ${token}" "${API_ADDR}/api/v2/tasks/${task_id}/logs?tail=200" >&2 || true
return 1
fi
sleep 10
done
}
echo "[host] ===== run_all_v25_api.sh begin ====="
"${SCRIPT_DIR}/00_prereq_check.sh"
"${SCRIPT_DIR}/03_cleanup_v1_legacy.sh"
"${SCRIPT_DIR}/04_cleanup_v2_legacy.sh"
echo "[host] bring down existing containers (best-effort)"
"${SCRIPT_DIR}/02_down.sh" || true
echo "[host] (re)create containers"
"${SCRIPT_DIR}/01_up.sh"
echo "[host] restart ray head (no compute on head)"
"${SCRIPT_DIR}/20_start_head.sh"
echo "[host] start head discovery publisher"
"${SCRIPT_DIR}/22_start_head_discovery.sh"
echo "[host] start stateless workers (watchdog auto-connect)"
"${SCRIPT_DIR}/23_start_workers_stateless.sh"
echo "[host] prepare data/model/code snapshot (idempotent; reuse cache)"
"${SCRIPT_DIR}/30_prepare_data_and_model.sh"
echo "[host] install api deps in head container"
"${SCRIPT_DIR}/12_install_api_deps.sh"
echo "[host] stop api (best-effort)"
"${SCRIPT_DIR}/61_stop_api.sh" || true
if [[ "${RESET_DB}" == "1" ]]; then
echo "[host] reset api sqlite db in container (best-effort)"
docker exec -i "${HEAD_CONTAINER}" bash -lc "rm -f /private/common/db/mvp.sqlite3 /private/common/db/mvp.sqlite3-wal /private/common/db/mvp.sqlite3-shm || true"
else
echo "[host] keep existing api sqlite db (RESET_DB=${RESET_DB})"
fi
echo "[host] start api (admin token via env)"
MVP_INTERNAL_TOKEN="${ADMIN_TOKEN}" "${SCRIPT_DIR}/60_start_api.sh"
api_wait_ready 60
echo "[host] ensure user exists + issue token"
api_curl_admin -H "Content-Type: application/json" -d "{\"user_id\":\"${USER_ID}\"}" "${API_ADDR}/api/v2/users" >/dev/null 2>&1 || true
USER_TOKEN="$(api_curl_admin -X POST "${API_ADDR}/api/v2/users/${USER_ID}/tokens" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')"
echo "[host] user_token_issued: user=${USER_ID}"
PPO_TASK_ID="$(submit_taskspec "${USER_TOKEN}" "${ROOT_DIR}/taskspecs/ppo.yaml")"
GRPO_TASK_ID="$(submit_taskspec "${USER_TOKEN}" "${ROOT_DIR}/taskspecs/grpo.yaml")"
SFT_TASK_ID="$(submit_taskspec "${USER_TOKEN}" "${ROOT_DIR}/taskspecs/sft.yaml")"
echo "[host] submitted task ids:"
echo " ppo=${PPO_TASK_ID}"
echo " grpo=${GRPO_TASK_ID}"
echo " sft=${SFT_TASK_ID}"
echo "[host] wait for tasks (in submission order)"
wait_task "${USER_TOKEN}" "${PPO_TASK_ID}"
wait_task "${USER_TOKEN}" "${GRPO_TASK_ID}"
wait_task "${USER_TOKEN}" "${SFT_TASK_ID}"
echo "[host] ===== run_all_v25_api.sh done ====="

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib.sh
source "${SCRIPT_DIR}/lib.sh"
API_ADDR="${API_ADDR:-http://127.0.0.1:8080}"
ADMIN_TOKEN="${MVP_INTERNAL_TOKEN:-}"
if [[ -z "${ADMIN_TOKEN}" ]]; then
echo "ERROR: MVP_INTERNAL_TOKEN must be set (admin token)" >&2
exit 1
fi
require_cmd jq
require_cmd python3
api_admin() { curl -sS -H "Authorization: Bearer ${ADMIN_TOKEN}" "$@"; }
api_user() { local tok="$1"; shift; curl -sS -H "Authorization: Bearer ${tok}" "$@"; }
wait_state() {
local tok="$1"
local task_id="$2"
local want="$3"
local tries="${4:-120}"
for i in $(seq 1 "${tries}"); do
local st
st="$(api_user "${tok}" "${API_ADDR}/api/v2/tasks/${task_id}" | jq -r .state)"
echo "[case] ${task_id} state=${st} (want=${want})"
if [[ "${st}" == "${want}" ]]; then
return 0
fi
if [[ "${st}" == "FAILED" || "${st}" == "CANCELED" || "${st}" == "SUCCEEDED" ]]; then
return 1
fi
sleep 5
done
return 2
}
issue_token() {
local user_id="$1"
api_admin -H "Content-Type: application/json" -d "{\"user_id\":\"${user_id}\"}" "${API_ADDR}/api/v2/users" >/dev/null 2>&1 || true
api_admin -X POST "${API_ADDR}/api/v2/users/${user_id}/tokens" | jq -r .token
}
submit_yaml() {
local tok="$1"
local yaml_path="$2"
api_user "${tok}" -H "Content-Type: application/yaml" --data-binary @"${yaml_path}" "${API_ADDR}/api/v2/tasks" | jq -r .task_id
}
echo "[case] ===== v2.5 e2e cases ====="
echo "[case] HP-1: run_all_v25_api.sh (happy path)"
RESET_DB="${RESET_DB:-1}"
MVP_INTERNAL_TOKEN="${ADMIN_TOKEN}" RESET_DB="${RESET_DB}" "${SCRIPT_DIR}/run_all_v25_api.sh"
echo "[case] E-Auth-1: missing bearer token"
code="$(curl -sS -o /dev/null -w '%{http_code}' "${API_ADDR}/api/v2/queue" || true)"
[[ "${code}" == "401" ]] || { echo "expected 401 got ${code}" >&2; exit 1; }
echo "[case] E-Auth-2: invalid token"
code="$(curl -sS -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer nope' "${API_ADDR}/api/v2/queue" || true)"
[[ "${code}" == "401" ]] || { echo "expected 401 got ${code}" >&2; exit 1; }
echo "[case] setup users alice/bob"
ALICE_TOKEN="$(issue_token alice)"
BOB_TOKEN="$(issue_token bob)"
echo "[case] E-Isolation-1: cross-user task visibility (404)"
TID="$(submit_yaml "${ALICE_TOKEN}" "${ROOT_DIR}/taskspecs/ppo.yaml")"
code="$(api_user "${BOB_TOKEN}" -o /dev/null -w '%{http_code}' "${API_ADDR}/api/v2/tasks/${TID}" || true)"
[[ "${code}" == "404" ]] || { echo "expected 404 got ${code}" >&2; exit 1; }
# Keep the queue clean for subsequent cases (avoid consuming scheduler capacity).
api_user "${ALICE_TOKEN}" -X POST "${API_ADDR}/api/v2/tasks/${TID}:cancel" >/dev/null 2>&1 || true
wait_state "${ALICE_TOKEN}" "${TID}" "CANCELED" 30 || true
echo "[case] E-Input-1: reject non-common inputs"
bad_yaml="$(mktemp)"
cat >"${bad_yaml}" <<'YAML'
workload: "ppo"
submission_id: ""
code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/datasets/gsm8k/train.parquet"
val_file: "/private/common/datasets/gsm8k/test.parquet"
nnodes: 2
n_gpus_per_node: 4
total_epochs: 1
total_training_steps: 10
save_freq: 10
test_freq: -1
YAML
code="$(api_user "${ALICE_TOKEN}" -H 'Content-Type: application/yaml' --data-binary @"${bad_yaml}" -o /dev/null -w '%{http_code}' "${API_ADDR}/api/v2/tasks" || true)"
rm -f "${bad_yaml}"
[[ "${code}" == "400" ]] || { echo "expected 400 got ${code}" >&2; exit 1; }
echo "[case] B-Queue-1: pending_resources when demand > cluster capacity"
big_yaml="$(mktemp)"
cat >"${big_yaml}" <<'YAML'
workload: "ppo"
submission_id: ""
code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/common/datasets/gsm8k/train.parquet"
val_file: "/private/common/datasets/gsm8k/test.parquet"
nnodes: 3
n_gpus_per_node: 4
total_epochs: 1
total_training_steps: 10
save_freq: 10
test_freq: -1
YAML
BIG_TID="$(api_user "${ALICE_TOKEN}" -H 'Content-Type: application/yaml' --data-binary @"${big_yaml}" "${API_ADDR}/api/v2/tasks" | jq -r .task_id)"
rm -f "${big_yaml}"
wait_state "${ALICE_TOKEN}" "${BIG_TID}" "PENDING_RESOURCES" 60
echo "[case] B-Cancel-1: cancel a running task (best-effort)"
long_yaml="$(mktemp)"
cat >"${long_yaml}" <<'YAML'
workload: "ppo"
submission_id: ""
code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/common/datasets/gsm8k/train.parquet"
val_file: "/private/common/datasets/gsm8k/test.parquet"
nnodes: 2
n_gpus_per_node: 4
total_epochs: 1
total_training_steps: 200
save_freq: 10
test_freq: -1
YAML
LONG_TID="$(api_user "${ALICE_TOKEN}" -H 'Content-Type: application/yaml' --data-binary @"${long_yaml}" "${API_ADDR}/api/v2/tasks" | jq -r .task_id)"
rm -f "${long_yaml}"
for i in $(seq 1 60); do
st="$(api_user "${ALICE_TOKEN}" "${API_ADDR}/api/v2/tasks/${LONG_TID}" | jq -r .state)"
echo "[case] wait running: ${LONG_TID} state=${st}"
if [[ "${st}" == "RUNNING" ]]; then
break
fi
if [[ "${st}" == "FAILED" || "${st}" == "SUCCEEDED" ]]; then
break
fi
sleep 5
done
api_user "${ALICE_TOKEN}" -X POST "${API_ADDR}/api/v2/tasks/${LONG_TID}:cancel" | jq .
st="$(api_admin "${API_ADDR}/api/v2/tasks/${LONG_TID}" | jq -r .state)"
[[ "${st}" == "CANCELED" ]] || { echo "expected CANCELED got ${st}" >&2; exit 1; }
echo "[case] ===== all v2.5 e2e cases passed ====="

View File

@@ -6,8 +6,8 @@ code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/datasets/gsm8k/train.parquet"
val_file: "/private/datasets/gsm8k/test.parquet"
train_file: "/private/common/datasets/gsm8k/train.parquet"
val_file: "/private/common/datasets/gsm8k/test.parquet"
nnodes: 2
n_gpus_per_node: 4
@@ -17,4 +17,3 @@ total_training_steps: 10
save_freq: 10
test_freq: -1

View File

@@ -8,8 +8,8 @@ code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/datasets/gsm8k/train.parquet"
val_file: "/private/datasets/gsm8k/test.parquet"
train_file: "/private/common/datasets/gsm8k/train.parquet"
val_file: "/private/common/datasets/gsm8k/test.parquet"
nnodes: 2
n_gpus_per_node: 4
@@ -19,4 +19,3 @@ total_training_steps: 10
save_freq: 10
test_freq: -1

View File

@@ -6,7 +6,7 @@ code_path: "/private/common/code/verl/verl_repo"
model_id: "Qwen/Qwen2.5-0.5B-Instruct"
train_file: "/private/datasets/gsm8k_sft/train.parquet"
train_file: "/private/common/datasets/gsm8k_sft/train.parquet"
val_file: null
nnodes: 2
@@ -19,4 +19,3 @@ save_freq: 10
# SFT driver 默认不分配 GPU(ray job entrypoint 不指定 entrypoint_num_gpus),因此 driver 侧不要依赖 CUDA
trainer_device: "cpu"