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