dominus-sdk-python-dev 2.1.6__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.
- dominus/__init__.py +47 -0
- dominus/config/__init__.py +24 -0
- dominus/config/endpoints.py +39 -0
- dominus/errors.py +201 -0
- dominus/helpers/__init__.py +2 -0
- dominus/helpers/auth.py +23 -0
- dominus/helpers/cache.py +192 -0
- dominus/helpers/core.py +657 -0
- dominus/helpers/crypto.py +118 -0
- dominus/namespaces/__init__.py +26 -0
- dominus/namespaces/_deprecated_crossover.py +10 -0
- dominus/namespaces/_deprecated_sql.py +1341 -0
- dominus/namespaces/auth.py +1056 -0
- dominus/namespaces/courier.py +251 -0
- dominus/namespaces/db.py +267 -0
- dominus/namespaces/ddl.py +590 -0
- dominus/namespaces/files.py +299 -0
- dominus/namespaces/health.py +59 -0
- dominus/namespaces/logs.py +367 -0
- dominus/namespaces/open.py +72 -0
- dominus/namespaces/portal.py +504 -0
- dominus/namespaces/redis.py +357 -0
- dominus/namespaces/secrets.py +126 -0
- dominus/services/__init__.py +2 -0
- dominus/services/_deprecated_architect.py +323 -0
- dominus/services/_deprecated_sovereign.py +93 -0
- dominus/start.py +1034 -0
- dominus_sdk_python_dev-2.1.6.dist-info/METADATA +381 -0
- dominus_sdk_python_dev-2.1.6.dist-info/RECORD +31 -0
- dominus_sdk_python_dev-2.1.6.dist-info/WHEEL +5 -0
- dominus_sdk_python_dev-2.1.6.dist-info/top_level.txt +1 -0
dominus/helpers/core.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core HTTP client, routing, and retry logic.
|
|
3
|
+
|
|
4
|
+
Includes circuit breaker pattern to prevent runaway retries from exhausting
|
|
5
|
+
Cloud Run CPU quota during service outages or circular dependency issues.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import httpx
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from .cache import dominus_cache, sovereign_circuit_breaker, exponential_backoff_with_jitter
|
|
15
|
+
|
|
16
|
+
# Max retries for HTTP requests (reduced from implicit to explicit)
|
|
17
|
+
MAX_RETRIES = 3
|
|
18
|
+
|
|
19
|
+
# Type alias
|
|
20
|
+
DominusResponse = dict[str, Any]
|
|
21
|
+
|
|
22
|
+
# Base64 helpers
|
|
23
|
+
# Encode: Convert dict to JSON, then to raw base64 string (matches middleware expectation)
|
|
24
|
+
_b64_encode = lambda d: base64.b64encode(json.dumps(d).encode()).decode()
|
|
25
|
+
# Decode: Decode raw base64 string to JSON, then parse (matches middleware response format)
|
|
26
|
+
_b64_decode = lambda s: json.loads(base64.b64decode(s.encode('utf-8')).decode('utf-8')) if isinstance(s, str) else s
|
|
27
|
+
_b64_token = lambda t: base64.b64encode(t.encode()).decode()
|
|
28
|
+
|
|
29
|
+
# Base64url helpers for JWT
|
|
30
|
+
def _b64url_decode(s: str) -> bytes:
|
|
31
|
+
"""Decode base64url string (JWT uses base64url, not base64)."""
|
|
32
|
+
# Add padding if needed
|
|
33
|
+
padding = 4 - len(s) % 4
|
|
34
|
+
if padding != 4:
|
|
35
|
+
s += '=' * padding
|
|
36
|
+
# Replace URL-safe characters
|
|
37
|
+
s = s.replace('-', '+').replace('_', '/')
|
|
38
|
+
return base64.b64decode(s)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode_jwt_payload(jwt: str) -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Decode JWT payload without verification (for exp checks only).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
jwt: JWT token string
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Decoded payload dict
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
parts = jwt.split('.')
|
|
53
|
+
if len(parts) != 3:
|
|
54
|
+
raise ValueError("Invalid JWT format")
|
|
55
|
+
payload_b64url = parts[1]
|
|
56
|
+
payload_bytes = _b64url_decode(payload_b64url)
|
|
57
|
+
return json.loads(payload_bytes.decode('utf-8'))
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise ValueError(f"Failed to decode JWT payload: {e}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _get_service_jwt(psk_token: str, base_url: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Get service JWT by calling /api/warden/mint with PSK.
|
|
65
|
+
|
|
66
|
+
Uses circuit breaker to prevent retry storms during service outages.
|
|
67
|
+
Retries on 401/5xx with exponential backoff (orchestrator cold start handling).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
psk_token: PSK token (DOMINUS_TOKEN)
|
|
71
|
+
base_url: Orchestrator base URL
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
JWT token string
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
RuntimeError: If circuit is open or auth fails after retries
|
|
78
|
+
"""
|
|
79
|
+
# Circuit breaker check
|
|
80
|
+
if not sovereign_circuit_breaker.can_execute():
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"Circuit breaker OPEN for auth - "
|
|
83
|
+
f"too many recent failures. Will retry after recovery timeout."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if sovereign_circuit_breaker.state == sovereign_circuit_breaker.HALF_OPEN:
|
|
87
|
+
sovereign_circuit_breaker.record_half_open_call()
|
|
88
|
+
|
|
89
|
+
headers = {
|
|
90
|
+
"Authorization": f"Bearer {psk_token}",
|
|
91
|
+
"Content-Type": "text/plain"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Body format for warden/mint endpoint
|
|
95
|
+
body_json = {"method": "auth.self", "params": {}}
|
|
96
|
+
body_b64 = _b64_encode(body_json)
|
|
97
|
+
|
|
98
|
+
# Retry loop for JWT minting (handles orchestrator cold start)
|
|
99
|
+
JWT_MINT_RETRIES = 3
|
|
100
|
+
last_error = None
|
|
101
|
+
|
|
102
|
+
for attempt in range(JWT_MINT_RETRIES):
|
|
103
|
+
try:
|
|
104
|
+
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
105
|
+
response = await client.post("/api/warden/mint", content=body_b64)
|
|
106
|
+
|
|
107
|
+
# Check for retryable status codes before raise_for_status
|
|
108
|
+
if response.status_code == 401 or response.status_code >= 500:
|
|
109
|
+
if attempt < JWT_MINT_RETRIES - 1:
|
|
110
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
|
|
111
|
+
print(
|
|
112
|
+
f"[Dominus] JWT mint returned {response.status_code}, "
|
|
113
|
+
f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
|
|
114
|
+
)
|
|
115
|
+
await asyncio.sleep(delay)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
|
|
120
|
+
# Decode base64 response
|
|
121
|
+
result = _b64_decode(response.text)
|
|
122
|
+
|
|
123
|
+
if not result.get("success"):
|
|
124
|
+
error_msg = result.get("error", "Unknown auth error")
|
|
125
|
+
sovereign_circuit_breaker.record_failure()
|
|
126
|
+
raise RuntimeError(f"Auth error: {error_msg}")
|
|
127
|
+
|
|
128
|
+
data = result.get("data", {})
|
|
129
|
+
jwt = data.get("access_token") or data.get("token")
|
|
130
|
+
if not jwt:
|
|
131
|
+
sovereign_circuit_breaker.record_failure()
|
|
132
|
+
raise RuntimeError("No JWT token in auth response")
|
|
133
|
+
|
|
134
|
+
# Success - record it
|
|
135
|
+
sovereign_circuit_breaker.record_success()
|
|
136
|
+
return jwt
|
|
137
|
+
|
|
138
|
+
except httpx.TimeoutException as e:
|
|
139
|
+
last_error = e
|
|
140
|
+
if attempt < JWT_MINT_RETRIES - 1:
|
|
141
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
|
|
142
|
+
print(
|
|
143
|
+
f"[Dominus] JWT mint timed out, "
|
|
144
|
+
f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
|
|
145
|
+
)
|
|
146
|
+
await asyncio.sleep(delay)
|
|
147
|
+
continue
|
|
148
|
+
sovereign_circuit_breaker.record_failure()
|
|
149
|
+
raise RuntimeError(f"Failed to get JWT: {e}") from e
|
|
150
|
+
|
|
151
|
+
except httpx.NetworkError as e:
|
|
152
|
+
last_error = e
|
|
153
|
+
if attempt < JWT_MINT_RETRIES - 1:
|
|
154
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
|
|
155
|
+
print(
|
|
156
|
+
f"[Dominus] JWT mint network error ({e}), "
|
|
157
|
+
f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
|
|
158
|
+
)
|
|
159
|
+
await asyncio.sleep(delay)
|
|
160
|
+
continue
|
|
161
|
+
sovereign_circuit_breaker.record_failure()
|
|
162
|
+
raise RuntimeError(f"Failed to get JWT: {e}") from e
|
|
163
|
+
|
|
164
|
+
except httpx.HTTPStatusError as e:
|
|
165
|
+
last_error = e
|
|
166
|
+
# 4xx errors (except 401 which is retried above) should not be retried
|
|
167
|
+
if 400 <= e.response.status_code < 500 and e.response.status_code != 401:
|
|
168
|
+
sovereign_circuit_breaker.record_failure()
|
|
169
|
+
raise RuntimeError(f"Failed to get JWT: {e}") from e
|
|
170
|
+
# For other errors, we've already handled retries in the status check above
|
|
171
|
+
sovereign_circuit_breaker.record_failure()
|
|
172
|
+
raise RuntimeError(f"Failed to get JWT: {e}") from e
|
|
173
|
+
|
|
174
|
+
# Should not reach here, but just in case
|
|
175
|
+
sovereign_circuit_breaker.record_failure()
|
|
176
|
+
raise RuntimeError(f"Failed to get JWT after {JWT_MINT_RETRIES} retries: {last_error}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_architect_url(psk_token: str = None, sovereign_url: str = None, environment: str = None) -> str:
|
|
180
|
+
"""
|
|
181
|
+
Get Architect Cloud URL from SDK config.
|
|
182
|
+
|
|
183
|
+
Uses flat file config (dominus/config/endpoints.py) for URL lookup.
|
|
184
|
+
No network calls needed - URLs are hardcoded constants.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
psk_token: Unused (kept for backward compatibility)
|
|
188
|
+
sovereign_url: Unused (kept for backward compatibility)
|
|
189
|
+
environment: Environment override (development, staging, production).
|
|
190
|
+
Defaults to CB_ENVIRONMENT env var or "production".
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Architect base URL string
|
|
194
|
+
"""
|
|
195
|
+
from ..config.endpoints import get_architect_url
|
|
196
|
+
return get_architect_url(environment)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _ensure_valid_jwt(psk_token: str, sovereign_url: str) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Ensure we have a valid JWT, fetching and caching if needed.
|
|
202
|
+
|
|
203
|
+
Cache key: "jwt:self:service"
|
|
204
|
+
Cache TTL: 14 minutes (JWT lifetime is 15 minutes)
|
|
205
|
+
Refresh when <60 seconds remain.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
psk_token: PSK token (DOMINUS_TOKEN)
|
|
209
|
+
sovereign_url: Sovereign base URL
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Valid JWT token string
|
|
213
|
+
"""
|
|
214
|
+
cache_key = "jwt:self:service"
|
|
215
|
+
|
|
216
|
+
# Check cache
|
|
217
|
+
cached_jwt = dominus_cache.get(cache_key)
|
|
218
|
+
if cached_jwt:
|
|
219
|
+
try:
|
|
220
|
+
# Decode payload to check expiry
|
|
221
|
+
payload = _decode_jwt_payload(cached_jwt)
|
|
222
|
+
exp = payload.get("exp", 0)
|
|
223
|
+
current_time = int(time.time())
|
|
224
|
+
|
|
225
|
+
# Refresh if <60 seconds remain
|
|
226
|
+
if exp - current_time > 60:
|
|
227
|
+
return cached_jwt
|
|
228
|
+
except Exception:
|
|
229
|
+
# If decode fails, fetch new JWT
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# Fetch new JWT
|
|
233
|
+
jwt = await _get_service_jwt(psk_token, sovereign_url)
|
|
234
|
+
|
|
235
|
+
# Cache for 14 minutes (840 seconds)
|
|
236
|
+
dominus_cache.set(cache_key, jwt, ttl=840)
|
|
237
|
+
|
|
238
|
+
return jwt
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def verify_token_format(token: str) -> bool:
|
|
242
|
+
"""
|
|
243
|
+
Verify token format is valid (basic check, not server verification).
|
|
244
|
+
Actual token validation happens when we call /api/warden/mint.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
token: Auth token to verify
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if token format is valid
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
RuntimeError: If token format is invalid
|
|
254
|
+
"""
|
|
255
|
+
# PSK tokens are 64-character hex strings
|
|
256
|
+
if not token or len(token) < 32:
|
|
257
|
+
raise RuntimeError("Invalid token format")
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def health_check_all(base_url: str) -> dict:
|
|
262
|
+
"""
|
|
263
|
+
Check health of orchestrator services.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
base_url: Orchestrator base URL
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Health status dict with service results
|
|
270
|
+
"""
|
|
271
|
+
results = {}
|
|
272
|
+
|
|
273
|
+
# Check orchestrator via /api/health
|
|
274
|
+
try:
|
|
275
|
+
start = time.time()
|
|
276
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=5.0) as client:
|
|
277
|
+
response = await client.get("/api/health")
|
|
278
|
+
response.raise_for_status()
|
|
279
|
+
|
|
280
|
+
# Parse response (may be JSON string or dict)
|
|
281
|
+
health_data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {"status": "ok"}
|
|
282
|
+
|
|
283
|
+
latency = int((time.time() - start) * 1000)
|
|
284
|
+
results["orchestrator"] = {
|
|
285
|
+
"status": health_data.get("status", "healthy"),
|
|
286
|
+
"latency_ms": latency,
|
|
287
|
+
**health_data
|
|
288
|
+
}
|
|
289
|
+
except Exception as e:
|
|
290
|
+
results["orchestrator"] = {"status": "unhealthy", "error": str(e)}
|
|
291
|
+
return {"status": "unhealthy", "services": results, "message": "Orchestrator unhealthy"}
|
|
292
|
+
|
|
293
|
+
return {"status": "healthy", "services": results, "message": "All services healthy"}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def get_service_url(service_name: str, token: str, sovereign_url: str) -> str:
|
|
297
|
+
"""
|
|
298
|
+
Get service base URL. Cache-first, then asks sovereign.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
service_name: "architect", "notifier", etc.
|
|
302
|
+
token: Auth token (for sovereign API call)
|
|
303
|
+
sovereign_url: Sovereign base URL
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Base URL for the service
|
|
307
|
+
"""
|
|
308
|
+
cache_key = f"service_url:{service_name}"
|
|
309
|
+
|
|
310
|
+
# Check cache first
|
|
311
|
+
cached = dominus_cache.get(cache_key)
|
|
312
|
+
if cached:
|
|
313
|
+
return cached
|
|
314
|
+
|
|
315
|
+
# Ask sovereign (future endpoint)
|
|
316
|
+
# TODO: Create endpoint in sovereign to return service URLs
|
|
317
|
+
# For now, construct from pattern
|
|
318
|
+
url = f"https://{service_name}-cloud-production-775398158805.us-east4.run.app"
|
|
319
|
+
|
|
320
|
+
# Cache with long TTL (URLs don't change often)
|
|
321
|
+
dominus_cache.set(cache_key, url, ttl=3600) # 1 hour
|
|
322
|
+
|
|
323
|
+
return url
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def execute_with_retry(
|
|
327
|
+
route_info: tuple[str, str, bool, bool],
|
|
328
|
+
base_url: str,
|
|
329
|
+
token: str | None,
|
|
330
|
+
kwargs: dict
|
|
331
|
+
) -> DominusResponse:
|
|
332
|
+
"""
|
|
333
|
+
Execute HTTP request with retry logic.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
route_info: (method, path, requires_auth, cacheable)
|
|
337
|
+
base_url: Base URL for API
|
|
338
|
+
token: Auth token (if needed)
|
|
339
|
+
kwargs: Request parameters
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Response dict
|
|
343
|
+
"""
|
|
344
|
+
method, path, requires_auth, cacheable = route_info
|
|
345
|
+
|
|
346
|
+
# Check cache first
|
|
347
|
+
if cacheable and kwargs:
|
|
348
|
+
cache_key = f"{path}:{str(sorted(kwargs.items()))}"
|
|
349
|
+
cached = dominus_cache.get(cache_key)
|
|
350
|
+
if cached:
|
|
351
|
+
return cached
|
|
352
|
+
|
|
353
|
+
# Validate token
|
|
354
|
+
if requires_auth and not token:
|
|
355
|
+
raise RuntimeError(
|
|
356
|
+
"DOMINUS_TOKEN not set. "
|
|
357
|
+
"Set the environment variable: export DOMINUS_TOKEN=your_token"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Retry loop with exponential backoff and jitter
|
|
361
|
+
for attempt in range(MAX_RETRIES):
|
|
362
|
+
try:
|
|
363
|
+
# Prepare headers
|
|
364
|
+
headers = {}
|
|
365
|
+
if requires_auth:
|
|
366
|
+
headers["Authorization"] = f"Bearer {_b64_token(token)}"
|
|
367
|
+
|
|
368
|
+
# Prepare body (encode if auth required and kwargs provided)
|
|
369
|
+
# For auth routes: send raw base64 string as text/plain (matches middleware expectation)
|
|
370
|
+
# For non-auth routes: send JSON as normal
|
|
371
|
+
if requires_auth and kwargs:
|
|
372
|
+
body_b64 = _b64_encode(kwargs)
|
|
373
|
+
headers["Content-Type"] = "text/plain"
|
|
374
|
+
body = body_b64
|
|
375
|
+
else:
|
|
376
|
+
body = kwargs if kwargs else {}
|
|
377
|
+
|
|
378
|
+
# Make request
|
|
379
|
+
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
380
|
+
if method == "GET":
|
|
381
|
+
response = await client.get(path, params=kwargs if kwargs else None)
|
|
382
|
+
else:
|
|
383
|
+
if requires_auth and kwargs:
|
|
384
|
+
# Send raw base64 string as text
|
|
385
|
+
response = await client.post(path, content=body)
|
|
386
|
+
else:
|
|
387
|
+
# Send JSON as normal
|
|
388
|
+
response = await client.post(path, json=body if body else {})
|
|
389
|
+
|
|
390
|
+
response.raise_for_status()
|
|
391
|
+
|
|
392
|
+
# Decode response
|
|
393
|
+
# For auth routes: middleware returns raw base64 string as text/plain
|
|
394
|
+
# For non-auth routes: normal JSON response
|
|
395
|
+
if requires_auth:
|
|
396
|
+
# Read response as text (base64 string), then decode
|
|
397
|
+
result = _b64_decode(response.text)
|
|
398
|
+
else:
|
|
399
|
+
result = response.json()
|
|
400
|
+
|
|
401
|
+
# Cache if needed (encrypted automatically)
|
|
402
|
+
if cacheable and kwargs:
|
|
403
|
+
dominus_cache.set(cache_key, result, ttl=300)
|
|
404
|
+
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
408
|
+
if attempt == MAX_RETRIES - 1: # Last attempt
|
|
409
|
+
raise RuntimeError(f"Request failed after {MAX_RETRIES} retries: {e}") from e
|
|
410
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
|
|
411
|
+
await asyncio.sleep(delay)
|
|
412
|
+
|
|
413
|
+
except httpx.HTTPStatusError as e:
|
|
414
|
+
# Don't retry 4xx errors (client errors)
|
|
415
|
+
if 400 <= e.response.status_code < 500:
|
|
416
|
+
# Try to decode error response for better error messages
|
|
417
|
+
error_detail = f"HTTP {e.response.status_code}"
|
|
418
|
+
try:
|
|
419
|
+
if requires_auth:
|
|
420
|
+
# Auth routes return base64-encoded responses
|
|
421
|
+
error_body = _b64_decode(e.response.text)
|
|
422
|
+
if isinstance(error_body, dict):
|
|
423
|
+
error_detail = error_body.get("error") or error_body.get("detail") or str(error_body)
|
|
424
|
+
else:
|
|
425
|
+
error_detail = str(error_body)
|
|
426
|
+
else:
|
|
427
|
+
# Non-auth routes return JSON
|
|
428
|
+
error_body = e.response.json()
|
|
429
|
+
error_detail = error_body.get("error") or error_body.get("detail") or str(error_body)
|
|
430
|
+
except Exception:
|
|
431
|
+
# If decoding fails, use response text as-is
|
|
432
|
+
error_detail = e.response.text[:200] if e.response.text else str(e)
|
|
433
|
+
|
|
434
|
+
raise RuntimeError(f"{error_detail} (status {e.response.status_code})") from e
|
|
435
|
+
if attempt == 2: # Last attempt
|
|
436
|
+
raise
|
|
437
|
+
await asyncio.sleep(2 ** attempt)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def execute_bridge_call(
|
|
441
|
+
method: str,
|
|
442
|
+
base_url: str,
|
|
443
|
+
token: str,
|
|
444
|
+
params: dict,
|
|
445
|
+
cacheable: bool = False,
|
|
446
|
+
endpoint: str = "/newapi/bridge"
|
|
447
|
+
) -> DominusResponse:
|
|
448
|
+
"""
|
|
449
|
+
Execute a call to the Bridge API (or crossover endpoint).
|
|
450
|
+
|
|
451
|
+
The Bridge API uses:
|
|
452
|
+
- JWT token in Authorization header (obtained via _ensure_valid_jwt)
|
|
453
|
+
- Base64-encoded JSON body: {"method": "...", "params": {...}}
|
|
454
|
+
- Base64-encoded JSON response: {"success": true, "data": {...}}
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
method: Bridge method name (e.g., "secrets.get")
|
|
458
|
+
base_url: Base URL for sovereign
|
|
459
|
+
token: PSK token (used to get JWT via _ensure_valid_jwt)
|
|
460
|
+
params: Method parameters
|
|
461
|
+
cacheable: Whether to cache the result
|
|
462
|
+
endpoint: API endpoint path (default: "/newapi/bridge", can be "/newapi/crossover")
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Response data (the "data" field from successful response)
|
|
466
|
+
"""
|
|
467
|
+
# Check cache first
|
|
468
|
+
if cacheable and params:
|
|
469
|
+
cache_key = f"bridge:{method}:{str(sorted(params.items()))}"
|
|
470
|
+
cached = dominus_cache.get(cache_key)
|
|
471
|
+
if cached:
|
|
472
|
+
return cached
|
|
473
|
+
|
|
474
|
+
if not token:
|
|
475
|
+
raise RuntimeError(
|
|
476
|
+
"DOMINUS_TOKEN not set. "
|
|
477
|
+
"Set the environment variable: export DOMINUS_TOKEN=your_token"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Get JWT for Bridge/Crossover calls
|
|
481
|
+
jwt = await _ensure_valid_jwt(token, base_url)
|
|
482
|
+
|
|
483
|
+
# Prepare Bridge API request with JWT
|
|
484
|
+
headers = {
|
|
485
|
+
"Authorization": f"Bearer {jwt}",
|
|
486
|
+
"Content-Type": "text/plain"
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# Body format: {"method": "...", "params": {...}}
|
|
490
|
+
body_json = {"method": method, "params": params}
|
|
491
|
+
body_b64 = _b64_encode(body_json)
|
|
492
|
+
|
|
493
|
+
# Retry loop with exponential backoff and jitter
|
|
494
|
+
for attempt in range(MAX_RETRIES):
|
|
495
|
+
try:
|
|
496
|
+
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
497
|
+
response = await client.post(endpoint, content=body_b64)
|
|
498
|
+
|
|
499
|
+
response.raise_for_status()
|
|
500
|
+
|
|
501
|
+
# Decode base64 response
|
|
502
|
+
result = _b64_decode(response.text)
|
|
503
|
+
|
|
504
|
+
# Bridge API returns {"success": true, "data": {...}} or {"success": false, "error": "..."}
|
|
505
|
+
if not result.get("success"):
|
|
506
|
+
error_msg = result.get("error", "Unknown bridge error")
|
|
507
|
+
raise RuntimeError(f"Bridge error: {error_msg}")
|
|
508
|
+
|
|
509
|
+
data = result.get("data", {})
|
|
510
|
+
|
|
511
|
+
# Cache if needed
|
|
512
|
+
if cacheable and params:
|
|
513
|
+
dominus_cache.set(cache_key, data, ttl=300)
|
|
514
|
+
|
|
515
|
+
return data
|
|
516
|
+
|
|
517
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
518
|
+
if attempt == MAX_RETRIES - 1:
|
|
519
|
+
raise RuntimeError(f"Bridge call failed after {MAX_RETRIES} retries: {e}") from e
|
|
520
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
|
|
521
|
+
await asyncio.sleep(delay)
|
|
522
|
+
|
|
523
|
+
except httpx.HTTPStatusError as e:
|
|
524
|
+
# Don't retry 4xx errors
|
|
525
|
+
if 400 <= e.response.status_code < 500:
|
|
526
|
+
error_detail = f"HTTP {e.response.status_code}"
|
|
527
|
+
try:
|
|
528
|
+
error_body = _b64_decode(e.response.text)
|
|
529
|
+
if isinstance(error_body, dict):
|
|
530
|
+
error_detail = error_body.get("error") or str(error_body)
|
|
531
|
+
else:
|
|
532
|
+
error_detail = str(error_body)
|
|
533
|
+
except Exception:
|
|
534
|
+
error_detail = e.response.text[:200] if e.response.text else str(e)
|
|
535
|
+
|
|
536
|
+
raise RuntimeError(f"{error_detail} (status {e.response.status_code})") from e
|
|
537
|
+
if attempt == MAX_RETRIES - 1:
|
|
538
|
+
raise RuntimeError(f"Bridge call failed after {MAX_RETRIES} retries: {e}") from e
|
|
539
|
+
delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
|
|
540
|
+
await asyncio.sleep(delay)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
async def execute_command(command: str, token: str, sovereign_url: str, **kwargs) -> DominusResponse:
|
|
544
|
+
"""
|
|
545
|
+
Execute a command by routing to the appropriate service handler.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
command: Command string (e.g., "secrets.get", "health.check", "auth.self")
|
|
549
|
+
token: PSK auth token (DOMINUS_TOKEN)
|
|
550
|
+
sovereign_url: Sovereign base URL
|
|
551
|
+
**kwargs: Command parameters
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
Response dict
|
|
555
|
+
"""
|
|
556
|
+
# Special command: health.check
|
|
557
|
+
if command == "health.check":
|
|
558
|
+
return await health_check_all(sovereign_url)
|
|
559
|
+
|
|
560
|
+
# Special command: auth.self (get service JWT)
|
|
561
|
+
if command == "auth.self":
|
|
562
|
+
jwt = await _ensure_valid_jwt(token, sovereign_url)
|
|
563
|
+
return {"access_token": jwt, "token_type": "bearer"}
|
|
564
|
+
|
|
565
|
+
# Route Sovereign auth commands to /newapi/auth
|
|
566
|
+
# auth.jwks is public (no PSK required), auth.mint requires PSK
|
|
567
|
+
if command == "auth.jwks":
|
|
568
|
+
return await _execute_auth_call(command, None, sovereign_url, **kwargs)
|
|
569
|
+
elif command == "auth.mint":
|
|
570
|
+
return await _execute_auth_call(command, token, sovereign_url, **kwargs)
|
|
571
|
+
|
|
572
|
+
# Route sql.* commands to Architect
|
|
573
|
+
if command.startswith("sql."):
|
|
574
|
+
from ..services import architect
|
|
575
|
+
architect_url = _get_architect_url(token, sovereign_url)
|
|
576
|
+
return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
|
|
577
|
+
|
|
578
|
+
# Route auth.* commands to Architect (subsidiary auth management)
|
|
579
|
+
# Note: auth.jwks and auth.mint are handled above (Sovereign)
|
|
580
|
+
if command.startswith("auth."):
|
|
581
|
+
from ..services import architect
|
|
582
|
+
architect_url = _get_architect_url(token, sovereign_url)
|
|
583
|
+
return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
|
|
584
|
+
|
|
585
|
+
# Route schema.* commands to Architect
|
|
586
|
+
if command.startswith("schema."):
|
|
587
|
+
from ..services import architect
|
|
588
|
+
architect_url = _get_architect_url(token, sovereign_url)
|
|
589
|
+
return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
|
|
590
|
+
|
|
591
|
+
# Route crossover.sql.* to Architect Crossover
|
|
592
|
+
if command.startswith("crossover.sql."):
|
|
593
|
+
from ..services import architect
|
|
594
|
+
architect_url = _get_architect_url(token, sovereign_url)
|
|
595
|
+
return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
|
|
596
|
+
|
|
597
|
+
# Route crossover.* (non-sql) to Sovereign Crossover
|
|
598
|
+
if command.startswith("crossover."):
|
|
599
|
+
from ..services import sovereign
|
|
600
|
+
return await sovereign.handle(command, token, sovereign_url, **kwargs)
|
|
601
|
+
|
|
602
|
+
# Default to Sovereign
|
|
603
|
+
from ..services import sovereign
|
|
604
|
+
return await sovereign.handle(command, token, sovereign_url, **kwargs)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
async def _execute_auth_call(
|
|
608
|
+
method: str,
|
|
609
|
+
psk_token: Optional[str],
|
|
610
|
+
base_url: str,
|
|
611
|
+
**kwargs
|
|
612
|
+
) -> DominusResponse:
|
|
613
|
+
"""
|
|
614
|
+
Execute an auth call to /api/warden/* endpoints.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
method: Auth method name (e.g., "auth.mint", "auth.jwks")
|
|
618
|
+
psk_token: PSK token (DOMINUS_TOKEN) - None for public endpoints like auth.jwks
|
|
619
|
+
base_url: Orchestrator base URL
|
|
620
|
+
**kwargs: Method parameters
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
Response data
|
|
624
|
+
"""
|
|
625
|
+
headers = {
|
|
626
|
+
"Content-Type": "text/plain"
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
# Add PSK header only if token provided (auth.jwks is public)
|
|
630
|
+
if psk_token:
|
|
631
|
+
headers["Authorization"] = f"Bearer {psk_token}"
|
|
632
|
+
|
|
633
|
+
# Body format: {"method": "...", "params": {...}}
|
|
634
|
+
body_json = {"method": method, "params": kwargs}
|
|
635
|
+
body_b64 = _b64_encode(body_json)
|
|
636
|
+
|
|
637
|
+
# Map method to endpoint
|
|
638
|
+
if method == "auth.jwks":
|
|
639
|
+
endpoint = "/api/warden/jwks"
|
|
640
|
+
elif method == "auth.mint":
|
|
641
|
+
endpoint = "/api/warden/mint"
|
|
642
|
+
else:
|
|
643
|
+
endpoint = "/api/warden/mint"
|
|
644
|
+
|
|
645
|
+
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
646
|
+
response = await client.post(endpoint, content=body_b64)
|
|
647
|
+
response.raise_for_status()
|
|
648
|
+
|
|
649
|
+
# Decode base64 response
|
|
650
|
+
result = _b64_decode(response.text)
|
|
651
|
+
|
|
652
|
+
if not result.get("success"):
|
|
653
|
+
error_msg = result.get("error", "Unknown auth error")
|
|
654
|
+
raise RuntimeError(f"Auth error: {error_msg}")
|
|
655
|
+
|
|
656
|
+
return result.get("data", {})
|
|
657
|
+
|