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:
2026-08-21 21:48:05 +02:00
co-authored by Claude Opus 5
parent 97785ee590
commit 60d7ec81c0
170 changed files with 629 additions and 619 deletions
+91
View File
@@ -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}'")
+91
View File
@@ -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)},
)
+291
View File
@@ -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"
)
+181
View File
@@ -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))
+898
View File
@@ -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()
+123
View File
@@ -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}
)
+114
View File
@@ -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],
)
+69
View File
@@ -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)
+536
View File
@@ -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}'")
+408
View File
@@ -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)
+340
View File
@@ -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
+45
View File
@@ -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
+282
View File
@@ -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)
+51
View File
@@ -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}'")
+237
View File
@@ -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")
+96
View File
@@ -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(),
}
+165
View File
@@ -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)