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
+68 -5
View File
@@ -9,12 +9,18 @@ from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fluksio.flow.messages import MessageSpec
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
#: Megabytes, gigabytes, or a bare number already in megabytes.
_SIZE = re.compile(r"^\s*(\d+)\s*([mg]b?)?\s*$", re.IGNORECASE)
#: Seconds, minutes, hours or days — one unit, so there is one thing to read.
_SPAN = re.compile(r"^\s*(\d+)\s*([smhd])?\s*$", re.IGNORECASE)
_SPAN_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
def _validate_name(value: str) -> str:
if not NAME_PATTERN.match(value):
@@ -24,6 +30,27 @@ def _validate_name(value: str) -> str:
return value
def _megabytes(value: Any) -> Any:
"""``2G`` and ``512M`` and ``512`` all mean a number of megabytes."""
if not isinstance(value, str):
return value
match = _SIZE.match(value)
if not match:
raise ValueError(f"'{value}' is not a size — write 512M, 2G, or 512")
amount, unit = int(match.group(1)), (match.group(2) or "m").lower()
return amount * 1024 if unit.startswith("g") else amount
def _seconds(value: Any) -> Any:
"""``2h``, ``30m``, ``90s`` and ``90`` all mean a number of seconds."""
if not isinstance(value, str):
return value
match = _SPAN.match(value)
if not match:
raise ValueError(f"'{value}' is not a duration — write 90s, 30m, 2h, or 90")
return int(match.group(1)) * _SPAN_SECONDS[(match.group(2) or "s").lower()]
class Resources(BaseModel):
"""What one execution of a node needs to have to itself.
@@ -64,10 +91,27 @@ class Resources(BaseModel):
default=None,
ge=1,
description=(
"Megabytes held for the whole execution. Counted against machines "
"that said how much they have, and ignored by those that did not — "
"which is a machine with nothing to say about memory, not one with "
"none."
"Megabytes held for the whole execution; accepts '512M' or '2G'. "
"Counted against machines that said how much they have, and "
"ignored by those that did not — which is a machine with nothing "
"to say about memory, not one with none."
),
)
flavor: str | None = Field(
default=None,
description=(
"A stored size by name, standing in for cpus, gpus and ram. Read "
"again every time the node is built, so editing the flavor edits "
"what the next run gets."
),
)
duration_s: int | None = Field(
default=None,
ge=1,
description=(
"How long one execution is expected to take; accepts '30m' or "
"'2h'. A statement about the node for whoever is planning around "
"it, not a limit — the limit is `timeout`."
),
)
env: dict[str, str] = Field(
@@ -80,6 +124,25 @@ class Resources(BaseModel):
),
)
_parse_ram = field_validator("ram", mode="before")(_megabytes)
_parse_duration = field_validator("duration_s", mode="before")(_seconds)
@model_validator(mode="after")
def _a_flavor_says_it_all(self) -> Resources:
"""A flavor and a number for the same thing is two answers.
Compared by value rather than by what was set, because an editor that
writes the whole object back sends the defaults with it — and a node
that says `flavor` and `cpus: 1` has not actually asked for anything
the flavor does not already cover.
"""
if self.flavor and (self.cpus != 1 or self.gpus != 0 or self.ram is not None):
raise ValueError(
f"flavor '{self.flavor}' already says how much — "
"drop cpus, gpus and ram, or drop the flavor"
)
return self
class NodeDef(BaseModel):
"""A node as stored: identity, configuration and ports.