Files
app/backend/fluksio/api/routes/workers.py
T
stroblme 34243e6902 SDK: artifacts work outside a node, run --follow prints status and queue waits, fluksio cancel
save_artifact/load_artifact outside a node use the worker's own store code in
$FLUKSIO_ARTIFACT_DIR or <tmp>/fluksio-artifacts, so a node can be called by
hand. --follow prints status changes and each node's resource wait; the
placer's waiting snapshot now carries the run. fluksio cancel <run>...
2026-10-28 08:07:35 +01:00

361 lines
12 KiB
Python

"""Remote workers: how one attaches, and what is attached right now.
A worker dials in rather than being dialled: the GPU box and the engine are
usually on different networks, and only one of them can be reached. It presents
a token minted here, which says who it is and which labels it carries, says
what else it can do, and then answers calls on the socket it opened.
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import PlainTextResponse
from fluksio_worker import worker_main
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel, Field
from sqlmodel import col, select
from starlette.concurrency import run_in_threadpool
from fluksio.api.deps import (
SessionDep,
get_current_active_superuser,
get_current_user,
worker_claims,
)
from fluksio.api.socket_auth import socket_token
from fluksio.core import security
from fluksio.flow.remote import (
PROTOCOL,
RemoteWorker,
RemoteWorkerHub,
WorkerInventory,
)
from fluksio.models import WorkerToken
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/workers", tags=["workers"])
#: Long, because a worker is a machine somebody set up once and left running.
TOKEN_DAYS = 365
#: The sockets attached with each token, by `jti`, so revoking one drops them.
_attached: dict[str, set[WebSocket]] = {}
#: Workers refused for an older protocol, by name, until one attaches again.
#: Such an agent exits on the refusal, so this is the one place left that says
#: which host still needs `pip install -U fluksio-worker`.
_behind: dict[str, WorkerInfo] = {}
class WorkerInfo(BaseModel):
name: str
labels: list[str] = Field(default_factory=list)
max_parallel: int = 1
in_flight: int = 0
attached_at: float = 0.0
last_seen: float = 0.0
python: str = ""
venv_digest: str = ""
cpus: int = 1
gpus: int = 0
ram_mb: int | None = None
protocol: int = 0
#: The fluksio-worker it runs; empty for an agent too old to say.
version: str = ""
#: Refused for speaking an older protocol than this engine, so not attached.
behind: bool = False
class TokenRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
#: What a node's `worker` may match on this worker; it cannot add to them.
labels: list[str] = Field(default_factory=list)
class TokenIssued(BaseModel):
name: str
token: str
jti: str
labels: list[str] = Field(default_factory=list)
expires_days: int = TOKEN_DAYS
def _hub(app: Any) -> RemoteWorkerHub:
hub: RemoteWorkerHub | None = getattr(app.state, "worker_hub", None)
if hub is None:
raise HTTPException(status_code=503, detail="Remote workers are not available")
return hub
@router.get(
"", response_model=list[WorkerInfo], dependencies=[Depends(get_current_user)]
)
def read_workers(request: Request) -> Any:
"""What is attached, and how busy it is."""
return [
WorkerInfo(
name=worker.name,
labels=sorted(worker.labels),
max_parallel=worker.max_parallel,
in_flight=worker.in_flight,
attached_at=worker.attached_at,
last_seen=worker.last_seen,
python=str(worker.info.get("python") or ""),
venv_digest=str(worker.info.get("venv_digest") or ""),
cpus=worker.inventory.cpus,
gpus=worker.inventory.gpus,
ram_mb=worker.inventory.ram_mb,
protocol=int(worker.info.get("protocol") or 0),
version=str(worker.info.get("version") or ""),
)
for worker in _hub(request.app).workers()
] + list(_behind.values())
class ResourceLevel(BaseModel):
total: int
free: int
class WaitingNode(BaseModel):
node: str
#: The run it belongs to, so a client can tell its own wait from another's.
run: str = ""
reason: str
seconds: float
class TargetResources(BaseModel):
"""One machine: this engine, or a worker attached to it."""
target: str
cpus: ResourceLevel
gpus: ResourceLevel
#: Absent where the machine did not say how much memory it has.
ram_mb: ResourceLevel | None = None
labels: list[str] = Field(default_factory=list)
in_flight: int = 0
class ResourcesSnapshot(BaseModel):
#: This engine's own figures, kept where they have always been.
cpus: ResourceLevel
gpus: ResourceLevel
waiting: list[WaitingNode] = Field(default_factory=list)
targets: list[TargetResources] = Field(default_factory=list)
provisioners: list[dict[str, Any]] = Field(default_factory=list)
@router.get(
"/resources",
response_model=ResourcesSnapshot,
dependencies=[Depends(get_current_user)],
)
def read_resources(request: Request) -> Any:
"""Every machine, what is free of it, and which nodes are queued.
A node waiting its turn looks exactly like a node that has hung — the run
sits at `running` and says nothing — so what is waiting, and for what, has
to be readable somewhere.
"""
placer = getattr(request.app.state, "placer", None)
if placer is None:
raise HTTPException(status_code=503, detail="Resources are not accounted here")
return placer.snapshot()
@router.post(
"/tokens",
response_model=TokenIssued,
dependencies=[Depends(get_current_active_superuser)],
)
def issue_token(session: SessionDep, body: TokenRequest) -> Any:
"""Mint the credential a worker presents when it dials in.
Shown once. The worker's name and labels are signed into it, so a worker is
what it was minted as whatever it says about itself, and only its `jti` is
kept here, to revoke it by.
"""
labels = sorted({label.strip() for label in body.labels if label.strip()})
row = WorkerToken(
jti=uuid.uuid4().hex,
name=body.name,
labels=labels,
expires_at=datetime.now(UTC) + timedelta(days=TOKEN_DAYS),
)
token = security.create_worker_token(
body.name, timedelta(days=TOKEN_DAYS), labels=labels, jti=row.jti
)
session.add(row)
session.commit()
return TokenIssued(name=body.name, token=token, jti=row.jti, labels=labels)
@router.get(
"/tokens",
response_model=list[WorkerToken],
dependencies=[Depends(get_current_active_superuser)],
)
def read_tokens(session: SessionDep) -> Any:
"""Every worker token minted here, newest first, without the tokens."""
return session.exec(
select(WorkerToken).order_by(col(WorkerToken.created_at).desc())
).all()
@router.post(
"/tokens/{jti}/revoke",
response_model=WorkerToken,
dependencies=[Depends(get_current_active_superuser)],
)
async def revoke_token(session: SessionDep, jti: str) -> Any:
"""Refuse this token from now on, and drop a worker attached with it."""
row = session.get(WorkerToken, jti)
if row is None:
raise HTTPException(status_code=404, detail="No such worker token")
if row.revoked_at is None:
row.revoked_at = datetime.now(UTC)
session.add(row)
session.commit()
session.refresh(row)
for socket in list(_attached.get(jti, ())):
await socket.close(code=1008)
return row
@router.get(
"/runtime",
response_class=PlainTextResponse,
dependencies=[Depends(get_current_user)],
)
def read_runtime() -> str:
"""The worker's own code, so a fresh host installs by fetching one file.
It is the same module the engine's local workers run — deliberately
standard library only, and with nothing of the engine importable in it.
"""
return worker_main.__file__ and open(worker_main.__file__).read()
@router.websocket("/attach")
async def attach(websocket: WebSocket, token: str = "") -> None:
"""A worker's connection, for as long as it holds.
The token comes as `Authorization: Bearer` on the handshake. `?token=` is
still read for one release, so the engine can be upgraded before its
workers.
"""
token = socket_token(websocket.headers, token=token)
try:
claims = await run_in_threadpool(worker_claims, token)
except InvalidTokenError:
await websocket.close(code=1008)
return
jti = str(claims["jti"])
await websocket.accept()
_attached.setdefault(jti, set()).add(websocket)
try:
await _attached_session(websocket, claims)
finally:
sockets = _attached.get(jti, set())
sockets.discard(websocket)
if not sockets:
_attached.pop(jti, None)
async def _attached_session(websocket: WebSocket, claims: dict[str, Any]) -> None:
"""The hello, then calls, until the socket goes or the token is revoked."""
try:
hello = await asyncio.wait_for(websocket.receive_json(), timeout=30)
except WebSocketDisconnect:
return
except (TimeoutError, ValueError):
await websocket.close(code=1002)
return
protocol = int(hello.get("protocol", 0))
if hello.get("op") != "hello" or protocol != PROTOCOL:
if hello.get("op") == "hello" and protocol < PROTOCOL:
name = str(claims.get("sub") or hello.get("name") or "worker")
_behind[name] = WorkerInfo(
name=name,
labels=sorted(str(label) for label in claims.get("labels") or []),
last_seen=time.time(),
python=str(hello.get("python") or ""),
protocol=protocol,
version=str(hello.get("version") or ""),
behind=True,
)
await websocket.send_json(
{"op": "refused", "reason": f"this engine speaks protocol {PROTOCOL}"}
)
await websocket.close(code=1002)
return
# The token names the worker and its labels. What it calls itself is a
# suggestion, so two hosts cannot fight over one identity by claiming the
# same name; labels it may repeat but not change, since they decide which
# node code and secrets come its way.
labels = sorted({str(label) for label in claims.get("labels") or []})
claimed = sorted({str(label) for label in hello.get("labels") or []})
if claimed and claimed != labels:
await websocket.send_json(
{
"op": "refused",
"reason": f"this token carries the labels {labels}, not {claimed}",
}
)
await websocket.close(code=1008)
return
name = str(claims.get("sub") or hello.get("name") or "worker")
hub = _hub(websocket.app)
worker = RemoteWorker(
name=name,
labels=labels,
send=websocket.send_json,
loop=asyncio.get_running_loop(),
max_parallel=max(1, int(hello.get("max_parallel") or 1)),
info={
"python": hello.get("python"),
"venv_digest": hello.get("venv_digest"),
"protocol": protocol,
"version": hello.get("version"),
},
inventory=WorkerInventory.from_hello(hello.get("inventory")),
host=str(
hello.get("service_host")
or (websocket.client.host if websocket.client else "")
),
)
hub.attach(worker)
_behind.pop(name, None)
await websocket.send_json({"op": "welcome", "protocol": PROTOCOL, "name": name})
try:
while True:
message = await websocket.receive_json()
worker.deliver(message)
except Exception:
# Any way this ends is the same thing: the socket is gone, and whatever
# was waiting on it has to be told rather than left hanging.
logger.info("Worker '%s' disconnected", name)
finally:
hub.detach(name)