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:
@@ -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
|
||||
Reference in New Issue
Block a user