Files
zhuangxiu/services/api/app/runtime_settings.py
T

501 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import hashlib
import json
import os
import secrets
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urljoin, urlparse
import boto3
import httpx
import anyio
import psycopg
import redis
from botocore.config import Config as BotoConfig
from cryptography.fernet import Fernet, InvalidToken
from pydantic import BaseModel, Field
from app.config import Settings, get_settings
from app.settings_schema import CATEGORIES, EDITABLE_FIELDS, FIELDS, SECRET_FIELDS, SettingCategory
class SettingsReadiness(BaseModel):
ready: bool
completed_required: int
total_required: int
missing: list[str] = Field(default_factory=list)
untested: list[str] = Field(default_factory=list)
class RuntimeSettingsResponse(BaseModel):
categories: list[SettingCategory]
values: dict[str, Any]
configured: dict[str, bool]
tests: dict[str, dict[str, Any]]
readiness: SettingsReadiness
class RuntimeSettingsUpdate(BaseModel):
values: dict[str, Any]
class RuntimeSettingsTestRequest(BaseModel):
target: Literal[
"infrastructure",
"storage",
"baidu_ocr",
"ai_models",
"gpu",
"langfuse",
"sentry",
]
values: dict[str, Any] = Field(default_factory=dict)
class RuntimeSettingsTestResult(BaseModel):
target: str
ok: bool
message: str
details: dict[str, Any] = Field(default_factory=dict)
class SecretGenerateRequest(BaseModel):
kind: Literal["hex24", "hex32", "base64_32"]
class SecretGenerateResponse(BaseModel):
value: str
REQUIRED_GROUPS: dict[str, list[str]] = {
"基础运行": ["database_status", "redis_status", "encryption_status"],
"文件存储": [
"s3_endpoint",
"s3_public_endpoint",
"s3_access_key_id",
"s3_secret_access_key",
"s3_bucket_inputs",
"s3_bucket_derived",
"s3_bucket_renders",
],
"AI 模型": [
"ai_provider",
"ai_base_url",
"ai_api_key",
"orchestrator_model",
"vision_model",
"image_model",
],
}
REQUIRED_TESTS = {
"基础运行": "infrastructure",
"文件存储": "storage",
"AI 模型": "ai_models",
}
TEST_FIELDS: dict[str, set[str]] = {
"infrastructure": set(),
"storage": {key for key in FIELDS if key.startswith("s3_")},
"baidu_ocr": {key for key in FIELDS if key.startswith("baidu_ocr_")},
"ai_models": {key for key in FIELDS if key.startswith("ai_")} | {
"orchestrator_model",
"vision_model",
"image_model",
},
"gpu": {key for key in FIELDS if key.startswith("gpu_")},
"langfuse": {key for key in FIELDS if key.startswith("langfuse_")},
"sentry": {key for key in FIELDS if key.startswith("sentry_")},
}
def _is_configured(value: Any) -> bool:
if value is None or value is False:
return False
if isinstance(value, str):
normalized = value.strip().lower()
return bool(normalized) and not normalized.startswith(("replace_", "__auto_"))
return True
def _join_api_url(base_url: str, path: str) -> str:
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
class EncryptedSettingsStore:
def __init__(self, bootstrap: Settings | None = None) -> None:
self.bootstrap = bootstrap or get_settings()
self.root = Path(self.bootstrap.runtime_settings_dir)
if not self.root.is_absolute():
self.root = Path.cwd() / self.root
self.data_path = self.root / "settings.enc"
self.key_path = self.root / ".master-key"
def _fernet(self) -> Fernet:
self.root.mkdir(parents=True, exist_ok=True)
configured_key = self.bootstrap.settings_master_key.strip()
if configured_key:
try:
return Fernet(configured_key.encode("ascii"))
except (ValueError, TypeError):
derived = base64.urlsafe_b64encode(hashlib.sha256(configured_key.encode()).digest())
return Fernet(derived)
if self.key_path.exists():
return Fernet(self.key_path.read_bytes().strip())
key = Fernet.generate_key()
self.key_path.write_bytes(key)
try:
os.chmod(self.key_path, 0o600)
except OSError:
pass
return Fernet(key)
def defaults(self) -> dict[str, Any]:
return {
key: field.default
for key, field in FIELDS.items()
if key in EDITABLE_FIELDS and field.default is not None
}
def load_document(self) -> dict[str, Any]:
if not self.data_path.exists():
return {"values": self.defaults(), "tests": {}}
try:
decrypted = self._fernet().decrypt(self.data_path.read_bytes())
document = json.loads(decrypted.decode("utf-8"))
except (InvalidToken, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc
document.setdefault("values", {})
document.setdefault("tests", {})
return document
def save_document(self, document: dict[str, Any]) -> None:
self.root.mkdir(parents=True, exist_ok=True)
payload = json.dumps(document, ensure_ascii=False, sort_keys=True).encode("utf-8")
encrypted = self._fernet().encrypt(payload)
temporary = self.data_path.with_suffix(".tmp")
temporary.write_bytes(encrypted)
temporary.replace(self.data_path)
def merged_values(self, pending: dict[str, Any] | None = None) -> dict[str, Any]:
document = self.load_document()
values = {**self.defaults(), **document["values"]}
for key, value in (pending or {}).items():
if key not in EDITABLE_FIELDS:
continue
if key in SECRET_FIELDS and not _is_configured(value):
continue
values[key] = self._coerce(key, value)
return values
def update(self, patch: dict[str, Any]) -> None:
document = self.load_document()
changed: set[str] = set()
for key, raw_value in patch.items():
if key not in EDITABLE_FIELDS:
continue
if key in SECRET_FIELDS and not _is_configured(raw_value):
continue
value = self._coerce(key, raw_value)
if document["values"].get(key) != value:
document["values"][key] = value
changed.add(key)
for target, fields in TEST_FIELDS.items():
if changed & fields:
document["tests"].pop(target, None)
self.save_document(document)
def record_test(self, result: RuntimeSettingsTestResult, values: dict[str, Any] | None = None) -> None:
document = self.load_document()
document["tests"][result.target] = {
"ok": result.ok,
"message": result.message,
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
}
self.save_document(document)
def public_response(self) -> RuntimeSettingsResponse:
document = self.load_document()
values = {**self.defaults(), **document["values"]}
public_values = {key: value for key, value in values.items() if key not in SECRET_FIELDS}
configured = {key: _is_configured(values.get(key)) for key in FIELDS}
configured.update(self.bootstrap_statuses())
current_tests = self.current_tests(values, document["tests"])
return RuntimeSettingsResponse(
categories=CATEGORIES,
values=public_values,
configured=configured,
tests=current_tests,
readiness=self.readiness(values, current_tests),
)
def bootstrap_statuses(self) -> dict[str, bool]:
return {
"database_status": _is_configured(self.bootstrap.database_url),
"redis_status": _is_configured(self.bootstrap.redis_url),
"encryption_status": self._master_key_available(),
}
def _master_key_available(self) -> bool:
if self.bootstrap.settings_master_key.strip() or self.key_path.exists():
return True
try:
self._fernet()
return True
except OSError:
return False
def readiness(
self,
values: dict[str, Any] | None = None,
tests: dict[str, dict[str, Any]] | None = None,
) -> SettingsReadiness:
values = values or self.merged_values()
if tests is None:
tests = self.current_tests(values, self.load_document()["tests"])
statuses = self.bootstrap_statuses()
all_values = {**values, **statuses}
missing: list[str] = []
untested: list[str] = []
completed = 0
for group, keys in REQUIRED_GROUPS.items():
group_missing = [key for key in keys if not _is_configured(all_values.get(key))]
if group_missing:
labels = [FIELDS[key].label for key in group_missing]
missing.append(f"{group}{'、'.join(labels)}")
continue
target = REQUIRED_TESTS[group]
if not tests.get(target, {}).get("ok"):
untested.append(f"{group}:尚未通过连接测试")
continue
completed += 1
return SettingsReadiness(
ready=not missing and not untested,
completed_required=completed,
total_required=len(REQUIRED_GROUPS),
missing=missing,
untested=untested,
)
def current_tests(
self,
values: dict[str, Any],
tests: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
current: dict[str, dict[str, Any]] = {}
for target, result in tests.items():
item = dict(result)
if item.get("fingerprint") != self.test_fingerprint(target, values):
item["ok"] = False
item["message"] = "配置已变更,请重新测试。"
current[target] = item
return current
def test_fingerprint(self, target: str, values: dict[str, Any]) -> str:
if target == "infrastructure":
relevant: dict[str, Any] = {
"database_url": self.bootstrap.database_url,
"redis_url": self.bootstrap.redis_url,
}
else:
relevant = {key: values.get(key) for key in sorted(TEST_FIELDS.get(target, set()))}
payload = json.dumps(relevant, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
@staticmethod
def _coerce(key: str, value: Any) -> Any:
field = FIELDS[key]
if field.kind == "toggle":
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
if field.kind == "number":
return int(value)
return value.strip() if isinstance(value, str) else value
def get_runtime_store() -> EncryptedSettingsStore:
return EncryptedSettingsStore(get_settings())
def generate_secret(kind: str) -> str:
if kind == "hex24":
return secrets.token_hex(24)
if kind == "hex32":
return secrets.token_hex(32)
if kind == "base64_32":
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii")
raise ValueError("不支持的密钥类型。")
async def test_runtime_settings(
target: str,
values: dict[str, Any],
bootstrap: Settings,
) -> RuntimeSettingsTestResult:
try:
if target == "infrastructure":
return await _test_infrastructure(bootstrap)
if target == "storage":
return await _test_storage(values)
if target == "baidu_ocr":
return await _test_baidu_ocr(values)
if target == "ai_models":
return await _test_ai_models(values)
if target == "gpu":
return await _test_gpu(values)
if target == "langfuse":
return await _test_langfuse(values)
if target == "sentry":
return await _test_sentry(values)
except Exception as exc: # Integration boundaries return a safe, user-readable failure.
return RuntimeSettingsTestResult(target=target, ok=False, message=f"连接失败:{exc}")
return RuntimeSettingsTestResult(target=target, ok=False, message="未知测试类型。")
async def _test_infrastructure(bootstrap: Settings) -> RuntimeSettingsTestResult:
return await anyio.to_thread.run_sync(_test_infrastructure_sync, bootstrap)
def _test_infrastructure_sync(bootstrap: Settings) -> RuntimeSettingsTestResult:
with psycopg.connect(bootstrap.database_url, connect_timeout=5) as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
redis_client = redis.Redis.from_url(
bootstrap.redis_url,
socket_connect_timeout=5,
socket_timeout=5,
)
redis_client.ping()
return RuntimeSettingsTestResult(
target="infrastructure",
ok=True,
message="PostgreSQL 与 Redis 均连接正常。",
)
async def _test_storage(values: dict[str, Any]) -> RuntimeSettingsTestResult:
return await anyio.to_thread.run_sync(_test_storage_sync, values)
def _test_storage_sync(values: dict[str, Any]) -> RuntimeSettingsTestResult:
client = boto3.client(
"s3",
endpoint_url=values["s3_endpoint"],
aws_access_key_id=values["s3_access_key_id"],
aws_secret_access_key=values["s3_secret_access_key"],
region_name="us-east-1",
config=BotoConfig(
connect_timeout=5,
read_timeout=5,
retries={"max_attempts": 1},
s3={"addressing_style": "path" if values.get("s3_force_path_style", True) else "virtual"},
),
)
existing = {bucket["Name"] for bucket in client.list_buckets().get("Buckets", [])}
required = [
values["s3_bucket_inputs"],
values["s3_bucket_derived"],
values["s3_bucket_renders"],
]
missing = [bucket for bucket in required if bucket not in existing]
return RuntimeSettingsTestResult(
target="storage",
ok=not missing,
message="MinIO 连接正常,三个存储空间均可用。" if not missing else "MinIO 可连接,但需要先创建部分存储空间。",
details={"missing_buckets": missing},
)
async def _test_baidu_ocr(values: dict[str, Any]) -> RuntimeSettingsTestResult:
if not values.get("baidu_ocr_enabled"):
return RuntimeSettingsTestResult(target="baidu_ocr", ok=True, message="百度 OCR 当前未启用。")
async with httpx.AsyncClient(timeout=8) as client:
response = await client.post(
"https://aip.baidubce.com/oauth/2.0/token",
params={
"grant_type": "client_credentials",
"client_id": values.get("baidu_ocr_api_key", ""),
"client_secret": values.get("baidu_ocr_secret_key", ""),
},
)
response.raise_for_status()
payload = response.json()
ok = bool(payload.get("access_token"))
return RuntimeSettingsTestResult(
target="baidu_ocr",
ok=ok,
message="百度 OCR 凭证有效。" if ok else "百度 OCR 未返回访问令牌。",
)
async def _test_ai_models(values: dict[str, Any]) -> RuntimeSettingsTestResult:
url = _join_api_url(values["ai_base_url"], values.get("ai_models_path", "/models"))
async with httpx.AsyncClient(timeout=12) as client:
response = await client.get(url, headers={"Authorization": f"Bearer {values['ai_api_key']}"})
response.raise_for_status()
payload = response.json()
model_ids = {
item.get("id")
for item in payload.get("data", [])
if isinstance(item, dict) and item.get("id")
}
selected = [values.get("orchestrator_model"), values.get("vision_model"), values.get("image_model")]
missing = [model for model in selected if model and model_ids and model not in model_ids]
return RuntimeSettingsTestResult(
target="ai_models",
ok=not missing,
message="API Key 有效,三个模型均可用。" if not missing else "API 可以连接,但部分模型名不在账号模型列表中。",
details={"missing_models": missing, "model_count": len(model_ids)},
)
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
mode = values.get("gpu_mode", "disabled")
if mode == "disabled":
return RuntimeSettingsTestResult(target="gpu", ok=True, message="本地 GPU 当前未启用。")
local_host = "host.docker.internal" if Path("/.dockerenv").exists() else "127.0.0.1"
url = f"http://{local_host}:8100" if mode == "local" else values.get("gpu_service_url", "")
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(
_join_api_url(url, "/health"),
headers={"Authorization": f"Bearer {values.get('gpu_service_token', '')}"},
)
response.raise_for_status()
return RuntimeSettingsTestResult(target="gpu", ok=True, message="GPU Worker 连接正常。")
async def _test_langfuse(values: dict[str, Any]) -> RuntimeSettingsTestResult:
if not values.get("langfuse_enabled"):
return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 当前未启用。")
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(
_join_api_url(values.get("langfuse_host", ""), "/api/public/projects"),
auth=(
str(values.get("langfuse_public_key", "")),
str(values.get("langfuse_secret_key", "")),
),
)
response.raise_for_status()
return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 服务与项目密钥均有效。")
async def _test_sentry(values: dict[str, Any]) -> RuntimeSettingsTestResult:
if not values.get("sentry_enabled"):
return RuntimeSettingsTestResult(target="sentry", ok=True, message="Sentry 当前未启用。")
dsn = values.get("sentry_dsn", "")
parsed = urlparse(dsn)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or not parsed.username:
return RuntimeSettingsTestResult(target="sentry", ok=False, message="Sentry DSN 格式不正确。")
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(f"{parsed.scheme}://{parsed.netloc.split('@')[-1]}")
return RuntimeSettingsTestResult(
target="sentry",
ok=response.status_code < 500,
message="Sentry 地址可访问,DSN 格式正确。" if response.status_code < 500 else "Sentry 服务返回异常。",
)