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)
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.
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`` / ``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}.
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.
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.
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.
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).
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.