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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user