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
+11 -3
View File
@@ -268,7 +268,7 @@ def node(
device: str | None = None,
device_policy: str = "require",
cache: bool = True,
resources: dict[str, Any] | None = None,
resources: dict[str, Any] | str | None = None,
) -> Callable[[F], F]:
"""Mark a function as a node, declaring its ports.
@@ -302,14 +302,22 @@ def node(
library that sizes itself to every visible core is told otherwise. ``env``
is for the tuning the engine must not invent, such as ``XLA_FLAGS``.
Declaring nothing is the default and changes nothing.
A string is a stored flavor — ``resources="gpu-small"`` — which is the same
thing as ``{"flavor": "gpu-small"}`` and stands in for cpus, gpus and ram.
Sizes accept ``"2G"``; ``duration_s`` accepts ``"30m"``.
"""
if device_policy not in ("require", "prefer"):
raise SyncError("device_policy is 'require' or 'prefer'")
if isinstance(resources, str):
resources = {"flavor": resources}
if resources is not None:
unknown = sorted(set(resources) - {"cpus", "gpus", "env"})
known = {"cpus", "gpus", "ram", "flavor", "duration_s", "env"}
unknown = sorted(set(resources) - known)
if unknown:
raise SyncError(
f"resources={{'{unknown[0]}': ...}} is not one of cpus, gpus, env"
f"resources={{'{unknown[0]}': ...}} is not one of "
f"{', '.join(sorted(known))}"
)
if timeout is not None and timeout < 0:
raise SyncError(
+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"
)
+10
View File
@@ -272,6 +272,16 @@ class Client:
result = self._call("GET", "/flows/")
return list(result.get("data") or [])
def flavors(self) -> list[dict[str, Any]]:
"""The named sizes a node can ask for."""
result = self._call("GET", "/flavors/")
return list(result.get("data") or [])
def resources(self) -> dict[str, Any]:
"""Every machine, what is free of it, and which nodes are queued."""
result: dict[str, Any] = self._call("GET", "/workers/resources")
return result
# -- how the engine is doing -------------------------------------------
def summary(self) -> dict[str, Any]: