Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ac82bfb
feat(dovecot-charm): add TLS certificate integration via certificates…
alithethird Apr 3, 2026
32b0441
docs: add release notes for pr/3-tls
alithethird Apr 3, 2026
261d741
Refactor TLS certificate tests and update dependencies
alithethird Apr 9, 2026
7c634f8
fix: vale
alithethird Apr 13, 2026
2d793f2
fix: vale
alithethird Apr 13, 2026
0c52c18
fix(tls): make ssl=required conditional on cert file presence
alithethird Apr 14, 2026
fbe8652
fix(tests): ensure mountpoint check executes correctly in data persis…
alithethird Apr 14, 2026
b9d86c7
fix(tests): update LUKS secret generation in dovecot charm tests
alithethird Apr 14, 2026
839d37e
fix(test): mock ensure_storage_ready in TLS tests to prevent Permissi…
alithethird Apr 20, 2026
d255443
refactor(tls): make TLS mandatory via _setup_tls in _reconcile
alithethird Apr 20, 2026
cd5d5e4
delete(docs): remove Dovecot charm state diagrams documentation
alithethird Apr 20, 2026
bf98cee
refactor(tls): streamline TLS integration in tests and remove redunda…
alithethird Apr 20, 2026
6d24bfe
test(tls): add TLS tests and remove unused deploy_with_tls parameter
alithethird Apr 20, 2026
96ad970
feat(dovecot-charm): add HA support with SSH key exchange and force-s…
alithethird Apr 20, 2026
6c78502
docs: add release notes for pr/4-ha
alithethird Apr 20, 2026
2224e3a
refactor(tests): clean up TLS test cases by removing unused test and …
alithethird Apr 20, 2026
e3ba9af
refactor(ha): holistic reconcile, fix security and test issues
alithethird Apr 20, 2026
e741195
chore: fmt
alithethird Apr 20, 2026
2c94239
fix(tls): correct charm name in TLS relation integration
alithethird Apr 20, 2026
4463014
refactor(tls): replace inline status check with jubilant.all_active f…
alithethird Apr 20, 2026
9e96d05
fix(tls): close plaintext ports 143 and 110 since TLS is mandatory
alithethird Apr 20, 2026
d807952
feat(dovecot-charm): add HA support with SSH key exchange and force-s…
alithethird Apr 20, 2026
e1c91ee
docs: add release notes for pr/4-ha
alithethird Apr 20, 2026
d3c292f
refactor(ha): holistic reconcile, fix security and test issues
alithethird Apr 20, 2026
0b20264
feat(tests): add integration tests for high availability support
alithethird Apr 20, 2026
802fef5
Merge branch 'pr/3-tls' into pr/4-ha
alithethird Apr 20, 2026
4854b3d
feat(ha): add known_hosts synchronization for SSH key exchange
alithethird Apr 20, 2026
dc37e92
feat(ha): ensure system user exists for doveadm user lookup in sync s…
alithethird Apr 21, 2026
7ed1e20
Merge branch 'main' into pr/4-ha
alithethird Apr 21, 2026
7771996
refactor(tests): streamline TLS setup tests by removing unnecessary p…
alithethird Apr 21, 2026
c0e37e6
refactor: remove redundant TLS setup method from DovecotCharm
alithethird Apr 21, 2026
ec1bb23
feat(ha): implement high availability functions and refactor SSH key …
alithethird Apr 21, 2026
700cd90
refactor(tests): increase timeout for unit activation in HA and stora…
alithethird Apr 21, 2026
28beade
refactor(ha): enhance SSH key generation error handling and update cr…
alithethird Apr 22, 2026
01aaf94
chore: fmt
alithethird Apr 22, 2026
5489322
refactor(ha): enhance dual unit support in HA tests and improve sync …
alithethird Apr 22, 2026
f803c20
feat(ha): add SSH drop-in configuration for PermitRootLogin and valid…
alithethird Apr 24, 2026
33bc065
feat(ha): enhance cron schedule validation and add unit tests for dis…
alithethird Apr 24, 2026
599a59d
feat(ha): add sync_schedule field to DovecotConfig and validate cron …
alithethird Apr 24, 2026
18de7ab
feat(ha): ensure privsep directory exists for SSHD config checks
alithethird Apr 24, 2026
d7665c1
feat(ha): enhance sync_schedule validation to disallow question mark …
alithethird Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down
18 changes: 18 additions & 0 deletions docs/release-notes/artifacts/pr-4-ha.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/release-notes/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ Releases
release-notes-0002
release-notes-0003
release-notes-0004
release-notes-0005
57 changes: 57 additions & 0 deletions docs/release-notes/release-notes-0005.rst
Original file line number Diff line number Diff line change
@@ -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 <release_notes_index>`.

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 <https://github.com/canonical/mailserver-operators/pull/15>`_

Bug fixes
---------

No bug fixes in this release.

Known issues
------------

No known issues.
2 changes: 2 additions & 0 deletions dovecot-charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
80 changes: 74 additions & 6 deletions dovecot-charm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)
Expand All @@ -49,25 +51,25 @@ 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)
self.framework.observe(self.on.upgrade_charm, self._on_install)
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
Comment thread
alithethird marked this conversation as resolved.
)

# TLS certificates integration
self._tls = None
mailname = self.config.get("mailname", "")
if mailname:
Expand Down Expand Up @@ -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
Comment thread
alithethird marked this conversation as resolved.
Comment thread
alithethird marked this conversation as resolved.

Comment thread
alithethird marked this conversation as resolved.
def _get_dovecot_config(self) -> DovecotConfig:
"""Craft the DovecotConfig from charm configuration and validate it.

Expand Down Expand Up @@ -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()
Expand All @@ -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."""
Comment thread
alithethird marked this conversation as resolved.
self.unit.status = MaintenanceStatus("Installing required dependencies")
apt.update()
apt.add_package(REQUIRED_PACKAGES)
Expand Down Expand Up @@ -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"})
Comment thread
alithethird marked this conversation as resolved.
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)
Comment thread
alithethird marked this conversation as resolved.

def _setup_tls(self, dovecot_config: DovecotConfig) -> None:
"""Write TLS cert+key to disk from the certificates relation.

Expand Down
12 changes: 12 additions & 0 deletions dovecot-charm/src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
alithethird marked this conversation as resolved.
21 changes: 21 additions & 0 deletions dovecot-charm/src/dovecot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Dovecot charm configuration."""

import logging
import re
from typing import TYPE_CHECKING

from ops import ModelError, SecretNotFoundError
Expand Down Expand Up @@ -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
Expand All @@ -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")
Comment thread
alithethird marked this conversation as resolved.
return " ".join(fields)

@field_validator("primary_unit", mode="after")
@classmethod
def _validate_primary_unit_exists(cls, value: str, info: ValidationInfo) -> str:
Expand Down Expand Up @@ -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},
)
Expand Down
6 changes: 6 additions & 0 deletions dovecot-charm/src/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading