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). Omit a field to leave unchanged. Wraps
 55        ``PATCH /admin/nodes/{id}``."""
 56        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
 57        _raise_for_status(resp)
 58        return NodeInfo.model_validate(resp.json())
 59
 60    def health_history(
 61        self, node_id: str, offset: int = 0, limit: int = 50
 62    ) -> list[NodeHealthSample]:
 63        """Recent heartbeat samples for one node — for the node detail page."""
 64        resp = self._http.request(
 65            "GET",
 66            f"/admin/nodes/{node_id}/health-history",
 67            params={"offset": offset, "limit": limit},
 68        )
 69        _raise_for_status(resp)
 70        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]
 71
 72    def events(self, node_id: str) -> list[Activity]:
 73        """Activity rows where ``actor`` or subject is this node."""
 74        resp = self._http.request("GET", f"/admin/nodes/{node_id}/events")
 75        _raise_for_status(resp)
 76        return [Activity.model_validate(a) for a in resp.json()]
 77
 78    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
 79        """Outstanding one-time worker-node bootstrap invites."""
 80        resp = self._http.request(
 81            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
 82        )
 83        _raise_for_status(resp)
 84        return _extract_items(resp.json(), NodeInviteInfo)
 85
 86    def mint_invite(
 87        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 88    ) -> CreateInviteResponse:
 89        """Mint a one-time node bootstrap token. A node redeems it once
 90        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 91        the node it registers to a vendor (a bring-your-own-node); "" mints a
 92        platform-shared node (the default)."""
 93        resp = self._http.request(
 94            "POST",
 95            "/nodes/invites",
 96            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
 97        )
 98        _raise_for_status(resp)
 99        return CreateInviteResponse.model_validate(resp.json())
100
101    def revoke_invite(self, invite_id: str) -> None:
102        """Revoke an unredeemed node invite. Idempotent."""
103        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
104        _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). Omit a 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) -> list[Activity]:
 74        """Activity rows where ``actor`` or subject is this node."""
 75        resp = self._http.request("GET", f"/admin/nodes/{node_id}/events")
 76        _raise_for_status(resp)
 77        return [Activity.model_validate(a) for a in resp.json()]
 78
 79    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
 80        """Outstanding one-time worker-node bootstrap invites."""
 81        resp = self._http.request(
 82            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
 83        )
 84        _raise_for_status(resp)
 85        return _extract_items(resp.json(), NodeInviteInfo)
 86
 87    def mint_invite(
 88        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 89    ) -> CreateInviteResponse:
 90        """Mint a one-time node bootstrap token. A node redeems it once
 91        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 92        the node it registers to a vendor (a bring-your-own-node); "" mints a
 93        platform-shared node (the default)."""
 94        resp = self._http.request(
 95            "POST",
 96            "/nodes/invites",
 97            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
 98        )
 99        _raise_for_status(resp)
100        return CreateInviteResponse.model_validate(resp.json())
101
102    def revoke_invite(self, invite_id: str) -> None:
103        """Revoke an unredeemed node invite. Idempotent."""
104        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
105        _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). Omit a 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())

Admin-only: update editable node metadata. Pass any subset of display_name / labels / owner_vendor_id (re-bind or clear the vendor ownership). 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]:
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"]]

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

def events(self, node_id: str) -> list[ctfy.server.models.Activity]:
73    def events(self, node_id: str) -> list[Activity]:
74        """Activity rows where ``actor`` or subject is this node."""
75        resp = self._http.request("GET", f"/admin/nodes/{node_id}/events")
76        _raise_for_status(resp)
77        return [Activity.model_validate(a) for a in resp.json()]

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]:
79    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
80        """Outstanding one-time worker-node bootstrap invites."""
81        resp = self._http.request(
82            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
83        )
84        _raise_for_status(resp)
85        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:
 87    def mint_invite(
 88        self, ttl_seconds: int = 24 * 3600, *, owner_vendor_id: str = ""
 89    ) -> CreateInviteResponse:
 90        """Mint a one-time node bootstrap token. A node redeems it once
 91        to obtain its long-lived per-node bearer. ``owner_vendor_id`` binds
 92        the node it registers to a vendor (a bring-your-own-node); "" mints a
 93        platform-shared node (the default)."""
 94        resp = self._http.request(
 95            "POST",
 96            "/nodes/invites",
 97            json={"ttl_seconds": ttl_seconds, "owner_vendor_id": owner_vendor_id},
 98        )
 99        _raise_for_status(resp)
100        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:
102    def revoke_invite(self, invite_id: str) -> None:
103        """Revoke an unredeemed node invite. Idempotent."""
104        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
105        _raise_for_status(resp)

Revoke an unredeemed node invite. Idempotent.