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.node_join import NodeProfile
 14from ctfy.core.state.models import NodeHealthSample
 15from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 16from ctfy.sdk.base import BaseHttpClient
 17from ctfy.server.models import (
 18    Activity,
 19    CreateInviteResponse,
 20    CreateJoinCommandResponse,
 21    FleetReachAck,
 22    FleetReachAckCleared,
 23    FleetReachAckResult,
 24    FleetReachManifest,
 25    FleetReachToken,
 26    NodeAddress,
 27    NodeFeatures,
 28    NodeInfo,
 29    NodeInviteInfo,
 30)
 31
 32
 33class AdminNodesResource:
 34    """Register / remove / patch worker nodes, read health + events, and
 35    mint the one-time bootstrap invites a fresh node redeems."""
 36
 37    def __init__(self, http: BaseHttpClient) -> None:
 38        self._http = http
 39
 40    def register(
 41        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
 42    ) -> NodeInfo:
 43        resp = self._http.request(
 44            "POST",
 45            "/nodes/register",
 46            json={
 47                "url": url,
 48                "capacity": capacity,
 49                "labels": labels or {},
 50            },
 51        )
 52        _raise_for_status(resp)
 53        return NodeInfo.model_validate(resp.json())
 54
 55    def remove(self, node_id: str) -> None:
 56        resp = self._http.request("DELETE", f"/nodes/{node_id}")
 57        _raise_for_status(resp)
 58
 59    def get(self, node_id: str) -> NodeInfo:
 60        """Admin-only single-node lookup (full health + capacity)."""
 61        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
 62        _raise_for_status(resp)
 63        return NodeInfo.model_validate(resp.json())
 64
 65    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
 66        """Admin-only: update editable node metadata. Pass any subset of
 67        ``display_name`` / ``labels`` / ``cordoned`` (drain the node, #1900). Omit a
 68        field to leave unchanged. Wraps
 69        ``PATCH /admin/nodes/{id}``."""
 70        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
 71        _raise_for_status(resp)
 72        return NodeInfo.model_validate(resp.json())
 73
 74    def approve(
 75        self,
 76        node_id: str,
 77        profile: NodeProfile = NodeProfile.NORMAL,
 78        *,
 79        competition_id: str = "",
 80        disabled_features: list[str] | None = None,
 81        url: str = "",
 82    ) -> NodeInfo:
 83        """Admin-only: approve a pending node into *profile*'s pool (#3037),
 84        with *disabled_features* switched off, and its address corrected to
 85        *url* when given (held from then on). Wraps
 86        ``POST /admin/nodes/{id}/approve``."""
 87        resp = self._http.request(
 88            "POST",
 89            f"/admin/nodes/{node_id}/approve",
 90            json={
 91                "profile": str(profile),
 92                "competition_id": competition_id,
 93                "disabled_features": disabled_features or [],
 94                "url": url,
 95            },
 96        )
 97        _raise_for_status(resp)
 98        return NodeInfo.model_validate(resp.json())
 99
100    def features(self, node_id: str) -> NodeFeatures:
101        """Admin-only: per feature, what the node supports and whether the admin
102        enabled it (#3037). Wraps ``GET /admin/nodes/{id}/features``; switch one
103        with ``patch(node_id, disabled_features=[...])``."""
104        resp = self._http.request("GET", f"/admin/nodes/{node_id}/features")
105        _raise_for_status(resp)
106        return NodeFeatures.model_validate(resp.json())
107
108    def fleet_reach(self) -> FleetReachManifest:
109        """Admin-only: what an agent on one node must reach on every other,
110        derived from the node rows (#3428). Wraps ``GET /admin/fleet-reach``;
111        the cb host renders its VM firewall accepts from the same body."""
112        resp = self._http.request("GET", "/admin/fleet-reach")
113        _raise_for_status(resp)
114        return FleetReachManifest.model_validate(resp.json())
115
116    def ack_fleet_reach(self, ack: FleetReachAck) -> FleetReachAckResult:
117        """Record that a renderer applied *ack*'s manifest for its agent pools,
118        which placement then derives reach from (#3428). The cb timer calls
119        this after a converged apply. Wraps ``POST /admin/fleet-reach/ack``."""
120        resp = self._http.request(
121            "POST", "/admin/fleet-reach/ack", json=ack.model_dump(mode="json")
122        )
123        _raise_for_status(resp)
124        return FleetReachAckResult.model_validate(resp.json())
125
126    def clear_fleet_reach_ack(self, pool: str = "") -> FleetReachAckCleared:
127        """Forget the stored ack of *pool*, or of every pool when ``""``
128        (#3428 rollback): placement then reads it as unproven (fails open)
129        until the renderer acks again. Admin only. Wraps
130        ``DELETE /admin/fleet-reach/ack``."""
131        params = {"pool": pool} if pool else None
132        resp = self._http.request("DELETE", "/admin/fleet-reach/ack", params=params)
133        _raise_for_status(resp)
134        return FleetReachAckCleared.model_validate(resp.json())
135
136    def mint_fleet_reach_token(self) -> FleetReachToken:
137        """Mint the cb renderer's bearer, scoped to the fleet reach routes
138        (#3428). Shown once; replaces (voids) any previous one. Wraps
139        ``POST /admin/fleet-reach/token``."""
140        resp = self._http.request("POST", "/admin/fleet-reach/token")
141        _raise_for_status(resp)
142        return FleetReachToken.model_validate(resp.json())
143
144    def revoke_fleet_reach_token(self) -> None:
145        """Void the cb renderer's bearer. Idempotent. Wraps
146        ``DELETE /admin/fleet-reach/token``."""
147        resp = self._http.request("DELETE", "/admin/fleet-reach/token")
148        _raise_for_status(resp)
149
150    def address(self, node_id: str, *, check: bool = False) -> NodeAddress:
151        """Admin-only: the node's claimed address, the source address the
152        platform saw and its UDP readings (#3037). *check* probes the UDP
153        surfaces first. Wraps ``GET /admin/nodes/{id}/address`` and
154        ``POST /admin/nodes/{id}/address/check``."""
155        if check:
156            resp = self._http.request("POST", f"/admin/nodes/{node_id}/address/check")
157        else:
158            resp = self._http.request("GET", f"/admin/nodes/{node_id}/address")
159        _raise_for_status(resp)
160        return NodeAddress.model_validate(resp.json())
161
162    def reject(self, node_id: str) -> None:
163        """Admin-only: reject a pending node — its row and token are removed
164        (#3037). Wraps ``POST /admin/nodes/{id}/reject``."""
165        resp = self._http.request("POST", f"/admin/nodes/{node_id}/reject")
166        _raise_for_status(resp)
167
168    def health_history(
169        self, node_id: str, offset: int = 0, limit: int = 50
170    ) -> list[NodeHealthSample]:
171        """Recent heartbeat samples for one node — for the node detail page."""
172        resp = self._http.request(
173            "GET",
174            f"/admin/nodes/{node_id}/health-history",
175            params={"offset": offset, "limit": limit},
176        )
177        _raise_for_status(resp)
178        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]
179
180    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
181        """Activity rows where ``actor`` or subject is this node."""
182        resp = self._http.request(
183            "GET",
184            f"/admin/nodes/{node_id}/events",
185            params={"offset": offset, "limit": limit},
186        )
187        _raise_for_status(resp)
188        return _extract_items(resp.json(), Activity)
189
190    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
191        """Outstanding one-time worker-node bootstrap invites."""
192        resp = self._http.request(
193            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
194        )
195        _raise_for_status(resp)
196        return _extract_items(resp.json(), NodeInviteInfo)
197
198    def mint_invite(self, ttl_seconds: int = 24 * 3600) -> CreateInviteResponse:
199        """Mint a one-time node bootstrap token. A node redeems it once
200        to obtain its long-lived per-node bearer."""
201        resp = self._http.request("POST", "/nodes/invites", json={"ttl_seconds": ttl_seconds})
202        _raise_for_status(resp)
203        return CreateInviteResponse.model_validate(resp.json())
204
205    def mint_join_command(
206        self,
207        profile: NodeProfile = NodeProfile.NORMAL,
208        *,
209        competition_id: str = "",
210        display_name: str = "",
211        capacity: int = 0,
212        ttl_seconds: int = 3600,
213    ) -> CreateJoinCommandResponse:
214        """Mint a one-time invite wrapped as the single command a bare machine
215        runs (#2115). The plaintext rides the returned ``command`` / ``join_code``
216        once and is never listed again. Wraps ``POST /nodes/join-commands``."""
217        resp = self._http.request(
218            "POST",
219            "/nodes/join-commands",
220            json={
221                "profile": str(profile),
222                "competition_id": competition_id,
223                "display_name": display_name,
224                "capacity": capacity,
225                "ttl_seconds": ttl_seconds,
226            },
227        )
228        _raise_for_status(resp)
229        return CreateJoinCommandResponse.model_validate(resp.json())
230
231    def revoke_invite(self, invite_id: str) -> None:
232        """Revoke an unredeemed node invite. Idempotent."""
233        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
234        _raise_for_status(resp)
class AdminNodesResource:
 34class AdminNodesResource:
 35    """Register / remove / patch worker nodes, read health + events, and
 36    mint the one-time bootstrap invites a fresh node redeems."""
 37
 38    def __init__(self, http: BaseHttpClient) -> None:
 39        self._http = http
 40
 41    def register(
 42        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
 43    ) -> NodeInfo:
 44        resp = self._http.request(
 45            "POST",
 46            "/nodes/register",
 47            json={
 48                "url": url,
 49                "capacity": capacity,
 50                "labels": labels or {},
 51            },
 52        )
 53        _raise_for_status(resp)
 54        return NodeInfo.model_validate(resp.json())
 55
 56    def remove(self, node_id: str) -> None:
 57        resp = self._http.request("DELETE", f"/nodes/{node_id}")
 58        _raise_for_status(resp)
 59
 60    def get(self, node_id: str) -> NodeInfo:
 61        """Admin-only single-node lookup (full health + capacity)."""
 62        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
 63        _raise_for_status(resp)
 64        return NodeInfo.model_validate(resp.json())
 65
 66    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
 67        """Admin-only: update editable node metadata. Pass any subset of
 68        ``display_name`` / ``labels`` / ``cordoned`` (drain the node, #1900). Omit a
 69        field to leave unchanged. Wraps
 70        ``PATCH /admin/nodes/{id}``."""
 71        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
 72        _raise_for_status(resp)
 73        return NodeInfo.model_validate(resp.json())
 74
 75    def approve(
 76        self,
 77        node_id: str,
 78        profile: NodeProfile = NodeProfile.NORMAL,
 79        *,
 80        competition_id: str = "",
 81        disabled_features: list[str] | None = None,
 82        url: str = "",
 83    ) -> NodeInfo:
 84        """Admin-only: approve a pending node into *profile*'s pool (#3037),
 85        with *disabled_features* switched off, and its address corrected to
 86        *url* when given (held from then on). Wraps
 87        ``POST /admin/nodes/{id}/approve``."""
 88        resp = self._http.request(
 89            "POST",
 90            f"/admin/nodes/{node_id}/approve",
 91            json={
 92                "profile": str(profile),
 93                "competition_id": competition_id,
 94                "disabled_features": disabled_features or [],
 95                "url": url,
 96            },
 97        )
 98        _raise_for_status(resp)
 99        return NodeInfo.model_validate(resp.json())
100
101    def features(self, node_id: str) -> NodeFeatures:
102        """Admin-only: per feature, what the node supports and whether the admin
103        enabled it (#3037). Wraps ``GET /admin/nodes/{id}/features``; switch one
104        with ``patch(node_id, disabled_features=[...])``."""
105        resp = self._http.request("GET", f"/admin/nodes/{node_id}/features")
106        _raise_for_status(resp)
107        return NodeFeatures.model_validate(resp.json())
108
109    def fleet_reach(self) -> FleetReachManifest:
110        """Admin-only: what an agent on one node must reach on every other,
111        derived from the node rows (#3428). Wraps ``GET /admin/fleet-reach``;
112        the cb host renders its VM firewall accepts from the same body."""
113        resp = self._http.request("GET", "/admin/fleet-reach")
114        _raise_for_status(resp)
115        return FleetReachManifest.model_validate(resp.json())
116
117    def ack_fleet_reach(self, ack: FleetReachAck) -> FleetReachAckResult:
118        """Record that a renderer applied *ack*'s manifest for its agent pools,
119        which placement then derives reach from (#3428). The cb timer calls
120        this after a converged apply. Wraps ``POST /admin/fleet-reach/ack``."""
121        resp = self._http.request(
122            "POST", "/admin/fleet-reach/ack", json=ack.model_dump(mode="json")
123        )
124        _raise_for_status(resp)
125        return FleetReachAckResult.model_validate(resp.json())
126
127    def clear_fleet_reach_ack(self, pool: str = "") -> FleetReachAckCleared:
128        """Forget the stored ack of *pool*, or of every pool when ``""``
129        (#3428 rollback): placement then reads it as unproven (fails open)
130        until the renderer acks again. Admin only. Wraps
131        ``DELETE /admin/fleet-reach/ack``."""
132        params = {"pool": pool} if pool else None
133        resp = self._http.request("DELETE", "/admin/fleet-reach/ack", params=params)
134        _raise_for_status(resp)
135        return FleetReachAckCleared.model_validate(resp.json())
136
137    def mint_fleet_reach_token(self) -> FleetReachToken:
138        """Mint the cb renderer's bearer, scoped to the fleet reach routes
139        (#3428). Shown once; replaces (voids) any previous one. Wraps
140        ``POST /admin/fleet-reach/token``."""
141        resp = self._http.request("POST", "/admin/fleet-reach/token")
142        _raise_for_status(resp)
143        return FleetReachToken.model_validate(resp.json())
144
145    def revoke_fleet_reach_token(self) -> None:
146        """Void the cb renderer's bearer. Idempotent. Wraps
147        ``DELETE /admin/fleet-reach/token``."""
148        resp = self._http.request("DELETE", "/admin/fleet-reach/token")
149        _raise_for_status(resp)
150
151    def address(self, node_id: str, *, check: bool = False) -> NodeAddress:
152        """Admin-only: the node's claimed address, the source address the
153        platform saw and its UDP readings (#3037). *check* probes the UDP
154        surfaces first. Wraps ``GET /admin/nodes/{id}/address`` and
155        ``POST /admin/nodes/{id}/address/check``."""
156        if check:
157            resp = self._http.request("POST", f"/admin/nodes/{node_id}/address/check")
158        else:
159            resp = self._http.request("GET", f"/admin/nodes/{node_id}/address")
160        _raise_for_status(resp)
161        return NodeAddress.model_validate(resp.json())
162
163    def reject(self, node_id: str) -> None:
164        """Admin-only: reject a pending node — its row and token are removed
165        (#3037). Wraps ``POST /admin/nodes/{id}/reject``."""
166        resp = self._http.request("POST", f"/admin/nodes/{node_id}/reject")
167        _raise_for_status(resp)
168
169    def health_history(
170        self, node_id: str, offset: int = 0, limit: int = 50
171    ) -> list[NodeHealthSample]:
172        """Recent heartbeat samples for one node — for the node detail page."""
173        resp = self._http.request(
174            "GET",
175            f"/admin/nodes/{node_id}/health-history",
176            params={"offset": offset, "limit": limit},
177        )
178        _raise_for_status(resp)
179        return [NodeHealthSample.model_validate(s) for s in resp.json()["items"]]
180
181    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
182        """Activity rows where ``actor`` or subject is this node."""
183        resp = self._http.request(
184            "GET",
185            f"/admin/nodes/{node_id}/events",
186            params={"offset": offset, "limit": limit},
187        )
188        _raise_for_status(resp)
189        return _extract_items(resp.json(), Activity)
190
191    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
192        """Outstanding one-time worker-node bootstrap invites."""
193        resp = self._http.request(
194            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
195        )
196        _raise_for_status(resp)
197        return _extract_items(resp.json(), NodeInviteInfo)
198
199    def mint_invite(self, ttl_seconds: int = 24 * 3600) -> CreateInviteResponse:
200        """Mint a one-time node bootstrap token. A node redeems it once
201        to obtain its long-lived per-node bearer."""
202        resp = self._http.request("POST", "/nodes/invites", json={"ttl_seconds": ttl_seconds})
203        _raise_for_status(resp)
204        return CreateInviteResponse.model_validate(resp.json())
205
206    def mint_join_command(
207        self,
208        profile: NodeProfile = NodeProfile.NORMAL,
209        *,
210        competition_id: str = "",
211        display_name: str = "",
212        capacity: int = 0,
213        ttl_seconds: int = 3600,
214    ) -> CreateJoinCommandResponse:
215        """Mint a one-time invite wrapped as the single command a bare machine
216        runs (#2115). The plaintext rides the returned ``command`` / ``join_code``
217        once and is never listed again. Wraps ``POST /nodes/join-commands``."""
218        resp = self._http.request(
219            "POST",
220            "/nodes/join-commands",
221            json={
222                "profile": str(profile),
223                "competition_id": competition_id,
224                "display_name": display_name,
225                "capacity": capacity,
226                "ttl_seconds": ttl_seconds,
227            },
228        )
229        _raise_for_status(resp)
230        return CreateJoinCommandResponse.model_validate(resp.json())
231
232    def revoke_invite(self, invite_id: str) -> None:
233        """Revoke an unredeemed node invite. Idempotent."""
234        resp = self._http.request("DELETE", f"/nodes/invites/{invite_id}")
235        _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)
38    def __init__(self, http: BaseHttpClient) -> None:
39        self._http = http
def register( self, url: str, capacity: int = 10, labels: dict[str, str] | None = None) -> ctfy.server.models.NodeInfo:
41    def register(
42        self, url: str, capacity: int = 10, labels: dict[str, str] | None = None
43    ) -> NodeInfo:
44        resp = self._http.request(
45            "POST",
46            "/nodes/register",
47            json={
48                "url": url,
49                "capacity": capacity,
50                "labels": labels or {},
51            },
52        )
53        _raise_for_status(resp)
54        return NodeInfo.model_validate(resp.json())
def remove(self, node_id: str) -> None:
56    def remove(self, node_id: str) -> None:
57        resp = self._http.request("DELETE", f"/nodes/{node_id}")
58        _raise_for_status(resp)
def get(self, node_id: str) -> ctfy.server.models.NodeInfo:
60    def get(self, node_id: str) -> NodeInfo:
61        """Admin-only single-node lookup (full health + capacity)."""
62        resp = self._http.request("GET", f"/admin/nodes/{node_id}")
63        _raise_for_status(resp)
64        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:
66    def patch(self, node_id: str, **fields: Any) -> NodeInfo:
67        """Admin-only: update editable node metadata. Pass any subset of
68        ``display_name`` / ``labels`` / ``cordoned`` (drain the node, #1900). Omit a
69        field to leave unchanged. Wraps
70        ``PATCH /admin/nodes/{id}``."""
71        resp = self._http.request("PATCH", f"/admin/nodes/{node_id}", json=fields)
72        _raise_for_status(resp)
73        return NodeInfo.model_validate(resp.json())

Admin-only: update editable node metadata. Pass any subset of display_name / labels / cordoned (drain the node, #1900). Omit a field to leave unchanged. Wraps PATCH /admin/nodes/{id}.

def approve( self, node_id: str, profile: ctfy.core.node_join.NodeProfile = <NodeProfile.NORMAL: 'normal'>, *, competition_id: str = '', disabled_features: list[str] | None = None, url: str = '') -> ctfy.server.models.NodeInfo:
75    def approve(
76        self,
77        node_id: str,
78        profile: NodeProfile = NodeProfile.NORMAL,
79        *,
80        competition_id: str = "",
81        disabled_features: list[str] | None = None,
82        url: str = "",
83    ) -> NodeInfo:
84        """Admin-only: approve a pending node into *profile*'s pool (#3037),
85        with *disabled_features* switched off, and its address corrected to
86        *url* when given (held from then on). Wraps
87        ``POST /admin/nodes/{id}/approve``."""
88        resp = self._http.request(
89            "POST",
90            f"/admin/nodes/{node_id}/approve",
91            json={
92                "profile": str(profile),
93                "competition_id": competition_id,
94                "disabled_features": disabled_features or [],
95                "url": url,
96            },
97        )
98        _raise_for_status(resp)
99        return NodeInfo.model_validate(resp.json())

Admin-only: approve a pending node into profile's pool (#3037), with disabled_features switched off, and its address corrected to url when given (held from then on). Wraps POST /admin/nodes/{id}/approve.

def features(self, node_id: str) -> ctfy.server.models.NodeFeatures:
101    def features(self, node_id: str) -> NodeFeatures:
102        """Admin-only: per feature, what the node supports and whether the admin
103        enabled it (#3037). Wraps ``GET /admin/nodes/{id}/features``; switch one
104        with ``patch(node_id, disabled_features=[...])``."""
105        resp = self._http.request("GET", f"/admin/nodes/{node_id}/features")
106        _raise_for_status(resp)
107        return NodeFeatures.model_validate(resp.json())

Admin-only: per feature, what the node supports and whether the admin enabled it (#3037). Wraps GET /admin/nodes/{id}/features; switch one with patch(node_id, disabled_features=[...]).

def fleet_reach(self) -> ctfy.server.models.FleetReachManifest:
109    def fleet_reach(self) -> FleetReachManifest:
110        """Admin-only: what an agent on one node must reach on every other,
111        derived from the node rows (#3428). Wraps ``GET /admin/fleet-reach``;
112        the cb host renders its VM firewall accepts from the same body."""
113        resp = self._http.request("GET", "/admin/fleet-reach")
114        _raise_for_status(resp)
115        return FleetReachManifest.model_validate(resp.json())

Admin-only: what an agent on one node must reach on every other, derived from the node rows (#3428). Wraps GET /admin/fleet-reach; the cb host renders its VM firewall accepts from the same body.

def ack_fleet_reach( self, ack: ctfy.server.models.FleetReachAck) -> ctfy.server.models.FleetReachAckResult:
117    def ack_fleet_reach(self, ack: FleetReachAck) -> FleetReachAckResult:
118        """Record that a renderer applied *ack*'s manifest for its agent pools,
119        which placement then derives reach from (#3428). The cb timer calls
120        this after a converged apply. Wraps ``POST /admin/fleet-reach/ack``."""
121        resp = self._http.request(
122            "POST", "/admin/fleet-reach/ack", json=ack.model_dump(mode="json")
123        )
124        _raise_for_status(resp)
125        return FleetReachAckResult.model_validate(resp.json())

Record that a renderer applied ack's manifest for its agent pools, which placement then derives reach from (#3428). The cb timer calls this after a converged apply. Wraps POST /admin/fleet-reach/ack.

def clear_fleet_reach_ack(self, pool: str = '') -> ctfy.server.models.FleetReachAckCleared:
127    def clear_fleet_reach_ack(self, pool: str = "") -> FleetReachAckCleared:
128        """Forget the stored ack of *pool*, or of every pool when ``""``
129        (#3428 rollback): placement then reads it as unproven (fails open)
130        until the renderer acks again. Admin only. Wraps
131        ``DELETE /admin/fleet-reach/ack``."""
132        params = {"pool": pool} if pool else None
133        resp = self._http.request("DELETE", "/admin/fleet-reach/ack", params=params)
134        _raise_for_status(resp)
135        return FleetReachAckCleared.model_validate(resp.json())

Forget the stored ack of pool, or of every pool when "" (#3428 rollback): placement then reads it as unproven (fails open) until the renderer acks again. Admin only. Wraps DELETE /admin/fleet-reach/ack.

def mint_fleet_reach_token(self) -> ctfy.server.models.FleetReachToken:
137    def mint_fleet_reach_token(self) -> FleetReachToken:
138        """Mint the cb renderer's bearer, scoped to the fleet reach routes
139        (#3428). Shown once; replaces (voids) any previous one. Wraps
140        ``POST /admin/fleet-reach/token``."""
141        resp = self._http.request("POST", "/admin/fleet-reach/token")
142        _raise_for_status(resp)
143        return FleetReachToken.model_validate(resp.json())

Mint the cb renderer's bearer, scoped to the fleet reach routes (#3428). Shown once; replaces (voids) any previous one. Wraps POST /admin/fleet-reach/token.

def revoke_fleet_reach_token(self) -> None:
145    def revoke_fleet_reach_token(self) -> None:
146        """Void the cb renderer's bearer. Idempotent. Wraps
147        ``DELETE /admin/fleet-reach/token``."""
148        resp = self._http.request("DELETE", "/admin/fleet-reach/token")
149        _raise_for_status(resp)

Void the cb renderer's bearer. Idempotent. Wraps DELETE /admin/fleet-reach/token.

def address( self, node_id: str, *, check: bool = False) -> ctfy.server.models.NodeAddress:
151    def address(self, node_id: str, *, check: bool = False) -> NodeAddress:
152        """Admin-only: the node's claimed address, the source address the
153        platform saw and its UDP readings (#3037). *check* probes the UDP
154        surfaces first. Wraps ``GET /admin/nodes/{id}/address`` and
155        ``POST /admin/nodes/{id}/address/check``."""
156        if check:
157            resp = self._http.request("POST", f"/admin/nodes/{node_id}/address/check")
158        else:
159            resp = self._http.request("GET", f"/admin/nodes/{node_id}/address")
160        _raise_for_status(resp)
161        return NodeAddress.model_validate(resp.json())

Admin-only: the node's claimed address, the source address the platform saw and its UDP readings (#3037). check probes the UDP surfaces first. Wraps GET /admin/nodes/{id}/address and POST /admin/nodes/{id}/address/check.

def reject(self, node_id: str) -> None:
163    def reject(self, node_id: str) -> None:
164        """Admin-only: reject a pending node — its row and token are removed
165        (#3037). Wraps ``POST /admin/nodes/{id}/reject``."""
166        resp = self._http.request("POST", f"/admin/nodes/{node_id}/reject")
167        _raise_for_status(resp)

Admin-only: reject a pending node — its row and token are removed (#3037). Wraps POST /admin/nodes/{id}/reject.

def health_history( self, node_id: str, offset: int = 0, limit: int = 50) -> list[ctfy.core.state.models.NodeHealthSample]:
169    def health_history(
170        self, node_id: str, offset: int = 0, limit: int = 50
171    ) -> list[NodeHealthSample]:
172        """Recent heartbeat samples for one node — for the node detail page."""
173        resp = self._http.request(
174            "GET",
175            f"/admin/nodes/{node_id}/health-history",
176            params={"offset": offset, "limit": limit},
177        )
178        _raise_for_status(resp)
179        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, *, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.Activity]:
181    def events(self, node_id: str, *, offset: int = 0, limit: int = 50) -> PagedList[Activity]:
182        """Activity rows where ``actor`` or subject is this node."""
183        resp = self._http.request(
184            "GET",
185            f"/admin/nodes/{node_id}/events",
186            params={"offset": offset, "limit": limit},
187        )
188        _raise_for_status(resp)
189        return _extract_items(resp.json(), Activity)

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]:
191    def list_invites(self, offset: int = 0, limit: int = 50) -> PagedList[NodeInviteInfo]:
192        """Outstanding one-time worker-node bootstrap invites."""
193        resp = self._http.request(
194            "GET", "/nodes/invites", params={"offset": offset, "limit": limit}
195        )
196        _raise_for_status(resp)
197        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:
199    def mint_invite(self, ttl_seconds: int = 24 * 3600) -> CreateInviteResponse:
200        """Mint a one-time node bootstrap token. A node redeems it once
201        to obtain its long-lived per-node bearer."""
202        resp = self._http.request("POST", "/nodes/invites", json={"ttl_seconds": ttl_seconds})
203        _raise_for_status(resp)
204        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 mint_join_command( self, profile: ctfy.core.node_join.NodeProfile = <NodeProfile.NORMAL: 'normal'>, *, competition_id: str = '', display_name: str = '', capacity: int = 0, ttl_seconds: int = 3600) -> ctfy.server.models.CreateJoinCommandResponse:
206    def mint_join_command(
207        self,
208        profile: NodeProfile = NodeProfile.NORMAL,
209        *,
210        competition_id: str = "",
211        display_name: str = "",
212        capacity: int = 0,
213        ttl_seconds: int = 3600,
214    ) -> CreateJoinCommandResponse:
215        """Mint a one-time invite wrapped as the single command a bare machine
216        runs (#2115). The plaintext rides the returned ``command`` / ``join_code``
217        once and is never listed again. Wraps ``POST /nodes/join-commands``."""
218        resp = self._http.request(
219            "POST",
220            "/nodes/join-commands",
221            json={
222                "profile": str(profile),
223                "competition_id": competition_id,
224                "display_name": display_name,
225                "capacity": capacity,
226                "ttl_seconds": ttl_seconds,
227            },
228        )
229        _raise_for_status(resp)
230        return CreateJoinCommandResponse.model_validate(resp.json())

Mint a one-time invite wrapped as the single command a bare machine runs (#2115). The plaintext rides the returned command / join_code once and is never listed again. Wraps POST /nodes/join-commands.

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

Revoke an unredeemed node invite. Idempotent.