Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Check the backend's PostgreSQL target before init is allowed to change its schema."""
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import URL, make_url
|
||
from sqlalchemy.ext.asyncio import create_async_engine
|
||
from sqlalchemy.pool import NullPool
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
CONNECT_TIMEOUT_SECONDS = 10
|
||
DEFAULT_POSTGRES_PORT = 5432
|
||
POSTGRES_CONTAINER = "planet_postgres"
|
||
POSTGRES_CONTAINER_PORT = "5432/tcp"
|
||
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||
|
||
|
||
class DatabaseReadinessError(RuntimeError):
|
||
"""A safe, actionable diagnostic that does not include connection credentials."""
|
||
|
||
|
||
class MissingPortBindingError(DatabaseReadinessError):
|
||
"""Exit 2 asks planet.sh to reconcile the managed container once, preserving its volume."""
|
||
|
||
|
||
def backend_database_url() -> str:
|
||
sys.path.insert(0, str(ROOT / "backend"))
|
||
from app.core.config import settings
|
||
|
||
return settings.DATABASE_URL
|
||
|
||
|
||
def check_published_port(url: URL) -> None:
|
||
if url.host not in LOOPBACK_HOSTS:
|
||
return # An explicitly configured external database has no local container mapping.
|
||
result = subprocess.run(
|
||
[
|
||
"docker",
|
||
"inspect",
|
||
"--format",
|
||
"{{.HostConfig.NetworkMode}}\n{{json .NetworkSettings.Ports}}",
|
||
POSTGRES_CONTAINER,
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=CONNECT_TIMEOUT_SECONDS,
|
||
check=False,
|
||
)
|
||
if result.returncode:
|
||
raise DatabaseReadinessError(
|
||
"无法读取 planet_postgres;请检查当前 Docker context 和容器状态。"
|
||
)
|
||
network_mode, ports_json = result.stdout.strip().split("\n", 1)
|
||
if network_mode == "host":
|
||
return # Host networking intentionally has no published-port table.
|
||
ports = json.loads(ports_json) or {}
|
||
bindings = ports.get(POSTGRES_CONTAINER_PORT) or []
|
||
port = str(url.port or DEFAULT_POSTGRES_PORT)
|
||
if not any(binding.get("HostPort") == port for binding in bindings):
|
||
raise MissingPortBindingError(
|
||
f"planet_postgres 未向宿主机发布后端配置的端口 {port};"
|
||
"请核对 Compose ports、DATABASE_URL 和 Docker context。"
|
||
"容器内部健康不代表宿主机可连接。"
|
||
)
|
||
|
||
|
||
async def check_connection(url: URL) -> None:
|
||
engine = create_async_engine(url, poolclass=NullPool, echo=False)
|
||
try:
|
||
async with asyncio.timeout(CONNECT_TIMEOUT_SECONDS):
|
||
async with engine.connect() as connection:
|
||
await connection.execute(text("SELECT 1"))
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
def connection_diagnostic(error: BaseException) -> str:
|
||
pending = [error]
|
||
visited: set[int] = set()
|
||
while pending:
|
||
current = pending.pop()
|
||
if id(current) in visited:
|
||
continue
|
||
visited.add(id(current))
|
||
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||
if sqlstate in {"28P01", "28000"}:
|
||
return (
|
||
"PostgreSQL 认证失败:核对 DATABASE_URL 的账号密码及连接目标。"
|
||
"只改 POSTGRES_PASSWORD 不会更新 DATABASE_URL,也不会重设已有数据卷的密码。"
|
||
)
|
||
if sqlstate == "3D000":
|
||
return "目标数据库不存在:核对 DATABASE_URL 的库名与已有数据库。"
|
||
if isinstance(current, (TimeoutError, OSError)):
|
||
return "数据库连接被拒绝、超时或地址不可达:核对端口映射、监听服务和 Docker endpoint。"
|
||
for nested in (getattr(current, "orig", None), current.__cause__, current.__context__):
|
||
if isinstance(nested, BaseException):
|
||
pending.append(nested)
|
||
return "数据库连接检查失败:核对 backend/.env、进程环境变量和目标 PostgreSQL 服务日志。"
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
url = make_url(backend_database_url())
|
||
print(
|
||
f"后端数据库目标: host={url.host!r} port={url.port or DEFAULT_POSTGRES_PORT} database={url.database!r}"
|
||
)
|
||
print(
|
||
"DATABASE_URL 来源: "
|
||
+ ("进程环境变量" if "DATABASE_URL" in os.environ else "backend 配置")
|
||
)
|
||
check_published_port(url)
|
||
asyncio.run(check_connection(url))
|
||
except MissingPortBindingError as error:
|
||
print(str(error), file=sys.stderr)
|
||
return 2
|
||
except DatabaseReadinessError as error:
|
||
print(str(error), file=sys.stderr)
|
||
return 1
|
||
except Exception as error:
|
||
# Driver/config exceptions can contain DSNs and passwords. Never echo their raw text.
|
||
print(connection_diagnostic(error), file=sys.stderr)
|
||
return 1
|
||
print("后端 PostgreSQL 连接与认证已通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|