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:
@@ -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
|
||||
@@ -150,3 +150,36 @@ def test_declaring_nothing_stays_exactly_as_it_was():
|
||||
def test_a_misspelled_resource_is_refused():
|
||||
with pytest.raises(ValueError):
|
||||
Resources(cpu=4)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sizes with names, and sizes written out
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_size_can_be_written_the_way_people_write_sizes():
|
||||
assert Resources(ram="2G").ram == 2048
|
||||
assert Resources(ram="512M").ram == 512
|
||||
assert Resources(ram="512").ram == 512
|
||||
assert Resources(ram=512).ram == 512
|
||||
with pytest.raises(ValueError, match="not a size"):
|
||||
Resources(ram="a lot")
|
||||
|
||||
|
||||
def test_a_duration_is_one_unit_and_says_so_when_it_is_not():
|
||||
assert Resources(duration_s="2h").duration_s == 7200
|
||||
assert Resources(duration_s="30m").duration_s == 1800
|
||||
assert Resources(duration_s="90").duration_s == 90
|
||||
with pytest.raises(ValueError, match="not a duration"):
|
||||
Resources(duration_s="1h30m")
|
||||
|
||||
|
||||
def test_a_flavor_and_a_number_for_the_same_thing_is_two_answers():
|
||||
with pytest.raises(ValueError, match="already says how much"):
|
||||
Resources(flavor="gpu-small", cpus=4)
|
||||
|
||||
# The defaults are not an answer: an editor writing the whole object back
|
||||
# sends them, and that must survive the round trip.
|
||||
assert Resources(flavor="gpu-small", cpus=1, gpus=0).flavor == "gpu-small"
|
||||
# Neither is a duration, which a flavor says nothing about.
|
||||
assert Resources(flavor="gpu-small", duration_s="2h").duration_s == 7200
|
||||
|
||||
Reference in New Issue
Block a user