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,140 @@
|
|
|
1
|
+
"""Public admin/observer/break-glass client distribution.
|
|
2
|
+
|
|
3
|
+
Thin role surfaces over shared ``_workhold_client_core`` primitives.
|
|
4
|
+
Observer operations: getCapabilities, getTask, listTaskAttempts, getQueue,
|
|
5
|
+
getStats, getMaintenanceStatus, listInspectionTasks, listInspectionAttempts,
|
|
6
|
+
listDeadLetters (see ``packages/client-operation-ownership.json``).
|
|
7
|
+
Admin audit/maintenance: listAdminAudit, getMaintenanceStatus, runMaintenance.
|
|
8
|
+
Admin recovery: replayDeadLetter, preview/execute bulk replay/cancel.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from _workhold_client_core.capabilities import Capabilities
|
|
14
|
+
from _workhold_client_core.errors import (
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
MalformedResponseError,
|
|
17
|
+
ProtocolError,
|
|
18
|
+
QueueClientError,
|
|
19
|
+
TimeoutError,
|
|
20
|
+
TransportError,
|
|
21
|
+
)
|
|
22
|
+
from _workhold_client_core.models import Task, TaskState
|
|
23
|
+
from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
|
|
24
|
+
from workhold_admin.admin import AdminClient
|
|
25
|
+
from workhold_admin.break_glass import BreakGlassClient
|
|
26
|
+
from workhold_admin.models import (
|
|
27
|
+
AdminMutationResult,
|
|
28
|
+
AuditOperation,
|
|
29
|
+
AuditPage,
|
|
30
|
+
AuditRecord,
|
|
31
|
+
Attempt,
|
|
32
|
+
AttemptOutcome,
|
|
33
|
+
AttemptPage,
|
|
34
|
+
BackoffStrategy,
|
|
35
|
+
BULK_BATCH_MAX,
|
|
36
|
+
BULK_CANDIDATE_MAX,
|
|
37
|
+
BreakGlassCounterResult,
|
|
38
|
+
BreakGlassMutationResult,
|
|
39
|
+
BreakGlassReplayLimitResult,
|
|
40
|
+
BulkExecuteResult,
|
|
41
|
+
BulkItemOutcome,
|
|
42
|
+
BulkItemOutcomeKind,
|
|
43
|
+
BulkOperation,
|
|
44
|
+
BulkPreviewResult,
|
|
45
|
+
CONFIRMATION_TOKEN_MAX_LENGTH,
|
|
46
|
+
ConfigVersion,
|
|
47
|
+
CURSOR_MAX_LENGTH,
|
|
48
|
+
DeadLetterPage,
|
|
49
|
+
DeadLetterReplayResult,
|
|
50
|
+
IDEMPOTENCY_KEY_MAX_LENGTH,
|
|
51
|
+
IDEMPOTENCY_KEY_MIN_LENGTH,
|
|
52
|
+
MaintenanceOutcome,
|
|
53
|
+
MaintenanceRunResult,
|
|
54
|
+
MaintenanceStatus,
|
|
55
|
+
PAGE_LIMIT_DEFAULT,
|
|
56
|
+
PAGE_LIMIT_MAX,
|
|
57
|
+
PAGE_LIMIT_MIN,
|
|
58
|
+
PolicyVersion,
|
|
59
|
+
Queue,
|
|
60
|
+
QueuePage,
|
|
61
|
+
QueueState,
|
|
62
|
+
REASON_MAX_LENGTH,
|
|
63
|
+
RetryPolicy,
|
|
64
|
+
RetryPolicyDraft,
|
|
65
|
+
StatsAvailability,
|
|
66
|
+
StatsFreshness,
|
|
67
|
+
StatsMaintenanceSummary,
|
|
68
|
+
StatsQueueAggregate,
|
|
69
|
+
StatsSnapshot,
|
|
70
|
+
StatsTelemetrySource,
|
|
71
|
+
StatsTelemetrySummary,
|
|
72
|
+
TaskPage,
|
|
73
|
+
)
|
|
74
|
+
from workhold_admin.observer import ObserverClient
|
|
75
|
+
|
|
76
|
+
__version__ = "1.0.0"
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"AdminClient",
|
|
80
|
+
"AdminMutationResult",
|
|
81
|
+
"AuditOperation",
|
|
82
|
+
"AuditPage",
|
|
83
|
+
"AuditRecord",
|
|
84
|
+
"Attempt",
|
|
85
|
+
"AttemptOutcome",
|
|
86
|
+
"AttemptPage",
|
|
87
|
+
"AuthenticationError",
|
|
88
|
+
"BackoffStrategy",
|
|
89
|
+
"BULK_BATCH_MAX",
|
|
90
|
+
"BULK_CANDIDATE_MAX",
|
|
91
|
+
"BreakGlassClient",
|
|
92
|
+
"BreakGlassCounterResult",
|
|
93
|
+
"BreakGlassMutationResult",
|
|
94
|
+
"BreakGlassReplayLimitResult",
|
|
95
|
+
"BulkExecuteResult",
|
|
96
|
+
"BulkItemOutcome",
|
|
97
|
+
"BulkItemOutcomeKind",
|
|
98
|
+
"BulkOperation",
|
|
99
|
+
"BulkPreviewResult",
|
|
100
|
+
"Capabilities",
|
|
101
|
+
"CONFIRMATION_TOKEN_MAX_LENGTH",
|
|
102
|
+
"ConfigVersion",
|
|
103
|
+
"CURSOR_MAX_LENGTH",
|
|
104
|
+
"DeadLetterPage",
|
|
105
|
+
"DeadLetterReplayResult",
|
|
106
|
+
"HttpJsonTransport",
|
|
107
|
+
"IDEMPOTENCY_KEY_MAX_LENGTH",
|
|
108
|
+
"IDEMPOTENCY_KEY_MIN_LENGTH",
|
|
109
|
+
"MaintenanceOutcome",
|
|
110
|
+
"MaintenanceRunResult",
|
|
111
|
+
"MaintenanceStatus",
|
|
112
|
+
"MalformedResponseError",
|
|
113
|
+
"ObserverClient",
|
|
114
|
+
"PAGE_LIMIT_DEFAULT",
|
|
115
|
+
"PAGE_LIMIT_MAX",
|
|
116
|
+
"PAGE_LIMIT_MIN",
|
|
117
|
+
"PolicyVersion",
|
|
118
|
+
"ProtocolError",
|
|
119
|
+
"Queue",
|
|
120
|
+
"QueueClientError",
|
|
121
|
+
"QueuePage",
|
|
122
|
+
"QueueState",
|
|
123
|
+
"REASON_MAX_LENGTH",
|
|
124
|
+
"RetryPolicy",
|
|
125
|
+
"RetryPolicyDraft",
|
|
126
|
+
"StatsAvailability",
|
|
127
|
+
"StatsFreshness",
|
|
128
|
+
"StatsMaintenanceSummary",
|
|
129
|
+
"StatsQueueAggregate",
|
|
130
|
+
"StatsSnapshot",
|
|
131
|
+
"StatsTelemetrySource",
|
|
132
|
+
"StatsTelemetrySummary",
|
|
133
|
+
"Task",
|
|
134
|
+
"TaskPage",
|
|
135
|
+
"TaskState",
|
|
136
|
+
"TimeoutError",
|
|
137
|
+
"TransportError",
|
|
138
|
+
"encode_path_segment",
|
|
139
|
+
"__version__",
|
|
140
|
+
]
|
workhold_admin/admin.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Admin SDK adapter: queue/policy/state, audit/maintenance, and recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from _workhold_client_core.capabilities import Capabilities
|
|
11
|
+
from _workhold_client_core.errors import MalformedResponseError
|
|
12
|
+
from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
|
|
13
|
+
|
|
14
|
+
from workhold_admin.models import (
|
|
15
|
+
AdminMutationResult,
|
|
16
|
+
AttemptPage,
|
|
17
|
+
AuditPage,
|
|
18
|
+
BulkExecuteResult,
|
|
19
|
+
BulkPreviewResult,
|
|
20
|
+
ConfigVersion,
|
|
21
|
+
DeadLetterPage,
|
|
22
|
+
DeadLetterReplayResult,
|
|
23
|
+
MaintenanceRunResult,
|
|
24
|
+
MaintenanceStatus,
|
|
25
|
+
PAGE_LIMIT_DEFAULT,
|
|
26
|
+
PolicyVersion,
|
|
27
|
+
Queue,
|
|
28
|
+
QueuePage,
|
|
29
|
+
QueueState,
|
|
30
|
+
RetryPolicyDraft,
|
|
31
|
+
StatsSnapshot,
|
|
32
|
+
TaskPage,
|
|
33
|
+
format_datetime,
|
|
34
|
+
validate_batch_limit,
|
|
35
|
+
validate_bulk_filters,
|
|
36
|
+
validate_confirmation_token,
|
|
37
|
+
validate_cursor,
|
|
38
|
+
validate_idempotency_key,
|
|
39
|
+
validate_known_queue_state,
|
|
40
|
+
validate_page_limit,
|
|
41
|
+
validate_queue_name,
|
|
42
|
+
validate_reason,
|
|
43
|
+
validate_start_index,
|
|
44
|
+
validate_task_id,
|
|
45
|
+
validate_time_range,
|
|
46
|
+
)
|
|
47
|
+
from workhold_admin.observer import ObserverClient, _page_query
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AdminClient:
|
|
51
|
+
"""Admin mutations: queue/policy/state, audit/maintenance, and recovery.
|
|
52
|
+
|
|
53
|
+
Single-transport form ``AdminClient(transport, bearer_token=...)`` uses the
|
|
54
|
+
same transport for admin-plane calls. Dual-transport form
|
|
55
|
+
``AdminClient(public, bearer_token=..., admin_transport=admin)`` routes
|
|
56
|
+
admin-plane ops through ``admin_transport`` and composes ``ObserverClient``
|
|
57
|
+
for maintenance status reads. Break-glass operations are intentionally
|
|
58
|
+
absent. The SDK does not silently retry or auto-read stale optimistic
|
|
59
|
+
mutations.
|
|
60
|
+
|
|
61
|
+
Recovery: preview and execute are separate calls. Execute methods require an
|
|
62
|
+
explicit ``BulkPreviewResult`` from a prior preview; the SDK never
|
|
63
|
+
auto-previews or silently widens filters. Replay is at-least-once and may
|
|
64
|
+
repeat external effects; models preserve immutable ``source_task_id`` lineage.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
transport: HttpJsonTransport,
|
|
70
|
+
*,
|
|
71
|
+
bearer_token: str,
|
|
72
|
+
admin_transport: HttpJsonTransport | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
if not bearer_token or not bearer_token.strip():
|
|
75
|
+
raise ValueError("bearer_token is required")
|
|
76
|
+
resolved_admin = transport if admin_transport is None else admin_transport
|
|
77
|
+
self._observer = ObserverClient(
|
|
78
|
+
transport,
|
|
79
|
+
bearer_token=bearer_token,
|
|
80
|
+
admin_transport=resolved_admin,
|
|
81
|
+
)
|
|
82
|
+
self._transport = transport
|
|
83
|
+
self._admin_transport = resolved_admin
|
|
84
|
+
self._bearer_token = bearer_token
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return "AdminClient(transport=..., admin_transport=..., bearer_token=<redacted>)"
|
|
88
|
+
|
|
89
|
+
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
90
|
+
headers = {"Authorization": f"Bearer {self._bearer_token}"}
|
|
91
|
+
if extra:
|
|
92
|
+
headers.update(extra)
|
|
93
|
+
return headers
|
|
94
|
+
|
|
95
|
+
def list_queues(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
cursor: str | None = None,
|
|
99
|
+
limit: int = PAGE_LIMIT_DEFAULT,
|
|
100
|
+
) -> QueuePage:
|
|
101
|
+
"""GET ``/admin/v1/queues`` (OpenAPI ``listQueues``)."""
|
|
102
|
+
|
|
103
|
+
wire_limit = validate_page_limit(limit)
|
|
104
|
+
wire_cursor = validate_cursor(cursor)
|
|
105
|
+
query = _page_query(limit=wire_limit, cursor=wire_cursor)
|
|
106
|
+
response = self._admin_transport.request(
|
|
107
|
+
"GET",
|
|
108
|
+
f"/admin/v1/queues{query}",
|
|
109
|
+
headers=self._auth_headers(),
|
|
110
|
+
json_body=None,
|
|
111
|
+
)
|
|
112
|
+
return self._parse(QueuePage.parse, response.status_code, response.body)
|
|
113
|
+
|
|
114
|
+
def create_queue(
|
|
115
|
+
self,
|
|
116
|
+
name: str,
|
|
117
|
+
*,
|
|
118
|
+
initial_policy: RetryPolicyDraft,
|
|
119
|
+
idempotency_key: str,
|
|
120
|
+
) -> AdminMutationResult:
|
|
121
|
+
"""POST ``/admin/v1/queues`` (OpenAPI ``createQueue``)."""
|
|
122
|
+
|
|
123
|
+
wire_name = validate_queue_name(name)
|
|
124
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
125
|
+
body = {
|
|
126
|
+
"name": wire_name,
|
|
127
|
+
"initial_policy": initial_policy.to_wire(),
|
|
128
|
+
}
|
|
129
|
+
response = self._admin_transport.request(
|
|
130
|
+
"POST",
|
|
131
|
+
"/admin/v1/queues",
|
|
132
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
133
|
+
json_body=body,
|
|
134
|
+
)
|
|
135
|
+
return self._parse(AdminMutationResult.parse, response.status_code, response.body)
|
|
136
|
+
|
|
137
|
+
def get_queue(self, queue_name: str) -> Queue:
|
|
138
|
+
"""GET ``/admin/v1/queues/{queue_name}`` (OpenAPI ``getQueue``)."""
|
|
139
|
+
|
|
140
|
+
wire_name = validate_queue_name(queue_name)
|
|
141
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}"
|
|
142
|
+
response = self._admin_transport.request(
|
|
143
|
+
"GET",
|
|
144
|
+
path,
|
|
145
|
+
headers=self._auth_headers(),
|
|
146
|
+
json_body=None,
|
|
147
|
+
)
|
|
148
|
+
return self._parse(Queue.parse, response.status_code, response.body)
|
|
149
|
+
|
|
150
|
+
def create_queue_policy(
|
|
151
|
+
self,
|
|
152
|
+
queue_name: str,
|
|
153
|
+
policy: RetryPolicyDraft,
|
|
154
|
+
*,
|
|
155
|
+
idempotency_key: str,
|
|
156
|
+
) -> AdminMutationResult:
|
|
157
|
+
"""POST ``/admin/v1/queues/{queue_name}/policies`` (OpenAPI ``createQueuePolicy``)."""
|
|
158
|
+
|
|
159
|
+
wire_name = validate_queue_name(queue_name)
|
|
160
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
161
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies"
|
|
162
|
+
response = self._admin_transport.request(
|
|
163
|
+
"POST",
|
|
164
|
+
path,
|
|
165
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
166
|
+
json_body=policy.to_wire(),
|
|
167
|
+
)
|
|
168
|
+
return self._parse(AdminMutationResult.parse, response.status_code, response.body)
|
|
169
|
+
|
|
170
|
+
def activate_queue_policy(
|
|
171
|
+
self,
|
|
172
|
+
queue_name: str,
|
|
173
|
+
policy_version: PolicyVersion | int,
|
|
174
|
+
*,
|
|
175
|
+
expected_config_version: ConfigVersion | int,
|
|
176
|
+
idempotency_key: str,
|
|
177
|
+
) -> AdminMutationResult:
|
|
178
|
+
"""POST ``/admin/v1/queues/{queue_name}/policies/{policy_version}:activate``."""
|
|
179
|
+
|
|
180
|
+
wire_name = validate_queue_name(queue_name)
|
|
181
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
182
|
+
wire_policy_version = _wire_policy_version(policy_version)
|
|
183
|
+
wire_config_version = _wire_config_version(expected_config_version)
|
|
184
|
+
path = (
|
|
185
|
+
f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies/"
|
|
186
|
+
f"{wire_policy_version}:activate"
|
|
187
|
+
)
|
|
188
|
+
body = {"expected_config_version": wire_config_version}
|
|
189
|
+
response = self._admin_transport.request(
|
|
190
|
+
"POST",
|
|
191
|
+
path,
|
|
192
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
193
|
+
json_body=body,
|
|
194
|
+
)
|
|
195
|
+
return self._parse(AdminMutationResult.parse, response.status_code, response.body)
|
|
196
|
+
|
|
197
|
+
def set_queue_state(
|
|
198
|
+
self,
|
|
199
|
+
queue_name: str,
|
|
200
|
+
state: QueueState | str,
|
|
201
|
+
*,
|
|
202
|
+
expected_config_version: ConfigVersion | int,
|
|
203
|
+
idempotency_key: str,
|
|
204
|
+
) -> AdminMutationResult:
|
|
205
|
+
"""POST ``/admin/v1/queues/{queue_name}:set-state`` (OpenAPI ``setQueueState``)."""
|
|
206
|
+
|
|
207
|
+
wire_name = validate_queue_name(queue_name)
|
|
208
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
209
|
+
wire_config_version = _wire_config_version(expected_config_version)
|
|
210
|
+
wire_state = _wire_queue_state(state)
|
|
211
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:set-state"
|
|
212
|
+
body = {
|
|
213
|
+
"expected_config_version": wire_config_version,
|
|
214
|
+
"state": wire_state,
|
|
215
|
+
}
|
|
216
|
+
response = self._admin_transport.request(
|
|
217
|
+
"POST",
|
|
218
|
+
path,
|
|
219
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
220
|
+
json_body=body,
|
|
221
|
+
)
|
|
222
|
+
return self._parse(AdminMutationResult.parse, response.status_code, response.body)
|
|
223
|
+
|
|
224
|
+
def get_maintenance_status(self) -> MaintenanceStatus:
|
|
225
|
+
"""GET ``/admin/v1/maintenance`` (OpenAPI ``getMaintenanceStatus``)."""
|
|
226
|
+
|
|
227
|
+
return self._observer.get_maintenance_status()
|
|
228
|
+
|
|
229
|
+
def get_capabilities(self) -> Capabilities:
|
|
230
|
+
"""GET ``/v1/capabilities`` (OpenAPI ``getCapabilities``; admin-authorized)."""
|
|
231
|
+
|
|
232
|
+
return self._observer.get_capabilities()
|
|
233
|
+
|
|
234
|
+
def get_stats(self) -> StatsSnapshot:
|
|
235
|
+
"""GET ``/admin/v1/stats`` (OpenAPI ``getStats``; parameterless)."""
|
|
236
|
+
|
|
237
|
+
return self._observer.get_stats()
|
|
238
|
+
|
|
239
|
+
def list_inspection_tasks(
|
|
240
|
+
self,
|
|
241
|
+
queue_name: str,
|
|
242
|
+
*,
|
|
243
|
+
cursor: str | None = None,
|
|
244
|
+
limit: int = PAGE_LIMIT_DEFAULT,
|
|
245
|
+
) -> TaskPage:
|
|
246
|
+
"""GET ``/admin/v1/tasks`` (OpenAPI ``listInspectionTasks``)."""
|
|
247
|
+
|
|
248
|
+
return self._observer.list_inspection_tasks(
|
|
249
|
+
queue_name, cursor=cursor, limit=limit
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def list_inspection_attempts(
|
|
253
|
+
self,
|
|
254
|
+
task_id: str,
|
|
255
|
+
*,
|
|
256
|
+
time_from: datetime,
|
|
257
|
+
time_to: datetime,
|
|
258
|
+
cursor: str | None = None,
|
|
259
|
+
limit: int = PAGE_LIMIT_DEFAULT,
|
|
260
|
+
) -> AttemptPage:
|
|
261
|
+
"""GET ``/admin/v1/attempts`` (OpenAPI ``listInspectionAttempts``)."""
|
|
262
|
+
|
|
263
|
+
return self._observer.list_inspection_attempts(
|
|
264
|
+
task_id,
|
|
265
|
+
time_from=time_from,
|
|
266
|
+
time_to=time_to,
|
|
267
|
+
cursor=cursor,
|
|
268
|
+
limit=limit,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def list_dead_letters(
|
|
272
|
+
self,
|
|
273
|
+
queue_name: str,
|
|
274
|
+
*,
|
|
275
|
+
time_from: datetime,
|
|
276
|
+
time_to: datetime,
|
|
277
|
+
cursor: str | None = None,
|
|
278
|
+
limit: int = PAGE_LIMIT_DEFAULT,
|
|
279
|
+
) -> DeadLetterPage:
|
|
280
|
+
"""GET ``/admin/v1/dead-letters`` (OpenAPI ``listDeadLetters``)."""
|
|
281
|
+
|
|
282
|
+
return self._observer.list_dead_letters(
|
|
283
|
+
queue_name,
|
|
284
|
+
time_from=time_from,
|
|
285
|
+
time_to=time_to,
|
|
286
|
+
cursor=cursor,
|
|
287
|
+
limit=limit,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def list_admin_audit(
|
|
291
|
+
self,
|
|
292
|
+
*,
|
|
293
|
+
time_from: datetime,
|
|
294
|
+
time_to: datetime,
|
|
295
|
+
queue_name: str | None = None,
|
|
296
|
+
cursor: str | None = None,
|
|
297
|
+
limit: int = PAGE_LIMIT_DEFAULT,
|
|
298
|
+
) -> AuditPage:
|
|
299
|
+
"""GET ``/admin/v1/audit`` (OpenAPI ``listAdminAudit``)."""
|
|
300
|
+
|
|
301
|
+
validate_time_range(time_from, time_to)
|
|
302
|
+
wire_limit = validate_page_limit(limit)
|
|
303
|
+
wire_cursor = validate_cursor(cursor)
|
|
304
|
+
params: dict[str, str] = {
|
|
305
|
+
"from": format_datetime(time_from),
|
|
306
|
+
"to": format_datetime(time_to),
|
|
307
|
+
}
|
|
308
|
+
if queue_name is not None:
|
|
309
|
+
params["queue_name"] = validate_queue_name(queue_name)
|
|
310
|
+
params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
|
|
311
|
+
path = f"/admin/v1/audit?{urlencode(params)}"
|
|
312
|
+
response = self._admin_transport.request(
|
|
313
|
+
"GET",
|
|
314
|
+
path,
|
|
315
|
+
headers=self._auth_headers(),
|
|
316
|
+
json_body=None,
|
|
317
|
+
)
|
|
318
|
+
return self._parse(AuditPage.parse, response.status_code, response.body)
|
|
319
|
+
|
|
320
|
+
def run_maintenance(self, *, idempotency_key: str) -> MaintenanceRunResult:
|
|
321
|
+
"""POST ``/admin/v1/maintenance:run`` (OpenAPI ``runMaintenance``)."""
|
|
322
|
+
|
|
323
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
324
|
+
response = self._admin_transport.request(
|
|
325
|
+
"POST",
|
|
326
|
+
"/admin/v1/maintenance:run",
|
|
327
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
328
|
+
json_body=None,
|
|
329
|
+
)
|
|
330
|
+
return self._parse(MaintenanceRunResult.parse, response.status_code, response.body)
|
|
331
|
+
|
|
332
|
+
def replay_dead_letter(
|
|
333
|
+
self,
|
|
334
|
+
queue_name: str,
|
|
335
|
+
task_id: str,
|
|
336
|
+
*,
|
|
337
|
+
idempotency_key: str,
|
|
338
|
+
reason: str,
|
|
339
|
+
) -> DeadLetterReplayResult:
|
|
340
|
+
"""POST ``.../dead-letters/{task_id}:replay`` (OpenAPI ``replayDeadLetter``).
|
|
341
|
+
|
|
342
|
+
Creates a new ready task linked to the immutable dead-letter source.
|
|
343
|
+
Never mutates terminal history. Replay is at-least-once; ``replayed`` may
|
|
344
|
+
be true when the same admin idempotency key is retried within ADR017 TTL.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
wire_queue = validate_queue_name(queue_name)
|
|
348
|
+
wire_task_id = validate_task_id(task_id)
|
|
349
|
+
wire_key = validate_idempotency_key(idempotency_key)
|
|
350
|
+
wire_reason = validate_reason(reason)
|
|
351
|
+
path = (
|
|
352
|
+
f"/admin/v1/queues/{encode_path_segment(wire_queue)}"
|
|
353
|
+
f"/dead-letters/{encode_path_segment(wire_task_id)}:replay"
|
|
354
|
+
)
|
|
355
|
+
response = self._admin_transport.request(
|
|
356
|
+
"POST",
|
|
357
|
+
path,
|
|
358
|
+
headers=self._auth_headers({"Idempotency-Key": wire_key}),
|
|
359
|
+
json_body={"reason": wire_reason},
|
|
360
|
+
)
|
|
361
|
+
return self._parse(DeadLetterReplayResult.parse, response.status_code, response.body)
|
|
362
|
+
|
|
363
|
+
def preview_bulk_replay(
|
|
364
|
+
self,
|
|
365
|
+
queue_name: str,
|
|
366
|
+
*,
|
|
367
|
+
filters: Mapping[str, str] | None = None,
|
|
368
|
+
) -> BulkPreviewResult:
|
|
369
|
+
"""POST ``.../bulk:preview-replay`` (OpenAPI ``previewBulkReplay``).
|
|
370
|
+
|
|
371
|
+
Dry-run only: returns a principal-bound expiring confirmation token and
|
|
372
|
+
bounded candidate summary without mutating tasks.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
return self._preview_bulk(
|
|
376
|
+
queue_name,
|
|
377
|
+
operation_path="bulk:preview-replay",
|
|
378
|
+
filters=filters,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def execute_bulk_replay(
|
|
382
|
+
self,
|
|
383
|
+
queue_name: str,
|
|
384
|
+
*,
|
|
385
|
+
preview: BulkPreviewResult,
|
|
386
|
+
idempotency_key: str,
|
|
387
|
+
reason: str,
|
|
388
|
+
filters: Mapping[str, str],
|
|
389
|
+
start_index: int = 0,
|
|
390
|
+
batch_limit: int | None = None,
|
|
391
|
+
) -> BulkExecuteResult:
|
|
392
|
+
"""POST ``.../bulk:execute-replay`` (OpenAPI ``executeBulkReplay``).
|
|
393
|
+
|
|
394
|
+
Requires ``preview`` from ``preview_bulk_replay`` with matching queue,
|
|
395
|
+
operation and caller-supplied ``filters``. Partial batches are explicit
|
|
396
|
+
via ``partial`` and ``next_start_index``; replay remains at-least-once.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
return self._execute_bulk(
|
|
400
|
+
queue_name,
|
|
401
|
+
operation_path="bulk:execute-replay",
|
|
402
|
+
expected_operation="bulk_replay",
|
|
403
|
+
preview=preview,
|
|
404
|
+
idempotency_key=idempotency_key,
|
|
405
|
+
reason=reason,
|
|
406
|
+
filters=filters,
|
|
407
|
+
start_index=start_index,
|
|
408
|
+
batch_limit=batch_limit,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def preview_bulk_cancel(
|
|
412
|
+
self,
|
|
413
|
+
queue_name: str,
|
|
414
|
+
*,
|
|
415
|
+
filters: Mapping[str, str] | None = None,
|
|
416
|
+
) -> BulkPreviewResult:
|
|
417
|
+
"""POST ``.../bulk:preview-cancel`` (OpenAPI ``previewBulkCancel``).
|
|
418
|
+
|
|
419
|
+
Dry-run only: returns a confirmation token for bounded bulk cancellation.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
return self._preview_bulk(
|
|
423
|
+
queue_name,
|
|
424
|
+
operation_path="bulk:preview-cancel",
|
|
425
|
+
filters=filters,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def execute_bulk_cancel(
|
|
429
|
+
self,
|
|
430
|
+
queue_name: str,
|
|
431
|
+
*,
|
|
432
|
+
preview: BulkPreviewResult,
|
|
433
|
+
reason: str,
|
|
434
|
+
filters: Mapping[str, str],
|
|
435
|
+
start_index: int = 0,
|
|
436
|
+
batch_limit: int | None = None,
|
|
437
|
+
) -> BulkExecuteResult:
|
|
438
|
+
"""POST ``.../bulk:execute-cancel`` (OpenAPI ``executeBulkCancel``).
|
|
439
|
+
|
|
440
|
+
Requires ``preview`` from ``preview_bulk_cancel`` with matching queue,
|
|
441
|
+
operation and ``filters``. Never creates spawn or delivery events.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
return self._execute_bulk(
|
|
445
|
+
queue_name,
|
|
446
|
+
operation_path="bulk:execute-cancel",
|
|
447
|
+
expected_operation="bulk_cancel",
|
|
448
|
+
preview=preview,
|
|
449
|
+
idempotency_key=None,
|
|
450
|
+
reason=reason,
|
|
451
|
+
filters=filters,
|
|
452
|
+
start_index=start_index,
|
|
453
|
+
batch_limit=batch_limit,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _preview_bulk(
|
|
457
|
+
self,
|
|
458
|
+
queue_name: str,
|
|
459
|
+
*,
|
|
460
|
+
operation_path: str,
|
|
461
|
+
filters: Mapping[str, str] | None,
|
|
462
|
+
) -> BulkPreviewResult:
|
|
463
|
+
wire_queue = validate_queue_name(queue_name)
|
|
464
|
+
body: dict[str, Any] = {}
|
|
465
|
+
if filters is not None:
|
|
466
|
+
body["filters"] = validate_bulk_filters(filters)
|
|
467
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
|
|
468
|
+
response = self._admin_transport.request(
|
|
469
|
+
"POST",
|
|
470
|
+
path,
|
|
471
|
+
headers=self._auth_headers(),
|
|
472
|
+
json_body=body,
|
|
473
|
+
)
|
|
474
|
+
result = self._parse(BulkPreviewResult.parse, response.status_code, response.body)
|
|
475
|
+
if result.queue != wire_queue:
|
|
476
|
+
raise ValueError("preview.queue does not match queue_name")
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
def _execute_bulk(
|
|
480
|
+
self,
|
|
481
|
+
queue_name: str,
|
|
482
|
+
*,
|
|
483
|
+
operation_path: str,
|
|
484
|
+
expected_operation: str,
|
|
485
|
+
preview: BulkPreviewResult,
|
|
486
|
+
idempotency_key: str | None,
|
|
487
|
+
reason: str,
|
|
488
|
+
filters: Mapping[str, str],
|
|
489
|
+
start_index: int,
|
|
490
|
+
batch_limit: int | None,
|
|
491
|
+
) -> BulkExecuteResult:
|
|
492
|
+
wire_queue = validate_queue_name(queue_name)
|
|
493
|
+
self._assert_execute_preview_binding(preview, wire_queue, expected_operation)
|
|
494
|
+
wire_token = validate_confirmation_token(preview.confirmation_token)
|
|
495
|
+
wire_reason = validate_reason(reason)
|
|
496
|
+
wire_filters = validate_bulk_filters(filters)
|
|
497
|
+
wire_start = validate_start_index(start_index)
|
|
498
|
+
wire_batch_limit = validate_batch_limit(batch_limit)
|
|
499
|
+
body: dict[str, Any] = {
|
|
500
|
+
"confirmation_token": wire_token,
|
|
501
|
+
"filters": wire_filters,
|
|
502
|
+
"reason": wire_reason,
|
|
503
|
+
"start_index": wire_start,
|
|
504
|
+
}
|
|
505
|
+
if wire_batch_limit is not None:
|
|
506
|
+
body["batch_limit"] = wire_batch_limit
|
|
507
|
+
extra_headers: dict[str, str] = {}
|
|
508
|
+
if idempotency_key is not None:
|
|
509
|
+
extra_headers["Idempotency-Key"] = validate_idempotency_key(idempotency_key)
|
|
510
|
+
path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
|
|
511
|
+
response = self._admin_transport.request(
|
|
512
|
+
"POST",
|
|
513
|
+
path,
|
|
514
|
+
headers=self._auth_headers(extra_headers or None),
|
|
515
|
+
json_body=body,
|
|
516
|
+
)
|
|
517
|
+
return self._parse(BulkExecuteResult.parse, response.status_code, response.body)
|
|
518
|
+
|
|
519
|
+
@staticmethod
|
|
520
|
+
def _assert_execute_preview_binding(
|
|
521
|
+
preview: BulkPreviewResult,
|
|
522
|
+
queue_name: str,
|
|
523
|
+
expected_operation: str,
|
|
524
|
+
) -> None:
|
|
525
|
+
if preview.queue != queue_name:
|
|
526
|
+
raise ValueError("preview.queue does not match queue_name")
|
|
527
|
+
if preview.operation.value != expected_operation:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"preview.operation must be {expected_operation!r}, "
|
|
530
|
+
f"got {preview.operation.value!r}"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _parse(parser: Any, status_code: int, body: object | None) -> Any:
|
|
535
|
+
if body is None:
|
|
536
|
+
raise MalformedResponseError(
|
|
537
|
+
status_code=status_code,
|
|
538
|
+
reason="empty success response body",
|
|
539
|
+
)
|
|
540
|
+
try:
|
|
541
|
+
return parser(body)
|
|
542
|
+
except ValueError as exc:
|
|
543
|
+
raise MalformedResponseError(
|
|
544
|
+
status_code=status_code,
|
|
545
|
+
reason=str(exc),
|
|
546
|
+
) from exc
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _wire_config_version(value: ConfigVersion | int) -> int:
|
|
550
|
+
if isinstance(value, ConfigVersion):
|
|
551
|
+
return value.to_wire()
|
|
552
|
+
return ConfigVersion.parse(value).to_wire()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _wire_policy_version(value: PolicyVersion | int) -> int:
|
|
556
|
+
if isinstance(value, PolicyVersion):
|
|
557
|
+
return value.to_wire()
|
|
558
|
+
return PolicyVersion.parse(value).to_wire()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _wire_queue_state(state: QueueState | str) -> str:
|
|
562
|
+
if isinstance(state, QueueState):
|
|
563
|
+
return validate_known_queue_state(state).value
|
|
564
|
+
if not isinstance(state, str) or not state:
|
|
565
|
+
raise ValueError("state must be a non-empty string")
|
|
566
|
+
return validate_known_queue_state(QueueState.parse(state)).value
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _page_params(*, limit: int, cursor: str | None) -> dict[str, str]:
|
|
570
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
571
|
+
if cursor is not None:
|
|
572
|
+
params["cursor"] = cursor
|
|
573
|
+
return params
|