fix(models): Literal types on topology enum fields, hoist _MUTATION_OPS, top-level json import

MutationRow.op was str despite _MUTATION_OPS existing; Topology.mode/status,
TopologyDecky.state, TopologyMutation.op/state carried valid values only in
comments; deferred json import had no justification.

- Promote _MUTATION_OPS before table classes so table fields can reference it
- Add sa_column=Column(String) on each Literal-annotated table field to satisfy
  SQLModel 0.0.38 column-type inference
- Move import json to module top; remove deferred import inside _decode_json_payload
- MutationRow.op: str -> _MUTATION_OPS
This commit is contained in:
2026-04-30 23:17:24 -04:00
parent 3cb0203d07
commit 3456d3ab45

View File

@@ -1,14 +1,25 @@
"""MazeNET topology tables + the REST DTOs that wrap them.""" """MazeNET topology tables + the REST DTOs that wrap them."""
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Any, Literal, Optional from typing import Annotated, Any, Literal, Optional
from uuid import uuid4 from uuid import uuid4
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field as PydanticField from pydantic import BaseModel, BeforeValidator, ConfigDict, Field as PydanticField
from sqlalchemy import Column, Index, Text, UniqueConstraint from sqlalchemy import Column, Index, String, Text, UniqueConstraint
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from ._base import _BIG_TEXT from ._base import _BIG_TEXT
_MUTATION_OPS = Literal[
"add_lan",
"remove_lan",
"add_decky",
"attach_decky",
"detach_decky",
"remove_decky",
"update_decky",
"update_lan",
]
# --- MazeNET tables --- # --- MazeNET tables ---
# Nested deception topologies: an arbitrary-depth DAG of LANs connected by # Nested deception topologies: an arbitrary-depth DAG of LANs connected by
@@ -19,7 +30,9 @@ class Topology(SQLModel, table=True):
__tablename__ = "topologies" __tablename__ = "topologies"
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
mode: str = Field(default="unihost") # unihost|agent mode: Literal["unihost", "agent"] = Field(
default="unihost", sa_column=Column("mode", String, nullable=False, default="unihost")
)
# When ``mode == "agent"``, pins this topology to a specific enrolled # When ``mode == "agent"``, pins this topology to a specific enrolled
# worker. ``None`` for unihost topologies (master-local deploy). # worker. ``None`` for unihost topologies (master-local deploy).
target_host_uuid: Optional[str] = Field( target_host_uuid: Optional[str] = Field(
@@ -29,9 +42,12 @@ class Topology(SQLModel, table=True):
config_snapshot: str = Field( config_snapshot: str = Field(
sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}") sa_column=Column("config_snapshot", _BIG_TEXT, nullable=False, default="{}")
) )
status: str = Field( status: Literal[
default="pending", index=True "pending", "deploying", "active", "degraded", "failed", "tearing_down", "torn_down"
) # pending|deploying|active|degraded|failed|tearing_down|torn_down ] = Field(
default="pending",
sa_column=Column("status", String, nullable=False, default="pending", index=True),
)
status_changed_at: datetime = Field( status_changed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc) default_factory=lambda: datetime.now(timezone.utc)
) )
@@ -101,10 +117,12 @@ class TopologyDecky(SQLModel, table=True):
default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True)
) )
ip: Optional[str] = Field(default=None) ip: Optional[str] = Field(default=None)
# Same vocabulary as DeckyShard.state to keep dashboard rendering uniform. state: Literal[
state: str = Field( "pending", "running", "failed", "torn_down", "degraded", "tearing_down", "teardown_failed"
default="pending", index=True ] = Field(
) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed default="pending",
sa_column=Column("state", String, nullable=False, default="pending", index=True),
)
last_error: Optional[str] = Field( last_error: Optional[str] = Field(
default=None, sa_column=Column("last_error", Text, nullable=True) default=None, sa_column=Column("last_error", Text, nullable=True)
) )
@@ -168,15 +186,14 @@ class TopologyMutation(SQLModel, table=True):
) )
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
topology_id: str = Field(foreign_key="topologies.id", index=True) topology_id: str = Field(foreign_key="topologies.id", index=True)
# add_lan|remove_lan|add_decky|attach_decky|detach_decky| op: _MUTATION_OPS = Field(sa_column=Column("op", String, nullable=False, index=True))
# remove_decky|update_decky|update_lan
op: str = Field(index=True)
# JSON-serialised op payload (keys depend on ``op``).
payload: str = Field( payload: str = Field(
sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}") sa_column=Column("payload", _BIG_TEXT, nullable=False, default="{}")
) )
# pending|applying|applied|failed state: Literal["pending", "applying", "applied", "failed"] = Field(
state: str = Field(default="pending", index=True) default="pending",
sa_column=Column("state", String, nullable=False, default="pending", index=True),
)
requested_at: datetime = Field( requested_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), index=True default_factory=lambda: datetime.now(timezone.utc), index=True
) )
@@ -332,18 +349,6 @@ class EdgeCreateRequest(BaseModel):
expected_version: Optional[int] = None expected_version: Optional[int] = None
_MUTATION_OPS = Literal[
"add_lan",
"remove_lan",
"add_decky",
"attach_decky",
"detach_decky",
"remove_decky",
"update_decky",
"update_lan",
]
class MutationEnqueueRequest(BaseModel): class MutationEnqueueRequest(BaseModel):
op: _MUTATION_OPS op: _MUTATION_OPS
payload: dict[str, Any] = PydanticField(default_factory=dict) payload: dict[str, Any] = PydanticField(default_factory=dict)
@@ -353,8 +358,7 @@ class MutationEnqueueRequest(BaseModel):
def _decode_json_payload(v: Any) -> Any: def _decode_json_payload(v: Any) -> Any:
"""Accept either a dict or a JSON-encoded string for mutation payloads.""" """Accept either a dict or a JSON-encoded string for mutation payloads."""
if isinstance(v, str): if isinstance(v, str):
import json as _json return json.loads(v) if v else {}
return _json.loads(v) if v else {}
return v return v
@@ -365,7 +369,7 @@ class MutationRow(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str id: str
topology_id: str topology_id: str
op: str op: _MUTATION_OPS
payload: _MutationPayload = PydanticField(default_factory=dict) payload: _MutationPayload = PydanticField(default_factory=dict)
state: str state: str
requested_at: datetime requested_at: datetime