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.
129 lines
4.0 KiB
Python
129 lines
4.0 KiB
Python
"""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
|