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)
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.
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())
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).
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}.
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.
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.
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.
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.