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