**SQLite is the database, and now says so.** `metric_minute` and every run
table are written with `sqlalchemy.dialects.sqlite.insert(...)
.on_conflict_do_update` and with `max(a, b)`, neither of which another
dialect has — so pointing `DATABASE_URL` at Postgres migrated cleanly,
served, logged in, and then lost every observability flush into the
collector's hold buffer and failed every run. It refuses at startup
instead. (The Postgres in the compose stack is Umami's; the engine's own
database has been a file beside the flows since 2026-08-21.)
**Every integer query parameter is bounded.** The caps were written as
`min(limit, 500)`, which a negative walks straight through — `?limit=-1`
compiles to `LIMIT -1` and SQLite returns the whole table. Ten signatures,
now `Query(ge=…, le=…)`. `hours=0` still means an hour, which
`_window_hours` was already deliberate about.
**Exports are capped at 10 000 runs** and say so with `X-Truncated`. The
filters bounded a sensible request and nothing bounded an unfiltered one,
which read every row into memory before a byte was streamed. `_series`
resolves cached curves in two queries rather than a `Run` lookup and a
`RunMetric` query per restored node — a comparison of twenty runs was
calling that twenty times over.
**`PUT /artifacts` has a size limit** (`MAX_ARTIFACT_BYTES`, 2 GiB, 0 to
disable), checked against `Content-Length` and again against the stream for
a chunked body, and its writes moved off the event loop.
**`/observability/timeseries` takes `since`/`until`**, the same window
`/runs` and `/events` take, capped at 2000 points — `hours=720&bucket_s=60`
was 43 200 of them in one array. It is also what a dragged chart needs to
re-fetch at its own resolution rather than magnifying buckets it has.
**Composite indexes** for the three list screens: `run(flow, created_at)`
and `(status, created_at)`, `flow_run(flow, started_at)`,
`engine_event(type, ts)`. Every index was single-column, so SQLite picked
one and sorted the rest by hand. Verified against a copy of a live database
(250k `flow_run` rows): the planner takes all four.
**Redis clients have socket timeouts.** A Redis that stops answering
without closing the connection hung the caller until the kernel gave up —
including `/utils/health/`, whose job is to notice.
**The panels file is written under one lock.** `save_panels` and
`unpair_panel` are both read-modify-write, and a save that read before an
unpair wrote put the old nonce back — silently un-revoking a screen that
had just been unpaired. The nonce carry-forward was written to make that
impossible; the gap between its read and its write is where it happened.
**Startup releases what it acquired.** Everything past `event_bus.bind`
registers how to close itself and the `finally` walks that list backwards;
a failure part-way through used to reach none of the shutdown steps and
leave the worker pool's subprocesses and every background task behind —
under `--reload`, once per bad edit. `modules.reconcile` moved into the
background: `uv` gets five minutes twice over, the healthcheck allows
eighty seconds, and the autoheal restarted the container before it could
finish installing.
`delete_run` takes SQLite's write lock up front (`core.db.writing`) rather
than upgrading a deferred transaction and losing to whichever flush
committed in between. `modules.sync` is serialised — two applies mutated
one venv at once. The proxied-call and stream dicts are bounded, and a
reused id cancels its predecessor instead of dropping the reference.
Security, found in passing and small enough to fix here:
- **`/secrets/` required only a signed-in user.** The names alone say what
this installation talks to, and `PUT /{name}` takes any name, so any
account could overwrite the credential a flow authenticates with.
Superuser now — which `/search` already assumed and said so.
- **`POST /login/access-token` had no rate limit.** Argon2 is deliberately
expensive and the route is unauthenticated and runs in the shared
threadpool. Ten *failed* attempts per address per five minutes; a
successful sign-in spends nothing.
- **a password reset link worked repeatedly for 48 hours.** The token now
carries a digest of the password hash it was minted against, so it stops
verifying once it has set one. No table of spent tokens needed.
- **enrolment accepted `http://`**, sending the claim code and then this
installation's credential in clear. https, or a local address.
- the rate limiter read `request.client.host`, which behind Traefik is the
proxy — so every per-address limit was one global bucket and one caller
could lock out everyone. It reads the forwarded address, and its
bucket table is capped rather than growing one key per address forever.
- SMTP has a timeout and sends after the response, so an unreachable mail
host cannot pin a threadpool worker, and a reply's timing no longer says
whether the address exists.
Test suite: engine-written rows are cleared between modules. A `FlowRun`
left `running` by one module turned up in another's query. Per-test
rollback is not available here — the module-scoped `client` runs the real
lifespan and its collector and run service write through sessions of their
own — so this bounds it where the writes come from. Three consecutive
green runs, orders randomised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
535 lines
19 KiB
Python
535 lines
19 KiB
Python
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from pydantic import EmailStr
|
|
from sqlalchemy import JSON, Column, Index
|
|
from sqlmodel import Field, SQLModel
|
|
|
|
from fluksio.core.types import UTCDateTime
|
|
|
|
|
|
def get_datetime_utc() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
# Shared properties
|
|
class UserBase(SQLModel):
|
|
email: EmailStr = Field(unique=True, index=True, max_length=255)
|
|
is_active: bool = True
|
|
is_superuser: bool = False
|
|
full_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
# Properties to receive via API on creation
|
|
class UserCreate(UserBase):
|
|
password: str = Field(min_length=8, max_length=128)
|
|
|
|
|
|
class UserRegister(SQLModel):
|
|
email: EmailStr = Field(max_length=255)
|
|
password: str = Field(min_length=8, max_length=128)
|
|
full_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
# Properties to receive via API on update, all are optional
|
|
class UserUpdate(UserBase):
|
|
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
|
|
password: str | None = Field(default=None, min_length=8, max_length=128)
|
|
|
|
|
|
class UserUpdateMe(SQLModel):
|
|
full_name: str | None = Field(default=None, max_length=255)
|
|
email: EmailStr | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class UpdatePassword(SQLModel):
|
|
current_password: str = Field(min_length=8, max_length=128)
|
|
new_password: str = Field(min_length=8, max_length=128)
|
|
|
|
|
|
# Database model, database table inferred from class name
|
|
class User(UserBase, table=True):
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
hashed_password: str
|
|
#: The portal account this local user stands for, if any. Set for the
|
|
#: superuser who enrolled this installation and for every remote user one
|
|
#: of them admitted; None for a purely local account, which is what an
|
|
#: installation nobody enrolled has only.
|
|
portal_sub: str | None = Field(default=None, max_length=64, unique=True, index=True)
|
|
created_at: datetime | None = Field(
|
|
default_factory=get_datetime_utc,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
|
|
|
|
# Properties to return via API, id is always required
|
|
class UserPublic(UserBase):
|
|
id: uuid.UUID
|
|
portal_sub: str | None = None
|
|
created_at: datetime | None = None
|
|
|
|
|
|
class UsersPublic(SQLModel):
|
|
data: list[UserPublic]
|
|
count: int
|
|
|
|
|
|
# Generic message
|
|
class Message(SQLModel):
|
|
message: str
|
|
|
|
|
|
# JSON payload containing access token
|
|
class Token(SQLModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
|
|
|
|
# Contents of JWT token
|
|
class TokenPayload(SQLModel):
|
|
sub: str | None = None
|
|
#: Set instead of ``sub`` by a token the portal minted for a person: it
|
|
#: names them on the portal, and the local account it stands for is looked
|
|
#: up from it. A portal identity nobody here was mapped to resolves to no
|
|
#: user at all, which is the refusal.
|
|
portal_sub: str | None = None
|
|
|
|
|
|
class NewPassword(SQLModel):
|
|
token: str
|
|
new_password: str = Field(min_length=8, max_length=128)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# OAuth 2.1, for the MCP endpoint
|
|
#
|
|
# Agents cannot be handed a password, so they get their own authorization flow:
|
|
# the client registers itself, a human approves it in the browser, and the code
|
|
# that comes back is exchanged for a token. Only the hash of a code or a refresh
|
|
# token is stored, so a copy of this table is not a set of credentials.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class OAuthClient(SQLModel, table=True):
|
|
"""A registered agent. Registration alone grants nothing."""
|
|
|
|
__tablename__ = "oauth_client"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
client_name: str = Field(max_length=128)
|
|
redirect_uris: list[str] = Field(sa_column=Column(JSON), default_factory=list)
|
|
created_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
|
|
|
|
class OAuthAuthorizationCode(SQLModel, table=True):
|
|
"""One approved authorization, waiting to be exchanged for a token."""
|
|
|
|
__tablename__ = "oauth_authorization_code"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
code_hash: str = Field(max_length=64, unique=True, index=True)
|
|
client_id: uuid.UUID = Field(
|
|
foreign_key="oauth_client.id", nullable=False, ondelete="CASCADE"
|
|
)
|
|
user_id: uuid.UUID = Field(
|
|
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
|
)
|
|
redirect_uri: str = Field(max_length=2048)
|
|
code_challenge: str = Field(max_length=128)
|
|
resource: str | None = Field(default=None, max_length=2048)
|
|
expires_at: datetime = Field(sa_type=UTCDateTime)
|
|
used_at: datetime | None = Field(default=None, sa_type=UTCDateTime)
|
|
#: The refresh token this code produced, so replaying the code can revoke it.
|
|
refresh_token_id: uuid.UUID | None = None
|
|
created_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
|
|
|
|
class OAuthRefreshToken(SQLModel, table=True):
|
|
"""A rotating refresh token, one family per authorization."""
|
|
|
|
__tablename__ = "oauth_refresh_token"
|
|
|
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
token_hash: str = Field(max_length=64, unique=True, index=True)
|
|
client_id: uuid.UUID = Field(
|
|
foreign_key="oauth_client.id", nullable=False, ondelete="CASCADE"
|
|
)
|
|
user_id: uuid.UUID = Field(
|
|
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
|
)
|
|
#: Every token rotated out of one authorization shares this, so reusing an
|
|
#: old one can revoke the whole line rather than just itself.
|
|
family_id: uuid.UUID
|
|
expires_at: datetime = Field(sa_type=UTCDateTime)
|
|
revoked: bool = False
|
|
created_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
nullable=False,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
|
|
|
|
class OAuthClientRegister(SQLModel):
|
|
"""RFC 7591 dynamic client registration request."""
|
|
|
|
client_name: str = Field(default="MCP client", max_length=128)
|
|
redirect_uris: list[str]
|
|
grant_types: list[str] | None = None
|
|
response_types: list[str] | None = None
|
|
token_endpoint_auth_method: str | None = None
|
|
|
|
|
|
class OAuthClientInfo(SQLModel):
|
|
client_id: str
|
|
client_name: str
|
|
redirect_uris: list[str]
|
|
client_id_issued_at: int
|
|
grant_types: list[str] = ["authorization_code", "refresh_token"]
|
|
response_types: list[str] = ["code"]
|
|
token_endpoint_auth_method: str = "none"
|
|
|
|
|
|
class OAuthAuthorizeInfo(SQLModel):
|
|
"""What the consent page shows, all of it validated server-side."""
|
|
|
|
client_name: str
|
|
redirect_uri: str
|
|
scope: str
|
|
|
|
|
|
class OAuthAuthorizeRequest(SQLModel):
|
|
client_id: str
|
|
redirect_uri: str
|
|
code_challenge: str
|
|
code_challenge_method: str = "S256"
|
|
state: str | None = None
|
|
resource: str | None = None
|
|
scope: str | None = None
|
|
|
|
|
|
class OAuthAuthorizeResponse(SQLModel):
|
|
redirect_url: str
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Observability
|
|
#
|
|
# The engine's own history: what ran, how long it took, and what went wrong.
|
|
# Rolled up per minute rather than kept per execution — a node firing every
|
|
# second is 86 400 rows a day raw, and nobody reads a row of that.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class MetricBucket(SQLModel, table=True):
|
|
"""One node's minute: how much ran, how long it took, how late it was."""
|
|
|
|
__tablename__ = "metric_minute"
|
|
|
|
flow: str = Field(primary_key=True, max_length=255)
|
|
node: str = Field(primary_key=True, max_length=255)
|
|
bucket: datetime = Field(
|
|
primary_key=True,
|
|
index=True,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
executions: int = 0
|
|
errors: int = 0
|
|
#: Messages emitted, which is not the same as executions: a node can run
|
|
#: and publish nothing.
|
|
messages: int = 0
|
|
duration_sum_ms: float = 0.0
|
|
duration_max_ms: float = 0.0
|
|
#: How long queued work waited before it ran.
|
|
lag_sum_ms: float = 0.0
|
|
lag_max_ms: float = 0.0
|
|
#: Items the lag sums are over, so an average can be taken.
|
|
items: int = 0
|
|
|
|
|
|
class EngineEvent(SQLModel, table=True):
|
|
"""Something worth keeping after the websocket has forgotten it."""
|
|
|
|
__tablename__ = "engine_event"
|
|
# The events screen excludes audit rows and reads newest first. A `type`
|
|
# index alone serves neither half of that.
|
|
__table_args__ = (Index("ix_engine_event_type_ts", "type", "ts"),)
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
ts: datetime = Field(
|
|
index=True,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
#: node_error, flow_quarantined, engine_degraded, …, or audit.
|
|
type: str = Field(max_length=32, index=True)
|
|
flow: str = ""
|
|
node: str = ""
|
|
#: The error and its traceback, or what an audit entry says was done.
|
|
detail: str = ""
|
|
#: Who did it, on audit rows.
|
|
actor: str = ""
|
|
#: The run this happened in: a batch run's id, or the journaled item a live
|
|
#: cascade came from. Empty for everything that belongs to neither.
|
|
run: str = Field(default="", index=True, max_length=64)
|
|
|
|
|
|
class FlowRun(SQLModel, table=True):
|
|
"""One cascade, from the item that started it to the last node in it.
|
|
|
|
Or one batch run: it opens no cascade, but it is still a thing that ran,
|
|
and the history screens are where somebody looks for it.
|
|
"""
|
|
|
|
__tablename__ = "flow_run"
|
|
# The history screen filters by flow, newest first.
|
|
__table_args__ = (Index("ix_flow_run_flow_started", "flow", "started_at"),)
|
|
|
|
#: The queue entry id, a `manual-` one for a run that never queued, or the
|
|
#: `Run.id` of a batch run.
|
|
id: str = Field(primary_key=True, max_length=64)
|
|
flow: str = Field(index=True, max_length=255)
|
|
#: What caused it: the work item's cause, "manual", or "run" for a batch.
|
|
source: str = ""
|
|
started_at: datetime = Field(
|
|
index=True,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
finished_at: datetime | None = Field(
|
|
default=None,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
#: running, ok, error, abandoned — or whatever a batch run ended as.
|
|
status: str = "running"
|
|
nodes: int = 0
|
|
errors: int = 0
|
|
duration_ms: float = 0.0
|
|
deliveries: int = 1
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Runs
|
|
#
|
|
# A cascade above is what an always-on flow does when a value arrives. A run is
|
|
# the other shape: a batch flow taken from its inputs to its outputs once, with
|
|
# parameters that identify it and a result worth keeping. An experiment is a
|
|
# run, and so is a CI-style job — same entity, different caller.
|
|
#
|
|
# Kept apart from the observability tables on purpose: those are rolled up and
|
|
# pruned on a retention window, and an experiment nobody wants deleted must not
|
|
# share that fate.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class Run(SQLModel, table=True):
|
|
"""One finite execution of a flow, with what it was asked and what it made."""
|
|
|
|
__tablename__ = "run"
|
|
# The runs list filters by flow and/or status, newest first.
|
|
__table_args__ = (
|
|
Index("ix_run_flow_created", "flow", "created_at"),
|
|
Index("ix_run_status_created", "status", "created_at"),
|
|
)
|
|
|
|
id: str = Field(primary_key=True, max_length=64)
|
|
flow: str = Field(index=True, max_length=255)
|
|
#: The flow document this ran, and the commit it was read at, so a result
|
|
#: can be traced back to the code that produced it.
|
|
flow_version: int = 1
|
|
commit: str = Field(default="", max_length=64)
|
|
#: The commit of the *user's* repository, for a flow declared there with
|
|
#: the decorators. The store's commit names a generated import shim; this
|
|
#: one names the code it imported. A `-dirty` suffix is git's own way of
|
|
#: saying the tree had changes that are in no commit at all.
|
|
origin_commit: str = Field(default="", max_length=80)
|
|
#: sha256 over that repository's python files, read when the run actually
|
|
#: started. The commit cannot tell two runs of an uncommitted tree apart —
|
|
#: both stamp `-dirty` — and the shim imports whatever is on disk, so this
|
|
#: is the half that names the code. Empty when the engine cannot see the
|
|
#: repository, or for a flow drawn on the canvas.
|
|
code_digest: str = Field(default="", max_length=64)
|
|
params: dict[str, Any] = Field(sa_column=Column(JSON), default_factory=dict)
|
|
#: sha256 over params and seed. Two runs of the same thing share it, which
|
|
#: is what makes "have I already run this?" a lookup.
|
|
params_digest: str = Field(default="", index=True, max_length=64)
|
|
seed: int | None = None
|
|
#: Runs submitted together — a sweep, an ensemble.
|
|
group_id: str | None = Field(default=None, index=True, max_length=64)
|
|
#: The run this one was made from, on a retry.
|
|
parent_id: str | None = Field(default=None, max_length=64)
|
|
#: A key the caller made up for one submission, so retrying a submit that
|
|
#: may already have landed returns that run instead of starting a second.
|
|
#: Unique where it is set; null for anything submitted without one.
|
|
idempotency_key: str | None = Field(
|
|
default=None, unique=True, index=True, max_length=64
|
|
)
|
|
#: Where it was asked for: api, cli, sdk, hook or sweep.
|
|
cause: str = Field(default="api", max_length=32)
|
|
#: Re-execute every node, whatever the stage cache holds for it.
|
|
no_cache: bool = False
|
|
#: queued, running, ok, error, cancelled or abandoned.
|
|
status: str = Field(default="queued", index=True, max_length=16)
|
|
#: Why it is where it is: what it waits for, or what went wrong.
|
|
status_reason: str = Field(default="", max_length=1024)
|
|
#: Worker labels its nodes need, so a run with nowhere to go can say so.
|
|
labels: list[str] = Field(sa_column=Column(JSON), default_factory=list)
|
|
#: The largest machine its nodes ask for, as cpus, gpus, ram_mb and device.
|
|
#: Read while the run is queued, to tell waiting for a machine from having
|
|
#: nowhere to run at all.
|
|
needs: dict[str, Any] | None = Field(sa_column=Column(JSON), default=None)
|
|
created_at: datetime = Field(
|
|
index=True,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
started_at: datetime | None = Field(
|
|
default=None,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
finished_at: datetime | None = Field(
|
|
default=None,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
duration_ms: float = 0.0
|
|
#: The flow's declared outputs once it finished.
|
|
result: dict[str, Any] = Field(sa_column=Column(JSON), default_factory=dict)
|
|
#: Which engine holds it, and when it last said so. A run whose lease has
|
|
#: gone stale is one whose engine died mid-run.
|
|
engine: str = Field(default="", max_length=64)
|
|
lease_at: datetime | None = Field(
|
|
default=None,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
actor: str = Field(default="", max_length=255)
|
|
|
|
|
|
class RunNode(SQLModel, table=True):
|
|
"""What one node did inside a run: the per-stage view of it."""
|
|
|
|
__tablename__ = "run_node"
|
|
|
|
run_id: str = Field(primary_key=True, max_length=64)
|
|
node: str = Field(primary_key=True, max_length=255)
|
|
#: ok, error, skipped, cached or cancelled.
|
|
status: str = Field(default="ok", max_length=16)
|
|
attempt: int = 1
|
|
started_at: datetime | None = Field(
|
|
default=None,
|
|
sa_type=UTCDateTime,
|
|
)
|
|
duration_ms: float = 0.0
|
|
#: Which worker ran it: "local", or a remote worker's name.
|
|
worker: str = Field(default="local", max_length=64)
|
|
error: str = ""
|
|
logs: str = ""
|
|
#: Everything this node's output depends on, hashed: its source, its
|
|
#: settings and the values it read. What a later run looks itself up by.
|
|
cache_key: str = Field(default="", index=True, max_length=64)
|
|
#: The run this node's values were restored from, when it was not executed.
|
|
#: Its series lives there too, which is what makes a cached run's curve
|
|
#: readable without copying a few thousand rows per reuse.
|
|
cached_from: str = Field(default="", max_length=64)
|
|
#: What it returned, as canonical JSON, so a node with this key can be
|
|
#: skipped and its outputs restored. None when it may not be reused —
|
|
#: opted out, too large, or a value JSON cannot carry. Written with
|
|
#: `cache_key` or not at all, so a keyed row is always restorable.
|
|
outputs: str | None = None
|
|
|
|
|
|
class RunMetric(SQLModel, table=True):
|
|
"""One number a run reported, at one step.
|
|
|
|
Written by the run's own driver rather than folded off the event bus: the
|
|
bus drops what it cannot keep up with, which is the right trade for a live
|
|
canvas and the wrong one for a training curve.
|
|
"""
|
|
|
|
__tablename__ = "run_metric"
|
|
|
|
run_id: str = Field(primary_key=True, max_length=64)
|
|
name: str = Field(primary_key=True, max_length=128)
|
|
#: -1 for a value with no step of its own — a final score.
|
|
step: int = Field(primary_key=True)
|
|
node: str = Field(default="", max_length=255)
|
|
ts: float = 0.0
|
|
value: float = 0.0
|
|
|
|
|
|
class RunArtifact(SQLModel, table=True):
|
|
"""A file a run produced, addressed by the hash of its content."""
|
|
|
|
__tablename__ = "run_artifact"
|
|
|
|
run_id: str = Field(primary_key=True, max_length=64)
|
|
#: The message the bytes left the node on, which is what addresses them.
|
|
name: str = Field(primary_key=True, max_length=255)
|
|
#: What the node called the file, when it said. Kept because a reference
|
|
#: rebuilt from this row is otherwise the same bytes under another name.
|
|
filename: str | None = Field(default=None, max_length=255)
|
|
node: str = Field(default="", max_length=255)
|
|
digest: str = Field(default="", index=True, max_length=71)
|
|
size: int = 0
|
|
media_type: str = Field(default="application/octet-stream", max_length=128)
|
|
created_at: datetime = Field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
sa_type=UTCDateTime,
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Flavors
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class FlavorBase(SQLModel):
|
|
"""A named amount of machine, so a node can ask for one by name.
|
|
|
|
The point is not to save typing. Cores and megabytes are a property of the
|
|
machines an installation actually has, and they change when those machines
|
|
do — so a node saying "gpu-small" keeps meaning something after the cluster
|
|
is replaced, where a node saying 8 and 16384 quietly stops.
|
|
"""
|
|
|
|
cpus: int = Field(default=1, ge=1)
|
|
gpus: int = Field(default=0, ge=0)
|
|
#: Megabytes. A flavor always says, which is what a node asking for one is
|
|
#: buying — the raw form leaves memory unstated and unaccounted.
|
|
ram: int = Field(default=2048, ge=1)
|
|
description: str = Field(default="", max_length=255)
|
|
|
|
|
|
class Flavor(FlavorBase, table=True):
|
|
"""A resource size somebody named, stored so nodes can refer to it."""
|
|
|
|
__tablename__ = "flavor"
|
|
|
|
name: str = Field(primary_key=True, max_length=64)
|
|
|
|
|
|
class FlavorCreate(FlavorBase):
|
|
name: str = Field(max_length=64)
|
|
|
|
|
|
class FlavorUpdate(SQLModel):
|
|
"""Everything but the name: renaming would orphan the nodes that ask."""
|
|
|
|
cpus: int | None = Field(default=None, ge=1)
|
|
gpus: int | None = Field(default=None, ge=0)
|
|
ram: int | None = Field(default=None, ge=1)
|
|
description: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class FlavorPublic(FlavorBase):
|
|
name: str
|
|
|
|
|
|
class FlavorsPublic(SQLModel):
|
|
data: list[FlavorPublic]
|
|
count: int
|