Follows the portal: the noun is "instance" everywhere the app says it — UI strings, CLI output, error details, docs and comments. The wire keys (`instance_id`, `instance_token`) and the hub route this calls move with it. An existing cloud.json is adopted rather than refused: without the key alias the dataclass fails to parse, which the caller swallows and reads as "never enrolled" instead of "reconnect". `instance_key` on a node type becomes `target_key`. It means the outside thing a node points at, which is a different sense of the word, and keeping both would put two meanings of "instance" in one codebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
181 lines
6.0 KiB
Python
181 lines
6.0 KiB
Python
"""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_instance_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_instance_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
|