Files
planet/scripts/harness/test_database_startup.py
rayd1o a54fcdbeed
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
release: bump version to 0.74.3
2026-09-13 02:17:55 +08:00

275 lines
12 KiB
Python

"""Database lifecycle regressions without starting or changing host containers."""
import asyncio
from contextlib import redirect_stderr, redirect_stdout
import io
import json
from pathlib import Path
import re
import subprocess
import sys
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from asyncpg import InvalidCatalogNameError, InvalidPasswordError
from sqlalchemy.engine import make_url
from sqlalchemy.exc import DBAPIError
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
import check_database_connection as probe # noqa: E402
TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/planet_db")
def shell_function(name: str) -> str:
source = (ROOT / "planet.sh").read_text()
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
if match is None:
raise AssertionError(f"missing shell function: {name}")
return match.group()
def run_shell(functions: list[str], setup: str, action: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["zsh", "-f"],
input="\n".join(["set -e", *(shell_function(name) for name in functions), setup, action]),
capture_output=True,
text=True,
timeout=10,
check=False,
)
class DatabaseLifecycleTests(unittest.TestCase):
def test_existing_container_gets_current_compose_configuration(self) -> None:
for function in ("start_database_services", "start_postgres_service"):
with self.subTest(function=function):
result = run_shell(
[function],
"""
mapped=0
docker() { return 0; }
compose_up() { mapped=1; }
""",
f"{function}\n[ $mapped -eq 1 ]",
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_port_conflict_is_visible_and_cannot_fall_back_to_old_container(self) -> None:
result = run_shell(
["start_database_services"],
"""
docker() { return 0; }
compose_up() { echo 'port is already allocated' >&2; return 1; }
""",
"start_database_services",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("port is already allocated", result.stderr)
def test_installed_compose_failure_is_not_reported_as_missing_compose(self) -> None:
result = run_shell(
["compose_up"],
"""
compose_available() { return 0; }
compose_v1_available() { return 1; }
docker() { echo 'address already in use' >&2; return 1; }
set_wait_detail() { :; }
log_warn() { echo "$*"; }
log_error() { echo "$*"; }
report_missing_compose() { echo MISSING_COMPOSE; return 1; }
""",
"compose_up up -d postgres",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("address already in use", result.stderr)
self.assertNotIn("MISSING_COMPOSE", result.stdout)
def run_init(self, connection_status: int) -> subprocess.CompletedProcess[str]:
return run_shell(
["init"],
f"""
for fn in parse_service_args guard_init_when_services_running print_splash \
start_wait_session stop_wait_session ensure_docker_runtime ensure_python_runtime \
sync_python_deps sync_frontend_deps ensure_planet_env_files \
prepare_motion_agent_host_dependencies set_wait_detail; do
functions[$fn]='return 0'
done
log_success() {{ echo "$*"; }}
log_error() {{ echo "$*"; }}
log_step() {{ :; }}
log_note() {{ :; }}
ensure_database_services_healthy() {{ echo CONTAINERS_HEALTHY; }}
verify_backend_database_connection() {{ echo CONNECTION_CHECK; return {connection_status}; }}
run_command_with_spinner() {{ shift; "$@"; }}
initialize_backend_database() {{ echo SCHEMA_INITIALIZED; }}
""",
"init --non-motion-agent",
)
def test_healthy_containers_do_not_allow_schema_changes_on_bad_connection(self) -> None:
result = self.run_init(1)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("CONNECTION_CHECK", result.stdout)
self.assertNotIn("SCHEMA_INITIALIZED", result.stdout)
self.assertNotIn("数据库服务已就绪", result.stdout)
def test_real_connection_is_checked_before_reporting_ready_and_creating_tables(self) -> None:
result = self.run_init(0)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("CONNECTION_CHECK", result.stdout)
self.assertLess(
result.stdout.index("CONNECTION_CHECK"), result.stdout.index("数据库服务已就绪")
)
self.assertLess(
result.stdout.index("数据库服务已就绪"), result.stdout.index("SCHEMA_INITIALIZED")
)
def test_only_missing_ports_trigger_one_volume_preserving_recreation(self) -> None:
for first_status, retry_status, expected_status, expected_recreates in (
(0, 0, 0, 0),
(1, 0, 1, 0),
(2, 0, 0, 1),
(2, 2, 2, 1),
):
with self.subTest(first_status=first_status, retry_status=retry_status):
result = run_shell(
["verify_backend_database_connection"],
f"""
checks=0
set_wait_detail() {{ :; }}
log_warn() {{ :; }}
compose_up() {{ echo "COMPOSE $*"; }}
wait_for_postgres_health() {{ return 0; }}
run_command_with_spinner() {{
checks=$((checks + 1))
if [ $checks -eq 1 ]; then return {first_status}; fi
return {retry_status}
}}
""",
"verify_backend_database_connection",
)
self.assertEqual(result.returncode, expected_status, result.stderr)
self.assertEqual(result.stdout.count("COMPOSE"), expected_recreates)
if expected_recreates:
self.assertIn("up -d --no-deps --force-recreate postgres", result.stdout)
self.assertNotIn("--renew-anon-volumes", result.stdout)
class DatabaseProbeTests(unittest.TestCase):
def docker_result(
self, ports: dict | None, mode: str = "bridge"
) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess([], 0, f"{mode}\n{json.dumps(ports)}\n", "")
def test_missing_port_blocks_sql_even_if_container_is_healthy(self) -> None:
output = io.StringIO()
with (
patch.object(probe, "backend_database_url", return_value=TEST_URL),
patch.object(probe.subprocess, "run", return_value=self.docker_result({})),
patch.object(probe, "check_connection", new_callable=AsyncMock) as connect,
redirect_stderr(output),
redirect_stdout(output),
):
self.assertEqual(probe.main(), 2)
connect.assert_not_called()
self.assertIn("未向宿主机发布", output.getvalue())
self.assertNotIn("test-secret", output.getvalue())
def test_actual_backend_port_must_match_published_port(self) -> None:
result = self.docker_result({"5432/tcp": [{"HostIp": "0.0.0.0", "HostPort": "15432"}]})
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError):
probe.check_published_port(TEST_URL)
probe.check_published_port(TEST_URL.set(port=15432))
def test_host_networking_does_not_require_a_port_mapping(self) -> None:
with patch.object(probe.subprocess, "run", return_value=self.docker_result(None, "host")):
probe.check_published_port(TEST_URL)
def test_external_database_does_not_require_a_local_mapping(self) -> None:
with patch.object(probe.subprocess, "run") as inspect:
probe.check_published_port(TEST_URL.set(host="configured-db.example"))
inspect.assert_not_called()
def test_docker_failure_does_not_echo_raw_output(self) -> None:
result = subprocess.CompletedProcess([], 1, "", "sensitive test-secret")
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError) as error:
probe.check_published_port(TEST_URL)
self.assertIn("Docker context", str(error.exception))
self.assertNotIn("test-secret", str(error.exception))
def test_authentication_error_is_actionable_without_leaking_dsn_or_query_secrets(self) -> None:
output = io.StringIO()
url = TEST_URL.update_query_dict({"sslpassword": "query-secret"})
error = DBAPIError(
None, None, InvalidPasswordError(url.render_as_string(hide_password=False))
)
with (
patch.object(probe, "backend_database_url", return_value=url),
patch.object(probe, "check_published_port"),
patch.object(probe, "check_connection", new_callable=AsyncMock, side_effect=error),
redirect_stderr(output),
redirect_stdout(output),
):
self.assertEqual(probe.main(), 1)
self.assertIn("认证失败", output.getvalue())
self.assertNotIn("test-secret", output.getvalue())
self.assertNotIn("query-secret", output.getvalue())
self.assertNotIn("Traceback", output.getvalue())
def test_missing_database_and_network_errors_have_distinct_diagnostics(self) -> None:
self.assertIn("数据库不存在", probe.connection_diagnostic(InvalidCatalogNameError()))
for error in (ConnectionRefusedError(), TimeoutError()):
self.assertIn("地址不可达", probe.connection_diagnostic(error))
def test_invalid_config_never_echoes_validation_input(self) -> None:
output = io.StringIO()
with (
patch.object(probe, "backend_database_url", side_effect=ValueError("test-secret")),
redirect_stderr(output),
):
self.assertEqual(probe.main(), 1)
self.assertNotIn("test-secret", output.getvalue())
class DatabaseConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_connection_deadline_stops_a_stalled_probe(self) -> None:
async def stall() -> None:
await asyncio.sleep(1)
engine = MagicMock()
engine.dispose = AsyncMock()
engine.connect.return_value.__aenter__.side_effect = stall
with (
patch.object(probe, "create_async_engine", return_value=engine),
patch.object(probe, "CONNECT_TIMEOUT_SECONDS", 0.001),
):
with self.assertRaises(TimeoutError):
await probe.check_connection(TEST_URL)
engine.dispose.assert_awaited_once()
async def test_probe_only_selects_and_always_disposes_the_engine(self) -> None:
for error in (None, InvalidPasswordError("test-secret")):
with self.subTest(error=type(error).__name__):
engine = MagicMock()
engine.dispose = AsyncMock()
connection = AsyncMock()
engine.connect.return_value.__aenter__.return_value = connection
connection.execute.side_effect = error
with patch.object(probe, "create_async_engine", return_value=engine):
if error:
with self.assertRaises(InvalidPasswordError):
await probe.check_connection(TEST_URL)
else:
await probe.check_connection(TEST_URL)
self.assertEqual(str(connection.execute.call_args.args[0]), "SELECT 1")
engine.dispose.assert_awaited_once()
if __name__ == "__main__":
unittest.main()