diff --git a/osism/commands/loadbalancer.py b/osism/commands/loadbalancer.py index 168e4b81..04389175 100644 --- a/osism/commands/loadbalancer.py +++ b/osism/commands/loadbalancer.py @@ -11,6 +11,7 @@ import yaml from osism.commands.octavia import wait_for_amphora_boot +from osism.utils import mariadb def _load_kolla_configuration(): @@ -79,16 +80,12 @@ def _get_octavia_database_connection(): if password is None: return None - # Determine database user based on ProxySQL configuration - enable_proxysql = config.get("enable_proxysql", False) - db_user = "octavia_shard_0" if enable_proxysql else "octavia" - try: - connection = pymysql.connect( - host=vip_address, + connection = mariadb.connect( + vip_address, + "octavia", + password, port=3306, - user=db_user, - password=password, database="octavia", cursorclass=pymysql.cursors.DictCursor, connect_timeout=10, diff --git a/osism/commands/status.py b/osism/commands/status.py index da92052a..f9f37d9c 100644 --- a/osism/commands/status.py +++ b/osism/commands/status.py @@ -332,22 +332,20 @@ def take_action(self, parsed_args): logger.error("Failed to load database password") return 1 - # Determine database user based on ProxySQL configuration - enable_proxysql = config.get("enable_proxysql", False) - db_user = "root_shard_0" if enable_proxysql else "root" - # Connect to MariaDB if format == "log": - logger.info(f"Connecting to MariaDB at {vip_address} as {db_user}...") + logger.info(f"Connecting to MariaDB at {vip_address}...") import pymysql + from osism.utils import mariadb + try: - connection = pymysql.connect( - host=vip_address, + connection = mariadb.connect( + vip_address, + "root", + password, port=3306, - user=db_user, - password=password, connect_timeout=10, ) except pymysql.Error as exc: diff --git a/osism/utils/mariadb.py b/osism/utils/mariadb.py new file mode 100644 index 00000000..2cd520ee --- /dev/null +++ b/osism/utils/mariadb.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Shared MariaDB connection helper used by the status and loadbalancer commands. + +Both need a superuser connection to the cluster's MariaDB, and both have to cope +with the two load-balancer topologies OSISM ships. On a ProxySQL cluster (the +2025.2 default) the proxied superuser is the sharded name ``_shard_0``; on a +plain HAProxy cluster it is ````. + +Which topology is in use is decided by ``enable_proxysql``, a release-gated +``osism/defaults`` group_vars value. It is absent from the operator's +``environments/kolla/configuration.yml`` and is only correct after Ansible templates +it in the host's variable context, so it cannot be read from a raw config file (doing +so silently returned the wrong user -- the ProxySQL DB-user bug). Rather than resolve +it, connect empirically: try the ProxySQL user first and fall back to the plain user +on an authentication failure. The connection itself tells us which topology we are on, +with no dependency on defaults resolution. +""" + +from loguru import logger +import pymysql + +# MariaDB ER_ACCESS_DENIED_ERROR. ProxySQL also returns this code when the supplied +# user is unknown to it, so it covers "wrong user for this topology" on both paths. +_ACCESS_DENIED = 1045 + + +def connect(host, user, password, **connect_kwargs): + """Connect to MariaDB, transparently handling the ProxySQL sharded superuser. + + Tries ``_shard_0`` (ProxySQL) first, then ```` (plain HAProxy), + falling back only on an access-denied error so genuine connectivity failures + (host unreachable, timeout, ...) surface immediately without a pointless retry. + ``connect_kwargs`` are passed through to :func:`pymysql.connect` unchanged (e.g. + ``port``, ``database``, ``cursorclass``, ``connect_timeout``). + + Raises the last :class:`pymysql.Error` if neither user connects. + """ + last_exc = None + for candidate in (f"{user}_shard_0", user): + try: + connection = pymysql.connect( + host=host, user=candidate, password=password, **connect_kwargs + ) + logger.debug(f"Connected to MariaDB at {host} as {candidate}") + return connection + except pymysql.err.OperationalError as exc: + if exc.args and exc.args[0] == _ACCESS_DENIED: + logger.debug( + f"MariaDB user {candidate} denied at {host}; trying next candidate" + ) + last_exc = exc + continue + raise + raise last_exc diff --git a/tests/unit/utils/test_mariadb.py b/tests/unit/utils/test_mariadb.py new file mode 100644 index 00000000..639d17e1 --- /dev/null +++ b/tests/unit/utils/test_mariadb.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 + +from unittest import mock + +import pymysql +import pytest + +from osism.utils import mariadb + + +def _access_denied(user): + return pymysql.err.OperationalError(1045, f"Access denied for user '{user}'") + + +def test_connect_prefers_proxysql_shard_user(): + """A ProxySQL cluster: the sharded user connects on the first try.""" + sentinel = object() + with mock.patch("pymysql.connect", return_value=sentinel) as connect: + result = mariadb.connect("vip", "root", "pw", port=3306) + + assert result is sentinel + connect.assert_called_once_with( + host="vip", user="root_shard_0", password="pw", port=3306 + ) + + +def test_connect_passes_through_extra_kwargs(): + """The call sites' connection kwargs reach pymysql.connect unchanged.""" + sentinel = object() + cursor_cls = object() + with mock.patch("pymysql.connect", return_value=sentinel) as connect: + result = mariadb.connect( + "vip", + "octavia", + "pw", + port=3306, + database="octavia", + cursorclass=cursor_cls, + connect_timeout=10, + ) + + assert result is sentinel + connect.assert_called_once_with( + host="vip", + user="octavia_shard_0", + password="pw", + port=3306, + database="octavia", + cursorclass=cursor_cls, + connect_timeout=10, + ) + + +def test_connect_falls_back_to_plain_user_on_access_denied(): + """A plain HAProxy cluster: the shard user is denied, the plain user connects.""" + sentinel = object() + with mock.patch( + "pymysql.connect", side_effect=[_access_denied("root_shard_0"), sentinel] + ) as connect: + result = mariadb.connect("vip", "root", "pw") + + assert result is sentinel + assert [c.kwargs["user"] for c in connect.call_args_list] == [ + "root_shard_0", + "root", + ] + + +def test_connect_does_not_retry_on_non_auth_errors(): + """A genuine connectivity failure surfaces immediately, without a second attempt.""" + unreachable = pymysql.err.OperationalError(2003, "Can't connect to MySQL server") + with mock.patch("pymysql.connect", side_effect=unreachable) as connect: + with pytest.raises(pymysql.err.OperationalError) as excinfo: + mariadb.connect("vip", "root", "pw") + + assert excinfo.value.args[0] == 2003 + connect.assert_called_once() + + +def test_connect_raises_last_error_when_both_users_denied(): + """Neither user authenticates: the last access-denied error propagates.""" + errors = [_access_denied("root_shard_0"), _access_denied("root")] + with mock.patch("pymysql.connect", side_effect=errors): + with pytest.raises(pymysql.err.OperationalError) as excinfo: + mariadb.connect("vip", "root", "pw") + + assert "root'" in excinfo.value.args[1]