ctfy.sdk.admin_resources.nodes

client.admin.nodes — worker-node fleet management + bootstrap invites.

The non-admin /nodes registration routes are operator-only in practice (no player registers a worker node), so register / remove live here alongside the rest of the fleet tooling. The public node list is on ctfy.sdk.resources.nodes.NodesResource.list().

  1"""``client.admin.nodes`` — worker-node fleet management + bootstrap invites.
  2
  3The non-admin ``/nodes`` registration routes are operator-only in practice
  4(no player registers a worker node), so ``register`` / ``remove`` live here
  5alongside the rest of the fleet tooling. The public node *list* is on
  6:meth:`ctfy.sdk.resources.nodes.NodesResource.list`.
  7"""
  8
  9from __future__ import annotations
 10
 11from typing import Any
 12
 13from ctfy.core.state.models import NodeHealthSample
 14from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 15from ctfy.sdk.base import BaseHttpClient
 16from ctfy.server.models import Activity, CreateInviteResponse, NodeInfo, NodeInviteInfo
 17
 18
 19class AdminNodesResource:
 20    """Register / remove / patch worker nodes, read health + events, and
 21    mint the one-time bootstrap invites a fresh node redeems."""
 22
 23    def __init__(self, http: BaseHttpClient) -> None:
 24        self._http = http
 25
 26    def register(
 27        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
 28    ) -> NodeInfo:
 29        resp = self._http.request(
 30            "POST",
 31            "/nodes/register",
 32            json={
 33                "url": url,
 34                "capacity": capacity,
 35                "labels": labels or {},
 36            },
 37        )
 38        _raise_for_status(resp)
 39        return NodeInfo.model_validate(resp.json())
 40
 41    def remove(self, node_id: str) -> None:
 42        resp = self._http.request("DELETE", f"/nodes/{node_id}")
 43        _raise_for_status(resp)
 44
 45    def get(self, node_id: str) -> NodeInfo:
 46        """Admin-only single-node lookup (full health + capacity)."""
 47        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
 48        _raise_for_status(resp)
 49        return NodeInfo.model_validate(resp.json())
 50
 51    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
 52        """Admin-only: update editable node metadata. Pass any subset of
 53        ``display_name`` / ``labels`` / ``owner_vendor_id`` (re-bind or clear
 54        the vendor ownership) / ``cordoned`` (drain the node, #1900). Omit a
 55        field to leave unchanged. Wraps
 56        ``PATCH /admin/nodes/{id}``."""
 57        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
 58        _raise_for_status(resp)
 59        return NodeInfo.model_validate(resp.json())
 60
 61    def health_history(
 62        self, node_id: str, offset: int = 0, limit: int = 50
 63    ) -> list[NodeHealthSample]:
 64        """Recent heartbeat samples for one node — for the node detail page."""
 65        resp = self._http.request(
 66            "GET",
 67            f"/admin/nodes/{node_id}/health-history",
 68            params={"offset": offset, "limit": limit},
 69        )
 70        _raise_for_status(resp)
 71        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]
 72
 73    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
 74        """Activity rows where ``actor`` or subject is this node."""
 75        resp = self._http.request(
 76            "GET",
 77            f"/admin/nodes/{node_id}/events",
 78            params={"offset": offset, "limit": limit},
 79        )
 80        _raise_for_status(resp)
 81        return _extract_items(resp.json(), Activity)
 82
 83    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
 84        """Outstanding one-time worker-node bootstrap invites."""
 85        resp = self._http.request(
 86            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
 87        )
 88        _raise_for_status(resp)
 89        return _extract_items(resp.json(), NodeInviteInfo)
 90
 91    def mint_invite(
 92        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 93    ) -> CreateInviteResponse:
 94        """Mint a one-time node bootstrap token. A node redeems it once
 95        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 96        the node it registers to a vendor (a bring-your-own-node); "" mints a
 97        platform-shared node (the default)."""
 98        resp = self._http.request(
 99            "POST",
100            "/nodes/invites",
101            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
102        )
103        _raise_for_status(resp)
104        return CreateInviteResponse.model_validate(resp.json())
105
106    def revoke_invite(self, invite_id: str) -> None:
107        """Revoke an unredeemed node invite. Idempotent."""
108        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
109        _raise_for_status(resp)
class AdminNodesResource:
 20class AdminNodesResource:
 21    """Register / remove / patch worker nodes, read health + events, and
 22    mint the one-time bootstrap invites a fresh node redeems."""
 23
 24    def __init__(self, http: BaseHttpClient) -> None:
 25        self._http = http
 26
 27    def register(
 28        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
 29    ) -> NodeInfo:
 30        resp = self._http.request(
 31            "POST",
 32            "/nodes/register",
 33            json={
 34                "url": url,
 35                "capacity": capacity,
 36                "labels": labels or {},
 37            },
 38        )
 39        _raise_for_status(resp)
 40        return NodeInfo.model_validate(resp.json())
 41
 42    def remove(self, node_id: str) -> None:
 43        resp = self._http.request("DELETE", f"/nodes/{node_id}")
 44        _raise_for_status(resp)
 45
 46    def get(self, node_id: str) -> NodeInfo:
 47        """Admin-only single-node lookup (full health + capacity)."""
 48        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
 49        _raise_for_status(resp)
 50        return NodeInfo.model_validate(resp.json())
 51
 52    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
 53        """Admin-only: update editable node metadata. Pass any subset of
 54        ``display_name`` / ``labels`` / ``owner_vendor_id`` (re-bind or clear
 55        the vendor ownership) / ``cordoned`` (drain the node, #1900). Omit a
 56        field to leave unchanged. Wraps
 57        ``PATCH /admin/nodes/{id}``."""
 58        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
 59        _raise_for_status(resp)
 60        return NodeInfo.model_validate(resp.json())
 61
 62    def health_history(
 63        self, node_id: str, offset: int = 0, limit: int = 50
 64    ) -> list[NodeHealthSample]:
 65        """Recent heartbeat samples for one node — for the node detail page."""
 66        resp = self._http.request(
 67            "GET",
 68            f"/admin/nodes/{node_id}/health-history",
 69            params={"offset": offset, "limit": limit},
 70        )
 71        _raise_for_status(resp)
 72        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]
 73
 74    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
 75        """Activity rows where ``actor`` or subject is this node."""
 76        resp = self._http.request(
 77            "GET",
 78            f"/admin/nodes/{node_id}/events",
 79            params={"offset": offset, "limit": limit},
 80        )
 81        _raise_for_status(resp)
 82        return _extract_items(resp.json(), Activity)
 83
 84    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
 85        """Outstanding one-time worker-node bootstrap invites."""
 86        resp = self._http.request(
 87            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
 88        )
 89        _raise_for_status(resp)
 90        return _extract_items(resp.json(), NodeInviteInfo)
 91
 92    def mint_invite(
 93        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 94    ) -> CreateInviteResponse:
 95        """Mint a one-time node bootstrap token. A node redeems it once
 96        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 97        the node it registers to a vendor (a bring-your-own-node); "" mints a
 98        platform-shared node (the default)."""
 99        resp = self._http.request(
100            "POST",
101            "/nodes/invites",
102            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
103        )
104        _raise_for_status(resp)
105        return CreateInviteResponse.model_validate(resp.json())
106
107    def revoke_invite(self, invite_id: str) -> None:
108        """Revoke an unredeemed node invite. Idempotent."""
109        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
110        _raise_for_status(resp)

Register / remove / patch worker nodes, read health + events, and mint the one-time bootstrap invites a fresh node redeems.

AdminNodesResource(http: ctfy.sdk.base.BaseHttpClient)
24    def __init__(self, http: BaseHttpClient) -> None:
25        self._http = http
def register( self, url: str, capacity: int = 10, labels: dict[str, str] | None = None) -> ctfy.server.models.NodeInfo:
27    def register(
28        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
29    ) -> NodeInfo:
30        resp = self._http.request(
31            "POST",
32            "/nodes/register",
33            json={
34                "url": url,
35                "capacity": capacity,
36                "labels": labels or {},
37            },
38        )
39        _raise_for_status(resp)
40        return NodeInfo.model_validate(resp.json())
def remove(self, node_id: str) -> None:
42    def remove(self, node_id: str) -> None:
43        resp = self._http.request("DELETE", f"/nodes/{node_id}")
44        _raise_for_status(resp)
def get(self, node_id: str) -> ctfy.server.models.NodeInfo:
46    def get(self, node_id: str) -> NodeInfo:
47        """Admin-only single-node lookup (full health + capacity)."""
48        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
49        _raise_for_status(resp)
50        return NodeInfo.model_validate(resp.json())

Admin-only single-node lookup (full health + capacity).

def patch(self, node_id: str, **fields: Any) -> ctfy.server.models.NodeInfo:
52    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
53        """Admin-only: update editable node metadata. Pass any subset of
54        ``display_name`` / ``labels`` / ``owner_vendor_id`` (re-bind or clear
55        the vendor ownership) / ``cordoned`` (drain the node, #1900). Omit a
56        field to leave unchanged. Wraps
57        ``PATCH /admin/nodes/{id}``."""
58        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
59        _raise_for_status(resp)
60        return NodeInfo.model_validate(resp.json())

Admin-only: update editable node metadata. Pass any subset of display_name / labels / owner_vendor_id (re-bind or clear the vendor ownership) / cordoned (drain the node, #1900). Omit a field to leave unchanged. Wraps PATCH /admin/nodes/{id}.

def health_history( self, node_id: str, offset: int = 0, limit: int = 50) -> list[ctfy.core.state.models.NodeHealthSample]:
62    def health_history(
63        self, node_id: str, offset: int = 0, limit: int = 50
64    ) -> list[NodeHealthSample]:
65        """Recent heartbeat samples for one node — for the node detail page."""
66        resp = self._http.request(
67            "GET",
68            f"/admin/nodes/{node_id}/health-history",
69            params={"offset": offset, "limit": limit},
70        )
71        _raise_for_status(resp)
72        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]

Recent heartbeat samples for one node — for the node detail page.

def events( self, node_id: str, *, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.Activity]:
74    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
75        """Activity rows where ``actor`` or subject is this node."""
76        resp = self._http.request(
77            "GET",
78            f"/admin/nodes/{node_id}/events",
79            params={"offset": offset, "limit": limit},
80        )
81        _raise_for_status(resp)
82        return _extract_items(resp.json(), Activity)

Activity rows where actor or subject is this node.

def list_invites( self, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.NodeInviteInfo]:
84    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
85        """Outstanding one-time worker-node bootstrap invites."""
86        resp = self._http.request(
87            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
88        )
89        _raise_for_status(resp)
90        return _extract_items(resp.json(), NodeInviteInfo)

Outstanding one-time worker-node bootstrap invites.

def mint_invite( self, ttl_seconds: int = 86400, *, owner_vendor_id: str = '') -> ctfy.server.models.CreateInviteResponse:
 92    def mint_invite(
 93        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 94    ) -> CreateInviteResponse:
 95        """Mint a one-time node bootstrap token. A node redeems it once
 96        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 97        the node it registers to a vendor (a bring-your-own-node); "" mints a
 98        platform-shared node (the default)."""
 99        resp = self._http.request(
100            "POST",
101            "/nodes/invites",
102            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
103        )
104        _raise_for_status(resp)
105        return CreateInviteResponse.model_validate(resp.json())

Mint a one-time node bootstrap token. A node redeems it once to obtain its long-lived per-node bearer. owner_vendor_id binds the node it registers to a vendor (a bring-your-own-node); "" mints a platform-shared node (the default).

def revoke_invite(self, invite_id: str) -> None:
107    def revoke_invite(self, invite_id: str) -> None:
108        """Revoke an unredeemed node invite. Idempotent."""
109        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
110        _raise_for_status(resp)

Revoke an unredeemed node invite. Idempotent.