Skip to content

Commit f6cb835

Browse files
[DPE-8747] Handle persistent storage in set_up_database to prevent ObjectInUse errors (#54)
* fix: handle persistent storage in set_up_database to prevent ObjectInUse errors Fix a bug where set_up_database() fails with "directory already in use as a tablespace" error when running on persistent storage after a reboot or failover. The issue occurred because the method assumed tmpfs storage and would rename the existing 'temp' tablespace and try to create a new one at the same location. With persistent storage, the directory remains in use by the renamed tablespace, causing PostgreSQL to reject the CREATE TABLESPACE command. Changes: - Add is_tmpfs() utility function to detect filesystem type via /proc/mounts - Modify set_up_database() to differentiate between tmpfs and persistent storage: * tmpfs: Preserve existing behavior (rename + recreate for empty directory) * persistent: Only fix permissions, keep existing tablespace - Extract tablespace handling into _handle_temp_tablespace_on_reboot() helper method to reduce cyclomatic complexity - Add comprehensive test coverage (7 new tests for is_tmpfs, 1 for persistent storage handling) - Update existing tests to mock filesystem detection This ensures the library works correctly with both tmpfs and persistent storage configurations for the temp tablespace. Fixes cluster failures after reboot with persistent storage. Bumps version to 16.1.5. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * fix: include temp_location path in persistent storage log message Add temp_location parameter to the log message in _handle_temp_tablespace_on_reboot() when handling persistent storage scenarios. This provides better observability and makes debugging easier by showing operators the exact directory path where permissions were fixed. Addresses feedback from PR review comment: #54 (comment) Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * docs: explain timestamp collision prevention in tablespace rename Add comment documenting why timestamp collision cannot occur during temp tablespace rename operations on tmpfs storage. The comment clarifies that consuming charms ensure leader-only execution and PostgreSQL serializes operations through exclusive locks, making second-precision timestamps sufficient for uniqueness. Addresses feedback from PR review comment: #54 (comment) Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> --------- Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent 5559a77 commit f6cb835

File tree

6 files changed

+247
-24
lines changed

6 files changed

+247
-24
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[project]
55
name = "postgresql-charms-single-kernel"
66
description = "Shared and reusable code for PostgreSQL-related charms"
7-
version = "16.1.4"
7+
version = "16.1.5"
88
readme = "README.md"
99
license = {file = "LICENSE"}
1010
authors = [

single_kernel_postgresql/utils/filesystem.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,45 @@ def change_owner(path: str) -> None:
1818
user_database = pwd.getpwnam(SNAP_USER)
1919
# Set the correct ownership for the file or directory.
2020
os.chown(path, uid=user_database.pw_uid, gid=user_database.pw_gid)
21+
22+
23+
def is_tmpfs(path: str) -> bool:
24+
"""Check if a path is on a tmpfs filesystem.
25+
26+
This function reads /proc/mounts to determine the filesystem type of the
27+
mount point containing the given path.
28+
29+
Args:
30+
path: Path to check.
31+
32+
Returns:
33+
True if the path is on a tmpfs filesystem, False otherwise.
34+
Returns False if /proc/mounts cannot be read (e.g., not on Linux).
35+
"""
36+
try:
37+
with open("/proc/mounts") as f:
38+
mounts = f.readlines()
39+
except (FileNotFoundError, PermissionError):
40+
# Not on Linux or /proc not available, assume persistent storage
41+
return False
42+
43+
# Get absolute path to handle relative paths and symlinks
44+
abs_path = os.path.abspath(path)
45+
46+
# Find the longest matching mount point (most specific)
47+
best_match_fs = None
48+
best_match_len = 0
49+
50+
for line in mounts:
51+
parts = line.split()
52+
if len(parts) < 3:
53+
continue
54+
mount_point = parts[1]
55+
fs_type = parts[2]
56+
57+
# Check if path is under this mount point and is more specific
58+
if abs_path.startswith(mount_point) and len(mount_point) > best_match_len:
59+
best_match_fs = fs_type
60+
best_match_len = len(mount_point)
61+
62+
return best_match_fs == "tmpfs"

single_kernel_postgresql/utils/postgresql.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
SYSTEM_USERS,
3838
Substrates,
3939
)
40-
from .filesystem import change_owner
40+
from .filesystem import change_owner, is_tmpfs
4141

4242
# Groups to distinguish HBA access
4343
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -1107,39 +1107,88 @@ def _get_existing_databases(self) -> List[str]:
11071107
connection.close()
11081108
return databases
11091109

1110+
def _handle_temp_tablespace_on_reboot(
1111+
self, cursor, temp_location: str, temp_tablespace_exists: bool
1112+
) -> None:
1113+
"""Handle temp tablespace when permissions need fixing after reboot.
1114+
1115+
Args:
1116+
cursor: Database cursor.
1117+
temp_location: Path to the temp tablespace location.
1118+
temp_tablespace_exists: Whether the temp tablespace already exists.
1119+
"""
1120+
if not temp_tablespace_exists:
1121+
return
1122+
1123+
# Different handling based on storage type
1124+
if is_tmpfs(temp_location):
1125+
# tmpfs: Directory is empty after reboot, safe to rename and recreate
1126+
# Rename existing temp tablespace instead of dropping it.
1127+
# Timestamp collision is not possible: the charm ensures this code runs leader-only,
1128+
# and it executes within a single database transaction holding exclusive locks.
1129+
new_name = f"temp_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
1130+
cursor.execute(f"ALTER TABLESPACE temp RENAME TO {new_name};")
1131+
1132+
# List temp tablespaces with suffix for operator follow-up cleanup
1133+
cursor.execute("SELECT spcname FROM pg_tablespace WHERE spcname LIKE 'temp_%';")
1134+
temp_tbls = sorted([row[0] for row in cursor.fetchall()])
1135+
logger.info(
1136+
"There are %d temp tablespaces that should be checked and removed: %s",
1137+
len(temp_tbls),
1138+
", ".join(temp_tbls),
1139+
)
1140+
else:
1141+
# Persistent storage: Tablespace is still valid, permissions already fixed
1142+
# Log that we fixed permissions but didn't recreate
1143+
logger.info(
1144+
"Fixed permissions on temp tablespace directory at %s (persistent storage), "
1145+
"existing tablespace remains valid",
1146+
temp_location,
1147+
)
1148+
11101149
def set_up_database(self, temp_location: Optional[str] = None) -> None:
1111-
"""Set up postgres database with the right permissions."""
1150+
"""Set up postgres database with the right permissions.
1151+
1152+
This method configures the postgres database with appropriate permissions and
1153+
optionally creates a temporary tablespace.
1154+
1155+
Args:
1156+
temp_location: Optional path for the temp tablespace. If provided, the method
1157+
will ensure proper permissions and create the tablespace if it doesn't exist.
1158+
1159+
Behavior on reboot:
1160+
- For tmpfs storage: If permissions are incorrect after reboot, renames the old
1161+
tablespace and creates a new one (tmpfs directory is empty after reboot).
1162+
- For persistent storage: If permissions are incorrect after reboot, fixes
1163+
permissions but keeps the existing tablespace (directory contents persist).
1164+
1165+
Raises:
1166+
PostgreSQLDatabasesSetupError: If database setup fails.
1167+
"""
11121168
connection = None
11131169
cursor = None
11141170
try:
11151171
connection = self._connect_to_database()
11161172
cursor = connection.cursor()
11171173

11181174
if temp_location is not None:
1119-
# Fix permissions on the temporary tablespace location when a reboot happens and tmpfs is being used.
1175+
# Fix permissions on the temporary tablespace location when a reboot happens.
11201176
temp_location_stats = os.stat(temp_location)
1121-
if self.substrate == Substrates.VM and (
1177+
permissions_need_fix = self.substrate == Substrates.VM and (
11221178
pwd.getpwuid(temp_location_stats.st_uid).pw_name != SNAP_USER
11231179
or int(temp_location_stats.st_mode & 0o777) != POSTGRESQL_STORAGE_PERMISSIONS
1124-
):
1180+
)
1181+
1182+
if permissions_need_fix:
11251183
change_owner(temp_location)
11261184
os.chmod(temp_location, POSTGRESQL_STORAGE_PERMISSIONS)
1127-
# Rename existing temp tablespace if it exists, instead of dropping it.
1128-
cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")
1129-
if cursor.fetchone() is not None:
1130-
new_name = f"temp_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
1131-
cursor.execute(f"ALTER TABLESPACE temp RENAME TO {new_name};")
11321185

1133-
# List temp tablespaces with suffix for operator follow-up cleanup and log them.
1134-
cursor.execute(
1135-
"SELECT spcname FROM pg_tablespace WHERE spcname LIKE 'temp_%';"
1136-
)
1137-
temp_tbls = sorted([row[0] for row in cursor.fetchall()])
1138-
logger.info(
1139-
"There are %d temp tablespaces that should be checked and removed: %s",
1140-
len(temp_tbls),
1141-
", ".join(temp_tbls),
1142-
)
1186+
# Check if temp tablespace exists and handle it appropriately
1187+
cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")
1188+
temp_tablespace_exists = cursor.fetchone() is not None
1189+
self._handle_temp_tablespace_on_reboot(
1190+
cursor, temp_location, temp_tablespace_exists
1191+
)
11431192

11441193
# Ensure a fresh temp tablespace exists at the expected location.
11451194
cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")

tests/unit/test_filesystem.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# Copyright 2025 Canonical Ltd.
22
# See LICENSE file for licensing details.
33
from tempfile import NamedTemporaryFile
4-
from unittest.mock import MagicMock, patch
4+
from unittest.mock import MagicMock, mock_open, patch
55

66
import pytest
77
from single_kernel_postgresql.config.literals import SNAP_USER
8-
from single_kernel_postgresql.utils.filesystem import change_owner
8+
from single_kernel_postgresql.utils.filesystem import change_owner, is_tmpfs
99

1010

1111
def test_change_owner_calls_pwd_and_os_chown_with_daemon_user():
@@ -50,3 +50,80 @@ def test_change_owner_bubbles_up_os_error():
5050
getpwnam.return_value = entry
5151
with pytest.raises(OSError):
5252
change_owner(tmp.name)
53+
54+
55+
def test_is_tmpfs_detects_tmpfs_correctly():
56+
"""Test that is_tmpfs correctly identifies tmpfs filesystems."""
57+
proc_mounts_content = (
58+
"tmpfs /tmp tmpfs rw,nosuid,nodev 0 0\n"
59+
"tmpfs /run tmpfs rw,nosuid,nodev,noexec,relatime 0 0\n"
60+
"/dev/sda1 / ext4 rw,relatime 0 0\n"
61+
"/dev/sda1 /var ext4 rw,relatime 0 0\n"
62+
)
63+
with patch("builtins.open", mock_open(read_data=proc_mounts_content)):
64+
assert is_tmpfs("/tmp/test") is True
65+
assert is_tmpfs("/run/postgresql") is True
66+
assert is_tmpfs("/var/lib/postgresql") is False
67+
assert is_tmpfs("/") is False
68+
69+
70+
def test_is_tmpfs_handles_nested_paths():
71+
"""Test that is_tmpfs correctly handles nested paths under mount points."""
72+
proc_mounts_content = (
73+
"tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0\n/dev/sda1 / ext4 rw,relatime 0 0\n"
74+
)
75+
with patch("builtins.open", mock_open(read_data=proc_mounts_content)):
76+
assert is_tmpfs("/dev/shm") is True
77+
assert is_tmpfs("/dev/shm/postgresql") is True
78+
assert is_tmpfs("/dev/shm/postgresql/temp") is True
79+
assert is_tmpfs("/dev/other") is False
80+
81+
82+
def test_is_tmpfs_uses_longest_mount_match():
83+
"""Test that is_tmpfs uses the most specific (longest) mount point."""
84+
proc_mounts_content = (
85+
"/dev/sda1 / ext4 rw,relatime 0 0\n"
86+
"tmpfs /var/tmp tmpfs rw,nosuid,nodev 0 0\n"
87+
"/dev/sdb1 /var ext4 rw,relatime 0 0\n"
88+
)
89+
with patch("builtins.open", mock_open(read_data=proc_mounts_content)):
90+
# /var is ext4, but /var/tmp is tmpfs - should match the longer path
91+
assert is_tmpfs("/var/tmp/test") is True
92+
assert is_tmpfs("/var/lib/test") is False
93+
94+
95+
def test_is_tmpfs_handles_relative_paths():
96+
"""Test that is_tmpfs correctly resolves relative paths."""
97+
proc_mounts_content = "tmpfs /run tmpfs rw,nosuid,nodev 0 0\n"
98+
with (
99+
patch("builtins.open", mock_open(read_data=proc_mounts_content)),
100+
patch("single_kernel_postgresql.utils.filesystem.os.path.abspath") as mock_abspath,
101+
):
102+
mock_abspath.return_value = "/run/test"
103+
assert is_tmpfs("../run/test") is True
104+
mock_abspath.assert_called_once_with("../run/test")
105+
106+
107+
def test_is_tmpfs_handles_missing_proc_mounts():
108+
"""Test that is_tmpfs returns False when /proc/mounts is unavailable."""
109+
with patch("builtins.open", side_effect=FileNotFoundError):
110+
assert is_tmpfs("/any/path") is False
111+
112+
113+
def test_is_tmpfs_handles_permission_error():
114+
"""Test that is_tmpfs returns False when /proc/mounts cannot be read."""
115+
with patch("builtins.open", side_effect=PermissionError):
116+
assert is_tmpfs("/any/path") is False
117+
118+
119+
def test_is_tmpfs_handles_malformed_mount_lines():
120+
"""Test that is_tmpfs gracefully handles malformed lines in /proc/mounts."""
121+
proc_mounts_content = (
122+
"invalid line\n"
123+
"device /path\n" # Only 2 parts
124+
"tmpfs /tmp tmpfs rw,nosuid,nodev 0 0\n" # Valid line
125+
"\n" # Empty line
126+
)
127+
with patch("builtins.open", mock_open(read_data=proc_mounts_content)):
128+
# Should still work and find the valid tmpfs line
129+
assert is_tmpfs("/tmp/test") is True

tests/unit/test_postgresql.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,11 @@ def test_set_up_database_owner_mismatch_triggers_rename_and_fix(harness):
406406
patch("single_kernel_postgresql.utils.postgresql.os.stat") as _stat,
407407
patch("single_kernel_postgresql.utils.postgresql.pwd.getpwuid") as _getpwuid,
408408
patch("single_kernel_postgresql.utils.postgresql.datetime") as _dt,
409+
patch("single_kernel_postgresql.utils.postgresql.is_tmpfs") as _is_tmpfs,
409410
):
411+
# Simulate tmpfs storage for this test
412+
_is_tmpfs.return_value = True
413+
410414
# Owner differs, permissions are correct (16832 means 0o700)
411415
# Simulate directory owned by uid 1000 while expected owner has uid 0 to force mismatch
412416
stat_result = type("stat_result", (), {"st_uid": 1000, "st_gid": 1000, "st_mode": 16832})
@@ -446,7 +450,11 @@ def test_set_up_database_permissions_mismatch_triggers_rename_and_fix(harness):
446450
patch("single_kernel_postgresql.utils.postgresql.os.stat") as _stat,
447451
patch("single_kernel_postgresql.utils.postgresql.pwd.getpwuid") as _getpwuid,
448452
patch("single_kernel_postgresql.utils.postgresql.datetime") as _dt,
453+
patch("single_kernel_postgresql.utils.postgresql.is_tmpfs") as _is_tmpfs,
449454
):
455+
# Simulate tmpfs storage for this test
456+
_is_tmpfs.return_value = True
457+
450458
# Owner matches SNAP_USER, permissions differ (33188 means 0o644)
451459
stat_result = type("stat_result", (), {"st_uid": 0, "st_gid": 0, "st_mode": 33188})
452460
_stat.return_value = stat_result
@@ -470,6 +478,53 @@ def test_set_up_database_permissions_mismatch_triggers_rename_and_fix(harness):
470478
execute_direct.assert_any_call("ALTER TABLESPACE temp RENAME TO temp_20250101010203;")
471479

472480

481+
def test_set_up_database_persistent_storage_no_rename(harness):
482+
"""Test that persistent storage permissions fix doesn't rename tablespace."""
483+
with (
484+
patch(
485+
"single_kernel_postgresql.utils.postgresql.PostgreSQL._connect_to_database"
486+
) as _connect_to_database,
487+
patch("single_kernel_postgresql.utils.postgresql.PostgreSQL.set_up_login_hook_function"),
488+
patch(
489+
"single_kernel_postgresql.utils.postgresql.PostgreSQL.set_up_predefined_catalog_roles_function"
490+
),
491+
patch("single_kernel_postgresql.utils.postgresql.change_owner") as _change_owner,
492+
patch("single_kernel_postgresql.utils.postgresql.os.chmod") as _chmod,
493+
patch("single_kernel_postgresql.utils.postgresql.os.stat") as _stat,
494+
patch("single_kernel_postgresql.utils.postgresql.pwd.getpwuid") as _getpwuid,
495+
patch("single_kernel_postgresql.utils.postgresql.is_tmpfs") as _is_tmpfs,
496+
):
497+
# Simulate persistent storage (not tmpfs)
498+
_is_tmpfs.return_value = False
499+
500+
# Permissions need fixing (wrong owner)
501+
stat_result = type("stat_result", (), {"st_uid": 1000, "st_gid": 1000, "st_mode": 16832})
502+
_stat.return_value = stat_result
503+
_getpwuid.return_value.pw_name = "root"
504+
505+
execute = _connect_to_database.return_value.cursor.return_value.execute
506+
fetchone = _connect_to_database.return_value.cursor.return_value.fetchone
507+
# First check: tablespace exists, second check: still exists (not renamed)
508+
fetchone.side_effect = [True, True]
509+
510+
harness.charm.postgresql.set_up_database(temp_location="/var/lib/postgresql/tmp")
511+
512+
# Permissions should be fixed
513+
_change_owner.assert_called_once_with("/var/lib/postgresql/tmp")
514+
_chmod.assert_called_once_with("/var/lib/postgresql/tmp", POSTGRESQL_STORAGE_PERMISSIONS)
515+
516+
# Tablespace should NOT be renamed
517+
for call in execute.call_args_list:
518+
call_str = str(call)
519+
assert "ALTER TABLESPACE temp RENAME" not in call_str, f"Found rename in: {call_str}"
520+
521+
# Should NOT try to create new tablespace (because it still exists)
522+
create_calls = [
523+
call for call in execute.call_args_list if "CREATE TABLESPACE temp" in str(call)
524+
]
525+
assert len(create_calls) == 0, f"Unexpected CREATE TABLESPACE calls: {create_calls}"
526+
527+
473528
def test_set_up_database_no_temp_and_existing_owner_role(harness):
474529
with (
475530
patch(

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)