Skip to content
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
13 changes: 5 additions & 8 deletions osism/commands/loadbalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import yaml

from osism.commands.octavia import wait_for_amphora_boot
from osism.utils import mariadb


def _load_kolla_configuration():
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions osism/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions osism/utils/mariadb.py
Original file line number Diff line number Diff line change
@@ -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 ``<user>_shard_0``; on a
plain HAProxy cluster it is ``<user>``.

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 ``<user>_shard_0`` (ProxySQL) first, then ``<user>`` (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
87 changes: 87 additions & 0 deletions tests/unit/utils/test_mariadb.py
Original file line number Diff line number Diff line change
@@ -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
)
Comment on lines +15 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a test to verify that extra connection kwargs are passed through unchanged to pymysql.connect

The helper is also used in status and loadbalancer with extra kwargs like database, cursorclass, and connect_timeout, but the current tests only cover port. Please add a test (e.g. test_connect_passes_through_extra_kwargs) that calls mariadb.connect with several extra kwargs and asserts that pymysql.connect receives them unchanged, so future refactors don’t accidentally drop or modify these parameters.

Suggested change
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_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():
"""Extra connection kwargs are passed through unchanged to pymysql.connect."""
sentinel = object()
cursor_cls = object()
with mock.patch("pymysql.connect", return_value=sentinel) as connect:
result = mariadb.connect(
"vip",
"root",
"pw",
port=3306,
database="mydb",
cursorclass=cursor_cls,
connect_timeout=5,
)
assert result is sentinel
connect.assert_called_once_with(
host="vip",
user="root_shard_0",
password="pw",
port=3306,
database="mydb",
cursorclass=cursor_cls,
connect_timeout=5,
)



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]
Comment on lines +80 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick (testing): Tighten the assertion to clearly prove that the last (plain user) error is propagated

This test should clearly verify that the last access-denied error (for the plain user) is raised. The current assertion only checks for "root'" in the message, which would also succeed if the wrong error were raised. Please assert both the error code (1045) and that the message refers to the plain user, e.g. assert excinfo.value.args[0] == 1045 and assert "Access denied for user 'root'" in excinfo.value.args[1], so the "last error propagates" behavior is explicitly covered.