ctfy.sdk.admin_resources.env_sharing

client.admin.env_sharing -- how many teams stand on one environment, the groups behind it, and the health of the shared environments (#2129 B).

For a competition's organisers (global admins and the event's own competition admins). ⚠️ Everything here is the secret the feature keeps from players: a group number, a group's members and a group's size are never on a player-facing call.

  1"""``client.admin.env_sharing`` -- how many teams stand on one environment,
  2the groups behind it, and the health of the shared environments (#2129 B).
  3
  4For a competition's organisers (global admins and the event's own
  5competition admins). ⚠️ Everything here is the secret the feature keeps
  6from players: a group number, a group's members and a group's size are
  7never on a player-facing call.
  8"""
  9
 10from __future__ import annotations
 11
 12from typing import Any
 13
 14from ctfy.core.enums import EnvShareMode, GroupingMethod
 15from ctfy.sdk._helpers import _raise_for_status
 16from ctfy.sdk.base import BaseHttpClient
 17from ctfy.server.models import EnvSharingView, GroupingTable, SharedEnvTable
 18
 19
 20class AdminEnvSharingResource:
 21    """Read and edit one competition's environment sharing."""
 22
 23    def __init__(self, http: BaseHttpClient) -> None:
 24        self._http = http
 25
 26    def get(self, competition_id: str) -> EnvSharingView:
 27        """The default, the per-challenge overrides (with each challenge's
 28        declaration and what blocks sharing it), the cost of each mode in
 29        environments per challenge, and the grouping's state."""
 30        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing")
 31        _raise_for_status(resp)
 32        return EnvSharingView.model_validate(resp.json())
 33
 34    def update(
 35        self,
 36        competition_id: str,
 37        *,
 38        mode: EnvShareMode | None = None,
 39        group_size: int | None = None,
 40        overrides: dict[str, EnvShareMode | None] | None = None,
 41        confirm: bool = False,
 42    ) -> EnvSharingView:
 43        """Set the default and/or per-challenge overrides.
 44
 45        Partial: only what you pass changes; an override of ``None`` follows
 46        the competition again. After the competition has started a change
 47        needs ``confirm=True`` and affects only environments started after
 48        it -- running ones are left as they are. A challenge that cannot be
 49        shared (engagement, defence, judged) refuses an explicit override
 50        (422 ``shared_environment_unsupported``).
 51        """
 52        payload: dict[str, Any] = {"confirm": confirm, "overrides": {}}
 53        if mode is not None:
 54            payload["mode"] = EnvShareMode(mode).value
 55        if group_size is not None:
 56            payload["group_size"] = group_size
 57        for cid, m in (overrides or {}).items():
 58            payload["overrides"][cid] = None if m is None else EnvShareMode(m).value
 59        resp = self._http.request(
 60            "PATCH", f"/admin/competitions/{competition_id}/env-sharing", json=payload
 61        )
 62        _raise_for_status(resp)
 63        return EnvSharingView.model_validate(resp.json())
 64
 65    def groups(self, competition_id: str) -> GroupingTable:
 66        """Who is in which group."""
 67        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing/groups")
 68        _raise_for_status(resp)
 69        return GroupingTable.model_validate(resp.json())
 70
 71    def draw(
 72        self,
 73        competition_id: str,
 74        *,
 75        method: GroupingMethod = GroupingMethod.RANDOM,
 76        by_field: str = "",
 77        seed: int | None = None,
 78        csv: str = "",
 79        force: bool = False,
 80    ) -> EnvSharingView:
 81        """(Re-)divide the teams: ``random`` (the default), ``spread`` by a
 82        registration field with more than one value, or ``csv`` -- lines of
 83        ``team id, group number``. Refused (409 ``groups_in_use``) once a
 84        grouped environment exists unless ``force``, which stops those boxes
 85        and discards their flags."""
 86        payload: dict[str, Any] = {
 87            "method": GroupingMethod(method).value,
 88            "by_field": by_field,
 89            "csv": csv,
 90            "force": force,
 91        }
 92        if seed is not None:
 93            payload["seed"] = seed
 94        resp = self._http.request(
 95            "POST", f"/admin/competitions/{competition_id}/env-sharing/groups", json=payload
 96        )
 97        _raise_for_status(resp)
 98        return EnvSharingView.model_validate(resp.json())
 99
100    def export_groups(self, competition_id: str) -> str:
101        """The grouping as CSV text (``team_id,group,team_name,value``)."""
102        resp = self._http.request(
103            "GET", f"/admin/competitions/{competition_id}/env-sharing/groups.csv"
104        )
105        _raise_for_status(resp)
106        return resp.text
107
108    def environments(self, competition_id: str) -> SharedEnvTable:
109        """Every shared environment: what it is doing now, its last 24 hours,
110        its open reports and its restarts."""
111        resp = self._http.request(
112            "GET", f"/admin/competitions/{competition_id}/shared-environments"
113        )
114        _raise_for_status(resp)
115        return SharedEnvTable.model_validate(resp.json())
116
117    def reset(self, competition_id: str, challenge_id: str, group_key: str) -> SharedEnvTable:
118        """Stop one shared environment and bring a fresh one up with the same
119        flags; resolves its open reports. ``group_key`` is ``all`` or ``g<N>``
120        as the table shows it."""
121        resp = self._http.request(
122            "POST",
123            f"/admin/competitions/{competition_id}/shared-environments/reset",
124            json={"challenge_id": challenge_id, "group_key": group_key},
125        )
126        _raise_for_status(resp)
127        return SharedEnvTable.model_validate(resp.json())
128
129    def dismiss_reports(
130        self, competition_id: str, challenge_id: str, group_key: str
131    ) -> SharedEnvTable:
132        """Mark an environment's reports seen without resetting it."""
133        resp = self._http.request(
134            "POST",
135            f"/admin/competitions/{competition_id}/shared-environments/dismiss-reports",
136            json={"challenge_id": challenge_id, "group_key": group_key},
137        )
138        _raise_for_status(resp)
139        return SharedEnvTable.model_validate(resp.json())
class AdminEnvSharingResource:
 21class AdminEnvSharingResource:
 22    """Read and edit one competition's environment sharing."""
 23
 24    def __init__(self, http: BaseHttpClient) -> None:
 25        self._http = http
 26
 27    def get(self, competition_id: str) -> EnvSharingView:
 28        """The default, the per-challenge overrides (with each challenge's
 29        declaration and what blocks sharing it), the cost of each mode in
 30        environments per challenge, and the grouping's state."""
 31        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing")
 32        _raise_for_status(resp)
 33        return EnvSharingView.model_validate(resp.json())
 34
 35    def update(
 36        self,
 37        competition_id: str,
 38        *,
 39        mode: EnvShareMode | None = None,
 40        group_size: int | None = None,
 41        overrides: dict[str, EnvShareMode | None] | None = None,
 42        confirm: bool = False,
 43    ) -> EnvSharingView:
 44        """Set the default and/or per-challenge overrides.
 45
 46        Partial: only what you pass changes; an override of ``None`` follows
 47        the competition again. After the competition has started a change
 48        needs ``confirm=True`` and affects only environments started after
 49        it -- running ones are left as they are. A challenge that cannot be
 50        shared (engagement, defence, judged) refuses an explicit override
 51        (422 ``shared_environment_unsupported``).
 52        """
 53        payload: dict[str, Any] = {"confirm": confirm, "overrides": {}}
 54        if mode is not None:
 55            payload["mode"] = EnvShareMode(mode).value
 56        if group_size is not None:
 57            payload["group_size"] = group_size
 58        for cid, m in (overrides or {}).items():
 59            payload["overrides"][cid] = None if m is None else EnvShareMode(m).value
 60        resp = self._http.request(
 61            "PATCH", f"/admin/competitions/{competition_id}/env-sharing", json=payload
 62        )
 63        _raise_for_status(resp)
 64        return EnvSharingView.model_validate(resp.json())
 65
 66    def groups(self, competition_id: str) -> GroupingTable:
 67        """Who is in which group."""
 68        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing/groups")
 69        _raise_for_status(resp)
 70        return GroupingTable.model_validate(resp.json())
 71
 72    def draw(
 73        self,
 74        competition_id: str,
 75        *,
 76        method: GroupingMethod = GroupingMethod.RANDOM,
 77        by_field: str = "",
 78        seed: int | None = None,
 79        csv: str = "",
 80        force: bool = False,
 81    ) -> EnvSharingView:
 82        """(Re-)divide the teams: ``random`` (the default), ``spread`` by a
 83        registration field with more than one value, or ``csv`` -- lines of
 84        ``team id, group number``. Refused (409 ``groups_in_use``) once a
 85        grouped environment exists unless ``force``, which stops those boxes
 86        and discards their flags."""
 87        payload: dict[str, Any] = {
 88            "method": GroupingMethod(method).value,
 89            "by_field": by_field,
 90            "csv": csv,
 91            "force": force,
 92        }
 93        if seed is not None:
 94            payload["seed"] = seed
 95        resp = self._http.request(
 96            "POST", f"/admin/competitions/{competition_id}/env-sharing/groups", json=payload
 97        )
 98        _raise_for_status(resp)
 99        return EnvSharingView.model_validate(resp.json())
100
101    def export_groups(self, competition_id: str) -> str:
102        """The grouping as CSV text (``team_id,group,team_name,value``)."""
103        resp = self._http.request(
104            "GET", f"/admin/competitions/{competition_id}/env-sharing/groups.csv"
105        )
106        _raise_for_status(resp)
107        return resp.text
108
109    def environments(self, competition_id: str) -> SharedEnvTable:
110        """Every shared environment: what it is doing now, its last 24 hours,
111        its open reports and its restarts."""
112        resp = self._http.request(
113            "GET", f"/admin/competitions/{competition_id}/shared-environments"
114        )
115        _raise_for_status(resp)
116        return SharedEnvTable.model_validate(resp.json())
117
118    def reset(self, competition_id: str, challenge_id: str, group_key: str) -> SharedEnvTable:
119        """Stop one shared environment and bring a fresh one up with the same
120        flags; resolves its open reports. ``group_key`` is ``all`` or ``g<N>``
121        as the table shows it."""
122        resp = self._http.request(
123            "POST",
124            f"/admin/competitions/{competition_id}/shared-environments/reset",
125            json={"challenge_id": challenge_id, "group_key": group_key},
126        )
127        _raise_for_status(resp)
128        return SharedEnvTable.model_validate(resp.json())
129
130    def dismiss_reports(
131        self, competition_id: str, challenge_id: str, group_key: str
132    ) -> SharedEnvTable:
133        """Mark an environment's reports seen without resetting it."""
134        resp = self._http.request(
135            "POST",
136            f"/admin/competitions/{competition_id}/shared-environments/dismiss-reports",
137            json={"challenge_id": challenge_id, "group_key": group_key},
138        )
139        _raise_for_status(resp)
140        return SharedEnvTable.model_validate(resp.json())

Read and edit one competition's environment sharing.

AdminEnvSharingResource(http: ctfy.sdk.base.BaseHttpClient)
24    def __init__(self, http: BaseHttpClient) -> None:
25        self._http = http
def get( self, competition_id: str) -> ctfy.server.models.EnvSharingView:
27    def get(self, competition_id: str) -> EnvSharingView:
28        """The default, the per-challenge overrides (with each challenge's
29        declaration and what blocks sharing it), the cost of each mode in
30        environments per challenge, and the grouping's state."""
31        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing")
32        _raise_for_status(resp)
33        return EnvSharingView.model_validate(resp.json())

The default, the per-challenge overrides (with each challenge's declaration and what blocks sharing it), the cost of each mode in environments per challenge, and the grouping's state.

def update( self, competition_id: str, *, mode: ctfy.core.enums.EnvShareMode | None = None, group_size: int | None = None, overrides: dict[str, ctfy.core.enums.EnvShareMode | None] | None = None, confirm: bool = False) -> ctfy.server.models.EnvSharingView:
35    def update(
36        self,
37        competition_id: str,
38        *,
39        mode: EnvShareMode | None = None,
40        group_size: int | None = None,
41        overrides: dict[str, EnvShareMode | None] | None = None,
42        confirm: bool = False,
43    ) -> EnvSharingView:
44        """Set the default and/or per-challenge overrides.
45
46        Partial: only what you pass changes; an override of ``None`` follows
47        the competition again. After the competition has started a change
48        needs ``confirm=True`` and affects only environments started after
49        it -- running ones are left as they are. A challenge that cannot be
50        shared (engagement, defence, judged) refuses an explicit override
51        (422 ``shared_environment_unsupported``).
52        """
53        payload: dict[str, Any] = {"confirm": confirm, "overrides": {}}
54        if mode is not None:
55            payload["mode"] = EnvShareMode(mode).value
56        if group_size is not None:
57            payload["group_size"] = group_size
58        for cid, m in (overrides or {}).items():
59            payload["overrides"][cid] = None if m is None else EnvShareMode(m).value
60        resp = self._http.request(
61            "PATCH", f"/admin/competitions/{competition_id}/env-sharing", json=payload
62        )
63        _raise_for_status(resp)
64        return EnvSharingView.model_validate(resp.json())

Set the default and/or per-challenge overrides.

Partial: only what you pass changes; an override of None follows the competition again. After the competition has started a change needs confirm=True and affects only environments started after it -- running ones are left as they are. A challenge that cannot be shared (engagement, defence, judged) refuses an explicit override (422 shared_environment_unsupported).

def groups( self, competition_id: str) -> ctfy.server.models.GroupingTable:
66    def groups(self, competition_id: str) -> GroupingTable:
67        """Who is in which group."""
68        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/env-sharing/groups")
69        _raise_for_status(resp)
70        return GroupingTable.model_validate(resp.json())

Who is in which group.

def draw( self, competition_id: str, *, method: ctfy.core.enums.GroupingMethod = <GroupingMethod.RANDOM: 'random'>, by_field: str = '', seed: int | None = None, csv: str = '', force: bool = False) -> ctfy.server.models.EnvSharingView:
72    def draw(
73        self,
74        competition_id: str,
75        *,
76        method: GroupingMethod = GroupingMethod.RANDOM,
77        by_field: str = "",
78        seed: int | None = None,
79        csv: str = "",
80        force: bool = False,
81    ) -> EnvSharingView:
82        """(Re-)divide the teams: ``random`` (the default), ``spread`` by a
83        registration field with more than one value, or ``csv`` -- lines of
84        ``team id, group number``. Refused (409 ``groups_in_use``) once a
85        grouped environment exists unless ``force``, which stops those boxes
86        and discards their flags."""
87        payload: dict[str, Any] = {
88            "method": GroupingMethod(method).value,
89            "by_field": by_field,
90            "csv": csv,
91            "force": force,
92        }
93        if seed is not None:
94            payload["seed"] = seed
95        resp = self._http.request(
96            "POST", f"/admin/competitions/{competition_id}/env-sharing/groups", json=payload
97        )
98        _raise_for_status(resp)
99        return EnvSharingView.model_validate(resp.json())

(Re-)divide the teams: random (the default), spread by a registration field with more than one value, or csv -- lines of team id, group number. Refused (409 groups_in_use) once a grouped environment exists unless force, which stops those boxes and discards their flags.

def export_groups(self, competition_id: str) -> str:
101    def export_groups(self, competition_id: str) -> str:
102        """The grouping as CSV text (``team_id,group,team_name,value``)."""
103        resp = self._http.request(
104            "GET", f"/admin/competitions/{competition_id}/env-sharing/groups.csv"
105        )
106        _raise_for_status(resp)
107        return resp.text

The grouping as CSV text (team_id,group,team_name,value).

def environments( self, competition_id: str) -> ctfy.server.models.SharedEnvTable:
109    def environments(self, competition_id: str) -> SharedEnvTable:
110        """Every shared environment: what it is doing now, its last 24 hours,
111        its open reports and its restarts."""
112        resp = self._http.request(
113            "GET", f"/admin/competitions/{competition_id}/shared-environments"
114        )
115        _raise_for_status(resp)
116        return SharedEnvTable.model_validate(resp.json())

Every shared environment: what it is doing now, its last 24 hours, its open reports and its restarts.

def reset( self, competition_id: str, challenge_id: str, group_key: str) -> ctfy.server.models.SharedEnvTable:
118    def reset(self, competition_id: str, challenge_id: str, group_key: str) -> SharedEnvTable:
119        """Stop one shared environment and bring a fresh one up with the same
120        flags; resolves its open reports. ``group_key`` is ``all`` or ``g<N>``
121        as the table shows it."""
122        resp = self._http.request(
123            "POST",
124            f"/admin/competitions/{competition_id}/shared-environments/reset",
125            json={"challenge_id": challenge_id, "group_key": group_key},
126        )
127        _raise_for_status(resp)
128        return SharedEnvTable.model_validate(resp.json())

Stop one shared environment and bring a fresh one up with the same flags; resolves its open reports. group_key is all or g<N> as the table shows it.

def dismiss_reports( self, competition_id: str, challenge_id: str, group_key: str) -> ctfy.server.models.SharedEnvTable:
130    def dismiss_reports(
131        self, competition_id: str, challenge_id: str, group_key: str
132    ) -> SharedEnvTable:
133        """Mark an environment's reports seen without resetting it."""
134        resp = self._http.request(
135            "POST",
136            f"/admin/competitions/{competition_id}/shared-environments/dismiss-reports",
137            json={"challenge_id": challenge_id, "group_key": group_key},
138        )
139        _raise_for_status(resp)
140        return SharedEnvTable.model_validate(resp.json())

Mark an environment's reports seen without resetting it.