ctfy.sdk.admin_resources.gauntlet

client.admin.gauntlet — plan a GAUNTLET range, and keep one.

⚠️ This used to be the planning half alone, on the argument that rendering a range is generate.py new in the corpus and a wrapper here would be an API for writing 78 files into somebody's git checkout from a script. That argument still holds for the corpus — and it is exactly why create_range() writes nowhere near it. A generated range lands in a directory this deployment owns, beside the corpus rather than inside it, so nothing here touches a checkout anybody commits from. What the platform can answer that a script cannot is "does this deployment's library close a chain to this goal, what estate comes out, and can this deployment keep it".

  1"""``client.admin.gauntlet`` — plan a GAUNTLET range, and keep one.
  2
  3⚠️ **This used to be the planning half alone**, on the argument that
  4rendering a range is ``generate.py new`` in the corpus and a wrapper
  5here would be an API for writing 78 files into somebody's git checkout
  6from a script. That argument still holds *for the corpus* — and it is
  7exactly why :meth:`create_range` writes nowhere near it. A generated
  8range lands in a directory this **deployment** owns, beside the corpus
  9rather than inside it, so nothing here touches a checkout anybody
 10commits from. What the platform can answer that a script cannot is
 11*"does this deployment's library close a chain to this goal, what
 12estate comes out, and can this deployment keep it"*.
 13"""
 14
 15from __future__ import annotations
 16
 17from ctfy.sdk._helpers import _raise_for_status
 18from ctfy.sdk.base import BaseHttpClient
 19from ctfy.server.models import (
 20    GauntletLibraryResponse,
 21    GauntletPlanRequest,
 22    GauntletPlanResponse,
 23    GauntletPreviewRequest,
 24    GauntletPreviewResponse,
 25    GauntletRangeCreate,
 26    GauntletRangeCreated,
 27    GauntletRangeListResponse,
 28)
 29
 30
 31class AdminGauntletResource:
 32    """Preview a generated range against this deployment's own corpus."""
 33
 34    def __init__(self, http: BaseHttpClient) -> None:
 35        self._http = http
 36
 37    def library(self) -> GauntletLibraryResponse:
 38        """What this deployment's corpus can build a range out of.
 39
 40        ⚠️ **Check ``available`` before reading ``modules``.** A corpus
 41        that is a release tarball, or a submodule nobody initialised,
 42        carries no scenario compiler — and that answers 200 with
 43        ``available=False`` and a ``reason`` naming the path it looked
 44        in, rather than 404. An empty ``modules`` list on an
 45        ``available`` deployment is a different fact: the compiler is
 46        there and its library is empty. Wraps
 47        ``GET /admin/gauntlet/library``."""
 48        resp = self._http.request("GET", "/admin/gauntlet/library")
 49        _raise_for_status(resp)
 50        return GauntletLibraryResponse.model_validate(resp.json())
 51
 52    def preview(self, request: GauntletPreviewRequest) -> GauntletPreviewResponse:
 53        """The range these knobs would produce, without producing it.
 54
 55        Returns the estate as a :class:`ChallengeBlueprint` — the same
 56        model, from the same renderer, as a range that already exists —
 57        plus what the chain came up short of and why, and the scenario
 58        YAML to commit. Nothing is written to the corpus: the render
 59        happens in a temporary directory that is removed before the
 60        response is composed.
 61
 62        Raises on a refusal, and the two statuses mean different things:
 63        **422** is about the request (nothing in the library produces
 64        that goal; the ratios leave no room for the chain; a module
 65        named under ``defenses`` is not a control) and **503** is about
 66        the deployment (no compiler in this corpus). Wraps
 67        ``POST /admin/gauntlet/preview``."""
 68        resp = self._http.request(
 69            "POST", "/admin/gauntlet/preview", json=request.model_dump(mode="json")
 70        )
 71        _raise_for_status(resp)
 72        return GauntletPreviewResponse.model_validate(resp.json())
 73
 74    def plan(self, request: GauntletPlanRequest) -> GauntletPlanResponse:
 75        """The shape these knobs decide, without drawing the estate.
 76
 77        ⚠️ **The cheap half of :meth:`preview`, and the difference is
 78        three orders of magnitude.** Measured per phase against the
 79        shipped corpus: ``plan_chain`` 0.4 ms, ``enrich`` 3.6,
 80        ``decorate`` 0.4 — then ``render`` 273.4 (76 files written to a
 81        temporary directory) and ``build_blueprint`` 89.3 to read the
 82        topology back. So a caller sweeping seeds to find a range of the
 83        right size wants this one; a caller that needs the picture wants
 84        :meth:`preview`.
 85
 86        Both go through one ``compile_plan``, so this refuses exactly
 87        what :meth:`preview` refuses — a script that tuned against a
 88        plan the preview would reject is the failure the shared call
 89        prevents. Same two statuses, same meanings. Wraps
 90        ``POST /admin/gauntlet/plan``."""
 91        resp = self._http.request(
 92            "POST", "/admin/gauntlet/plan", json=request.model_dump(mode="json")
 93        )
 94        _raise_for_status(resp)
 95        return GauntletPlanResponse.model_validate(resp.json())
 96
 97    def create_range(self, request: GauntletRangeCreate) -> GauntletRangeCreated:
 98        """Keep a range built from these knobs on this deployment.
 99
100        Answers with **both** halves: the stored source (scenario plus,
101        once it builds, the corpus commit it was compiled from) and the
102        background task doing the render. The task is where the build
103        fails, so a caller that only reads the range is reading a record
104        of an intention.
105
106        ⚠️ The id is **minted** from ``slug``, always carrying the
107        generated bucket's own prefix — read it off the response rather
108        than deriving it, because that prefix is what stops a later
109        corpus commit from shadowing the range.
110
111        Raises **409** when this deployment already holds that id (as a
112        stored source, or as a directory an earlier render left behind)
113        and **422** when the slug cannot become a directory name. Wraps
114        ``POST /admin/gauntlet/ranges``."""
115        resp = self._http.request(
116            "POST", "/admin/gauntlet/ranges", json=request.model_dump(mode="json")
117        )
118        _raise_for_status(resp)
119        return GauntletRangeCreated.model_validate(resp.json())
120
121    def ranges(self) -> GauntletRangeListResponse:
122        """Every range this deployment has a source for.
123
124        ⚠️ ``built`` is read off the **catalog**, not off the row: it says
125        whether the estate is on this deployment's disk right now, which
126        a stored status could not — a directory reclaimed, or a source
127        whose build failed, both read ``False`` here and would read
128        ``True`` from a column. Wraps ``GET /admin/gauntlet/ranges``."""
129        resp = self._http.request("GET", "/admin/gauntlet/ranges")
130        _raise_for_status(resp)
131        return GauntletRangeListResponse.model_validate(resp.json())
132
133    def rebuild_range(self, range_id: str) -> GauntletRangeCreated:
134        """Render this range's estate again from the scenario already stored.
135
136        ⚠️ **Its own verb, not a flag on :meth:`create_range`.** ``create``
137        means this deployment does not have the estate; this means replace
138        the one it has. `require_free_id` refuses an id the catalog holds
139        — unconditionally, our own bucket included — and its docstring
140        says the caller removes the directory first; this is that caller,
141        and until it existed a built range could not be re-rendered on the
142        platform at all.
143
144        ⚠️ **:meth:`delete_range` is not the way to do this**: it drops the
145        stored scenario too, so the only route to a fresh estate from the
146        same seed was re-typing every knob.
147
148        Raises **404** when this deployment stores no such range and
149        **409** while it has live instances, naming how many — stop them
150        first. Answers with the stored source and the background task, the
151        same pair :meth:`create_range` does. Wraps
152        ``POST /admin/gauntlet/ranges/{range_id}/rebuild``."""
153        resp = self._http.request("POST", f"/admin/gauntlet/ranges/{range_id}/rebuild")
154        _raise_for_status(resp)
155        return GauntletRangeCreated.model_validate(resp.json())
156
157    def delete_range(self, range_id: str) -> None:
158        """Drop the source and the estate together.
159
160        The directory is composed from the generated bucket rather than
161        resolved from the id, so this can only ever remove something
162        this deployment made. Wraps
163        ``DELETE /admin/gauntlet/ranges/{range_id}``."""
164        resp = self._http.request("DELETE", f"/admin/gauntlet/ranges/{range_id}")
165        _raise_for_status(resp)
class AdminGauntletResource:
 32class AdminGauntletResource:
 33    """Preview a generated range against this deployment's own corpus."""
 34
 35    def __init__(self, http: BaseHttpClient) -> None:
 36        self._http = http
 37
 38    def library(self) -> GauntletLibraryResponse:
 39        """What this deployment's corpus can build a range out of.
 40
 41        ⚠️ **Check ``available`` before reading ``modules``.** A corpus
 42        that is a release tarball, or a submodule nobody initialised,
 43        carries no scenario compiler — and that answers 200 with
 44        ``available=False`` and a ``reason`` naming the path it looked
 45        in, rather than 404. An empty ``modules`` list on an
 46        ``available`` deployment is a different fact: the compiler is
 47        there and its library is empty. Wraps
 48        ``GET /admin/gauntlet/library``."""
 49        resp = self._http.request("GET", "/admin/gauntlet/library")
 50        _raise_for_status(resp)
 51        return GauntletLibraryResponse.model_validate(resp.json())
 52
 53    def preview(self, request: GauntletPreviewRequest) -> GauntletPreviewResponse:
 54        """The range these knobs would produce, without producing it.
 55
 56        Returns the estate as a :class:`ChallengeBlueprint` — the same
 57        model, from the same renderer, as a range that already exists —
 58        plus what the chain came up short of and why, and the scenario
 59        YAML to commit. Nothing is written to the corpus: the render
 60        happens in a temporary directory that is removed before the
 61        response is composed.
 62
 63        Raises on a refusal, and the two statuses mean different things:
 64        **422** is about the request (nothing in the library produces
 65        that goal; the ratios leave no room for the chain; a module
 66        named under ``defenses`` is not a control) and **503** is about
 67        the deployment (no compiler in this corpus). Wraps
 68        ``POST /admin/gauntlet/preview``."""
 69        resp = self._http.request(
 70            "POST", "/admin/gauntlet/preview", json=request.model_dump(mode="json")
 71        )
 72        _raise_for_status(resp)
 73        return GauntletPreviewResponse.model_validate(resp.json())
 74
 75    def plan(self, request: GauntletPlanRequest) -> GauntletPlanResponse:
 76        """The shape these knobs decide, without drawing the estate.
 77
 78        ⚠️ **The cheap half of :meth:`preview`, and the difference is
 79        three orders of magnitude.** Measured per phase against the
 80        shipped corpus: ``plan_chain`` 0.4 ms, ``enrich`` 3.6,
 81        ``decorate`` 0.4 — then ``render`` 273.4 (76 files written to a
 82        temporary directory) and ``build_blueprint`` 89.3 to read the
 83        topology back. So a caller sweeping seeds to find a range of the
 84        right size wants this one; a caller that needs the picture wants
 85        :meth:`preview`.
 86
 87        Both go through one ``compile_plan``, so this refuses exactly
 88        what :meth:`preview` refuses — a script that tuned against a
 89        plan the preview would reject is the failure the shared call
 90        prevents. Same two statuses, same meanings. Wraps
 91        ``POST /admin/gauntlet/plan``."""
 92        resp = self._http.request(
 93            "POST", "/admin/gauntlet/plan", json=request.model_dump(mode="json")
 94        )
 95        _raise_for_status(resp)
 96        return GauntletPlanResponse.model_validate(resp.json())
 97
 98    def create_range(self, request: GauntletRangeCreate) -> GauntletRangeCreated:
 99        """Keep a range built from these knobs on this deployment.
100
101        Answers with **both** halves: the stored source (scenario plus,
102        once it builds, the corpus commit it was compiled from) and the
103        background task doing the render. The task is where the build
104        fails, so a caller that only reads the range is reading a record
105        of an intention.
106
107        ⚠️ The id is **minted** from ``slug``, always carrying the
108        generated bucket's own prefix — read it off the response rather
109        than deriving it, because that prefix is what stops a later
110        corpus commit from shadowing the range.
111
112        Raises **409** when this deployment already holds that id (as a
113        stored source, or as a directory an earlier render left behind)
114        and **422** when the slug cannot become a directory name. Wraps
115        ``POST /admin/gauntlet/ranges``."""
116        resp = self._http.request(
117            "POST", "/admin/gauntlet/ranges", json=request.model_dump(mode="json")
118        )
119        _raise_for_status(resp)
120        return GauntletRangeCreated.model_validate(resp.json())
121
122    def ranges(self) -> GauntletRangeListResponse:
123        """Every range this deployment has a source for.
124
125        ⚠️ ``built`` is read off the **catalog**, not off the row: it says
126        whether the estate is on this deployment's disk right now, which
127        a stored status could not — a directory reclaimed, or a source
128        whose build failed, both read ``False`` here and would read
129        ``True`` from a column. Wraps ``GET /admin/gauntlet/ranges``."""
130        resp = self._http.request("GET", "/admin/gauntlet/ranges")
131        _raise_for_status(resp)
132        return GauntletRangeListResponse.model_validate(resp.json())
133
134    def rebuild_range(self, range_id: str) -> GauntletRangeCreated:
135        """Render this range's estate again from the scenario already stored.
136
137        ⚠️ **Its own verb, not a flag on :meth:`create_range`.** ``create``
138        means this deployment does not have the estate; this means replace
139        the one it has. `require_free_id` refuses an id the catalog holds
140        — unconditionally, our own bucket included — and its docstring
141        says the caller removes the directory first; this is that caller,
142        and until it existed a built range could not be re-rendered on the
143        platform at all.
144
145        ⚠️ **:meth:`delete_range` is not the way to do this**: it drops the
146        stored scenario too, so the only route to a fresh estate from the
147        same seed was re-typing every knob.
148
149        Raises **404** when this deployment stores no such range and
150        **409** while it has live instances, naming how many — stop them
151        first. Answers with the stored source and the background task, the
152        same pair :meth:`create_range` does. Wraps
153        ``POST /admin/gauntlet/ranges/{range_id}/rebuild``."""
154        resp = self._http.request("POST", f"/admin/gauntlet/ranges/{range_id}/rebuild")
155        _raise_for_status(resp)
156        return GauntletRangeCreated.model_validate(resp.json())
157
158    def delete_range(self, range_id: str) -> None:
159        """Drop the source and the estate together.
160
161        The directory is composed from the generated bucket rather than
162        resolved from the id, so this can only ever remove something
163        this deployment made. Wraps
164        ``DELETE /admin/gauntlet/ranges/{range_id}``."""
165        resp = self._http.request("DELETE", f"/admin/gauntlet/ranges/{range_id}")
166        _raise_for_status(resp)

Preview a generated range against this deployment's own corpus.

AdminGauntletResource(http: ctfy.sdk.base.BaseHttpClient)
35    def __init__(self, http: BaseHttpClient) -> None:
36        self._http = http
def library(self) -> ctfy.server.models.GauntletLibraryResponse:
38    def library(self) -> GauntletLibraryResponse:
39        """What this deployment's corpus can build a range out of.
40
41        ⚠️ **Check ``available`` before reading ``modules``.** A corpus
42        that is a release tarball, or a submodule nobody initialised,
43        carries no scenario compiler — and that answers 200 with
44        ``available=False`` and a ``reason`` naming the path it looked
45        in, rather than 404. An empty ``modules`` list on an
46        ``available`` deployment is a different fact: the compiler is
47        there and its library is empty. Wraps
48        ``GET /admin/gauntlet/library``."""
49        resp = self._http.request("GET", "/admin/gauntlet/library")
50        _raise_for_status(resp)
51        return GauntletLibraryResponse.model_validate(resp.json())

What this deployment's corpus can build a range out of.

⚠️ Check available before reading modules. A corpus that is a release tarball, or a submodule nobody initialised, carries no scenario compiler — and that answers 200 with available=False and a reason naming the path it looked in, rather than 404. An empty modules list on an available deployment is a different fact: the compiler is there and its library is empty. Wraps GET /admin/gauntlet/library.

53    def preview(self, request: GauntletPreviewRequest) -> GauntletPreviewResponse:
54        """The range these knobs would produce, without producing it.
55
56        Returns the estate as a :class:`ChallengeBlueprint` — the same
57        model, from the same renderer, as a range that already exists —
58        plus what the chain came up short of and why, and the scenario
59        YAML to commit. Nothing is written to the corpus: the render
60        happens in a temporary directory that is removed before the
61        response is composed.
62
63        Raises on a refusal, and the two statuses mean different things:
64        **422** is about the request (nothing in the library produces
65        that goal; the ratios leave no room for the chain; a module
66        named under ``defenses`` is not a control) and **503** is about
67        the deployment (no compiler in this corpus). Wraps
68        ``POST /admin/gauntlet/preview``."""
69        resp = self._http.request(
70            "POST", "/admin/gauntlet/preview", json=request.model_dump(mode="json")
71        )
72        _raise_for_status(resp)
73        return GauntletPreviewResponse.model_validate(resp.json())

The range these knobs would produce, without producing it.

Returns the estate as a ChallengeBlueprint — the same model, from the same renderer, as a range that already exists — plus what the chain came up short of and why, and the scenario YAML to commit. Nothing is written to the corpus: the render happens in a temporary directory that is removed before the response is composed.

Raises on a refusal, and the two statuses mean different things: 422 is about the request (nothing in the library produces that goal; the ratios leave no room for the chain; a module named under defenses is not a control) and 503 is about the deployment (no compiler in this corpus). Wraps POST /admin/gauntlet/preview.

75    def plan(self, request: GauntletPlanRequest) -> GauntletPlanResponse:
76        """The shape these knobs decide, without drawing the estate.
77
78        ⚠️ **The cheap half of :meth:`preview`, and the difference is
79        three orders of magnitude.** Measured per phase against the
80        shipped corpus: ``plan_chain`` 0.4 ms, ``enrich`` 3.6,
81        ``decorate`` 0.4 — then ``render`` 273.4 (76 files written to a
82        temporary directory) and ``build_blueprint`` 89.3 to read the
83        topology back. So a caller sweeping seeds to find a range of the
84        right size wants this one; a caller that needs the picture wants
85        :meth:`preview`.
86
87        Both go through one ``compile_plan``, so this refuses exactly
88        what :meth:`preview` refuses — a script that tuned against a
89        plan the preview would reject is the failure the shared call
90        prevents. Same two statuses, same meanings. Wraps
91        ``POST /admin/gauntlet/plan``."""
92        resp = self._http.request(
93            "POST", "/admin/gauntlet/plan", json=request.model_dump(mode="json")
94        )
95        _raise_for_status(resp)
96        return GauntletPlanResponse.model_validate(resp.json())

The shape these knobs decide, without drawing the estate.

⚠️ The cheap half of preview(), and the difference is three orders of magnitude. Measured per phase against the shipped corpus: plan_chain 0.4 ms, enrich 3.6, decorate 0.4 — then render 273.4 (76 files written to a temporary directory) and build_blueprint 89.3 to read the topology back. So a caller sweeping seeds to find a range of the right size wants this one; a caller that needs the picture wants preview().

Both go through one compile_plan, so this refuses exactly what preview() refuses — a script that tuned against a plan the preview would reject is the failure the shared call prevents. Same two statuses, same meanings. Wraps POST /admin/gauntlet/plan.

def create_range( self, request: ctfy.server.models.GauntletRangeCreate) -> ctfy.server.models.GauntletRangeCreated:
 98    def create_range(self, request: GauntletRangeCreate) -> GauntletRangeCreated:
 99        """Keep a range built from these knobs on this deployment.
100
101        Answers with **both** halves: the stored source (scenario plus,
102        once it builds, the corpus commit it was compiled from) and the
103        background task doing the render. The task is where the build
104        fails, so a caller that only reads the range is reading a record
105        of an intention.
106
107        ⚠️ The id is **minted** from ``slug``, always carrying the
108        generated bucket's own prefix — read it off the response rather
109        than deriving it, because that prefix is what stops a later
110        corpus commit from shadowing the range.
111
112        Raises **409** when this deployment already holds that id (as a
113        stored source, or as a directory an earlier render left behind)
114        and **422** when the slug cannot become a directory name. Wraps
115        ``POST /admin/gauntlet/ranges``."""
116        resp = self._http.request(
117            "POST", "/admin/gauntlet/ranges", json=request.model_dump(mode="json")
118        )
119        _raise_for_status(resp)
120        return GauntletRangeCreated.model_validate(resp.json())

Keep a range built from these knobs on this deployment.

Answers with both halves: the stored source (scenario plus, once it builds, the corpus commit it was compiled from) and the background task doing the render. The task is where the build fails, so a caller that only reads the range is reading a record of an intention.

⚠️ The id is minted from slug, always carrying the generated bucket's own prefix — read it off the response rather than deriving it, because that prefix is what stops a later corpus commit from shadowing the range.

Raises 409 when this deployment already holds that id (as a stored source, or as a directory an earlier render left behind) and 422 when the slug cannot become a directory name. Wraps POST /admin/gauntlet/ranges.

def ranges(self) -> ctfy.server.models.GauntletRangeListResponse:
122    def ranges(self) -> GauntletRangeListResponse:
123        """Every range this deployment has a source for.
124
125        ⚠️ ``built`` is read off the **catalog**, not off the row: it says
126        whether the estate is on this deployment's disk right now, which
127        a stored status could not — a directory reclaimed, or a source
128        whose build failed, both read ``False`` here and would read
129        ``True`` from a column. Wraps ``GET /admin/gauntlet/ranges``."""
130        resp = self._http.request("GET", "/admin/gauntlet/ranges")
131        _raise_for_status(resp)
132        return GauntletRangeListResponse.model_validate(resp.json())

Every range this deployment has a source for.

⚠️ built is read off the catalog, not off the row: it says whether the estate is on this deployment's disk right now, which a stored status could not — a directory reclaimed, or a source whose build failed, both read False here and would read True from a column. Wraps GET /admin/gauntlet/ranges.

def rebuild_range(self, range_id: str) -> ctfy.server.models.GauntletRangeCreated:
134    def rebuild_range(self, range_id: str) -> GauntletRangeCreated:
135        """Render this range's estate again from the scenario already stored.
136
137        ⚠️ **Its own verb, not a flag on :meth:`create_range`.** ``create``
138        means this deployment does not have the estate; this means replace
139        the one it has. `require_free_id` refuses an id the catalog holds
140        — unconditionally, our own bucket included — and its docstring
141        says the caller removes the directory first; this is that caller,
142        and until it existed a built range could not be re-rendered on the
143        platform at all.
144
145        ⚠️ **:meth:`delete_range` is not the way to do this**: it drops the
146        stored scenario too, so the only route to a fresh estate from the
147        same seed was re-typing every knob.
148
149        Raises **404** when this deployment stores no such range and
150        **409** while it has live instances, naming how many — stop them
151        first. Answers with the stored source and the background task, the
152        same pair :meth:`create_range` does. Wraps
153        ``POST /admin/gauntlet/ranges/{range_id}/rebuild``."""
154        resp = self._http.request("POST", f"/admin/gauntlet/ranges/{range_id}/rebuild")
155        _raise_for_status(resp)
156        return GauntletRangeCreated.model_validate(resp.json())

Render this range's estate again from the scenario already stored.

⚠️ Its own verb, not a flag on create_range(). create means this deployment does not have the estate; this means replace the one it has. require_free_id refuses an id the catalog holds — unconditionally, our own bucket included — and its docstring says the caller removes the directory first; this is that caller, and until it existed a built range could not be re-rendered on the platform at all.

⚠️ delete_range() is not the way to do this: it drops the stored scenario too, so the only route to a fresh estate from the same seed was re-typing every knob.

Raises 404 when this deployment stores no such range and 409 while it has live instances, naming how many — stop them first. Answers with the stored source and the background task, the same pair create_range() does. Wraps POST /admin/gauntlet/ranges/{range_id}/rebuild.

def delete_range(self, range_id: str) -> None:
158    def delete_range(self, range_id: str) -> None:
159        """Drop the source and the estate together.
160
161        The directory is composed from the generated bucket rather than
162        resolved from the id, so this can only ever remove something
163        this deployment made. Wraps
164        ``DELETE /admin/gauntlet/ranges/{range_id}``."""
165        resp = self._http.request("DELETE", f"/admin/gauntlet/ranges/{range_id}")
166        _raise_for_status(resp)

Drop the source and the estate together.

The directory is composed from the generated bucket rather than resolved from the id, so this can only ever remove something this deployment made. Wraps DELETE /admin/gauntlet/ranges/{range_id}.