Skip to content

Commit 685d78c

Browse files
[DPE-5601] Add pgBackRest logrotate configuration (#645)
* Add pgBackRest logrotate configuration Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Update how logrotate starts Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Refactor method name and add unit tests Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Merge linting changes --------- Signed-off-by: Marcelo Henrique Neppel <[email protected]> Co-authored-by: Dragomir Penev <[email protected]>
1 parent 037d1bb commit 685d78c

File tree

9 files changed

+221
-3
lines changed

9 files changed

+221
-3
lines changed

src/backups.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
PGBACKREST_CONF_PATH,
3636
PGBACKREST_CONFIGURATION_FILE,
3737
PGBACKREST_EXECUTABLE,
38+
PGBACKREST_LOGROTATE_FILE,
3839
PGBACKREST_LOGS_PATH,
3940
POSTGRESQL_DATA_PATH,
4041
)
@@ -1153,6 +1154,13 @@ def _render_pgbackrest_conf_file(self) -> bool:
11531154
# Render pgBackRest config file.
11541155
self.charm._patroni.render_file(f"{PGBACKREST_CONF_PATH}/pgbackrest.conf", rendered, 0o644)
11551156

1157+
# Render the logrotate configuration file.
1158+
with open("templates/pgbackrest.logrotate.j2") as file:
1159+
template = Template(file.read())
1160+
self.charm._patroni.render_file(
1161+
PGBACKREST_LOGROTATE_FILE, template.render(), 0o644, change_owner=False
1162+
)
1163+
11561164
return True
11571165

11581166
def _restart_database(self) -> None:

src/charm.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
)
103103
from relations.db import EXTENSIONS_BLOCKING_MESSAGE, DbProvides
104104
from relations.postgresql_provider import PostgreSQLProvider
105+
from rotate_logs import RotateLogs
105106
from upgrade import PostgreSQLUpgrade, get_postgresql_dependencies_model
106107
from utils import new_password
107108

@@ -170,6 +171,7 @@ def __init__(self, *args):
170171
juju_version = JujuVersion.from_environ()
171172
run_cmd = "/usr/bin/juju-exec" if juju_version.major > 2 else "/usr/bin/juju-run"
172173
self._observer = ClusterTopologyObserver(self, run_cmd)
174+
self._rotate_logs = RotateLogs(self)
173175
self.framework.observe(self.on.cluster_topology_change, self._on_cluster_topology_change)
174176
self.framework.observe(self.on.install, self._on_install)
175177
self.framework.observe(self.on.leader_elected, self._on_leader_elected)
@@ -203,6 +205,7 @@ def __init__(self, *args):
203205
charm=self, relation="restart", callback=self._restart
204206
)
205207
self._observer.start_observer()
208+
self._rotate_logs.start_log_rotation()
206209
self._grafana_agent = COSAgentProvider(
207210
self,
208211
metrics_endpoints=[{"path": "/metrics", "port": METRICS_PORT}],

src/cluster.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,14 +546,16 @@ def promote_standby_cluster(self) -> None:
546546
if self.get_primary() is None:
547547
raise ClusterNotPromotedError("cluster not promoted")
548548

549-
def render_file(self, path: str, content: str, mode: int) -> None:
549+
def render_file(self, path: str, content: str, mode: int, change_owner: bool = True) -> None:
550550
"""Write a content rendered from a template to a file.
551551
552552
Args:
553553
path: the path to the file.
554554
content: the data to be written to the file.
555555
mode: access permission mask applied to the
556556
file using chmod (e.g. 0o640).
557+
change_owner: whether to change the file owner
558+
to the snap_daemon user.
557559
"""
558560
# TODO: keep this method to use it also for generating replication configuration files and
559561
# move it to an utils / helpers file.
@@ -562,7 +564,8 @@ def render_file(self, path: str, content: str, mode: int) -> None:
562564
file.write(content)
563565
# Ensure correct permissions are set on the file.
564566
os.chmod(path, mode)
565-
self._change_owner(path)
567+
if change_owner:
568+
self._change_owner(path)
566569

567570
def render_patroni_yml_file(
568571
self,

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,5 @@
8484
PLUGIN_OVERRIDES = {"audit": "pgaudit", "uuid_ossp": '"uuid-ossp"'}
8585

8686
SPI_MODULE = ["refint", "autoinc", "insert_username", "moddatetime"]
87+
88+
PGBACKREST_LOGROTATE_FILE = "/etc/logrotate.d/pgbackrest.logrotate"

src/rotate_logs.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Background process for rotating logs."""
5+
6+
import logging
7+
import os
8+
import subprocess
9+
from time import sleep
10+
11+
from ops.charm import CharmBase
12+
from ops.framework import Object
13+
from ops.model import ActiveStatus
14+
15+
from constants import PGBACKREST_LOGROTATE_FILE
16+
17+
logger = logging.getLogger(__name__)
18+
19+
# File path for the spawned rotate logs process to write logs.
20+
LOG_FILE_PATH = "/var/log/rotate_logs.log"
21+
22+
23+
class RotateLogs(Object):
24+
"""Rotate logs every minute."""
25+
26+
def __init__(self, charm: CharmBase):
27+
super().__init__(charm, "rotate-logs")
28+
self._charm = charm
29+
30+
def start_log_rotation(self):
31+
"""Start the rotate logs running in a new process."""
32+
if (
33+
not isinstance(self._charm.unit.status, ActiveStatus)
34+
or self._charm._peers is None
35+
or not os.path.exists(PGBACKREST_LOGROTATE_FILE)
36+
):
37+
return
38+
if "rotate-logs-pid" in self._charm.unit_peer_data:
39+
# Double check that the PID exists.
40+
pid = int(self._charm.unit_peer_data["rotate-logs-pid"])
41+
try:
42+
os.kill(pid, 0)
43+
return
44+
except OSError:
45+
pass
46+
47+
logging.info("Starting rotate logs process")
48+
49+
# Input is generated by the charm
50+
pid = subprocess.Popen( # noqa: S603
51+
["/usr/bin/python3", "src/rotate_logs.py"],
52+
# File should not close
53+
stdout=open(LOG_FILE_PATH, "a"), # noqa: SIM115
54+
stderr=subprocess.STDOUT,
55+
).pid
56+
57+
self._charm.unit_peer_data.update({"rotate-logs-pid": f"{pid}"})
58+
logging.info(f"Started rotate logs process with PID {pid}")
59+
60+
61+
def main():
62+
"""Main loop that calls logrotate."""
63+
while True:
64+
# Input is constant
65+
subprocess.run(["/usr/sbin/logrotate", "-f", PGBACKREST_LOGROTATE_FILE]) # noqa: S603
66+
67+
# Wait 60 seconds before executing logrotate again.
68+
sleep(60)
69+
70+
71+
if __name__ == "__main__":
72+
main()

templates/pgbackrest.logrotate.j2

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/var/snap/charmed-postgresql/common/var/log/pgbackrest/*.log {
2+
rotate 10
3+
missingok
4+
notifempty
5+
nocompress
6+
daily
7+
create 0600 snap_daemon snap_daemon
8+
dateext
9+
dateformat -%Y%m%d_%H:%M.log
10+
}

tests/unit/test_backups.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1691,13 +1691,24 @@ def test_render_pgbackrest_conf_file(harness, tls_ca_chain_filename):
16911691
# Check the template is opened read-only in the call to open.
16921692
assert mock.call_args_list[0][0] == ("templates/pgbackrest.conf.j2",)
16931693

1694+
# Get the expected content from a file.
1695+
with open("templates/pgbackrest.conf.j2") as file:
1696+
template = Template(file.read())
1697+
log_rotation_expected_content = template.render()
1698+
16941699
# Ensure the correct rendered template is sent to _render_file method.
16951700
calls = [
16961701
call(
16971702
"/var/snap/charmed-postgresql/current/etc/pgbackrest/pgbackrest.conf",
16981703
expected_content,
16991704
0o644,
1700-
)
1705+
),
1706+
call(
1707+
"/etc/logrotate.d/pgbackrest.logrotate",
1708+
log_rotation_expected_content,
1709+
0o644,
1710+
change_owner=False,
1711+
),
17011712
]
17021713
if tls_ca_chain_filename != "":
17031714
calls.insert(0, call(tls_ca_chain_filename, "fake-tls-ca-chain", 0o644))

tests/unit/test_cluster.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,17 @@ def test_render_file(peers_ips, patroni):
285285
# Ensure the file is chown'd correctly.
286286
_chown.assert_called_with(filename, uid=35, gid=35)
287287

288+
# Test when it's requested to not change the file owner.
289+
mock.reset_mock()
290+
_pwnam.reset_mock()
291+
_chmod.reset_mock()
292+
_chown.reset_mock()
293+
with patch("builtins.open", mock, create=True):
294+
patroni.render_file(filename, "rendered-content", 0o640, change_owner=False)
295+
_pwnam.assert_not_called()
296+
_chmod.assert_called_once_with(filename, 0o640)
297+
_chown.assert_not_called()
298+
288299

289300
def test_render_patroni_yml_file(peers_ips, patroni):
290301
with (

tests/unit/test_rotate_logs.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
from unittest.mock import Mock, PropertyMock, patch
4+
5+
import pytest
6+
from ops.charm import CharmBase
7+
from ops.model import ActiveStatus, Relation, WaitingStatus
8+
from ops.testing import Harness
9+
10+
from rotate_logs import RotateLogs
11+
12+
13+
class MockCharm(CharmBase):
14+
def __init__(self, *args):
15+
super().__init__(*args)
16+
17+
self.rotate_logs = RotateLogs(self)
18+
19+
@property
20+
def _peers(self) -> Relation | None:
21+
return None
22+
23+
@property
24+
def unit_peer_data(self) -> dict:
25+
"""Unit peer relation data object."""
26+
if self._peers is None:
27+
return {}
28+
29+
return self._peers.data[self.unit]
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def harness():
34+
harness = Harness(MockCharm, meta="name: test-charm")
35+
harness.begin()
36+
yield harness
37+
harness.cleanup()
38+
39+
40+
def test_start_log_rotation(harness):
41+
with (
42+
patch("builtins.open") as _open,
43+
patch("subprocess.Popen") as _popen,
44+
patch("os.path.exists") as _exists,
45+
patch.object(MockCharm, "_peers", new_callable=PropertyMock) as _peers,
46+
):
47+
# Test that nothing is done if there is already a running process.
48+
_peers.return_value = Mock(data={harness.charm.unit: {"rotate-logs-pid": "1"}})
49+
_exists.return_value = True
50+
harness.charm.rotate_logs.start_log_rotation()
51+
_popen.assert_not_called()
52+
53+
# Test that nothing is done if the charm is not in an active status.
54+
harness.charm.unit.status = WaitingStatus()
55+
_peers.return_value = Mock(data={harness.charm.unit: {}})
56+
harness.charm.rotate_logs.start_log_rotation()
57+
_popen.assert_not_called()
58+
59+
# Test that nothing is done if peer relation is not available yet.
60+
harness.charm.unit.status = ActiveStatus()
61+
_peers.return_value = None
62+
harness.charm.rotate_logs.start_log_rotation()
63+
_popen.assert_not_called()
64+
65+
# Test that nothing is done if the logrotate file does not exist.
66+
_peers.return_value = Mock(data={harness.charm.unit: {}})
67+
_exists.return_value = False
68+
harness.charm.rotate_logs.start_log_rotation()
69+
_popen.assert_not_called()
70+
71+
# Test that nothing is done if there is already a running process.
72+
_popen.return_value = Mock(pid=1)
73+
_exists.return_value = True
74+
harness.charm.rotate_logs.start_log_rotation()
75+
_popen.assert_called_once()
76+
77+
78+
def test_start_log_rotation_already_running(harness):
79+
with (
80+
patch("builtins.open") as _open,
81+
patch("subprocess.Popen") as _popen,
82+
patch("os.kill") as _kill,
83+
patch("os.path.exists") as _exists,
84+
patch.object(MockCharm, "_peers", new_callable=PropertyMock) as _peers,
85+
):
86+
harness.charm.unit.status = ActiveStatus()
87+
_peers.return_value = Mock(data={harness.charm.unit: {"rotate-logs-pid": "1234"}})
88+
_exists.return_value = True
89+
harness.charm.rotate_logs.start_log_rotation()
90+
_kill.assert_called_once_with(1234, 0)
91+
assert not _popen.called
92+
_kill.reset_mock()
93+
94+
# If process is already dead, it should restart.
95+
_kill.side_effect = OSError
96+
harness.charm.rotate_logs.start_log_rotation()
97+
_kill.assert_called_once_with(1234, 0)
98+
_popen.assert_called_once()

0 commit comments

Comments
 (0)