Skip to content

Commit 24fea96

Browse files
feat: DPE-5656 log rotation new options (#597)
* feat: add new log rotation configuration options * lint fixes * fix/add unit tests * minor lint fixes * test fixes * Add missing types * fix typo Co-authored-by: Sinclert Pérez <[email protected]> * ditched custom event to simplify implementation * test fixes * better description plus typo fix * audit_log_filter is not in use, disabling it completely --------- Co-authored-by: Sinclert Pérez <[email protected]>
1 parent 9530600 commit 24fea96

File tree

13 files changed

+274
-64
lines changed

13 files changed

+274
-64
lines changed

config.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ options:
4444
description: Number of days for binary logs retention
4545
type: int
4646
default: 7
47+
logs_audit_policy:
48+
description: |
49+
Audit log policy. Allowed values are: "all", "logins" (default), "queries".
50+
Ref. at https://docs.percona.com/percona-server/8.0/audit-log-plugin.html#audit_log_policy
51+
type: string
52+
default: logins
53+
logs_retention_period:
54+
description: |
55+
Specifies the retention period for rotated logs, in days. Accepts an integer value of 3 or
56+
greater, or the special value "auto". When set to "auto" (default), the retention period is
57+
3 days, except when COS-related, where it is 1 day
58+
type: string
59+
default: auto
4760
# Experimental features
4861
experimental-max-connections:
4962
type: int

lib/charms/mysql/v0/mysql.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def wait_until_mysql_connection(self) -> None:
133133
# Increment this major API version when introducing breaking changes
134134
LIBAPI = 0
135135

136-
LIBPATCH = 81
136+
LIBPATCH = 82
137137

138138
UNIT_TEARDOWN_LOCKNAME = "unit-teardown"
139139
UNIT_ADD_LOCKNAME = "unit-add"
@@ -930,6 +930,7 @@ def render_mysqld_configuration( # noqa: C901
930930
profile: str,
931931
audit_log_enabled: bool,
932932
audit_log_strategy: str,
933+
audit_log_policy: str,
933934
memory_limit: Optional[int] = None,
934935
experimental_max_connections: Optional[int] = None,
935936
binlog_retention_days: int,
@@ -1000,8 +1001,7 @@ def render_mysqld_configuration( # noqa: C901
10001001
"general_log_file": f"{snap_common}/var/log/mysql/general.log",
10011002
"slow_query_log_file": f"{snap_common}/var/log/mysql/slow.log",
10021003
"binlog_expire_logs_seconds": f"{binlog_retention_seconds}",
1003-
"loose-audit_log_filter": "OFF",
1004-
"loose-audit_log_policy": "LOGINS",
1004+
"loose-audit_log_policy": audit_log_policy.upper(),
10051005
"loose-audit_log_file": f"{snap_common}/var/log/mysql/audit.log",
10061006
}
10071007

@@ -2950,8 +2950,8 @@ def retrieve_backup_with_xbcloud(
29502950
temp_restore_directory: str,
29512951
xbcloud_location: str,
29522952
xbstream_location: str,
2953-
user=None,
2954-
group=None,
2953+
user: Optional[str] = None,
2954+
group: Optional[str] = None,
29552955
) -> Tuple[str, str, str]:
29562956
"""Retrieve the specified backup from S3."""
29572957
nproc_command = ["nproc"]
@@ -3017,8 +3017,8 @@ def prepare_backup_for_restore(
30173017
backup_location: str,
30183018
xtrabackup_location: str,
30193019
xtrabackup_plugin_dir: str,
3020-
user=None,
3021-
group=None,
3020+
user: Optional[str] = None,
3021+
group: Optional[str] = None,
30223022
) -> Tuple[str, str]:
30233023
"""Prepare the backup in the provided dir for restore."""
30243024
try:
@@ -3058,8 +3058,8 @@ def prepare_backup_for_restore(
30583058
def empty_data_files(
30593059
self,
30603060
mysql_data_directory: str,
3061-
user=None,
3062-
group=None,
3061+
user: Optional[str] = None,
3062+
group: Optional[str] = None,
30633063
) -> None:
30643064
"""Empty the mysql data directory in preparation of backup restore."""
30653065
empty_data_files_command = [
@@ -3095,8 +3095,8 @@ def restore_backup(
30953095
defaults_config_file: str,
30963096
mysql_data_directory: str,
30973097
xtrabackup_plugin_directory: str,
3098-
user=None,
3099-
group=None,
3098+
user: Optional[str] = None,
3099+
group: Optional[str] = None,
31003100
) -> Tuple[str, str]:
31013101
"""Restore the provided prepared backup."""
31023102
restore_backup_command = [
@@ -3129,8 +3129,8 @@ def restore_backup(
31293129
def delete_temp_restore_directory(
31303130
self,
31313131
temp_restore_directory: str,
3132-
user=None,
3133-
group=None,
3132+
user: Optional[str] = None,
3133+
group: Optional[str] = None,
31343134
) -> None:
31353135
"""Delete the temp restore directory from the mysql data directory."""
31363136
logger.info(f"Deleting temp restore directory in {temp_restore_directory}")

src/charm.py

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
)
2828
from charms.mysql.v0.backups import S3_INTEGRATOR_RELATION_NAME, MySQLBackups
2929
from charms.mysql.v0.mysql import (
30-
BYTES_1MB,
3130
Error,
3231
MySQLAddInstanceToClusterError,
3332
MySQLCharmBase,
@@ -75,7 +74,6 @@
7574
from constants import (
7675
BACKUPS_PASSWORD_KEY,
7776
BACKUPS_USERNAME,
78-
CHARMED_MYSQL_COMMON_DIRECTORY,
7977
CHARMED_MYSQL_SNAP_NAME,
8078
CHARMED_MYSQLD_SERVICE,
8179
CLUSTER_ADMIN_PASSWORD_KEY,
@@ -97,6 +95,7 @@
9795
from flush_mysql_logs import FlushMySQLLogsCharmEvents, MySQLLogs
9896
from hostname_resolution import MySQLMachineHostnameResolution
9997
from ip_address_observer import IPAddressChangeCharmEvents
98+
from log_rotation_setup import LogRotationSetup
10099
from mysql_vm_helpers import (
101100
MySQL,
102101
MySQLCreateCustomMySQLDConfigError,
@@ -189,6 +188,8 @@ def __init__(self, *args):
189188
self.framework.observe(
190189
self.on[COS_AGENT_RELATION_NAME].relation_broken, self._on_cos_agent_relation_broken
191190
)
191+
192+
self.log_rotation_setup = LogRotationSetup(self)
192193
self.s3_integrator = S3Requirer(self, S3_INTEGRATOR_RELATION_NAME)
193194
self.backups = MySQLBackups(self, self.s3_integrator)
194195
self.hostname_resolution = MySQLMachineHostnameResolution(self)
@@ -284,25 +285,12 @@ def _on_config_changed(self, _) -> None:
284285
return
285286

286287
# render the new config
287-
memory_limit_bytes = (self.config.profile_limit_memory or 0) * BYTES_1MB
288-
new_config_content, new_config_dict = self._mysql.render_mysqld_configuration(
289-
profile=self.config.profile,
290-
audit_log_enabled=self.config.plugin_audit_enabled,
291-
audit_log_strategy=self.config.plugin_audit_strategy,
292-
snap_common=CHARMED_MYSQL_COMMON_DIRECTORY,
293-
memory_limit=memory_limit_bytes,
294-
experimental_max_connections=self.config.experimental_max_connections,
295-
binlog_retention_days=self.config.binlog_retention_days,
296-
)
288+
new_config_dict = self._mysql.write_mysqld_config()
297289

298290
changed_config = compare_dictionaries(previous_config, new_config_dict)
299291

300-
logger.info("Persisting configuration changes to file")
301-
# always persist config to file
302-
self._mysql.write_content_to_file(
303-
path=MYSQLD_CUSTOM_CONFIG_FILE, content=new_config_content
304-
)
305-
self._mysql.setup_logrotate_and_cron(self.text_logs)
292+
# Override log rotation
293+
self.log_rotation_setup.setup()
306294

307295
if (
308296
self.mysql_config.keys_requires_restart(changed_config)
@@ -312,9 +300,9 @@ def _on_config_changed(self, _) -> None:
312300
if "loose-audit_log_format" in changed_config:
313301
# plugins are manipulated on running daemon
314302
if self.config.plugin_audit_enabled:
315-
self._mysql.install_plugins(["audit_log", "audit_log_filter"])
303+
self._mysql.install_plugins(["audit_log"])
316304
else:
317-
self._mysql.uninstall_plugins(["audit_log", "audit_log_filter"])
305+
self._mysql.uninstall_plugins(["audit_log"])
318306

319307
self.on[f"{self.restart.name}"].acquire_lock.emit()
320308

@@ -325,7 +313,9 @@ def _on_config_changed(self, _) -> None:
325313
if config not in new_config_dict:
326314
# skip removed configs
327315
continue
328-
self._mysql.set_dynamic_variable(config, new_config_dict[config])
316+
self._mysql.set_dynamic_variable(
317+
config.removeprefix("loose-"), new_config_dict[config]
318+
)
329319

330320
def _on_start(self, event: StartEvent) -> None:
331321
"""Handle the start event.
@@ -650,7 +640,7 @@ def get_unit_hostname(self, unit_name: Optional[str] = None) -> str:
650640
"""Get the hostname of the unit."""
651641
if unit_name:
652642
unit = self.model.get_unit(unit_name)
653-
return self.peers.data[unit]["instance-hostname"].split(":")[0]
643+
return self.peers.data[unit]["instance-hostname"].split(":")[0] # type: ignore
654644
return self.unit_peer_data["instance-hostname"].split(":")[0]
655645

656646
@property
@@ -703,12 +693,12 @@ def workload_initialise(self) -> None:
703693
self.hostname_resolution.update_etc_hosts(None)
704694

705695
self._mysql.write_mysqld_config()
706-
self._mysql.setup_logrotate_and_cron(self.text_logs)
696+
self.log_rotation_setup.setup()
707697
self._mysql.reset_root_password_and_start_mysqld()
708698
self._mysql.configure_mysql_users()
709699

710700
if self.config.plugin_audit_enabled:
711-
self._mysql.install_plugins(["audit_log", "audit_log_filter"])
701+
self._mysql.install_plugins(["audit_log"])
712702

713703
current_mysqld_pid = self._mysql.get_pid_of_port_3306()
714704
self._mysql.configure_instance()

src/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class CharmConfig(BaseConfigModel):
7070
binlog_retention_days: int
7171
plugin_audit_enabled: bool
7272
plugin_audit_strategy: str
73+
logs_audit_policy: str
74+
logs_retention_period: str
7375

7476
@validator("profile")
7577
@classmethod
@@ -142,3 +144,22 @@ def plugin_audit_strategy_validator(cls, value: str) -> Optional[str]:
142144
raise ValueError("Value not one of 'async' or 'semi-async'")
143145

144146
return value
147+
148+
@validator("logs_audit_policy")
149+
@classmethod
150+
def logs_audit_policy_validator(cls, value: str) -> Optional[str]:
151+
"""Check values for audit log policy."""
152+
valid_values = ["all", "logins", "queries"]
153+
if value not in valid_values:
154+
raise ValueError(f"logs_audit_policy not one of {', '.join(valid_values)}")
155+
156+
return value
157+
158+
@validator("logs_retention_period")
159+
@classmethod
160+
def logs_retention_period_validator(cls, value: str) -> str:
161+
"""Check logs retention period."""
162+
if (value.isalpha() and value != "auto") or (value.isdigit() and int(value) < 3):
163+
raise ValueError("logs_retention_period must be >= 3 or `auto`")
164+
165+
return value

src/log_rotation_setup.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Handler for log rotation setup in relation to COS."""
5+
6+
import logging
7+
import typing
8+
from pathlib import Path
9+
10+
import yaml
11+
from ops.framework import Object
12+
13+
from constants import COS_AGENT_RELATION_NAME
14+
15+
if typing.TYPE_CHECKING:
16+
from charm import MySQLOperatorCharm
17+
18+
logger = logging.getLogger(__name__)
19+
20+
_POSITIONS_FILE = "/var/snap/grafana-agent/current/grafana-agent-positions/log_file_scraper.yml"
21+
_LOGS_SYNCED = "logs_synced"
22+
23+
24+
class LogRotationSetup(Object):
25+
"""Configure logrotation settings in relation to COS integration."""
26+
27+
def __init__(self, charm: "MySQLOperatorCharm"):
28+
super().__init__(charm, "log-rotation-setup")
29+
30+
self.charm = charm
31+
32+
self.framework.observe(self.charm.on.update_status, self._update_logs_rotation)
33+
self.framework.observe(
34+
self.charm.on[COS_AGENT_RELATION_NAME].relation_created, self._cos_relation_created
35+
)
36+
self.framework.observe(
37+
self.charm.on[COS_AGENT_RELATION_NAME].relation_broken, self._cos_relation_broken
38+
)
39+
40+
@property
41+
def _logs_are_syncing(self):
42+
return self.charm.unit_peer_data.get(_LOGS_SYNCED) == "true"
43+
44+
def setup(self):
45+
"""Setup log rotation."""
46+
# retention setting
47+
if self.charm.config.logs_retention_period == "auto":
48+
retention_period = 1 if self._logs_are_syncing else 3
49+
else:
50+
retention_period = int(self.charm.config.logs_retention_period)
51+
52+
# compression setting
53+
compress = self._logs_are_syncing or not self.charm.has_cos_relation
54+
55+
self.charm._mysql.setup_logrotate_and_cron(
56+
retention_period, self.charm.text_logs, compress
57+
)
58+
59+
def _update_logs_rotation(self, _):
60+
"""Check for log rotation auto configuration handler.
61+
62+
Reconfigure log rotation if promtail/gagent start sync.
63+
"""
64+
if not self.model.get_relation(COS_AGENT_RELATION_NAME):
65+
return
66+
67+
if self._logs_are_syncing:
68+
# reconfiguration done
69+
return
70+
71+
positions_file = Path(_POSITIONS_FILE)
72+
73+
not_started_msg = "Log syncing not yet started."
74+
if not positions_file.exists():
75+
logger.debug(not_started_msg)
76+
return
77+
78+
with open(positions_file, "r") as pos_fd:
79+
positions = yaml.safe_load(pos_fd.read())
80+
81+
if sync_files := positions.get("positions"):
82+
for log_file, line in sync_files.items():
83+
if "mysql" in log_file and int(line) > 0:
84+
break
85+
else:
86+
logger.debug(not_started_msg)
87+
return
88+
else:
89+
logger.debug(not_started_msg)
90+
return
91+
92+
logger.info("Reconfigure log rotation after logs upload started")
93+
self.charm.unit_peer_data[_LOGS_SYNCED] = "true"
94+
self.setup()
95+
96+
def _cos_relation_created(self, _):
97+
"""Handle relation created."""
98+
logger.info("Reconfigure log rotation on cos relation created")
99+
self.setup()
100+
101+
def _cos_relation_broken(self, _):
102+
"""Unset auto value for log retention."""
103+
logger.info("Reconfigure log rotation after logs upload stops")
104+
105+
del self.charm.unit_peer_data[_LOGS_SYNCED]
106+
self.setup()

0 commit comments

Comments
 (0)