workhold-admin 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workhold_admin/__init__.py +140 -0
- workhold_admin/admin.py +573 -0
- workhold_admin/async_client.py +1071 -0
- workhold_admin/async_pagination.py +453 -0
- workhold_admin/break_glass.py +291 -0
- workhold_admin/models.py +1516 -0
- workhold_admin/observer.py +250 -0
- workhold_admin/pagination.py +478 -0
- workhold_admin-1.0.0.dist-info/METADATA +10 -0
- workhold_admin-1.0.0.dist-info/RECORD +11 -0
- workhold_admin-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Emergency break-glass SDK adapter (OpenAPI BREAK_GLASS operations only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Final
|
|
6
|
+
|
|
7
|
+
from _workhold_client_core.errors import MalformedResponseError
|
|
8
|
+
from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
|
|
9
|
+
|
|
10
|
+
from workhold_admin.models import (
|
|
11
|
+
BreakGlassCounterResult,
|
|
12
|
+
BreakGlassMutationResult,
|
|
13
|
+
BreakGlassReplayLimitResult,
|
|
14
|
+
validate_acknowledge_duplicate_window,
|
|
15
|
+
validate_break_glass_reason,
|
|
16
|
+
validate_event_id,
|
|
17
|
+
validate_extend_seconds,
|
|
18
|
+
validate_failure_code,
|
|
19
|
+
validate_incident_reference,
|
|
20
|
+
validate_partition_name,
|
|
21
|
+
validate_registry_entry_id,
|
|
22
|
+
validate_replay_factor,
|
|
23
|
+
validate_replay_ttl_seconds,
|
|
24
|
+
validate_risk_acknowledged,
|
|
25
|
+
validate_queue_name,
|
|
26
|
+
validate_task_id,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_DEFAULT_FAILURE_CODE: Final[str] = "break_glass_force_dead_letter"
|
|
30
|
+
_DEFAULT_EXTEND_SECONDS: Final[int] = 86400
|
|
31
|
+
_DEFAULT_REGISTRY: Final[str] = "enqueue_dedup"
|
|
32
|
+
_DEFAULT_REPLAY_FACTOR: Final[float] = 2.0
|
|
33
|
+
_DEFAULT_REPLAY_TTL_SECONDS: Final[int] = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BreakGlassClient:
|
|
37
|
+
"""Break-glass emergency mutations on the admin control plane.
|
|
38
|
+
|
|
39
|
+
Requires deployment-issued short-lived JIT credentials with role
|
|
40
|
+
``BREAK_GLASS`` (Bearer token). The SDK does not parse, validate expiry,
|
|
41
|
+
or mint JIT credentials; the server remains authoritative for credential
|
|
42
|
+
lifetime and ``allowed_operations`` audience.
|
|
43
|
+
|
|
44
|
+
Every mutation requires a non-empty ``reason``, ``incident_reference`` and
|
|
45
|
+
literal ``risk_acknowledged=True``. Operations never return worker claim
|
|
46
|
+
tokens. The SDK does not silently retry requests.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
50
|
+
raise TypeError(f"{cls.__name__} cannot be subclassed")
|
|
51
|
+
|
|
52
|
+
def __init__(self, transport: HttpJsonTransport, *, bearer_token: str) -> None:
|
|
53
|
+
if not bearer_token or not bearer_token.strip():
|
|
54
|
+
raise ValueError("bearer_token is required")
|
|
55
|
+
self._transport = transport
|
|
56
|
+
self._bearer_token = bearer_token
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return "BreakGlassClient(transport=..., bearer_token=<redacted>)"
|
|
60
|
+
|
|
61
|
+
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
62
|
+
headers = {"Authorization": f"Bearer {self._bearer_token}"}
|
|
63
|
+
if extra:
|
|
64
|
+
headers.update(extra)
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
def force_lease_expiry(
|
|
68
|
+
self,
|
|
69
|
+
queue_name: str,
|
|
70
|
+
task_id: str,
|
|
71
|
+
*,
|
|
72
|
+
reason: str,
|
|
73
|
+
incident_reference: str,
|
|
74
|
+
risk_acknowledged: bool,
|
|
75
|
+
) -> BreakGlassMutationResult:
|
|
76
|
+
"""POST ``.../tasks/{task_id}:force-lease-expiry`` (``forceLeaseExpiry``)."""
|
|
77
|
+
|
|
78
|
+
wire_name = validate_queue_name(queue_name)
|
|
79
|
+
wire_task_id = validate_task_id(task_id)
|
|
80
|
+
body = _ack_body(
|
|
81
|
+
reason=reason,
|
|
82
|
+
incident_reference=incident_reference,
|
|
83
|
+
risk_acknowledged=risk_acknowledged,
|
|
84
|
+
extra={"task_id": wire_task_id},
|
|
85
|
+
)
|
|
86
|
+
path = (
|
|
87
|
+
f"/admin/v1/queues/{encode_path_segment(wire_name)}/tasks/"
|
|
88
|
+
f"{encode_path_segment(wire_task_id)}:force-lease-expiry"
|
|
89
|
+
)
|
|
90
|
+
return self._post(path, body, BreakGlassMutationResult.parse)
|
|
91
|
+
|
|
92
|
+
def force_delivery_reclaim(
|
|
93
|
+
self,
|
|
94
|
+
queue_name: str,
|
|
95
|
+
event_id: str,
|
|
96
|
+
*,
|
|
97
|
+
reason: str,
|
|
98
|
+
incident_reference: str,
|
|
99
|
+
risk_acknowledged: bool,
|
|
100
|
+
) -> BreakGlassMutationResult:
|
|
101
|
+
"""POST ``.../delivery-events/{event_id}:force-reclaim``."""
|
|
102
|
+
|
|
103
|
+
wire_name = validate_queue_name(queue_name)
|
|
104
|
+
wire_event_id = validate_event_id(event_id)
|
|
105
|
+
body = _ack_body(
|
|
106
|
+
reason=reason,
|
|
107
|
+
incident_reference=incident_reference,
|
|
108
|
+
risk_acknowledged=risk_acknowledged,
|
|
109
|
+
)
|
|
110
|
+
path = (
|
|
111
|
+
f"/admin/v1/queues/{encode_path_segment(wire_name)}/delivery-events/"
|
|
112
|
+
f"{encode_path_segment(wire_event_id)}:force-reclaim"
|
|
113
|
+
)
|
|
114
|
+
return self._post(path, body, BreakGlassMutationResult.parse)
|
|
115
|
+
|
|
116
|
+
def force_delivery_dead_letter(
|
|
117
|
+
self,
|
|
118
|
+
queue_name: str,
|
|
119
|
+
event_id: str,
|
|
120
|
+
*,
|
|
121
|
+
reason: str,
|
|
122
|
+
incident_reference: str,
|
|
123
|
+
risk_acknowledged: bool,
|
|
124
|
+
failure_code: str = _DEFAULT_FAILURE_CODE,
|
|
125
|
+
) -> BreakGlassMutationResult:
|
|
126
|
+
"""POST ``.../delivery-events/{event_id}:force-dead-letter``."""
|
|
127
|
+
|
|
128
|
+
wire_name = validate_queue_name(queue_name)
|
|
129
|
+
wire_event_id = validate_event_id(event_id)
|
|
130
|
+
wire_failure_code = validate_failure_code(failure_code)
|
|
131
|
+
body = _ack_body(
|
|
132
|
+
reason=reason,
|
|
133
|
+
incident_reference=incident_reference,
|
|
134
|
+
risk_acknowledged=risk_acknowledged,
|
|
135
|
+
extra={"failure_code": wire_failure_code},
|
|
136
|
+
)
|
|
137
|
+
path = (
|
|
138
|
+
f"/admin/v1/queues/{encode_path_segment(wire_name)}/delivery-events/"
|
|
139
|
+
f"{encode_path_segment(wire_event_id)}:force-dead-letter"
|
|
140
|
+
)
|
|
141
|
+
return self._post(path, body, BreakGlassMutationResult.parse)
|
|
142
|
+
|
|
143
|
+
def reconcile_counters(
|
|
144
|
+
self,
|
|
145
|
+
queue_name: str,
|
|
146
|
+
*,
|
|
147
|
+
reason: str,
|
|
148
|
+
incident_reference: str,
|
|
149
|
+
risk_acknowledged: bool,
|
|
150
|
+
) -> BreakGlassCounterResult:
|
|
151
|
+
"""POST ``/admin/v1/queues/{queue_name}:reconcile-counters``."""
|
|
152
|
+
|
|
153
|
+
wire_name = validate_queue_name(queue_name)
|
|
154
|
+
body = _ack_body(
|
|
155
|
+
reason=reason,
|
|
156
|
+
incident_reference=incident_reference,
|
|
157
|
+
risk_acknowledged=risk_acknowledged,
|
|
158
|
+
)
|
|
159
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:reconcile-counters"
|
|
160
|
+
return self._post(path, body, BreakGlassCounterResult.parse)
|
|
161
|
+
|
|
162
|
+
def raise_replay_limit(
|
|
163
|
+
self,
|
|
164
|
+
queue_name: str,
|
|
165
|
+
*,
|
|
166
|
+
reason: str,
|
|
167
|
+
incident_reference: str,
|
|
168
|
+
risk_acknowledged: bool,
|
|
169
|
+
factor: float = _DEFAULT_REPLAY_FACTOR,
|
|
170
|
+
ttl_seconds: int = _DEFAULT_REPLAY_TTL_SECONDS,
|
|
171
|
+
) -> BreakGlassReplayLimitResult:
|
|
172
|
+
"""POST ``/admin/v1/queues/{queue_name}:raise-replay-limit``."""
|
|
173
|
+
|
|
174
|
+
wire_name = validate_queue_name(queue_name)
|
|
175
|
+
wire_factor = validate_replay_factor(factor)
|
|
176
|
+
wire_ttl = validate_replay_ttl_seconds(ttl_seconds)
|
|
177
|
+
body = _ack_body(
|
|
178
|
+
reason=reason,
|
|
179
|
+
incident_reference=incident_reference,
|
|
180
|
+
risk_acknowledged=risk_acknowledged,
|
|
181
|
+
extra={"factor": wire_factor, "ttl_seconds": wire_ttl},
|
|
182
|
+
)
|
|
183
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:raise-replay-limit"
|
|
184
|
+
return self._post(path, body, BreakGlassReplayLimitResult.parse)
|
|
185
|
+
|
|
186
|
+
def drop_expired_partition(
|
|
187
|
+
self,
|
|
188
|
+
partition_name: str,
|
|
189
|
+
*,
|
|
190
|
+
reason: str,
|
|
191
|
+
incident_reference: str,
|
|
192
|
+
risk_acknowledged: bool,
|
|
193
|
+
) -> BreakGlassMutationResult:
|
|
194
|
+
"""POST ``/admin/v1/partitions/{partition_name}:force-drop``."""
|
|
195
|
+
|
|
196
|
+
wire_partition = validate_partition_name(partition_name)
|
|
197
|
+
body = _ack_body(
|
|
198
|
+
reason=reason,
|
|
199
|
+
incident_reference=incident_reference,
|
|
200
|
+
risk_acknowledged=risk_acknowledged,
|
|
201
|
+
)
|
|
202
|
+
path = (
|
|
203
|
+
f"/admin/v1/partitions/{encode_path_segment(wire_partition)}:force-drop"
|
|
204
|
+
)
|
|
205
|
+
return self._post(path, body, BreakGlassMutationResult.parse)
|
|
206
|
+
|
|
207
|
+
def repair_registry_entry(
|
|
208
|
+
self,
|
|
209
|
+
queue_name: str,
|
|
210
|
+
*,
|
|
211
|
+
entry_id: int,
|
|
212
|
+
reason: str,
|
|
213
|
+
incident_reference: str,
|
|
214
|
+
risk_acknowledged: bool,
|
|
215
|
+
acknowledge_duplicate_window: bool,
|
|
216
|
+
extend_seconds: int = _DEFAULT_EXTEND_SECONDS,
|
|
217
|
+
registry: str = _DEFAULT_REGISTRY,
|
|
218
|
+
) -> BreakGlassMutationResult:
|
|
219
|
+
"""POST ``/admin/v1/queues/{queue_name}/registry:repair``."""
|
|
220
|
+
|
|
221
|
+
wire_name = validate_queue_name(queue_name)
|
|
222
|
+
wire_entry_id = validate_registry_entry_id(entry_id)
|
|
223
|
+
wire_extend = validate_extend_seconds(extend_seconds)
|
|
224
|
+
wire_registry = _wire_registry(registry)
|
|
225
|
+
body = _ack_body(
|
|
226
|
+
reason=reason,
|
|
227
|
+
incident_reference=incident_reference,
|
|
228
|
+
risk_acknowledged=risk_acknowledged,
|
|
229
|
+
extra={
|
|
230
|
+
"entry_id": wire_entry_id,
|
|
231
|
+
"acknowledge_duplicate_window": validate_acknowledge_duplicate_window(
|
|
232
|
+
acknowledge_duplicate_window
|
|
233
|
+
),
|
|
234
|
+
"extend_seconds": wire_extend,
|
|
235
|
+
"registry": wire_registry,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}/registry:repair"
|
|
239
|
+
return self._post(path, body, BreakGlassMutationResult.parse)
|
|
240
|
+
|
|
241
|
+
def _post(
|
|
242
|
+
self,
|
|
243
|
+
path: str,
|
|
244
|
+
body: dict[str, Any],
|
|
245
|
+
parser: Any,
|
|
246
|
+
) -> Any:
|
|
247
|
+
response = self._transport.request(
|
|
248
|
+
"POST",
|
|
249
|
+
path,
|
|
250
|
+
headers=self._auth_headers(),
|
|
251
|
+
json_body=body,
|
|
252
|
+
)
|
|
253
|
+
return self._parse(parser, response.status_code, response.body)
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _parse(parser: Any, status_code: int, body: object | None) -> Any:
|
|
257
|
+
if body is None:
|
|
258
|
+
raise MalformedResponseError(
|
|
259
|
+
status_code=status_code,
|
|
260
|
+
reason="empty success response body",
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
return parser(body)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise MalformedResponseError(
|
|
266
|
+
status_code=status_code,
|
|
267
|
+
reason=str(exc),
|
|
268
|
+
) from exc
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _ack_body(
|
|
272
|
+
*,
|
|
273
|
+
reason: str,
|
|
274
|
+
incident_reference: str,
|
|
275
|
+
risk_acknowledged: bool,
|
|
276
|
+
extra: dict[str, Any] | None = None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
body: dict[str, Any] = {
|
|
279
|
+
"reason": validate_break_glass_reason(reason),
|
|
280
|
+
"incident_reference": validate_incident_reference(incident_reference),
|
|
281
|
+
"risk_acknowledged": validate_risk_acknowledged(risk_acknowledged),
|
|
282
|
+
}
|
|
283
|
+
if extra:
|
|
284
|
+
body.update(extra)
|
|
285
|
+
return body
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _wire_registry(registry: str) -> str:
|
|
289
|
+
if registry != _DEFAULT_REGISTRY:
|
|
290
|
+
raise ValueError("registry must be enqueue_dedup")
|
|
291
|
+
return registry
|