Name the sizes a node can ask for

Raw cpus and gpus are a property of the machines an installation has, so a node
written against a cluster quietly stops meaning anything when the cluster is
replaced. A node says "gpu-small" instead, and what that is stored here —
editable, and read again every time the node is built, so changing the flavor
changes what the next run gets.

Memory joins the schema properly (`ram`, in MB, accepting "2G"), along with
`duration_s` for how long a node is expected to take. That one is recorded and
shown and nothing else yet: a statement for whoever is planning around the node,
not a limit — the limit is still `timeout`.

A flavor and a number for the same thing is refused, compared by value so an
editor writing the whole object back with its defaults still round-trips. A name
nothing stores is refused at the save, which covers the canvas and `fluksio
sync` at once, and deleting one a node still asks for says which node.

Four sizes are seeded on an installation that has none, and never re-seeded:
re-adding one somebody deliberately removed is an argument nobody wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
This commit is contained in:
2026-08-27 08:59:10 +02:00
co-authored by Claude Opus 5
parent 6ff56533f5
commit a82f88cf0a
15 changed files with 720 additions and 19 deletions
+2
View File
@@ -5,6 +5,7 @@ from fluksio.api.routes import (
artifacts,
cloud,
dashboards,
flavors,
flows,
login,
messages,
@@ -24,6 +25,7 @@ api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(flavors.router)
api_router.include_router(flows.router)
api_router.include_router(flows.ws_router)
api_router.include_router(secrets.router)
+128
View File
@@ -0,0 +1,128 @@
"""Named amounts of machine, for nodes to ask for by name.
A node saying ``gpu-small`` keeps meaning something after the cluster it was
written against is replaced; one saying eight cores and sixteen gigabytes
quietly stops. So the sizes live here, editable, and a node refers to one.
Reading them is open to anyone signed in — the node panel offers them in a
dropdown. Changing one is a superuser's, because it changes what every node
that names it gets.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from sqlmodel import select
from fluksio.api.deps import (
FlowControllerDep,
SessionDep,
get_current_active_superuser,
get_current_user,
)
from fluksio.flow.resources import FLAVOR_NAME
from fluksio.models import (
Flavor,
FlavorCreate,
FlavorPublic,
FlavorsPublic,
FlavorUpdate,
Message,
)
router = APIRouter(
prefix="/flavors", tags=["flavors"], dependencies=[Depends(get_current_user)]
)
def _get(session: SessionDep, name: str) -> Flavor:
flavor = session.get(Flavor, name)
if flavor is None:
raise HTTPException(status_code=404, detail=f"No flavor named '{name}'")
return flavor
@router.get("/", response_model=FlavorsPublic)
def read_flavors(session: SessionDep) -> Any:
"""Every size a node can ask for."""
flavors = list(session.exec(select(Flavor).order_by(Flavor.name)).all())
return FlavorsPublic(data=flavors, count=len(flavors))
@router.post(
"/",
response_model=FlavorPublic,
dependencies=[Depends(get_current_active_superuser)],
)
def create_flavor(session: SessionDep, body: FlavorCreate) -> Any:
if not FLAVOR_NAME.match(body.name):
raise HTTPException(
status_code=422,
detail="A flavor name is lowercase letters, digits, dashes and "
"underscores, starting with a letter",
)
if session.get(Flavor, body.name) is not None:
raise HTTPException(
status_code=409, detail=f"A flavor named '{body.name}' already exists"
)
flavor = Flavor(**body.model_dump())
session.add(flavor)
session.commit()
session.refresh(flavor)
return flavor
@router.patch(
"/{name}",
response_model=FlavorPublic,
dependencies=[Depends(get_current_active_superuser)],
)
def update_flavor(session: SessionDep, name: str, body: FlavorUpdate) -> Any:
"""Change what a size means. Every node naming it gets the new one."""
flavor = _get(session, name)
flavor.sqlmodel_update(body.model_dump(exclude_unset=True))
session.add(flavor)
session.commit()
session.refresh(flavor)
return flavor
@router.delete(
"/{name}",
response_model=Message,
dependencies=[Depends(get_current_active_superuser)],
)
async def delete_flavor(
session: SessionDep, controller: FlowControllerDep, name: str
) -> Any:
"""Remove a size, as long as no node still asks for it."""
_get(session, name)
used_by = await run_in_threadpool(_usages, controller, name)
if used_by:
raise HTTPException(
status_code=409,
detail=f"'{name}' is still asked for by {', '.join(used_by)}",
)
session.delete(session.get(Flavor, name))
session.commit()
return Message(message=f"Deleted flavor '{name}'")
def _usages(controller: FlowControllerDep, name: str) -> list[str]:
"""Which nodes name this flavor, published or in a draft."""
used: list[str] = []
for flow_name in controller.store.list_flows():
seen = set()
for draft in (False, True):
if draft and not controller.store.has_draft(flow_name):
continue
try:
flow = controller.store.read_flow(flow_name, draft=draft)
except Exception:
continue
for node in flow.nodes:
if node.resources is not None and node.resources.flavor == name:
seen.add(f"{flow_name}.{node.id}")
used.extend(sorted(seen))
return used
+28 -1
View File
@@ -57,7 +57,7 @@ from fluksio.flow.store import (
LibNotFound,
StaleVersion,
)
from fluksio.models import Message, Run, RunArtifact, RunMetric, RunNode
from fluksio.models import Flavor, Message, Run, RunArtifact, RunMetric, RunNode
router = APIRouter(
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
@@ -368,6 +368,31 @@ def read_flow(name: str, controller: FlowControllerDep) -> Any:
return _detail(controller, _read_flow(controller, name))
def _check_flavors(definition: FlowDef) -> None:
"""Refuse a size nobody stored, here rather than when the node is built.
Catching it at the save covers the canvas and ``fluksio sync`` in one
place; a node that only finds out at build time is a red node somebody has
to go and look at.
"""
named = {
node.resources.flavor
for node in definition.nodes
if node.resources is not None and node.resources.flavor
}
if not named:
return
with Session(engine) as session:
known = set(session.exec(select(Flavor.name)).all())
unknown = sorted(named - known)
if unknown:
raise HTTPException(
status_code=422,
detail=f"No flavor named '{unknown[0]}' "
f"(known: {', '.join(sorted(known)) or 'none'})",
)
@router.put("/{name}", response_model=FlowDetail)
async def save_flow(
name: str,
@@ -390,6 +415,8 @@ async def save_flow(
if len(duplicates) != len(definition.nodes):
raise HTTPException(status_code=400, detail="Node names must be unique")
_check_flavors(definition)
try:
stored = await run_in_threadpool(
controller.store.write_draft, definition, definition.version