254 lines
9.0 KiB
Python
254 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""M11b-2 运行时单事务写入 —— 连接工厂解析(conn_factory 三级优先级 + fail-closed)。
|
||
|
||
QC 退回意见 #5 已响应:**不再把 ``env/test.json`` 作为候选路径**。
|
||
按 project-directory-spec 8.1,部署环境信息唯一存放于 ``projects/{项目}/env/``,
|
||
应用/模块仓库不得存放、亦不得自行读取 ``env/{test,prod}.json`` 凭据。
|
||
本模块的连接参数只允许来自以下三处(优先级从高到低):
|
||
|
||
1. **显式注入**:调用方(宿主应用)直接传 ``conn_factory=callable``;
|
||
2. **ServerEnv 登记**:宿主应用 init() 里把 ``pbl_runtime_conn_factory`` /
|
||
``world_sync_conn_factory`` 挂到 ServerEnv,本模块按 key 读取;
|
||
3. **模块自有配置**:``conf/db.json``(模块仓库内,非部署环境凭据文件),
|
||
凭据字段一律走环境变量名间接引用(``"password_env": "PBL_DB_PASSWORD"``),
|
||
配置文件里不出现明文口令;
|
||
4. 以上皆无 → 抛 :class:`ConnFactoryNotConfigured`(fail-closed,绝不偷偷连库、
|
||
绝不回落到「读一下 env/test.json 试试」)。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
|
||
from .pbl_runtime_errors import ConnFactoryNotConfigured, TxConfigError
|
||
|
||
__all__ = [
|
||
"CONN_FACTORY_ENV_KEYS",
|
||
"SERVER_ENV_KEYS",
|
||
"DB_CONFIG_RELATIVE",
|
||
"resolve_conn_factory",
|
||
"build_sqlite_conn_factory",
|
||
"build_mysql_conn_factory",
|
||
"load_db_config",
|
||
"module_conf_dir",
|
||
]
|
||
|
||
# 环境变量:值为 "sqlite:///path" 或 "mysql://user:pass@host:3306/db"(口令只在环境变量里,
|
||
# 不进任何配置文件),存在即优先级最高之一。
|
||
CONN_FACTORY_ENV_KEYS = ("PBL_RUNTIME_DB_URL", "WORLD_SYNC_DB_URL")
|
||
|
||
# ServerEnv 上的登记键(宿主注入),按顺序尝试。
|
||
SERVER_ENV_KEYS = ("pbl_runtime_conn_factory", "world_sync_conn_factory")
|
||
|
||
# 模块自有配置文件(相对模块仓库根),只放非敏感参数 + 环境变量名引用。
|
||
DB_CONFIG_RELATIVE = os.path.join("conf", "db.json")
|
||
|
||
_DB_URL_SCHEME_SQLITE = ("sqlite:///", "sqlite://")
|
||
_DB_URL_SCHEME_MYSQL = ("mysql://", "mysql+pymysql://")
|
||
|
||
|
||
def module_root():
|
||
"""模块仓库根(本文件位于 <root>/world_sync/pbl_runtime_tx_env.py)。"""
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
return os.path.dirname(here)
|
||
|
||
|
||
def module_conf_dir():
|
||
"""模块自有 conf 目录绝对路径。"""
|
||
return os.path.join(module_root(), "conf")
|
||
|
||
|
||
def load_db_config(conf_dir=None):
|
||
"""读取模块自有 ``conf/db.json``;不存在返回 ``None``(不是错误,交给 fail-closed 判定)。
|
||
|
||
文件存在但不可解析 → 抛 :class:`TxConfigError`(不吞错误,不静默降级)。
|
||
"""
|
||
path = os.path.join(conf_dir or module_conf_dir(), "db.json")
|
||
if not os.path.isfile(path):
|
||
return None
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
except ValueError as exc:
|
||
raise TxConfigError("cannot parse module conf db.json: %s" % path, path=path)
|
||
except (IOError, OSError) as exc:
|
||
raise TxConfigError("cannot read module conf db.json: %s" % path, path=path)
|
||
if not isinstance(data, dict):
|
||
raise TxConfigError("module conf db.json must be a JSON object", path=path)
|
||
return data
|
||
|
||
|
||
def _parse_db_url(url):
|
||
"""把 DB URL 解析成参数字典。口令只来自环境变量构造出的 URL 本身,不落盘。"""
|
||
url = (url or "").strip()
|
||
for scheme in _DB_URL_SCHEME_SQLITE:
|
||
if url.startswith(scheme):
|
||
return {"dialect": "sqlite", "path": url[len(scheme):] or ":memory:"}
|
||
for scheme in _DB_URL_SCHEME_MYSQL:
|
||
if url.startswith(scheme):
|
||
rest = url[len(scheme):]
|
||
userinfo, _, hostpart = rest.rpartition("@")
|
||
hostport, _, database = hostpart.partition("/")
|
||
host, _, port = hostport.partition(":")
|
||
user, _, password = userinfo.partition(":")
|
||
return {
|
||
"dialect": "mysql",
|
||
"host": host,
|
||
"port": int(port) if port else 3306,
|
||
"user": user,
|
||
"password": password,
|
||
"database": database.split("?")[0],
|
||
}
|
||
raise TxConfigError("unsupported db url scheme: %r" % url, url_scheme=url.split(":")[0])
|
||
|
||
|
||
def build_sqlite_conn_factory(path=":memory:"):
|
||
"""构造 sqlite 连接工厂(单测 / 本地)。
|
||
|
||
``isolation_level=None`` 关掉 sqlite3 自动插 BEGIN 的行为,
|
||
让事务边界完全由 :mod:`world_sync.pbl_runtime_tx` 控制(原子性可测)。
|
||
"""
|
||
import sqlite3
|
||
|
||
def _factory():
|
||
conn = sqlite3.connect(path)
|
||
conn.isolation_level = None # 显式事务控制
|
||
conn.execute("PRAGMA foreign_keys = ON")
|
||
return conn
|
||
|
||
return _factory
|
||
|
||
|
||
def build_mysql_conn_factory(params):
|
||
"""构造 MySQL 连接工厂(生产)。缺驱动 / 缺参数一律抛错,不降级到 sqlite。"""
|
||
try:
|
||
import pymysql
|
||
except ImportError as exc:
|
||
raise TxConfigError("pymysql not installed; cannot build mysql conn_factory")
|
||
host = params.get("host")
|
||
user = params.get("user")
|
||
database = params.get("database")
|
||
if not (host and user and database):
|
||
raise TxConfigError(
|
||
"incomplete mysql params", missing=[
|
||
k for k in ("host", "user", "database") if not params.get(k)
|
||
]
|
||
)
|
||
kw = {
|
||
"host": host,
|
||
"port": int(params.get("port") or 3306),
|
||
"user": user,
|
||
"password": params.get("password") or "",
|
||
"database": database,
|
||
"charset": params.get("charset") or "utf8mb4",
|
||
"autocommit": False,
|
||
}
|
||
|
||
def _factory():
|
||
return pymysql.connect(**kw)
|
||
|
||
return _factory
|
||
|
||
|
||
def _server_env():
|
||
"""取宿主 ServerEnv 实例;宿主框架不可用时返回 ``None``(不抛,交给 fail-closed)。"""
|
||
try:
|
||
from appPublic.serverEnv import ServerEnv # 宿主框架提供
|
||
except Exception:
|
||
return None
|
||
try:
|
||
return ServerEnv()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _from_server_env():
|
||
env = _server_env()
|
||
if env is None:
|
||
return None
|
||
for key in SERVER_ENV_KEYS:
|
||
getter = getattr(env, "get", None)
|
||
if getter is None:
|
||
break
|
||
try:
|
||
value = getter(key)
|
||
except Exception:
|
||
value = None
|
||
if callable(value):
|
||
return value
|
||
if isinstance(value, str) and value.strip():
|
||
return _factory_from_url(value)
|
||
return None
|
||
|
||
|
||
def _factory_from_url(url):
|
||
params = _parse_db_url(url)
|
||
if params["dialect"] == "sqlite":
|
||
return build_sqlite_conn_factory(params["path"])
|
||
return build_mysql_conn_factory(params)
|
||
|
||
|
||
def _from_env_vars():
|
||
for key in CONN_FACTORY_ENV_KEYS:
|
||
url = os.environ.get(key)
|
||
if url and url.strip():
|
||
return _factory_from_url(url.strip())
|
||
return None
|
||
|
||
|
||
def _from_module_conf():
|
||
"""模块自有 conf/db.json:只允许环境变量名引用凭据,禁止明文口令。"""
|
||
cfg = load_db_config()
|
||
if not cfg:
|
||
return None
|
||
dialect = (cfg.get("dialect") or "").lower()
|
||
if dialect == "sqlite":
|
||
return build_sqlite_conn_factory(cfg.get("path") or ":memory:")
|
||
if dialect in ("mysql", "mysql+pymysql"):
|
||
params = dict(cfg)
|
||
params["dialect"] = "mysql"
|
||
pw_env = cfg.get("password_env")
|
||
if pw_env:
|
||
params["password"] = os.environ.get(pw_env, "")
|
||
if cfg.get("password"):
|
||
raise TxConfigError(
|
||
"plaintext password is forbidden in module conf db.json; "
|
||
"use password_env to reference an environment variable"
|
||
)
|
||
return build_mysql_conn_factory(params)
|
||
raise TxConfigError("unknown dialect in module conf db.json", dialect=dialect)
|
||
|
||
|
||
def resolve_conn_factory(conn_factory=None):
|
||
"""按三级优先级解析出可用的 conn_factory,全部落空则 fail-closed 抛错。
|
||
|
||
:param conn_factory: 优先级 1(显式注入)
|
||
:returns: 无参可调用对象,每次调用返回一个新的 DB-API 连接
|
||
:raises ConnFactoryNotConfigured: 三级皆无
|
||
"""
|
||
if callable(conn_factory):
|
||
return conn_factory
|
||
|
||
if conn_factory is not None and isinstance(conn_factory, str):
|
||
return _factory_from_url(conn_factory)
|
||
|
||
resolved = _from_server_env()
|
||
if resolved is not None:
|
||
return resolved
|
||
|
||
resolved = _from_env_vars()
|
||
if resolved is not None:
|
||
return resolved
|
||
|
||
resolved = _from_module_conf()
|
||
if resolved is not None:
|
||
return resolved
|
||
|
||
raise ConnFactoryNotConfigured(
|
||
"no conn_factory available: pass conn_factory=..., register "
|
||
"ServerEnv.pbl_runtime_conn_factory, or set %s / module conf/db.json. "
|
||
"Reading deployment env/{test,prod}.json from a module repository is "
|
||
"forbidden by project-directory-spec 8.1 (fail-closed)."
|
||
% ", ".join(CONN_FACTORY_ENV_KEYS),
|
||
tried=["explicit", "server_env", "env_vars", "module_conf/db.json"],
|
||
)
|