"""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