diff --git a/test_scripts/ps/group-replication/README.md b/test_scripts/ps/group-replication/README.md index bc3d8d5..9b79510 100644 --- a/test_scripts/ps/group-replication/README.md +++ b/test_scripts/ps/group-replication/README.md @@ -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: +`/percona-server:` → `/percona-mysql-router:` and +`/percona-xtrabackup:` (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 ``` diff --git a/test_scripts/ps/group-replication/conftest.py b/test_scripts/ps/group-replication/conftest.py index 045c9a9..946810f 100644 --- a/test_scripts/ps/group-replication/conftest.py +++ b/test_scripts/ps/group-replication/conftest.py @@ -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. @@ -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, diff --git a/test_scripts/ps/group-replication/docker_helper.py b/test_scripts/ps/group-replication/docker_helper.py index e265db1..49453c1 100644 --- a/test_scripts/ps/group-replication/docker_helper.py +++ b/test_scripts/ps/group-replication/docker_helper.py @@ -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( diff --git a/test_scripts/ps/group-replication/generic_helper.py b/test_scripts/ps/group-replication/generic_helper.py index 8e88e35..191e654 100644 --- a/test_scripts/ps/group-replication/generic_helper.py +++ b/test_scripts/ps/group-replication/generic_helper.py @@ -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: @@ -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 + /percona-server: to /:, 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}" diff --git a/test_scripts/ps/group-replication/group_replication_helper.py b/test_scripts/ps/group-replication/group_replication_helper.py index 98aa363..330b295 100644 --- a/test_scripts/ps/group-replication/group_replication_helper.py +++ b/test_scripts/ps/group-replication/group_replication_helper.py @@ -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, @@ -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 @@ -1205,6 +1211,11 @@ 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, @@ -1212,7 +1223,14 @@ def mysqlsh_available(self) -> bool: 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.""" diff --git a/test_scripts/ps/group-replication/xtrabackup_helper.py b/test_scripts/ps/group-replication/xtrabackup_helper.py index 880a438..2f57b6f 100644 --- a/test_scripts/ps/group-replication/xtrabackup_helper.py +++ b/test_scripts/ps/group-replication/xtrabackup_helper.py @@ -3,6 +3,7 @@ from collections.abc import Callable from docker_helper import DockerHelper +from generic_helper import companion_image class XtraBackup: @@ -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", @@ -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