Skip to content

Commit 0e95fcf

Browse files
[DPE-9685] Release storage on teardown (#1827)
* fix(charm): release storage on unit teardown The charm implemented no removal hooks, so on unit teardown the charmed-postgresql snap services kept the Juju storage mounts busy. Juju's unmount then failed with "target is busy", leaving storage stuck detaching and blocking machine and model removal (only destroy-model --force could clear it). Stop the workload in the storage-detaching hook, which Juju runs before stop, so the mounts are free by the time Juju unmounts them. This stops every charmed-postgresql snap service plus the charm's topology-observer and log-rotation processes, and is idempotent across the per-storage detaching events. Fixes #1550. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(integration): reproduce #1550 teardown on juju 4.0 The storage-detaching regression only manifests on Juju 4.0: 3.6 masks it with a cleanup_storage shortcut that removes still-Dying storage, and on 4.0 only rootfs (machine-scoped) storage reproduces the stuck unmount, so running the teardown check on 3.6 proves nothing. Move it out of test_storage.py into its own spread task pinned to a juju40 variant with rootfs storage (force-deployed because the charm still declares assumes: juju < 4). Also make the list_storage adapter tolerate Juju 4.0's empty list-storage output, which it prints instead of "{}" for a model with no storage. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * fix(charm): only release storage on full app teardown Stopping the workload in storage-detaching also fired on scale-down (remove-unit), where the surviving leader still needs the departing unit's Patroni reachable to remove it from the raft cluster. Stopping it early broke that reconfiguration, leaving the cluster unable to elect a primary ("Primary unit not found") and failing the HA/scaling integration tests. The storage unmount only actually hangs on full teardown (remove-application/destroy-model), so guard the handler on planned_units() == 0. On scale-down it now does nothing, restoring the pre-fix cluster behaviour, while destroy-model/remove-application still release the storage. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * docs(charm): trim storage-detaching comments and fix copyright year Match the one-liner docstring density of the surrounding handlers instead of an 8-line block (the why lives in the commit that added the guard), and date the new test file 2026 (it was copied as 2025). Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * fix(charm): disable snap services on teardown, not just stop stop() leaves the snap services enabled, so a mid-teardown restart of the unit could re-enable them and re-grab the storage mounts before Juju finishes unmounting. stop(disable=True) prevents that. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(spread): run storage-detaching on juju36 as a no-regression guard The task ran only on juju40 (where #1550 reproduces). Add juju36 so the teardown change (stopping/disabling the snap in storage-detaching) is also exercised on 3.6, where the bug is masked by force-removal but a new breakage would still surface. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> --------- Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent ead8a76 commit 0e95fcf

7 files changed

Lines changed: 158 additions & 1 deletion

File tree

src/charm.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ def __init__(self, *args):
409409
# incorrectly
410410
# https://canonical-charm-refresh.readthedocs-hosted.com/latest/add-to-charm/status/#implementation
411411
self.framework.observe(self.on.collect_unit_status, self._reconcile_refresh_status)
412+
for storage_name in self.meta.storages:
413+
self.framework.observe(
414+
self.on[storage_name].storage_detaching, self._on_storage_detaching
415+
)
412416
self.cluster_name = self.app.name
413417
self._member_name = self.unit.name.replace("/", "-")
414418
self._certs_path = "/usr/local/share/ca-certificates"
@@ -2628,6 +2632,21 @@ def _install_snap_package(
26282632
)
26292633
raise
26302634

2635+
def _on_storage_detaching(self, _) -> None:
2636+
"""Stop the workload so Juju can unmount the storage on app teardown."""
2637+
# On scale-down the surviving cluster still needs this unit's Patroni to
2638+
# remove it from raft; only stop when the whole app is going away.
2639+
if self.app.planned_units() > 0:
2640+
return
2641+
self._observer.stop_observer()
2642+
self._rotate_logs.stop_log_rotation()
2643+
try:
2644+
# Disable too, so a mid-teardown restart of the unit can't re-enable the
2645+
# services and re-grab the storage mounts before Juju finishes unmounting.
2646+
snap.SnapCache()[charm_refresh.snap_name()].stop(disable=True)
2647+
except snap.SnapError:
2648+
logger.exception("Failed to stop charmed-postgresql snap services")
2649+
26312650
def _is_storage_attached(self) -> bool:
26322651
"""Returns if storage is attached."""
26332652
try:

src/rotate_logs.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import logging
77
import os
8+
import signal
89
import subprocess
910
from typing import TYPE_CHECKING
1011

@@ -56,3 +57,14 @@ def start_log_rotation(self):
5657

5758
self._charm.unit_peer_data.update({"rotate-logs-pid": f"{pid}"})
5859
logging.info(f"Started rotate logs process with PID {pid}")
60+
61+
def stop_log_rotation(self) -> None:
62+
"""Stop the running rotate logs process if we have previously started it."""
63+
if stored_pid := self._charm.unit_peer_data.get("rotate-logs-pid"):
64+
pid = int(stored_pid)
65+
try:
66+
os.kill(pid, signal.SIGINT)
67+
logging.info(f"Stopped rotate logs process with PID {pid}")
68+
self._charm.unit_peer_data.update({"rotate-logs-pid": ""})
69+
except OSError:
70+
pass

tests/integration/adapters.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,8 @@ def destroy_unit(
379379
def list_storage(self, filesystem: bool = False, volume: bool = False) -> list[TStorageInfo]:
380380
"""Lists storage details."""
381381
raw = self._juju.cli("list-storage", "--format", "json")
382-
json_ = json.loads(raw)
382+
# Juju 4.0 prints nothing (not `{}`) for a model with no storage.
383+
json_ = json.loads(raw) if raw.strip() else {}
383384
ret = []
384385
for storage_key, storage_details in json_.get("storage", {}).items():
385386
ret.append({"key": storage_key, **storage_details})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
import logging
6+
7+
import pytest
8+
9+
from .adapters import JujuFixture
10+
from .jubilant_helpers import DATABASE_APP_NAME
11+
12+
logger = logging.getLogger(__name__)
13+
14+
# rootfs (machine-scoped) is the only pool that reproduces the stuck unmount on
15+
# Juju 4.0: the detachable lxd pool destroys the container before detaching.
16+
ROOTFS_STORAGE = dict.fromkeys(("archive", "data", "logs", "temp"), "rootfs")
17+
18+
19+
@pytest.mark.abort_on_fail
20+
def test_storage_released_on_removal(juju: JujuFixture, charm):
21+
"""Graceful removal must release the storage so teardown completes.
22+
23+
Regression test for canonical/postgresql-operator#1550: without stopping the
24+
workload in the ``storage-detaching`` hook the snap keeps the storage mounts
25+
busy, so Juju's unmount fails ("target is busy") and teardown hangs.
26+
"""
27+
juju.ext.model.deploy(
28+
charm,
29+
num_units=1,
30+
config={"profile": "testing"},
31+
storage=ROOTFS_STORAGE,
32+
force=True,
33+
)
34+
juju.ext.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active")
35+
36+
logger.info("Removing %s and waiting for a clean teardown", DATABASE_APP_NAME)
37+
juju.ext.model.remove_application(
38+
DATABASE_APP_NAME,
39+
block_until_done=True,
40+
destroy_storage=True,
41+
timeout=15 * 60,
42+
)
43+
44+
detaching = [
45+
storage for storage in juju.ext.model.list_storage() if storage["life"] == "detaching"
46+
]
47+
assert not detaching, f"storage stuck detaching after removal: {detaching}"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
summary: test_storage_detaching.py
2+
environment:
3+
TEST_MODULE: test_storage_detaching.py
4+
# #1550 only reproduces on Juju 4.0 (3.6 masks it by removing still-Dying storage),
5+
# so juju40 is the real regression test. Run juju36 too as a no-regression guard:
6+
# stopping/disabling the snap in storage-detaching must not break 3.6 teardown.
7+
CONCIERGE_JUJU_CHANNEL/juju40: 4.0/stable
8+
execute: |
9+
tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results"
10+
artifacts:
11+
- allure-results

tests/unit/test_charm.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,42 @@ def test_on_install(harness):
125125
assert isinstance(harness.model.unit.status, WaitingStatus)
126126

127127

128+
def test_on_storage_detaching(harness):
129+
with (
130+
patch("charm.snap.SnapCache") as _snap_cache,
131+
patch("charm.ClusterTopologyObserver.stop_observer") as _stop_observer,
132+
patch("charm.RotateLogs.stop_log_rotation") as _stop_log_rotation,
133+
patch.object(harness.charm.app, "planned_units") as _planned_units,
134+
):
135+
_selected_snap = _snap_cache.return_value.__getitem__.return_value
136+
137+
# Scale-down (units remain): the surviving cluster still needs this unit's
138+
# Patroni reachable to remove it from raft, so the workload must NOT be
139+
# stopped here.
140+
_planned_units.return_value = 2
141+
storage_id = harness.add_storage("data", attach=True)[0]
142+
harness.detach_storage(storage_id)
143+
_stop_observer.assert_not_called()
144+
_stop_log_rotation.assert_not_called()
145+
_selected_snap.stop.assert_not_called()
146+
147+
# Full teardown (no units remain): release the storage by stopping the
148+
# background processes and disabling (so a mid-teardown restart can't
149+
# re-grab the mount) all the snap services.
150+
_planned_units.return_value = 0
151+
for storage_name in ("archive", "data", "logs", "temp"):
152+
_stop_observer.reset_mock()
153+
_stop_log_rotation.reset_mock()
154+
_selected_snap.reset_mock()
155+
156+
storage_id = harness.add_storage(storage_name, attach=True)[0]
157+
harness.detach_storage(storage_id)
158+
159+
_stop_observer.assert_called_once_with()
160+
_stop_log_rotation.assert_called_once_with()
161+
_selected_snap.stop.assert_called_once_with(disable=True)
162+
163+
128164
def test_patroni_scrape_config(harness):
129165
result = harness.charm.patroni_scrape_config()
130166

tests/unit/test_rotate_logs.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright 2024 Canonical Ltd.
22
# See LICENSE file for licensing details.
3+
import signal
34
from unittest.mock import Mock, PropertyMock, patch
45

56
import pytest
@@ -96,3 +97,33 @@ def test_start_log_rotation_already_running(harness):
9697
harness.charm.rotate_logs.start_log_rotation()
9798
_kill.assert_called_once_with(1234, 0)
9899
_popen.assert_called_once()
100+
101+
102+
def test_stop_log_rotation(harness):
103+
with (
104+
patch("os.kill") as _kill,
105+
patch.object(MockCharm, "_peers", new_callable=PropertyMock) as _peers,
106+
):
107+
# Nothing is done when the peer relation (and so the stored PID) is gone.
108+
_peers.return_value = None
109+
harness.charm.rotate_logs.stop_log_rotation()
110+
_kill.assert_not_called()
111+
112+
# Nothing is done when there is no process running.
113+
_peers.return_value = Mock(data={harness.charm.unit: {}})
114+
harness.charm.rotate_logs.stop_log_rotation()
115+
_kill.assert_not_called()
116+
117+
# The process is killed and the stored PID is cleared.
118+
peer_data = {"rotate-logs-pid": "1"}
119+
_peers.return_value = Mock(data={harness.charm.unit: peer_data})
120+
harness.charm.rotate_logs.stop_log_rotation()
121+
_kill.assert_called_once_with(1, signal.SIGINT)
122+
assert peer_data["rotate-logs-pid"] == ""
123+
_kill.reset_mock()
124+
125+
# A dead process doesn't break the script.
126+
_peers.return_value = Mock(data={harness.charm.unit: {"rotate-logs-pid": "1"}})
127+
_kill.side_effect = OSError
128+
harness.charm.rotate_logs.stop_log_rotation()
129+
_kill.assert_called_once_with(1, signal.SIGINT)

0 commit comments

Comments
 (0)