ctfy.sdk.admin_resources.instances
client.admin.instances — live cluster-wide instance fleet (admin).
1"""``client.admin.instances`` — live cluster-wide instance fleet (admin).""" 2 3from __future__ import annotations 4 5import builtins 6from typing import Any 7 8from ctfy.core.enums import InternetAccess 9from ctfy.core.state.models import TimingGroup 10from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 11from ctfy.sdk.base import BaseHttpClient 12from ctfy.server.models import ( 13 AdminInstanceTimings, 14 InstanceInfo, 15 InstanceInternetRequest, 16 InstanceInternetResult, 17 InstanceStorageReport, 18) 19 20 21class AdminInstancesResource: 22 """Every live instance cluster-wide, with forensic pcap / traffic reads. 23 Archived (terminal) records live on :class:`AdminRecordsResource`.""" 24 25 def __init__(self, http: BaseHttpClient) -> None: 26 self._http = http 27 28 def stop_all(self) -> builtins.list[dict[str, Any]]: 29 """Cluster-wide tear-down: stop every running instance. Used for 30 maintenance windows. Returns one :class:`StopResponse` row per 31 instance that was stopped.""" 32 resp = self._http.request("DELETE", "/instances") 33 _raise_for_status(resp) 34 rows: builtins.list[dict[str, Any]] = resp.json() 35 return rows 36 37 def stop(self, instance_id: str) -> dict[str, Any]: 38 """Stop one running instance, whoever owns it — the surgical 39 counterpart to :meth:`stop_all` for a wedged or abusive box. 40 The owner team's record is archived (logs captured) first.""" 41 resp = self._http.request("DELETE", f"/admin/instances/{instance_id}") 42 _raise_for_status(resp) 43 row: dict[str, Any] = resp.json() 44 return row 45 46 def set_internet( 47 self, instance_id: str, internet: InternetAccess, *, reason: str 48 ) -> InstanceInternetResult: 49 """Open or cut one running box's internet access (#2067). 50 51 The operator's escape hatch over the verdict the box was started 52 with: the owning node applies it first, then the platform records it 53 and audits *reason*. ``outcome`` is ``changed`` or ``unchanged`` (it 54 already had that setting); every failure raises with its code and 55 leaves the box as it was. Holds until the box is reset. 56 """ 57 resp = self._http.request( 58 "POST", 59 f"/admin/instances/{instance_id}/internet", 60 json=InstanceInternetRequest(internet=internet, reason=reason).model_dump(mode="json"), 61 ) 62 _raise_for_status(resp) 63 return InstanceInternetResult.model_validate(resp.json()) 64 65 def list( 66 self, 67 *, 68 node_id: str = "", 69 status: str = "", 70 offset: int = 0, 71 limit: int = 50, 72 ) -> PagedList[InstanceInfo]: 73 """Every live instance cluster-wide (no team-ownership filter).""" 74 params: dict[str, Any] = {"offset": offset, "limit": limit} 75 if node_id: 76 params["node_id"] = node_id 77 if status: 78 params["status"] = status 79 resp = self._http.request("GET", "/admin/instances", params=params) 80 _raise_for_status(resp) 81 return _extract_items(resp.json(), InstanceInfo) 82 83 def get(self, instance_id: str) -> InstanceInfo: 84 """Single live instance, admin view (any team's).""" 85 resp = self._http.request("GET", f"/admin/instances/{instance_id}") 86 _raise_for_status(resp) 87 return InstanceInfo.model_validate(resp.json()) 88 89 def pcap(self, instance_id: str) -> bytes: 90 """Live tcpdump capture for a running instance. Returns empty 91 ``bytes`` when none exists (sidecar off, never ran) so callers 92 can persist conditionally without try/except.""" 93 resp = self._http.request("GET", f"/admin/instances/{instance_id}/pcap") 94 if resp.status_code == 404: 95 return b"" 96 _raise_for_status(resp) 97 return resp.content 98 99 def traffic(self, instance_id: str, *, cursor: str = "", limit: int = 0) -> dict[str, Any]: 100 """Live mitmproxy traffic for any running instance (admin twin 101 of :meth:`InstancesResource.traffic`, no ownership check). 102 103 Same ``{flows, cursor, truncated, capture}`` envelope and the same 104 polling contract; see the player's method for why an empty 105 cursor is not a value to send on every call. 106 """ 107 params: dict[str, Any] = {"cursor": cursor} if cursor else {} 108 if limit > 0: 109 params["limit"] = limit 110 resp = self._http.request("GET", f"/admin/instances/{instance_id}/traffic", params=params) 111 _raise_for_status(resp) 112 data = resp.json() 113 return data if isinstance(data, dict) else {} 114 115 def storage(self) -> InstanceStorageReport: 116 """Disk held by the per-instance archive and each node's captures (#1714). 117 118 The archive is the platform's own last reading; each capture row is 119 that node's, carried on its heartbeat. ``report is None`` on a row is 120 *not measured yet*, never an empty store. 121 """ 122 resp = self._http.request("GET", "/admin/instance-storage") 123 _raise_for_status(resp) 124 return InstanceStorageReport.model_validate(resp.json()) 125 126 def timings( 127 self, 128 *, 129 group_by: TimingGroup = "challenge", 130 competition_id: str = "", 131 window: int = 0, 132 image_kind: str = "", 133 ) -> AdminInstanceTimings: 134 """Where instance starts spend their time, per challenge or node (#2095). 135 136 Wraps ``GET /admin/instance-timings``: p50 / p95 of the wait and of 137 its phases over the starts the node measured, slowest first. 138 *window* is the last N seconds (``0`` = the whole archive). 139 """ 140 params: dict[str, Any] = {"group_by": group_by} 141 if competition_id: 142 params["competition_id"] = competition_id 143 if window: 144 params["window"] = window 145 if image_kind: 146 params["image_kind"] = image_kind 147 resp = self._http.request("GET", "/admin/instance-timings", params=params) 148 _raise_for_status(resp) 149 return AdminInstanceTimings.model_validate(resp.json())
22class AdminInstancesResource: 23 """Every live instance cluster-wide, with forensic pcap / traffic reads. 24 Archived (terminal) records live on :class:`AdminRecordsResource`.""" 25 26 def __init__(self, http: BaseHttpClient) -> None: 27 self._http = http 28 29 def stop_all(self) -> builtins.list[dict[str, Any]]: 30 """Cluster-wide tear-down: stop every running instance. Used for 31 maintenance windows. Returns one :class:`StopResponse` row per 32 instance that was stopped.""" 33 resp = self._http.request("DELETE", "/instances") 34 _raise_for_status(resp) 35 rows: builtins.list[dict[str, Any]] = resp.json() 36 return rows 37 38 def stop(self, instance_id: str) -> dict[str, Any]: 39 """Stop one running instance, whoever owns it — the surgical 40 counterpart to :meth:`stop_all` for a wedged or abusive box. 41 The owner team's record is archived (logs captured) first.""" 42 resp = self._http.request("DELETE", f"/admin/instances/{instance_id}") 43 _raise_for_status(resp) 44 row: dict[str, Any] = resp.json() 45 return row 46 47 def set_internet( 48 self, instance_id: str, internet: InternetAccess, *, reason: str 49 ) -> InstanceInternetResult: 50 """Open or cut one running box's internet access (#2067). 51 52 The operator's escape hatch over the verdict the box was started 53 with: the owning node applies it first, then the platform records it 54 and audits *reason*. ``outcome`` is ``changed`` or ``unchanged`` (it 55 already had that setting); every failure raises with its code and 56 leaves the box as it was. Holds until the box is reset. 57 """ 58 resp = self._http.request( 59 "POST", 60 f"/admin/instances/{instance_id}/internet", 61 json=InstanceInternetRequest(internet=internet, reason=reason).model_dump(mode="json"), 62 ) 63 _raise_for_status(resp) 64 return InstanceInternetResult.model_validate(resp.json()) 65 66 def list( 67 self, 68 *, 69 node_id: str = "", 70 status: str = "", 71 offset: int = 0, 72 limit: int = 50, 73 ) -> PagedList[InstanceInfo]: 74 """Every live instance cluster-wide (no team-ownership filter).""" 75 params: dict[str, Any] = {"offset": offset, "limit": limit} 76 if node_id: 77 params["node_id"] = node_id 78 if status: 79 params["status"] = status 80 resp = self._http.request("GET", "/admin/instances", params=params) 81 _raise_for_status(resp) 82 return _extract_items(resp.json(), InstanceInfo) 83 84 def get(self, instance_id: str) -> InstanceInfo: 85 """Single live instance, admin view (any team's).""" 86 resp = self._http.request("GET", f"/admin/instances/{instance_id}") 87 _raise_for_status(resp) 88 return InstanceInfo.model_validate(resp.json()) 89 90 def pcap(self, instance_id: str) -> bytes: 91 """Live tcpdump capture for a running instance. Returns empty 92 ``bytes`` when none exists (sidecar off, never ran) so callers 93 can persist conditionally without try/except.""" 94 resp = self._http.request("GET", f"/admin/instances/{instance_id}/pcap") 95 if resp.status_code == 404: 96 return b"" 97 _raise_for_status(resp) 98 return resp.content 99 100 def traffic(self, instance_id: str, *, cursor: str = "", limit: int = 0) -> dict[str, Any]: 101 """Live mitmproxy traffic for any running instance (admin twin 102 of :meth:`InstancesResource.traffic`, no ownership check). 103 104 Same ``{flows, cursor, truncated, capture}`` envelope and the same 105 polling contract; see the player's method for why an empty 106 cursor is not a value to send on every call. 107 """ 108 params: dict[str, Any] = {"cursor": cursor} if cursor else {} 109 if limit > 0: 110 params["limit"] = limit 111 resp = self._http.request("GET", f"/admin/instances/{instance_id}/traffic", params=params) 112 _raise_for_status(resp) 113 data = resp.json() 114 return data if isinstance(data, dict) else {} 115 116 def storage(self) -> InstanceStorageReport: 117 """Disk held by the per-instance archive and each node's captures (#1714). 118 119 The archive is the platform's own last reading; each capture row is 120 that node's, carried on its heartbeat. ``report is None`` on a row is 121 *not measured yet*, never an empty store. 122 """ 123 resp = self._http.request("GET", "/admin/instance-storage") 124 _raise_for_status(resp) 125 return InstanceStorageReport.model_validate(resp.json()) 126 127 def timings( 128 self, 129 *, 130 group_by: TimingGroup = "challenge", 131 competition_id: str = "", 132 window: int = 0, 133 image_kind: str = "", 134 ) -> AdminInstanceTimings: 135 """Where instance starts spend their time, per challenge or node (#2095). 136 137 Wraps ``GET /admin/instance-timings``: p50 / p95 of the wait and of 138 its phases over the starts the node measured, slowest first. 139 *window* is the last N seconds (``0`` = the whole archive). 140 """ 141 params: dict[str, Any] = {"group_by": group_by} 142 if competition_id: 143 params["competition_id"] = competition_id 144 if window: 145 params["window"] = window 146 if image_kind: 147 params["image_kind"] = image_kind 148 resp = self._http.request("GET", "/admin/instance-timings", params=params) 149 _raise_for_status(resp) 150 return AdminInstanceTimings.model_validate(resp.json())
Every live instance cluster-wide, with forensic pcap / traffic reads.
Archived (terminal) records live on AdminRecordsResource.
29 def stop_all(self) -> builtins.list[dict[str, Any]]: 30 """Cluster-wide tear-down: stop every running instance. Used for 31 maintenance windows. Returns one :class:`StopResponse` row per 32 instance that was stopped.""" 33 resp = self._http.request("DELETE", "/instances") 34 _raise_for_status(resp) 35 rows: builtins.list[dict[str, Any]] = resp.json() 36 return rows
Cluster-wide tear-down: stop every running instance. Used for
maintenance windows. Returns one StopResponse row per
instance that was stopped.
38 def stop(self, instance_id: str) -> dict[str, Any]: 39 """Stop one running instance, whoever owns it — the surgical 40 counterpart to :meth:`stop_all` for a wedged or abusive box. 41 The owner team's record is archived (logs captured) first.""" 42 resp = self._http.request("DELETE", f"/admin/instances/{instance_id}") 43 _raise_for_status(resp) 44 row: dict[str, Any] = resp.json() 45 return row
Stop one running instance, whoever owns it — the surgical
counterpart to stop_all() for a wedged or abusive box.
The owner team's record is archived (logs captured) first.
47 def set_internet( 48 self, instance_id: str, internet: InternetAccess, *, reason: str 49 ) -> InstanceInternetResult: 50 """Open or cut one running box's internet access (#2067). 51 52 The operator's escape hatch over the verdict the box was started 53 with: the owning node applies it first, then the platform records it 54 and audits *reason*. ``outcome`` is ``changed`` or ``unchanged`` (it 55 already had that setting); every failure raises with its code and 56 leaves the box as it was. Holds until the box is reset. 57 """ 58 resp = self._http.request( 59 "POST", 60 f"/admin/instances/{instance_id}/internet", 61 json=InstanceInternetRequest(internet=internet, reason=reason).model_dump(mode="json"), 62 ) 63 _raise_for_status(resp) 64 return InstanceInternetResult.model_validate(resp.json())
Open or cut one running box's internet access (#2067).
The operator's escape hatch over the verdict the box was started
with: the owning node applies it first, then the platform records it
and audits reason. outcome is changed or unchanged (it
already had that setting); every failure raises with its code and
leaves the box as it was. Holds until the box is reset.
66 def list( 67 self, 68 *, 69 node_id: str = "", 70 status: str = "", 71 offset: int = 0, 72 limit: int = 50, 73 ) -> PagedList[InstanceInfo]: 74 """Every live instance cluster-wide (no team-ownership filter).""" 75 params: dict[str, Any] = {"offset": offset, "limit": limit} 76 if node_id: 77 params["node_id"] = node_id 78 if status: 79 params["status"] = status 80 resp = self._http.request("GET", "/admin/instances", params=params) 81 _raise_for_status(resp) 82 return _extract_items(resp.json(), InstanceInfo)
Every live instance cluster-wide (no team-ownership filter).
84 def get(self, instance_id: str) -> InstanceInfo: 85 """Single live instance, admin view (any team's).""" 86 resp = self._http.request("GET", f"/admin/instances/{instance_id}") 87 _raise_for_status(resp) 88 return InstanceInfo.model_validate(resp.json())
Single live instance, admin view (any team's).
90 def pcap(self, instance_id: str) -> bytes: 91 """Live tcpdump capture for a running instance. Returns empty 92 ``bytes`` when none exists (sidecar off, never ran) so callers 93 can persist conditionally without try/except.""" 94 resp = self._http.request("GET", f"/admin/instances/{instance_id}/pcap") 95 if resp.status_code == 404: 96 return b"" 97 _raise_for_status(resp) 98 return resp.content
Live tcpdump capture for a running instance. Returns empty
bytes when none exists (sidecar off, never ran) so callers
can persist conditionally without try/except.
100 def traffic(self, instance_id: str, *, cursor: str = "", limit: int = 0) -> dict[str, Any]: 101 """Live mitmproxy traffic for any running instance (admin twin 102 of :meth:`InstancesResource.traffic`, no ownership check). 103 104 Same ``{flows, cursor, truncated, capture}`` envelope and the same 105 polling contract; see the player's method for why an empty 106 cursor is not a value to send on every call. 107 """ 108 params: dict[str, Any] = {"cursor": cursor} if cursor else {} 109 if limit > 0: 110 params["limit"] = limit 111 resp = self._http.request("GET", f"/admin/instances/{instance_id}/traffic", params=params) 112 _raise_for_status(resp) 113 data = resp.json() 114 return data if isinstance(data, dict) else {}
Live mitmproxy traffic for any running instance (admin twin
of InstancesResource.traffic(), no ownership check).
Same {flows, cursor, truncated, capture} envelope and the same
polling contract; see the player's method for why an empty
cursor is not a value to send on every call.
116 def storage(self) -> InstanceStorageReport: 117 """Disk held by the per-instance archive and each node's captures (#1714). 118 119 The archive is the platform's own last reading; each capture row is 120 that node's, carried on its heartbeat. ``report is None`` on a row is 121 *not measured yet*, never an empty store. 122 """ 123 resp = self._http.request("GET", "/admin/instance-storage") 124 _raise_for_status(resp) 125 return InstanceStorageReport.model_validate(resp.json())
Disk held by the per-instance archive and each node's captures (#1714).
The archive is the platform's own last reading; each capture row is
that node's, carried on its heartbeat. report is None on a row is
not measured yet, never an empty store.
127 def timings( 128 self, 129 *, 130 group_by: TimingGroup = "challenge", 131 competition_id: str = "", 132 window: int = 0, 133 image_kind: str = "", 134 ) -> AdminInstanceTimings: 135 """Where instance starts spend their time, per challenge or node (#2095). 136 137 Wraps ``GET /admin/instance-timings``: p50 / p95 of the wait and of 138 its phases over the starts the node measured, slowest first. 139 *window* is the last N seconds (``0`` = the whole archive). 140 """ 141 params: dict[str, Any] = {"group_by": group_by} 142 if competition_id: 143 params["competition_id"] = competition_id 144 if window: 145 params["window"] = window 146 if image_kind: 147 params["image_kind"] = image_kind 148 resp = self._http.request("GET", "/admin/instance-timings", params=params) 149 _raise_for_status(resp) 150 return AdminInstanceTimings.model_validate(resp.json())
Where instance starts spend their time, per challenge or node (#2095).
Wraps GET /admin/instance-timings: p50 / p95 of the wait and of
its phases over the starts the node measured, slowest first.
window is the last N seconds (0 = the whole archive).