diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 05db51f56..2932aae2e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,8 +45,6 @@ jobs: build: name: Build charm uses: canonical/data-platform-workflows/.github/workflows/build_charms_with_cache.yaml@v4.2.3 - with: - charmcraft-snap-revision: 1349 # version 2.3.0 permissions: actions: write # Needed to manage GitHub Actions cache diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index eaeea2123..7131d8ca3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,8 +33,6 @@ jobs: build: name: Build charm uses: canonical/data-platform-workflows/.github/workflows/build_charm_without_cache.yaml@v4.2.3 - with: - charmcraft-snap-revision: 1349 # version 2.3.0 release: name: Release charm diff --git a/charmcraft.yaml b/charmcraft.yaml index cb519784d..62e4eff35 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -22,6 +22,7 @@ parts: prime: - charm_version - workload_version + - scripts build-packages: - libffi-dev - libssl-dev diff --git a/metadata.yaml b/metadata.yaml index e30ed19e1..6fd820d1a 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -43,6 +43,6 @@ resources: mysql-router-image: type: oci-image description: OCI image for mysql-router - upstream-source: ghcr.io/canonical/charmed-mysql@sha256:3b6a4a63971acec3b71a0178cd093014a695ddf7c31d91d56ebb110eec6cdbe1 + upstream-source: ghcr.io/canonical/charmed-mysql@sha256:0f5fe7d7679b1881afde24ecfb9d14a9daade790ec787087aa5d8de1d7b00b21 assumes: - k8s-api diff --git a/poetry.lock b/poetry.lock index be6cc7509..9511afb05 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2199,4 +2199,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "23bae050128bb34b801c136cd8e422960efa5de457a655ade250ab966dcef889" +content-hash = "f512effa8f8ed2535c3878b2949c7ed71c39a390cf7251e7a2a00bc3b0a10cb0" diff --git a/pyproject.toml b/pyproject.toml index 0cd8fd1cb..7cfb47d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ python = "^3.10" ops = "^2.6.0" lightkube = "^0.14.0" tenacity = "^8.2.3" +jinja2 = "^3.1.2" poetry-core = "^1.7.0" [tool.poetry.group.charm-libs.dependencies] @@ -57,6 +58,7 @@ pytest-operator-groups = {git = "https://github.com/canonical/data-platform-work juju = "^2.9.44.1" mysql-connector-python = "~8.0.33" pyyaml = "^6.0.1" +tenacity = "^8.2.2" [tool.coverage.run] diff --git a/scripts/logrotate_executor.py b/scripts/logrotate_executor.py new file mode 100644 index 000000000..7eeaeb3bb --- /dev/null +++ b/scripts/logrotate_executor.py @@ -0,0 +1,36 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Runs logrotate every minute.""" + +import subprocess +import time + + +def main(): + """Main watch and dispatch loop. + + Roughly every 60s at the top of the minute, execute logrotate. + """ + # wait till the top of the minute + time.sleep(60 - (time.time() % 60)) + start_time = time.monotonic() + + while True: + subprocess.run( + [ + "logrotate", + "-f", + "-s", + "/tmp/logrotate.status", + "/etc/logrotate.d/flush_mysqlrouter_logs", + ], + check=True, + ) + + # wait again till the top of the next minute + time.sleep(60.0 - ((time.monotonic() - start_time) % 60.0)) + + +if __name__ == "__main__": + main() diff --git a/src/abstract_charm.py b/src/abstract_charm.py index 201765aa8..7d76040ee 100644 --- a/src/abstract_charm.py +++ b/src/abstract_charm.py @@ -13,6 +13,7 @@ import container import lifecycle +import logrotate import relations.database_provides import relations.database_requires import upgrade @@ -70,6 +71,11 @@ def _tls_certificate_saved(self) -> bool: def _container(self) -> container.Container: """Workload container (snap or ROCK)""" + @property + @abc.abstractmethod + def _logrotate(self) -> logrotate.LogRotate: + """logrotate""" + @property @abc.abstractmethod def _upgrade(self) -> typing.Optional[upgrade.Upgrade]: @@ -90,10 +96,11 @@ def get_workload(self, *, event): if connection_info := self._database_requires.get_connection_info(event=event): return self._authenticated_workload_type( container_=self._container, + logrotate_=self._logrotate, connection_info=connection_info, charm_=self, ) - return self._workload_type(container_=self._container) + return self._workload_type(container_=self._container, logrotate_=self._logrotate) @staticmethod # TODO python3.10 min version: Use `list` instead of `typing.List` diff --git a/src/kubernetes_charm.py b/src/kubernetes_charm.py index 86e867a85..9a5e8d364 100755 --- a/src/kubernetes_charm.py +++ b/src/kubernetes_charm.py @@ -17,7 +17,9 @@ import ops import abstract_charm +import kubernetes_logrotate import kubernetes_upgrade +import logrotate import relations.tls import rock import upgrade @@ -53,6 +55,10 @@ def tls_certificate_saved(self) -> bool: def _container(self) -> rock.Rock: return rock.Rock(unit=self.unit) + @property + def _logrotate(self) -> logrotate.LogRotate: + return kubernetes_logrotate.LogRotate(container_=self._container) + @property def _upgrade(self) -> typing.Optional[kubernetes_upgrade.Upgrade]: try: diff --git a/src/kubernetes_logrotate.py b/src/kubernetes_logrotate.py new file mode 100644 index 000000000..f36e949a4 --- /dev/null +++ b/src/kubernetes_logrotate.py @@ -0,0 +1,45 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""logrotate implementation for k8s""" + +import logging +import pathlib + +import container +import logrotate + +logger = logging.getLogger(__name__) + + +class LogRotate(logrotate.LogRotate): + """logrotate implementation for k8s""" + + _SYSTEM_USER = "mysql" + + def __init__(self, *, container_: container.Container): + super().__init__(container_=container_) + self._logrotate_executor = self._container.path("/logrotate_executor.py") + + def enable(self) -> None: + super().enable() + + logger.debug("Copying log rotate executor script to workload container") + self._logrotate_executor.write_text( + pathlib.Path("scripts/logrotate_executor.py").read_text() + ) + logger.debug("Copied log rotate executor to workload container") + + logger.debug("Starting the logrotate executor service") + self._container.update_logrotate_executor_service(enabled=True) + logger.debug("Started the logrotate executor service") + + def disable(self) -> None: + logger.debug("Stopping the logrotate executor service") + self._container.update_logrotate_executor_service(enabled=False) + logger.debug("Stopped the logrotate executor service") + + logger.debug("Removing logrotate config and executor files") + super().disable() + self._logrotate_executor.unlink() + logger.debug("Removed logrotate config and executor files") diff --git a/src/logrotate.py b/src/logrotate.py new file mode 100644 index 000000000..70c113d32 --- /dev/null +++ b/src/logrotate.py @@ -0,0 +1,50 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""logrotate + +https://manpages.ubuntu.com/manpages/jammy/man8/logrotate.8.html +""" + +import abc +import logging +import pathlib + +import jinja2 + +import container + +logger = logging.getLogger(__name__) + + +class LogRotate(abc.ABC): + """logrotate""" + + def __init__(self, *, container_: container.Container): + self._container = container_ + + self._logrotate_config = self._container.path("/etc/logrotate.d/flush_mysqlrouter_logs") + + @property + @abc.abstractmethod + def _SYSTEM_USER(self) -> str: # noqa + """The system user that mysqlrouter runs as.""" + + def enable(self) -> None: + """Enable logrotate.""" + logger.debug("Creating logrotate config file") + + template = jinja2.Template(pathlib.Path("templates/logrotate.j2").read_text()) + + log_file_path = self._container.path("/var/log/mysqlrouter/mysqlrouter.log") + rendered = template.render( + log_file_path=str(log_file_path), + system_user=self._SYSTEM_USER, + ) + self._logrotate_config.write_text(rendered) + + logger.debug("Created logrotate config file") + + def disable(self) -> None: + """Disable logrotate.""" + self._logrotate_config.unlink() diff --git a/src/rock.py b/src/rock.py index 541f34a43..0e6db2519 100644 --- a/src/rock.py +++ b/src/rock.py @@ -62,6 +62,7 @@ class Rock(container.Container): """Workload ROCK or OCI container""" _SERVICE_NAME = "mysql_router" + _LOGROTATE_EXECUTOR_SERVICE_NAME = "logrotate_executor" def __init__(self, *, unit: ops.Unit) -> None: super().__init__(mysql_router_command="mysqlrouter", mysql_shell_command="mysqlsh") @@ -89,7 +90,6 @@ def update_mysql_router_service(self, *, enabled: bool, tls: bool = None) -> Non startup = ops.pebble.ServiceStartup.DISABLED.value layer = ops.pebble.Layer( { - "summary": "MySQL Router layer", "services": { self._SERVICE_NAME: { "override": "replace", @@ -110,6 +110,39 @@ def update_mysql_router_service(self, *, enabled: bool, tls: bool = None) -> Non else: self._container.stop(self._SERVICE_NAME) + def update_logrotate_executor_service(self, *, enabled: bool) -> None: + """Update and restart log rotate executor service. + + Args: + enabled: Whether log rotate executor service is enabled + """ + startup = ( + ops.pebble.ServiceStartup.ENABLED.value + if enabled + else ops.pebble.ServiceStartup.DISABLED.value + ) + layer = ops.pebble.Layer( + { + "services": { + self._LOGROTATE_EXECUTOR_SERVICE_NAME: { + "override": "replace", + "summary": "Logrotate executor", + "command": "python3 /logrotate_executor.py", + "startup": startup, + "user": _UNIX_USERNAME, + "group": _UNIX_USERNAME, + }, + }, + } + ) + self._container.add_layer(self._LOGROTATE_EXECUTOR_SERVICE_NAME, layer, combine=True) + # `self._container.replan()` does not stop services that have been disabled + # Use `restart()` and `stop()` instead + if enabled: + self._container.restart(self._LOGROTATE_EXECUTOR_SERVICE_NAME) + else: + self._container.stop(self._LOGROTATE_EXECUTOR_SERVICE_NAME) + # TODO python3.10 min version: Use `list` instead of `typing.List` def _run_command(self, command: typing.List[str], *, timeout: typing.Optional[int]) -> str: try: diff --git a/src/workload.py b/src/workload.py index 0abbb2c63..828f27629 100644 --- a/src/workload.py +++ b/src/workload.py @@ -13,6 +13,7 @@ import ops import container +import logrotate import mysql_shell if typing.TYPE_CHECKING: @@ -25,8 +26,11 @@ class Workload: """MySQL Router workload""" - def __init__(self, *, container_: container.Container) -> None: + def __init__( + self, *, container_: container.Container, logrotate_: logrotate.LogRotate + ) -> None: self._container = container_ + self._logrotate = logrotate_ self._router_data_directory = self._container.path("/var/lib/mysqlrouter") self._tls_key_file = self._container.router_config_directory / "custom-key.pem" self._tls_certificate_file = ( @@ -56,6 +60,7 @@ def disable(self) -> None: return logger.debug("Disabling MySQL Router service") self._container.update_mysql_router_service(enabled=False) + self._logrotate.disable() self._container.router_config_directory.rmtree() self._container.router_config_directory.mkdir() self._router_data_directory.rmtree() @@ -108,10 +113,11 @@ def __init__( self, *, container_: container.Container, + logrotate_: logrotate.LogRotate, connection_info: "relations.database_requires.ConnectionInformation", charm_: "abstract_charm.MySQLRouterCharm", ) -> None: - super().__init__(container_=container_) + super().__init__(container_=container_, logrotate_=logrotate_) self._connection_info = connection_info self._charm = charm_ @@ -218,6 +224,7 @@ def enable(self, *, tls: bool, unit_name: str) -> None: username=self._router_username, router_id=self._router_id, unit_name=unit_name ) self._container.update_mysql_router_service(enabled=True, tls=tls) + self._logrotate.enable() logger.debug("Enabled MySQL Router service") self._charm.wait_until_mysql_router_ready() diff --git a/templates/logrotate.j2 b/templates/logrotate.j2 new file mode 100644 index 000000000..fa17233a3 --- /dev/null +++ b/templates/logrotate.j2 @@ -0,0 +1,29 @@ +# Use system user +su {{ system_user }} {{ system_user }} + +# Create dedicated subdirectory for rotated files +createolddir 770 {{ system_user }} {{ system_user }} + +# Frequency of logs rotation +hourly +maxage 7 +rotate 10800 + +# Naming of rotated files should be in the format: +dateext +dateformat -%V_%H%M + +# Settings to prevent misconfigurations and unwanted behaviours +ifempty +missingok +nocompress +nomail +nosharedscripts +nocopytruncate + +{{ log_file_path }} { + olddir archive_mysqlrouter + postrotate + kill -HUP $(pidof mysqlrouter) + endscript +} diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 7e6578f5b..429ddb150 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -2,13 +2,18 @@ # See LICENSE file for licensing details. import itertools +import subprocess +import tempfile from typing import Dict, List import mysql.connector from juju.unit import Unit from pytest_operator.plugin import OpsTest +from tenacity import Retrying, stop_after_attempt, wait_fixed SERVER_CONFIG_USERNAME = "serverconfig" +CONTAINER_NAME = "mysql-router" +LOGROTATE_EXECUTOR_SERVICE = "logrotate_executor" async def execute_queries_on_unit( @@ -116,3 +121,232 @@ async def scale_application( timeout=(15 * 60), wait_for_exact_units=desired_count, ) + + +async def delete_file_or_directory_in_unit( + ops_test: OpsTest, unit_name: str, path: str, container_name: str = CONTAINER_NAME +) -> bool: + """Delete a file in the provided unit. + + Args: + ops_test: The ops test framework + unit_name: The name unit on which to delete the file from + container_name: The name of the container where the file or directory is + path: The path of file or directory to delete + + Returns: + boolean indicating success + """ + if path.strip() in ["/", "."]: + return + + return_code, _, _ = await ops_test.juju( + "ssh", + "--container", + container_name, + unit_name, + "find", + path, + "-maxdepth", + "1", + "-delete", + ) + + +async def get_process_pid( + ops_test: OpsTest, unit_name: str, container_name: str, process: str +) -> int: + """Return the pid of a process running in a given unit. + + Args: + ops_test: The ops test object passed into every test case + unit_name: The name of the unit + container_name: The name of the container to get the process pid from + process: The process name to search for + Returns: + A integer for the process id + """ + try: + _, raw_pid, _ = await ops_test.juju("ssh", unit_name, "pgrep", "-x", process) + pid = int(raw_pid.strip()) + + return pid + except Exception: + return None + + +async def write_content_to_file_in_unit( + ops_test: OpsTest, unit: Unit, path: str, content: str, container_name: str = CONTAINER_NAME +) -> None: + """Write content to the file in the provided unit. + + Args: + ops_test: The ops test framework + unit: THe unit in which to write to file in + path: The path at which to write the content to + content: The content to write to the file + container_name: The container where to write the file + """ + pod_name = unit.name.replace("/", "-") + + with tempfile.NamedTemporaryFile(mode="w") as temp_file: + temp_file.write(content) + temp_file.flush() + + subprocess.run( + [ + "kubectl", + "cp", + "-n", + ops_test.model.info.name, + "-c", + container_name, + temp_file.name, + f"{pod_name}:{path}", + ], + check=True, + ) + + +async def read_contents_from_file_in_unit( + ops_test: OpsTest, unit: Unit, path: str, container_name: str = CONTAINER_NAME +) -> str: + """Read contents from file in the provided unit. + + Args: + ops_test: The ops test framework + unit: The unit in which to read file from + path: The path from which to read content from + container_name: The container where the file exists + + Returns: + the contents of the file + """ + pod_name = unit.name.replace("/", "-") + + with tempfile.NamedTemporaryFile(mode="r+") as temp_file: + subprocess.run( + [ + "kubectl", + "cp", + "-n", + ops_test.model.info.name, + "-c", + container_name, + f"{pod_name}:{path}", + temp_file.name, + ], + check=True, + ) + + temp_file.seek(0) + + contents = "" + for line in temp_file: + contents += line + contents += "\n" + + return contents + + +async def ls_la_in_unit( + ops_test: OpsTest, unit_name: str, directory: str, container_name: str = CONTAINER_NAME +) -> list[str]: + """Returns the output of ls -la in unit. + + Args: + ops_test: The ops test framework + unit_name: The name of unit in which to run ls -la + path: The path from which to run ls -la + container_name: The container where to run ls -la + + Returns: + a list of files returned by ls -la + """ + return_code, output, _ = await ops_test.juju( + "ssh", "--container", container_name, unit_name, "ls", "-la", directory + ) + assert return_code == 0 + + ls_output = output.split("\n")[1:] + + return [ + line.strip("\r") + for line in ls_output + if len(line.strip()) > 0 and line.split()[-1] not in [".", ".."] + ] + + +async def stop_running_log_rotate_executor(ops_test: OpsTest, unit_name: str): + """Stop running the log rotate executor script. + + Args: + ops_test: The ops test object passed into every test case + unit_name: The name of the unit to be tested + """ + # send KILL signal to log rotate executor, which trigger shutdown process + await ops_test.juju( + "ssh", + "--container", + CONTAINER_NAME, + unit_name, + "pebble", + "stop", + LOGROTATE_EXECUTOR_SERVICE, + ) + + +async def stop_running_flush_mysqlrouter_job(ops_test: OpsTest, unit_name: str) -> None: + """Stop running any logrotate jobs that may have been triggered by cron. + + Args: + ops_test: The ops test object passed into every test case + unit_name: The name of the unit to be tested + """ + # send KILL signal to log rotate process, which trigger shutdown process + await ops_test.juju( + "ssh", + "--container", + CONTAINER_NAME, + unit_name, + "pkill", + "-9", + "-f", + "logrotate -f /etc/logrotate.d/flush_mysqlrouter_logs", + ) + + # hold execution until process is stopped + for attempt in Retrying(reraise=True, stop=stop_after_attempt(45), wait=wait_fixed(2)): + with attempt: + if await get_process_pid(ops_test, unit_name, CONTAINER_NAME, "logrotate"): + raise Exception("Failed to stop the flush_mysql_logs logrotate process.") + + +async def rotate_mysqlrouter_logs(ops_test: OpsTest, unit_name: str) -> None: + """Dispatch the custom event to run logrotate. + + Args: + ops_test: The ops test object passed into every test case + unit_name: The name of the unit to be tested + """ + pod_label = unit_name.replace("/", "-") + + subprocess.run( + [ + "kubectl", + "exec", + "-n", + ops_test.model.info.name, + "-it", + pod_label, + "--container", + CONTAINER_NAME, + "--", + "su", + "-", + "mysql", + "-c", + "logrotate -f -s /tmp/logrotate.status /etc/logrotate.d/flush_mysqlrouter_logs", + ], + check=True, + ) diff --git a/tests/integration/test_log_rotation.py b/tests/integration/test_log_rotation.py new file mode 100644 index 000000000..e36012002 --- /dev/null +++ b/tests/integration/test_log_rotation.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +from .helpers import ( + delete_file_or_directory_in_unit, + ls_la_in_unit, + read_contents_from_file_in_unit, + rotate_mysqlrouter_logs, + stop_running_flush_mysqlrouter_job, + stop_running_log_rotate_executor, + write_content_to_file_in_unit, +) + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) + +MYSQL_APP_NAME = "mysql-k8s" +MYSQL_ROUTER_APP_NAME = "mysql-router-k8s" +APPLICATION_APP_NAME = "mysql-test-app" +SLOW_TIMEOUT = 15 * 60 +MODEL_CONFIG = {"logging-config": "=INFO;unit=DEBUG"} + + +@pytest.mark.group(1) +@pytest.mark.abort_on_fail +async def test_log_rotation(ops_test: OpsTest): + """Test log rotation.""" + # Build and deploy applications + mysqlrouter_charm = await ops_test.build_charm(".") + await ops_test.model.set_config(MODEL_CONFIG) + + mysqlrouter_resources = { + "mysql-router-image": METADATA["resources"]["mysql-router-image"]["upstream-source"] + } + + logger.info("Deploying mysql, mysqlrouter and application") + applications = await asyncio.gather( + ops_test.model.deploy( + MYSQL_APP_NAME, + channel="8.0/edge", + application_name=MYSQL_APP_NAME, + config={"profile": "testing"}, + series="jammy", + num_units=3, + trust=True, # Necessary after a6f1f01: Fix/endpoints as k8s services (#142) + ), + ops_test.model.deploy( + mysqlrouter_charm, + application_name=MYSQL_ROUTER_APP_NAME, + series="jammy", + resources=mysqlrouter_resources, + num_units=1, + trust=True, + ), + ops_test.model.deploy( + APPLICATION_APP_NAME, + channel="latest/edge", + application_name=APPLICATION_APP_NAME, + series="jammy", + num_units=1, + ), + ) + + mysql_app, mysql_router_app, application_app = applications + unit = mysql_router_app.units[0] + + async with ops_test.fast_forward(): + logger.info("Waiting for mysqlrouter to be in BlockedStatus") + await ops_test.model.block_until( + lambda: ops_test.model.applications[MYSQL_ROUTER_APP_NAME].status == "blocked", + timeout=SLOW_TIMEOUT, + ) + + logger.info("Relating mysql, mysqlrouter and application") + # Relate the database with mysqlrouter + await ops_test.model.relate( + f"{MYSQL_ROUTER_APP_NAME}:backend-database", f"{MYSQL_APP_NAME}:database" + ) + # Relate mysqlrouter with application next + await ops_test.model.relate( + f"{APPLICATION_APP_NAME}:database", f"{MYSQL_ROUTER_APP_NAME}:database" + ) + + await asyncio.gather( + ops_test.model.block_until(lambda: mysql_app.status == "active", timeout=SLOW_TIMEOUT), + ops_test.model.block_until( + lambda: mysql_router_app.status == "active", timeout=SLOW_TIMEOUT + ), + ops_test.model.block_until( + lambda: application_app.status == "active", timeout=SLOW_TIMEOUT + ), + ) + + logger.info("Stopping the logrotate executor pebble service") + await stop_running_log_rotate_executor(ops_test, unit.name) + + logger.info("Stopping any running logrotate jobs") + await stop_running_flush_mysqlrouter_job(ops_test, unit.name) + + logger.info("Removing existing archive directory") + await delete_file_or_directory_in_unit( + ops_test, + unit.name, + "/var/log/mysqlrouter/archive_mysqlrouter/", + ) + + logger.info("Writing some data mysqlrouter log file") + log_path = "/var/log/mysqlrouter/mysqlrouter.log" + await write_content_to_file_in_unit(ops_test, unit, log_path, "test mysqlrouter content\n") + + logger.info("Ensuring only log files exist") + ls_la_output = await ls_la_in_unit(ops_test, unit.name, "/var/log/mysqlrouter/") + + assert len(ls_la_output) == 1, f"❌ files other than log files exist {ls_la_output}" + directories = [line.split()[-1] for line in ls_la_output] + assert directories == [ + "mysqlrouter.log" + ], f"❌ file other than logs files exist: {ls_la_output}" + + logger.info("Executing logrotate") + await rotate_mysqlrouter_logs(ops_test, unit.name) + + logger.info("Ensuring log files and archive directories exist") + ls_la_output = await ls_la_in_unit(ops_test, unit.name, "/var/log/mysqlrouter/") + + assert ( + len(ls_la_output) == 2 + ), f"❌ unexpected files/directories in log directory: {ls_la_output}" + directories = [line.split()[-1] for line in ls_la_output] + assert sorted(directories) == sorted( + ["mysqlrouter.log", "archive_mysqlrouter"] + ), f"❌ unexpected files/directories in log directory: {ls_la_output}" + + logger.info("Ensuring log files was rotated") + file_contents = await read_contents_from_file_in_unit( + ops_test, unit, "/var/log/mysqlrouter/mysqlrouter.log" + ) + assert ( + "test mysqlrouter content" not in file_contents + ), "❌ log file mysqlrouter.log not rotated" + + ls_la_output = await ls_la_in_unit( + ops_test, + unit.name, + "/var/log/mysqlrouter/archive_mysqlrouter/", + ) + assert len(ls_la_output) == 1, f"❌ more than 1 file in archive directory: {ls_la_output}" + + filename = ls_la_output[0].split()[-1] + file_contents = await read_contents_from_file_in_unit( + ops_test, + unit, + f"/var/log/mysqlrouter/archive_mysqlrouter/{filename}", + ) + assert "test mysqlrouter content" in file_contents, "❌ log file mysqlrouter.log not rotated" diff --git a/tox.ini b/tox.ini index 1e9ad9f34..f8254220d 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,8 @@ env_list = lint, unit [vars] src_path = {tox_root}/src tests_path = {tox_root}/tests -all_path = {[vars]src_path} {[vars]tests_path} +scripts_path = {tox_root}/scripts +all_path = {[vars]src_path} {[vars]tests_path} {[vars]scripts_path} [testenv] set_env = @@ -31,7 +32,8 @@ commands_pre = # is pending review. python -c 'import pathlib; import shutil; import subprocess; git_hash=subprocess.run(["git", "describe", "--always", "--dirty"], capture_output=True, check=True, encoding="utf-8").stdout; file = pathlib.Path("charm_version"); shutil.copy(file, pathlib.Path("charm_version.backup")); version = file.read_text().strip(); file.write_text(f"{version}+{git_hash}")' - poetry export --only main,charm-libs --output requirements.txt + # `--without-hashes` workaround for https://github.com/canonical/charmcraft/issues/1179 + poetry export --only main,charm-libs --output requirements.txt --without-hashes commands = build: charmcraft pack {posargs} commands_post =