feat(canary): allow custom canaries on MazeNET deckies via API
POST /api/v1/canary/tokens grows an optional topology_id field. When present, the server hydrates the topology, validates the named decky is in it, and resolves the docker container via planter.resolve_topology_container — <name>-ssh if the decky exposes ssh, else the topology base container. Absent ⇒ fleet semantics, unchanged. The token row gets a nullable topology_id column (no migration helper per pre-v1 policy). GET /api/v1/canary/tokens accepts ?topology_id= as a filter. DELETE re-resolves the container at revoke time so a redeployed topology is still reachable. 422 when the named decky isn't in the topology; 404 when the topology itself doesn't exist.
This commit is contained in:
@@ -100,6 +100,12 @@ class CanaryToken(SQLModel, table=True):
|
||||
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
kind: str = Field(index=True) # CanaryKind literal at the API layer
|
||||
decky_name: str = Field(index=True) # FleetDecky.name; no FK (composite PK)
|
||||
# When NULL, the token is on a fleet decky (decky_name resolves to
|
||||
# ``<name>-ssh``). When set, it points at a MazeNET topology — the
|
||||
# planter resolves the container via :func:`resolve_topology_container`.
|
||||
# No FK: topologies are mutable and we don't want a row to vanish on
|
||||
# cascade; the row is the historical record of placement.
|
||||
topology_id: Optional[str] = Field(default=None, index=True)
|
||||
blob_uuid: Optional[str] = Field(
|
||||
default=None, foreign_key="canary_blobs.uuid", index=True,
|
||||
)
|
||||
@@ -188,6 +194,10 @@ class CanaryTokenCreateRequest(BaseModel):
|
||||
router so the 400 carries a clear detail message.
|
||||
"""
|
||||
decky_name: str = PydanticField(..., min_length=1)
|
||||
# When set, ``decky_name`` is interpreted as a MazeNET topology decky
|
||||
# name; the server validates membership and resolves the container
|
||||
# accordingly. Absent ⇒ fleet semantics (today's behavior).
|
||||
topology_id: Optional[str] = None
|
||||
kind: CanaryKind
|
||||
placement_path: str = PydanticField(..., min_length=1)
|
||||
blob_uuid: Optional[str] = None
|
||||
@@ -202,6 +212,7 @@ class CanaryTokenResponse(BaseModel):
|
||||
uuid: str
|
||||
kind: CanaryKind
|
||||
decky_name: str
|
||||
topology_id: Optional[str] = None
|
||||
blob_uuid: Optional[str]
|
||||
instrumenter: Optional[str]
|
||||
generator: Optional[str]
|
||||
|
||||
@@ -936,6 +936,7 @@ class BaseRepository(ABC):
|
||||
decky_name: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
topology_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class CanaryMixin:
|
||||
decky_name: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
topology_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with self._session() as session:
|
||||
stmt = select(CanaryToken)
|
||||
@@ -131,6 +132,8 @@ class CanaryMixin:
|
||||
stmt = stmt.where(CanaryToken.state == state)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(CanaryToken.kind == kind)
|
||||
if topology_id is not None:
|
||||
stmt = stmt.where(CanaryToken.topology_id == topology_id)
|
||||
stmt = stmt.order_by(desc(CanaryToken.placed_at))
|
||||
result = await session.execute(stmt)
|
||||
return [r.model_dump(mode="json") for r in result.scalars().all()]
|
||||
|
||||
Reference in New Issue
Block a user