Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Alerting configuration: which failures go where."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from fluksio.api.deps import FlowControllerDep, get_current_user
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.alerts import Alert, AlertsConfig
|
||||
from fluksio.models import Message
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
return settings.ALERTS_FILE
|
||||
|
||||
|
||||
def read_config() -> AlertsConfig:
|
||||
"""The stored configuration, or an empty one. Blocking."""
|
||||
path = _path()
|
||||
if not path.exists():
|
||||
return AlertsConfig()
|
||||
try:
|
||||
return AlertsConfig.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
# A hand-edited file that no longer parses must not stop the engine.
|
||||
return AlertsConfig()
|
||||
|
||||
|
||||
def write_config(config: AlertsConfig) -> None:
|
||||
"""Blocking."""
|
||||
path = _path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(config.model_dump_json(indent=2))
|
||||
|
||||
|
||||
@router.get("/config", response_model=AlertsConfig)
|
||||
async def read_alerts_config() -> Any:
|
||||
"""What the engine alerts on, and where it sends it."""
|
||||
return await run_in_threadpool(read_config)
|
||||
|
||||
|
||||
@router.put("/config", response_model=AlertsConfig)
|
||||
async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any:
|
||||
"""Replace the configuration. Takes effect immediately."""
|
||||
known = {channel.name for channel in body.channels}
|
||||
for rule in body.rules:
|
||||
missing = [name for name in rule.channels if name not in known]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"No channel named {', '.join(repr(m) for m in missing)}",
|
||||
)
|
||||
|
||||
await run_in_threadpool(write_config, body)
|
||||
if controller.alerts is not None:
|
||||
controller.alerts.config = body
|
||||
return body
|
||||
|
||||
|
||||
@router.post("/test/{channel_name}", response_model=Message)
|
||||
async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Send one alert, to prove the channel works before relying on it."""
|
||||
if controller.alerts is None:
|
||||
raise HTTPException(status_code=503, detail="Alerting is not running")
|
||||
|
||||
config = controller.alerts.config
|
||||
channel = next((c for c in config.channels if c.name == channel_name), None)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'")
|
||||
|
||||
try:
|
||||
await controller.alerts.send(
|
||||
channel,
|
||||
Alert(
|
||||
title="Fluksio test alert",
|
||||
body="If you are reading this, the channel works.",
|
||||
severity="warning",
|
||||
),
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Whatever the sender said, verbatim: it is the only clue the operator
|
||||
# has about why the channel does not work.
|
||||
raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__)
|
||||
return Message(message=f"Sent a test alert through '{channel_name}'")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Artifacts over HTTP: the one way bytes get in and out of the store.
|
||||
|
||||
A node on this host could reach the directory itself, but a node on a remote
|
||||
worker cannot — and having one path rather than two is what keeps a flow's
|
||||
code the same wherever it runs.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
from fluksio.api.deps import user_from_token
|
||||
from fluksio.core import security
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.flow.artifacts import ArtifactStore
|
||||
|
||||
|
||||
def artifact_caller(request: Request) -> str:
|
||||
"""Who may move artifacts: a signed-in person, or an attached worker.
|
||||
|
||||
A worker's node stores its checkpoints through this endpoint, so its own
|
||||
credential has to open it — and only it. The token is no use anywhere else
|
||||
in the API, which is why this check is here rather than in the shared
|
||||
dependency every other route uses.
|
||||
"""
|
||||
header = request.headers.get("Authorization", "")
|
||||
token = header[7:] if header.lower().startswith("bearer ") else ""
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
claims = security.decode_worker_token(token)
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
else:
|
||||
return f"worker:{claims.get('sub')}"
|
||||
with Session(engine) as session:
|
||||
# With the request, so a credential that is scoped by route — a wall
|
||||
# panel's — is judged against this one rather than waved through.
|
||||
user = user_from_token(session, token, request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user.email
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/artifacts", tags=["artifacts"], dependencies=[Depends(artifact_caller)]
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRef(BaseModel):
|
||||
digest: str
|
||||
size: int
|
||||
media_type: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
def _store(request: Request) -> ArtifactStore:
|
||||
store: ArtifactStore | None = getattr(request.app.state, "artifact_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="The artifact store is not ready")
|
||||
return store
|
||||
|
||||
|
||||
@router.put("", response_model=ArtifactRef)
|
||||
async def put_artifact(
|
||||
request: Request,
|
||||
name: str = Query(default=""),
|
||||
media_type: str = Query(default=""),
|
||||
) -> Any:
|
||||
"""Store the request body and answer with the reference to it."""
|
||||
store = _store(request)
|
||||
body = await request.body()
|
||||
return store.put([body], name=name, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{digest}")
|
||||
def get_artifact(digest: str, request: Request) -> Any:
|
||||
"""Stream one artifact back."""
|
||||
store = _store(request)
|
||||
path = store.path(digest)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No such artifact")
|
||||
return StreamingResponse(
|
||||
store.read(digest),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Length": str(path.stat().st_size)},
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Connecting this installation to a Fluksio portal, and cutting it loose.
|
||||
|
||||
Entirely optional, and superuser-only to change: enrolling grants a remote
|
||||
party the rights of the account that performed it, which is not a decision an
|
||||
ordinary user of this installation gets to make on everyone else's behalf.
|
||||
|
||||
Admitting further portal accounts is the same decision made again, so it is
|
||||
guarded the same way. A person let in this way gets an ordinary local account
|
||||
of their own — never a superuser, so they cannot admit anyone else, and the
|
||||
chain stops with whoever a superuser here typed a code for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
|
||||
from fluksio import crud
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
get_current_user,
|
||||
)
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.core.security import get_password_hash
|
||||
from fluksio.models import Message, User, UserPublic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"])
|
||||
|
||||
|
||||
class EnrollBody(BaseModel):
|
||||
#: Where the portal's API lives, e.g. https://hub.fluksio.com
|
||||
portal_url: str = Field(min_length=1, max_length=255)
|
||||
claim_code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
@router.get("/status", dependencies=[Depends(get_current_user)])
|
||||
def read_status(request: Request) -> dict[str, Any]:
|
||||
"""Whether this installation is enrolled, and whether the link is up.
|
||||
|
||||
Readable by any signed-in user: everyone here has a right to know whether
|
||||
the machine they are using can be reached from outside.
|
||||
"""
|
||||
connector = getattr(request.app.state, "cloud_connector", None)
|
||||
if connector is None:
|
||||
config = cloud_config.load()
|
||||
return {
|
||||
"enrolled": config is not None,
|
||||
"connected": False,
|
||||
"portal_url": config.portal_url if config else None,
|
||||
"issuer": config.issuer if config else None,
|
||||
"portal_account": config.portal_account if config else None,
|
||||
"installation_id": config.installation_id if config else None,
|
||||
"last_error": None,
|
||||
"connected_since": None,
|
||||
}
|
||||
status: dict[str, Any] = connector.status()
|
||||
return status
|
||||
|
||||
|
||||
@router.post(
|
||||
"/enroll",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=Message,
|
||||
)
|
||||
async def enroll(
|
||||
request: Request, session: SessionDep, current_user: CurrentUser, body: EnrollBody
|
||||
) -> Message:
|
||||
"""Redeem a claim code and start dialling the portal.
|
||||
|
||||
The account performing this is mapped to the portal account that owns the
|
||||
installation, so the owner's portal sessions arrive here as them. Widening
|
||||
that to anyone else is a local decision made one person at a time, below —
|
||||
never something the portal can do from its side.
|
||||
"""
|
||||
if cloud_config.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This installation is already connected to a portal",
|
||||
)
|
||||
|
||||
base = body.portal_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
f"{base}/api/v1/enroll/",
|
||||
json={"claim_code": body.claim_code, "app_version": "0.1.0"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="That claim code is unknown or has expired"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused the claim ({response.status_code})",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
owner_id = data.get("owner_id")
|
||||
if not owner_id:
|
||||
# A portal older than remote users does not say who owns the
|
||||
# installation, and without that the enrolling account cannot be mapped
|
||||
# to anyone — which would leave the portal connected but refused here.
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="That portal is too old for this installation: it did not "
|
||||
"say which account owns the installation",
|
||||
)
|
||||
config = cloud_config.CloudConfig(
|
||||
portal_url=base,
|
||||
ws_url=data["ws_url"],
|
||||
installation_id=data["installation_id"],
|
||||
token=data["installation_token"],
|
||||
issuer=data["issuer"],
|
||||
# Pinned here, at the one moment the claim code proves who we are
|
||||
# talking to. Nothing refreshes this.
|
||||
jwks=data["jwks"],
|
||||
local_user_id=str(current_user.id),
|
||||
enrolled_at=datetime.now(timezone.utc).isoformat(),
|
||||
portal_account=current_user.email,
|
||||
)
|
||||
cloud_config.save(config)
|
||||
|
||||
owner_id = str(owner_id)
|
||||
# Re-enrolling from a different local account moves the mapping rather than
|
||||
# leaving two accounts claiming the same portal identity, which the unique
|
||||
# index would refuse and the lookup could not choose between anyway.
|
||||
for other in session.exec(
|
||||
select(User).where(User.portal_sub == owner_id, User.id != current_user.id)
|
||||
):
|
||||
other.portal_sub = None
|
||||
session.add(other)
|
||||
current_user.portal_sub = owner_id
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
_start_connector(request.app)
|
||||
return Message(message="Connected to the portal")
|
||||
|
||||
|
||||
class RemoteUserBody(BaseModel):
|
||||
#: The code the person wanting in minted on their portal account.
|
||||
code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/users",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=UserPublic,
|
||||
)
|
||||
def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
"""Admit a portal account, and give it a local user of its own.
|
||||
|
||||
The point of the local user is that it is ordinary: a remote person shows
|
||||
up in Admin → Users like everyone else, holds no superuser flag, and is
|
||||
removed by deleting them there. They have no password because they never
|
||||
sign in here — the portal vouches for them, and this row is what says which
|
||||
of our accounts that vouching amounts to.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This installation is not connected to a portal",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"code": body.code.strip()},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="That code is unknown or has expired"
|
||||
)
|
||||
if response.status_code == 409:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="That code belongs to this installation's owner"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused the code ({response.status_code})",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
portal_sub = str(data["user_id"])
|
||||
email = str(data["email"])
|
||||
existing = session.exec(select(User).where(User.portal_sub == portal_sub)).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"{existing.email} already has access"
|
||||
)
|
||||
if crud.get_user_by_email(session=session, email=email) is not None:
|
||||
# Never quietly hand an existing local account — possibly a superuser's
|
||||
# — to whoever holds that address on the portal.
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A local user with this email already exists"
|
||||
)
|
||||
user = User(
|
||||
email=email,
|
||||
# Unusable by construction: this account is reached through the portal
|
||||
# or not at all. Password recovery would issue one, which is a gap
|
||||
# worth closing the day local sign-in for these accounts is wanted.
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(32)),
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
portal_sub=portal_sub,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def forget_remote_user(portal_sub: str) -> None:
|
||||
"""Tell the portal a remote user is gone from here.
|
||||
|
||||
Best effort on purpose: the local account is what grants access, so it is
|
||||
already over by the time this runs. A portal that cannot be reached keeps a
|
||||
row that opens nothing — the installation refuses the session either way.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
return
|
||||
try:
|
||||
response = httpx.delete(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Could not drop %s at the portal: %s", portal_sub, exc)
|
||||
return
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"The portal refused to drop %s (%s)", portal_sub, response.status_code
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message
|
||||
)
|
||||
def disconnect(request: Request) -> Message:
|
||||
"""Sever the connection from this side.
|
||||
|
||||
Unilateral and immediate: the config is the only reason portal tokens
|
||||
verify here, so deleting it ends remote access whatever the portal still
|
||||
has on file.
|
||||
"""
|
||||
task = getattr(request.app.state, "cloud_task", None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
request.app.state.cloud_task = None
|
||||
request.app.state.cloud_connector = None
|
||||
cloud_config.delete()
|
||||
return Message(message="Disconnected from the portal")
|
||||
|
||||
|
||||
def _start_connector(app: Any) -> None:
|
||||
from fluksio.cloud.connector import CloudConnector
|
||||
|
||||
existing = getattr(app.state, "cloud_task", None)
|
||||
if existing is not None:
|
||||
existing.cancel()
|
||||
connector = CloudConnector(app)
|
||||
app.state.cloud_connector = connector
|
||||
app.state.cloud_task = asyncio.create_task(
|
||||
connector.serve_forever(), name="cloud-connector"
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Dashboards: documents of widgets bound to message names."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fluksio.api.deps import DashboardStoreDep, FlowControllerDep, get_current_user
|
||||
from fluksio.flow.dashboards import (
|
||||
DashboardDef,
|
||||
DashboardExists,
|
||||
DashboardNotFound,
|
||||
DashboardsPublic,
|
||||
default_dashboard,
|
||||
)
|
||||
from fluksio.flow.events import event_bus
|
||||
from fluksio.flow.store import StaleVersion
|
||||
from fluksio.models import Message
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/dashboards", tags=["dashboards"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
version: int
|
||||
|
||||
|
||||
def _apply_history_limits(store: Any, controller: Any) -> None:
|
||||
"""Tell the engine how much past each charted message needs kept."""
|
||||
controller.set_history_limits(store.history_requirements())
|
||||
|
||||
|
||||
@router.get("/", response_model=DashboardsPublic)
|
||||
async def read_dashboards(store: DashboardStoreDep) -> Any:
|
||||
"""Every dashboard, without its contents."""
|
||||
summaries = await run_in_threadpool(store.list)
|
||||
return DashboardsPublic(data=summaries, count=len(summaries))
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=DashboardDef)
|
||||
async def read_dashboard(
|
||||
name: str, store: DashboardStoreDep, draft: bool = False
|
||||
) -> Any:
|
||||
"""What a panel shows, or with ``draft`` the copy the editor is on."""
|
||||
try:
|
||||
return await run_in_threadpool(store.read, name, draft)
|
||||
except DashboardNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}", response_model=DashboardDef)
|
||||
async def create_dashboard(name: str, store: DashboardStoreDep) -> Any:
|
||||
"""Start a dashboard: one page, one section, nothing on it yet.
|
||||
|
||||
A draft, like every edit that follows it — a dashboard reaches a panel
|
||||
only once someone publishes it, so an empty one never does.
|
||||
"""
|
||||
if await run_in_threadpool(store.exists, name):
|
||||
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
|
||||
try:
|
||||
defn = default_dashboard(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
# No history limits to apply: they are read from the published documents,
|
||||
# and this one is not one of them yet.
|
||||
return await run_in_threadpool(store.write_draft, defn, 0)
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=DashboardDef)
|
||||
async def save_dashboard(
|
||||
name: str,
|
||||
body: DashboardDef,
|
||||
store: DashboardStoreDep,
|
||||
) -> Any:
|
||||
"""Save unpublished changes, refusing a save someone else has moved past.
|
||||
|
||||
This writes a draft: panels keep showing the published document until
|
||||
someone publishes, so nothing here can change what a wall is displaying —
|
||||
which is also why the engine's history limits are left alone.
|
||||
"""
|
||||
if body.name != name:
|
||||
raise HTTPException(status_code=422, detail="The name in the body must match")
|
||||
try:
|
||||
saved = await run_in_threadpool(store.write_draft, body, body.version)
|
||||
except DashboardNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
except StaleVersion as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Someone else saved this dashboard first",
|
||||
"current_version": exc.current,
|
||||
},
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
@router.post("/{name}/publish", response_model=DashboardDef)
|
||||
async def publish_dashboard(
|
||||
name: str,
|
||||
body: PublishRequest,
|
||||
store: DashboardStoreDep,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Put the unpublished changes on the panels."""
|
||||
if not await run_in_threadpool(store.exists, name):
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
if not await run_in_threadpool(store.has_draft, name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
|
||||
)
|
||||
try:
|
||||
published = await run_in_threadpool(store.publish, name, body.version)
|
||||
except StaleVersion as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Someone else saved this dashboard first",
|
||||
"current_version": exc.current,
|
||||
},
|
||||
)
|
||||
await run_in_threadpool(_apply_history_limits, store, controller)
|
||||
# What a panel is showing has changed. Panels watch the flow socket, and
|
||||
# the tile values alone cannot tell them the document itself moved.
|
||||
event_bus.publish(
|
||||
{"type": "dashboard_changed", "dashboard": name, "ts": time.time()}
|
||||
)
|
||||
return published
|
||||
|
||||
|
||||
@router.post("/{name}/discard", response_model=DashboardDef)
|
||||
async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
|
||||
"""Throw the unpublished changes away and go back to what is shown."""
|
||||
if not await run_in_threadpool(store.exists, name):
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
if not await run_in_threadpool(store.has_draft, name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
|
||||
)
|
||||
if not await run_in_threadpool(store.is_published, name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Dashboard '{name}' has never been published — delete it instead "
|
||||
"of discarding it"
|
||||
),
|
||||
)
|
||||
return await run_in_threadpool(store.discard_draft, name)
|
||||
|
||||
|
||||
@router.delete("/{name}", response_model=Message)
|
||||
async def delete_dashboard(
|
||||
name: str, store: DashboardStoreDep, controller: FlowControllerDep
|
||||
) -> Any:
|
||||
try:
|
||||
await run_in_threadpool(store.delete, name)
|
||||
except DashboardNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
await run_in_threadpool(_apply_history_limits, store, controller)
|
||||
return Message(message=f"Deleted dashboard '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/rename", response_model=DashboardDef)
|
||||
async def rename_dashboard(
|
||||
name: str, body: RenameRequest, store: DashboardStoreDep
|
||||
) -> Any:
|
||||
try:
|
||||
return await run_in_threadpool(store.rename, name, body.name)
|
||||
except DashboardNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
|
||||
except DashboardExists:
|
||||
raise HTTPException(status_code=409, detail=f"'{body.name}' already exists")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
@@ -0,0 +1,898 @@
|
||||
"""The flow API. Everything the editor can do is available here first."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
FlowControllerDep,
|
||||
SessionDep,
|
||||
decode_token,
|
||||
get_current_user,
|
||||
user_from_token,
|
||||
)
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.dashboards import DashboardStore
|
||||
from fluksio.flow.events import event_bus
|
||||
from fluksio.flow.messages import qualify
|
||||
from fluksio.flow.panels import messages_for
|
||||
from fluksio.flow.pipeline import ValidationIssue
|
||||
from fluksio.flow.runs import RunRejected
|
||||
from fluksio.flow.schemas import (
|
||||
NAME_PATTERN,
|
||||
BrainGraph,
|
||||
FlowDef,
|
||||
FlowsPublic,
|
||||
FlowStatePublic,
|
||||
FlowSummary,
|
||||
HistoryPoint,
|
||||
LibraryNode,
|
||||
MessageHistory,
|
||||
MessageValue,
|
||||
NodeSource,
|
||||
NodeStatusPublic,
|
||||
NodeTypeInfo,
|
||||
)
|
||||
from fluksio.flow.state import as_number
|
||||
from fluksio.flow.store import (
|
||||
FlowExists,
|
||||
FlowNotFound,
|
||||
LibExists,
|
||||
LibNotFound,
|
||||
StaleVersion,
|
||||
)
|
||||
from fluksio.models import Message, Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
# The websocket authenticates from its query string, so it stays off the
|
||||
# router that enforces the HTTP bearer scheme.
|
||||
ws_router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
|
||||
class Endpoint(BaseModel):
|
||||
"""Something wired into this flow that is not a node in it.
|
||||
|
||||
A dashboard control setting one of its messages, a tile showing one, or a
|
||||
node in another flow on the far side of a dotted name. The canvas draws
|
||||
these so a value never appears to come from nowhere — or worse, appears to
|
||||
come from whichever node happens to be drawn as a producer.
|
||||
"""
|
||||
|
||||
#: dashboard or flow.
|
||||
kind: str
|
||||
#: Stable within its kind, and used as the canvas node id.
|
||||
id: str
|
||||
label: str
|
||||
#: What sort of widget, or the node type in the other flow.
|
||||
detail: str = ""
|
||||
#: Messages of this flow it publishes, and ones it reads.
|
||||
provides: list[str] = []
|
||||
requires: list[str] = []
|
||||
|
||||
|
||||
class FlowDetail(BaseModel):
|
||||
"""A flow plus how it is currently doing.
|
||||
|
||||
``definition`` is the working copy — the unpublished draft when there is
|
||||
one — because that is what the editor shows. ``nodes`` reports the
|
||||
published flow, which is what is actually running.
|
||||
"""
|
||||
|
||||
definition: FlowDef
|
||||
nodes: list[NodeStatusPublic] = []
|
||||
issues: list[ValidationIssue] = []
|
||||
has_draft: bool = False
|
||||
enabled: bool = True
|
||||
paused: bool = False
|
||||
#: Dashboards and other flows wired into this one.
|
||||
endpoints: list[Endpoint] = []
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
new_name: str
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
version: int
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
lib_name: str
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
inputs: dict[str, Any] = {}
|
||||
|
||||
|
||||
class TriggerRequest(BaseModel):
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _endpoints(controller: FlowController, flow: str) -> list[Endpoint]:
|
||||
"""Everything wired into ``flow`` from outside it."""
|
||||
found: list[Endpoint] = []
|
||||
|
||||
dashboards: DashboardStore | None = getattr(controller, "dashboards", None)
|
||||
if dashboards is not None:
|
||||
for binding in dashboards.bindings_for(flow):
|
||||
found.append(
|
||||
Endpoint(
|
||||
kind="dashboard",
|
||||
id=f"dashboard:{binding['dashboard']}:{binding['widget']}",
|
||||
label=binding["title"],
|
||||
detail=binding["type"],
|
||||
provides=[binding["provides"]] if binding["provides"] else [],
|
||||
requires=binding["requires"],
|
||||
)
|
||||
)
|
||||
|
||||
for other, node_id, provides, requires in controller.cross_flow_nodes(flow):
|
||||
found.append(
|
||||
Endpoint(
|
||||
kind="flow",
|
||||
id=f"flow:{other}.{node_id}",
|
||||
label=f"{other}.{node_id}",
|
||||
detail="flow",
|
||||
provides=provides,
|
||||
requires=requires,
|
||||
)
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
||||
name = definition.name
|
||||
running = {
|
||||
"enabled": controller.is_enabled(name),
|
||||
"paused": controller.is_paused(name),
|
||||
}
|
||||
endpoints = _endpoints(controller, name)
|
||||
if controller.store.has_draft(name):
|
||||
# Report the draft the editor is showing, not the version running
|
||||
# underneath it — otherwise a node the author just broke looks fine.
|
||||
preview = controller.preview(name)
|
||||
return FlowDetail(
|
||||
definition=definition,
|
||||
nodes=preview.nodes,
|
||||
issues=preview.issues,
|
||||
has_draft=True,
|
||||
endpoints=endpoints,
|
||||
**running,
|
||||
)
|
||||
return FlowDetail(
|
||||
definition=definition,
|
||||
nodes=controller.node_statuses(name),
|
||||
issues=controller.flow_issues(name),
|
||||
endpoints=endpoints,
|
||||
**running,
|
||||
)
|
||||
|
||||
|
||||
def _read_flow(controller: FlowController, name: str) -> FlowDef:
|
||||
"""The working copy: the draft when there is one, else what is published."""
|
||||
try:
|
||||
return controller.store.read_flow(name, draft=True)
|
||||
except FlowNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
|
||||
|
||||
|
||||
def _audit(action: str, flow: str, user: CurrentUser) -> None:
|
||||
"""Record who changed what. The collector writes it down; the bus carries it."""
|
||||
event_bus.publish(
|
||||
{
|
||||
"type": "audit",
|
||||
"action": action,
|
||||
"flow": flow,
|
||||
"user": user.email,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _forget_runs(session: Session, flow: str) -> None:
|
||||
"""A deleted flow's runs, and everything hanging off them.
|
||||
|
||||
Here rather than in ``FlowController.forget_flow`` because renaming a flow
|
||||
calls that too, and a rename must keep its experiment history.
|
||||
|
||||
Only the run tables: ``flow_run``, ``metric_minute`` and ``engine_event``
|
||||
are the observability rollups, deliberately kept as a record of what ran
|
||||
and already pruned at OBS_RETENTION_DAYS.
|
||||
"""
|
||||
# A subquery, not a materialised list of ids: a demo can hold thousands.
|
||||
runs = select(col(Run.id)).where(col(Run.flow) == flow)
|
||||
session.execute(delete(RunNode).where(col(RunNode.run_id).in_(runs)))
|
||||
session.execute(delete(RunMetric).where(col(RunMetric.run_id).in_(runs)))
|
||||
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id).in_(runs)))
|
||||
session.execute(delete(Run).where(col(Run.flow) == flow))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _source_ref(definition: FlowDef, node_id: str) -> str | None:
|
||||
"""The library source this node runs, if it is a shared one."""
|
||||
node = next((n for n in definition.nodes if n.id == node_id), None)
|
||||
return node.source_ref if node else None
|
||||
|
||||
|
||||
def _require_enabled(controller: FlowController, name: str) -> None:
|
||||
if not controller.is_enabled(name):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Flow '{name}' is stopped — start it before running it",
|
||||
)
|
||||
|
||||
|
||||
def _flow_state(controller: FlowController, name: str) -> FlowStatePublic:
|
||||
return FlowStatePublic(
|
||||
values={
|
||||
key: MessageValue(**value) for key, value in controller.values(name).items()
|
||||
},
|
||||
nodes=controller.node_statuses(name),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flows
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_model=FlowsPublic)
|
||||
def read_flows(controller: FlowControllerDep) -> Any:
|
||||
"""List every flow."""
|
||||
summaries = []
|
||||
for name in controller.store.list_flows():
|
||||
try:
|
||||
definition = controller.store.read_flow(name, draft=True)
|
||||
except FlowNotFound:
|
||||
continue
|
||||
statuses = controller.node_statuses(name)
|
||||
summaries.append(
|
||||
FlowSummary(
|
||||
name=definition.name,
|
||||
title=definition.title,
|
||||
node_count=len(definition.nodes),
|
||||
error_count=sum(1 for s in statuses if s.status == "error"),
|
||||
has_draft=controller.store.has_draft(name),
|
||||
enabled=controller.is_enabled(name),
|
||||
paused=controller.is_paused(name),
|
||||
quarantined=controller.is_quarantined(name),
|
||||
version=definition.version,
|
||||
)
|
||||
)
|
||||
return FlowsPublic(data=summaries, count=len(summaries))
|
||||
|
||||
|
||||
@router.get("/node-types", response_model=list[NodeTypeInfo])
|
||||
def read_node_types() -> Any:
|
||||
"""The node types that can be placed on a canvas."""
|
||||
from fluksio.flow.controller import node_type_info
|
||||
|
||||
return node_type_info()
|
||||
|
||||
|
||||
# Above "/{name}" for the same reason "library" is: a flow called "graph" would
|
||||
# otherwise be unreachable.
|
||||
@router.get("/graph", response_model=BrainGraph)
|
||||
def read_graph(controller: FlowControllerDep) -> Any:
|
||||
"""Every flow as one graph, with nodes talking to the same thing merged."""
|
||||
return controller.brain_graph()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared nodes
|
||||
#
|
||||
# Declared above the "/{name}" routes: "library" would otherwise be read as a
|
||||
# flow name.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/library", response_model=list[LibraryNode])
|
||||
def read_library(controller: FlowControllerDep) -> Any:
|
||||
"""The node sources shared across flows, and which nodes use each."""
|
||||
return [
|
||||
LibraryNode(name=name, used_by=controller.store.usages(name))
|
||||
for name in controller.store.list_lib()
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/library/{lib_name}", response_model=Message)
|
||||
async def delete_shared_node(lib_name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Remove a shared source, as long as no flow still runs it."""
|
||||
used_by = controller.store.usages(lib_name)
|
||||
if used_by:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"'{lib_name}' is still used by {', '.join(used_by)}",
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(controller.store.delete_lib_source, lib_name)
|
||||
except LibNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No shared node '{lib_name}'")
|
||||
return Message(message=f"Deleted shared node '{lib_name}'")
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FlowDetail)
|
||||
def read_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Read one flow, with the state of its nodes."""
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FlowDetail)
|
||||
async def save_flow(
|
||||
name: str,
|
||||
definition: FlowDef,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Save unpublished changes to a flow.
|
||||
|
||||
This writes a draft: the running pipeline keeps the published version until
|
||||
someone publishes. ``version`` is the one the editor last saw — a mismatch
|
||||
means another client saved in between and answers 409 rather than throwing
|
||||
their work away.
|
||||
"""
|
||||
if definition.name != name:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="The flow name in the body must match the URL"
|
||||
)
|
||||
|
||||
duplicates = {n.id for n in definition.nodes}
|
||||
if len(duplicates) != len(definition.nodes):
|
||||
raise HTTPException(status_code=400, detail="Node names must be unique")
|
||||
|
||||
try:
|
||||
stored = await run_in_threadpool(
|
||||
controller.store.write_draft, definition, definition.version
|
||||
)
|
||||
except StaleVersion as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"message": str(exc), "current_version": exc.current},
|
||||
)
|
||||
return _detail(controller, stored)
|
||||
|
||||
|
||||
@router.post("/{name}/publish", response_model=FlowDetail)
|
||||
async def publish_flow(
|
||||
name: str,
|
||||
body: PublishRequest,
|
||||
controller: FlowControllerDep,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Deploy the unpublished changes: the engine picks them up from here."""
|
||||
_read_flow(controller, name)
|
||||
if not controller.store.has_draft(name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
|
||||
)
|
||||
try:
|
||||
published = await run_in_threadpool(
|
||||
controller.store.publish_flow, name, body.version
|
||||
)
|
||||
except StaleVersion as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"message": str(exc), "current_version": exc.current},
|
||||
)
|
||||
_audit("published", name, user)
|
||||
await controller.reload()
|
||||
return _detail(controller, published)
|
||||
|
||||
|
||||
@router.post("/{name}/discard-draft", response_model=FlowDetail)
|
||||
async def discard_draft(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Throw the unpublished changes away and go back to what is running."""
|
||||
_read_flow(controller, name)
|
||||
if not controller.store.has_draft(name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Flow '{name}' has no unpublished changes"
|
||||
)
|
||||
if not controller.store.is_published(name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Flow '{name}' has never been published — delete it instead of "
|
||||
"discarding it"
|
||||
),
|
||||
)
|
||||
published = await run_in_threadpool(controller.store.discard_draft, name)
|
||||
return _detail(controller, published)
|
||||
|
||||
|
||||
@router.delete("/{name}", response_model=Message)
|
||||
async def delete_flow(
|
||||
name: str, controller: FlowControllerDep, user: CurrentUser, session: SessionDep
|
||||
) -> Any:
|
||||
"""Delete a flow and everything in it."""
|
||||
try:
|
||||
await run_in_threadpool(controller.store.delete_flow, name)
|
||||
except FlowNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
|
||||
_audit("deleted", name, user)
|
||||
# Its files are gone; its values and queued work would otherwise linger.
|
||||
await run_in_threadpool(controller.forget_flow, name)
|
||||
await run_in_threadpool(_forget_runs, session, name)
|
||||
await controller.reload()
|
||||
return Message(message=f"Deleted flow '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/rename", response_model=FlowDetail)
|
||||
async def rename_flow(
|
||||
name: str,
|
||||
body: RenameRequest,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Rename a flow, along with every reference to its messages."""
|
||||
if not NAME_PATTERN.match(body.new_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Use lowercase letters, digits and underscores, starting with a letter"
|
||||
),
|
||||
)
|
||||
try:
|
||||
renamed = await run_in_threadpool(
|
||||
controller.store.rename_flow, name, body.new_name
|
||||
)
|
||||
except FlowNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
|
||||
except FlowExists as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
|
||||
# The old name is nobody's namespace now; its values would sit there under a
|
||||
# flow that no longer exists. They repopulate under the new name on the next
|
||||
# run, so this is cleanup rather than a migration.
|
||||
await run_in_threadpool(controller.forget_flow, name)
|
||||
await controller.reload()
|
||||
return _detail(controller, renamed)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Node source
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{name}/nodes/{node_id}/source", response_model=NodeSource)
|
||||
def read_node_source(
|
||||
name: str,
|
||||
node_id: str,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Read a node's Python source, including unpublished edits."""
|
||||
definition = _read_flow(controller, name)
|
||||
ref = _source_ref(definition, node_id)
|
||||
if ref:
|
||||
try:
|
||||
return NodeSource(code=controller.store.read_lib_source(ref))
|
||||
except LibNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No shared node '{ref}'")
|
||||
return NodeSource(code=controller.store.read_node_source(name, node_id, draft=True))
|
||||
|
||||
|
||||
@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
|
||||
async def save_node_source(
|
||||
name: str,
|
||||
node_id: str,
|
||||
source: NodeSource,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Save a node's source as an unpublished edit and report whether it loads.
|
||||
|
||||
The answer comes from compiling the code rather than from the running
|
||||
pipeline: a draft is not deployed, and compiling is both faster and more
|
||||
precise about what the author just typed.
|
||||
|
||||
A shared node writes to the library, so the fix reaches every flow using
|
||||
it — and that one is live immediately rather than waiting for a publish,
|
||||
because the copy is not any single flow's to hold back.
|
||||
"""
|
||||
definition = _read_flow(controller, name)
|
||||
ref = _source_ref(definition, node_id)
|
||||
if ref:
|
||||
changed = await run_in_threadpool(
|
||||
controller.store.write_lib_source, ref, source.code
|
||||
)
|
||||
if changed:
|
||||
await controller.reload()
|
||||
else:
|
||||
await run_in_threadpool(
|
||||
controller.store.write_node_source, name, node_id, source.code, True
|
||||
)
|
||||
node_def = next((n for n in definition.nodes if n.id == node_id), None)
|
||||
device = (
|
||||
node_def.device
|
||||
if node_def is not None and node_def.device_policy == "require"
|
||||
else None
|
||||
)
|
||||
error = await run_in_threadpool(
|
||||
controller.compile_check, name, node_id, source.code, device
|
||||
)
|
||||
return NodeStatusPublic(
|
||||
id=f"{name}.{node_id}",
|
||||
status="error" if error else "active",
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/share", response_model=FlowDetail)
|
||||
async def share_node(
|
||||
name: str,
|
||||
node_id: str,
|
||||
body: ShareRequest,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Move this node's code into the library so other flows can run it too."""
|
||||
_read_flow(controller, name)
|
||||
if not NAME_PATTERN.match(body.lib_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Use lowercase letters, digits and underscores, starting with a letter"
|
||||
),
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
controller.store.share_node, name, node_id, body.lib_name
|
||||
)
|
||||
except LibExists as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/unshare", response_model=FlowDetail)
|
||||
async def unshare_node(
|
||||
name: str,
|
||||
node_id: str,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Take a private copy of the shared code back into this flow."""
|
||||
_read_flow(controller, name)
|
||||
try:
|
||||
await run_in_threadpool(controller.store.unshare_node, name, node_id)
|
||||
except LibNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Running, stopped, paused
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{name}/start", response_model=FlowDetail)
|
||||
async def start_flow(
|
||||
name: str, controller: FlowControllerDep, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Let the engine run this flow again."""
|
||||
_read_flow(controller, name)
|
||||
await controller.set_enabled(name, True)
|
||||
_audit("started", name, user)
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
@router.post("/{name}/stop", response_model=FlowDetail)
|
||||
async def stop_flow(name: str, controller: FlowControllerDep, user: CurrentUser) -> Any:
|
||||
"""Take this flow off the engine: no subscriptions, schedules or webhooks."""
|
||||
_read_flow(controller, name)
|
||||
await controller.set_enabled(name, False)
|
||||
_audit("stopped", name, user)
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
@router.post("/{name}/pause", response_model=Message)
|
||||
def pause_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Hold the flow's nodes so its messages can be stepped through."""
|
||||
_read_flow(controller, name)
|
||||
controller.pause_flow(name)
|
||||
return Message(message=f"Paused flow '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/resume", response_model=Message)
|
||||
async def resume_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Let the flow carry on, running whatever was held back."""
|
||||
_read_flow(controller, name)
|
||||
await run_in_threadpool(controller.resume_flow, name)
|
||||
return Message(message=f"Resumed flow '{name}'")
|
||||
|
||||
|
||||
@router.post("/{name}/step", response_model=Message)
|
||||
async def step_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Run one message a pause is holding back, leaving the flow paused."""
|
||||
_read_flow(controller, name)
|
||||
node = await run_in_threadpool(controller.step_flow, name)
|
||||
if node is None:
|
||||
return Message(message=f"Nothing held back in flow '{name}'")
|
||||
return Message(message=f"Stepped '{node}'")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Validation and execution
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{name}/validate", response_model=ValidationResult)
|
||||
def validate_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Report what would keep this flow from running."""
|
||||
_read_flow(controller, name)
|
||||
if controller.store.has_draft(name):
|
||||
return ValidationResult(issues=controller.preview(name).issues)
|
||||
return ValidationResult(issues=controller.flow_issues(name))
|
||||
|
||||
|
||||
@router.post("/{name}/run", response_model=FlowStatePublic)
|
||||
async def run_flow(
|
||||
name: str,
|
||||
body: RunRequest,
|
||||
request: Request,
|
||||
controller: FlowControllerDep,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Run every node of a flow once.
|
||||
|
||||
With unpublished changes this runs the draft, so the button matches what is
|
||||
on the canvas. Nothing is deployed by running it.
|
||||
|
||||
A *batch* flow is submitted as a run instead, because that is what running
|
||||
one means: it is what keeps the parameters, the series and the result, and
|
||||
a button that quietly did something else would be a trap.
|
||||
"""
|
||||
definition = _read_flow(controller, name)
|
||||
_require_enabled(controller, name)
|
||||
if definition.mode == "batch":
|
||||
service = getattr(request.app.state, "run_service", None)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="Runs are not available")
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
service.submit,
|
||||
name,
|
||||
params=body.inputs,
|
||||
cause="api",
|
||||
actor=user.email,
|
||||
draft=controller.store.has_draft(name),
|
||||
)
|
||||
except RunRejected as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _flow_state(controller, name)
|
||||
|
||||
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
|
||||
if controller.store.has_draft(name):
|
||||
await run_in_threadpool(controller.run_preview, name, inputs)
|
||||
else:
|
||||
await run_in_threadpool(controller.run_flow, name, inputs)
|
||||
return _flow_state(controller, name)
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/trigger", response_model=FlowStatePublic)
|
||||
async def trigger_node(
|
||||
name: str,
|
||||
node_id: str,
|
||||
body: TriggerRequest,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Feed values into a single node."""
|
||||
_require_enabled(controller, name)
|
||||
try:
|
||||
error = await run_in_threadpool(
|
||||
controller.trigger_node, f"{name}.{node_id}", body.values
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
|
||||
)
|
||||
if error:
|
||||
# The canvas already has the failure from the bus; the person who
|
||||
# clicked gets to read it too, instead of a stack trace in the log.
|
||||
raise HTTPException(status_code=400, detail=error)
|
||||
return _flow_state(controller, name)
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/cancel", response_model=Message)
|
||||
def cancel_node(name: str, node_id: str, controller: FlowControllerDep) -> Any:
|
||||
"""Stop a node that is running right now, by killing the worker running it.
|
||||
|
||||
Idempotent on purpose: by the time a click reaches here the node may well
|
||||
have finished, and that is the outcome that was asked for.
|
||||
"""
|
||||
pool = controller.workers
|
||||
if pool is not None and pool.cancel(f"{name}.{node_id}"):
|
||||
return Message(message=f"Stopped '{node_id}'")
|
||||
return Message(message=f"'{node_id}' was not running")
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/acknowledge", response_model=Message)
|
||||
def acknowledge_node_error(
|
||||
name: str, node_id: str, controller: FlowControllerDep
|
||||
) -> Any:
|
||||
"""Dismiss what a node last failed with, so the canvas stops marking it.
|
||||
|
||||
A failure outlives the next good run on purpose — otherwise one that fired
|
||||
an alert leaves no trace by the time anyone looks. Reading the traceback is
|
||||
what says it has been seen.
|
||||
"""
|
||||
try:
|
||||
controller.acknowledge_error(f"{name}.{node_id}")
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"No node named '{node_id}' in flow '{name}'"
|
||||
) from None
|
||||
return Message(message=f"Cleared the failure on '{node_id}'")
|
||||
|
||||
|
||||
@router.get("/{name}/state", response_model=FlowStatePublic)
|
||||
def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""The last value seen on every message of this flow."""
|
||||
_read_flow(controller, name)
|
||||
return _flow_state(controller, name)
|
||||
|
||||
|
||||
@router.get("/{name}/history/{message}", response_model=MessageHistory)
|
||||
def read_message_history(
|
||||
name: str,
|
||||
message: str,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""The recent values of one message, for plotting.
|
||||
|
||||
``message`` may be given bare or qualified; a message that never carried a
|
||||
number comes back with an empty series.
|
||||
"""
|
||||
_read_flow(controller, name)
|
||||
key = qualify(name, message)
|
||||
return MessageHistory(
|
||||
message=key,
|
||||
numeric=as_number(controller.state.get(key)) is not None,
|
||||
points=[
|
||||
HistoryPoint(ts=ts, value=value)
|
||||
for ts, value in controller.state.history(key)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Live updates
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def snapshot_payload(
|
||||
controller: FlowController, only: set[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Everything a client needs to catch up, sent the moment it connects.
|
||||
|
||||
Also sent by the tunnel connector, which serves this websocket inline: the
|
||||
two have to agree, so they build the message here rather than each their
|
||||
own. ``emits`` is what the bus counted while nobody was listening — a
|
||||
client that reconnects between two pages would otherwise start from zero.
|
||||
|
||||
``only`` bounds it to a set of message names, which is what a wall panel
|
||||
gets: the values its own dashboards draw, and none of the rest — no node
|
||||
status, no logs, no shape of the graph. A panel renders none of that, and
|
||||
a screen may be hanging somewhere nobody here can see.
|
||||
"""
|
||||
if only is not None:
|
||||
# The same keys, emptied rather than dropped: a screen already hanging
|
||||
# runs whatever bundle it was paired with, and the shape of this
|
||||
# message is what that bundle reads.
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"values": {k: v for k, v in controller.values().items() if k in only},
|
||||
"nodes": [],
|
||||
"issues": [],
|
||||
"paused": [],
|
||||
"logs": [],
|
||||
"emits": {},
|
||||
}
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"values": controller.values(),
|
||||
"nodes": [s.model_dump() for s in controller.node_statuses()],
|
||||
"issues": [i.model_dump() for i in controller.issues],
|
||||
"paused": controller.paused_flows(),
|
||||
"logs": list(event_bus.recent_logs),
|
||||
"emits": dict(event_bus.emits),
|
||||
}
|
||||
|
||||
|
||||
def panel_scope(token: str, app: Any) -> set[str] | None:
|
||||
"""The messages this credential is bounded to, or None if it is a person's.
|
||||
|
||||
The socket is the one authenticated surface the route check cannot reach —
|
||||
a handshake has no route to judge — so a panel is bounded by what it is
|
||||
sent instead of by what it asks for.
|
||||
"""
|
||||
try:
|
||||
panel = str(decode_token(token).get("panel") or "")
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
if not panel:
|
||||
return None
|
||||
store: DashboardStore | None = getattr(app.state, "dashboard_store", None)
|
||||
return messages_for(panel, store) if store is not None else set()
|
||||
|
||||
|
||||
def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
|
||||
"""Whether a panel's socket should carry this event.
|
||||
|
||||
The same bound as the snapshot above, applied to the stream that follows
|
||||
it: a value the panel draws, and nothing else on the bus — save for a
|
||||
dashboard being published, which is how a screen hears that the document
|
||||
it is drawing, or the set of them it was given, has moved.
|
||||
"""
|
||||
if event.get("type") == "dashboard_changed":
|
||||
return True
|
||||
return event.get("type") == "message_value" and str(event.get("name") or "") in only
|
||||
|
||||
|
||||
@ws_router.websocket("/ws")
|
||||
async def flow_events(websocket: WebSocket, token: str = "") -> None:
|
||||
"""Stream values, node status and execution events as they happen.
|
||||
|
||||
The token goes in the query string because browsers cannot set headers on
|
||||
a websocket handshake.
|
||||
"""
|
||||
with Session(engine) as session:
|
||||
user = user_from_token(session, token)
|
||||
if user is None:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
only = panel_scope(token, websocket.app)
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
controller: FlowController | None = getattr(
|
||||
websocket.app.state, "flow_controller", None
|
||||
)
|
||||
if controller is not None:
|
||||
await websocket.send_json(snapshot_payload(controller, only))
|
||||
|
||||
async with event_bus.subscribe() as queue:
|
||||
receiver = asyncio.create_task(websocket.receive_text())
|
||||
try:
|
||||
while True:
|
||||
sender = asyncio.create_task(queue.get())
|
||||
done, _ = await asyncio.wait(
|
||||
{sender, receiver}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if receiver in done:
|
||||
# The client went away.
|
||||
sender.cancel()
|
||||
break
|
||||
event = sender.result()
|
||||
if only is not None and event.get("type") == "dashboard_changed":
|
||||
# The scope was resolved once, at the handshake. A panel
|
||||
# pointed at another dashboard would otherwise fetch the
|
||||
# new document and then draw tiles nothing ever updates.
|
||||
# ``or set()`` because a panel that was deleted resolves to
|
||||
# None, the same as a person's token — and that would widen
|
||||
# this socket to everything on the bus.
|
||||
only = panel_scope(token, websocket.app) or set()
|
||||
if controller is not None:
|
||||
await websocket.send_json(snapshot_payload(controller, only))
|
||||
if only is not None and not event_for_panel(event, only):
|
||||
continue
|
||||
await websocket.send_json(event)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
receiver.cancel()
|
||||
@@ -0,0 +1,123 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from fluksio import crud
|
||||
from fluksio.api.deps import CurrentUser, SessionDep, get_current_active_superuser
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.models import Message, NewPassword, Token, UserPublic, UserUpdate
|
||||
from fluksio.utils import (
|
||||
generate_password_reset_token,
|
||||
generate_reset_password_email,
|
||||
send_email,
|
||||
verify_password_reset_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["login"])
|
||||
|
||||
|
||||
@router.post("/login/access-token")
|
||||
def login_access_token(
|
||||
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
||||
) -> Token:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
user = crud.authenticate(
|
||||
session=session, email=form_data.username, password=form_data.password
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
elif not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/test-token", response_model=UserPublic)
|
||||
def test_token(current_user: CurrentUser) -> Any:
|
||||
"""
|
||||
Test access token
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/password-recovery/{email}")
|
||||
def recover_password(email: str, session: SessionDep) -> Message:
|
||||
"""
|
||||
Password Recovery
|
||||
"""
|
||||
user = crud.get_user_by_email(session=session, email=email)
|
||||
|
||||
# Always return the same response to prevent email enumeration attacks
|
||||
# Only send email if user actually exists
|
||||
if user:
|
||||
password_reset_token = generate_password_reset_token(email=email)
|
||||
email_data = generate_reset_password_email(
|
||||
email_to=user.email, email=email, token=password_reset_token
|
||||
)
|
||||
send_email(
|
||||
email_to=user.email,
|
||||
subject=email_data.subject,
|
||||
html_content=email_data.html_content,
|
||||
)
|
||||
return Message(
|
||||
message="If that email is registered, we sent a password recovery link"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password/")
|
||||
def reset_password(session: SessionDep, body: NewPassword) -> Message:
|
||||
"""
|
||||
Reset password
|
||||
"""
|
||||
email = verify_password_reset_token(token=body.token)
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Invalid token")
|
||||
user = crud.get_user_by_email(session=session, email=email)
|
||||
if not user:
|
||||
# Don't reveal that the user doesn't exist - use same error as invalid token
|
||||
raise HTTPException(status_code=400, detail="Invalid token")
|
||||
elif not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
user_in_update = UserUpdate(password=body.new_password)
|
||||
crud.update_user(
|
||||
session=session,
|
||||
db_user=user,
|
||||
user_in=user_in_update,
|
||||
)
|
||||
return Message(message="Password updated successfully")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password-recovery-html-content/{email}",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
def recover_password_html_content(email: str, session: SessionDep) -> Any:
|
||||
"""
|
||||
HTML Content for Password Recovery
|
||||
"""
|
||||
user = crud.get_user_by_email(session=session, email=email)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="The user with this username does not exist in the system.",
|
||||
)
|
||||
password_reset_token = generate_password_reset_token(email=email)
|
||||
email_data = generate_reset_password_email(
|
||||
email_to=user.email, email=email, token=password_reset_token
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
content=email_data.html_content, headers={"subject:": email_data.subject}
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Messages: what a dashboard binds to, across every flow.
|
||||
|
||||
The flow API is scoped to one flow, which is the wrong shape here — a wall
|
||||
panel shows the heating alongside the solar. These endpoints are the whole
|
||||
namespace at once: what exists, what it last was, and a way to put a value
|
||||
into it without owning a node.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fluksio.api.deps import FlowControllerDep, get_current_user
|
||||
from fluksio.flow.messages import flow_of
|
||||
from fluksio.flow.pipeline import ValueSource
|
||||
from fluksio.flow.state import as_number
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/messages", tags=["messages"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class MessageInfo(BaseModel):
|
||||
"""One message, as something to bind a widget to."""
|
||||
|
||||
name: str
|
||||
flow: str
|
||||
dtype: str
|
||||
#: Nodes publishing it. Empty means the flow declares it as an input.
|
||||
providers: list[str] = []
|
||||
#: Whether a dashboard may publish to it — that is, whether it is declared.
|
||||
writable: bool = True
|
||||
numeric: bool = False
|
||||
value: Any = None
|
||||
ts: float | None = None
|
||||
|
||||
|
||||
class MessagesPublic(BaseModel):
|
||||
data: list[MessageInfo]
|
||||
count: int
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
value: Any
|
||||
#: Where this came from, so the canvas can show it arriving from outside
|
||||
#: rather than crediting whichever node is drawn as a producer.
|
||||
source_kind: str = "api"
|
||||
source_id: str = ""
|
||||
source_label: str = ""
|
||||
source_detail: str = ""
|
||||
|
||||
|
||||
class MessageValue(BaseModel):
|
||||
name: str
|
||||
value: Any
|
||||
ts: float | None = None
|
||||
|
||||
|
||||
class MessagePoints(BaseModel):
|
||||
message: str
|
||||
numeric: bool
|
||||
points: list[dict[str, float]]
|
||||
|
||||
|
||||
@router.get("/", response_model=MessagesPublic)
|
||||
async def read_messages(controller: FlowControllerDep) -> Any:
|
||||
"""Every message any published flow declares, with its last value."""
|
||||
infos = await run_in_threadpool(controller.message_catalog)
|
||||
return MessagesPublic(data=infos, count=len(infos))
|
||||
|
||||
|
||||
@router.post("/{name}", response_model=MessageValue)
|
||||
async def publish_message(
|
||||
name: str, body: PublishRequest, controller: FlowControllerDep
|
||||
) -> Any:
|
||||
"""Put a value into the graph, as a dashboard control does.
|
||||
|
||||
Only a message some flow declares can be published to: flows own the
|
||||
namespace, and a dashboard is a client of it rather than a second author.
|
||||
"""
|
||||
source = ValueSource(
|
||||
kind=body.source_kind,
|
||||
id=body.source_id,
|
||||
label=body.source_label or body.source_id or "API",
|
||||
detail=body.source_detail,
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(controller.publish_message, name, body.value, source)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"No flow declares a message named '{name}'"
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
values = controller.values(flow_of(name))
|
||||
current = values.get(name, {})
|
||||
return MessageValue(name=name, value=current.get("value"), ts=current.get("ts"))
|
||||
|
||||
|
||||
@router.get("/{name}/history", response_model=MessagePoints)
|
||||
def read_message_history(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""The series behind a chart. Numbers only — nothing else plots."""
|
||||
if controller.pipeline is None:
|
||||
return MessagePoints(message=name, numeric=False, points=[])
|
||||
series = controller.state.history(name)
|
||||
numeric = as_number(controller.state.get(name)) is not None
|
||||
return MessagePoints(
|
||||
message=name,
|
||||
numeric=numeric or bool(series),
|
||||
points=[{"ts": ts, "value": value} for ts, value in series],
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The python packages node code may import.
|
||||
|
||||
A manifest in the flow store, a venv on the data volume, and one button that
|
||||
brings the second in line with the first. Nothing here restarts the engine:
|
||||
the worker pool retires its processes, and the next node call picks up the new
|
||||
packages.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
FlowControllerDep,
|
||||
WorkerPoolDep,
|
||||
get_current_user,
|
||||
)
|
||||
from fluksio.flow import modules
|
||||
from fluksio.flow.events import event_bus
|
||||
from fluksio.flow.schemas import ApplyRequest, ApplyResult, ModulesInfo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/modules", tags=["modules"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=ModulesInfo)
|
||||
async def read_modules(controller: FlowControllerDep) -> Any:
|
||||
"""What node code can import, and whether it matches the manifest."""
|
||||
return await run_in_threadpool(modules.info, controller.store)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResult)
|
||||
async def apply_modules(
|
||||
body: ApplyRequest,
|
||||
controller: FlowControllerDep,
|
||||
pool: WorkerPoolDep,
|
||||
user: CurrentUser,
|
||||
) -> Any:
|
||||
"""Install exactly these requirements, then hand them to the workers.
|
||||
|
||||
A manifest that does not resolve changes nothing: the venv is left as it
|
||||
was and the stored manifest is only written once the install succeeded.
|
||||
"""
|
||||
ok, output = await run_in_threadpool(modules.sync, body.requirements)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=output or "These requirements could not be installed",
|
||||
)
|
||||
await run_in_threadpool(controller.store.write_requirements, body.requirements)
|
||||
event_bus.publish(
|
||||
{
|
||||
"type": "audit",
|
||||
"action": "installed modules",
|
||||
"flow": "",
|
||||
"user": user.email,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
# Retire the workers first, so the rebuild compiles every node against the
|
||||
# packages that were just installed — a node that could not import one is
|
||||
# the reason this was called, and it stays red until it is built again.
|
||||
pool.respawn_all()
|
||||
await controller.reload()
|
||||
return ApplyResult(ok=True, output=output)
|
||||
@@ -0,0 +1,536 @@
|
||||
"""OAuth 2.1 authorization server, for agents reaching the MCP endpoint.
|
||||
|
||||
An agent cannot be handed a password, so it registers itself, sends a human to
|
||||
the browser to approve it, and exchanges the resulting code for a token. The
|
||||
parts that carry the security are the ones with the least room for
|
||||
interpretation:
|
||||
|
||||
* the code is single-use, short-lived, and only its hash is stored;
|
||||
* PKCE is required (S256 only), so a code intercepted on its way back is
|
||||
useless without the verifier that started the flow;
|
||||
* the redirect the browser is finally sent to is the one *registered*, never
|
||||
the one asked for;
|
||||
* refresh tokens rotate, and reusing a spent one revokes the whole line, which
|
||||
is how a stolen token gets noticed.
|
||||
|
||||
Errors here follow RFC 6749 — ``{"error": ...}`` rather than FastAPI's
|
||||
``{"detail": ...}`` — because that is what OAuth clients parse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import col, select
|
||||
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
get_current_user,
|
||||
)
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.models import (
|
||||
Message,
|
||||
OAuthAuthorizationCode,
|
||||
OAuthAuthorizeInfo,
|
||||
OAuthAuthorizeRequest,
|
||||
OAuthAuthorizeResponse,
|
||||
OAuthClient,
|
||||
OAuthClientInfo,
|
||||
OAuthClientRegister,
|
||||
OAuthRefreshToken,
|
||||
User,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/oauth", tags=["oauth"])
|
||||
|
||||
#: RFC 7636: 43-128 characters from the unreserved set.
|
||||
_PKCE_RE = re.compile(r"^[A-Za-z0-9._~-]{43,128}$")
|
||||
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
||||
_NO_STORE = {"Cache-Control": "no-store", "Pragma": "no-cache"}
|
||||
|
||||
|
||||
def _error(code: str, description: str, status: int = 400) -> JSONResponse:
|
||||
"""An OAuth error, in the shape clients expect to read."""
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={"error": code, "error_description": description},
|
||||
headers=_NO_STORE,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
#
|
||||
# Registration is open by necessity, so it is capped per address. In-process is
|
||||
# enough: the engine is one process, and this is a speed bump, not a boundary.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _too_many(bucket: str, limit: int, window: float) -> bool:
|
||||
now = time.monotonic()
|
||||
seen = _hits[bucket]
|
||||
while seen and now - seen[0] > window:
|
||||
seen.popleft()
|
||||
if len(seen) >= limit:
|
||||
return True
|
||||
seen.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _valid_redirect_uri(value: str) -> bool:
|
||||
"""https anywhere, or plain http only on the loopback interface.
|
||||
|
||||
An agent running on someone's laptop listens on 127.0.0.1 and has nowhere
|
||||
to get a certificate, which OAuth 2.1 allows for exactly that reason.
|
||||
"""
|
||||
parsed = urlparse(value)
|
||||
if parsed.fragment:
|
||||
return False
|
||||
if parsed.scheme == "https":
|
||||
return bool(parsed.hostname)
|
||||
if parsed.scheme == "http":
|
||||
return parsed.hostname in _LOOPBACK
|
||||
return False
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
"""Postgres hands back naive datetimes; compare them in UTC."""
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _prune(session: SessionDep) -> None:
|
||||
"""Drop codes long past the window where a replay could still matter."""
|
||||
cutoff = _now() - timedelta(days=1)
|
||||
for code in session.exec(
|
||||
select(OAuthAuthorizationCode).where(OAuthAuthorizationCode.expires_at < cutoff)
|
||||
).all():
|
||||
session.delete(code)
|
||||
|
||||
|
||||
def _issue(
|
||||
session: SessionDep, user_id: uuid.UUID, client_id: uuid.UUID, family: uuid.UUID
|
||||
) -> tuple[dict[str, Any], OAuthRefreshToken]:
|
||||
"""Mint an access token and the refresh token that will replace it."""
|
||||
access = security.create_oauth_access_token(
|
||||
user_id,
|
||||
client_id,
|
||||
timedelta(minutes=settings.MCP_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_secret = secrets.token_urlsafe(32)
|
||||
refresh = OAuthRefreshToken(
|
||||
token_hash=_hash(refresh_secret),
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
family_id=family,
|
||||
expires_at=_now() + timedelta(days=settings.MCP_REFRESH_EXPIRE_DAYS),
|
||||
)
|
||||
session.add(refresh)
|
||||
return {
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.MCP_TOKEN_EXPIRE_MINUTES * 60,
|
||||
"refresh_token": refresh_secret,
|
||||
"scope": security.MCP_SCOPE,
|
||||
}, refresh
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Registration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register_client(
|
||||
body: OAuthClientRegister, request: Request, session: SessionDep
|
||||
) -> Any:
|
||||
"""RFC 7591 dynamic client registration.
|
||||
|
||||
Open on purpose, and harmless on its own: a registered client can do
|
||||
nothing until a signed-in human approves it on the consent page.
|
||||
"""
|
||||
if not settings.MCP_ENABLED:
|
||||
return _error("access_denied", "The MCP endpoint is switched off.", 403)
|
||||
if _too_many(f"register:{_client_ip(request)}", limit=10, window=3600):
|
||||
return _error("invalid_request", "Too many registrations.", 429)
|
||||
|
||||
if not 1 <= len(body.redirect_uris) <= 10:
|
||||
return _error("invalid_redirect_uri", "Give between 1 and 10 redirect URIs.")
|
||||
for uri in body.redirect_uris:
|
||||
if not _valid_redirect_uri(uri):
|
||||
return _error(
|
||||
"invalid_redirect_uri",
|
||||
f"'{uri}' must be https, or http on a loopback address, "
|
||||
"and carry no fragment.",
|
||||
)
|
||||
if body.token_endpoint_auth_method not in (None, "none"):
|
||||
return _error(
|
||||
"invalid_client_metadata",
|
||||
"Only public clients are supported; use PKCE rather than a secret.",
|
||||
)
|
||||
if body.response_types not in (None, ["code"]):
|
||||
return _error("invalid_client_metadata", "Only the 'code' response type.")
|
||||
if body.grant_types is not None and not set(body.grant_types) <= {
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
}:
|
||||
return _error(
|
||||
"invalid_client_metadata",
|
||||
"Only the authorization_code and refresh_token grants.",
|
||||
)
|
||||
|
||||
client = OAuthClient(
|
||||
client_name=body.client_name, redirect_uris=list(body.redirect_uris)
|
||||
)
|
||||
session.add(client)
|
||||
session.commit()
|
||||
session.refresh(client)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content=OAuthClientInfo(
|
||||
client_id=str(client.id),
|
||||
client_name=client.client_name,
|
||||
redirect_uris=client.redirect_uris,
|
||||
client_id_issued_at=int(client.created_at.timestamp()),
|
||||
).model_dump(),
|
||||
headers=_NO_STORE,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_client(session: SessionDep, client_id: str) -> OAuthClient | None:
|
||||
try:
|
||||
return session.get(OAuthClient, uuid.UUID(client_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/authorize/validate",
|
||||
response_model=OAuthAuthorizeInfo,
|
||||
# Signed in, but which user it is does not matter until they approve.
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
def authorize_validate(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
session: SessionDep,
|
||||
) -> Any:
|
||||
"""What the consent page should say, checked before it says it."""
|
||||
if not settings.MCP_ENABLED:
|
||||
return _error("access_denied", "The MCP endpoint is switched off.", 403)
|
||||
client = _load_client(session, client_id)
|
||||
if client is None:
|
||||
return _error("invalid_client", "Unknown client.")
|
||||
if redirect_uri not in client.redirect_uris:
|
||||
return _error("invalid_request", "That redirect URI is not registered.")
|
||||
return OAuthAuthorizeInfo(
|
||||
client_name=client.client_name,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=security.MCP_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/authorize", response_model=OAuthAuthorizeResponse)
|
||||
def authorize(
|
||||
body: OAuthAuthorizeRequest,
|
||||
current_user: CurrentUser,
|
||||
session: SessionDep,
|
||||
) -> Any:
|
||||
"""Approve a client, on behalf of the signed-in user."""
|
||||
if not settings.MCP_ENABLED:
|
||||
return _error("access_denied", "The MCP endpoint is switched off.", 403)
|
||||
|
||||
client = _load_client(session, body.client_id)
|
||||
if client is None:
|
||||
return _error("invalid_client", "Unknown client.")
|
||||
if body.redirect_uri not in client.redirect_uris:
|
||||
return _error("invalid_request", "That redirect URI is not registered.")
|
||||
if body.code_challenge_method != "S256":
|
||||
return _error("invalid_request", "PKCE must use S256.")
|
||||
if not _PKCE_RE.match(body.code_challenge):
|
||||
return _error("invalid_request", "Malformed code challenge.")
|
||||
if body.resource and body.resource.rstrip("/") != settings.mcp_resource:
|
||||
return _error("invalid_target", "Unknown resource.")
|
||||
|
||||
code = secrets.token_urlsafe(32)
|
||||
session.add(
|
||||
OAuthAuthorizationCode(
|
||||
code_hash=_hash(code),
|
||||
client_id=client.id,
|
||||
user_id=current_user.id,
|
||||
redirect_uri=body.redirect_uri,
|
||||
code_challenge=body.code_challenge,
|
||||
resource=body.resource,
|
||||
expires_at=_now() + timedelta(seconds=settings.OAUTH_CODE_EXPIRE_SECONDS),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
query = {"code": code}
|
||||
if body.state:
|
||||
query["state"] = body.state
|
||||
separator = "&" if urlparse(body.redirect_uri).query else "?"
|
||||
# Built from the registered URI, never from what the request asked for.
|
||||
return OAuthAuthorizeResponse(
|
||||
redirect_url=f"{body.redirect_uri}{separator}{urlencode(query)}"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Token
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
def token( # noqa: PLR0911 - each branch is a distinct OAuth error
|
||||
request: Request,
|
||||
session: SessionDep,
|
||||
grant_type: str = Form(...),
|
||||
code: str | None = Form(None),
|
||||
redirect_uri: str | None = Form(None),
|
||||
client_id: str | None = Form(None),
|
||||
code_verifier: str | None = Form(None),
|
||||
refresh_token: str | None = Form(None),
|
||||
resource: str | None = Form(None),
|
||||
) -> Any:
|
||||
"""Exchange a code, or a refresh token, for an access token."""
|
||||
if not settings.MCP_ENABLED:
|
||||
return _error("access_denied", "The MCP endpoint is switched off.", 403)
|
||||
if _too_many(f"token:{_client_ip(request)}", limit=60, window=60):
|
||||
return _error("invalid_request", "Too many token requests.", 429)
|
||||
|
||||
if grant_type == "authorization_code":
|
||||
return _authorization_code_grant(
|
||||
session, code, redirect_uri, client_id, code_verifier, resource
|
||||
)
|
||||
if grant_type == "refresh_token":
|
||||
return _refresh_token_grant(session, refresh_token, client_id)
|
||||
return _error("unsupported_grant_type", f"'{grant_type}' is not supported.")
|
||||
|
||||
|
||||
def _authorization_code_grant(
|
||||
session: SessionDep,
|
||||
code: str | None,
|
||||
redirect_uri: str | None,
|
||||
client_id: str | None,
|
||||
code_verifier: str | None,
|
||||
resource: str | None,
|
||||
) -> Any:
|
||||
if not (code and redirect_uri and client_id and code_verifier):
|
||||
return _error("invalid_request", "Missing a required parameter.")
|
||||
|
||||
record = session.exec(
|
||||
select(OAuthAuthorizationCode).where(
|
||||
OAuthAuthorizationCode.code_hash == _hash(code)
|
||||
)
|
||||
).first()
|
||||
if record is None:
|
||||
return _error("invalid_grant", "Unknown or already used code.")
|
||||
|
||||
if record.used_at is not None:
|
||||
# OAuth 2.1 §4.1.3: a replayed code means the first exchange may have
|
||||
# been someone else's, so everything it produced is withdrawn.
|
||||
_revoke_family(session, record.refresh_token_id)
|
||||
session.commit()
|
||||
return _error("invalid_grant", "That code has already been used.")
|
||||
if _aware(record.expires_at) < _now():
|
||||
return _error("invalid_grant", "That code has expired.")
|
||||
if str(record.client_id) != client_id or record.redirect_uri != redirect_uri:
|
||||
return _error("invalid_grant", "The code was issued to another client.")
|
||||
if resource and resource.rstrip("/") != (record.resource or "").rstrip("/"):
|
||||
return _error("invalid_target", "Resource does not match the authorization.")
|
||||
|
||||
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
if not hmac.compare_digest(expected, record.code_challenge):
|
||||
return _error("invalid_grant", "PKCE verification failed.")
|
||||
|
||||
user = session.get(User, record.user_id)
|
||||
if user is None or not user.is_active:
|
||||
return _error("invalid_grant", "That account is no longer active.")
|
||||
|
||||
payload, refresh = _issue(session, user.id, record.client_id, uuid.uuid4())
|
||||
record.used_at = _now()
|
||||
record.refresh_token_id = refresh.id
|
||||
session.add(record)
|
||||
_prune(session)
|
||||
session.commit()
|
||||
return JSONResponse(content=payload, headers=_NO_STORE)
|
||||
|
||||
|
||||
def _refresh_token_grant(
|
||||
session: SessionDep, refresh_token: str | None, client_id: str | None
|
||||
) -> Any:
|
||||
if not refresh_token:
|
||||
return _error("invalid_request", "Missing the refresh token.")
|
||||
|
||||
record = session.exec(
|
||||
select(OAuthRefreshToken).where(
|
||||
OAuthRefreshToken.token_hash == _hash(refresh_token)
|
||||
)
|
||||
).first()
|
||||
if record is None:
|
||||
return _error("invalid_grant", "Unknown refresh token.")
|
||||
if record.revoked:
|
||||
# A revoked token coming back means someone kept a copy: the whole
|
||||
# family goes, including whatever is in legitimate use.
|
||||
_revoke_family(session, record.id, family_id=record.family_id)
|
||||
session.commit()
|
||||
return _error("invalid_grant", "That refresh token was already used.")
|
||||
if _aware(record.expires_at) < _now():
|
||||
return _error("invalid_grant", "That refresh token has expired.")
|
||||
if client_id and str(record.client_id) != client_id:
|
||||
return _error("invalid_grant", "The token belongs to another client.")
|
||||
|
||||
user = session.get(User, record.user_id)
|
||||
if user is None or not user.is_active:
|
||||
return _error("invalid_grant", "That account is no longer active.")
|
||||
|
||||
payload, _ = _issue(session, user.id, record.client_id, record.family_id)
|
||||
record.revoked = True
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return JSONResponse(content=payload, headers=_NO_STORE)
|
||||
|
||||
|
||||
def _revoke_family(
|
||||
session: SessionDep,
|
||||
token_id: uuid.UUID | None,
|
||||
family_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Withdraw every refresh token descended from one authorization."""
|
||||
if family_id is None:
|
||||
if token_id is None:
|
||||
return
|
||||
issued = session.get(OAuthRefreshToken, token_id)
|
||||
if issued is None:
|
||||
return
|
||||
family_id = issued.family_id
|
||||
for token in session.exec(
|
||||
select(OAuthRefreshToken).where(OAuthRefreshToken.family_id == family_id)
|
||||
).all():
|
||||
token.revoked = True
|
||||
session.add(token)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Management
|
||||
#
|
||||
# Registration is open, so without these the only way to withdraw one agent's
|
||||
# access was rotating the signing key — which cuts off every agent at once.
|
||||
# They answer in FastAPI's error shape rather than OAuth's, because the
|
||||
# dashboard reads them and no OAuth client ever does, and they keep working
|
||||
# with MCP switched off: that is exactly when leftover clients want clearing.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisteredClient(BaseModel):
|
||||
"""A registered agent, and whether anyone actually let it in."""
|
||||
|
||||
id: uuid.UUID
|
||||
client_name: str
|
||||
redirect_uris: list[str]
|
||||
created_at: datetime
|
||||
#: Live refresh tokens. Zero means it registered and was never approved.
|
||||
active_tokens: int
|
||||
last_authorized_at: datetime | None
|
||||
|
||||
|
||||
class RegisteredClients(BaseModel):
|
||||
data: list[RegisteredClient]
|
||||
count: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clients",
|
||||
response_model=RegisteredClients,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def read_clients(session: SessionDep) -> Any:
|
||||
"""Every agent that registered itself, newest first."""
|
||||
# ponytail: counts live tokens in Python; a GROUP BY if this ever grows.
|
||||
# It also keeps the expiry comparison off a naive Postgres column.
|
||||
now = _now()
|
||||
live: dict[uuid.UUID, list[datetime]] = defaultdict(list)
|
||||
for token in session.exec(
|
||||
select(OAuthRefreshToken).where(col(OAuthRefreshToken.revoked).is_(False))
|
||||
).all():
|
||||
if _aware(token.expires_at) > now:
|
||||
live[token.client_id].append(_aware(token.created_at))
|
||||
|
||||
data = [
|
||||
RegisteredClient(
|
||||
id=client.id,
|
||||
client_name=client.client_name,
|
||||
redirect_uris=client.redirect_uris,
|
||||
created_at=_aware(client.created_at),
|
||||
active_tokens=len(live[client.id]),
|
||||
last_authorized_at=max(live[client.id], default=None),
|
||||
)
|
||||
for client in session.exec(
|
||||
select(OAuthClient).order_by(col(OAuthClient.created_at).desc())
|
||||
).all()
|
||||
]
|
||||
return RegisteredClients(data=data, count=len(data))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/clients/{client_id}",
|
||||
response_model=Message,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def revoke_client(client_id: uuid.UUID, session: SessionDep) -> Any:
|
||||
"""Withdraw one agent's access, leaving every other agent alone.
|
||||
|
||||
Deleting the client cascades to its codes and refresh tokens, so it can
|
||||
get nothing new and cannot come back without registering again. An access
|
||||
token already in its hands keeps working until it expires
|
||||
(``MCP_TOKEN_EXPIRE_MINUTES``) — those are stateless by design.
|
||||
"""
|
||||
client = session.get(OAuthClient, client_id)
|
||||
if client is None:
|
||||
raise HTTPException(status_code=404, detail="No such client")
|
||||
name = client.client_name
|
||||
session.delete(client)
|
||||
session.commit()
|
||||
return Message(message=f"Revoked '{name}'")
|
||||
@@ -0,0 +1,408 @@
|
||||
"""What the engine has been doing: health now, and history since.
|
||||
|
||||
The live state comes from the controller; everything older than the websocket's
|
||||
memory comes from the rollups the metrics collector writes. Deliberately not
|
||||
built on ``/utils/health/``: that endpoint answers 503 when something is wrong,
|
||||
which the generated SDK turns into a thrown error — and a health page that
|
||||
cannot render while the engine is degraded is the wrong way round.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import ColumnElement, DateTime, Interval, cast, func, literal
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlmodel import col, select
|
||||
|
||||
from fluksio.api.deps import FlowControllerDep, SessionDep, get_current_user
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.controller import ADVISORY_ISSUES, NodeStatus
|
||||
from fluksio.models import EngineEvent, FlowRun, MetricBucket
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/observability",
|
||||
tags=["observability"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
#: How many slices a per-flow sparkline is folded into.
|
||||
SPARK_SLICES = 60
|
||||
|
||||
#: The origin fixed-stride slots are aligned to, which is the alignment the
|
||||
#: fold used to get from ``stamp - stamp % bucket_s``.
|
||||
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class HealthSummary(BaseModel):
|
||||
status: str
|
||||
problems: list[str]
|
||||
flows: dict[str, int]
|
||||
nodes: dict[str, int]
|
||||
queue: dict[str, Any]
|
||||
loop_lag: dict[str, float]
|
||||
|
||||
|
||||
class SeriesPoint(BaseModel):
|
||||
ts: float
|
||||
executions: int
|
||||
errors: int
|
||||
messages: int
|
||||
avg_ms: float
|
||||
max_ms: float
|
||||
avg_lag_ms: float
|
||||
|
||||
|
||||
class FlowRollup(BaseModel):
|
||||
flow: str
|
||||
executions: int
|
||||
errors: int
|
||||
messages: int
|
||||
avg_ms: float
|
||||
avg_lag_ms: float
|
||||
spark: list[int]
|
||||
last_error_ts: float | None = None
|
||||
|
||||
|
||||
class RunRow(BaseModel):
|
||||
id: str
|
||||
flow: str
|
||||
source: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
nodes: int
|
||||
errors: int
|
||||
duration_ms: float
|
||||
deliveries: int
|
||||
|
||||
|
||||
class RunPage(BaseModel):
|
||||
data: list[RunRow]
|
||||
count: int
|
||||
|
||||
|
||||
class EventRow(BaseModel):
|
||||
id: int
|
||||
ts: datetime
|
||||
type: str
|
||||
flow: str
|
||||
node: str
|
||||
detail: str
|
||||
actor: str
|
||||
|
||||
|
||||
class DeadLetter(BaseModel):
|
||||
id: str
|
||||
ts: float
|
||||
flow: str
|
||||
node: str
|
||||
cause: str
|
||||
reason: str
|
||||
|
||||
|
||||
def _since(hours: int) -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
|
||||
def _window_hours(hours: int) -> int:
|
||||
"""A window the rollups can answer for: an hour at least, retention at most.
|
||||
|
||||
Zero used to divide by nothing and answer 500, and nothing older than
|
||||
retention exists, so a larger window is a scan that can only find less.
|
||||
"""
|
||||
return max(1, min(hours, settings.OBS_RETENTION_DAYS * 24))
|
||||
|
||||
|
||||
def _aware(when: datetime) -> datetime:
|
||||
"""A bound as the columns store it. A naive one is read as UTC."""
|
||||
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=HealthSummary)
|
||||
async def read_summary(request: Request, controller: FlowControllerDep) -> Any:
|
||||
"""How the engine is doing right now. Always 200, degraded or not."""
|
||||
watchdog = getattr(request.app.state, "watchdog", None)
|
||||
problems: list[str] = []
|
||||
if watchdog is not None and watchdog.degraded:
|
||||
problems.append("event loop lagging")
|
||||
|
||||
queue = await run_in_threadpool(controller.queue_stats)
|
||||
if queue.get("oldest_pending_s", 0) > 120:
|
||||
problems.append("queue stalled")
|
||||
if queue.get("error"):
|
||||
problems.append("work queue unreachable")
|
||||
|
||||
names = await run_in_threadpool(controller.store.list_flows)
|
||||
quarantined = controller.quarantined
|
||||
paused = set(controller.paused_flows())
|
||||
entries = list(controller.loaded.values())
|
||||
errored = [e for e in entries if e.status is NodeStatus.ERROR]
|
||||
if quarantined:
|
||||
problems.append(f"{len(quarantined)} flow(s) quarantined")
|
||||
if errored:
|
||||
problems.append(f"{len(errored)} node(s) failed to load")
|
||||
|
||||
# What the canvas flags on a flow — a dependency loop, an input nothing
|
||||
# feeds — stops that flow running just as surely as a node that will not
|
||||
# load, and until now this screen was the one place it did not show.
|
||||
# `node_error` is left out: those are the nodes already counted above.
|
||||
invalid = sorted(
|
||||
{
|
||||
issue.flow
|
||||
for issue in controller.issues
|
||||
if issue.flow
|
||||
and issue.code != "node_error"
|
||||
and issue.code not in ADVISORY_ISSUES
|
||||
}
|
||||
)
|
||||
if invalid:
|
||||
problems.append(f"{len(invalid)} flow(s) cannot run: {', '.join(invalid)}")
|
||||
|
||||
return HealthSummary(
|
||||
status="degraded" if problems else "ok",
|
||||
problems=problems,
|
||||
flows={
|
||||
"total": len(names),
|
||||
# What the engine will actually act on. Enabled is not enough:
|
||||
# validation stops a flow as surely as quarantine does, and a
|
||||
# paused one is holding its messages rather than running them.
|
||||
"running": len(
|
||||
[
|
||||
n
|
||||
for n in names
|
||||
if controller.is_enabled(n)
|
||||
and n not in quarantined
|
||||
and n not in paused
|
||||
and n not in invalid
|
||||
]
|
||||
),
|
||||
"paused": len(paused),
|
||||
"quarantined": len(quarantined),
|
||||
"invalid": len(invalid),
|
||||
},
|
||||
nodes={"total": len(entries), "error": len(errored)},
|
||||
queue=queue,
|
||||
loop_lag=(
|
||||
watchdog.snapshot()
|
||||
if watchdog is not None
|
||||
else {"ewma": 0.0, "max_60s": 0.0}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/timeseries", response_model=list[SeriesPoint])
|
||||
def read_timeseries(
|
||||
session: SessionDep,
|
||||
flow: str | None = None,
|
||||
node: str | None = None,
|
||||
hours: int = 24,
|
||||
bucket_s: int = 60,
|
||||
) -> Any:
|
||||
"""Executions, errors and timings over time, summed across nodes."""
|
||||
hours = _window_hours(hours)
|
||||
# Postgres does the fold: a week of minute rows per node used to cross the
|
||||
# wire on every poll, and only the slices need to. The casts are load
|
||||
# bearing — date_bin() is overloaded on timestamp and timestamptz, and an
|
||||
# untyped bind parameter leaves the call ambiguous.
|
||||
stride = timedelta(seconds=max(60, bucket_s))
|
||||
slot = func.date_bin(
|
||||
cast(literal(stride), Interval),
|
||||
col(MetricBucket.bucket),
|
||||
cast(literal(EPOCH), DateTime(timezone=True)),
|
||||
).label("slot")
|
||||
statement = sa_select(
|
||||
slot,
|
||||
func.sum(col(MetricBucket.executions)).label("executions"),
|
||||
func.sum(col(MetricBucket.errors)).label("errors"),
|
||||
func.sum(col(MetricBucket.messages)).label("messages"),
|
||||
func.sum(col(MetricBucket.duration_sum_ms)).label("duration_sum_ms"),
|
||||
func.max(col(MetricBucket.duration_max_ms)).label("max_ms"),
|
||||
func.sum(col(MetricBucket.lag_sum_ms)).label("lag_sum_ms"),
|
||||
func.sum(col(MetricBucket.items)).label("items"),
|
||||
).where(col(MetricBucket.bucket) >= _since(hours))
|
||||
if flow:
|
||||
statement = statement.where(col(MetricBucket.flow) == flow)
|
||||
if node:
|
||||
statement = statement.where(col(MetricBucket.node) == node)
|
||||
|
||||
return [
|
||||
SeriesPoint(
|
||||
ts=row.slot.timestamp(),
|
||||
executions=int(row.executions),
|
||||
errors=int(row.errors),
|
||||
messages=int(row.messages),
|
||||
avg_ms=round(row.duration_sum_ms / (row.executions or 1), 2),
|
||||
max_ms=round(row.max_ms, 2),
|
||||
avg_lag_ms=round(row.lag_sum_ms / (row.items or 1), 2),
|
||||
)
|
||||
for row in session.execute(statement.group_by(slot).order_by(slot))
|
||||
]
|
||||
|
||||
|
||||
@router.get("/flows", response_model=list[FlowRollup])
|
||||
def read_flow_rollups(session: SessionDep, hours: int = 24) -> Any:
|
||||
"""One row per flow, with a coarse trend of how much it ran."""
|
||||
hours = _window_hours(hours)
|
||||
since = _since(hours)
|
||||
window = hours * 3600
|
||||
start = since.timestamp()
|
||||
|
||||
# Binned to the sparkline slice rather than the minute, so a flow costs at
|
||||
# most SPARK_SLICES rows however long the window is. The slice is the
|
||||
# window over SPARK_SLICES, which for whole hours is whole minutes.
|
||||
slot = func.date_bin(
|
||||
cast(literal(timedelta(minutes=hours)), Interval),
|
||||
col(MetricBucket.bucket),
|
||||
cast(literal(since), DateTime(timezone=True)),
|
||||
).label("slot")
|
||||
statement = (
|
||||
sa_select(
|
||||
col(MetricBucket.flow),
|
||||
slot,
|
||||
func.sum(col(MetricBucket.executions)).label("executions"),
|
||||
func.sum(col(MetricBucket.errors)).label("errors"),
|
||||
func.sum(col(MetricBucket.messages)).label("messages"),
|
||||
func.sum(col(MetricBucket.duration_sum_ms)).label("duration_sum_ms"),
|
||||
func.sum(col(MetricBucket.lag_sum_ms)).label("lag_sum_ms"),
|
||||
func.sum(col(MetricBucket.items)).label("items"),
|
||||
)
|
||||
.where(col(MetricBucket.bucket) >= since)
|
||||
.group_by(col(MetricBucket.flow), slot)
|
||||
)
|
||||
|
||||
rollups: dict[str, dict[str, Any]] = {}
|
||||
for row in session.execute(statement):
|
||||
entry = rollups.setdefault(
|
||||
row.flow,
|
||||
{
|
||||
"executions": 0,
|
||||
"errors": 0,
|
||||
"messages": 0,
|
||||
"duration_sum_ms": 0.0,
|
||||
"lag_sum_ms": 0.0,
|
||||
"items": 0,
|
||||
"spark": [0] * SPARK_SLICES,
|
||||
},
|
||||
)
|
||||
entry["executions"] += row.executions
|
||||
entry["errors"] += row.errors
|
||||
entry["messages"] += row.messages
|
||||
entry["duration_sum_ms"] += row.duration_sum_ms
|
||||
entry["lag_sum_ms"] += row.lag_sum_ms
|
||||
entry["items"] += row.items
|
||||
# A slot sits a whole number of slices from `since`, so this rounds
|
||||
# rather than truncates: a float a hair short would lose a slice. The
|
||||
# clamp holds the bucket landing exactly on the far edge in range.
|
||||
index = min(
|
||||
SPARK_SLICES - 1,
|
||||
max(0, round((row.slot.timestamp() - start) / window * SPARK_SLICES)),
|
||||
)
|
||||
entry["spark"][index] += row.executions
|
||||
|
||||
# `.all()` first: a Result has `keys()`, so dict() would read it as a
|
||||
# mapping and subscript it.
|
||||
last_errors = dict(
|
||||
session.exec(
|
||||
select(col(EngineEvent.flow), func.max(col(EngineEvent.ts)))
|
||||
.where(col(EngineEvent.type) == "node_error", col(EngineEvent.ts) >= since)
|
||||
.group_by(col(EngineEvent.flow))
|
||||
).all()
|
||||
)
|
||||
|
||||
return [
|
||||
FlowRollup(
|
||||
flow=flow,
|
||||
executions=entry["executions"],
|
||||
errors=entry["errors"],
|
||||
messages=entry["messages"],
|
||||
avg_ms=round(entry["duration_sum_ms"] / (entry["executions"] or 1), 2),
|
||||
avg_lag_ms=round(entry["lag_sum_ms"] / (entry["items"] or 1), 2),
|
||||
spark=entry["spark"],
|
||||
last_error_ts=(
|
||||
last_errors[flow].timestamp() if flow in last_errors else None
|
||||
),
|
||||
)
|
||||
for flow, entry in sorted(rollups.items())
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs", response_model=RunPage)
|
||||
def read_runs(
|
||||
session: SessionDep,
|
||||
flow: str | None = None,
|
||||
status: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Any:
|
||||
"""Recent cascades, newest first, and how many there were in total.
|
||||
|
||||
``since`` is inclusive and ``until`` exclusive, so a window of one minute
|
||||
holds exactly the runs of the minute bucket the charts are drawn from.
|
||||
"""
|
||||
filters: list[ColumnElement[bool]] = []
|
||||
if flow:
|
||||
filters.append(col(FlowRun.flow) == flow)
|
||||
if status:
|
||||
filters.append(col(FlowRun.status) == status)
|
||||
if since:
|
||||
filters.append(col(FlowRun.started_at) >= _aware(since))
|
||||
if until:
|
||||
filters.append(col(FlowRun.started_at) < _aware(until))
|
||||
|
||||
capped = min(limit, 200)
|
||||
statement = select(FlowRun).where(*filters).order_by(col(FlowRun.started_at).desc())
|
||||
rows = list(session.exec(statement.limit(capped)))
|
||||
# A short page is its own total. The lists poll their whole range every
|
||||
# thirty seconds, and counting on each of those would hand back what
|
||||
# binning the metrics just saved — for a number that only ever says
|
||||
# "there is more here than fits".
|
||||
count = (
|
||||
len(rows)
|
||||
if len(rows) < capped
|
||||
else int(
|
||||
session.exec(
|
||||
select(func.count()).select_from(FlowRun).where(*filters)
|
||||
).one()
|
||||
)
|
||||
)
|
||||
return {"data": rows, "count": count}
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[EventRow])
|
||||
def read_events(
|
||||
session: SessionDep,
|
||||
kind: Literal["failure", "audit"] = "failure",
|
||||
flow: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 100,
|
||||
) -> Any:
|
||||
"""What went wrong, or who changed what. Newest first.
|
||||
|
||||
``since`` is inclusive and ``until`` exclusive, the same window ``/runs``
|
||||
takes, so a list can cover the span the charts beside it are drawn from.
|
||||
"""
|
||||
statement = select(EngineEvent).order_by(col(EngineEvent.ts).desc())
|
||||
if kind == "audit":
|
||||
statement = statement.where(col(EngineEvent.type) == "audit")
|
||||
else:
|
||||
statement = statement.where(col(EngineEvent.type) != "audit")
|
||||
if flow:
|
||||
statement = statement.where(col(EngineEvent.flow) == flow)
|
||||
if since:
|
||||
statement = statement.where(col(EngineEvent.ts) >= _aware(since))
|
||||
if until:
|
||||
statement = statement.where(col(EngineEvent.ts) < _aware(until))
|
||||
return list(session.exec(statement.limit(min(limit, 500))))
|
||||
|
||||
|
||||
@router.get("/dead-letter", response_model=list[DeadLetter])
|
||||
async def read_dead_letters(controller: FlowControllerDep, limit: int = 50) -> Any:
|
||||
"""Work the engine gave up on, which nothing else surfaces."""
|
||||
if controller.execution is None:
|
||||
return []
|
||||
return await run_in_threadpool(controller.execution.queue.dead_letters, limit)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Panels: which dashboards a device shows, and how it gets a credential.
|
||||
|
||||
A wall tablet has no keyboard, so it cannot log in. It asks for a code instead,
|
||||
shows it on the wall, and somebody with an account types that code into the
|
||||
panels dialog to say which panel the device is. The device polls, collects the
|
||||
credential the approval minted, and never asks again.
|
||||
|
||||
A screen hanging somewhere this installation is not reachable from does the
|
||||
same thing through the portal, which forwards those two calls down the tunnel
|
||||
without a session — a device with no credential is the whole point of them —
|
||||
and mints the credential itself when the approval comes. Which side minted it
|
||||
changes nothing about what it may do: the scope check is here either way.
|
||||
|
||||
The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards that
|
||||
panel was assigned and nothing else. Removing the panel revokes it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fluksio.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow.events import event_bus
|
||||
from fluksio.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
||||
from fluksio.models import Message
|
||||
|
||||
#: Gated per route rather than on the router: the two pairing endpoints are the
|
||||
#: only unauthenticated ones in the app, because a device with no credential is
|
||||
#: the whole point of them.
|
||||
router = APIRouter(prefix="/panels", tags=["panels"])
|
||||
|
||||
#: Long, because a panel is a screen somebody hung once and left running, with
|
||||
#: no keyboard to sign in again with. Same reasoning as a remote worker's.
|
||||
TOKEN_DAYS = 365
|
||||
|
||||
#: How long a device's code is worth typing. Long enough to walk to a computer.
|
||||
PAIR_TTL = 600
|
||||
|
||||
#: No I/O/0/1: the code is read off a wall and typed on a keyboard.
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
CODE_LENGTH = 6
|
||||
|
||||
#: Refuse to grow past this. Codes are free to ask for and cost memory.
|
||||
MAX_PENDING = 50
|
||||
|
||||
|
||||
class _Pending:
|
||||
"""A device waiting to be told what it is."""
|
||||
|
||||
def __init__(self, secret_value: str, device: str, remote: bool) -> None:
|
||||
self.secret = secret_value
|
||||
self.expires = time.monotonic() + PAIR_TTL
|
||||
self.token: str = ""
|
||||
self.panel: str = ""
|
||||
#: What the request looked like, shown to whoever approves the code so
|
||||
#: they can tell the screen in the hall from one they were not
|
||||
#: expecting. Self-reported and worth what that is worth.
|
||||
self.device = device
|
||||
#: Whether it came down the tunnel. A device that reached the portal
|
||||
#: cannot reach this installation, so its credential has to be minted
|
||||
#: where it can collect it.
|
||||
self.remote = remote
|
||||
|
||||
|
||||
# ponytail: in-process, so pairing needs the API to be one process — which it
|
||||
# is. Move to a table if it ever runs behind more than one worker.
|
||||
_pending: dict[str, _Pending] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.monotonic()
|
||||
for code in [c for c, p in _pending.items() if p.expires < now]:
|
||||
del _pending[code]
|
||||
|
||||
|
||||
class PairStarted(BaseModel):
|
||||
"""What the device puts on the wall, and what it polls with."""
|
||||
|
||||
code: str
|
||||
#: Held by the device, never shown. Without it the code alone would let
|
||||
#: anyone who reads it off the wall collect the credential first.
|
||||
secret: str
|
||||
expires_in: int = PAIR_TTL
|
||||
|
||||
|
||||
class PairStatus(BaseModel):
|
||||
"""Nothing yet, or the credential and the panel it is for."""
|
||||
|
||||
access_token: str | None = None
|
||||
panel: str | None = None
|
||||
|
||||
|
||||
class PairRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class PendingDevice(BaseModel):
|
||||
"""Who is asking, as far as the request itself says."""
|
||||
|
||||
device: str
|
||||
remote: bool = False
|
||||
|
||||
|
||||
def _describe(request: Request) -> str:
|
||||
"""A line naming the device behind a pairing request.
|
||||
|
||||
# ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if
|
||||
# it reads badly on the approval screen.
|
||||
"""
|
||||
agent = request.headers.get("user-agent", "").strip()[:120]
|
||||
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
# Proxied requests are replayed into this process over an ASGI transport,
|
||||
# which reports every caller as localhost; the forwarded address is the
|
||||
# only true one there, and behind the local reverse proxy it is too.
|
||||
address = forwarded or (request.client.host if request.client else "")
|
||||
return " · ".join(part for part in (agent or "Unknown device", address) if part)
|
||||
|
||||
|
||||
def _mint_at_hub(panel_id: str) -> str:
|
||||
"""Ask the portal for this panel's credential.
|
||||
|
||||
A device that arrived through the portal cannot reach this installation, so
|
||||
a token this installation signed would be one it could never present: the
|
||||
portal verifies what crosses its tunnel, and it verifies against its own
|
||||
key. It mints, we say which panel — and the scope check here decides the
|
||||
rest, on this call and on every later one.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="That device came through a portal this installation is no "
|
||||
"longer enrolled with",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"panel": panel_id},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused to mint a credential ({response.status_code})",
|
||||
)
|
||||
token: str = response.json()["access_token"]
|
||||
return token
|
||||
|
||||
|
||||
class PanelsPublic(BaseModel):
|
||||
"""The panels, and the address a device should be pointed at.
|
||||
|
||||
The address is the server's own, because the browser's origin is not a
|
||||
reliable answer to it: an admin working through the portal is on the
|
||||
portal's origin, and this one is for a screen on this network.
|
||||
|
||||
An installation enrolled with a portal has a second address, built by the
|
||||
dialog from what ``/cloud/status`` reports rather than from here — a panel
|
||||
is not the thing that knows whether remote access is on.
|
||||
"""
|
||||
|
||||
panels: list[PanelDef] = Field(default_factory=list)
|
||||
#: Whatever this installation was told it is reachable at. The same setting
|
||||
#: the password-reset links are built from, so an installation that has it
|
||||
#: wrong has it wrong in both places.
|
||||
frontend_host: str = ""
|
||||
|
||||
|
||||
def _public(config: PanelsConfig) -> PanelsPublic:
|
||||
return PanelsPublic(
|
||||
panels=config.panels, frontend_host=settings.FRONTEND_HOST.rstrip("/")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=PanelsPublic, dependencies=[Depends(get_current_user)])
|
||||
async def read_panels() -> Any:
|
||||
"""Every panel, what each one shows, and where to point a device."""
|
||||
return _public(await run_in_threadpool(read_config))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/",
|
||||
response_model=PanelsPublic,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
async def save_panels(body: PanelsConfig) -> Any:
|
||||
"""Replace the panels. Takes effect on the devices' next read.
|
||||
|
||||
A panel that disappears here takes its credential with it, so this is also
|
||||
how a device is unpaired.
|
||||
"""
|
||||
seen = set()
|
||||
for panel in body.panels:
|
||||
if panel.id in seen:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"Two panels named {panel.id!r}"
|
||||
)
|
||||
seen.add(panel.id)
|
||||
|
||||
await run_in_threadpool(write_config, body)
|
||||
# Which dashboards hang on which panel just changed. An empty name says
|
||||
# that much and no more: every screen listening rescopes and refetches
|
||||
# what it shows, rather than waiting for whenever it next reads.
|
||||
event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()})
|
||||
return _public(body)
|
||||
|
||||
|
||||
@router.post("/pair", response_model=PairStarted)
|
||||
def start_pairing(request: Request) -> Any:
|
||||
"""A device asks to be adopted. Unauthenticated, by necessity.
|
||||
|
||||
All this hands out is a code that means nothing until somebody with an
|
||||
account approves it, so the worst an unwelcome caller achieves is a line in
|
||||
a dictionary that expires ten minutes later. Reachable from the internet
|
||||
when this installation is enrolled with a portal, which is what the cap and
|
||||
the portal's own per-address limits are between.
|
||||
"""
|
||||
_prune()
|
||||
if len(_pending) >= MAX_PENDING:
|
||||
raise HTTPException(
|
||||
status_code=429, detail="Too many devices are waiting to be paired"
|
||||
)
|
||||
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
while code in _pending:
|
||||
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
|
||||
remote = secrets.compare_digest(
|
||||
request.headers.get(cloud_config.VIA_HEADER, ""), cloud_config.VIA_PORTAL
|
||||
)
|
||||
entry = _Pending(secrets.token_urlsafe(16), _describe(request), remote)
|
||||
_pending[code] = entry
|
||||
return PairStarted(code=code, secret=entry.secret)
|
||||
|
||||
|
||||
@router.get("/pair/{code}", response_model=PairStatus)
|
||||
def poll_pairing(code: str, secret: str = "") -> Any:
|
||||
"""Has anyone claimed this device yet?
|
||||
|
||||
Answers the same 404 for a code that never existed, one that expired and
|
||||
one polled with the wrong secret: a caller reading a code off a wall learns
|
||||
nothing by guessing.
|
||||
"""
|
||||
_prune()
|
||||
entry = _pending.get(code)
|
||||
if entry is None or not secrets.compare_digest(entry.secret, secret):
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No pairing is waiting on that code"
|
||||
)
|
||||
if not entry.token:
|
||||
return PairStatus()
|
||||
|
||||
# Handed over once. A credential left lying in memory is a second copy of
|
||||
# it, and the device has the only one it needs.
|
||||
del _pending[code]
|
||||
return PairStatus(access_token=entry.token, panel=entry.panel)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pair/{code}/device",
|
||||
response_model=PendingDevice,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def pending_device(code: str) -> Any:
|
||||
"""What is waiting on this code, before anyone says what it is.
|
||||
|
||||
Approving a code adopts whatever is holding it, so it is worth seeing that
|
||||
it looks like the screen you just hung.
|
||||
"""
|
||||
_prune()
|
||||
entry = _pending.get(code.strip().upper())
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="No device is waiting on that code")
|
||||
return PendingDevice(device=entry.device, remote=entry.remote)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{panel_id}/pair",
|
||||
response_model=Message,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) -> Any:
|
||||
"""Say which panel the device showing this code is.
|
||||
|
||||
A credential minted here names the approver, so what the panel does stays
|
||||
attributable to a person rather than to nobody. One minted by the portal —
|
||||
for a device that reached this installation only through it — names the
|
||||
account this installation was enrolled with instead, since that is the one
|
||||
every portal-borne request already acts as.
|
||||
"""
|
||||
_prune()
|
||||
if find(panel_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
||||
|
||||
entry = _pending.get(body.code.strip().upper())
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No device is waiting on that code — check it again",
|
||||
)
|
||||
|
||||
if entry.remote:
|
||||
entry.token = _mint_at_hub(panel_id)
|
||||
else:
|
||||
entry.token = security.create_panel_token(
|
||||
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
|
||||
)
|
||||
entry.panel = panel_id
|
||||
return Message(message=f"Paired {entry.device} with {panel_id}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{panel_id}", response_model=PanelDef, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def read_panel(panel_id: str) -> Any:
|
||||
"""One panel — read by a person, or by the device holding its credential.
|
||||
|
||||
Declared after the pairing routes so a device polling ``/panels/pair/...``
|
||||
is never mistaken for someone reading a panel named ``pair``.
|
||||
"""
|
||||
panel = await run_in_threadpool(find, panel_id)
|
||||
if panel is None:
|
||||
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
||||
return panel
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fluksio.api.deps import SessionDep
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.core.security import get_password_hash
|
||||
from fluksio.models import (
|
||||
User,
|
||||
UserPublic,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["private"], prefix="/private")
|
||||
|
||||
|
||||
def _require_private_api() -> None:
|
||||
if not (settings.ENVIRONMENT == "local" and settings.PRIVATE_API_ENABLED):
|
||||
raise HTTPException(status_code=403, detail="Private API is disabled")
|
||||
|
||||
|
||||
class PrivateUserCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
full_name: str
|
||||
is_verified: bool = False
|
||||
|
||||
|
||||
@router.post("/users/", response_model=UserPublic)
|
||||
def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
|
||||
"""
|
||||
Create a new user.
|
||||
"""
|
||||
_require_private_api()
|
||||
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
full_name=user_in.full_name,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Runs over the API: submit one, watch it, read what it made.
|
||||
|
||||
Submitting returns immediately with a queued run — a training run is measured
|
||||
in hours, so nothing here waits for one. The way to follow a run is to poll it
|
||||
or to listen on the flow socket, which carries its start and finish.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
|
||||
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
|
||||
from fluksio.flow.runs import RunRejected, RunService, new_run_id
|
||||
from fluksio.flow.store import FlowNotFound
|
||||
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/runs", tags=["runs"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
#: A sweep bigger than this is almost always a mistake in a loop.
|
||||
MAX_SWEEP = 1000
|
||||
|
||||
|
||||
class RunCreate(BaseModel):
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
seed: int | None = None
|
||||
#: Run the unpublished draft instead of what is published.
|
||||
draft: bool = False
|
||||
|
||||
|
||||
class SweepEntry(BaseModel):
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
seed: int | None = None
|
||||
|
||||
|
||||
class SweepCreate(BaseModel):
|
||||
runs: list[SweepEntry] = Field(default_factory=list)
|
||||
draft: bool = False
|
||||
|
||||
|
||||
class RunNodeRow(BaseModel):
|
||||
node: str
|
||||
status: str
|
||||
attempt: int
|
||||
duration_ms: float
|
||||
worker: str
|
||||
error: str
|
||||
logs: str
|
||||
|
||||
|
||||
class ArtifactRow(BaseModel):
|
||||
name: str
|
||||
node: str
|
||||
digest: str
|
||||
size: int
|
||||
media_type: str
|
||||
|
||||
|
||||
class RunRow(BaseModel):
|
||||
"""A run without its result, which is the part that can be large."""
|
||||
|
||||
id: str
|
||||
flow: str
|
||||
status: str
|
||||
status_reason: str
|
||||
cause: str
|
||||
params: dict[str, Any]
|
||||
params_digest: str
|
||||
seed: int | None
|
||||
group_id: str | None
|
||||
labels: list[str]
|
||||
created_at: Any
|
||||
started_at: Any = None
|
||||
finished_at: Any = None
|
||||
duration_ms: float
|
||||
actor: str
|
||||
|
||||
|
||||
class RunDetail(RunRow):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
commit: str = ""
|
||||
flow_version: int = 1
|
||||
nodes: list[RunNodeRow] = Field(default_factory=list)
|
||||
artifacts: list[ArtifactRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MetricPoint(BaseModel):
|
||||
step: int
|
||||
ts: float
|
||||
value: float
|
||||
|
||||
|
||||
class MetricSeries(BaseModel):
|
||||
"""The shape a chart widget already draws, so comparing runs is a binding."""
|
||||
|
||||
label: str
|
||||
points: list[list[float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SeriesAnswer(BaseModel):
|
||||
metric: str
|
||||
lines: list[MetricSeries] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _service(request: Request) -> RunService:
|
||||
service: RunService | None = getattr(request.app.state, "run_service", None)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="Runs are not available")
|
||||
return service
|
||||
|
||||
|
||||
@router.post("/flows/{name}", response_model=RunRow, status_code=202)
|
||||
async def create_run(
|
||||
name: str, body: RunCreate, request: Request, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Queue one run of a flow."""
|
||||
service = _service(request)
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
service.submit,
|
||||
name,
|
||||
params=body.params,
|
||||
seed=body.seed,
|
||||
cause="api",
|
||||
actor=user.email,
|
||||
draft=body.draft,
|
||||
)
|
||||
except FlowNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except RunRejected as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/flows/{name}/sweep", response_model=list[RunRow], status_code=202)
|
||||
async def create_sweep(
|
||||
name: str, body: SweepCreate, request: Request, user: CurrentUser
|
||||
) -> Any:
|
||||
"""Queue many runs of one flow under a shared group.
|
||||
|
||||
An ensemble is this with the same parameters and different seeds; a grid
|
||||
search is this with the parameters spread out. Either way the caller
|
||||
builds the list — the engine does not own a sweep grammar.
|
||||
"""
|
||||
if not body.runs:
|
||||
raise HTTPException(status_code=422, detail="A sweep needs at least one run")
|
||||
if len(body.runs) > MAX_SWEEP:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"A sweep is capped at {MAX_SWEEP} runs"
|
||||
)
|
||||
service = _service(request)
|
||||
group = new_run_id()
|
||||
|
||||
def submit_all() -> list[Run]:
|
||||
return [
|
||||
service.submit(
|
||||
name,
|
||||
params=entry.params,
|
||||
seed=entry.seed,
|
||||
group_id=group,
|
||||
cause="sweep",
|
||||
actor=user.email,
|
||||
draft=body.draft,
|
||||
)
|
||||
for entry in body.runs
|
||||
]
|
||||
|
||||
try:
|
||||
return await run_in_threadpool(submit_all)
|
||||
except FlowNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except RunRejected as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("", response_model=list[RunRow])
|
||||
def read_runs(
|
||||
session: SessionDep,
|
||||
flow: str | None = None,
|
||||
status: str | None = None,
|
||||
group: str | None = None,
|
||||
digest: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Any:
|
||||
"""Runs, newest first. The queryable table an experiment log needs."""
|
||||
statement = select(Run).order_by(col(Run.created_at).desc())
|
||||
if flow:
|
||||
statement = statement.where(col(Run.flow) == flow)
|
||||
if status:
|
||||
statement = statement.where(col(Run.status) == status)
|
||||
if group:
|
||||
statement = statement.where(col(Run.group_id) == group)
|
||||
if digest:
|
||||
statement = statement.where(col(Run.params_digest) == digest)
|
||||
return list(session.exec(statement.limit(min(limit, 500))))
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunDetail)
|
||||
def read_run(run_id: str, session: SessionDep) -> Any:
|
||||
"""One run in full: what it was asked, what each node did, what it made."""
|
||||
run = session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="No such run")
|
||||
nodes = session.exec(select(RunNode).where(col(RunNode.run_id) == run_id)).all()
|
||||
artifacts = session.exec(
|
||||
select(RunArtifact).where(col(RunArtifact.run_id) == run_id)
|
||||
).all()
|
||||
detail = RunDetail.model_validate(run, from_attributes=True)
|
||||
detail.nodes = [RunNodeRow.model_validate(n, from_attributes=True) for n in nodes]
|
||||
detail.artifacts = [
|
||||
ArtifactRow.model_validate(a, from_attributes=True) for a in artifacts
|
||||
]
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel", response_model=RunRow)
|
||||
async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any:
|
||||
"""Stop a run. One already past its last node is left as it finished."""
|
||||
run = session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="No such run")
|
||||
service = _service(request)
|
||||
await run_in_threadpool(service.cancel, run_id)
|
||||
session.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/{run_id}/metrics", response_model=list[MetricPoint])
|
||||
def read_metrics(run_id: str, session: SessionDep, name: str, stride: int = 1) -> Any:
|
||||
"""One metric's series, in step order.
|
||||
|
||||
``stride`` thins a long curve down: 3000 steps drawn on a 400-pixel chart
|
||||
is 3000 points nobody can see.
|
||||
"""
|
||||
statement = (
|
||||
select(RunMetric)
|
||||
.where(col(RunMetric.run_id) == run_id, col(RunMetric.name) == name)
|
||||
.order_by(col(RunMetric.step))
|
||||
)
|
||||
rows = list(session.exec(statement))
|
||||
if stride > 1:
|
||||
rows = rows[:: max(1, stride)]
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/series/compare", response_model=SeriesAnswer)
|
||||
def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
|
||||
"""One metric across several runs, as the chart widget's series shape.
|
||||
|
||||
This is the comparison view: it answers in the same shape a flow answers a
|
||||
chart's query with, so putting three training curves beside each other is
|
||||
a widget binding rather than a screen of its own.
|
||||
"""
|
||||
run_ids = [part for part in ids.split(",") if part]
|
||||
if not run_ids:
|
||||
raise HTTPException(status_code=422, detail="Name at least one run")
|
||||
runs = {
|
||||
run.id: run
|
||||
for run in session.exec(select(Run).where(col(Run.id).in_(run_ids))).all()
|
||||
}
|
||||
lines: list[MetricSeries] = []
|
||||
for run_id in run_ids:
|
||||
run = runs.get(run_id)
|
||||
if run is None:
|
||||
continue
|
||||
rows = session.exec(
|
||||
select(RunMetric)
|
||||
.where(col(RunMetric.run_id) == run_id, col(RunMetric.name) == metric)
|
||||
.order_by(col(RunMetric.step))
|
||||
).all()
|
||||
label = run_id
|
||||
if run.seed is not None:
|
||||
label = f"{run_id} (seed {run.seed})"
|
||||
lines.append(
|
||||
MetricSeries(
|
||||
label=label, points=[[float(row.step), row.value] for row in rows]
|
||||
)
|
||||
)
|
||||
return SeriesAnswer(metric=metric, lines=lines)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Credentials for node integrations.
|
||||
|
||||
Values go in and are never handed back out — the API only ever lists names.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fluksio.api.deps import get_current_user
|
||||
from fluksio.flow.secrets import SecretNotFound, get_secrets
|
||||
from fluksio.models import Message
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/secrets", tags=["secrets"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class SecretNames(BaseModel):
|
||||
data: list[str]
|
||||
count: int
|
||||
|
||||
|
||||
class SecretValue(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/", response_model=SecretNames)
|
||||
def read_secrets() -> Any:
|
||||
"""List the names of stored secrets."""
|
||||
names = get_secrets().list()
|
||||
return SecretNames(data=names, count=len(names))
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=Message)
|
||||
async def save_secret(name: str, body: SecretValue) -> Any:
|
||||
"""Store a secret under a name that nodes can reference."""
|
||||
await run_in_threadpool(get_secrets().set, name, body.value)
|
||||
return Message(message=f"Saved secret '{name}'")
|
||||
|
||||
|
||||
@router.delete("/{name}", response_model=Message)
|
||||
async def delete_secret(name: str) -> Any:
|
||||
"""Delete a secret."""
|
||||
try:
|
||||
await run_in_threadpool(get_secrets().delete, name)
|
||||
except SecretNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No secret named '{name}'")
|
||||
return Message(message=f"Deleted secret '{name}'")
|
||||
@@ -0,0 +1,237 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from fluksio import crud
|
||||
from fluksio.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
)
|
||||
from fluksio.api.routes.cloud import forget_remote_user
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.core.security import get_password_hash, verify_password
|
||||
from fluksio.models import (
|
||||
Message,
|
||||
UpdatePassword,
|
||||
User,
|
||||
UserCreate,
|
||||
UserPublic,
|
||||
UserRegister,
|
||||
UsersPublic,
|
||||
UserUpdate,
|
||||
UserUpdateMe,
|
||||
)
|
||||
from fluksio.utils import generate_new_account_email, send_email
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=UsersPublic,
|
||||
)
|
||||
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
|
||||
"""
|
||||
Retrieve users.
|
||||
"""
|
||||
|
||||
count_statement = select(func.count()).select_from(User)
|
||||
count = session.exec(count_statement).one()
|
||||
|
||||
statement = (
|
||||
select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit)
|
||||
)
|
||||
users = session.exec(statement).all()
|
||||
|
||||
return UsersPublic(data=users, count=count)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
|
||||
)
|
||||
def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
|
||||
"""
|
||||
Create new user.
|
||||
"""
|
||||
user = crud.get_user_by_email(session=session, email=user_in.email)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The user with this email already exists in the system.",
|
||||
)
|
||||
|
||||
user = crud.create_user(session=session, user_create=user_in)
|
||||
if settings.emails_enabled and user_in.email:
|
||||
email_data = generate_new_account_email(
|
||||
email_to=user_in.email, username=user_in.email, password=user_in.password
|
||||
)
|
||||
send_email(
|
||||
email_to=user_in.email,
|
||||
subject=email_data.subject,
|
||||
html_content=email_data.html_content,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserPublic)
|
||||
def update_user_me(
|
||||
*, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser
|
||||
) -> Any:
|
||||
"""
|
||||
Update own user.
|
||||
"""
|
||||
|
||||
if user_in.email:
|
||||
existing_user = crud.get_user_by_email(session=session, email=user_in.email)
|
||||
if existing_user and existing_user.id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="User with this email already exists"
|
||||
)
|
||||
user_data = user_in.model_dump(exclude_unset=True)
|
||||
current_user.sqlmodel_update(user_data)
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
session.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.patch("/me/password", response_model=Message)
|
||||
def update_password_me(
|
||||
*, session: SessionDep, body: UpdatePassword, current_user: CurrentUser
|
||||
) -> Any:
|
||||
"""
|
||||
Update own password.
|
||||
"""
|
||||
verified, _ = verify_password(body.current_password, current_user.hashed_password)
|
||||
if not verified:
|
||||
raise HTTPException(status_code=400, detail="Incorrect password")
|
||||
if body.current_password == body.new_password:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="New password cannot be the same as the current one"
|
||||
)
|
||||
hashed_password = get_password_hash(body.new_password)
|
||||
current_user.hashed_password = hashed_password
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
return Message(message="Password updated successfully")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
def read_user_me(current_user: CurrentUser) -> Any:
|
||||
"""
|
||||
Get current user.
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.delete("/me", response_model=Message)
|
||||
def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
|
||||
"""
|
||||
Delete own user.
|
||||
"""
|
||||
if current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = current_user.portal_sub
|
||||
session.delete(current_user)
|
||||
session.commit()
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
|
||||
@router.post("/signup", response_model=UserPublic)
|
||||
def register_user(session: SessionDep, user_in: UserRegister) -> Any:
|
||||
"""
|
||||
Create new user without the need to be logged in.
|
||||
"""
|
||||
user = crud.get_user_by_email(session=session, email=user_in.email)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The user with this email already exists in the system",
|
||||
)
|
||||
user_create = UserCreate.model_validate(user_in)
|
||||
user = crud.create_user(session=session, user_create=user_create)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
def read_user_by_id(
|
||||
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific user by id.
|
||||
"""
|
||||
user = session.get(User, user_id)
|
||||
if user == current_user:
|
||||
return user
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="The user doesn't have enough privileges",
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=UserPublic,
|
||||
)
|
||||
def update_user(
|
||||
*,
|
||||
session: SessionDep,
|
||||
user_id: uuid.UUID,
|
||||
user_in: UserUpdate,
|
||||
) -> Any:
|
||||
"""
|
||||
Update a user.
|
||||
"""
|
||||
|
||||
db_user = session.get(User, user_id)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="The user with this id does not exist in the system",
|
||||
)
|
||||
if user_in.email:
|
||||
existing_user = crud.get_user_by_email(session=session, email=user_in.email)
|
||||
if existing_user and existing_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="User with this email already exists"
|
||||
)
|
||||
|
||||
db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
|
||||
return db_user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
|
||||
def delete_user(
|
||||
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
|
||||
) -> Message:
|
||||
"""
|
||||
Delete a user.
|
||||
"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user == current_user:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = user.portal_sub
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
# The local row is the access; dropping the portal's is tidying up, and it
|
||||
# happens after the thing that actually revokes has already happened.
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
@@ -0,0 +1,96 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic.networks import EmailStr
|
||||
|
||||
from fluksio.api.deps import get_current_active_superuser
|
||||
from fluksio.flow.state import RedisState
|
||||
from fluksio.models import Message
|
||||
from fluksio.utils import generate_test_email, send_email
|
||||
|
||||
router = APIRouter(prefix="/utils", tags=["utils"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/test-email/",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
status_code=201,
|
||||
)
|
||||
def test_email(email_to: EmailStr) -> Message:
|
||||
"""
|
||||
Test emails.
|
||||
"""
|
||||
email_data = generate_test_email(email_to=email_to)
|
||||
send_email(
|
||||
email_to=email_to,
|
||||
subject=email_data.subject,
|
||||
html_content=email_data.html_content,
|
||||
)
|
||||
return Message(message="Test email sent")
|
||||
|
||||
|
||||
@router.get("/health-check/")
|
||||
async def health_check() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/health/")
|
||||
async def health(request: Request, response: Response) -> dict[str, Any]:
|
||||
"""Deep health: 200 while the engine can serve its purpose, 503 otherwise.
|
||||
|
||||
"Serve its purpose" means the event loop is responsive and the configured
|
||||
state backend answers — the two failure modes a process-alive check never
|
||||
sees. The queue and pool sections are filled by the execution service.
|
||||
"""
|
||||
controller = getattr(request.app.state, "flow_controller", None)
|
||||
watchdog = getattr(request.app.state, "watchdog", None)
|
||||
problems: list[str] = []
|
||||
|
||||
loop_lag = watchdog.snapshot() if watchdog else {"ewma": 0.0, "max_60s": 0.0}
|
||||
if watchdog is not None and watchdog.degraded:
|
||||
problems.append("event loop lagging")
|
||||
|
||||
redis_info: dict[str, Any] = {
|
||||
"configured": False,
|
||||
"connected": None,
|
||||
"rtt_ms": None,
|
||||
}
|
||||
engine: dict[str, Any] = {"flows": 0, "nodes": 0, "quarantined": []}
|
||||
queue: dict[str, Any] = {}
|
||||
if controller is not None:
|
||||
state = controller.state
|
||||
if isinstance(state, RedisState):
|
||||
redis_info["configured"] = True
|
||||
start = time.perf_counter()
|
||||
connected = await run_in_threadpool(state.ping)
|
||||
redis_info["connected"] = connected
|
||||
redis_info["rtt_ms"] = round((time.perf_counter() - start) * 1000, 1)
|
||||
if not connected:
|
||||
problems.append("redis unreachable")
|
||||
# `loaded` is keyed by node id, so its length is a node count.
|
||||
loaded = getattr(controller, "loaded", {}) or {}
|
||||
engine["flows"] = len({entry.flow for entry in loaded.values()})
|
||||
engine["nodes"] = len(
|
||||
getattr(getattr(controller, "pipeline", None), "nodes", []) or []
|
||||
)
|
||||
engine["quarantined"] = sorted(getattr(controller, "quarantined", ()) or ())
|
||||
stats = getattr(controller, "queue_stats", None)
|
||||
if callable(stats):
|
||||
queue = await run_in_threadpool(stats)
|
||||
if queue.get("oldest_pending_s", 0) > 120:
|
||||
problems.append("queue stalled")
|
||||
|
||||
status = "degraded" if problems else "ok"
|
||||
if problems:
|
||||
response.status_code = 503
|
||||
return {
|
||||
"status": status,
|
||||
"problems": problems,
|
||||
"loop_lag_ms": loop_lag,
|
||||
"redis": redis_info,
|
||||
"engine": engine,
|
||||
"queue": queue,
|
||||
"ts": time.time(),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""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, says what it can do, and then answers calls on the socket
|
||||
it opened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fluksio.api.deps import get_current_active_superuser, get_current_user
|
||||
from fluksio.core import security
|
||||
from fluksio.flow import worker_main
|
||||
from fluksio.flow.remote import PROTOCOL, RemoteWorker, RemoteWorkerHub
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 = ""
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class TokenIssued(BaseModel):
|
||||
name: str
|
||||
token: str
|
||||
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 ""),
|
||||
)
|
||||
for worker in _hub(request.app).workers()
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tokens",
|
||||
response_model=TokenIssued,
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
)
|
||||
def issue_token(body: TokenRequest) -> Any:
|
||||
"""Mint the credential a worker presents when it dials in.
|
||||
|
||||
Shown once. It is signed with the same keypair the agent tokens use, so
|
||||
rotating that key revokes every worker along with them.
|
||||
"""
|
||||
token = security.create_worker_token(body.name, timedelta(days=TOKEN_DAYS))
|
||||
return TokenIssued(name=body.name, token=token)
|
||||
|
||||
|
||||
@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 app 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 goes in the query string for the same reason the dashboard's
|
||||
does: a websocket handshake carries no headers of its own.
|
||||
"""
|
||||
try:
|
||||
claims = security.decode_worker_token(token)
|
||||
except InvalidTokenError:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
hello = await asyncio.wait_for(websocket.receive_json(), timeout=30)
|
||||
except (TimeoutError, asyncio.TimeoutError, ValueError):
|
||||
await websocket.close(code=1002)
|
||||
return
|
||||
|
||||
if hello.get("op") != "hello" or int(hello.get("protocol", 0)) != PROTOCOL:
|
||||
await websocket.send_json(
|
||||
{"op": "refused", "reason": f"this engine speaks protocol {PROTOCOL}"}
|
||||
)
|
||||
await websocket.close(code=1002)
|
||||
return
|
||||
|
||||
# The token names the worker; what it calls itself is a suggestion, so two
|
||||
# hosts cannot fight over one identity by claiming the same name.
|
||||
name = str(claims.get("sub") or hello.get("name") or "worker")
|
||||
hub = _hub(websocket.app)
|
||||
worker = RemoteWorker(
|
||||
name=name,
|
||||
labels=[str(label) for label in (hello.get("labels") or [])],
|
||||
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"),
|
||||
},
|
||||
)
|
||||
hub.attach(worker)
|
||||
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)
|
||||
Reference in New Issue
Block a user