Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue/#1043 Minimum database version check #1053

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from server import info
from server.config import config
from server.control import ControlServer
from server.db import FAFDatabase, get_and_validate_database_version
from server.game_service import GameService
from server.health import HealthServer
from server.player_service import PlayerService
Expand Down Expand Up @@ -69,12 +70,17 @@ def done_handler(sig: int, frame):
signal.signal(signal.SIGTERM, done_handler)
signal.signal(signal.SIGINT, done_handler)

database = server.db.FAFDatabase(
database = FAFDatabase(
host=config.DB_SERVER,
port=int(config.DB_PORT),
user=config.DB_LOGIN,
password=config.DB_PASSWORD,
db=config.DB_NAME
db=config.DB_NAME,
)
database_version = await get_and_validate_database_version(database)
logger.info(
"Database version is %s",
f"v{database_version}" if database_version is not None else "unknown",
)

# Set up services
Expand Down
2 changes: 2 additions & 0 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def __init__(self):
self.DB_LOGIN = "root"
self.DB_PASSWORD = "banana"
self.DB_NAME = "faf"
# An empty value will disable the database version check
self.DB_FLYWAY_TABLE = "flyway_schema_history"

self.API_CLIENT_ID = "client_id"
self.API_CLIENT_SECRET = "banana"
Expand Down
43 changes: 42 additions & 1 deletion server/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@
import asyncio
import logging
from contextlib import contextmanager
from typing import Optional

from sqlalchemy import create_engine, text
from sqlalchemy import and_, create_engine, select, text, true
from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.ext.asyncio import AsyncConnection as _AsyncConnection
from sqlalchemy.ext.asyncio import AsyncEngine as _AsyncEngine
from sqlalchemy.util import EMPTY_DICT

from server.config import config
from server.db.models import get_flyway_schema_history_table
from server.metrics import db_exceptions

logger = logging.getLogger(__name__)


FLYWAY_MINIMUM_REQUIRED_VERSION = 136


@contextmanager
def stat_db_errors():
"""
Expand Down Expand Up @@ -198,3 +204,38 @@ async def _deadlock_retry_execute(
execution_options=execution_options,
**kwargs
)


async def get_and_validate_database_version(db: FAFDatabase) -> Optional[int]:
if not config.DB_FLYWAY_TABLE:
return None

flyway_schema_history = get_flyway_schema_history_table(
config.DB_FLYWAY_TABLE,
)

async with db.acquire() as conn:
result = await conn.execute(
select(
flyway_schema_history.c.version,
).where(
and_(
flyway_schema_history.c.success == true(),
flyway_schema_history.c.version.is_not(None),
),
),
)
version = max((int(row.version) for row in result), default=None)

if version is None:
raise RuntimeError(
"No successful database migrations found! Unable to determine "
"database version",
)
if version < FLYWAY_MINIMUM_REQUIRED_VERSION:
raise RuntimeError(
f"Database version v{version} does not meet minimum requirement "
f"v{FLYWAY_MINIMUM_REQUIRED_VERSION}",
)
Comment on lines +236 to +239
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this sufficient to actually crash the application?

Copy link
Collaborator Author

@Askaholic Askaholic Mar 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it looks something like this:

$ pipenv run devserver
DEBUG    Mar 02  15:51:36 asyncio                        Using selector: EpollSelector
INFO     Mar 02  15:51:36 root                           Lobby dev (Python 3.10.0) on linux named faf-python-server
INFO     Mar 02  15:51:36 root                           Event loop: <_UnixSelectorEventLoop running=True closed=False debug=False>
Traceback (most recent call last):
  File "/home/user/Documents/fa-server/main.py", line 214, in <module>
    exit_code = asyncio.run(main())
  File "/usr/local/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/local/lib/python3.10/asyncio/base_events.py", line 641, in run_until_complete
    return future.result()
  File "/home/user/Documents/fa-server/main.py", line 80, in main
    database_version = await get_and_validate_database_version(database)
  File "/home/user/Documents/fa-server/server/db/__init__.py", line 236, in get_and_validate_database_version
    raise RuntimeError(
RuntimeError: Database version v136 does not meet minimum requirement v1000
Exception ignored in: <function Connection.__del__ at 0x7f63a4fe5990>
Traceback (most recent call last):
  File "/home/user/Documents/fa-server/.venv/lib/python3.10/site-packages/aiomysql/connection.py", line 1131, in __del__
  File "/home/user/Documents/fa-server/.venv/lib/python3.10/site-packages/aiomysql/connection.py", line 339, in close
  File "/usr/local/lib/python3.10/asyncio/selector_events.py", line 700, in close
  File "/usr/local/lib/python3.10/asyncio/base_events.py", line 745, in call_soon
  File "/usr/local/lib/python3.10/asyncio/base_events.py", line 510, in _check_closed
RuntimeError: Event loop is closed


$ echo $?
1


return version
10 changes: 10 additions & 0 deletions server/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,13 @@
Column("create_time", TIMESTAMP, nullable=False),
Column("update_time", TIMESTAMP, nullable=False)
)


def get_flyway_schema_history_table(table_name: str) -> Table:
return Table(
table_name, metadata,
Column("version", String(50)),
Column("success", Boolean),
# For interaction with unit tests
extend_existing=True,
)
98 changes: 97 additions & 1 deletion tests/unit_tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,32 @@
import pytest
from sqlalchemy.exc import OperationalError

from server.db import AsyncConnection
from server.config import config
from server.db import (
FLYWAY_MINIMUM_REQUIRED_VERSION,
AsyncConnection,
get_and_validate_database_version
)
from server.db.models import get_flyway_schema_history_table
from tests.utils import fast_forward


@pytest.fixture(scope="session")
def flyway_schema_history_table():
return get_flyway_schema_history_table("mock_flyway_schema_history")


@pytest.fixture
async def flyway_schema_history_table_setup(
database,
flyway_schema_history_table,
):
async with database.acquire() as conn:
await conn.run_sync(flyway_schema_history_table.create, database.engine)

return flyway_schema_history_table


@fast_forward(10)
async def test_deadlock_retry_execute():
mock_conn = mock.Mock()
Expand Down Expand Up @@ -41,3 +63,77 @@ async def _execute(*args, **kwargs):
await AsyncConnection._deadlock_retry_execute(mock_conn, "foo")

assert mock_conn._execute.call_count == 2


async def test_get_and_validate_database_version(
database,
flyway_schema_history_table_setup,
monkeypatch,
):
current_version = FLYWAY_MINIMUM_REQUIRED_VERSION + 1
async with database.acquire() as conn:
await conn.execute(
flyway_schema_history_table_setup.insert().values(
version="1",
success=True,
).values(
version="100",
success=True,
).values(
version="1000000",
success=False,
).values(
version=str(current_version),
success=True,
),
)

table_name = flyway_schema_history_table_setup.name
monkeypatch.setattr(config, "DB_FLYWAY_TABLE", table_name)

version = await get_and_validate_database_version(database)

assert version is not None
assert version == current_version


async def test_get_and_validate_database_version_empty(
database,
flyway_schema_history_table_setup,
monkeypatch,
):
table_name = flyway_schema_history_table_setup.name
monkeypatch.setattr(config, "DB_FLYWAY_TABLE", table_name)

with pytest.raises(RuntimeError, match="No successful database migrations"):
await get_and_validate_database_version(database)


async def test_get_and_validate_database_version_too_low(
database,
flyway_schema_history_table_setup,
monkeypatch,
):
async with database.acquire() as conn:
await conn.execute(
flyway_schema_history_table_setup.insert().values(
version="1",
success=True,
),
)

table_name = flyway_schema_history_table_setup.name
monkeypatch.setattr(config, "DB_FLYWAY_TABLE", table_name)

with pytest.raises(RuntimeError, match="does not meet minimum requirement"):
await get_and_validate_database_version(database)


async def test_get_and_validate_database_version_disabled(
database,
monkeypatch,
):
monkeypatch.setattr(config, "DB_FLYWAY_TABLE", "")
version = await get_and_validate_database_version(database)

assert version is None
4 changes: 3 additions & 1 deletion tests/utils/mock_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ class MockConnectionContext:
def __init__(self, db):
self._db = db

def commit(self):
"""Ignore manual commits"""

async def __aenter__(self):
await self._db._lock.acquire()
return self._db._connection
Expand All @@ -24,7 +27,6 @@ class MockDatabase(FAFDatabase):
at the same time we can rollback all these changes once the test is over.

Note that right now the server relies on autocommit behaviour sqlalchemy.
Any future manual commit() calls should be mocked here as well.
"""

def __init__(
Expand Down
Loading