diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 92261d6..3d11bfa 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -26,6 +26,7 @@ jobs: "test_mail.py", "test_storage.py", "test_tls.py", + "test_ha.py", ] allure-report: if: ${{ !cancelled() && github.event_name == 'schedule' }} diff --git a/docs/release-notes/artifacts/pr-4-ha.yaml b/docs/release-notes/artifacts/pr-4-ha.yaml new file mode 100644 index 0000000..c20101b --- /dev/null +++ b/docs/release-notes/artifacts/pr-4-ha.yaml @@ -0,0 +1,18 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +# Version of the artifact schema +version_schema: 2 + +changes: +- title: Added HA support with SSH key exchange and force-sync action + author: alithethird + type: major + description: Added high availability support for Dovecot with automatic SSH key exchange between primary and secondary units via the replicas peer relation, doveadm backup (dsync)-based mail synchronization via cron, and a force-sync Juju action for on-demand replication. + urls: + pr: + - "https://github.com/canonical/mailserver-operators/pull/15" + related_doc: + related_issue: + visibility: public + highlight: true diff --git a/docs/release-notes/index.rst b/docs/release-notes/index.rst index 23c635d..9ca49e0 100644 --- a/docs/release-notes/index.rst +++ b/docs/release-notes/index.rst @@ -35,3 +35,4 @@ Releases release-notes-0002 release-notes-0003 release-notes-0004 + release-notes-0005 diff --git a/docs/release-notes/release-notes-0005.rst b/docs/release-notes/release-notes-0005.rst new file mode 100644 index 0000000..a09e423 --- /dev/null +++ b/docs/release-notes/release-notes-0005.rst @@ -0,0 +1,57 @@ +.. _release_notes_release_notes_0005: + +Dovecot release notes – 2.3/edge +================================= + +These release notes cover new features and changes in Dovecot. + +Main features: + +* Added HA support with SSH key exchange and ``force-sync`` action. + +See our :ref:`Release policy and schedule `. + +Requirements and compatibility +------------------------------- + +The charm operates Dovecot 2.3. + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Software + - Required version + * - Juju + - 3.x + * - Ubuntu + - 24.04 + +Updates +------- + +The following major and minor features were added in this release. + +HA support with SSH key exchange and force-sync action +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +High-availability support was added to the Dovecot charm. The charm now +exchanges SSH keys between primary and secondary units during installation, +enabling passwordless root SSH access required for mail pool synchronisation. +A new ``force-sync`` action was introduced, allowing operators to trigger an +immediate synchronisation of the mail pool from the primary unit to the +secondary unit on demand. + +Relevant links: + +* `PR `_ + +Bug fixes +--------- + +No bug fixes in this release. + +Known issues +------------ + +No known issues. diff --git a/dovecot-charm/charmcraft.yaml b/dovecot-charm/charmcraft.yaml index 7ef911e..616d42e 100644 --- a/dovecot-charm/charmcraft.yaml +++ b/dovecot-charm/charmcraft.yaml @@ -97,3 +97,5 @@ actions: description: The queue to clear (deferred or all). default: deferred enum: [deferred, all] + force-sync: + description: Manually trigger synchronization of mail to the secondary unit. diff --git a/dovecot-charm/src/charm.py b/dovecot-charm/src/charm.py index dcde3b9..0edca8c 100644 --- a/dovecot-charm/src/charm.py +++ b/dovecot-charm/src/charm.py @@ -22,6 +22,7 @@ from ops.main import main from ops.model import BlockedStatus, MaintenanceStatus +import ha from constants import ( DOVECOT_CONF_TARGET, DOVECOT_CONF_TEMPLATE, @@ -33,11 +34,12 @@ PROCMAILRC_TARGET, PROCMAILRC_TEMPLATE, REQUIRED_PACKAGES, + SYNC_TO_SECONDARY_TARGET, TEMPLATES_DIR, TLS_CERT_DIR, ) from dovecot_config import DovecotConfig, DovecotConfigInvalidError, DovecotConfigSecretError -from exceptions import CharmBlockedError, ConfigurationError +from exceptions import CharmBlockedError, ConfigurationError, HASetupError from storage import ensure_storage_ready, teardown_detaching_storage logger = logging.getLogger(__name__) @@ -49,7 +51,6 @@ class DovecotCharm(CharmBase): def __init__(self, *args): super().__init__(*args) - # Events self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.start, self._reconcile) self.framework.observe(self.on.config_changed, self._reconcile) @@ -57,17 +58,18 @@ def __init__(self, *args): self.framework.observe(self.on.clear_queue_action, self._on_clear_queue_action) self.framework.observe(self.on.mail_data_storage_attached, self._reconcile) self.framework.observe(self.on.mail_data_storage_detaching, self._reconcile) + self.framework.observe(self.on[PEER_RELATION_NAME].relation_changed, self._reconcile) + self.framework.observe(self.on.force_sync_action, self._on_force_sync) self.framework.observe( self.on[PEER_RELATION_NAME].relation_created, self._on_peer_relation_created, ) - # Template system + self.jinja = jinja2.Environment( loader=jinja2.FileSystemLoader(TEMPLATES_DIR), autoescape=True ) - # TLS certificates integration self._tls = None mailname = self.config.get("mailname", "") if mailname: @@ -107,6 +109,23 @@ def _on_peer_relation_created(self, event): relation_data = event.relation.data[self.unit] relation_data["unit-name"] = self.unit.name + @property + def _is_primary(self) -> bool: + """Return True if this unit is the configured primary unit.""" + return self.unit.name == self.config.get("primary-unit", "") + + @property + def _secondary_hostname(self) -> typing.Optional[str]: + """Return the hostname of the first remote peer unit, or None.""" + relation = self.model.get_relation(PEER_RELATION_NAME) + if not relation: + return None + for unit in relation.units: + hostname = relation.data[unit].get("hostname") + if hostname: + return hostname + return None + def _get_dovecot_config(self) -> DovecotConfig: """Craft the DovecotConfig from charm configuration and validate it. @@ -135,7 +154,7 @@ def _on_install(self, event): self._reconcile(event) def _reconcile(self, event): - """Reconcile charm state for install, upgrade, config-changed, and storage events.""" + """Reconcile charm state.""" self.unit.status = MaintenanceStatus("Configuring charm") try: dovecot_config = self._get_dovecot_config() @@ -154,11 +173,21 @@ def _reconcile(self, event): except ConfigurationError as e: self.unit.status = BlockedStatus(str(e)) return + try: + ha.setup_ssh_keys(self) + ha.sync_authorized_keys(self) + ha.sync_known_hosts(self) + if self._is_primary: + ha.install_mail_sync_script(self) + ha.setup_mail_sync_cronjob(self, dovecot_config) + except HASetupError as e: + self.unit.status = BlockedStatus(str(e)) + return self._open_ports() self.unit.status = ops.ActiveStatus() def _install(self): - """Perform basic installation.""" + """Install required packages and set up mailname.""" self.unit.status = MaintenanceStatus("Installing required dependencies") apt.update() apt.add_package(REQUIRED_PACKAGES) @@ -269,6 +298,45 @@ def _on_clear_queue_action(self, event): logger.exception(f"Failed to clear Postfix queue: {e.stderr}") event.fail(f"Failed to run postsuper: {e.stderr}") + def _on_force_sync(self, event): + """Force synchronization with secondary unit.""" + if not self._is_primary: + event.fail("This action can only be run on the primary unit.") + return + + if not self._secondary_hostname: + event.fail("No secondary unit found to sync to.") + return + + if not Path(SYNC_TO_SECONDARY_TARGET).exists(): + event.fail( + "Sync script not yet installed. " + "Please wait for the charm to reach active state before running force-sync." + ) + return + + try: + cmd = [SYNC_TO_SECONDARY_TARGET] + logger.info(f"Running manual sync: {' '.join(cmd)}") + subprocess.run(cmd, check=True, capture_output=True, text=True) + event.set_results({"result": "Sync completed successfully"}) + except subprocess.CalledProcessError as e: + parts = [ + f"Sync failed with exit code {e.returncode} while running " + f"{' '.join(e.cmd) if isinstance(e.cmd, (list, tuple)) else e.cmd}" + ] + if e.stderr and e.stderr.strip(): + parts.append(f"stderr: {e.stderr.strip()}") + if e.stdout and e.stdout.strip(): + parts.append(f"stdout: {e.stdout.strip()}") + msg = ". ".join(parts) + logger.error(msg) + event.fail(msg) + except FileNotFoundError as e: + msg = f"Sync failed: {e}" + logger.error(msg) + event.fail(msg) + def _setup_tls(self, dovecot_config: DovecotConfig) -> None: """Write TLS cert+key to disk from the certificates relation. diff --git a/dovecot-charm/src/constants.py b/dovecot-charm/src/constants.py index 4df5950..99de573 100644 --- a/dovecot-charm/src/constants.py +++ b/dovecot-charm/src/constants.py @@ -47,3 +47,15 @@ STORAGE_DEV_PATH_FILE = "/var/lib/dovecot-charm/storage-dev-path" TLS_CERT_DIR = Path("/etc/dovecot/private") + +# HA sync paths +SYNC_TO_SECONDARY_TARGET = "/usr/local/bin/sync-to-secondary.sh" +SYNC_TO_SECONDARY_CRONJOB_TARGET = "/etc/cron.d/sync-to-secondary" +SYNC_TO_SECONDARY_TEMPLATE = "sync-to-secondary.sh.tmpl" +SYNC_TO_SECONDARY_CRONJOB_TEMPLATE = "sync-to-secondary_cron.tmpl" + +SSHD_CONFIG = Path("/etc/ssh/sshd_config") +SSHD_DROPIN_DIR = Path("/etc/ssh/sshd_config.d") +SSHD_DROPIN_FILE = SSHD_DROPIN_DIR / "99-dovecot-ha.conf" +SSH_DIR = Path("/root/.ssh") +SSH_HOST_KEY_FILE = Path("/etc/ssh/ssh_host_ed25519_key.pub") diff --git a/dovecot-charm/src/dovecot_config.py b/dovecot-charm/src/dovecot_config.py index 413baf8..5b870fa 100644 --- a/dovecot-charm/src/dovecot_config.py +++ b/dovecot-charm/src/dovecot_config.py @@ -4,6 +4,7 @@ """Dovecot charm configuration.""" import logging +import re from typing import TYPE_CHECKING from ops import ModelError, SecretNotFoundError @@ -62,6 +63,10 @@ class DovecotConfig(BaseModel): "", description="LUKS passphrase from the luks-key secret. Required when luks_auto_provisioning is true.", ) + sync_schedule: str = Field( + "*/30 * * * *", + description="Cron schedule for syncing mail from primary to secondary units.", + ) @field_validator("luks_key", mode="after") @classmethod @@ -72,6 +77,21 @@ def _validate_luks_key(cls, value: str, info: ValidationInfo) -> str: raise ValueError("luks-key secret must be set when luks-auto-provisioning is enabled") return value + @field_validator("sync_schedule", mode="after") + @classmethod + def _validate_sync_schedule(cls, value: str) -> str: + """Validate the cron schedule: 5 fields, safe characters only.""" + if "\n" in value or "\r" in value: + raise ValueError("sync-schedule must not contain newlines") + fields = value.split() + if len(fields) != 5: + raise ValueError(f"sync-schedule must have exactly 5 fields, got {len(fields)}") + allowed = re.compile(r"^[0-9\*/,\-]+$") + for field in fields: + if not allowed.match(field): + raise ValueError(f"sync-schedule field {field!r} contains disallowed characters") + return " ".join(fields) + @field_validator("primary_unit", mode="after") @classmethod def _validate_primary_unit_exists(cls, value: str, info: ValidationInfo) -> str: @@ -115,6 +135,7 @@ def from_charm(cls, charm: "DovecotCharm") -> "DovecotConfig": "primary_unit": config.get("primary-unit"), "luks_auto_provisioning": luks_auto_provisioning, "luks_key": luks_key, + "sync_schedule": config.get("sync-schedule", "*/30 * * * *"), }, context={"charm": charm}, ) diff --git a/dovecot-charm/src/exceptions.py b/dovecot-charm/src/exceptions.py index f3e9f1c..a2baaf3 100644 --- a/dovecot-charm/src/exceptions.py +++ b/dovecot-charm/src/exceptions.py @@ -29,3 +29,9 @@ class ConfigurationError(CharmBlockedError): """Raised when charm or service configuration is invalid or fails.""" pass + + +class HASetupError(CharmBlockedError): + """Raised when HA setup (SSH keys, sync scripts, sshd config, etc.) fails.""" + + pass diff --git a/dovecot-charm/src/ha.py b/dovecot-charm/src/ha.py new file mode 100644 index 0000000..8a3ff3e --- /dev/null +++ b/dovecot-charm/src/ha.py @@ -0,0 +1,258 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""High availability functions for the Dovecot charm.""" + +from __future__ import annotations + +import logging +import socket +import subprocess # nosec +import typing +from pathlib import Path + +from charmhelpers.core import host +from charmlibs import systemd +from ops.model import MaintenanceStatus + +from constants import ( + MAIL_ROOT, + PEER_RELATION_NAME, + SSH_DIR, + SSH_HOST_KEY_FILE, + SSHD_CONFIG, + SSHD_DROPIN_DIR, + SSHD_DROPIN_FILE, + SYNC_TO_SECONDARY_CRONJOB_TARGET, + SYNC_TO_SECONDARY_CRONJOB_TEMPLATE, + SYNC_TO_SECONDARY_TARGET, + SYNC_TO_SECONDARY_TEMPLATE, +) +from exceptions import HASetupError + +if typing.TYPE_CHECKING: + from charm import DovecotCharm + from dovecot_config import DovecotConfig + +logger = logging.getLogger(__name__) + + +def setup_ssh_keys(charm: DovecotCharm) -> None: + """Generate an SSH key pair if absent and publish keys via the peer relation. + + Publishes both the user public key (for authorized_keys) and the host + public key (for known_hosts) so peers can verify each other's identity + without disabling StrictHostKeyChecking. + + Raises: + HASetupError: If SSH key generation fails. + """ + SSH_DIR.mkdir(mode=0o700, exist_ok=True) + key_file = SSH_DIR / "id_ed25519" + + if not key_file.exists(): + try: + subprocess.run( + ["/usr/bin/ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_file)], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + raise HASetupError(f"SSH key generation failed: {e.stderr}") from e + + pub_key_file = SSH_DIR / "id_ed25519.pub" + if not pub_key_file.exists(): + raise HASetupError("SSH public key file not found after key generation") + + pub_key = pub_key_file.read_text().strip() + relation = charm.model.get_relation(PEER_RELATION_NAME) + if relation: + relation.data[charm.unit]["public_key"] = pub_key + relation.data[charm.unit]["hostname"] = socket.gethostname() + + # Publish own IP so peers can restrict root SSH to known addresses. + binding = charm.model.get_binding(PEER_RELATION_NAME) + if binding: + relation.data[charm.unit]["ip_address"] = str(binding.network.bind_address) + + if SSH_HOST_KEY_FILE.exists(): + host_key = SSH_HOST_KEY_FILE.read_text().strip() + relation.data[charm.unit]["ssh_host_key"] = host_key + + +def sync_authorized_keys(charm: DovecotCharm) -> None: + """Collect public keys and IPs from all peer units and write authorized_keys. + + Also calls ensure_root_ssh_login with the collected peer IPs so that root + SSH key login is restricted to known peer addresses only. + """ + relation = charm.model.get_relation(PEER_RELATION_NAME) + if not relation: + return + + authorized_keys = [] + peer_ips: list[str] = [] + for unit in relation.units: + pk = relation.data[unit].get("public_key") + if pk: + authorized_keys.append(pk) + ip = relation.data[unit].get("ip_address") + if ip: + peer_ips.append(ip) + + our_pk = relation.data[charm.unit].get("public_key") + if our_pk: + authorized_keys.append(our_pk) + our_ip = relation.data[charm.unit].get("ip_address") + if our_ip: + peer_ips.append(our_ip) + + if not authorized_keys: + return + + auth_file = SSH_DIR / "authorized_keys" + auth_file.write_text("\n".join(authorized_keys) + "\n") + auth_file.chmod(0o600) + + ensure_root_ssh_login(peer_ips) + + +def sync_known_hosts(charm: DovecotCharm) -> None: + """Populate known_hosts with peer SSH host keys from the peer relation. + + Each peer publishes its host public key and hostname on the relation. + This writes those into known_hosts so SSH connections between units use + StrictHostKeyChecking (the default) instead of disabling it. + """ + relation = charm.model.get_relation(PEER_RELATION_NAME) + if not relation: + return + + entries = [] + for unit in relation.units: + host_key = relation.data[unit].get("ssh_host_key") + hostname = relation.data[unit].get("hostname") + if host_key and hostname: + entries.append(f"{hostname} {host_key}") + + if not entries: + return + + known_hosts_file = SSH_DIR / "known_hosts" + known_hosts_file.write_text("\n".join(entries) + "\n") + known_hosts_file.chmod(0o600) + + +def ensure_root_ssh_login(peer_ips: list[str]) -> None: + """Set PermitRootLogin via an sshd drop-in restricted to peer addresses. + + Writes /etc/ssh/sshd_config.d/99-dovecot-ha.conf with: + - A global ``PermitRootLogin no`` baseline. + - A ``Match Address`` block that permits ``prohibit-password`` only for + the supplied peer IP addresses. + + If no peer IPs are known yet the drop-in is removed (or not written) so + that root login remains governed by the distro default until peers are + available. Validates with ``sshd -t`` before reloading; rolls back on + failure. + + Raises: + HASetupError: If sshd validation or reload fails. + """ + if not SSHD_CONFIG.exists(): + return + + if not peer_ips: + # No peers known yet — remove our drop-in if present and return. + if SSHD_DROPIN_FILE.exists(): + SSHD_DROPIN_FILE.unlink() + try: + systemd.service_reload("ssh", restart_on_failure=True) + except subprocess.CalledProcessError as e: + raise HASetupError(f"Failed to reload sshd after config change: {e}") from e + return + + address_list = ",".join(sorted(set(peer_ips))) + drop_in_content = ( + "PermitRootLogin no\n" + "\n" + f"Match Address {address_list}\n" + " PermitRootLogin prohibit-password\n" + ) + + if SSHD_DROPIN_FILE.exists() and SSHD_DROPIN_FILE.read_text() == drop_in_content: + return + + previous_exists = SSHD_DROPIN_FILE.exists() + previous_content = SSHD_DROPIN_FILE.read_text() if previous_exists else None + + SSHD_DROPIN_DIR.mkdir(mode=0o755, parents=True, exist_ok=True) + SSHD_DROPIN_FILE.write_text(drop_in_content) + + # sshd -t requires the privsep directory to exist even for config checks. + Path("/run/sshd").mkdir(mode=0o755, exist_ok=True) + + try: + subprocess.run( + ["/usr/sbin/sshd", "-t", "-f", str(SSHD_CONFIG)], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + if previous_exists and previous_content is not None: + SSHD_DROPIN_FILE.write_text(previous_content) + else: + SSHD_DROPIN_FILE.unlink(missing_ok=True) + raise HASetupError(f"Failed to validate sshd configuration: {e.stderr}") from e + + try: + systemd.service_reload("ssh", restart_on_failure=True) + except subprocess.CalledProcessError as e: + raise HASetupError(f"Failed to reload sshd after config change: {e}") from e + + +def install_mail_sync_script(charm: DovecotCharm) -> None: + """Render and install the mail pool synchronization script. + + Skipped when the secondary hostname is not yet known (no remote peer). + """ + secondary = charm._secondary_hostname + if not secondary: + logger.info("Secondary hostname not yet known; skipping sync script installation") + return + + charm.unit.status = MaintenanceStatus("Installing mail pool synchronization script") + template_context = { + "secondary_hostname": secondary, + "mail_root": MAIL_ROOT, + } + template = charm.jinja.get_template(SYNC_TO_SECONDARY_TEMPLATE) + contents = template.render(template_context) + host.write_file(SYNC_TO_SECONDARY_TARGET, contents, perms=0o755) + + +def setup_mail_sync_cronjob(charm: DovecotCharm, dovecot_config: DovecotConfig) -> None: + """Set up the mail pool synchronization cronjob. + + Skips writing and does not restart cron if the file content is unchanged. + Cron on Ubuntu automatically picks up changes in /etc/cron.d, so no + service restart is needed when the file is updated. + """ + if not charm._secondary_hostname: + logger.info("Secondary hostname not yet known; skipping cronjob setup") + return + + charm.unit.status = MaintenanceStatus("Setting up mail pool synchronization cronjob") + template_context = { + "schedule": dovecot_config.sync_schedule, + } + template = charm.jinja.get_template(SYNC_TO_SECONDARY_CRONJOB_TEMPLATE) + contents = template.render(template_context) + + cronjob_path = Path(SYNC_TO_SECONDARY_CRONJOB_TARGET) + if cronjob_path.exists() and cronjob_path.read_text() == contents: + return + + host.write_file(SYNC_TO_SECONDARY_CRONJOB_TARGET, contents, perms=0o644) diff --git a/dovecot-charm/templates/sync-to-secondary.sh.tmpl b/dovecot-charm/templates/sync-to-secondary.sh.tmpl new file mode 100644 index 0000000..6ee3594 --- /dev/null +++ b/dovecot-charm/templates/sync-to-secondary.sh.tmpl @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eu + +# Sync using doveadm (dsync) for users that have a Maildir. +# Avoids syncing system accounts without mailboxes. +remote="remote:root@{{ secondary_hostname }}" +found=0 +for user_dir in "{{ mail_root }}"/*; do + if [ -d "$user_dir/Maildir" ]; then + user="$(basename "$user_dir")" + doveadm backup -u "$user" "$remote" + found=1 + fi +done +if [ "$found" -eq 0 ]; then + echo "No Maildir found under {{ mail_root }}; nothing to sync." >&2 + exit 0 +fi +touch "{{ mail_root }}/.last-dsync" diff --git a/dovecot-charm/templates/sync-to-secondary_cron.tmpl b/dovecot-charm/templates/sync-to-secondary_cron.tmpl new file mode 100644 index 0000000..c91b0b5 --- /dev/null +++ b/dovecot-charm/templates/sync-to-secondary_cron.tmpl @@ -0,0 +1,2 @@ +{{ schedule }} root /usr/local/bin/sync-to-secondary.sh 2>&1 | /usr/bin/logger -t sync-to-secondary + diff --git a/dovecot-charm/tests/integration/conftest.py b/dovecot-charm/tests/integration/conftest.py index cbd7c40..d74dfdd 100644 --- a/dovecot-charm/tests/integration/conftest.py +++ b/dovecot-charm/tests/integration/conftest.py @@ -140,3 +140,63 @@ def tls_charm(juju: jubilant.Juju) -> str: logging.info(f"{tls_app} already deployed, skipping deployment.") return tls_app + + +@pytest.fixture(scope="module") +def dovecot_charm_dual_unit( + charm: str, + juju: jubilant.Juju, + tls_charm: str, +) -> str: + """Build and deploy the charm.""" + logging.info(f"Checking for existing application {APP_NAME}...") + luks_key = token_hex(16) + + if not juju.status().apps.get(APP_NAME): + logging.info(f"Application {APP_NAME} not found, proceeding with deployment.") + + secret_id = juju.cli("add-secret", "dovecot-luks-key", f"key={luks_key}").strip() + logging.info(f"Created LUKS secret: {secret_id}") + + config = { + "mailname": "example.com", + "postmaster-address": "postmaster@example.com", + "primary-unit": f"{APP_NAME}/0", + "luks-auto-provisioning": True, + "luks-key": secret_id, + } + charm_path = charm if charm.startswith(("./", "/")) else f"./{charm}" + juju.deploy( + charm_path, + app=APP_NAME, + config=config, + constraints={"virt-type": "virtual-machine"}, + trust=True, + num_units=2, + ) + else: + if len(juju.status().apps[APP_NAME].units) < 2: + logging.info("Adding the second unit...") + juju.add_unit(APP_NAME, num_units=1) + + def two_units_active(status): + app = status.apps.get(APP_NAME) + if not app or len(app.units) < 2: + return False + return jubilant.all_active(status) + + logging.info("Waiting for 2 units to be active...") + juju.wait(two_units_active, timeout=10 * 60) + + juju.cli("grant-secret", "dovecot-luks-key", APP_NAME) + try: + logging.info("Adding TLS relation...") + juju.integrate(f"{APP_NAME}:certificates", f"{tls_charm}:certificates") + except Exception: + logging.info("TLS relation already there...") + logging.info("Waiting for active status...") + juju.wait( + lambda status: jubilant.all_active(status, APP_NAME, tls_charm), + timeout=10 * 60, + ) + return APP_NAME diff --git a/dovecot-charm/tests/integration/test_ha.py b/dovecot-charm/tests/integration/test_ha.py new file mode 100644 index 0000000..0fed803 --- /dev/null +++ b/dovecot-charm/tests/integration/test_ha.py @@ -0,0 +1,284 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +import contextlib +import imaplib +import logging +import ssl +import time +from secrets import token_hex +from typing import cast + +import jubilant +import pytest + + +def _check_mail_via_imap(unit_ip: str, user: str, password: str, subject: str) -> bool: + """Poll IMAP on unit_ip until the email with the given subject is found.""" + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + for attempt in range(20): + mail = None + try: + mail = imaplib.IMAP4_SSL(unit_ip, port=993, ssl_context=context) + mail.login(user, password) + mail.select("inbox") + _, data = mail.search(None, f'(HEADER Subject "{subject}")') + if data and data[0]: + logging.info(f"Email found via IMAP on {unit_ip}. IDs: {data[0]}") + return True + logging.info(f"Email not found yet on {unit_ip} (attempt {attempt + 1})...") + except (imaplib.IMAP4.error, OSError) as e: + logging.warning(f"IMAP attempt {attempt + 1} on {unit_ip} failed: {e}. Retrying...") + finally: + if mail is not None: + with contextlib.suppress(imaplib.IMAP4.error, OSError): + mail.close() + with contextlib.suppress(imaplib.IMAP4.error, OSError): + mail.logout() + time.sleep(3) + + return False + + +def _setup_mail_user( + juju: jubilant.Juju, + primary: str, + secondary: str, + user: str, + password: str, +): + """Create a mail user on both units. + + The system account and password are created on both units so PAM auth works + on the secondary after sync. The Maildir is only initialised on the primary + so that dsync can replicate it to the secondary without GUID conflicts. + """ + for unit in (primary, secondary): + juju.exec( + ( + f"id -u {user} >/dev/null 2>&1 || " + f"useradd -M -d /srv/mail/{user} -s /usr/sbin/nologin {user}" + ), + unit=unit, + ) + juju.exec(f"echo '{user}:{password}' | chpasswd", unit=unit) + juju.exec(f"usermod -aG mail {user}", unit=unit) + + # Maildir only on primary — dsync creates it on the secondary during the + # first sync. Pre-initialising it on the secondary would give INBOX a + # different GUID and cause doveadm backup to fail with + # "mailbox_delete failed: INBOX can't be deleted". + juju.exec( + ( + f"install -d -m 0700 -o {user} -g mail /srv/mail/{user} && " + f"doveadm mailbox create -u {user} INBOX 2>/dev/null || true" + ), + unit=primary, + ) + + +def _get_last_sync_mtime(juju: jubilant.Juju, unit: str) -> int | None: + """Return /srv/mail/.last-dsync mtime epoch on unit, or None if missing.""" + output = juju.exec( + "stat -c %Y /srv/mail/.last-dsync 2>/dev/null || true", unit=unit + ).stdout.strip() + return int(output) if output.isdigit() else None + + +def _get_sync_cron_run_count(juju: jubilant.Juju, unit: str) -> int: + """Return count of sync-to-secondary cron executions from syslog or direct log. + + Works across charm versions: newer versions log via logger to syslog, + older versions redirect directly to /var/log/sync-to-secondary.log. + """ + # Try syslog first (newer charm versions with logger) + syslog_output = juju.exec( + "grep -c 'sync-to-secondary' /var/log/syslog 2>/dev/null || true", + unit=unit, + ).stdout.strip() + syslog_count = int(syslog_output) if syslog_output.isdigit() else 0 + + # Also check direct sync log file (older charm versions) + synclog_output = juju.exec( + "wc -l /var/log/sync-to-secondary.log 2>/dev/null | awk '{print $1}' || true", + unit=unit, + ).stdout.strip() + synclog_count = int(synclog_output) if synclog_output.isdigit() else 0 + + # Return the higher count (more reliable detector across versions) + return max(syslog_count, synclog_count) + + +def _get_sync_log_content(juju: jubilant.Juju, unit: str, lines: int = 20) -> str: + """Return last N sync-to-secondary lines from syslog for debugging.""" + output = juju.exec( + f"grep 'sync-to-secondary' /var/log/syslog 2>/dev/null | tail -n {lines} || echo 'No sync entries in syslog'", + unit=unit, + ).stdout + return output + + +def _get_cron_file_content(juju: jubilant.Juju, unit: str) -> str: + """Return content of the sync-to-secondary cron file for debugging.""" + output = juju.exec( + "cat /etc/cron.d/sync-to-secondary 2>/dev/null || echo 'Cron file not found'", + unit=unit, + ).stdout + return output + + +def _wait_for_sync_trigger( + juju: jubilant.Juju, + unit: str, + previous_mtime: int | None, + previous_cron_count: int, + timeout: int = 4 * 60, + poll_interval: int = 5, +) -> int: + """Wait until /srv/mail/.last-dsync mtime advances, indicating a completed sync. + + The sync script touches .last-dsync only at the very end, so this is a + reliable end-of-sync marker. Syslog cron count is checked only to log + that the cron job appears to have fired while we continue waiting for + .last-dsync to be updated. + """ + deadline = time.time() + timeout + cron_fired = False + while time.time() < deadline: + current_mtime = _get_last_sync_mtime(juju, unit) + if current_mtime is not None and ( + previous_mtime is None or current_mtime > previous_mtime + ): + return current_mtime + + current_cron_count = _get_sync_cron_run_count(juju, unit) + if current_cron_count > previous_cron_count and not cron_fired: + logging.info( + "Cron fired (syslog count increased); waiting for .last-dsync to update..." + ) + cron_fired = True + + time.sleep(poll_interval) + + raise AssertionError( + "Timed out waiting for sync trigger on " + f"{unit}; previous mtime={previous_mtime}, previous cron count={previous_cron_count}" + ) + + +@pytest.mark.timeout(30 * 60) +def test_force_sync_action(juju: jubilant.Juju, dovecot_charm_dual_unit: str): + """force-sync action replicates mail from primary to secondary via doveadm backup.""" + status = juju.status() + units = sorted( + status.apps[dovecot_charm_dual_unit].units.keys(), key=lambda x: int(x.split("/")[-1]) + ) + primary, secondary = units[0], units[1] + logging.info(f"Primary: {primary}, Secondary: {secondary}") + + juju.config(dovecot_charm_dual_unit, {"primary-unit": primary}) + juju.wait(jubilant.all_active, timeout=5 * 60) + + # Remove legacy HA test users that can break dsync on reruns. + for unit in (primary, secondary): + juju.exec("rm -rf /srv/mail/syncuser* /srv/mail/autosyncuser*", unit=unit) + + # Set up test user on both units (PAM auth requires user to exist on secondary for IMAP) + user = f"syncuser{token_hex(3)}" + password = token_hex(8) + for unit in (primary, secondary): + juju.exec(f"rm -rf /srv/mail/{user}", unit=unit) + _setup_mail_user(juju, primary, secondary, user, password) + + # Send email on primary + subject = f"Force Sync Test {token_hex(4)}" + logging.info(f"Sending test email on primary with subject: {subject}") + juju.exec(f"echo 'test body' | mail -s '{subject}' {user}@localhost", unit=primary) + + # Run force-sync on primary + logging.info("Running force-sync action on primary...") + task = juju.run(unit=primary, action="force-sync", wait=2 * 60) + assert task.status == "completed" + assert task.results["result"] == "Sync completed successfully" + + # Verify email arrived on secondary via IMAP + secondary_ip = juju.status().apps[dovecot_charm_dual_unit].units[secondary].public_address + logging.info(f"Checking for email on secondary via IMAP at {secondary_ip}:993...") + assert _check_mail_via_imap(secondary_ip, user, password, subject), ( + f"Email with subject '{subject}' not found on secondary after force-sync" + ) + + # force-sync must fail on secondary + with pytest.raises(jubilant.TaskError) as exc_info: + juju.run(unit=secondary, action="force-sync", wait=2 * 60) + assert cast(jubilant.TaskError, exc_info.value).task.status == "failed" + logging.info("force-sync on secondary correctly failed.") + + +def test_auto_sync(juju: jubilant.Juju, dovecot_charm_dual_unit: str): + """Auto-sync via cron replicates mail from primary to secondary within 2 minutes.""" + status = juju.status() + units = sorted( + status.apps[dovecot_charm_dual_unit].units.keys(), key=lambda x: int(x.split("/")[-1]) + ) + primary, secondary = units[0], units[1] + + logging.info(f"Ensuring primary-unit is set to {primary}...") + juju.config(dovecot_charm_dual_unit, {"primary-unit": primary}) + juju.wait(jubilant.all_active, timeout=5 * 60) + + # Remove legacy HA test users that can break dsync on reruns. + for unit in (primary, secondary): + juju.exec("rm -rf /srv/mail/syncuser* /srv/mail/autosyncuser*", unit=unit) + + # Set up a fresh test user + user = f"autosyncuser{token_hex(3)}" + password = token_hex(8) + for unit in (primary, secondary): + juju.exec(f"rm -rf /srv/mail/{user}", unit=unit) + _setup_mail_user(juju, primary, secondary, user, password) + + # Send email on primary + subject = f"Auto Sync Test {token_hex(4)}" + logging.info(f"Sending test email on primary with subject: {subject}") + juju.exec(f"echo 'test body' | mail -s '{subject}' {user}@localhost", unit=primary) + + previous_sync_mtime = _get_last_sync_mtime(juju, primary) + previous_cron_count = _get_sync_cron_run_count(juju, primary) + + try: + # Lower sync schedule to every minute, wait for reconcile + logging.info("Setting sync-schedule to */1 * * * * (every minute)...") + juju.config(dovecot_charm_dual_unit, {"sync-schedule": "*/1 * * * *"}) + juju.wait(jubilant.all_active, timeout=5 * 60) + + logging.info(f"Cron file after config change:\n{_get_cron_file_content(juju, primary)}") + logging.info("Waiting for first cron-triggered sync signal on primary...") + _wait_for_sync_trigger(juju, primary, previous_sync_mtime, previous_cron_count) + + # Verify email arrived on secondary via IMAP + secondary_ip = juju.status().apps[dovecot_charm_dual_unit].units[secondary].public_address + logging.info(f"Checking for email on secondary via IMAP at {secondary_ip}:993...") + synced = _check_mail_via_imap(secondary_ip, user, password, subject) + if not synced: + logging.info("Email not found after first cron sync.") + logging.info(f"Sync log on primary:\n{_get_sync_log_content(juju, primary)}") + logging.info("Cron file content:") + logging.info(_get_cron_file_content(juju, primary)) + logging.info("Trying manual sync as fallback to verify sync mechanism works...") + juju.exec("/usr/local/bin/sync-to-secondary.sh", unit=primary) + time.sleep(15) + synced = _check_mail_via_imap(secondary_ip, user, password, subject) + if not synced: + logging.info("Manual sync also failed. Checking sync log after manual run:") + logging.info(f"Sync log:\n{_get_sync_log_content(juju, primary, lines=30)}") + + assert synced, f"Email with subject '{subject}' not found on secondary after auto-sync" + finally: + # Reset sync-schedule to default + logging.info("Resetting sync-schedule to default...") + juju.config(dovecot_charm_dual_unit, {"sync-schedule": "*/30 * * * *"}) + juju.wait(jubilant.all_active, timeout=5 * 60) diff --git a/dovecot-charm/tests/integration/test_mail.py b/dovecot-charm/tests/integration/test_mail.py index 3d063ff..315772e 100644 --- a/dovecot-charm/tests/integration/test_mail.py +++ b/dovecot-charm/tests/integration/test_mail.py @@ -17,7 +17,7 @@ def test_mail_workflow(juju: jubilant.Juju, dovecot_charm: str): unit_name = f"{dovecot_charm}/0" logging.info(f"Updating primary-unit config to {unit_name}...") juju.config(dovecot_charm, {"primary-unit": unit_name}) - juju.wait(jubilant.all_active, timeout=300) + juju.wait(jubilant.all_active, timeout=5 * 60) password = token_hex(8) logging.info("Configuring user 'ubuntu'...") diff --git a/dovecot-charm/tests/integration/test_storage.py b/dovecot-charm/tests/integration/test_storage.py index 9203398..de51ee8 100644 --- a/dovecot-charm/tests/integration/test_storage.py +++ b/dovecot-charm/tests/integration/test_storage.py @@ -17,7 +17,7 @@ def test_luks_storage_auto_provisioning(juju: jubilant.Juju, dovecot_charm: str) logging.info(f"Targeting unit: {unit_name}") logging.info("Waiting for charm to be active with storage attached...") - juju.wait(jubilant.all_active, timeout=600) + juju.wait(jubilant.all_active, timeout=10 * 60) logging.info("Verifying LUKS setup...") juju.exec("ls -l /dev/mapper/mail-data", unit=unit_name) @@ -126,7 +126,7 @@ def test_luks_storage_manual_provisioning(juju: jubilant.Juju, dovecot_charm_man juju.config(dovecot_charm_manual_storage, {"mailname": "example1.com"}) logging.info("Waiting for charm to become active...") - juju.wait(jubilant.all_active, timeout=300) + juju.wait(jubilant.all_active, timeout=5 * 60) # Verify LUKS device status logging.info("Verifying LUKS device is properly configured...") @@ -182,7 +182,7 @@ def test_data_persists_across_restart(juju: jubilant.Juju, dovecot_charm: str): # Wait for charm to re-settle after reboot logging.info("Waiting for charm to re-settle...") - juju.wait(jubilant.all_active, timeout=600) + juju.wait(jubilant.all_active, timeout=10 * 60) # After reboot the Juju storage API may not yet be re-provisioned when the # start hook fires; the charm defers and retries until LUKS open + mount diff --git a/dovecot-charm/tests/unit/test_charm.py b/dovecot-charm/tests/unit/test_charm.py index a5f5676..ed9393e 100644 --- a/dovecot-charm/tests/unit/test_charm.py +++ b/dovecot-charm/tests/unit/test_charm.py @@ -1,46 +1,63 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. +import contextlib +import dataclasses from subprocess import CalledProcessError # nosec -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import ops import ops.testing import pytest -from exceptions import ConfigurationError +from charm import DovecotCharm +from exceptions import ConfigurationError, HASetupError +# --------------------------------------------------------------------------- +# Helpers — patches shared across many tests +# --------------------------------------------------------------------------- -def test_open_ports(ctx, base_state): + +@contextlib.contextmanager +def reconcile_guards(): + """Guard all I/O in _reconcile so tests only exercise event wiring / status. + + Use when the test drives an event that triggers _reconcile but the test + is NOT about the logic inside these helpers (storage, TLS, dovecot, etc.). + """ with ( - # Guard real storage/TLS/dovecot operations so only port logic is exercised patch("charm.ensure_storage_ready"), patch("charm.teardown_detaching_storage"), patch("charm.shutil.which", return_value="/usr/bin/doveconf"), patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): - state_out = ctx.run(ctx.on.config_changed(), base_state) + yield - expected = {ops.testing.TCPPort(p) for p in [993, 995, 4190, 9900]} - assert state_out.opened_ports == expected +def test_reconcile_sets_active_on_success(ctx, base_state): + """Reconcile must reach ActiveStatus when all setup steps succeed.""" + with reconcile_guards(): + state_out = ctx.run(ctx.on.config_changed(), base_state) + assert isinstance(state_out.unit_status, ops.ActiveStatus) -def test_configure_sets_active_on_success(ctx, base_state): - with ( - patch("charm.ensure_storage_ready"), - patch("charm.teardown_detaching_storage"), - patch("charm.shutil.which", return_value="/usr/bin/doveconf"), - patch("charm.DovecotCharm._setup_tls"), - patch("charm.DovecotCharm._setup_dovecot"), - patch("charm.DovecotCharm._setup_procmail"), - ): + +def test_reconcile_opens_mail_ports(ctx, base_state): + """All required IMAP/POP3/Sieve/metrics ports must be opened.""" + with reconcile_guards(): state_out = ctx.run(ctx.on.config_changed(), base_state) - assert isinstance(state_out.unit_status, ops.ActiveStatus) + expected = {ops.testing.TCPPort(p) for p in [993, 995, 4190, 9900]} + assert state_out.opened_ports == expected -def test_configure_blocks_when_dovecot_setup_fails(ctx, base_state): +def test_reconcile_blocks_when_dovecot_setup_fails(ctx, base_state): + """Charm must be Blocked when _setup_dovecot raises ConfigurationError.""" with ( patch("charm.ensure_storage_ready"), patch("charm.teardown_detaching_storage"), @@ -60,7 +77,8 @@ def test_configure_blocks_when_dovecot_setup_fails(ctx, base_state): assert "Invalid Dovecot configuration" in state_out.unit_status.message -def test_configure_blocks_when_procmail_setup_fails(ctx, base_state): +def test_reconcile_blocks_when_procmail_setup_fails(ctx, base_state): + """Charm must be Blocked when _setup_procmail raises ConfigurationError.""" with ( patch("charm.ensure_storage_ready"), patch("charm.teardown_detaching_storage"), @@ -78,12 +96,76 @@ def test_configure_blocks_when_procmail_setup_fails(ctx, base_state): assert "postfix" in state_out.unit_status.message -# --- Clear-queue action tests --- +# --------------------------------------------------------------------------- +# HA: _is_primary +# --------------------------------------------------------------------------- + + +def test_is_primary_true_when_unit_matches_config(ctx, base_state): + """_is_primary returns True when primary-unit config matches this unit.""" + with reconcile_guards(), ctx(ctx.on.config_changed(), base_state) as mgr: + assert mgr.charm._is_primary is True + + +def test_is_primary_false_when_unit_differs(ctx, base_state): + """_is_primary returns False when primary-unit config doesn't match this unit. + + We access the charm inside the context manager before the event fires, + so no _reconcile I/O is reached — no patches needed for the HA methods. + Config validation is bypassed by patching _get_dovecot_config. + """ + state_in = dataclasses.replace( + base_state, config={**base_state.config, "primary-unit": "dovecot-charm/99"} + ) + with ( + patch("charm.DovecotCharm._get_dovecot_config"), + patch("charm.ensure_storage_ready"), + patch("charm.teardown_detaching_storage"), + patch("charm.shutil.which", return_value=None), + ctx(ctx.on.config_changed(), state_in) as mgr, + ): + assert mgr.charm._is_primary is False + + +# --------------------------------------------------------------------------- +# HA: reconcile calls sync script only on primary with known secondary +# --------------------------------------------------------------------------- + + +def test_reconcile_skips_sync_script_when_not_primary(ctx, base_state): + """When this unit is NOT primary, sync script and cronjob are not installed.""" + with ( + patch("charm.ensure_storage_ready"), + patch("charm.teardown_detaching_storage"), + patch("charm.shutil.which", return_value="/usr/bin/doveconf"), + patch("charm.DovecotCharm._setup_tls"), + patch("charm.DovecotCharm._setup_dovecot"), + patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("charm.DovecotCharm._is_primary", new_callable=PropertyMock, return_value=False), + patch("ha.install_mail_sync_script") as mock_sync, + patch("ha.setup_mail_sync_cronjob") as mock_cron, + ): + state_out = ctx.run(ctx.on.config_changed(), base_state) + + assert isinstance(state_out.unit_status, ops.ActiveStatus) + mock_sync.assert_not_called() + mock_cron.assert_not_called() + + +# --------------------------------------------------------------------------- +# Clear-queue action +# --------------------------------------------------------------------------- def test_clear_queue_deferred(ctx, base_state): + """clear-queue action with queue=deferred passes correct args to postsuper.""" mock_result = MagicMock(stdout="cleared") - with patch("charm.subprocess.run", return_value=mock_result) as mock_run: + with ( + patch("charm.subprocess.run", return_value=mock_result) as mock_run, + ): ctx.run( ctx.on.action("clear-queue", params={"queue": "deferred"}), base_state, @@ -98,8 +180,11 @@ def test_clear_queue_deferred(ctx, base_state): def test_clear_queue_all(ctx, base_state): + """clear-queue action with queue=all omits the deferred queue filter.""" mock_result = MagicMock(stdout="cleared") - with patch("charm.subprocess.run", return_value=mock_result) as mock_run: + with ( + patch("charm.subprocess.run", return_value=mock_result) as mock_run, + ): ctx.run( ctx.on.action("clear-queue", params={"queue": "all"}), base_state, @@ -114,6 +199,7 @@ def test_clear_queue_all(ctx, base_state): def test_clear_queue_failure(ctx, base_state): + """clear-queue action must fail when postsuper returns non-zero.""" with ( patch( "charm.subprocess.run", @@ -126,3 +212,104 @@ def test_clear_queue_failure(ctx, base_state): base_state, ) assert "postsuper" in exc_info.value.message + + +# --------------------------------------------------------------------------- +# Force-sync action +# --------------------------------------------------------------------------- + + +def test_force_sync_success(ctx, base_state): + """force-sync succeeds when this unit is primary and a secondary exists.""" + mock_result = MagicMock(stdout="ok", stderr="") + with ( + patch("charm.subprocess.run", return_value=mock_result), + patch("charm.Path") as mock_path_cls, + patch.object( + DovecotCharm, + "_secondary_hostname", + new_callable=PropertyMock, + return_value="10.0.0.2", + ), + ): + mock_path_cls.return_value.exists.return_value = True + ctx.run(ctx.on.action("force-sync"), base_state) + assert ctx.action_results == {"result": "Sync completed successfully"} + + +def test_force_sync_not_primary(ctx, base_state): + """force-sync must fail when executed on a non-primary unit.""" + with ( + patch("charm.DovecotCharm._is_primary", new_callable=PropertyMock, return_value=False), + pytest.raises(ops.testing.ActionFailed) as exc_info, + ): + ctx.run(ctx.on.action("force-sync"), base_state) + assert "primary unit" in exc_info.value.message + + +def test_force_sync_no_secondary(ctx, base_state): + """force-sync must fail when no secondary unit hostname is available.""" + with pytest.raises(ops.testing.ActionFailed) as exc_info: + ctx.run(ctx.on.action("force-sync"), base_state) + assert "secondary" in exc_info.value.message + + +def test_force_sync_subprocess_failure(ctx, base_state): + """force-sync must fail when the sync script exits non-zero.""" + with ( + patch( + "charm.subprocess.run", + side_effect=CalledProcessError(1, "sync", stderr="fail"), + ), + patch("charm.Path") as mock_path_cls, + patch.object( + DovecotCharm, + "_secondary_hostname", + new_callable=PropertyMock, + return_value="10.0.0.2", + ), + pytest.raises(ops.testing.ActionFailed) as exc_info, + ): + mock_path_cls.return_value.exists.return_value = True + ctx.run(ctx.on.action("force-sync"), base_state) + assert "fail" in exc_info.value.message + + +# --------------------------------------------------------------------------- +# HA: reconcile blocks on HASetupError +# --------------------------------------------------------------------------- + + +def test_reconcile_blocks_when_ha_setup_fails(ctx, base_state): + """Charm must be Blocked when HA setup raises HASetupError.""" + with ( + patch("charm.ensure_storage_ready"), + patch("charm.teardown_detaching_storage"), + patch("charm.shutil.which", return_value="/usr/bin/doveconf"), + patch("charm.DovecotCharm._setup_tls"), + patch("charm.DovecotCharm._setup_dovecot"), + patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys", side_effect=HASetupError("SSH keygen failed")), + ): + state_out = ctx.run(ctx.on.config_changed(), base_state) + + assert isinstance(state_out.unit_status, ops.BlockedStatus) + assert "SSH keygen failed" in state_out.unit_status.message + + +def test_force_sync_script_not_installed(ctx, base_state): + """force-sync must fail with a clear message when sync script is not yet installed.""" + with ( + patch.object( + DovecotCharm, + "_secondary_hostname", + new_callable=PropertyMock, + return_value="10.0.0.2", + ), + patch("charm.Path") as mock_path_cls, + pytest.raises(ops.testing.ActionFailed) as exc_info, + ): + mock_path_cls.return_value.exists.return_value = False + ctx.run(ctx.on.action("force-sync"), base_state) + + assert "wait for the charm" in exc_info.value.message diff --git a/dovecot-charm/tests/unit/test_config.py b/dovecot-charm/tests/unit/test_config.py index 24828bf..5581769 100644 --- a/dovecot-charm/tests/unit/test_config.py +++ b/dovecot-charm/tests/unit/test_config.py @@ -6,6 +6,7 @@ import pytest from ops.model import BlockedStatus +from pydantic import ValidationError from dovecot_config import DovecotConfig, DovecotConfigInvalidError @@ -53,3 +54,69 @@ def test_from_charm_primary_unit_does_not_exist_raises_value_error(base_state): with pytest.raises(DovecotConfigInvalidError, match="Primary unit does not exist"): DovecotConfig.from_charm(charm) + + +# Valid config kwargs shared by sync_schedule tests. +_VALID_BASE = { + "mailname": "example.com", + "postmaster_address": "admin@example.com", + "primary_unit": "dovecot-charm/0", +} + + +class TestSyncScheduleValidation: + def test_valid_default(self): + cfg = DovecotConfig(**_VALID_BASE) + assert cfg.sync_schedule == "*/30 * * * *" + + def test_valid_every_minute(self): + cfg = DovecotConfig(**_VALID_BASE, sync_schedule="*/1 * * * *") + assert cfg.sync_schedule == "*/1 * * * *" + + def test_valid_specific_fields(self): + cfg = DovecotConfig(**_VALID_BASE, sync_schedule="0 4 * * 1") + assert cfg.sync_schedule == "0 4 * * 1" + + def test_normalises_whitespace(self): + cfg = DovecotConfig(**_VALID_BASE, sync_schedule="*/30 * * * *") + assert cfg.sync_schedule == "*/30 * * * *" + + def test_rejects_newline(self): + with pytest.raises(ValidationError, match="must not contain newlines"): + DovecotConfig(**_VALID_BASE, sync_schedule="*/30 * * * *\nbad root /bin/evil") + + def test_rejects_too_few_fields(self): + with pytest.raises(ValidationError, match="exactly 5 fields"): + DovecotConfig(**_VALID_BASE, sync_schedule="*/30 * * *") + + def test_rejects_too_many_fields(self): + with pytest.raises(ValidationError, match="exactly 5 fields"): + DovecotConfig(**_VALID_BASE, sync_schedule="*/30 * * * * extra") + + def test_rejects_empty_string(self): + with pytest.raises(ValidationError, match="exactly 5 fields"): + DovecotConfig(**_VALID_BASE, sync_schedule="") + + def test_rejects_command_substitution(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="$(rm) * * * *") + + def test_rejects_backticks(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="`id` * * * *") + + def test_rejects_semicolon(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="*;id * * * *") + + def test_rejects_pipe(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="*|cat * * * *") + + def test_rejects_alphabetic_field(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="* * * * MON") + + def test_rejects_question_mark(self): + with pytest.raises(ValidationError, match="disallowed characters"): + DovecotConfig(**_VALID_BASE, sync_schedule="? * * * *") diff --git a/dovecot-charm/tests/unit/test_storage.py b/dovecot-charm/tests/unit/test_storage.py index c204655..1a9842d 100644 --- a/dovecot-charm/tests/unit/test_storage.py +++ b/dovecot-charm/tests/unit/test_storage.py @@ -29,6 +29,12 @@ def test_start_uses_saved_dev_path_when_model_error(ctx, base_state): patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O (ssh-keygen, authorized_keys, sync scripts) + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), patch("ops._main._Dispatcher.run_any_legacy_hook"), ): state_out = ctx.run(ctx.on.start(), state_in) @@ -72,6 +78,12 @@ def test_storage_attached_luks_auto_provisioning_disabled_mounted_is_active(ctx, patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O — not under test + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): state_out = ctx.run(ctx.on.storage_attached(storage), state_in) assert isinstance(state_out.unit_status, ops.ActiveStatus) @@ -105,6 +117,12 @@ def test_storage_attached_calls_setup_luks_with_key(ctx, base_state): patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O — not under test + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): state_out = ctx.run(ctx.on.storage_attached(storage), state_in) assert isinstance(state_out.unit_status, ops.ActiveStatus) @@ -125,6 +143,12 @@ def test_storage_attached_saves_dev_path(ctx, base_state): patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O — not under test + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): state_out = ctx.run(ctx.on.storage_attached(storage), state_in) assert isinstance(state_out.unit_status, ops.ActiveStatus) @@ -184,6 +208,12 @@ def test_storage_detaching_unmount_and_close(ctx, base_state): patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O — not under test + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): state_out = ctx.run(ctx.on.storage_detaching(storage), state_in) assert isinstance(state_out.unit_status, ops.ActiveStatus) @@ -219,6 +249,12 @@ def test_storage_detaching_luks_disabled_skips_close(ctx, base_state): patch("charm.DovecotCharm._setup_tls"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + # HA methods do filesystem I/O — not under test + patch("ha.setup_ssh_keys"), + patch("ha.sync_authorized_keys"), + patch("ha.sync_known_hosts"), + patch("ha.install_mail_sync_script"), + patch("ha.setup_mail_sync_cronjob"), ): state_out = ctx.run(ctx.on.storage_detaching(storage), state_in) assert isinstance(state_out.unit_status, ops.ActiveStatus) diff --git a/dovecot-charm/tests/unit/test_tls.py b/dovecot-charm/tests/unit/test_tls.py index b31756d..6a50d0f 100644 --- a/dovecot-charm/tests/unit/test_tls.py +++ b/dovecot-charm/tests/unit/test_tls.py @@ -42,13 +42,12 @@ def test_setup_tls_writes_cert_key_and_chain(ctx, base_state, tmp_path): mock_key.__str__ = MagicMock(return_value="KEY_DATA") with ( - # Redirect TLS_CERT_DIR so _setup_tls writes into tmp_path patch("charm.TLS_CERT_DIR", tmp_path), patch("charm.ensure_storage_ready"), patch("charm.shutil.which", return_value="/usr/bin/doveconf"), - # Isolate from dovecot/procmail filesystem writes patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys"), ctx(ctx.on.config_changed(), base_state) as mgr, ): # Override the TLS library instance so get_assigned_certificate @@ -87,6 +86,7 @@ def test_setup_tls_no_ca_omits_chain(ctx, base_state, tmp_path): patch("charm.shutil.which", return_value="/usr/bin/doveconf"), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys"), ctx(ctx.on.config_changed(), base_state) as mgr, ): mgr.charm._tls = MagicMock() @@ -143,6 +143,7 @@ def test_certificate_available_event_triggers_reconcile(ctx, base_state, tmp_pat ), patch("charm.DovecotCharm._setup_dovecot"), patch("charm.DovecotCharm._setup_procmail"), + patch("ha.setup_ssh_keys"), ): # Fire certificate_available via config_changed (same handler) state_out = ctx.run(ctx.on.config_changed(), base_state)