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

Admin-only: update editable node metadata. Pass any subset of display_name / labels. 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]:
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"]]

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

def events(self, node_id: str) -> list[ctfy.server.models.Activity]:
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()]

Activity rows where actor or subject is this node.

def list_invites( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.NodeInviteInfo]:
78    def list_invites(self, offset: int = 0, limit: int = 50) -> list[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)

Outstanding one-time worker-node bootstrap invites.

def mint_invite( self, ttl_seconds: int = 86400) -> ctfy.server.models.CreateInviteResponse:
86    def mint_invite(self, ttl_seconds: int = 24 * 3600) -> CreateInviteResponse:
87        """Mint a one-time node bootstrap token. A node redeems it once
88        to obtain its long-lived per-node bearer."""
89        resp = self._http.request("POST", "/nodes/invites", json={"ttl_seconds": ttl_seconds})
90        _raise_for_status(resp)
91        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.

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

Revoke an unredeemed node invite. Idempotent.