171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
"""The worker's jobs: schedule, heartbeat, recent runs, and "run now".
|
|||
|
|
|
||
|
|
Admin (password session) or Clerical (clerical or clerical_supervisor). "Run now" inserts a job_requests row and
|
||
|
|
NOTIFYs `ps_job_request` in the same transaction; the worker claims every pending request in one run.
|
||
|
|
`run_out`, `worker_status` and `request_run` are shared with api/feed.py.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, Query
|
||
|
|
from psycopg.rows import dict_row
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from .. import audit
|
||
|
|
from ..worker.heartbeat import STALE_SECONDS
|
||
|
|
from .deps import Db, Principal, current_principal, get_db, require_password_session
|
||
|
|
from .errors import ApiError
|
||
|
|
from .packets import WRITE
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||
|
|
|
||
|
|
CLERICAL = frozenset({"clerical", "clerical_supervisor"})
|
||
|
|
JOB_CHANNEL = "ps_job_request"
|
||
|
|
_JOB_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
||
|
|
|
||
|
|
|
||
|
|
def jobs_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
|
||
|
|
"""Clerical in any session, or an admin with a password session."""
|
||
|
|
if CLERICAL & set(p.roles):
|
||
|
|
return p
|
||
|
|
if p.is_admin:
|
||
|
|
return require_password_session(p)
|
||
|
|
raise ApiError(403, "forbidden", "Only Clerical or an admin can see and run the worker's jobs.")
|
||
|
|
|
||
|
|
|
||
|
|
class RunBody(BaseModel):
|
||
|
|
note: str | None = Field(None, max_length=500)
|
||
|
|
|
||
|
|
|
||
|
|
def run_out(r: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
|
|
"""One job_runs row as JSON."""
|
||
|
|
if r is None:
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"id": r["id"],
|
||
|
|
"job": r["job"],
|
||
|
|
"trigger": r["trigger"],
|
||
|
|
"requested_by": [str(u) for u in (r.get("requested_by") or [])],
|
||
|
|
"started_at": r["started_at"],
|
||
|
|
"finished_at": r["finished_at"],
|
||
|
|
"status": r["status"],
|
||
|
|
"counts": r.get("counts") or {},
|
||
|
|
"errors": r.get("errors") or [],
|
||
|
|
"build_version": r.get("build_version"),
|
||
|
|
"visual_target": r.get("visual_target"),
|
||
|
|
"host": r.get("host"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def recent_runs(conn: Any, job: str, limit: int = 10) -> list[dict[str, Any]]:
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
rows = cur.execute("SELECT * FROM job_runs WHERE job = %s ORDER BY id DESC LIMIT %s", (job, limit)).fetchall()
|
||
|
|
return [o for o in (run_out(r) for r in rows) if o is not None]
|
||
|
|
|
||
|
|
|
||
|
|
def worker_status(conn: Any) -> dict[str, Any]:
|
||
|
|
"""{at, build, host, running, stale}: stale when the worker has not beaten for STALE_SECONDS or never did."""
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
r = cur.execute(
|
||
|
|
"SELECT at, build, host, running, now() - at > make_interval(secs => %s) AS stale"
|
||
|
|
" FROM worker_heartbeat WHERE id = 1",
|
||
|
|
(STALE_SECONDS,),
|
||
|
|
).fetchone()
|
||
|
|
if r is None:
|
||
|
|
return {"at": None, "build": None, "host": None, "running": [], "stale": True}
|
||
|
|
return {
|
||
|
|
"at": r["at"],
|
||
|
|
"build": r["build"],
|
||
|
|
"host": r["host"],
|
||
|
|
"running": list(r["running"] or []),
|
||
|
|
"stale": bool(r["stale"]),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def known_job(conn: Any, job: str) -> bool:
|
||
|
|
if not _JOB_NAME.match(job):
|
||
|
|
return False
|
||
|
|
return conn.execute("SELECT 1 FROM job_schedule WHERE job = %s", (job,)).fetchone() is not None
|
||
|
|
|
||
|
|
|
||
|
|
def request_run(
|
||
|
|
conn: Any, p: Principal, job: str, *, note: str | None = None, action: str = "job.run_requested"
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Insert a request and NOTIFY the worker in one transaction. Returns {requested, pending}."""
|
||
|
|
with conn.transaction():
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO job_requests (job, requested_by, note) VALUES (%s, %s, %s)",
|
||
|
|
(job, p.user_id, (note or "").strip() or None),
|
||
|
|
)
|
||
|
|
conn.execute("SELECT ps_notify(%s, %s)", (JOB_CHANNEL, json.dumps({"job": job}, separators=(",", ":"))))
|
||
|
|
pending = conn.execute(
|
||
|
|
"SELECT count(*) FROM job_requests WHERE job = %s AND claimed_at IS NULL", (job,)
|
||
|
|
).fetchone()[0]
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action=action,
|
||
|
|
user_id=p.user_id,
|
||
|
|
session_id=p.session_id,
|
||
|
|
role_snapshot=p.role_snapshot,
|
||
|
|
subject_type="job",
|
||
|
|
subject_id=job,
|
||
|
|
detail={"job": job, "pending": int(pending)},
|
||
|
|
ip=p.ip,
|
||
|
|
)
|
||
|
|
return {"requested": True, "pending": int(pending)}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("")
|
||
|
|
def list_jobs(p: Principal = Depends(jobs_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
|
||
|
|
conn = d.conn
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
rows = cur.execute(
|
||
|
|
"SELECT s.job, s.next_run_at, s.interval_seconds, s.paused, s.updated_at,"
|
||
|
|
" (SELECT count(*) FROM job_requests q WHERE q.job = s.job AND q.claimed_at IS NULL) AS pending,"
|
||
|
|
" CASE WHEN r.id IS NULL THEN NULL ELSE to_jsonb(r) END AS last_run"
|
||
|
|
" FROM job_schedule s"
|
||
|
|
" LEFT JOIN LATERAL (SELECT * FROM job_runs j WHERE j.job = s.job ORDER BY j.id DESC LIMIT 1) r ON true"
|
||
|
|
" ORDER BY s.job"
|
||
|
|
).fetchall()
|
||
|
|
jobs = [
|
||
|
|
{
|
||
|
|
"job": r["job"],
|
||
|
|
"next_run_at": r["next_run_at"],
|
||
|
|
"interval_seconds": r["interval_seconds"],
|
||
|
|
"paused": r["paused"],
|
||
|
|
"updated_at": r["updated_at"],
|
||
|
|
"pending": int(r["pending"]),
|
||
|
|
"last_run": run_out(r["last_run"]),
|
||
|
|
}
|
||
|
|
for r in rows
|
||
|
|
]
|
||
|
|
return {"jobs": jobs, "heartbeat": worker_status(conn)}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/{job}/runs")
|
||
|
|
def job_runs(
|
||
|
|
job: str,
|
||
|
|
p: Principal = Depends(jobs_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
limit: int = Query(10, ge=1, le=100),
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
if not known_job(d.conn, job):
|
||
|
|
raise ApiError(404, "not_found", "No such job.")
|
||
|
|
return recent_runs(d.conn, job, limit)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/{job}/run", dependencies=WRITE)
|
||
|
|
def run_now(
|
||
|
|
job: str,
|
||
|
|
body: RunBody | None = None,
|
||
|
|
p: Principal = Depends(jobs_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
if not known_job(d.conn, job):
|
||
|
|
raise ApiError(404, "not_found", "No such job.")
|
||
|
|
return request_run(d.conn, p, job, note=body.note if body else None)
|