Files
planet/scripts/check_database_connection.py
linkong 60eefb19c9
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.74.7
2026-09-17 17:30:54 +08:00

190 lines
7.2 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.
#!/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 re
import socket
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
DEFAULT_REDIS_PORT = 6379
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 configured_ports() -> tuple[int, int]:
database = make_url(backend_database_url())
from app.core.config import settings
redis = make_url(settings.REDIS_URL)
postgres_port = database.port or DEFAULT_POSTGRES_PORT
redis_port = redis.port or DEFAULT_REDIS_PORT
targets = []
if database.host in LOOPBACK_HOSTS:
targets.append(("PostgreSQL", POSTGRES_CONTAINER, postgres_port))
if redis.host in LOOPBACK_HOSTS:
targets.append(("Redis", "planet_redis", redis_port))
check_port_conflicts(targets)
return postgres_port, redis_port
def check_port_conflicts(targets: list[tuple[str, str, int]]) -> None:
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"],
capture_output=True, text=True, timeout=CONNECT_TIMEOUT_SECONDS, check=False,
)
if result.returncode:
raise DatabaseReadinessError("无法读取 Docker 端口占用;请检查 daemon 和当前用户权限。")
for service, expected_container, port in targets:
own_binding = False
for line in result.stdout.splitlines():
name, _, bindings = line.partition("\t")
if not re.search(rf":{port}->", bindings):
continue
if name == expected_container:
own_binding = True
else:
raise DatabaseReadinessError(
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port} 被 {name} 占用;"
"请调整 Planet 后端连接端口。"
)
if not own_binding:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
try:
# Docker can reuse recently closed TCP connections; TIME_WAIT is not a listener.
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", port))
except OSError as error:
raise DatabaseReadinessError(
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port} 被宿主机服务占用;"
"请检查监听进程或调整 Planet 后端连接端口。"
) from error
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:
if sys.argv[1:] == ["--ports"]:
print(*configured_ports())
return 0
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())