eraflow 0.1.0a1__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.
- eraflow/__init__.py +39 -0
- eraflow/_version.py +4 -0
- eraflow/flask.py +357 -0
- eraflow/materials.py +7110 -0
- eraflow/provider.py +2153 -0
- eraflow/py.typed +0 -0
- eraflow/runtime.py +992 -0
- eraflow-0.1.0a1.dist-info/LICENSE +21 -0
- eraflow-0.1.0a1.dist-info/METADATA +54 -0
- eraflow-0.1.0a1.dist-info/NOTICE +4 -0
- eraflow-0.1.0a1.dist-info/RECORD +13 -0
- eraflow-0.1.0a1.dist-info/WHEEL +5 -0
- eraflow-0.1.0a1.dist-info/top_level.txt +1 -0
eraflow/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""ERAF 协议的 Python 接入 SDK;导入不启动任务或访问网络。"""
|
|
3
|
+
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .materials import (
|
|
6
|
+
ConsumerTarget,
|
|
7
|
+
CredentialBytes,
|
|
8
|
+
ERAFIdentityMaterialError,
|
|
9
|
+
NoPackageError,
|
|
10
|
+
TrustedArtifact,
|
|
11
|
+
)
|
|
12
|
+
from .provider import (
|
|
13
|
+
Admission,
|
|
14
|
+
HTTPRequestBytes,
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderAccessError,
|
|
17
|
+
ProviderOperation,
|
|
18
|
+
UnavailableProvider,
|
|
19
|
+
)
|
|
20
|
+
from .runtime import ExternalRuntime, ExternalStartupError, MaterialView, prepare_external_target
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"ConsumerTarget",
|
|
25
|
+
"CredentialBytes",
|
|
26
|
+
"ERAFIdentityMaterialError",
|
|
27
|
+
"NoPackageError",
|
|
28
|
+
"TrustedArtifact",
|
|
29
|
+
"Admission",
|
|
30
|
+
"HTTPRequestBytes",
|
|
31
|
+
"Provider",
|
|
32
|
+
"ProviderAccessError",
|
|
33
|
+
"ProviderOperation",
|
|
34
|
+
"UnavailableProvider",
|
|
35
|
+
"ExternalRuntime",
|
|
36
|
+
"MaterialView",
|
|
37
|
+
"ExternalStartupError",
|
|
38
|
+
"prepare_external_target",
|
|
39
|
+
]
|
eraflow/_version.py
ADDED
eraflow/flask.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# coding=utf-8
|
|
3
|
+
# CommonLib
|
|
4
|
+
|
|
5
|
+
"""External 公共 Flask 接线:安装入口、请求保护装饰器与 /external/status 路由。
|
|
6
|
+
|
|
7
|
+
来源实现:v1.1.0;发行版本统一由 eraflow._version 提供。
|
|
8
|
+
Updated: 2026-10-23
|
|
9
|
+
变更记录:
|
|
10
|
+
v1.1.0 | 2026-10-23 | HTTP 接纳使用服务器原始事实和原请求预算,有限缓冲正文并将同一字节交给业务;拒绝编码及流式入口。
|
|
11
|
+
v1.0.4 | 2026-10-20 | 公共文件简化为 provider_api/provider_lib,更新唯一引用;业务行为、协议与鉴权不变。
|
|
12
|
+
v1.0.3 | 2026-10-20 | 公共文件迁入 External 子目录,固定新引用和服务根定位;协议、鉴权与生命周期不变。
|
|
13
|
+
v1.0.2 | 2026-10-20 | 唯一安装入口承接无可信制品的公共失败 Status,保留管理员预算和保护拒绝链
|
|
14
|
+
v1.0.1 | 2026-10-19 | 仅 External 强制禁止缓存,统一脱敏 HTTPException,保留内部历史响应并拒绝异实例重复安装
|
|
15
|
+
v1.0.0 | 2026-10-19 | 首版:install_provider 向 app.extensions['commonlib.external']
|
|
16
|
+
注册生命周期(serv_process 监听前 start/退出 stop);
|
|
17
|
+
ExternalHTTP.protect(operation_id) 静态操作保护装饰器——
|
|
18
|
+
请求入口建统一单调 deadline、消费 commonlib.socket_facts、
|
|
19
|
+
解析唯一 Authorization(Bearer/DPoP)与 Edge 证明头、调用
|
|
20
|
+
Provider.authorize 一次内省并保存请求级 Admission;
|
|
21
|
+
read 操作在有限响应发出前自动复核,write 操作由业务在最后
|
|
22
|
+
可拒绝点显式 Admission.recheck();/external/status 复用既有
|
|
23
|
+
管理员 Bearer guard(libs.token_auth_lib),只读、no-store、
|
|
24
|
+
拒绝 query/body/Transfer-Encoding,HEAD 同鉴权、OPTIONS 不给
|
|
25
|
+
详情、清除 CORS;错误响应稳定脱敏,不回显 Token/异常正文
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from typing import Any, Callable, Optional
|
|
36
|
+
|
|
37
|
+
from flask import Blueprint, Response, g, jsonify, request
|
|
38
|
+
from werkzeug.exceptions import HTTPException
|
|
39
|
+
|
|
40
|
+
from .provider import (
|
|
41
|
+
MAX_HTTP_BODY_BYTES,
|
|
42
|
+
STATUS_TOTAL_TIMEOUT,
|
|
43
|
+
Admission,
|
|
44
|
+
HTTPRequestBytes,
|
|
45
|
+
Provider,
|
|
46
|
+
ProviderAccessError,
|
|
47
|
+
UnavailableProvider,
|
|
48
|
+
_http_request_fields,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_AUTHORIZATION_PATTERN = re.compile(r"(Bearer|DPoP) ([\x21-\x2b\x2d-\x7e]+)")
|
|
52
|
+
_ERROR_STATUS = {"authentication": 401, "authorization": 403, "dependency": 503}
|
|
53
|
+
_ERROR_MESSAGE = {
|
|
54
|
+
"authentication": "外部请求认证未通过",
|
|
55
|
+
"authorization": "外部请求无操作权限",
|
|
56
|
+
"dependency": "外部依赖暂不可用",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _request_id() -> Any:
|
|
61
|
+
return uuid.uuid4().hex
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _error_response(status: Any, code: Any, correlation_id: Any) -> Any:
|
|
65
|
+
response = jsonify({"code": code, "message": "请求未能完成", "request_id": correlation_id})
|
|
66
|
+
response.status_code = status
|
|
67
|
+
response.headers["X-Request-Id"] = correlation_id
|
|
68
|
+
response.headers["Cache-Control"] = "no-store"
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _access_error_response(error: Any) -> Any:
|
|
73
|
+
status = _ERROR_STATUS.get(error.category, 503)
|
|
74
|
+
response = jsonify(
|
|
75
|
+
{
|
|
76
|
+
"code": "external_" + error.category,
|
|
77
|
+
"message": _ERROR_MESSAGE.get(error.category, "请求未能完成"),
|
|
78
|
+
"request_id": _request_id(),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
response.status_code = status
|
|
82
|
+
if status == 401:
|
|
83
|
+
response.headers["WWW-Authenticate"] = "Bearer"
|
|
84
|
+
response.headers["Cache-Control"] = "no-store"
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip_external_cors(response: Any) -> Any:
|
|
89
|
+
"""External 响应不保留全局历史 CORS 许可。"""
|
|
90
|
+
if request.path == "/external" or request.path.startswith("/external/"):
|
|
91
|
+
for name in (
|
|
92
|
+
"Access-Control-Allow-Origin",
|
|
93
|
+
"Access-Control-Allow-Headers",
|
|
94
|
+
"Access-Control-Allow-Methods",
|
|
95
|
+
"Access-Control-Allow-Credentials",
|
|
96
|
+
"Access-Control-Expose-Headers",
|
|
97
|
+
"Access-Control-Max-Age",
|
|
98
|
+
):
|
|
99
|
+
response.headers.pop(name, None)
|
|
100
|
+
response.headers["Cache-Control"] = "no-store"
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ExternalHTTP(object):
|
|
105
|
+
"""安装入口返回的请求保护面;只在受保护请求内暴露 Admission。"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, app: Any, provider: Any) -> None:
|
|
108
|
+
self.app = app
|
|
109
|
+
self.provider = provider
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def admission(self) -> Admission:
|
|
113
|
+
"""本请求本操作的不可变接纳上下文;未受保护请求/跨请求读取均失败。"""
|
|
114
|
+
admission = getattr(g, "external_admission", None)
|
|
115
|
+
if not isinstance(admission, Admission):
|
|
116
|
+
raise ProviderAccessError("dependency", "admission_missing")
|
|
117
|
+
return admission
|
|
118
|
+
|
|
119
|
+
def protect(self, operation_id: str) -> Callable[..., Any]:
|
|
120
|
+
operation = self.provider.operations.get(operation_id)
|
|
121
|
+
if operation is None:
|
|
122
|
+
raise ValueError("unknown external operation: %s" % operation_id)
|
|
123
|
+
|
|
124
|
+
def decorate(handler: Any) -> Any:
|
|
125
|
+
endpoint = handler.__name__
|
|
126
|
+
|
|
127
|
+
@wraps(handler)
|
|
128
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
129
|
+
if request.endpoint is None or request.endpoint.rsplit(".", 1)[-1] != (
|
|
130
|
+
operation.endpoint if operation.endpoint else endpoint
|
|
131
|
+
):
|
|
132
|
+
return _error_response(405, "method_not_allowed", _request_id())
|
|
133
|
+
if request.method not in operation.methods:
|
|
134
|
+
return _error_response(405, "method_not_allowed", _request_id())
|
|
135
|
+
started = time.monotonic()
|
|
136
|
+
inbound = self.provider.inbound_seconds
|
|
137
|
+
deadline = started + inbound if inbound is not None else None
|
|
138
|
+
values = request.headers.getlist("Authorization")
|
|
139
|
+
match = _AUTHORIZATION_PATTERN.fullmatch(values[0]) if len(values) == 1 else None
|
|
140
|
+
if match is None or request.headers.getlist("DPoP"):
|
|
141
|
+
return _access_error_response(
|
|
142
|
+
ProviderAccessError("authentication", "malformed_token")
|
|
143
|
+
)
|
|
144
|
+
scheme, token = match.groups()
|
|
145
|
+
attestation_values = request.headers.getlist("X-SMAI-Transport-Attestation")
|
|
146
|
+
attestation = attestation_values[0] if len(attestation_values) == 1 else None
|
|
147
|
+
if len(attestation_values) > 1:
|
|
148
|
+
return _access_error_response(
|
|
149
|
+
ProviderAccessError("authentication", "attestation_malformed")
|
|
150
|
+
)
|
|
151
|
+
facts = request.environ.get("commonlib.socket_facts")
|
|
152
|
+
try:
|
|
153
|
+
captured = None
|
|
154
|
+
if type(facts) is dict and facts.get("tls") is False:
|
|
155
|
+
raw = request.environ.get("commonlib.http_request_bytes")
|
|
156
|
+
if (
|
|
157
|
+
type(raw) is not dict
|
|
158
|
+
or raw.get("input") is not request.environ["wsgi.input"]
|
|
159
|
+
or raw.get("method") != request.method
|
|
160
|
+
or raw.get("target") != facts.get("raw_target")
|
|
161
|
+
or type(raw.get("started")) not in (int, float)
|
|
162
|
+
or raw["started"] > started
|
|
163
|
+
or deadline is None
|
|
164
|
+
or "_cached_data" in request.__dict__
|
|
165
|
+
or getattr(raw["input"], "position", 0) != 0
|
|
166
|
+
):
|
|
167
|
+
raise ProviderAccessError("dependency", "socket_facts")
|
|
168
|
+
deadline = min(deadline, raw["started"] + inbound)
|
|
169
|
+
headers = raw["headers"]
|
|
170
|
+
|
|
171
|
+
def values_for(name: Any) -> Any:
|
|
172
|
+
return [v for k, v in headers if k.lower() == name]
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
values_for("authorization") != values
|
|
176
|
+
or values_for("x-smai-transport-attestation") != attestation_values
|
|
177
|
+
):
|
|
178
|
+
raise ProviderAccessError("authentication", "attestation_malformed")
|
|
179
|
+
# 先检查 framing,再在原预算内读取;不会等待未声明长度的流。
|
|
180
|
+
lengths = values_for("content-length")
|
|
181
|
+
if (
|
|
182
|
+
len(lengths) > 1
|
|
183
|
+
or lengths
|
|
184
|
+
and not re.fullmatch("0|[1-9][0-9]*", lengths[0])
|
|
185
|
+
or any(
|
|
186
|
+
values_for(n)
|
|
187
|
+
for n in (
|
|
188
|
+
"content-encoding",
|
|
189
|
+
"transfer-encoding",
|
|
190
|
+
"trailer",
|
|
191
|
+
"expect",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
):
|
|
195
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
196
|
+
length = int(lengths[0]) if lengths else 0
|
|
197
|
+
if length > MAX_HTTP_BODY_BYTES:
|
|
198
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
199
|
+
body = self.provider._bounded(lambda: raw["input"].read(length), deadline)
|
|
200
|
+
if len(body) != length:
|
|
201
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
202
|
+
captured = HTTPRequestBytes(request.method, raw["target"], headers, body)
|
|
203
|
+
_http_request_fields(captured)
|
|
204
|
+
request._cached_data = body
|
|
205
|
+
request.environ["wsgi.input"] = io.BytesIO(body)
|
|
206
|
+
request.__dict__["stream"] = request.environ["wsgi.input"]
|
|
207
|
+
admission = self.provider.authorize(
|
|
208
|
+
facts,
|
|
209
|
+
operation_id,
|
|
210
|
+
scheme,
|
|
211
|
+
token,
|
|
212
|
+
request.method,
|
|
213
|
+
attestation=attestation,
|
|
214
|
+
deadline=deadline,
|
|
215
|
+
**({"http_request": captured} if captured is not None else {}),
|
|
216
|
+
)
|
|
217
|
+
g.external_admission = admission
|
|
218
|
+
result = handler(*args, **kwargs)
|
|
219
|
+
if isinstance(result, Response):
|
|
220
|
+
payload = result
|
|
221
|
+
else:
|
|
222
|
+
payload = jsonify(result)
|
|
223
|
+
# 有限读响应发出前复核同次依据;写操作由业务最后可拒绝点显式复核。
|
|
224
|
+
if operation.accept == "read":
|
|
225
|
+
admission.recheck()
|
|
226
|
+
return payload
|
|
227
|
+
except ProviderAccessError as error:
|
|
228
|
+
return _access_error_response(error)
|
|
229
|
+
except HTTPException as error:
|
|
230
|
+
return _error_response(
|
|
231
|
+
error.code or 503, "external_request_failed", _request_id()
|
|
232
|
+
)
|
|
233
|
+
except Exception:
|
|
234
|
+
# 不记录异常链或请求正文;诊断按 request_id 关联。
|
|
235
|
+
return _error_response(503, "external_dependency_unavailable", _request_id())
|
|
236
|
+
|
|
237
|
+
return wrapped
|
|
238
|
+
|
|
239
|
+
return decorate
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _status_exception_response(error: Any) -> Any:
|
|
243
|
+
status = error.code if isinstance(error, HTTPException) else 503
|
|
244
|
+
description = (
|
|
245
|
+
error.description
|
|
246
|
+
if isinstance(error, HTTPException) and type(error.description) is dict
|
|
247
|
+
else {}
|
|
248
|
+
)
|
|
249
|
+
code = description.get("code")
|
|
250
|
+
if code not in ("00010", "00011", "00021"):
|
|
251
|
+
code = "00011" if status in (401, 403) else "00021"
|
|
252
|
+
message = description.get("msg") if status in (401, 403) else None
|
|
253
|
+
if message not in ("授权超时", "授权无效", "权限不足"):
|
|
254
|
+
message = "授权无效" if status in (401, 403) else "诊断请求失败"
|
|
255
|
+
return jsonify({"code": code, "msg": message, "data": {}}), status
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _bounded_status(call: Any, deadline: Any) -> Any:
|
|
259
|
+
from gevent import Timeout
|
|
260
|
+
|
|
261
|
+
class _Expired(Exception):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
remaining = deadline - time.monotonic()
|
|
265
|
+
if remaining <= 0:
|
|
266
|
+
return jsonify({"code": "00021", "msg": "诊断请求失败", "data": {}}), 503
|
|
267
|
+
try:
|
|
268
|
+
with Timeout(remaining, _Expired()):
|
|
269
|
+
return call()
|
|
270
|
+
except _Expired:
|
|
271
|
+
return jsonify({"code": "00021", "msg": "诊断请求失败", "data": {}}), 503
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def install_provider(
|
|
275
|
+
app: Any,
|
|
276
|
+
provider: Any,
|
|
277
|
+
service_display: Optional[str] = None,
|
|
278
|
+
storage_check: Optional[Callable[..., Any]] = None,
|
|
279
|
+
admin_guard: Optional[Callable[..., Any]] = None,
|
|
280
|
+
) -> ExternalHTTP:
|
|
281
|
+
"""唯一安装入口:注册生命周期、Status 路由与保护边界;import 时不启动。
|
|
282
|
+
|
|
283
|
+
provider 为可信制品构造的 Provider 或准备失败的 UnavailableProvider;后者
|
|
284
|
+
仅保留诊断和拒绝接纳,不创建运行时。storage_check 为服务自有
|
|
285
|
+
必要持久层只读检查(返回 STORAGE_NOT_APPLICABLE 表示真实无持久层),
|
|
286
|
+
不从 ZIP/请求新增探测目标。admin_guard 为既有管理员 Bearer guard 装饰器
|
|
287
|
+
(必须由宿主显式传入;缺失拒绝安装)。安装幂等;构造失败由创建方清理。
|
|
288
|
+
"""
|
|
289
|
+
if not callable(admin_guard):
|
|
290
|
+
raise ValueError("admin_guard must be an explicit administrator decorator")
|
|
291
|
+
if not isinstance(provider, (Provider, UnavailableProvider)):
|
|
292
|
+
raise ValueError("provider must be Provider or UnavailableProvider")
|
|
293
|
+
if service_display is not None and (
|
|
294
|
+
type(service_display) is not str
|
|
295
|
+
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,119}", service_display) is None
|
|
296
|
+
):
|
|
297
|
+
raise ValueError("invalid external service display")
|
|
298
|
+
http = app.extensions.get("commonlib.external_http")
|
|
299
|
+
if http is not None:
|
|
300
|
+
if http.provider is not provider:
|
|
301
|
+
raise ValueError("external provider already installed")
|
|
302
|
+
return http
|
|
303
|
+
http = ExternalHTTP(app, provider)
|
|
304
|
+
app.extensions["commonlib.external_http"] = http
|
|
305
|
+
# serv_process 在监听前 start、退出 finally stop;此处不重复注册 atexit。
|
|
306
|
+
app.extensions["commonlib.external"] = provider
|
|
307
|
+
|
|
308
|
+
status_api = Blueprint("commonlib_external_status", __name__)
|
|
309
|
+
|
|
310
|
+
@status_api.route("/external/status", methods=["GET"], provide_automatic_options=False)
|
|
311
|
+
def external_status() -> Any:
|
|
312
|
+
deadline = time.monotonic() + STATUS_TOTAL_TIMEOUT
|
|
313
|
+
|
|
314
|
+
def invoke() -> Any:
|
|
315
|
+
if (
|
|
316
|
+
request.args
|
|
317
|
+
or request.content_length not in (None, 0)
|
|
318
|
+
or request.environ.get("HTTP_TRANSFER_ENCODING")
|
|
319
|
+
):
|
|
320
|
+
from flask import abort
|
|
321
|
+
|
|
322
|
+
abort(400, {"code": "00021"})
|
|
323
|
+
data = provider.collect_status(
|
|
324
|
+
request.environ.get("commonlib.socket_facts"),
|
|
325
|
+
storage_check,
|
|
326
|
+
deadline=deadline,
|
|
327
|
+
service_display=service_display,
|
|
328
|
+
)
|
|
329
|
+
response = jsonify({"code": "00000", "msg": "成功", "data": data})
|
|
330
|
+
response.headers["Cache-Control"] = "no-store"
|
|
331
|
+
return response
|
|
332
|
+
|
|
333
|
+
guard = admin_guard
|
|
334
|
+
try:
|
|
335
|
+
return _bounded_status(guard(invoke), deadline)
|
|
336
|
+
except HTTPException as error:
|
|
337
|
+
return _status_exception_response(error)
|
|
338
|
+
except Exception:
|
|
339
|
+
return _status_exception_response(Exception())
|
|
340
|
+
|
|
341
|
+
app.register_blueprint(status_api)
|
|
342
|
+
|
|
343
|
+
# 路由匹配失败、未加保护的 External 路由也不进入会回显正文的历史处理器。
|
|
344
|
+
handle_http_exception = app.handle_http_exception
|
|
345
|
+
|
|
346
|
+
def external_http_exception(error: Any) -> Any:
|
|
347
|
+
if request.path == "/external" or request.path.startswith("/external/"):
|
|
348
|
+
return _error_response(error.code or 503, "external_request_failed", _request_id())
|
|
349
|
+
return handle_http_exception(error)
|
|
350
|
+
|
|
351
|
+
app.handle_http_exception = external_http_exception
|
|
352
|
+
|
|
353
|
+
@app.after_request
|
|
354
|
+
def _commonlib_external_boundary(response: Any) -> Any:
|
|
355
|
+
return _strip_external_cors(response)
|
|
356
|
+
|
|
357
|
+
return http
|