Skip to content

Commit 97ae9fb

Browse files
Remove k8s resources on application removal (canonical#13)
* Add k8s resources cleanup * Improve code * Uncomment previous tests * Remove duplicated and imcomplete test * Fix imports order * Minor fixes * Test idle period * Test lib versions * Change * Remove version test * Revert change * Revert change * Revert change * Uncomment tests * Fix CI workflow * Fix tests * Add test about redeployment * Revert some test changes * Improve code * Fix docstring * Add pytest marks
1 parent 48e352b commit 97ae9fb

File tree

4 files changed

+206
-30
lines changed

4 files changed

+206
-30
lines changed

src/charm.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from charms.postgresql_k8s.v0.postgresql import PostgreSQL
1111
from lightkube import ApiError, Client, codecs
12-
from lightkube.resources.core_v1 import Pod
12+
from lightkube.resources.core_v1 import Endpoints, Pod, Service
1313
from ops.charm import (
1414
ActionEvent,
1515
CharmBase,
@@ -57,6 +57,7 @@ def __init__(self, *args):
5757
self.framework.observe(self.on[PEER].relation_changed, self._on_peer_relation_changed)
5858
self.framework.observe(self.on[PEER].relation_departed, self._on_peer_relation_departed)
5959
self.framework.observe(self.on.postgresql_pebble_ready, self._on_postgresql_pebble_ready)
60+
self.framework.observe(self.on.stop, self._on_stop)
6061
self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm)
6162
self.framework.observe(
6263
self.on.get_operator_password_action, self._on_get_operator_password
@@ -437,6 +438,46 @@ def _on_get_primary(self, event: ActionEvent) -> None:
437438
except RetryError as e:
438439
logger.error(f"failed to get primary with error {e}")
439440

441+
def _on_stop(self, _) -> None:
442+
"""Remove k8s resources created by the charm and Patroni."""
443+
client = Client()
444+
445+
# Get the k8s resources created by the charm.
446+
with open("src/resources.yaml") as f:
447+
resources = codecs.load_all_yaml(f, context=self._context)
448+
# Ignore the service resources, which will be retrieved in the next step.
449+
resources_to_delete = list(
450+
filter(
451+
lambda x: not isinstance(x, Service),
452+
resources,
453+
)
454+
)
455+
456+
# Get the k8s resources created by the charm and Patroni.
457+
for kind in [Endpoints, Service]:
458+
resources_to_delete.extend(
459+
client.list(
460+
kind,
461+
namespace=self._namespace,
462+
labels={"app.juju.is/created-by": f"{self._name}"},
463+
)
464+
)
465+
466+
# Delete the resources.
467+
for resource in resources_to_delete:
468+
try:
469+
client.delete(
470+
type(resource),
471+
name=resource.metadata.name,
472+
namespace=resource.metadata.namespace,
473+
)
474+
except ApiError as e:
475+
if (
476+
e.status.code != 404
477+
): # 404 means that the resource was already deleted by other unit.
478+
# Only log a message, as the charm is being stopped.
479+
logger.error(f"failed to delete resource: {resource}.")
480+
440481
def _on_update_status(self, _) -> None:
441482
# Display an active status message if the current unit is the primary.
442483
try:

src/resources.yaml

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -84,32 +84,3 @@ roleRef:
8484
subjects:
8585
- kind: ServiceAccount
8686
name: {{ app_name }}
87-
88-
---
89-
apiVersion: rbac.authorization.k8s.io/v1
90-
kind: ClusterRole
91-
metadata:
92-
name: patroni-k8s-ep-access
93-
rules:
94-
- apiGroups:
95-
- ""
96-
resources:
97-
- endpoints
98-
resourceNames:
99-
- kubernetes
100-
verbs:
101-
- get
102-
103-
---
104-
apiVersion: rbac.authorization.k8s.io/v1
105-
kind: ClusterRoleBinding
106-
metadata:
107-
name: patroni-k8s-ep-access
108-
roleRef:
109-
apiGroup: rbac.authorization.k8s.io
110-
kind: ClusterRole
111-
name: patroni-k8s-ep-access
112-
subjects:
113-
- kind: ServiceAccount
114-
name: {{ app_name }}
115-
namespace: {{ namespace }}

tests/integration/helpers.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
import psycopg2
99
import requests
1010
import yaml
11+
from lightkube import codecs
12+
from lightkube.core.client import Client
13+
from lightkube.core.exceptions import ApiError
14+
from lightkube.generic_resource import GenericNamespacedResource
15+
from lightkube.resources.core_v1 import Endpoints, Service
1116
from pytest_operator.plugin import OpsTest
1217

1318
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
@@ -196,6 +201,110 @@ def get_application_units(ops_test: OpsTest, application_name: str) -> List[str]
196201
]
197202

198203

204+
def get_charm_resources(namespace: str, application: str) -> List[GenericNamespacedResource]:
205+
"""Return the list of k8s resources from resources.yaml file.
206+
207+
Args:
208+
namespace: namespace related to the model where
209+
the charm was deployed.
210+
application: application name.
211+
212+
Returns:
213+
list of existing charm/Patroni specific k8s resources.
214+
"""
215+
# Define the context needed for the k8s resources lists load.
216+
context = {"namespace": namespace, "app_name": application}
217+
218+
# Load the list of the resources from resources.yaml.
219+
with open("src/resources.yaml") as f:
220+
return codecs.load_all_yaml(f, context=context)
221+
222+
223+
def get_existing_k8s_resources(namespace: str, application: str) -> set:
224+
"""Return the list of k8s resources that were created by the charm and Patroni.
225+
226+
Args:
227+
namespace: namespace related to the model where
228+
the charm was deployed.
229+
application: application name.
230+
231+
Returns:
232+
list of existing charm/Patroni specific k8s resources.
233+
"""
234+
# Create a k8s API client instance.
235+
client = Client(namespace=namespace)
236+
237+
# Retrieve the k8s resources the charm should create.
238+
charm_resources = get_charm_resources(namespace, application)
239+
240+
# Add only the resources that currently exist.
241+
resources = set(
242+
map(
243+
# Build an identifier for each resource (using its type and name).
244+
lambda x: f"{type(x).__name__}/{x.metadata.name}",
245+
filter(
246+
lambda x: (resource_exists(client, x)),
247+
charm_resources,
248+
),
249+
)
250+
)
251+
252+
# Include the resources created by the charm and Patroni.
253+
for kind in [Endpoints, Service]:
254+
extra_resources = client.list(
255+
kind,
256+
namespace=namespace,
257+
labels={"app.juju.is/created-by": application},
258+
)
259+
resources.update(
260+
set(
261+
map(
262+
# Build an identifier for each resource (using its type and name).
263+
lambda x: f"{kind.__name__}/{x.metadata.name}",
264+
extra_resources,
265+
)
266+
)
267+
)
268+
269+
return resources
270+
271+
272+
def get_expected_k8s_resources(namespace: str, application: str) -> set:
273+
"""Return the list of expected k8s resources when the charm is deployed.
274+
275+
Args:
276+
namespace: namespace related to the model where
277+
the charm was deployed.
278+
application: application name.
279+
280+
Returns:
281+
list of existing charm/Patroni specific k8s resources.
282+
"""
283+
# Retrieve the k8s resources created by the charm.
284+
charm_resources = get_charm_resources(namespace, application)
285+
286+
# Build an identifier for each resource (using its type and name).
287+
resources = set(
288+
map(
289+
lambda x: f"{type(x).__name__}/{x.metadata.name}",
290+
charm_resources,
291+
)
292+
)
293+
294+
# Include the resources created by the charm and Patroni.
295+
resources.update(
296+
[
297+
f"Endpoints/patroni-{application}-config",
298+
f"Endpoints/patroni-{application}",
299+
f"Endpoints/{application}-primary",
300+
f"Endpoints/{application}-replicas",
301+
f"Service/patroni-{application}-config",
302+
]
303+
)
304+
305+
return resources
306+
307+
199308
async def get_operator_password(ops_test: OpsTest):
200309
"""Retrieve the operator user password using the action."""
201310
unit = ops_test.model.units.get(f"{DATABASE_APP_NAME}/0")
@@ -235,6 +344,23 @@ async def get_unit_address(ops_test: OpsTest, unit_name: str) -> str:
235344
return status["applications"][unit_name.split("/")[0]].units[unit_name]["address"]
236345

237346

347+
def resource_exists(client: Client, resource: GenericNamespacedResource) -> bool:
348+
"""Check whether a specific resource exists.
349+
350+
Args:
351+
client: k8s API client instance.
352+
resource: k8s resource.
353+
354+
Returns:
355+
whether the resource exists.
356+
"""
357+
try:
358+
client.get(type(resource), name=resource.metadata.name)
359+
return True
360+
except ApiError:
361+
return False
362+
363+
238364
async def scale_application(ops_test: OpsTest, application_name: str, scale: int) -> None:
239365
"""Scale a given application to a specific unit count.
240366

tests/integration/test_charm.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
convert_records_to_dict,
1919
get_application_units,
2020
get_cluster_members,
21+
get_existing_k8s_resources,
22+
get_expected_k8s_resources,
2123
get_operator_password,
2224
get_unit_address,
2325
scale_application,
@@ -52,6 +54,16 @@ async def test_build_and_deploy(ops_test: OpsTest):
5254
assert ops_test.model.applications[APP_NAME].units[unit_id].workload_status == "active"
5355

5456

57+
@pytest.mark.charm
58+
async def test_application_created_required_resources(ops_test: OpsTest) -> None:
59+
# Compare the k8s resources that the charm and Patroni should create with
60+
# the currently created k8s resources.
61+
namespace = ops_test.model.info.name
62+
existing_resources = get_existing_k8s_resources(namespace, APP_NAME)
63+
expected_resources = get_expected_k8s_resources(namespace, APP_NAME)
64+
assert set(existing_resources) == set(expected_resources)
65+
66+
5567
@pytest.mark.parametrize("unit_id", UNIT_IDS)
5668
async def test_labels_consistency_across_pods(ops_test: OpsTest, unit_id: int) -> None:
5769
model = ops_test.model.info
@@ -259,11 +271,37 @@ async def test_application_removal(ops_test: OpsTest) -> None:
259271
)
260272
)
261273

274+
# Check that all k8s resources created by the charm and Patroni were removed.
275+
namespace = ops_test.model.info.name
276+
existing_resources = get_existing_k8s_resources(namespace, APP_NAME)
277+
assert set(existing_resources) == set()
278+
262279
# Check whether the application is gone
263280
# (in that situation, the units aren't in an error state).
264281
assert APP_NAME not in ops_test.model.applications
265282

266283

284+
@pytest.mark.charm
285+
async def test_redeploy_charm_same_model(ops_test: OpsTest):
286+
"""Redeploy the charm in the same model to test that it works."""
287+
charm = await ops_test.build_charm(".")
288+
async with ops_test.fast_forward():
289+
await ops_test.model.deploy(
290+
charm,
291+
resources={
292+
"postgresql-image": METADATA["resources"]["postgresql-image"]["upstream-source"]
293+
},
294+
application_name=APP_NAME,
295+
num_units=3,
296+
trust=True,
297+
)
298+
299+
# This check is enough to ensure that the charm/workload is working for this specific test.
300+
await ops_test.model.wait_for_idle(
301+
apps=[APP_NAME], status="active", timeout=1000, wait_for_exact_units=3
302+
)
303+
304+
267305
@retry(
268306
retry=retry_if_result(lambda x: not x),
269307
stop=stop_after_attempt(10),

0 commit comments

Comments
 (0)