Skip to content
Open
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: 11 additions & 2 deletions test_scripts/ps/group-replication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,19 @@ internal registry mirror without editing code.
|--------------------|----------------|-------------------------------------|
| `SERVER_IMAGE` | Percona Server | `percona/percona-server:8.4` |
| `HAPROXY_IMAGE` | HAProxy | `percona/haproxy:2` |
| `ROUTER_IMAGE` | MySQL Router | `percona/percona-mysql-router:8.4` |
| `XTRABACKUP_IMAGE` | XtraBackup | `percona/percona-xtrabackup:8.4` |
| `ROUTER_IMAGE` | MySQL Router | derived from `SERVER_IMAGE`¹ |
| `XTRABACKUP_IMAGE` | XtraBackup | derived from `SERVER_IMAGE`¹ |
| `SYSBENCH_IMAGE` | sysbench | `pingwinator/sysbench:latest` |

¹ Router and XtraBackup refuse to work against a newer server, so when unset they follow
the server version, keeping its registry/namespace:
`<repo>/percona-server:<X.Y.Z>` → `<repo>/percona-mysql-router:<X.Y.Z>` and
`<repo>/percona-xtrabackup:<X.Y>` (e.g. `perconalab/percona-server:9.7.2` →
`perconalab/percona-mysql-router:9.7.2` and `perconalab/percona-xtrabackup:9.7`).
Build suffixes are dropped; images that don't match that pattern fall back to the `8.4`
tags. Set `ROUTER_IMAGE` / `XTRABACKUP_IMAGE` explicitly if the matching tag isn't
published.

```bash
SERVER_IMAGE=percona/percona-server:8.4.5 pytest -v test_basic.py
```
Expand Down
20 changes: 20 additions & 0 deletions test_scripts/ps/group-replication/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@
}


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""On a failed setup/call, attach each node's container state and log tail to the report.

Teardown destroys the containers, so without this a failure leaves no server-side
evidence (e.g. whether mysqld crashed and restart=always brought it back).
"""
outcome = yield
report = outcome.get_result()
cluster = item.funcargs.get("gr_cluster") if hasattr(item, "funcargs") else None
if report.when == "teardown" or not report.failed or not isinstance(cluster, GroupReplication):
return
for name in [*cluster.containers, cluster.proxy_name]:
if not name or not cluster.docker.container_exists(name):
continue
state = cluster.docker.container_state(name)
report.sections.append((f"docker logs {name} ({state})", cluster.docker.logs(name)))


def _worker_id(request) -> str:
"""Return the pytest-xdist worker id (e.g. 'gw0'), or '0' when running serially.

Expand Down Expand Up @@ -157,6 +176,7 @@ def xtrabackup(request, gr_cluster):
gr_cluster.docker,
network=gr_cluster.network,
backup_volume=backup_volume,
server_image=gr_cluster.server_image,
root_password=gr_cluster.root_password,
name_prefix=f"xtrabackup_{prefix}",
log=gr_cluster.log,
Expand Down
5 changes: 5 additions & 0 deletions test_scripts/ps/group-replication/docker_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ def container_state(self, name: str) -> str:
)
return result.stdout.strip() if result.ok else ""

def logs(self, name: str, tail: int = 80) -> str:
"""Return the last `tail` lines of a container's output (stdout and stderr), or ""."""
result = self._run(["logs", "--tail", str(tail), name], check=False)
return (result.stdout + result.stderr).strip() if result.ok else ""

def container_exists(self, name: str) -> bool:
"""Return True if a container with the exact given name exists (running or stopped)."""
result = self._run(
Expand Down
28 changes: 25 additions & 3 deletions test_scripts/ps/group-replication/generic_helper.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Small, dependency-free string-escaping helpers shared across the suite.
"""Small, dependency-free helpers shared across the suite.

These keep dynamic values (database names, credentials, identifiers) from breaking — or
being injectable into — the SQL, mysqlsh JS, and connection strings the helpers build.
The escaping helpers keep dynamic values (database names, credentials, identifiers) from
breaking — or being injectable into — the SQL, mysqlsh JS, and connection strings the
helpers build. companion_image() picks tool images that match the server version.
"""

import json
import re


def js_str(value: str) -> str:
Expand All @@ -31,3 +33,23 @@ def sql_str(value: str) -> str:
def sql_ident(name: str) -> str:
"""Quote a MySQL identifier (e.g. schema/table), escaping embedded backticks."""
return "`" + name.replace("`", "``") + "`"


def companion_image(server_image: str, repo_name: str, parts: int, default: str) -> str:
"""Derive a tool image (router, xtrabackup) matching the server image's version.

Router and XtraBackup both refuse to work against a server newer than themselves, so a
fixed 8.4 default breaks as soon as SERVER_IMAGE points at e.g. 9.7. Map
<repo>/percona-server:<X.Y.Z...> to <repo>/<repo_name>:<first `parts` version
components>, keeping the registry/namespace (percona vs perconalab publish different
tags) and dropping any build suffix, which differs between server and tool tags.
Anything that doesn't fit that shape (other repos, digests, "latest") gets `default`.
"""
repo, sep, tag = server_image.rpartition(":")
if not sep or "/" in tag or "@" in server_image or not repo.endswith("percona-server"):
return default
m = re.match(r"\d+(?:\.\d+){1,2}", tag)
if not m:
return default
version = ".".join(m.group().split(".")[:parts])
return f"{repo[: -len('percona-server')]}{repo_name}:{version}"
26 changes: 22 additions & 4 deletions test_scripts/ps/group-replication/group_replication_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
from urllib.parse import quote

from docker_helper import DockerHelper
from generic_helper import js_str, sql_ident, sql_str
from generic_helper import companion_image, js_str, sql_ident, sql_str

_logger = logging.getLogger("GR")


class GroupReplication:
def __init__(
self,
Expand Down Expand Up @@ -66,7 +65,14 @@ def __init__(
self.single_primary = single_primary
self.start_on_boot = start_on_boot
self.mysql_router = mysql_router
self.router_image = router_image or os.environ.get("ROUTER_IMAGE") or "percona/percona-mysql-router:8.4"
self.router_image = (
router_image
or os.environ.get("ROUTER_IMAGE")
# Router can't bootstrap against a newer server: follow the server's X.Y.Z.
or companion_image(
self.server_image, "percona-mysql-router", 3, "percona/percona-mysql-router:8.4"
)
)
self.router_name = f"{node_prefix}router"
self.router_rw_port = router_rw_port
self.router_ro_port = router_ro_port
Expand Down Expand Up @@ -1205,14 +1211,26 @@ def mysqlsh_available(self) -> bool:
Cluster bootstrap and instance-add go through mysqlsh's AdminAPI (see create()),
so a server build without it cannot run this suite. Probed with a throwaway
--rm container running `mysqlsh --version` (no node startup, no connection).

Only a missing binary (exit 127, how docker/podman report "executable file not
found") returns False. Any other failure (daemon down, permission denied on the
socket, image pull error) raises with the real error instead of being misreported
as a missing mysqlsh.
"""
result = self.docker.run(
image=self.server_image,
entrypoint="mysqlsh",
command=["--version"],
check=False,
)
return result.ok
if result.ok:
return True
if result.returncode == 127:
return False
raise RuntimeError(
f"could not probe mysqlsh in server image {self.server_image!r} "
f"(exit {result.returncode}): {(result.stderr or result.stdout).strip()}"
)

def create(self) -> None:
"""Create the network and nodes, bootstrap the cluster, add instances, and persist GR settings."""
Expand Down
12 changes: 11 additions & 1 deletion test_scripts/ps/group-replication/xtrabackup_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Callable

from docker_helper import DockerHelper
from generic_helper import companion_image


class XtraBackup:
Expand All @@ -20,6 +21,7 @@ def __init__(
network: str,
backup_volume: str,
image: str | None = None,
server_image: str | None = None,
platform: str | None = None,
root_password: str = "rootpass",
name_prefix: str = "xtrabackup",
Expand All @@ -28,7 +30,15 @@ def __init__(
self.docker = docker
self.network = network
self.backup_volume = backup_volume
self.image = image or os.environ.get("XTRABACKUP_IMAGE") or "percona/percona-xtrabackup:8.4"
# XtraBackup only backs up a server of its own X.Y ("Please use Percona XtraBackup
# 9.7 for this database"), so follow the server image's major.minor.
self.image = (
image
or os.environ.get("XTRABACKUP_IMAGE")
or companion_image(
server_image or "", "percona-xtrabackup", 2, "percona/percona-xtrabackup:8.4"
)
)
self.platform = platform
self.root_password = root_password
self.name_prefix = name_prefix
Expand Down
Loading