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