Skip to content

Commit 0f2c8c2

Browse files
[DPE-4106] Tests legacy and modern endpoints simultaneously (#396)
* Legacy endpoints: relate legacy+modern endpoints simultaneously (fool-protection check) * mv func * test legacy + modern endpoints * test legacy + modern endpoints * test relate legacy+modern endpoints simultaneously * Update src/relations/postgresql_provider.py Co-authored-by: Marcelo Henrique Neppel <[email protected]> * Update tests/integration/relations/helpers.py Co-authored-by: Marcelo Henrique Neppel <[email protected]> * Update tests/integration/relations/test_relations.py Co-authored-by: Marcelo Henrique Neppel <[email protected]> * Update tests/integration/relations/test_relations.py Co-authored-by: Marcelo Henrique Neppel <[email protected]> * check deploy postgresql * refactoring * update status on remove multiple relations endpoint * update status on remove multiple relations endpoint * fix unit test. change db endpoint(mailman3_core) to db endpoint(postgresql_test_app) * add pytest.mark.group to test_modern_endpoint_with_multiple_related_endpoints * fix check connect to legacy endpoint * test_self_healing: deploying postgresql-test-app revision 101 * deploy postgresql-test-app with latest version --------- Co-authored-by: BalabaDmintri <[email protected]> Co-authored-by: Marcelo Henrique Neppel <[email protected]>
1 parent 89f6d01 commit 0f2c8c2

File tree

7 files changed

+279
-4
lines changed

7 files changed

+279
-4
lines changed

src/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
LEGACY_DB_ADMIN = "db-admin"
1212
PEER = "database-peers"
1313
ALL_CLIENT_RELATIONS = [DATABASE, LEGACY_DB, LEGACY_DB_ADMIN]
14+
ALL_LEGACY_RELATIONS = [LEGACY_DB, LEGACY_DB_ADMIN]
1415
API_REQUEST_TIMEOUT = 5
1516
PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster"
1617
BACKUP_USER = "backup"
@@ -67,3 +68,7 @@
6768
UNIT_SCOPE = "unit"
6869

6970
SECRET_KEY_OVERRIDES = {"ca": "cauth"}
71+
72+
ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE = (
73+
"Please choose one endpoint to use. No need to relate all of them simultaneously!"
74+
)

src/relations/db.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from ops.model import ActiveStatus, BlockedStatus, Relation, Unit
2222
from pgconnstr import ConnectionString
2323

24-
from constants import APP_SCOPE, DATABASE_PORT
24+
from constants import (
25+
ALL_LEGACY_RELATIONS,
26+
APP_SCOPE,
27+
DATABASE_PORT,
28+
ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE,
29+
)
2530
from utils import new_password
2631

2732
logger = logging.getLogger(__name__)
@@ -87,6 +92,20 @@ def _check_for_blocking_relations(self, relation_id: int) -> bool:
8792
return True
8893
return False
8994

95+
def _check_exist_current_relation(self) -> bool:
96+
for r in self.charm.client_relations:
97+
if r in ALL_LEGACY_RELATIONS:
98+
return True
99+
return False
100+
101+
def _check_multiple_endpoints(self) -> bool:
102+
"""Checks if there are relations with other endpoints."""
103+
is_exist = self._check_exist_current_relation()
104+
for relation in self.charm.client_relations:
105+
if relation.name not in ALL_LEGACY_RELATIONS and is_exist:
106+
return True
107+
return False
108+
90109
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
91110
"""Handle the legacy db/db-admin relation changed event.
92111
@@ -96,6 +115,10 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None:
96115
if not self.charm.unit.is_leader():
97116
return
98117

118+
if self._check_multiple_endpoints():
119+
self.charm.unit.status = BlockedStatus(ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE)
120+
return
121+
99122
if (
100123
"cluster_initialised" not in self.charm._peers.data[self.charm.app]
101124
or not self.charm._patroni.member_started
@@ -277,6 +300,16 @@ def _update_unit_status(self, relation: Relation) -> None:
277300
]:
278301
if not self._check_for_blocking_relations(relation.id):
279302
self.charm.unit.status = ActiveStatus()
303+
self._update_unit_status_on_blocking_endpoint_simultaneously()
304+
305+
def _update_unit_status_on_blocking_endpoint_simultaneously(self):
306+
"""Clean up Blocked status if this is due related of multiple endpoints."""
307+
if (
308+
self.charm.is_blocked
309+
and self.charm.unit.status.message == ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE
310+
):
311+
if not self._check_multiple_endpoints():
312+
self.charm.unit.status = ActiveStatus()
280313

281314
def update_endpoints(self, relation: Relation = None) -> None:
282315
"""Set the read/write and read-only endpoints."""

src/relations/postgresql_provider.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
1717
PostgreSQLGetPostgreSQLVersionError,
1818
PostgreSQLListUsersError,
1919
)
20-
from ops.charm import CharmBase, RelationBrokenEvent
20+
from ops.charm import CharmBase, RelationBrokenEvent, RelationChangedEvent
2121
from ops.framework import Object
2222
from ops.model import ActiveStatus, BlockedStatus, Relation
2323

24-
from constants import ALL_CLIENT_RELATIONS, APP_SCOPE, DATABASE_PORT
24+
from constants import (
25+
ALL_CLIENT_RELATIONS,
26+
APP_SCOPE,
27+
DATABASE_PORT,
28+
ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE,
29+
)
2530
from utils import new_password
2631

2732
logger = logging.getLogger(__name__)
@@ -48,7 +53,10 @@ def __init__(self, charm: CharmBase, relation_name: str = "database") -> None:
4853
self.framework.observe(
4954
charm.on[self.relation_name].relation_broken, self._on_relation_broken
5055
)
51-
56+
self.framework.observe(
57+
charm.on[self.relation_name].relation_changed,
58+
self._on_relation_changed_event,
59+
)
5260
self.charm = charm
5361

5462
# Charm events defined in the database provides charm library.
@@ -190,6 +198,13 @@ def update_endpoints(self, event: DatabaseRequestedEvent = None) -> None:
190198
read_only_endpoints,
191199
)
192200

201+
def _check_multiple_endpoints(self) -> bool:
202+
"""Checks if there are relations with other endpoints."""
203+
relation_names = {relation.name for relation in self.charm.client_relations}
204+
if "database" in relation_names and len(relation_names) > 1:
205+
return True
206+
return False
207+
193208
def _update_unit_status(self, relation: Relation) -> None:
194209
"""# Clean up Blocked status if it's due to extensions request."""
195210
if (
@@ -199,6 +214,27 @@ def _update_unit_status(self, relation: Relation) -> None:
199214
if not self.check_for_invalid_extra_user_roles(relation.id):
200215
self.charm.unit.status = ActiveStatus()
201216

217+
self._update_unit_status_on_blocking_endpoint_simultaneously()
218+
219+
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
220+
"""Event emitted when the relation has changed."""
221+
# Leader only
222+
if not self.charm.unit.is_leader():
223+
return
224+
225+
if self._check_multiple_endpoints():
226+
self.charm.unit.status = BlockedStatus(ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE)
227+
return
228+
229+
def _update_unit_status_on_blocking_endpoint_simultaneously(self):
230+
"""Clean up Blocked status if this is due related of multiple endpoints."""
231+
if (
232+
self.charm.is_blocked
233+
and self.charm.unit.status.message == ENDPOINT_SIMULTANEOUSLY_BLOCKING_MESSAGE
234+
):
235+
if not self._check_multiple_endpoints():
236+
self.charm.unit.status = ActiveStatus()
237+
202238
def check_for_invalid_extra_user_roles(self, relation_id: int) -> bool:
203239
"""Checks if there are relations with invalid extra user roles.
204240
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
from typing import Optional
5+
6+
import yaml
7+
from pytest_operator.plugin import OpsTest
8+
9+
10+
async def get_legacy_db_connection_str(
11+
ops_test: OpsTest,
12+
application_name: str,
13+
relation_name: str,
14+
read_only_endpoint: bool = False,
15+
remote_unit_name: str = None,
16+
) -> Optional[str]:
17+
"""Returns a PostgreSQL connection string.
18+
19+
Args:
20+
ops_test: The ops test framework instance
21+
application_name: The name of the application
22+
relation_name: name of the relation to get connection data from
23+
read_only_endpoint: whether to choose the read-only endpoint
24+
instead of the read/write endpoint
25+
remote_unit_name: Optional remote unit name used to retrieve
26+
unit data instead of application data
27+
28+
Returns:
29+
a PostgreSQL connection string
30+
"""
31+
unit_name = f"{application_name}/0"
32+
raw_data = (await ops_test.juju("show-unit", unit_name))[1]
33+
if not raw_data:
34+
raise ValueError(f"no unit info could be grabbed for {unit_name}")
35+
data = yaml.safe_load(raw_data)
36+
# Filter the data based on the relation name.
37+
relation_data = [
38+
v for v in data[unit_name]["relation-info"] if v["related-endpoint"] == relation_name
39+
]
40+
if len(relation_data) == 0:
41+
raise ValueError(
42+
f"no relation data could be grabbed on relation with endpoint {relation_name}"
43+
)
44+
if remote_unit_name:
45+
data = relation_data[0]["related-units"][remote_unit_name]["data"]
46+
else:
47+
data = relation_data[0]["application-data"]
48+
if read_only_endpoint:
49+
if data.get("standbys") is None:
50+
return None
51+
return data.get("standbys").split(",")[0]
52+
else:
53+
return data.get("master")
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
import asyncio
5+
import logging
6+
7+
import psycopg2
8+
import pytest
9+
from pytest_operator.plugin import OpsTest
10+
from tenacity import Retrying, stop_after_delay, wait_fixed
11+
12+
from ..helpers import CHARM_SERIES, METADATA
13+
from ..new_relations.test_new_relations import APPLICATION_APP_NAME, build_connection_string
14+
from ..relations.helpers import get_legacy_db_connection_str
15+
16+
logger = logging.getLogger(__name__)
17+
18+
APP_NAME = METADATA["name"]
19+
# MAILMAN3_CORE_APP_NAME = "mailman3-core"
20+
DB_RELATION = "db"
21+
DATABASE_RELATION = "database"
22+
FIRST_DATABASE_RELATION = "first-database"
23+
DATABASE_APP_NAME = "database-app"
24+
DB_APP_NAME = "db-app"
25+
APP_NAMES = [APP_NAME, DATABASE_APP_NAME, DB_APP_NAME]
26+
27+
28+
@pytest.mark.group(1)
29+
@pytest.mark.abort_on_fail
30+
async def test_deploy_charms(ops_test: OpsTest, charm):
31+
"""Deploy both charms (application and database) to use in the tests."""
32+
# Deploy both charms (multiple units for each application to test that later they correctly
33+
# set data in the relation application databag using only the leader unit).
34+
async with ops_test.fast_forward():
35+
await asyncio.gather(
36+
ops_test.model.deploy(
37+
APPLICATION_APP_NAME,
38+
application_name=DATABASE_APP_NAME,
39+
num_units=1,
40+
series=CHARM_SERIES,
41+
channel="edge",
42+
),
43+
ops_test.model.deploy(
44+
charm,
45+
application_name=APP_NAME,
46+
num_units=1,
47+
series=CHARM_SERIES,
48+
config={
49+
"profile": "testing",
50+
"plugin_unaccent_enable": "True",
51+
"plugin_pg_trgm_enable": "True",
52+
},
53+
),
54+
ops_test.model.deploy(
55+
APPLICATION_APP_NAME,
56+
application_name=DB_APP_NAME,
57+
num_units=1,
58+
series=CHARM_SERIES,
59+
channel="edge",
60+
),
61+
)
62+
63+
await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", timeout=3000)
64+
65+
66+
@pytest.mark.group(1)
67+
async def test_legacy_endpoint_with_multiple_related_endpoints(ops_test: OpsTest):
68+
await ops_test.model.relate(f"{DB_APP_NAME}:{DB_RELATION}", f"{APP_NAME}:{DB_RELATION}")
69+
await ops_test.model.relate(APP_NAME, f"{DATABASE_APP_NAME}:{FIRST_DATABASE_RELATION}")
70+
71+
app = ops_test.model.applications[APP_NAME]
72+
await ops_test.model.block_until(
73+
lambda: "blocked" in {unit.workload_status for unit in app.units},
74+
timeout=1500,
75+
)
76+
77+
logger.info(" remove relation with modern endpoints")
78+
await ops_test.model.applications[APP_NAME].remove_relation(
79+
f"{APP_NAME}:{DATABASE_RELATION}", f"{DATABASE_APP_NAME}:{FIRST_DATABASE_RELATION}"
80+
)
81+
async with ops_test.fast_forward():
82+
await ops_test.model.wait_for_idle(
83+
status="active",
84+
timeout=1500,
85+
raise_on_error=False,
86+
)
87+
88+
legacy_interface_connect = await get_legacy_db_connection_str(
89+
ops_test, DB_APP_NAME, DB_RELATION, remote_unit_name=f"{APP_NAME}/0"
90+
)
91+
logger.info(f" check connect to = {legacy_interface_connect}")
92+
for attempt in Retrying(stop=stop_after_delay(60 * 3), wait=wait_fixed(10)):
93+
with attempt:
94+
with psycopg2.connect(legacy_interface_connect) as connection:
95+
assert connection.status == psycopg2.extensions.STATUS_READY
96+
97+
logger.info(f" remove relation {DB_APP_NAME}:{DB_RELATION}")
98+
async with ops_test.fast_forward():
99+
await ops_test.model.applications[APP_NAME].remove_relation(
100+
f"{APP_NAME}:{DB_RELATION}", f"{DB_APP_NAME}:{DB_RELATION}"
101+
)
102+
await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000)
103+
for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(10)):
104+
with attempt:
105+
with pytest.raises(psycopg2.OperationalError):
106+
psycopg2.connect(legacy_interface_connect)
107+
108+
109+
@pytest.mark.group(1)
110+
async def test_modern_endpoint_with_multiple_related_endpoints(ops_test: OpsTest):
111+
await ops_test.model.relate(f"{DB_APP_NAME}:{DB_RELATION}", f"{APP_NAME}:{DB_RELATION}")
112+
await ops_test.model.relate(APP_NAME, f"{DATABASE_APP_NAME}:{FIRST_DATABASE_RELATION}")
113+
114+
app = ops_test.model.applications[APP_NAME]
115+
await ops_test.model.block_until(
116+
lambda: "blocked" in {unit.workload_status for unit in app.units},
117+
timeout=1500,
118+
)
119+
120+
logger.info(" remove relation with legacy endpoints")
121+
await ops_test.model.applications[APP_NAME].remove_relation(
122+
f"{DB_APP_NAME}:{DB_RELATION}", f"{APP_NAME}:{DB_RELATION}"
123+
)
124+
async with ops_test.fast_forward():
125+
await ops_test.model.wait_for_idle(status="active", timeout=3000, raise_on_error=False)
126+
127+
modern_interface_connect = await build_connection_string(
128+
ops_test, DATABASE_APP_NAME, FIRST_DATABASE_RELATION
129+
)
130+
logger.info(f"check connect to = {modern_interface_connect}")
131+
for attempt in Retrying(stop=stop_after_delay(60 * 3), wait=wait_fixed(10)):
132+
with attempt:
133+
with psycopg2.connect(modern_interface_connect) as connection:
134+
assert connection.status == psycopg2.extensions.STATUS_READY
135+
136+
logger.info(f"remove relation {DATABASE_APP_NAME}:{FIRST_DATABASE_RELATION}")
137+
async with ops_test.fast_forward():
138+
await ops_test.model.applications[APP_NAME].remove_relation(
139+
f"{APP_NAME}:{DATABASE_RELATION}", f"{DATABASE_APP_NAME}:{FIRST_DATABASE_RELATION}"
140+
)
141+
await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000)
142+
for attempt in Retrying(stop=stop_after_delay(60 * 5), wait=wait_fixed(10)):
143+
with attempt:
144+
with pytest.raises(psycopg2.OperationalError):
145+
psycopg2.connect(modern_interface_connect)

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ allowlist_externals =
2626
charmcraft
2727
charmcraftcache
2828
mv
29+
psycopg2-binary
2930
commands_pre =
3031
poetry export --only main,charm-libs --output requirements.txt
3132
commands =

0 commit comments

Comments
 (0)