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
+64 -1
View File
@@ -558,6 +558,35 @@ def _portal_phrase(portal: dict[str, Any]) -> tuple[str, str]:
return "portal unreachable" + (f" ({trouble[:60]})" if trouble else ""), "red"
def _resource_lines(machines: dict[str, Any]) -> list[str]:
"""One line per machine: what it holds, and what is queued for it."""
targets = machines.get("targets") or []
if not targets:
return []
waiting = len(machines.get("waiting") or [])
lines = []
for target in targets:
parts = []
for label, level in (
("cpu", target.get("cpus")),
("gpu", target.get("gpus")),
):
# A machine with no card says nothing about cards.
if level and level.get("total"):
parts.append(
f"{label} {level['total'] - level['free']}/{level['total']}"
)
ram = target.get("ram_mb")
if ram and ram.get("total"):
parts.append(
f"ram {(ram['total'] - ram['free']) // 1024}/{ram['total'] // 1024}G"
)
lines.append(f"{str(target.get('target', '')):<16}" + " · ".join(parts))
if waiting:
lines[-1] += f" {waiting} waiting"
return [f"resources {lines[0]}"] + [f" {line}" for line in lines[1:]]
def _status_screen(client: Client) -> Any:
"""One frame: health, the flows, and the failures under them."""
from rich.console import Group
@@ -576,6 +605,11 @@ def _status_screen(client: Client) -> Any:
# Never enrolled, or an engine too old to answer. Neither is worth
# failing a status screen over.
portal = {}
try:
machines = client.resources()
except (SyncError, ApiError):
# An engine that accounts nothing, or one too old to answer.
machines = {}
healthy = summary.get("status") == "ok"
head = Text()
@@ -625,7 +659,9 @@ def _status_screen(client: Client) -> Any:
Text(" · ".join(notes), style="red" if flow.get("error_count") else "dim"),
)
parts: list[Any] = [head, facts, ""]
parts: list[Any] = [head, facts]
parts += [Text(line, style="dim") for line in _resource_lines(machines)]
parts.append("")
parts.append(
table if flows else Text("No flows yet. `fluksio sync` uploads yours.", "dim")
)
@@ -727,6 +763,27 @@ def cmd_runs(args: argparse.Namespace) -> int:
return 0
def cmd_flavors(args: argparse.Namespace) -> int:
"""The named sizes a node can ask for."""
try:
with _client_for(args) as client:
rows = client.flavors()
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
if not rows:
_say("No flavors. A node can still say cpus and gpus itself.")
return 0
for row in rows:
cards = f"{row['gpus']:>3} gpu" if row.get("gpus") else " " * 7
_say(
f"{row['name']:<14}{row['cpus']:>3} cpu {row['ram'] // 1024:>5} GB"
f"{cards} {row.get('description', '')}"
)
return 0
def _grid(
definition: dict[str, Any], values: list[str], seed: int | None
) -> list[dict[str, Any]]:
@@ -909,6 +966,12 @@ def add_parsers(subparsers: Any) -> None:
with_engine(parser, local=True)
parser.set_defaults(func=cmd_runs)
parser = subparsers.add_parser(
"flavors", help="the named resource sizes a node can ask for"
)
with_engine(parser)
parser.set_defaults(func=cmd_flavors)
parser = subparsers.add_parser(
"sweep", help="one flow, once per combination of the parameters given"
)