Skip to content

Commit cc02a6a

Browse files
draft
1 parent 9e90c84 commit cc02a6a

File tree

8 files changed

+866
-36
lines changed

8 files changed

+866
-36
lines changed

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
from collections.abc import Callable
6+
7+
import jubilant
8+
from jubilant import Juju
9+
from jubilant.statustypes import Status, UnitStatus
10+
from tenacity import Retrying, stop_after_delay, wait_fixed
11+
12+
from constants import SERVER_CONFIG_USERNAME
13+
14+
from ..helpers import execute_queries_on_unit
15+
16+
MINUTE_SECS = 60
17+
18+
JujuModelStatusFn = Callable[[Status], bool]
19+
JujuAppsStatusFn = Callable[[Status, str], bool]
20+
21+
22+
async def check_mysql_units_writes_increment(juju: Juju, app_name: str) -> None:
23+
"""Ensure that continuous writes is incrementing on all units.
24+
25+
Also, ensure that all continuous writes up to the max written value is available
26+
on all units (ensure that no committed data is lost).
27+
"""
28+
mysql_app_units = get_app_units(juju, app_name)
29+
mysql_app_primary = get_mysql_primary_unit(juju, app_name)
30+
31+
app_max_value = await get_mysql_max_written_value(juju, app_name, mysql_app_primary)
32+
33+
juju.model_config({"update-status-hook-interval": "15s"})
34+
for unit_name in mysql_app_units:
35+
for attempt in Retrying(
36+
reraise=True,
37+
stop=stop_after_delay(5 * MINUTE_SECS),
38+
wait=wait_fixed(10),
39+
):
40+
with attempt:
41+
unit_max_value = await get_mysql_max_written_value(juju, app_name, unit_name)
42+
assert unit_max_value > app_max_value, "Writes not incrementing"
43+
app_max_value = unit_max_value
44+
45+
46+
def get_app_leader(juju: Juju, app_name: str) -> str:
47+
"""Get the leader unit for the given application."""
48+
model_status = juju.status()
49+
app_status = model_status.apps[app_name]
50+
for name, status in app_status.units.items():
51+
if status.leader:
52+
return name
53+
54+
raise Exception("No leader unit found")
55+
56+
57+
def get_app_name(juju: Juju, charm_name: str) -> str | None:
58+
"""Get the application name for the given charm."""
59+
model_status = juju.status()
60+
app_statuses = model_status.apps
61+
for name, status in app_statuses.items():
62+
if status.charm_name == charm_name:
63+
return name
64+
65+
raise Exception("No application name found")
66+
67+
68+
def get_app_units(juju: Juju, app_name: str) -> dict[str, UnitStatus]:
69+
"""Get the units for the given application."""
70+
model_status = juju.status()
71+
app_status = model_status.apps[app_name]
72+
return app_status.units
73+
74+
75+
def get_unit_ip(juju: Juju, app_name: str, unit_name: str) -> str:
76+
"""Get the application unit IP."""
77+
model_status = juju.status()
78+
app_status = model_status.apps[app_name]
79+
for name, status in app_status.units.items():
80+
if name == unit_name:
81+
return status.public_address
82+
83+
raise Exception("No application unit found")
84+
85+
86+
def get_mysql_cluster_status(juju: Juju, unit: str, cluster_set: bool | None = False) -> dict:
87+
"""Get the cluster status by running the get-cluster-status action.
88+
89+
Args:
90+
juju: The juju instance to use.
91+
unit: The unit on which to execute the action on
92+
cluster_set: Whether to get the cluster-set instead
93+
94+
Returns:
95+
A dictionary representing the cluster status
96+
"""
97+
task = juju.run(
98+
unit=unit,
99+
action="get-cluster-status",
100+
params={"cluster-set": bool(cluster_set)},
101+
wait=5 * MINUTE_SECS,
102+
)
103+
task.raise_on_failure()
104+
105+
return task.results.get("status", {})
106+
107+
108+
def get_mysql_primary_unit(juju: Juju, app_name: str) -> str:
109+
"""Get the current primary node of the cluster."""
110+
mysql_primary = get_app_leader(juju, app_name)
111+
mysql_cluster_status = get_mysql_cluster_status(juju, mysql_primary)
112+
mysql_cluster_topology = mysql_cluster_status["defaultreplicaset"]["topology"]
113+
114+
for label, value in mysql_cluster_topology.items():
115+
if value["memberrole"] == "primary":
116+
return label.replace("-", "/")
117+
118+
raise Exception("No MySQL primary node found")
119+
120+
121+
async def get_mysql_max_written_value(juju: Juju, app_name: str, unit_name: str) -> int:
122+
"""Retrieve the max written value in the MySQL database.
123+
124+
Args:
125+
juju: The Juju model.
126+
app_name: The application name.
127+
unit_name: The unit name.
128+
"""
129+
credentials_task = juju.run(
130+
unit=unit_name,
131+
action="get-password",
132+
params={"username": SERVER_CONFIG_USERNAME},
133+
)
134+
credentials_task.raise_on_failure()
135+
136+
output = await execute_queries_on_unit(
137+
get_unit_ip(juju, app_name, unit_name),
138+
credentials_task.results["username"],
139+
credentials_task.results["password"],
140+
["SELECT MAX(number) FROM `continuous_writes`.`data`;"],
141+
)
142+
return output[0]
143+
144+
145+
async def get_mysql_variable_value(
146+
juju: Juju, app_name: str, unit_name: str, variable_name: str
147+
) -> str:
148+
"""Retrieve a database variable value as a string.
149+
150+
Args:
151+
juju: The Juju model.
152+
app_name: The application name.
153+
unit_name: The unit name.
154+
variable_name: The variable name.
155+
"""
156+
credentials_task = juju.run(
157+
unit=unit_name,
158+
action="get-password",
159+
params={"username": SERVER_CONFIG_USERNAME},
160+
)
161+
credentials_task.raise_on_failure()
162+
163+
output = await execute_queries_on_unit(
164+
get_unit_ip(juju, app_name, unit_name),
165+
credentials_task.results["username"],
166+
credentials_task.results["password"],
167+
[f"SELECT @@{variable_name};"],
168+
)
169+
return output[0]
170+
171+
172+
def wait_apps_for_status(jubilant_status_func: JujuAppsStatusFn, *apps: str) -> JujuModelStatusFn:
173+
"""Waits for Juju agents to be idle, and for applications to reach a certain status.
174+
175+
Args:
176+
jubilant_status_func: The Juju apps status function to wait for.
177+
apps: The applications to wait for.
178+
179+
Returns:
180+
Juju model status function.
181+
"""
182+
return lambda status: all((
183+
jubilant.all_agents_idle(status, *apps),
184+
jubilant_status_func(status, *apps),
185+
))

tests/integration/high_availability/test_async_replication.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ async def test_deploy_router_and_app(first_model: Model) -> None:
151151
152152
channel="latest/edge",
153153
num_units=1,
154+
trust=False,
154155
)
155156

156157
logger.info("Relate app and router")

0 commit comments

Comments
 (0)