Skip to content
171 changes: 165 additions & 6 deletions test_scripts/ps/group-replication/README.md

Large diffs are not rendered by default.

31 changes: 26 additions & 5 deletions test_scripts/ps/group-replication/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
# Proxy modes the suite can run a test behind. There is intentionally no "direct"
# entry: every test runs behind a proxy. Each test selects its proxies explicitly
# with @pytest.mark.parametrize("gr_cluster", [...], indirect=True) — see the test
# files. The value passed (e.g. "router"/"haproxy") is the key looked up here.
# files. The value passed is either a proxy name ("router"/"haproxy", giving the
# default 3 nodes) or a (proxy, num_nodes) tuple for a differently-sized cluster;
# the proxy name is the key looked up here.
PROXIES = {
"router": {"mysql_router": True},
"haproxy": {"haproxy": True},
Expand All @@ -44,16 +46,35 @@ def gr_cluster(request):
# @pytest.mark.parametrize("gr_cluster", [...], indirect=True). Validate it explicitly
# so a test that forgets the decorator fails with a clear message instead of an opaque
# AttributeError (no param) / KeyError (unknown proxy).
proxy = getattr(request, "param", None)
if proxy is None:
#
# A test needing a different cluster size passes a (proxy, num_nodes) tuple instead of a
# bare proxy name — wrapped in pytest.param(..., id=proxy), or the node id degrades to
# "gr_cluster0". Everything else about the fixture is the same either way.
param = getattr(request, "param", None)
if param is None:
raise pytest.UsageError(
'gr_cluster requires a proxy via indirect parametrization, e.g. '
'@pytest.mark.parametrize("gr_cluster", ["haproxy"], indirect=True)'
)
if proxy not in PROXIES:
if isinstance(param, tuple):
if len(param) != 2:
raise pytest.UsageError(
f"gr_cluster tuple parameter must be (proxy, num_nodes); got {param!r}"
)
proxy, num_nodes = param
else:
proxy, num_nodes = param, 3
# isinstance before the lookup: an unhashable proxy (e.g. a list) would otherwise raise
# TypeError from inside the dict membership test rather than reporting the bad value.
if not isinstance(proxy, str) or proxy not in PROXIES:
raise pytest.UsageError(
f"unknown gr_cluster proxy {proxy!r}; valid options: {sorted(PROXIES)}"
)
# bool is a subclass of int, so without the isinstance guard True would pass as 1 node.
if isinstance(num_nodes, bool) or not isinstance(num_nodes, int) or num_nodes < 1:
raise pytest.UsageError(
f"gr_cluster num_nodes must be a positive integer; got {num_nodes!r}"
)
try:
helper = DockerHelper()
except RuntimeError as exc:
Expand All @@ -71,7 +92,7 @@ def gr_cluster(request):
offset = int(m.group()) if m else 0
cluster = GroupReplication(
helper,
num_nodes=3,
num_nodes=num_nodes,
network=f"grnet-{safe_workerid}",
node_prefix=f"ps{safe_workerid}-",
base_host_port=33060 + offset * 100,
Expand Down
71 changes: 71 additions & 0 deletions test_scripts/ps/group-replication/docker_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def create(
detach: bool = True,
restart: str | None = None,
platform: str | None = None,
cap_add: list[str] | None = None,
) -> ExecResult:
"""Create and start a long-lived (detached) container with the given config."""
# These containers are long-lived (no --rm), so a run that crashed before teardown
Expand All @@ -110,6 +111,8 @@ def create(
args.extend(["--entrypoint", entrypoint])
if restart:
args.extend(["--restart", restart])
for cap in cap_add or []:
args.extend(["--cap-add", cap])
for k, v in (environment or {}).items():
args.extend(["-e", f"{k}={v}"])
for vol in volumes or []:
Expand Down Expand Up @@ -261,10 +264,78 @@ def network_remove(self, name: str) -> ExecResult:
"""Remove a container network, ignoring errors if it does not exist."""
return self._run(["network", "rm", name], check=False)

def container_networks(self, name: str) -> list[str]:
"""Return the names of the networks a container is currently attached to.

Empty means attached to nothing — the normal state of a node that
network_disconnect() has isolated, not an error. An inspect that fails (no such
container, no daemon) raises rather than returning [], so callers can act on the
answer instead of guessing: reporting a failure as "attached to nothing" would make
network_disconnect() skip the disconnect and report success, leaving a partition
test reasoning about a partition that never happened.
"""
result = self._run(
[
"inspect",
"-f",
'{{range $net, $_ := .NetworkSettings.Networks}}{{$net}}{{"\\n"}}{{end}}',
name,
],
)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]

def network_connect(self, network: str, name: str) -> ExecResult | None:
"""Attach a running container to a network, doing nothing if it is already attached.

The idempotence matters for reruns and for healing a partition that was only
partially applied: connecting twice otherwise fails with "already exists in network".
Returns None when the container was already attached.
"""
if network in self.container_networks(name):
return None
return self._run(["network", "connect", network, name])

def network_disconnect(self, network: str, name: str, force: bool = False) -> ExecResult | None:
"""Detach a running container from a network, doing nothing if it is not attached.

Unlike stop(), the process inside the container is untouched — it simply loses
connectivity — which is what makes this usable for network-partition tests.
Returns None when the container was not attached in the first place.

Note: reconnecting later does not restore the container's published host port
mappings (the -p flags given at create time). Nothing in this suite reaches nodes
from the host, but a healed node is no longer reachable on its host port.
"""
if network not in self.container_networks(name):
return None
args = ["network", "disconnect"]
if force:
args.append("--force")
args.extend([network, name])
return self._run(args)

def volume_remove(self, name: str) -> ExecResult:
"""Remove a container volume, ignoring errors if it does not exist."""
return self._run(["volume", "rm", name], check=False)

def container_ip(self, name: str, network: str) -> str:
"""Return a container's IPv4 address on the given network, or "" if it is not attached."""
template = f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}'
result = self._run(["inspect", "-f", template, name], check=False)
return result.stdout.strip() if result.ok else ""

def container_state(self, name: str) -> str:
"""Return a container's status and restart count ("running restarts=0"), or "" if unknown.

Handy in a timeout message: it distinguishes a container that is up but not yet
serving from one that has died or is stuck in a restart loop.
"""
result = self._run(
["inspect", "-f", "{{.State.Status}} restarts={{.RestartCount}}", name],
check=False,
)
return result.stdout.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
Loading
Loading