Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions single_kernel_postgresql/config/literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@
# VM services
VM_PATRONI_SERVICE_NAME = f"snap.{POSTGRESQL_SNAP_NAME}.patroni.service"
VM_PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{VM_PATRONI_SERVICE_NAME}"
VM_METRICS_SERVICE_NAME = "prometheus-postgres-exporter"
VM_PGBACKREST_METRICS_SERVICE_NAME = "pgbackrest-exporter"
UPDATE_CERTS_BIN_PATH = "/usr/sbin/update-ca-certificates"
VM_PGBACKREST_SERVICE_NAME = "pgbackrest-service"
VM_ROTATE_LOGS_LOG_FILE = "/var/log/rotate_logs.log"

Expand Down
4 changes: 4 additions & 0 deletions single_kernel_postgresql/managers/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ def stop_observer(self):

def start_raft_observer(self) -> None:
"""Render systemd units and start the observer."""
if self.state.substrate == Substrates.K8S:
# The K8s substrate has no systemd unit files; the raft observer
# is a VM-side concern (the K8s leg starts its own observer).
return
timer_service_file = "/etc/systemd/system/raft-observer.timer"
oneshot_service_file = "/etc/systemd/system/raft-observer.service"

Expand Down
4 changes: 4 additions & 0 deletions single_kernel_postgresql/workload/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ def stop_rotate_logs_loop(self, pid: int) -> bool:
"""Send SIGINT to the rotate-logs loop; False when the PID is gone (VM-only seam)."""
raise NotImplementedError

def ensure_storage_layout(self) -> None:
"""Recreate the versioned temp dir and chown the versioned parents (VM-only seam)."""
raise NotImplementedError

def get_postgresql_version(self) -> str:
"""Return the PostgreSQL version from the system."""
with pathlib.Path("refresh_versions.toml").open("rb") as file:
Expand Down
23 changes: 23 additions & 0 deletions single_kernel_postgresql/workload/vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,29 @@ def stop_rotate_logs_loop(self, pid: int) -> bool:
except OSError:
return False

# -- Storage layout (VM) ---------------------------------------------------------

def ensure_storage_layout(self) -> None:
"""Recreate the versioned temp dir and chown the versioned parents.

Port of the VM charm's ``_ensure_storage_layout``: the versioned
temp dir may live on a tmpfs mount that is wiped on reboot, so it
is recreated unconditionally; the versioned parents must be
_daemon_-owned so the snap daemon can remove/rename the versioned
subdirectories (CREATE TABLESPACE, ``patronictl reinit``, snap
rollbacks). The data dir leaf itself is only chowned-in-parent —
the snap's migrate-data.sh creates it.
"""
temp_dir = self.paths.temp
temp_dir.mkdir(parents=True, exist_ok=True)
shutil.chown(str(temp_dir), user=self.user, group=self.group)
if temp_dir.parent.exists():
shutil.chown(str(temp_dir.parent), user=self.user, group=self.group)

data_parent = self.paths.data.parent
if data_parent.exists():
shutil.chown(str(data_parent), user=self.user, group=self.group)

def is_service_started(self, paused: bool | None = False) -> bool:
"""Check if the snap service is running.

Expand Down
73 changes: 73 additions & 0 deletions tests/unit/test_observer_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
"""Tests for the ObserverManager raft-observer systemd unit rendering (VM).

Port of the VM charm's tests/unit/test_cluster_topology_observer.py
raft-observer cases (post-#2029 residual) against the lib manager.
"""

import importlib.resources
from unittest.mock import MagicMock, patch

from jinja2 import Template
from single_kernel_postgresql.config.enums import Substrates
from single_kernel_postgresql.managers.observer import ObserverManager


def _expected(service: bool, envvars: dict[str, str] | None = None) -> str:
name = "raft-observer.service.j2" if service else "raft-observer.timer.j2"
template = Template(
importlib.resources
.files("single_kernel_postgresql.templates")
.joinpath("vm", name)
.read_text()
)
if service:
return template.render(
envvars=envvars,
script="-m single_kernel_postgresql.scripts.raft_observer",
)
return template.render()


def test_start_raft_observer_renders_and_enables_the_timer():
state = MagicMock()
state.substrate = Substrates.VM
manager = ObserverManager(state, MagicMock())
with (
patch("single_kernel_postgresql.managers.observer.daemon_reload") as _daemon_reload,
patch("single_kernel_postgresql.managers.observer.service_enable") as _service_enable,
patch("single_kernel_postgresql.managers.observer.render_file") as _render_file,
patch(
"single_kernel_postgresql.managers.observer.copy_environment",
return_value={"ENV": "var"},
),
):
manager.start_raft_observer()

_daemon_reload.assert_called_once_with()
_service_enable.assert_called_once_with("/etc/systemd/system/raft-observer.timer", "--now")
assert _render_file.call_count == 2
_render_file.assert_any_call(
Substrates.VM,
"/etc/systemd/system/raft-observer.service",
_expected(True, envvars={"ENV": "var"}),
0o644,
change_owner=False,
)
_render_file.assert_any_call(
Substrates.VM,
"/etc/systemd/system/raft-observer.timer",
_expected(False),
0o644,
change_owner=False,
)


def test_start_raft_observer_noop_on_k8s():
state = MagicMock()
state.substrate = Substrates.K8S
manager = ObserverManager(state, MagicMock())
with patch("single_kernel_postgresql.managers.observer.render_file") as _render_file:
manager.start_raft_observer()
_render_file.assert_not_called()
56 changes: 56 additions & 0 deletions tests/unit/test_vm_storage_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
"""Tests for the VM workload storage layout seam.

Port of the VM charm's tests/unit/test_charm.py test_ensure_storage_layout
and test_ensure_storage_layout_recreates_temp_dir_on_reboot.
"""

from unittest.mock import MagicMock, PropertyMock, patch

from single_kernel_postgresql.workload.vm import VMWorkload


def test_ensure_storage_layout(tmp_path):
"""The versioned temp dir is created and the versioned parents chowned."""
workload = VMWorkload(".")
temp_root = tmp_path / "temp" / "16" / "main"
data_root = tmp_path / "data" / "16" / "main"
# The snap's migrate-data.sh creates the data parent (as root); the
# workload only fixes its ownership, it does not create the data dir.
data_root.parent.mkdir(parents=True)
paths = MagicMock()
paths.temp = temp_root
paths.data = data_root
with (
patch("single_kernel_postgresql.workload.vm.shutil") as mock_shutil,
patch.object(VMWorkload, "paths", new_callable=PropertyMock, return_value=paths),
):
workload.ensure_storage_layout()

assert temp_root.is_dir()
# The workload does not create the data dir leaf — only the snap does.
assert not data_root.exists()
chowned = {str(call.args[0]) for call in mock_shutil.chown.call_args_list}
assert str(temp_root) in chowned
assert str(temp_root.parent) in chowned
assert str(data_root.parent) in chowned
# Only the temp and data trees are touched.
assert not (tmp_path / "archive").exists()
assert not (tmp_path / "logs").exists()


def test_ensure_storage_layout_recreates_temp_dir_on_reboot(tmp_path):
"""The versioned temp dir is recreated after a tmpfs wipe on reboot."""
workload = VMWorkload(".")
temp_root = tmp_path / "temp" / "16" / "main"
paths = MagicMock()
paths.temp = temp_root
paths.data = tmp_path / "data" / "16" / "main"
with (
patch("single_kernel_postgresql.workload.vm.shutil"),
patch.object(VMWorkload, "paths", new_callable=PropertyMock, return_value=paths),
):
workload.ensure_storage_layout()

assert temp_root.is_dir()
Loading