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
+180
View File
@@ -0,0 +1,180 @@
"""Named sizes: storing them, and what happens to a node that asks for one."""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from fluksio.core.config import settings
from fluksio.core.db import seed_flavors
from fluksio.models import Flavor
def test_an_installation_starts_with_sizes_to_pick_from(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/flavors/", headers=superuser_token_headers
)
assert response.status_code == 200
names = {row["name"] for row in response.json()["data"]}
assert {"small", "medium", "large", "gpu-small"} <= names
def test_seeding_leaves_an_installation_that_has_its_own_alone(db: Session) -> None:
"""Re-adding a size somebody deliberately removed is an argument to avoid."""
before = {row.name for row in db.exec(select(Flavor)).all()}
seed_flavors(db)
assert {row.name for row in db.exec(select(Flavor)).all()} == before
def test_a_size_can_be_added_changed_and_removed(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
created = client.post(
f"{settings.API_V1_STR}/flavors/",
headers=superuser_token_headers,
json={"name": "gpu-big", "cpus": 16, "gpus": 4, "ram": 65536},
)
assert created.status_code == 200
assert created.json()["gpus"] == 4
again = client.post(
f"{settings.API_V1_STR}/flavors/",
headers=superuser_token_headers,
json={"name": "gpu-big", "cpus": 1},
)
assert again.status_code == 409
changed = client.patch(
f"{settings.API_V1_STR}/flavors/gpu-big",
headers=superuser_token_headers,
json={"cpus": 32},
)
assert changed.status_code == 200
# Only what was sent: the rest of the size is untouched.
assert (changed.json()["cpus"], changed.json()["gpus"]) == (32, 4)
removed = client.delete(
f"{settings.API_V1_STR}/flavors/gpu-big", headers=superuser_token_headers
)
assert removed.status_code == 200
listed = client.get(
f"{settings.API_V1_STR}/flavors/", headers=superuser_token_headers
).json()
assert "gpu-big" not in {row["name"] for row in listed["data"]}
def test_changing_a_size_is_a_superusers(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
"""Reading is open — the node panel offers them — and writing is not."""
assert (
client.get(
f"{settings.API_V1_STR}/flavors/", headers=normal_user_token_headers
).status_code
== 200
)
refused = client.post(
f"{settings.API_V1_STR}/flavors/",
headers=normal_user_token_headers,
json={"name": "sneaky", "cpus": 99},
)
assert refused.status_code == 403
def test_a_name_nobody_could_type_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
refused = client.post(
f"{settings.API_V1_STR}/flavors/",
headers=superuser_token_headers,
json={"name": "GPU Big!", "cpus": 1},
)
assert refused.status_code == 422
def test_a_flow_cannot_name_a_size_that_does_not_exist(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Caught at the save, which covers the canvas and `fluksio sync` at once."""
response = client.put(
f"{settings.API_V1_STR}/flows/flavor_check",
headers=superuser_token_headers,
json={
"name": "flavor_check",
"version": 1,
"nodes": [
{"id": "fit", "type": "python", "resources": {"flavor": "imaginary"}}
],
},
)
assert response.status_code == 422
assert "imaginary" in str(response.json()["detail"])
def test_a_size_a_node_still_asks_for_is_not_deleted(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.post(
f"{settings.API_V1_STR}/flavors/",
headers=superuser_token_headers,
json={"name": "in-use", "cpus": 2},
)
client.put(
f"{settings.API_V1_STR}/flows/flavor_user",
headers=superuser_token_headers,
json={
"name": "flavor_user",
"version": 1,
"nodes": [
{"id": "fit", "type": "python", "resources": {"flavor": "in-use"}}
],
},
)
try:
refused = client.delete(
f"{settings.API_V1_STR}/flavors/in-use", headers=superuser_token_headers
)
assert refused.status_code == 409
assert "flavor_user.fit" in refused.json()["detail"]
finally:
client.delete(
f"{settings.API_V1_STR}/flows/flavor_user", headers=superuser_token_headers
)
client.delete(
f"{settings.API_V1_STR}/flavors/in-use", headers=superuser_token_headers
)
def test_a_named_size_resolves_to_the_numbers_stored_for_it(db: Session) -> None:
from fluksio.flow.resources import UnknownFlavor, resolve_flavor
from fluksio.flow.schemas import Resources
resolved = resolve_flavor(Resources(flavor="gpu-small", duration_s=60))
seeded = db.get(Flavor, "gpu-small")
assert (resolved.cpus, resolved.gpus, resolved.ram) == (
seeded.cpus,
seeded.gpus,
seeded.ram,
)
# What the node said about itself survives; the flavor only sizes it.
assert resolved.duration_s == 60
assert resolved.flavor is None
# Read at build time, so editing a flavor edits what the next run gets.
seeded.cpus = 12
db.add(seeded)
db.commit()
assert resolve_flavor(Resources(flavor="gpu-small")).cpus == 12
with pytest.raises(UnknownFlavor, match="does not exist"):
resolve_flavor(Resources(flavor="never-stored"))
def test_declaring_nothing_never_looks_anything_up() -> None:
"""The common node pays nothing for a feature it does not use."""
from fluksio.flow.resources import resolve_flavor
from fluksio.flow.schemas import Resources
wanted = Resources(cpus=2)
assert resolve_flavor(wanted) is wanted