blun-king-cli 9.1.597 → 9.1.600
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.
- package/CHANGELOG.md +39 -0
- package/README.md +17 -1
- package/agent-spine-plugin/CHANGELOG.md +1 -1
- package/agent-spine-plugin/docs/host-integration.md +1 -15
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install.js +1 -6
- package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
- package/agent-spine-plugin/scripts/release-check.js +2 -3
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
- package/agent-spine-plugin/src/hook.js +40 -40
- package/agent-spine-plugin/src/lib/briefing.js +6 -3
- package/agent-spine-plugin/src/lib/hook-context.js +63 -64
- package/agent-spine-plugin/src/lib/hook-output.js +115 -94
- package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
- package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
- package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
- package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
- package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
- package/agent-spine-plugin/src/lib/source-roots.js +26 -16
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
- package/bin/core-bootstrap.js +18 -10
- package/bin/launcher-mode.js +5 -0
- package/bin/launcher-runtime.js +27 -0
- package/bin/verify-bundled-agent-sources.cjs +28 -2
- package/blun.mjs +798 -143
- package/bundled-agent-sources.json +192 -155
- package/package.json +1 -1
- package/standard-skills/translate-native/README.md +1334 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
- package/standard-skills/translate-native/integrations/website_localization.py +234 -10
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
- package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
- package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
- package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
- package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/commercial-localization.md +96 -17
- package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/VERSION +1 -1
- package/standard-tools/language-guard/blun_language_guard.py +3 -1
- package/standard-tools/language-guard/check_commercial_review.py +69 -9
- package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/provenance.json +2 -2
- package/standard-tools/manifest.json +1 -1
- package/worker-host.mjs +215 -82
- package/agent-spine-plugin/scripts/check-install-king.js +0 -37
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract-pinned HTTPS client for the website-source delivery sidecar.
|
|
3
|
+
|
|
4
|
+
CMS and website backends use this adapter to persist one immutable change,
|
|
5
|
+
cancellation, or tombstone without sharing a Python runtime with the outbox.
|
|
6
|
+
Every operation verifies the live sidecar contract, uses one fixed origin and
|
|
7
|
+
one transport attempt, and validates the response against both the sidecar and
|
|
8
|
+
downstream source-service capability pins. Retry scheduling remains external.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import socket
|
|
19
|
+
import sys
|
|
20
|
+
import unicodedata
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Mapping, Protocol
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MAX_ENDPOINT_LENGTH = 2_048
|
|
30
|
+
MAX_HEADER_VALUE_LENGTH = 4_096
|
|
31
|
+
# Six transport-owned headers may be added after credential construction.
|
|
32
|
+
MAX_AUTHENTICATION_HEADERS = 58
|
|
33
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
34
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
35
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
36
|
+
RESERVED_HEADERS = {
|
|
37
|
+
"accept", "connection", "content-length", "content-type", "host",
|
|
38
|
+
"idempotency-key", "transfer-encoding",
|
|
39
|
+
"x-localization-source-payload-sha256",
|
|
40
|
+
}
|
|
41
|
+
AUTH_CONTEXT_SCHEMA = (
|
|
42
|
+
"blun.cms-source-delivery-sidecar-client-auth-context.v1"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_module(name: str, path: Path):
|
|
47
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
48
|
+
if spec is None or spec.loader is None:
|
|
49
|
+
raise RuntimeError(f"cannot load sidecar client dependency: {path.name}")
|
|
50
|
+
module = importlib.util.module_from_spec(spec)
|
|
51
|
+
sys.modules[spec.name] = module
|
|
52
|
+
spec.loader.exec_module(module)
|
|
53
|
+
return module
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
57
|
+
_HTTP = _load_module(
|
|
58
|
+
"blun_website_localization_cms_source_delivery_client_http",
|
|
59
|
+
_ROOT / "integrations" / "website_localization_cms_source_delivery_http.py",
|
|
60
|
+
)
|
|
61
|
+
_CMS = _load_module(
|
|
62
|
+
"blun_website_localization_cms_source_delivery_client_cms",
|
|
63
|
+
_ROOT / "integrations" / "website_localization_cms.py",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CMSSourceDeliveryClientBlocked(RuntimeError):
|
|
68
|
+
"""Stable content-free client failure with a retry decision."""
|
|
69
|
+
|
|
70
|
+
cms_source_delivery_client_failure = True
|
|
71
|
+
|
|
72
|
+
def __init__(self, code: str, *, retryable: bool):
|
|
73
|
+
if ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool):
|
|
74
|
+
raise ValueError("source delivery client failure is invalid")
|
|
75
|
+
super().__init__(code)
|
|
76
|
+
self.code = code
|
|
77
|
+
self.retryable = retryable
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class HTTPResult:
|
|
82
|
+
status: int
|
|
83
|
+
headers: tuple[tuple[str, str], ...]
|
|
84
|
+
body: bytes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HTTPTransport(Protocol):
|
|
88
|
+
def request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
url: str,
|
|
92
|
+
headers: Mapping[str, str],
|
|
93
|
+
body: bytes | None,
|
|
94
|
+
*,
|
|
95
|
+
timeout: float,
|
|
96
|
+
) -> HTTPResult: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
100
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class URLTransport:
|
|
105
|
+
"""Perform one bounded HTTPS request without following redirects."""
|
|
106
|
+
|
|
107
|
+
def __init__(self):
|
|
108
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
109
|
+
|
|
110
|
+
def request(self, method, url, headers, body, *, timeout):
|
|
111
|
+
request = urllib.request.Request(
|
|
112
|
+
url, data=body, headers=dict(headers), method=method,
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
response = self._opener.open(request, timeout=timeout)
|
|
116
|
+
except urllib.error.HTTPError as error:
|
|
117
|
+
response = error
|
|
118
|
+
except (urllib.error.URLError, TimeoutError, socket.timeout, OSError):
|
|
119
|
+
_fail("network", retryable=True)
|
|
120
|
+
try:
|
|
121
|
+
raw = response.read(_HTTP.MAX_BODY_BYTES + 1)
|
|
122
|
+
return HTTPResult(
|
|
123
|
+
int(response.status), tuple(response.headers.items()), raw,
|
|
124
|
+
)
|
|
125
|
+
except (TimeoutError, socket.timeout, OSError):
|
|
126
|
+
_fail("network", retryable=True)
|
|
127
|
+
finally:
|
|
128
|
+
response.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fail(code: str, *, retryable: bool = False) -> None:
|
|
132
|
+
raise CMSSourceDeliveryClientBlocked(
|
|
133
|
+
"source_delivery_client." + code, retryable=retryable,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _canonical(value: Any) -> bytes:
|
|
138
|
+
try:
|
|
139
|
+
raw = json.dumps(
|
|
140
|
+
value, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
141
|
+
separators=(",", ":"),
|
|
142
|
+
).encode("utf-8")
|
|
143
|
+
except (TypeError, ValueError, RecursionError):
|
|
144
|
+
_fail("request_invalid")
|
|
145
|
+
if not raw or len(raw) > _HTTP.MAX_BODY_BYTES:
|
|
146
|
+
_fail("request_invalid")
|
|
147
|
+
return raw
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
151
|
+
result: dict[str, Any] = {}
|
|
152
|
+
for key, value in items:
|
|
153
|
+
if key in result:
|
|
154
|
+
raise ValueError("duplicate key")
|
|
155
|
+
result[key] = value
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _constant(_value: str) -> None:
|
|
160
|
+
raise ValueError("non-finite number")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _token(value: Any) -> bool:
|
|
164
|
+
return (
|
|
165
|
+
isinstance(value, str)
|
|
166
|
+
and TOKEN.fullmatch(value) is not None
|
|
167
|
+
and unicodedata.is_normalized("NFC", value)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sha256(value: Any) -> bool:
|
|
172
|
+
return isinstance(value, str) and SHA256.fullmatch(value) is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _count(value: Any, *, minimum: int = 0, maximum: int = 1_000_000) -> bool:
|
|
176
|
+
return (
|
|
177
|
+
isinstance(value, int)
|
|
178
|
+
and not isinstance(value, bool)
|
|
179
|
+
and minimum <= value <= maximum
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _timestamp(value: Any, *, nullable: bool = False) -> bool:
|
|
184
|
+
return (
|
|
185
|
+
nullable and value is None
|
|
186
|
+
or isinstance(value, (int, float))
|
|
187
|
+
and not isinstance(value, bool)
|
|
188
|
+
and math.isfinite(float(value))
|
|
189
|
+
and float(value) >= 0
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _endpoint(value: Any, allow_loopback_http: bool) -> str:
|
|
194
|
+
if (
|
|
195
|
+
not isinstance(value, str)
|
|
196
|
+
or value != value.strip()
|
|
197
|
+
or not value
|
|
198
|
+
or not value.isascii()
|
|
199
|
+
or len(value) > MAX_ENDPOINT_LENGTH
|
|
200
|
+
or any(ord(character) <= 32 or ord(character) == 127 for character in value)
|
|
201
|
+
):
|
|
202
|
+
raise ValueError("origin is invalid")
|
|
203
|
+
try:
|
|
204
|
+
parsed = urllib.parse.urlsplit(value)
|
|
205
|
+
hostname = parsed.hostname
|
|
206
|
+
port = parsed.port
|
|
207
|
+
except ValueError:
|
|
208
|
+
raise ValueError("origin is invalid") from None
|
|
209
|
+
secure = parsed.scheme == "https"
|
|
210
|
+
loopback = (
|
|
211
|
+
parsed.scheme == "http"
|
|
212
|
+
and allow_loopback_http
|
|
213
|
+
and isinstance(hostname, str)
|
|
214
|
+
and hostname.lower() in {"localhost", "127.0.0.1", "::1"}
|
|
215
|
+
)
|
|
216
|
+
if (
|
|
217
|
+
not (secure or loopback)
|
|
218
|
+
or not hostname
|
|
219
|
+
or parsed.username is not None
|
|
220
|
+
or parsed.password is not None
|
|
221
|
+
or parsed.path
|
|
222
|
+
or parsed.query
|
|
223
|
+
or parsed.fragment
|
|
224
|
+
or port is not None and not 1 <= port <= 65_535
|
|
225
|
+
or value != f"{parsed.scheme}://{parsed.netloc}"
|
|
226
|
+
):
|
|
227
|
+
raise ValueError("origin must be one exact HTTPS origin")
|
|
228
|
+
return value
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _authentication_headers(provider, context) -> dict[str, str]:
|
|
232
|
+
try:
|
|
233
|
+
supplied = provider(dict(context))
|
|
234
|
+
if not isinstance(supplied, Mapping):
|
|
235
|
+
raise TypeError
|
|
236
|
+
items = tuple(supplied.items())
|
|
237
|
+
except Exception:
|
|
238
|
+
_fail("authentication")
|
|
239
|
+
if len(items) > MAX_AUTHENTICATION_HEADERS:
|
|
240
|
+
_fail("authentication")
|
|
241
|
+
result: dict[str, str] = {}
|
|
242
|
+
names: set[str] = set()
|
|
243
|
+
for name, value in items:
|
|
244
|
+
normalized = name.lower() if isinstance(name, str) else ""
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(name, str)
|
|
247
|
+
or _HTTP.HEADER_NAME.fullmatch(name) is None
|
|
248
|
+
or normalized in RESERVED_HEADERS
|
|
249
|
+
or normalized in names
|
|
250
|
+
or not isinstance(value, str)
|
|
251
|
+
or not value
|
|
252
|
+
or len(value) > MAX_HEADER_VALUE_LENGTH
|
|
253
|
+
or "\r" in value
|
|
254
|
+
or "\n" in value
|
|
255
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
256
|
+
):
|
|
257
|
+
_fail("authentication")
|
|
258
|
+
names.add(normalized)
|
|
259
|
+
result[name] = value
|
|
260
|
+
if not result:
|
|
261
|
+
_fail("authentication")
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _response_headers(value: Any) -> dict[str, str]:
|
|
266
|
+
if not isinstance(value, tuple):
|
|
267
|
+
_fail("transport_invalid", retryable=True)
|
|
268
|
+
result: dict[str, str] = {}
|
|
269
|
+
for item in value:
|
|
270
|
+
if (
|
|
271
|
+
not isinstance(item, tuple)
|
|
272
|
+
or len(item) != 2
|
|
273
|
+
or not all(isinstance(part, str) for part in item)
|
|
274
|
+
):
|
|
275
|
+
_fail("transport_invalid", retryable=True)
|
|
276
|
+
name, content = item[0].lower(), item[1].strip()
|
|
277
|
+
if name in result:
|
|
278
|
+
_fail("response_headers")
|
|
279
|
+
result[name] = content
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _json_response(result: Any, allowed_statuses: set[int]) -> dict[str, Any]:
|
|
284
|
+
if (
|
|
285
|
+
not isinstance(result, HTTPResult)
|
|
286
|
+
or isinstance(result.status, bool)
|
|
287
|
+
or not isinstance(result.status, int)
|
|
288
|
+
or not 100 <= result.status <= 599
|
|
289
|
+
):
|
|
290
|
+
_fail("transport_invalid", retryable=True)
|
|
291
|
+
if result.status not in allowed_statuses:
|
|
292
|
+
if 300 <= result.status <= 399:
|
|
293
|
+
_fail("redirect")
|
|
294
|
+
retryable = result.status in {408, 425, 429} or result.status >= 500
|
|
295
|
+
_fail("http_status", retryable=retryable)
|
|
296
|
+
headers = _response_headers(result.headers)
|
|
297
|
+
content_type = headers.get("content-type", "").lower().replace(" ", "")
|
|
298
|
+
if content_type not in {"application/json", "application/json;charset=utf-8"}:
|
|
299
|
+
_fail("response_content_type")
|
|
300
|
+
if (
|
|
301
|
+
not isinstance(result.body, bytes)
|
|
302
|
+
or not result.body
|
|
303
|
+
or len(result.body) > _HTTP.MAX_BODY_BYTES
|
|
304
|
+
):
|
|
305
|
+
_fail("response_size")
|
|
306
|
+
declared = headers.get("content-length")
|
|
307
|
+
if declared is not None and (
|
|
308
|
+
not declared.isascii()
|
|
309
|
+
or not declared.isdecimal()
|
|
310
|
+
or int(declared) != len(result.body)
|
|
311
|
+
):
|
|
312
|
+
_fail("response_size")
|
|
313
|
+
try:
|
|
314
|
+
text = result.body.decode("utf-8")
|
|
315
|
+
if text.startswith("\ufeff"):
|
|
316
|
+
raise ValueError
|
|
317
|
+
value = json.loads(
|
|
318
|
+
text, object_pairs_hook=_pairs, parse_constant=_constant,
|
|
319
|
+
)
|
|
320
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
|
|
321
|
+
_fail("response_json")
|
|
322
|
+
if not isinstance(value, dict):
|
|
323
|
+
_fail("response_binding")
|
|
324
|
+
return value
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _copy_payload(value: Any, operation: str) -> tuple[dict[str, Any], bytes]:
|
|
328
|
+
if not isinstance(value, Mapping):
|
|
329
|
+
_fail("request_invalid")
|
|
330
|
+
raw = _canonical(dict(value))
|
|
331
|
+
try:
|
|
332
|
+
copied = json.loads(raw.decode("utf-8"), object_pairs_hook=_pairs)
|
|
333
|
+
if operation == "change":
|
|
334
|
+
_CMS.WebsiteLocalizationCMSBridge._validated_event(None, copied)
|
|
335
|
+
elif copied.get("schema") == _CMS.CANCELLATION_SCHEMA:
|
|
336
|
+
_CMS.WebsiteLocalizationCMSBridge._validated_cancellation(None, copied)
|
|
337
|
+
elif copied.get("schema") == _CMS.TOMBSTONE_SCHEMA:
|
|
338
|
+
_CMS.WebsiteLocalizationCMSBridge._validated_tombstone(None, copied)
|
|
339
|
+
else:
|
|
340
|
+
raise ValueError
|
|
341
|
+
except Exception:
|
|
342
|
+
_fail("request_invalid")
|
|
343
|
+
return copied, raw
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _identity(payload: Mapping[str, Any], operation: str) -> tuple[str, str, str]:
|
|
347
|
+
try:
|
|
348
|
+
site_id = payload["site_id"]
|
|
349
|
+
event_id = payload["event_id"]
|
|
350
|
+
if operation == "change":
|
|
351
|
+
request_id = event_id
|
|
352
|
+
elif payload["schema"] == _CMS.CANCELLATION_SCHEMA:
|
|
353
|
+
request_id = payload["cancellation_id"]
|
|
354
|
+
else:
|
|
355
|
+
request_id = payload["tombstone_id"]
|
|
356
|
+
except (KeyError, TypeError):
|
|
357
|
+
_fail("request_invalid")
|
|
358
|
+
if not all(_token(value) for value in (site_id, event_id, request_id)):
|
|
359
|
+
_fail("request_invalid")
|
|
360
|
+
return site_id, event_id, request_id
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _status_payload(
|
|
364
|
+
value: Any,
|
|
365
|
+
*,
|
|
366
|
+
expected_operation: str,
|
|
367
|
+
expected_request_id: str,
|
|
368
|
+
expected_event_id: str,
|
|
369
|
+
expected_site_id: str,
|
|
370
|
+
expected_payload_sha256: str,
|
|
371
|
+
expected_remote_capabilities_sha256: str,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
fields = {
|
|
374
|
+
"operation", "request_id", "event_id", "site_id", "payload_sha256",
|
|
375
|
+
"remote_capabilities_sha256", "status", "attempts",
|
|
376
|
+
"delivery_max_attempts", "source_max_attempts", "next_attempt_at",
|
|
377
|
+
"lease_expires_at", "lease_expired", "last_error_code",
|
|
378
|
+
"response_sha256",
|
|
379
|
+
}
|
|
380
|
+
if not isinstance(value, Mapping) or set(value) != fields:
|
|
381
|
+
_fail("status_binding")
|
|
382
|
+
operation = value.get("operation")
|
|
383
|
+
status = value.get("status")
|
|
384
|
+
lease_expires = value.get("lease_expires_at")
|
|
385
|
+
response_hash = value.get("response_sha256")
|
|
386
|
+
error = value.get("last_error_code")
|
|
387
|
+
valid = (
|
|
388
|
+
operation == expected_operation
|
|
389
|
+
and operation in _HTTP.OPERATIONS
|
|
390
|
+
and value.get("request_id") == expected_request_id
|
|
391
|
+
and value.get("event_id") == expected_event_id
|
|
392
|
+
and value.get("site_id") == expected_site_id
|
|
393
|
+
and value.get("payload_sha256") == expected_payload_sha256
|
|
394
|
+
and value.get("remote_capabilities_sha256")
|
|
395
|
+
== expected_remote_capabilities_sha256
|
|
396
|
+
and all(
|
|
397
|
+
_token(value.get(name))
|
|
398
|
+
for name in ("request_id", "event_id", "site_id")
|
|
399
|
+
)
|
|
400
|
+
and status in _HTTP.STATUSES
|
|
401
|
+
and _count(value.get("attempts"), maximum=20)
|
|
402
|
+
and _count(value.get("delivery_max_attempts"), minimum=1, maximum=20)
|
|
403
|
+
and _count(value.get("source_max_attempts"), minimum=1, maximum=20)
|
|
404
|
+
and value["attempts"] <= value["delivery_max_attempts"]
|
|
405
|
+
and _timestamp(value.get("next_attempt_at"))
|
|
406
|
+
and _timestamp(lease_expires, nullable=True)
|
|
407
|
+
and isinstance(value.get("lease_expired"), bool)
|
|
408
|
+
and (error is None or isinstance(error, str) and ERROR_CODE.fullmatch(error))
|
|
409
|
+
and (response_hash is None or _sha256(response_hash))
|
|
410
|
+
and (status == "leased") == (lease_expires is not None)
|
|
411
|
+
and (status == "succeeded") == (response_hash is not None)
|
|
412
|
+
and (status not in {"retry_wait", "failed"} or error is not None)
|
|
413
|
+
)
|
|
414
|
+
if not valid:
|
|
415
|
+
_fail("status_binding")
|
|
416
|
+
return dict(value)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class _PayloadView:
|
|
420
|
+
def __init__(self, value: Mapping[str, Any]):
|
|
421
|
+
self.value = value
|
|
422
|
+
|
|
423
|
+
def as_payload(self) -> Mapping[str, Any]:
|
|
424
|
+
return self.value
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class CMSSourceDeliverySidecarHTTPClient:
|
|
428
|
+
"""Operate one sidecar only through its freshly verified contract."""
|
|
429
|
+
|
|
430
|
+
def __init__(
|
|
431
|
+
self,
|
|
432
|
+
origin: str,
|
|
433
|
+
expected_capabilities_sha256: str,
|
|
434
|
+
expected_remote_capabilities_sha256: str,
|
|
435
|
+
authentication_headers: Callable[[Mapping[str, Any]], Mapping[str, str]],
|
|
436
|
+
*,
|
|
437
|
+
transport: HTTPTransport | None = None,
|
|
438
|
+
timeout: float | int = 30,
|
|
439
|
+
allow_loopback_http: bool = False,
|
|
440
|
+
):
|
|
441
|
+
if not isinstance(allow_loopback_http, bool):
|
|
442
|
+
raise TypeError("allow_loopback_http must be boolean")
|
|
443
|
+
self.origin = _endpoint(origin, allow_loopback_http)
|
|
444
|
+
if not _sha256(expected_capabilities_sha256):
|
|
445
|
+
raise ValueError("expected sidecar capability digest is invalid")
|
|
446
|
+
if not _sha256(expected_remote_capabilities_sha256):
|
|
447
|
+
raise ValueError("expected remote capability digest is invalid")
|
|
448
|
+
if not callable(authentication_headers):
|
|
449
|
+
raise TypeError("authentication_headers must be callable")
|
|
450
|
+
if (
|
|
451
|
+
isinstance(timeout, bool)
|
|
452
|
+
or not isinstance(timeout, (int, float))
|
|
453
|
+
or not math.isfinite(float(timeout))
|
|
454
|
+
or not 0 < float(timeout) <= 300
|
|
455
|
+
):
|
|
456
|
+
raise ValueError("timeout is outside the supported range")
|
|
457
|
+
self.expected_capabilities_sha256 = expected_capabilities_sha256
|
|
458
|
+
self.expected_remote_capabilities_sha256 = (
|
|
459
|
+
expected_remote_capabilities_sha256
|
|
460
|
+
)
|
|
461
|
+
self.authentication_headers = authentication_headers
|
|
462
|
+
self.transport = URLTransport() if transport is None else transport
|
|
463
|
+
if not callable(getattr(self.transport, "request", None)):
|
|
464
|
+
raise TypeError("transport must provide request")
|
|
465
|
+
self.timeout = float(timeout)
|
|
466
|
+
|
|
467
|
+
def __repr__(self) -> str:
|
|
468
|
+
return "CMSSourceDeliverySidecarHTTPClient(configured=True)"
|
|
469
|
+
|
|
470
|
+
def _request(self, method, path, body, allowed_statuses, context, headers=None):
|
|
471
|
+
body_hash = hashlib.sha256(body or b"").hexdigest()
|
|
472
|
+
authentication = {
|
|
473
|
+
"schema": AUTH_CONTEXT_SCHEMA,
|
|
474
|
+
"method": method,
|
|
475
|
+
"origin": self.origin,
|
|
476
|
+
"path": path,
|
|
477
|
+
"scope": _HTTP.SCOPES[path],
|
|
478
|
+
"body_sha256": body_hash,
|
|
479
|
+
**context,
|
|
480
|
+
}
|
|
481
|
+
request_headers = _authentication_headers(
|
|
482
|
+
self.authentication_headers, authentication,
|
|
483
|
+
)
|
|
484
|
+
request_headers["Accept"] = "application/json"
|
|
485
|
+
if body is not None:
|
|
486
|
+
request_headers["Content-Type"] = "application/json; charset=utf-8"
|
|
487
|
+
if headers:
|
|
488
|
+
request_headers.update(headers)
|
|
489
|
+
try:
|
|
490
|
+
result = self.transport.request(
|
|
491
|
+
method, self.origin + path, request_headers, body,
|
|
492
|
+
timeout=self.timeout,
|
|
493
|
+
)
|
|
494
|
+
except CMSSourceDeliveryClientBlocked:
|
|
495
|
+
raise
|
|
496
|
+
except Exception:
|
|
497
|
+
_fail("network", retryable=True)
|
|
498
|
+
return result, _json_response(result, allowed_statuses)
|
|
499
|
+
|
|
500
|
+
def capabilities(self) -> Mapping[str, Any]:
|
|
501
|
+
_result, response = self._request(
|
|
502
|
+
"GET", _HTTP.CAPABILITIES_PATH, None, {200}, {},
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
expected = _HTTP._capabilities_payload()
|
|
506
|
+
except Exception:
|
|
507
|
+
_fail("local_contract_invalid")
|
|
508
|
+
if (
|
|
509
|
+
set(response) != {"schema", "capabilities"}
|
|
510
|
+
or response.get("schema") != _HTTP.CAPABILITIES_RESPONSE_SCHEMA
|
|
511
|
+
or response.get("capabilities") != expected
|
|
512
|
+
or expected.get("sha256") != self.expected_capabilities_sha256
|
|
513
|
+
):
|
|
514
|
+
_fail("capabilities_binding")
|
|
515
|
+
return response
|
|
516
|
+
|
|
517
|
+
def _contract(self, operation: str) -> Mapping[str, Any]:
|
|
518
|
+
return self.capabilities()["capabilities"]["operations"][operation]
|
|
519
|
+
|
|
520
|
+
def submit_change(
|
|
521
|
+
self,
|
|
522
|
+
change: Mapping[str, Any],
|
|
523
|
+
*,
|
|
524
|
+
source_max_attempts: int = 5,
|
|
525
|
+
delivery_max_attempts: int = 5,
|
|
526
|
+
) -> Mapping[str, Any]:
|
|
527
|
+
payload, payload_body = _copy_payload(change, "change")
|
|
528
|
+
site_id, event_id, request_id = _identity(payload, "change")
|
|
529
|
+
return self._enqueue(
|
|
530
|
+
"change", payload, payload_body, site_id, event_id, request_id,
|
|
531
|
+
source_max_attempts, delivery_max_attempts,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def submit_removal(
|
|
535
|
+
self,
|
|
536
|
+
removal: Mapping[str, Any],
|
|
537
|
+
*,
|
|
538
|
+
source_max_attempts: int = 5,
|
|
539
|
+
delivery_max_attempts: int = 5,
|
|
540
|
+
) -> Mapping[str, Any]:
|
|
541
|
+
payload, payload_body = _copy_payload(removal, "removal")
|
|
542
|
+
operation = (
|
|
543
|
+
"cancellation"
|
|
544
|
+
if payload["schema"] == _CMS.CANCELLATION_SCHEMA
|
|
545
|
+
else "tombstone"
|
|
546
|
+
)
|
|
547
|
+
site_id, event_id, request_id = _identity(payload, operation)
|
|
548
|
+
return self._enqueue(
|
|
549
|
+
operation, payload, payload_body, site_id, event_id, request_id,
|
|
550
|
+
source_max_attempts, delivery_max_attempts,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _enqueue(
|
|
554
|
+
self,
|
|
555
|
+
operation,
|
|
556
|
+
payload,
|
|
557
|
+
payload_body,
|
|
558
|
+
site_id,
|
|
559
|
+
event_id,
|
|
560
|
+
request_id,
|
|
561
|
+
source_max_attempts,
|
|
562
|
+
delivery_max_attempts,
|
|
563
|
+
) -> Mapping[str, Any]:
|
|
564
|
+
if not _count(source_max_attempts, minimum=1, maximum=20):
|
|
565
|
+
_fail("request_invalid")
|
|
566
|
+
if not _count(delivery_max_attempts, minimum=1, maximum=20):
|
|
567
|
+
_fail("request_invalid")
|
|
568
|
+
contract_name = "change" if operation == "change" else "removal"
|
|
569
|
+
contract = self._contract(contract_name)
|
|
570
|
+
envelope_key = "change" if operation == "change" else "removal"
|
|
571
|
+
request = {
|
|
572
|
+
"schema": contract["request_schema"],
|
|
573
|
+
envelope_key: payload,
|
|
574
|
+
"source_max_attempts": source_max_attempts,
|
|
575
|
+
"delivery_max_attempts": delivery_max_attempts,
|
|
576
|
+
}
|
|
577
|
+
body = _canonical(request)
|
|
578
|
+
payload_hash = hashlib.sha256(payload_body).hexdigest()
|
|
579
|
+
_result, response = self._request(
|
|
580
|
+
contract["method"], contract["path"], body,
|
|
581
|
+
{contract["success_status"]},
|
|
582
|
+
{
|
|
583
|
+
"site_id": site_id,
|
|
584
|
+
"event_id": event_id,
|
|
585
|
+
"request_id": request_id,
|
|
586
|
+
"payload_sha256": payload_hash,
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
"Idempotency-Key": request_id,
|
|
590
|
+
"X-Localization-Source-Payload-Sha256": payload_hash,
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
if (
|
|
594
|
+
set(response) != {"schema", "queue", "capabilities_sha256"}
|
|
595
|
+
or response.get("schema") != contract["response_schema"]
|
|
596
|
+
or response.get("capabilities_sha256")
|
|
597
|
+
!= self.expected_capabilities_sha256
|
|
598
|
+
):
|
|
599
|
+
_fail("enqueue_binding")
|
|
600
|
+
try:
|
|
601
|
+
status = _status_payload(
|
|
602
|
+
response["queue"],
|
|
603
|
+
expected_operation=operation,
|
|
604
|
+
expected_request_id=request_id,
|
|
605
|
+
expected_event_id=event_id,
|
|
606
|
+
expected_site_id=site_id,
|
|
607
|
+
expected_payload_sha256=payload_hash,
|
|
608
|
+
expected_remote_capabilities_sha256=(
|
|
609
|
+
self.expected_remote_capabilities_sha256
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
except CMSSourceDeliveryClientBlocked:
|
|
613
|
+
_fail("enqueue_binding")
|
|
614
|
+
if (
|
|
615
|
+
status["source_max_attempts"] != source_max_attempts
|
|
616
|
+
or status["delivery_max_attempts"] != delivery_max_attempts
|
|
617
|
+
):
|
|
618
|
+
_fail("enqueue_binding")
|
|
619
|
+
return response
|
|
620
|
+
|
|
621
|
+
def status(
|
|
622
|
+
self,
|
|
623
|
+
operation: str,
|
|
624
|
+
request_id: str,
|
|
625
|
+
event_id: str,
|
|
626
|
+
site_id: str,
|
|
627
|
+
payload_sha256: str,
|
|
628
|
+
) -> Mapping[str, Any]:
|
|
629
|
+
if (
|
|
630
|
+
operation not in _HTTP.OPERATIONS
|
|
631
|
+
or not all(_token(value) for value in (request_id, event_id, site_id))
|
|
632
|
+
or not _sha256(payload_sha256)
|
|
633
|
+
):
|
|
634
|
+
_fail("request_invalid")
|
|
635
|
+
contract = self._contract("status")
|
|
636
|
+
request = {
|
|
637
|
+
"schema": contract["request_schema"],
|
|
638
|
+
"operation": operation,
|
|
639
|
+
"request_id": request_id,
|
|
640
|
+
"site_id": site_id,
|
|
641
|
+
}
|
|
642
|
+
body = _canonical(request)
|
|
643
|
+
_result, response = self._request(
|
|
644
|
+
contract["method"], contract["path"], body,
|
|
645
|
+
{contract["success_status"]},
|
|
646
|
+
{
|
|
647
|
+
"site_id": site_id,
|
|
648
|
+
"event_id": event_id,
|
|
649
|
+
"request_id": request_id,
|
|
650
|
+
"payload_sha256": payload_sha256,
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
if (
|
|
654
|
+
set(response) != {"schema", "status", "capabilities_sha256"}
|
|
655
|
+
or response.get("schema") != contract["response_schema"]
|
|
656
|
+
or response.get("capabilities_sha256")
|
|
657
|
+
!= self.expected_capabilities_sha256
|
|
658
|
+
):
|
|
659
|
+
_fail("status_binding")
|
|
660
|
+
_status_payload(
|
|
661
|
+
response["status"],
|
|
662
|
+
expected_operation=operation,
|
|
663
|
+
expected_request_id=request_id,
|
|
664
|
+
expected_event_id=event_id,
|
|
665
|
+
expected_site_id=site_id,
|
|
666
|
+
expected_payload_sha256=payload_sha256,
|
|
667
|
+
expected_remote_capabilities_sha256=(
|
|
668
|
+
self.expected_remote_capabilities_sha256
|
|
669
|
+
),
|
|
670
|
+
)
|
|
671
|
+
return response
|
|
672
|
+
|
|
673
|
+
def source_status(
|
|
674
|
+
self,
|
|
675
|
+
event_id: str,
|
|
676
|
+
site_id: str,
|
|
677
|
+
payload_sha256: str,
|
|
678
|
+
) -> Mapping[str, Any]:
|
|
679
|
+
"""Read the complete source lifecycle through the accepted sidecar row."""
|
|
680
|
+
|
|
681
|
+
if (
|
|
682
|
+
not _token(event_id)
|
|
683
|
+
or not _token(site_id)
|
|
684
|
+
or not _sha256(payload_sha256)
|
|
685
|
+
):
|
|
686
|
+
_fail("request_invalid")
|
|
687
|
+
contract = self._contract("source_status")
|
|
688
|
+
request = {
|
|
689
|
+
"schema": contract["request_schema"],
|
|
690
|
+
"event_id": event_id,
|
|
691
|
+
"site_id": site_id,
|
|
692
|
+
"payload_sha256": payload_sha256,
|
|
693
|
+
}
|
|
694
|
+
body = _canonical(request)
|
|
695
|
+
_result, response = self._request(
|
|
696
|
+
contract["method"],
|
|
697
|
+
contract["path"],
|
|
698
|
+
body,
|
|
699
|
+
{contract["success_status"]},
|
|
700
|
+
{
|
|
701
|
+
"site_id": site_id,
|
|
702
|
+
"event_id": event_id,
|
|
703
|
+
"request_id": event_id,
|
|
704
|
+
"payload_sha256": payload_sha256,
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
if (
|
|
708
|
+
set(response) != {
|
|
709
|
+
"schema", "source_status", "source_capabilities_sha256",
|
|
710
|
+
"capabilities_sha256", "source_capability_binding",
|
|
711
|
+
}
|
|
712
|
+
or response.get("schema") != contract["response_schema"]
|
|
713
|
+
or response.get("capabilities_sha256")
|
|
714
|
+
!= self.expected_capabilities_sha256
|
|
715
|
+
or response.get("source_capabilities_sha256")
|
|
716
|
+
!= self.expected_remote_capabilities_sha256
|
|
717
|
+
):
|
|
718
|
+
_fail("source_status_binding")
|
|
719
|
+
try:
|
|
720
|
+
normalized = _HTTP._source_status_response(
|
|
721
|
+
{
|
|
722
|
+
"schema": _HTTP._SOURCE_HTTP.STATUS_RESPONSE_SCHEMA,
|
|
723
|
+
"status": response["source_status"],
|
|
724
|
+
"capability_binding": response[
|
|
725
|
+
"source_capability_binding"
|
|
726
|
+
],
|
|
727
|
+
"capabilities_sha256": (
|
|
728
|
+
response["source_capabilities_sha256"]
|
|
729
|
+
),
|
|
730
|
+
},
|
|
731
|
+
expected_event_id=event_id,
|
|
732
|
+
expected_site_id=site_id,
|
|
733
|
+
expected_capabilities_sha256=(
|
|
734
|
+
self.expected_remote_capabilities_sha256
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
except Exception:
|
|
738
|
+
_fail("source_status_binding")
|
|
739
|
+
if (
|
|
740
|
+
normalized["status"] != response["source_status"]
|
|
741
|
+
or normalized["capability_binding"]
|
|
742
|
+
!= response["source_capability_binding"]
|
|
743
|
+
):
|
|
744
|
+
_fail("source_status_binding")
|
|
745
|
+
return response
|
|
746
|
+
|
|
747
|
+
def source_readiness(self) -> Mapping[str, Any]:
|
|
748
|
+
"""Read the downstream processing readiness through the sidecar."""
|
|
749
|
+
|
|
750
|
+
contract = self._contract("source_readiness")
|
|
751
|
+
result, response = self._request(
|
|
752
|
+
contract["method"], contract["path"], None, {200, 503}, {},
|
|
753
|
+
)
|
|
754
|
+
if (
|
|
755
|
+
set(response) != {
|
|
756
|
+
"schema", "source_readiness", "source_capabilities_sha256",
|
|
757
|
+
"capabilities_sha256", "source_capability_binding",
|
|
758
|
+
}
|
|
759
|
+
or response.get("schema") != contract["response_schema"]
|
|
760
|
+
or response.get("capabilities_sha256")
|
|
761
|
+
!= self.expected_capabilities_sha256
|
|
762
|
+
or response.get("source_capabilities_sha256")
|
|
763
|
+
!= self.expected_remote_capabilities_sha256
|
|
764
|
+
):
|
|
765
|
+
_fail("source_readiness_binding")
|
|
766
|
+
try:
|
|
767
|
+
normalized = _HTTP._source_readiness_response(
|
|
768
|
+
{
|
|
769
|
+
"schema": _HTTP._SOURCE_HTTP.READINESS_RESPONSE_SCHEMA,
|
|
770
|
+
"readiness": response["source_readiness"],
|
|
771
|
+
"capability_binding": response[
|
|
772
|
+
"source_capability_binding"
|
|
773
|
+
],
|
|
774
|
+
"capabilities_sha256": (
|
|
775
|
+
response["source_capabilities_sha256"]
|
|
776
|
+
),
|
|
777
|
+
},
|
|
778
|
+
expected_capabilities_sha256=(
|
|
779
|
+
self.expected_remote_capabilities_sha256
|
|
780
|
+
),
|
|
781
|
+
)
|
|
782
|
+
except Exception:
|
|
783
|
+
_fail("source_readiness_binding")
|
|
784
|
+
if (
|
|
785
|
+
normalized["readiness"] != response["source_readiness"]
|
|
786
|
+
or normalized["capability_binding"]
|
|
787
|
+
!= response["source_capability_binding"]
|
|
788
|
+
or (result.status == 200)
|
|
789
|
+
!= (normalized["readiness"]["status"] == "ready")
|
|
790
|
+
):
|
|
791
|
+
_fail("source_readiness_binding")
|
|
792
|
+
return response
|
|
793
|
+
|
|
794
|
+
def source_health(self) -> Mapping[str, Any]:
|
|
795
|
+
"""Read the downstream queue health through the sidecar."""
|
|
796
|
+
|
|
797
|
+
contract = self._contract("source_health")
|
|
798
|
+
result, response = self._request(
|
|
799
|
+
contract["method"], contract["path"], None, {200, 503}, {},
|
|
800
|
+
)
|
|
801
|
+
if (
|
|
802
|
+
set(response) != {
|
|
803
|
+
"schema", "source_health", "source_capabilities_sha256",
|
|
804
|
+
"capabilities_sha256", "source_capability_binding",
|
|
805
|
+
}
|
|
806
|
+
or response.get("schema") != contract["response_schema"]
|
|
807
|
+
or response.get("capabilities_sha256")
|
|
808
|
+
!= self.expected_capabilities_sha256
|
|
809
|
+
or response.get("source_capabilities_sha256")
|
|
810
|
+
!= self.expected_remote_capabilities_sha256
|
|
811
|
+
):
|
|
812
|
+
_fail("source_health_binding")
|
|
813
|
+
try:
|
|
814
|
+
normalized = _HTTP._source_health_response(
|
|
815
|
+
{
|
|
816
|
+
"schema": _HTTP._SOURCE_HTTP.HEALTH_RESPONSE_SCHEMA,
|
|
817
|
+
"health": response["source_health"],
|
|
818
|
+
"capability_binding": response[
|
|
819
|
+
"source_capability_binding"
|
|
820
|
+
],
|
|
821
|
+
"capabilities_sha256": (
|
|
822
|
+
response["source_capabilities_sha256"]
|
|
823
|
+
),
|
|
824
|
+
},
|
|
825
|
+
expected_capabilities_sha256=(
|
|
826
|
+
self.expected_remote_capabilities_sha256
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
except Exception:
|
|
830
|
+
_fail("source_health_binding")
|
|
831
|
+
if (
|
|
832
|
+
normalized["health"] != response["source_health"]
|
|
833
|
+
or normalized["capability_binding"]
|
|
834
|
+
!= response["source_capability_binding"]
|
|
835
|
+
or (result.status == 503)
|
|
836
|
+
!= (normalized["health"]["status"] == "blocked")
|
|
837
|
+
):
|
|
838
|
+
_fail("source_health_binding")
|
|
839
|
+
return response
|
|
840
|
+
|
|
841
|
+
def health(self) -> Mapping[str, Any]:
|
|
842
|
+
contract = self._contract("health")
|
|
843
|
+
result, response = self._request(
|
|
844
|
+
contract["method"], contract["path"], None, {200, 503}, {},
|
|
845
|
+
)
|
|
846
|
+
if (
|
|
847
|
+
set(response) != {"schema", "health", "capabilities_sha256"}
|
|
848
|
+
or response.get("schema") != contract["response_schema"]
|
|
849
|
+
or response.get("capabilities_sha256")
|
|
850
|
+
!= self.expected_capabilities_sha256
|
|
851
|
+
):
|
|
852
|
+
_fail("health_binding", retryable=result.status == 503)
|
|
853
|
+
try:
|
|
854
|
+
normalized = _HTTP._health_payload(_PayloadView(response["health"]))
|
|
855
|
+
except Exception:
|
|
856
|
+
_fail("health_binding", retryable=result.status == 503)
|
|
857
|
+
if (
|
|
858
|
+
normalized != response["health"]
|
|
859
|
+
or (result.status == 503) != (normalized["status"] == "blocked")
|
|
860
|
+
):
|
|
861
|
+
_fail("health_binding", retryable=result.status == 503)
|
|
862
|
+
return response
|
|
863
|
+
|
|
864
|
+
def readiness(self) -> Mapping[str, Any]:
|
|
865
|
+
contract = self._contract("readiness")
|
|
866
|
+
result, response = self._request(
|
|
867
|
+
contract["method"], contract["path"], None, {200, 503}, {},
|
|
868
|
+
)
|
|
869
|
+
if (
|
|
870
|
+
set(response) != {"schema", "readiness", "capabilities_sha256"}
|
|
871
|
+
or response.get("schema") != contract["response_schema"]
|
|
872
|
+
or response.get("capabilities_sha256")
|
|
873
|
+
!= self.expected_capabilities_sha256
|
|
874
|
+
):
|
|
875
|
+
_fail("readiness_binding", retryable=result.status == 503)
|
|
876
|
+
try:
|
|
877
|
+
normalized = _HTTP._readiness_payload(response["readiness"])
|
|
878
|
+
except Exception:
|
|
879
|
+
_fail("readiness_binding", retryable=result.status == 503)
|
|
880
|
+
if (
|
|
881
|
+
normalized != response["readiness"]
|
|
882
|
+
or (result.status == 200) != (normalized["status"] == "ready")
|
|
883
|
+
):
|
|
884
|
+
_fail("readiness_binding", retryable=result.status == 503)
|
|
885
|
+
return response
|