From fbd62d16ca5395f948e6772fd9896b8e112ce6d7 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 5 Aug 2026 16:13:18 +0200 Subject: [PATCH 01/17] fix!: drop unimplemented --type ipv6 from deploy-cmdeploy --- src/cmlxc/driver_cmdeploy.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/cmlxc/driver_cmdeploy.py b/src/cmlxc/driver_cmdeploy.py index 9b87779..d115103 100644 --- a/src/cmlxc/driver_cmdeploy.py +++ b/src/cmlxc/driver_cmdeploy.py @@ -31,9 +31,9 @@ def add_cli_options(cls, parser, completer=None): parser.add_argument( "--type", dest="type", - choices=["dns", "ipv4", "ipv6"], + choices=["dns", "ipv4"], default="dns", - help="Deploy the relay using dns (default), ipv4, or ipv6.", + help="Deploy the relay using dns (default) or a bare ipv4 literal.", ) parser.add_argument( "--filtermail", @@ -51,10 +51,6 @@ def get_test_domain_or_ip(self): if not self.ct.ipv4: self.ct.wait_ready() match self.type: - case "ipv6": - if not self.ct.ipv6: - raise SetupError(f"{self.ct.name} has no IPv6 address.") - return self.ct.ipv6 case "ipv4": return self.ct.ipv4 case _: From d2c3b341c6060033ca60a56de3cfecd7d74910d1 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 5 Aug 2026 16:15:11 +0200 Subject: [PATCH 02/17] fix: publish AAAA records so --type dns relays are really dual-stack --- src/cmlxc/container.py | 7 +++++++ src/cmlxc/driver_cmdeploy.py | 19 ++++++++++++++----- tests/test_incus.py | 27 ++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/cmlxc/container.py b/src/cmlxc/container.py index 9301d18..c4bbfdd 100644 --- a/src/cmlxc/container.py +++ b/src/cmlxc/container.py @@ -98,6 +98,13 @@ def format_ssh_config(containers, key_path): return "".join(lines) +def address_records(ct): + lines = [f"{ct.domain}. 3600 IN A {ct.ipv4}"] + if ct.ipv6: + lines.append(f"{ct.domain}. 3600 IN AAAA {ct.ipv6}") + return "\n".join(lines) + "\n" + + class Container: """Base container handle wrapping incus interactions.""" diff --git a/src/cmlxc/driver_cmdeploy.py b/src/cmlxc/driver_cmdeploy.py index d115103..c2122b8 100644 --- a/src/cmlxc/driver_cmdeploy.py +++ b/src/cmlxc/driver_cmdeploy.py @@ -7,12 +7,22 @@ import time from pathlib import Path -from cmlxc.container import SetupError +from cmlxc.container import SetupError, address_records from cmlxc.driver_base import Driver CMDEPLOY = "cmdeploy" +def ensure_ipv6_known(ct): + if not ct.ipv6 and not ct.is_ipv6_disabled: + ct.wait_ready(expect_ipv6=True) + + +def verify_dual_stack_zone(ct, zone_content): + if ct.ipv6 and "AAAA" not in zone_content: + raise SetupError(f"{ct.shortname}: dual-stack relay, zone has no AAAA") + + class CmdeployDriver(Driver): """Deploys chatmail relays via the ``cmdeploy`` tool.""" @@ -124,10 +134,8 @@ def deploy(self, source=None): domain = self.get_test_domain_or_ip() if self.type == "dns": - dns_ct.set_dns_records( - domain, - f"{domain}. 3600 IN A {self.ct.ipv4}", - ) + ensure_ipv6_known(self.ct) + dns_ct.set_dns_records(domain, address_records(self.ct)) with self.out.section(f"cmdeploy run: {self.ct.shortname} ({domain})"): self.out.print("Preparing chatmail.ini on builder ...") @@ -147,6 +155,7 @@ def deploy(self, source=None): self._run_cmdeploy("dns", "--zonefile", zone_path) zone_content = self.bld_ct.bash(f"cat {zone_path}") + verify_dual_stack_zone(self.ct, zone_content) self.out.print(" Loading zone content into PowerDNS ...") dns_ct.set_dns_records(self.ct.domain, zone_content) # Flush stale NXDOMAIN entries cached during initial checks diff --git a/tests/test_incus.py b/tests/test_incus.py index cf400b0..e370c61 100644 --- a/tests/test_incus.py +++ b/tests/test_incus.py @@ -10,9 +10,10 @@ RelayContainer, SetupError, _extract_ip, + address_records, format_ssh_config, ) -from cmlxc.driver_cmdeploy import CmdeployDriver +from cmlxc.driver_cmdeploy import CmdeployDriver, verify_dual_stack_zone from cmlxc.driver_madmail import MadmailDriver from cmlxc.incus import ( Incus, @@ -57,6 +58,30 @@ def test_extract_ip(): assert _extract_ip({}) is None +def test_address_records(ix): + ct = RelayContainer(ix, "t0") + ct.ipv4 = "10.0.0.5" + ct.ipv6 = "fd42::1" + assert address_records(ct) == ( + "_t0.localchat. 3600 IN A 10.0.0.5\n_t0.localchat. 3600 IN AAAA fd42::1\n" + ) + # v4-only container -> A record only + ct.ipv6 = None + assert address_records(ct) == "_t0.localchat. 3600 IN A 10.0.0.5\n" + + +def test_verify_dual_stack_zone(ix): + ct = RelayContainer(ix, "t0") + ct.ipv6 = "fd42::1" + # dual-stack without AAAA + with pytest.raises(SetupError, match="no AAAA"): + verify_dual_stack_zone(ct, "_t0.localchat. 3600 IN A 10.0.0.5\n") + verify_dual_stack_zone(ct, "_t0.localchat. 3600 IN AAAA fd42::1\n") + # v4-only relay + ct.ipv6 = None + verify_dual_stack_zone(ct, "_t0.localchat. 3600 IN A 10.0.0.5\n") + + def test_is_ip_address(): assert _is_ip_address("10.0.0.1") is True assert _is_ip_address("fd42::1") is True From e6001b3fc83ddfd8b94b7bc79998d0c5e00c6231 Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 20 Apr 2026 16:17:41 +0200 Subject: [PATCH 03/17] fix(container,incus): filter container IPs by Incus bridge subnet Containers with Docker or other networking can expose IPs on multiple interfaces. Extend _extract_ip() to accepts an optional subnet filter and add bridge_subnet() to get the bridge subnet so wait_ready() and list_managed() only pick addresses on incusbr0. --- src/cmlxc/container.py | 15 ++++++++++++--- src/cmlxc/incus.py | 20 +++++++++++++++++++- tests/test_incus.py | 26 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/cmlxc/container.py b/src/cmlxc/container.py index c4bbfdd..22067f6 100644 --- a/src/cmlxc/container.py +++ b/src/cmlxc/container.py @@ -5,6 +5,7 @@ All interaction with Incus containers goes through these types. """ +import ipaddress import shlex import socket import subprocess @@ -49,12 +50,15 @@ class SetupError(Exception): """User-facing error raised when a pre-condition is not met.""" -def _extract_ip(net_data, family="inet"): +def _extract_ip(net_data, family="inet", subnet=None): for iface_name, iface in net_data.items(): if iface_name == "lo": continue for addr in iface.get("addresses", []): if addr["family"] == family and addr["scope"] == "global": + if subnet is not None: + if ipaddress.ip_address(addr["address"]) not in subnet: + continue return addr["address"] return None @@ -290,7 +294,10 @@ def wait_ready(self, timeout=60, expect_ipv6=False): ) if data and data[0].get("status") == "Running": net = data[0].get("state", {}).get("network", {}) - self.ipv4 = _extract_ip(net, "inet") + # Docker relays expose docker0 alongside eth0, so restrict the + # IPv4 pick to the incus bridge subnet. No v6 filter for now as + # Docker's v6 support is off by default. + self.ipv4 = _extract_ip(net, "inet", subnet=self.incus.bridge_subnet) self.ipv6 = _extract_ip(net, "inet6") if self.ipv4 and (not expect_ipv6 or self.ipv6): return @@ -308,7 +315,9 @@ def wait_ready(self, timeout=60, expect_ipv6=False): msg = f"Container {self.name!r} did not become ready within {timeout}s" if not self.ipv4: - msg += " (No IPv4 address obtained)" + subnet = self.incus.bridge_subnet + where = f" within incus bridge subnet {subnet}" if subnet else "" + msg += f" (No IPv4 address obtained{where})" elif expect_ipv6 and not self.ipv6: msg += " (Has IPv4, but NO IPv6 address obtained)" raise TimeoutError(msg) diff --git a/src/cmlxc/incus.py b/src/cmlxc/incus.py index 18fb3c9..d1f5f26 100644 --- a/src/cmlxc/incus.py +++ b/src/cmlxc/incus.py @@ -82,6 +82,24 @@ def __init__(self, out): check=True, ) self.ssh_config_path = self.config_dir / "ssh-config" + self._bridge_subnet = NotImplemented + + @property + def bridge_subnet(self): + """Return the IPv4 subnet of incusbr0, used to filter container IPs.""" + if self._bridge_subnet is NotImplemented: + self._bridge_subnet = None + result = self.run( + ["network", "get", "incusbr0", "ipv4.address"], check=False + ) + if result.returncode == 0 and result.stdout.strip(): + try: + self._bridge_subnet = ipaddress.ip_network( + result.stdout.strip(), strict=False + ) + except ValueError: + pass + return self._bridge_subnet def write_ssh_config(self): """Write ``ssh-config`` mapping all containers to their IPs.""" @@ -210,7 +228,7 @@ def list_managed(self): containers.append( { "name": name, - "ip": _extract_ip(net, "inet"), + "ip": _extract_ip(net, "inet", subnet=self.bridge_subnet), "ipv6": _extract_ip(net, "inet6"), "domain": config.get(LABEL_DOMAIN, f"{name}{DOMAIN_SUFFIX}"), "status": ct.get("status", "Unknown"), diff --git a/tests/test_incus.py b/tests/test_incus.py index e370c61..1b2bf41 100644 --- a/tests/test_incus.py +++ b/tests/test_incus.py @@ -1,5 +1,6 @@ """Lightweight unit tests for pure logic in cmlxc.incus.""" +import ipaddress import shutil import pytest @@ -58,6 +59,31 @@ def test_extract_ip(): assert _extract_ip({}) is None +def test_extract_ip_subnet_filter(): + # docker0 comes first, so an unfiltered pick returns the wrong address; + # this is the existential reason for the subnet filter. + net = { + "docker0": { + "addresses": [ + {"family": "inet", "address": "172.17.0.1", "scope": "global"}, + ] + }, + "eth0": { + "addresses": [ + {"family": "inet", "address": "10.0.0.5", "scope": "global"}, + ] + }, + } + bridge = ipaddress.ip_network("10.0.0.0/24") + assert _extract_ip(net, "inet") == "172.17.0.1" + assert _extract_ip(net, "inet", subnet=bridge) == "10.0.0.5" + # nothing in the bridge subnet, other than a docker address -> None + only_docker = {"docker0": net["docker0"]} + assert _extract_ip(only_docker, "inet", subnet=bridge) is None + # subnet=None keeps unfiltered behaviour + assert _extract_ip(net, "inet", subnet=None) == "172.17.0.1" + + def test_address_records(ix): ct = RelayContainer(ix, "t0") ct.ipv4 = "10.0.0.5" From b16302a67ab8281adbdfc138cc432b895d7fafcb Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 20 Apr 2026 16:18:10 +0200 Subject: [PATCH 04/17] refactor(cli,incus): extract check_init() to Incus class Move the initialization check (DNS container running + base image present) from cli._check_init() into Incus.check_init() so drivers can call it without depending on the CLI module. --- src/cmlxc/cli.py | 10 +--------- src/cmlxc/driver_base.py | 15 +-------------- src/cmlxc/incus.py | 23 +++++++++++++++++++++-- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index 8b3eb92..595bf35 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -42,15 +42,7 @@ def _container_completer(prefix, **kwargs): def _check_init(ix, out): - managed = ix.list_managed() - dns_running = any( - c["name"] == DNS_CONTAINER_NAME and c["status"] == "Running" for c in managed - ) - if not dns_running or not ix.find_image([BASE_IMAGE_ALIAS]): - out.red("Error: cmlxc environment not initialized.") - out.red("Please run 'cmlxc init' first to set up the base image and DNS.") - return False - return True + return ix.check_init() def _destroy_all(ix, out): diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index 0706592..9bfd5a5 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -16,8 +16,6 @@ __version__ = "unknown" from cmlxc.container import ( - BASE_IMAGE_ALIAS, - DNS_CONTAINER_NAME, BuilderContainer, DNSContainer, ) @@ -124,18 +122,7 @@ def __init__(self, ct, out): def check_init(self): """Verify that the cmlxc environment has been initialized.""" - managed = self.ix.list_managed() - dns_running = any( - c["name"] == DNS_CONTAINER_NAME and c["status"] == "Running" - for c in managed - ) - if not dns_running or not self.ix.find_image([BASE_IMAGE_ALIAS]): - self.out.red("Error: cmlxc environment not initialized.") - self.out.red( - "Please run 'cmlxc init' first to set up the base image and DNS." - ) - return False - return True + return self.ix.check_init() def get_builder(self): """Return the running builder container, or None. diff --git a/src/cmlxc/incus.py b/src/cmlxc/incus.py index d1f5f26..12424a8 100644 --- a/src/cmlxc/incus.py +++ b/src/cmlxc/incus.py @@ -17,6 +17,7 @@ from cmlxc.container import ( BASE_IMAGE_ALIAS, + DNS_CONTAINER_NAME, DOMAIN_SUFFIX, LABEL_DEPLOY_DRIVER, LABEL_DEPLOY_SOURCE, @@ -34,6 +35,10 @@ BASE_SETUP_NAME = "localchat-base-setup" +# In subnet lookup, None means "no bridge subnet found", +# so "not looked up yet" needs a distinct object. +_UNSET = object() + def _is_ip_address(s): try: @@ -82,12 +87,12 @@ def __init__(self, out): check=True, ) self.ssh_config_path = self.config_dir / "ssh-config" - self._bridge_subnet = NotImplemented + self._bridge_subnet = _UNSET @property def bridge_subnet(self): """Return the IPv4 subnet of incusbr0, used to filter container IPs.""" - if self._bridge_subnet is NotImplemented: + if self._bridge_subnet is _UNSET: self._bridge_subnet = None result = self.run( ["network", "get", "incusbr0", "ipv4.address"], check=False @@ -101,6 +106,20 @@ def bridge_subnet(self): pass return self._bridge_subnet + def check_init(self): + managed = self.list_managed() + dns_running = any( + c["name"] == DNS_CONTAINER_NAME and c["status"] == "Running" + for c in managed + ) + if not dns_running or not self.find_image([BASE_IMAGE_ALIAS]): + self.out.red("Error: cmlxc environment not initialized.") + self.out.red( + "Please run 'cmlxc init' first to set up the base image and DNS." + ) + return False + return True + def write_ssh_config(self): """Write ``ssh-config`` mapping all containers to their IPs.""" containers = self.list_managed() From 8bddf0435a31d8e7988da0615917e8a9591a7e75 Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 20 Apr 2026 16:18:47 +0200 Subject: [PATCH 05/17] feat(container): add extra_config parameter to container launch()/ensure() Allows drivers to pass additional Incus config keys (e.g. security.nesting=true for Docker-in-LXC) when launching containers. Passed through Container and RelayContainer. --- src/cmlxc/container.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/cmlxc/container.py b/src/cmlxc/container.py index 22067f6..06e0d23 100644 --- a/src/cmlxc/container.py +++ b/src/cmlxc/container.py @@ -143,7 +143,7 @@ def stop(self, force=False): cmd.append("--force") self.incus.run(cmd, check=False) - def launch(self, image_candidates=None): + def launch(self, image_candidates=None, extra_config=None): """Launch from the base image or a provided candidate.""" if image_candidates is None: image_candidates = [BASE_IMAGE_ALIAS] @@ -158,6 +158,9 @@ def launch(self, image_candidates=None): cfg = [] cfg += ("-c", f"{LABEL_KEY}=true") cfg += ("-c", f"{LABEL_DOMAIN}={self.domain}") + if extra_config: + for k, v in extra_config.items(): + cfg += ("-c", f"{k}={v}") self.incus.run(["launch", image, self.name, *cfg]) return image @@ -175,7 +178,7 @@ def is_ipv6_disabled(self): ) return result == "1" - def ensure(self, ipv4_only=False, image_candidates=None): + def ensure(self, ipv4_only=False, image_candidates=None, extra_config=None): data = self.incus.run_json(["list", self.name], check=False) or [] existing = [c for c in data if c["name"] == self.name] @@ -185,7 +188,7 @@ def ensure(self, ipv4_only=False, image_candidates=None): if not ipv4_only: self.enable_ipv6() else: - self.launch(image_candidates=image_candidates) + self.launch(image_candidates=image_candidates, extra_config=extra_config) self.wait_ready(expect_ipv6=not ipv4_only) if ipv4_only: self.disable_ipv6() @@ -372,8 +375,10 @@ def destroy(self): ) super().destroy() - def launch(self, image_candidates=None): - image = super().launch(image_candidates=image_candidates) + def launch(self, image_candidates=None, extra_config=None): + image = super().launch( + image_candidates=image_candidates, extra_config=extra_config + ) # Re-inject the current SSH key; cached images may have a stale one. pub_key = self.incus.ssh_key_path.with_suffix(".pub").read_text().strip() self.bash(f""" @@ -387,12 +392,15 @@ def launch(self, image_candidates=None): """) return image - def ensure(self, ipv4_only=False, image_candidates=None): + def ensure(self, ipv4_only=False, image_candidates=None, extra_config=None): out = self.out out.green(f"Ensuring container {self.name!r} ({self.domain}) ...") - super().ensure(ipv4_only=ipv4_only, image_candidates=image_candidates) - + super().ensure( + ipv4_only=ipv4_only, + image_candidates=image_candidates, + extra_config=extra_config, + ) if self.get_deploy_state(): self.wait_services() From d44be651799fc09dc2fbd5f83cc5d0df5c7f406f Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 22 Apr 2026 16:58:23 +0200 Subject: [PATCH 06/17] feat(driver_base): support SHA-based source refs in init_builder When the source ref is a full 40-char SHA, e.g., from CI dispatch, the shallow git-main clone won't have it. Detect this case and fetch just that commit with --depth 1 before checkout. --- src/cmlxc/driver_base.py | 22 +++++++++++++++++++++- tests/test_cli.py | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index 9bfd5a5..0c10ccf 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -77,6 +77,14 @@ def parse_source(value: str, default_url: str) -> SourceSpec: raise ValueError(f"Invalid SOURCE: {value!r}. Use @ref, /path, ./path, or URL@ref.") +_SHA_RE = re.compile(r"[0-9a-f]{40}") + + +def is_sha(ref): + """Return True if ref is a full 40-char git SHA""" + return bool(_SHA_RE.fullmatch(ref or "")) + + _RELAY_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$") @@ -257,8 +265,20 @@ def init_builder(self, source): f" Copying {self.REPO_NAME}-git-main to {repo_path} on builder" ) self.bld_ct.bash(f"rm -rf {repo_path} && cp -a {tmp_dest} {repo_path}") - if source.ref != "main": + ref_is_sha = is_sha(source.ref) + if ref_is_sha: + # Shallow clone won't have arbitrary commits; fetch just this one. + self.out.print(f" Fetching {source.ref[:12]} ...") + self.bld_ct.bash( + f"cd {repo_path} && git fetch --depth 1 origin {source.ref}" + ) + elif source.ref != "main": self.out.print(f" Checking out {source.ref!r} ...") + reset_cmd = "" + if not ref_is_sha: + reset_cmd = ( + f"git reset --hard -q origin/{source.ref} 2>/dev/null || true" + ) self.bld_ct.bash(f""" cd {repo_path} git checkout -q {source.ref} diff --git a/tests/test_cli.py b/tests/test_cli.py index fe991ae..8ee5b20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ import pytest -from cmlxc.driver_base import SourceSpec, parse_source, validate_relay_name +from cmlxc.driver_base import SourceSpec, is_sha, parse_source, validate_relay_name from cmlxc.driver_cmdeploy import get_ini_overrides from cmlxc.driver_madmail import release_asset_url @@ -57,6 +57,24 @@ def test_parse_source_rejects_unsafe_ref(value): parse_source(value, URL) +@pytest.mark.parametrize( + "ref, expected", + [ + ("a" * 40, True), + ("0123456789abcdef0123456789abcdef01234567", True), + ("A" * 40, False), # git object names are lowercase hex + ("a" * 39, False), + ("a" * 41, False), + ("main", False), + ("v1.2.3", False), + ("", False), + (None, False), + ], +) +def test_is_sha(ref, expected): + assert is_sha(ref) is expected + + @pytest.mark.parametrize("bad", [".", "..", "../relay", "/path", "a/b", "a.b"]) def test_validate_relay_name_rejects_invalid(bad): with pytest.raises(ValueError, match="Invalid relay name"): From 787f66a8f12ab7ed56e1b50e38e823ee8cd0c2ef Mon Sep 17 00:00:00 2001 From: j4n Date: Tue, 28 Apr 2026 10:08:16 +0200 Subject: [PATCH 07/17] fix(init_builder): skip git reset --hard for SHA refs The `git reset --hard origin/{ref}` is only useful for branch refs, for SHA refs it always fails silently since there's no remote tracking branch. Only run it for branch refs. --- src/cmlxc/driver_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index 0c10ccf..db5bad3 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -282,7 +282,7 @@ def init_builder(self, source): self.bld_ct.bash(f""" cd {repo_path} git checkout -q {source.ref} - git reset --hard -q origin/{source.ref} 2>/dev/null || true + {reset_cmd} git clean -fdx if [ -f .gitmodules ]; then git submodule update --init --recursive From 5fde83f2d356678ade34794fc48be200d4d34ad6 Mon Sep 17 00:00:00 2001 From: j4n Date: Fri, 31 Jul 2026 19:53:16 +0200 Subject: [PATCH 08/17] refactor(driver_cmdeploy): extract shared pytest runner, restore on_init_relay Extract the cmdeploy test logic into a standalone run_test_cmdeploy() so any cmdeploy-based driver can call it, not just CmdeployDriver. --- src/cmlxc/driver_cmdeploy.py | 47 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/cmlxc/driver_cmdeploy.py b/src/cmlxc/driver_cmdeploy.py index c2122b8..846a287 100644 --- a/src/cmlxc/driver_cmdeploy.py +++ b/src/cmlxc/driver_cmdeploy.py @@ -23,6 +23,32 @@ def verify_dual_stack_zone(ct, zone_content): raise SetupError(f"{ct.shortname}: dual-stack relay, zone has no AAAA") +def run_test_cmdeploy(driver, test_addr, second_domain=None): + """Run the cmdeploy pytest suite via incus exec on the builder. + + Shared by CmdeployDriver and DockerDriver, test_addr is the address + already resolved by the caller. + """ + env = {"CHATMAIL_INI": f"{driver.repo_path}/chatmail.ini"} + if second_domain: + env["CHATMAIL_DOMAIN2"] = second_domain + + driver.out.print(f"Running cmdeploy tests against {test_addr} ...") + + env_args = "".join(f" --env {k}={v}" for k, v in env.items()) + cmd = ( + f"incus exec {driver.bld_ct.name}{env_args} --" + f" bash -c '" + f" source {driver.venv_path}/bin/activate &&" + f" cd {driver.repo_path} &&" + f" pytest cmdeploy/src/ -n4 -rs -x -v --durations=5'" + ) + ret = driver.out.shell(cmd) + if ret: + driver.out.red(f"test-cmdeploy failed (exit {ret})") + return ret + + class CmdeployDriver(Driver): """Deploys chatmail relays via the ``cmdeploy`` tool.""" @@ -102,26 +128,7 @@ def run_tests(self, second_domain=None): write_ini( self.bld_ct, self.ct, domain, disable_ipv6=self.ct.is_ipv6_disabled ) - - ini_path = f"{self.repo_path}/chatmail.ini" - env = {"CHATMAIL_INI": ini_path} - if second_domain: - env["CHATMAIL_DOMAIN2"] = second_domain - - self.out.print(f"Running cmdeploy tests against {domain} ...") - - env_args = "".join(f" --env {k}={v}" for k, v in env.items()) - cmd = ( - f"incus exec {self.bld_ct.name}{env_args} --" - f" bash -c '" - f" source {self.venv_path}/bin/activate &&" - f" cd {self.repo_path} &&" - f" pytest cmdeploy/src/ -n4 -rs -x -v --durations=5'" - ) - ret = self.out.shell(cmd) - if ret: - self.out.red(f"test-cmdeploy failed (exit {ret})") - return ret + return run_test_cmdeploy(self, domain, second_domain) def deploy(self, source=None): """Deploy chatmail services to a single relay via cmdeploy.""" From 88b92848370a638af154465019a4c5780508699e Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 18 May 2026 15:36:59 +0200 Subject: [PATCH 09/17] refactor(driver_cmdeploy): extract TEST_INI_OVERRIDES and make_ini_script Both the cmdeploy and docker drivers need to generate chatmail.ini with relaxed rate limits for testing. Extract the overrides dict and the Python snippet builder into shared helpers. --- src/cmlxc/driver_cmdeploy.py | 54 +++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/cmlxc/driver_cmdeploy.py b/src/cmlxc/driver_cmdeploy.py index 846a287..c238f84 100644 --- a/src/cmlxc/driver_cmdeploy.py +++ b/src/cmlxc/driver_cmdeploy.py @@ -203,40 +203,54 @@ def _run_cmdeploy(self, subcmd, *extra): # ------------------------------------------------------------------ +TEST_INI_OVERRIDES = { + "max_user_send_per_minute": 600, + "max_user_send_burst_size": 100, + # Relays reject new address creation while the machine looks + # busy, which a CI runner hosting several containers always + # does. Test relays are throwaway, so lift the gates instead + # of losing accounts to unrelated load. + "max_load_1m": 1000, + "min_available_memory": "1M", + "min_free_disk_space": "1M", + "mtail_address": "127.0.0.1", +} + + def get_ini_overrides(domain, disable_ipv6=False): """Return chatmail.ini settings suited for throwaway test relays.""" - overrides = { - "max_user_send_per_minute": 600, - "max_user_send_burst_size": 100, - # Relays reject new address creation while the machine looks - # busy, which a CI runner hosting several containers always - # does. Test relays are throwaway, so lift the gates instead - # of losing accounts to unrelated load. - "max_load_1m": 1000, - "min_available_memory": "1M", - "min_free_disk_space": "1M", - "mtail_address": "127.0.0.1", - "ssh_host": domain, - } + overrides = dict(TEST_INI_OVERRIDES) + overrides["ssh_host"] = domain if disable_ipv6: overrides["disable_ipv6"] = "True" return overrides -def write_ini(builder_ct, ct, domain, disable_ipv6=False): - """Write a chatmail.ini for *ct* using the builder container.""" - overrides = get_ini_overrides(domain, disable_ipv6=disable_ipv6) +def make_ini_script(domain, ini_path, overrides): + """Return a Python -c snippet that calls write_initial_config.""" overrides_str = ", ".join( f"'{k}': '{v}'" if isinstance(v, str) else f"'{k}': {v}" for k, v in overrides.items() ) + return "\n".join( + [ + "from chatmaild.config import write_initial_config", + "from pathlib import Path", + f"write_initial_config(Path('{ini_path}'), '{domain}', {{{overrides_str}}})", + ] + ) + + +def write_ini(builder_ct, ct, domain, disable_ipv6=False): + """Write a chatmail.ini for *ct* using the builder container.""" + overrides = get_ini_overrides(domain, disable_ipv6=disable_ipv6) repo_path = ct.get_repo_path(CMDEPLOY) ini_path = f"{repo_path}/chatmail.ini" + venv_path = f"{repo_path}/venv" + script = make_ini_script(domain, ini_path, overrides) builder_ct.bash(f""" - source {repo_path}/venv/bin/activate + source {venv_path}/bin/activate python3 -c " -from chatmaild.config import write_initial_config -from pathlib import Path -write_initial_config(Path('{ini_path}'), '{domain}', {{{overrides_str}}}) +{script} " """) From 4eb5a1aeca1dd1ccd4d86f29f40272778822bcf0 Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 18 May 2026 13:51:03 +0200 Subject: [PATCH 10/17] feat(container): add bash_get() and bash_do() helpers Two wrappers for Container.bash() with and without failure checking. --- src/cmlxc/container.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/cmlxc/container.py b/src/cmlxc/container.py index 06e0d23..be562ea 100644 --- a/src/cmlxc/container.py +++ b/src/cmlxc/container.py @@ -127,6 +127,23 @@ def bash(self, script, check=True): cmd = ["exec", self.name, "--", "bash", "-ec", script] return self.incus.run_output(cmd, check=check) + def bash_get(self, script): + """Run script, return stdout or None on failure without printing errors. + + Use for existence checks and polls where None is the expected "absent" signal. + """ + return self.bash(script, check=False) + + def bash_do(self, script): + """Run script, print errors on failure but return None instead of raising. + + Use when failure should be visible and the caller handles the None return. + """ + try: + return self.bash(script) + except subprocess.CalledProcessError: + return None + def run_cmd(self, *args, check=True): """Run command in container and return stdout.""" return self.incus.run_output( From de390f955aeefd6ca3138905bf7eacef71af1b2b Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 12 Aug 2026 10:58:50 +0200 Subject: [PATCH 11/17] refactor(driver_base): make running initenv.sh / test-cmdeploy the default Just the madmail driver overrides. --- src/cmlxc/cli.py | 9 ++++++++- src/cmlxc/driver_base.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index 595bf35..ba2f8b3 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -265,7 +265,14 @@ def test_cmdeploy_cmd(args, out): """Run cmdeploy integration tests inside the builder container.""" ix = Incus(out) ct = ix.get_running_relay(args.relay) - driver = CmdeployDriver(ct, out) + drv_cls = DRIVER_BY_NAME.get(ct.driver_name) + if drv_cls is None: + out.red( + f"Warning: unknown driver {ct.driver_name!r} for" + f" {ct.shortname}, falling back to cmdeploy." + ) + drv_cls = CmdeployDriver + driver = drv_cls(ct, out) if not driver.check_init(): return 1 diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index db5bad3..68961a1 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -203,8 +203,14 @@ def on_prep_builder(cls, out, bld_ct, tmp_dest): pass def on_init_relay(self, repo_path, tag): - """Hook called by ``init_builder`` after a relay checkout is ready.""" - pass + """Hook called by ``init_builder`` after a relay checkout is ready. + + Defaults to running scripts/initenv.sh, which cmdeploy-based drivers + need. Override without calling super for a driver that builds + the repo differently. + """ + self.out.print(f" Running scripts/initenv.sh for {self.ct.shortname} ...") + self.bld_ct.bash(f"cd {repo_path} && bash scripts/initenv.sh") @classmethod def get_git_main_path(cls, bld_ct, out): From bf795f039d667b082b87dc81098547e6965f5a15 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 22 Apr 2026 17:19:44 +0200 Subject: [PATCH 12/17] feat(docker): add Docker relay driver Add DockerDriver for deploying chatmail relays via Docker Compose inside LXC containers (Docker-in-LXC with security.nesting). Features: - Pull pre-built images from GHCR (--source ghcr:TAG) - Inject local builds - Healthcheck polling / log streaming - SSH forwarding into Docker containers (for test compatibility) - DNS zone extraction and PowerDNS loading - security.privileged fenced behind CI=true CLI subcommands: deploy, pull, logs, ps, shell --- README.md | 80 +++- src/cmlxc/driver_docker.py | 794 +++++++++++++++++++++++++++++++++++++ tests/test_docker.py | 89 +++++ 3 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 src/cmlxc/driver_docker.py create mode 100644 tests/test_docker.py diff --git a/README.md b/README.md index 5586000..0e6edc4 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,34 @@ Each `deploy-*` invocation initialises the driver's source in the builder (wipe-and-reclone). +**Deploy via Docker Compose** (runs chatmail inside Docker-in-LXC): + + # Pull a pre-built image directly from GHCR + cmlxc docker deploy --source ghcr:main dk0 + cmlxc docker deploy --source ghcr:sha-ce05b26 dk0 + + # Load a local image tarball + cmlxc docker deploy --image ./chatmail.tar dk0 + + # Inject a locally-built image from the host Docker daemon + cmlxc docker deploy --source docker:chatmail-relay:latest dk0 + +Pull a newer image into an already-deployed relay: + + cmlxc docker pull dk0 + cmlxc docker pull dk0 --tag sha-ce05b26 + +Inspect running services and logs: + + cmlxc docker ps dk0 + cmlxc docker logs dk0 + cmlxc docker logs dk0 -f + +SSH into a Docker service (auto-configured by ``cmlxc``): + + ssh chatmail@dk0.localchat + + **Run integration tests** inside the builder: cmlxc test-mini cm0 @@ -159,13 +187,14 @@ the host only needs `cmlxc` itself. **Relay containers** (e.g. `cm0-localchat`, `mad1-localchat`) -- ephemeral containers that receive a deployed chatmail service. -Each relay is locked to a single deployment driver (`cmdeploy` or -`madmail`); switching requires destroying and re-creating the container. +Each relay is locked to a single deployment driver (`cmdeploy`, +`madmail`, or `docker`); switching requires destroying and re-creating +the container. ### Deployment drivers -Drivers live in `driver_cmdeploy.py` and `driver_madmail.py`. +Drivers live in `driver_cmdeploy.py`, `driver_madmail.py`, and `driver_docker.py`. Each driver module exports its CLI subcommand metadata, builder init, and deploy orchestration. `cli.py` generates the `deploy-*` subcommands from a `DRIVER_BY_NAME` mapping. @@ -179,6 +208,51 @@ builder init, and deploy orchestration. pushes it via SCP and runs `madmail install --simple --ip `. No DNS entries are needed. +- **docker** -- deploys chatmail via Docker Compose inside a Docker-in-LXC +relay container (`security.nesting=true`), either directly pulled from GHCR or +injected from a host docker instance. Docker is installed inside the relay +automatically; no host Docker installation is required. + +#### Docker subcommands + +- `docker deploy RELAY` -- deploy chatmail into a relay container via + Docker Compose. Three image sources are supported: + - `--source ghcr:TAG` -- pull a pre-built image from GHCR directly + into the relay. No builder container is involved. + - `--source docker:TAG` -- pipe a locally-built image from the host + Docker daemon into the relay via `docker save | docker load`. + - `--image PATH` -- load a pre-exported image tarball. + A docker-compose.yaml is fetched from + [chatmail/docker](https://github.com/chatmail/docker) unless + `--compose URL` overrides the source. + +- `docker pull RELAY` -- pull a newer image from GHCR into an already + deployed relay without a full redeploy. Use `--tag` to specify the + image tag (default: `main`). + +- `docker ps RELAY` -- list running Docker Compose services in a relay. + +- `docker logs RELAY` -- show Docker Compose logs (last 100 lines). + Pass `-f` to follow in real time. + +- `docker shell RELAY [SERVICE]` -- open an interactive shell inside + the named Compose service (default: `chatmail`). + +#### SSH forwarding during tests + +`test-cmdeploy` runs against Docker relays over SSH, but the cmdeploy suite +expects to land on the machine running the services. On a Docker relay, SSH +lands on the LXC host while the services live in the container, so +`test-cmdeploy` installs an `authorized_keys` forced command on the relay +that forwards every session into the `chatmail` Compose service: + + ssh root@dk0.localchat # runs inside the chatmail container + +This is set up only by `test-cmdeploy`, not by `deploy` or `status`, and it +replaces direct SSH access to the LXC host. That host is managed via +`incus exec` anyway, so nothing is lost. Other Compose services are not +reachable this way; use `cmlxc docker shell RELAY SERVICE` for those. + ## Releasing diff --git a/src/cmlxc/driver_docker.py b/src/cmlxc/driver_docker.py new file mode 100644 index 0000000..e5bc738 --- /dev/null +++ b/src/cmlxc/driver_docker.py @@ -0,0 +1,794 @@ +"""Docker driver and management commands for cmlxc. + +Contains the DockerDriver (``cmlxc docker deploy``) and the +``docker logs / ps / shell / pull`` CLI subcommands. +""" + +import os +import re +import shlex +import subprocess +import time +from datetime import datetime, timezone +from types import SimpleNamespace + +from cmlxc.container import SetupError, address_records +from cmlxc.driver_base import Driver, parse_source +from cmlxc.driver_cmdeploy import ( + TEST_INI_OVERRIDES, + CmdeployDriver, + ensure_ipv6_known, + make_ini_script, + run_test_cmdeploy, + verify_dual_stack_zone, + write_ini, +) +from cmlxc.incus import Incus + +DOCKER = "docker" +DOCKER_COMPOSE_SERVICE = "chatmail" +DOCKER_IMAGE_TAG = "chatmail-relay" +GHCR_IMAGE = "ghcr.io/chatmail/docker" +DEFAULT_COMPOSE_URL = ( + "https://raw.githubusercontent.com/chatmail/docker/main/docker-compose.yaml" +) + +# Validates the TAG portion of ``docker:TAG``. Allows colons and slashes +# so users can pass ``image:version`` or ``registry/repo:tag`` forms. +_DOCKER_TAG_RE = re.compile(r"^[a-zA-Z0-9._:/-]+$") + + +def _add_relay_arg(parser, completer=None, *, help="Relay container name."): + """Add the positional RELAY argument with optional tab-completion.""" + arg = parser.add_argument("relay", metavar="RELAY", help=help) + if completer: + arg.completer = completer + + +def _parse_inject_tag(source_arg): + """Extract and validate tag from a ``docker:TAG`` source argument. + + Returns the tag string if *source_arg* starts with ``docker:`` and the + tag passes validation, ``None`` if the prefix is absent, or raises + ``ValueError`` if the prefix is present but the tag is empty/malformed. + """ + if not source_arg.startswith("docker:"): + return None + tag = source_arg[len("docker:") :] + if not tag or not _DOCKER_TAG_RE.match(tag): + raise ValueError(f"Invalid Docker tag: {tag!r}") + return tag + + +# ------------------------------------------------------------------- +# Image helpers +# ------------------------------------------------------------------- + + +def image_tag(sha): + """Docker image tag for a given git SHA.""" + return f"{DOCKER_IMAGE_TAG}:{sha[:12]}" + + +def ensure_docker(ct): + """Install Docker engine in container if not present.""" + if ct.bash_get("docker info >/dev/null 2>&1") is not None: + return + ct.bash(""" + mkdir -p /etc/apt/keyrings + /usr/lib/apt/apt-helper download-file \ + https://download.docker.com/linux/debian/gpg \ + /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) \ + signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo $VERSION_CODENAME) stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq \ + docker-ce docker-ce-cli containerd.io docker-compose-plugin + mkdir -p /etc/docker + # Docker iptables rules conflict with LXC-managed networking. + printf '{"iptables": false}\\n' > /etc/docker/daemon.json + systemctl enable --now docker + """) + + +def inject_image_from_host(ct, tag, out): + """Pipe a locally-built image from the host Docker daemon into the relay.""" + out.print(f" Injecting {tag} into {ct.shortname} ...") + cmd = f"docker save {shlex.quote(tag)} | incus exec {ct.name} -- docker load" + ret = out.shell(cmd) + if ret: + raise SetupError(f"Failed to inject {tag} into {ct.name}") + ct.bash(f"docker tag {shlex.quote(tag)} {DOCKER_IMAGE_TAG}:latest") + + +def pull_image(ct, tag, out): + """Pull a Docker image from GHCR into a container and tag locally. + + Returns the relay git SHA extracted from image labels, or None. + """ + ref = f"{GHCR_IMAGE}:{tag}" + ensure_docker(ct) + out.print(f" Pulling {ref} ...") + try: + ct.bash(f"docker pull {ref}") + except subprocess.CalledProcessError: + out.red(f" Failed to pull {ref}") + return None + ct.bash(f"docker tag {ref} {DOCKER_IMAGE_TAG}:latest") + sha = get_image_label_sha(ct, ref) + if sha: + local_tag = image_tag(sha) + ct.bash(f"docker tag {ref} {local_tag}") + out.print(f" Tagged as {local_tag}") + return sha + out.print(f" Pulled {ref} (no SHA label found)") + return None + + +def get_image_label_sha(ct, tag): + """Read the relay commit SHA from a Docker image's OCI labels.""" + sha = ct.bash_get( + f"docker inspect {tag}" + " --format '{{index .Config.Labels \"org.opencontainers.image.revision\"}}'" + ) + return sha.strip() if sha and sha.strip() else None + + +def _require_docker_relay(ix, name, out, running=True): + """Return a docker-deployed relay container, or None. + + Pass running=False on failure paths, where starting a stopped container + and waiting for its services would hang instead of reporting. + """ + ct = ix.get_running_relay(name) if running else ix.get_relay_container(name) + state = ct.get_deploy_state() + if not state or state.get("driver") != DOCKER: + out.red(f"Container {ct.shortname!r} is not a Docker deployment.") + return None + return ct + + +def dump_docker_diagnostics(ct, out, tail=80): + """Print the post-mortem log set for a failed Docker relay. + + Single source for both the deploy-time healthcheck timeout and the + "on failure" step of lxc-test.yml, which used to carry its own + near-identical copy of these incantations. + """ + svc = DOCKER_COMPOSE_SERVICE + sections = [ + ( + f"docker logs {svc} (last {tail})", + f"docker logs {svc} --tail {tail} 2>&1", + ), + ( + "healthcheck state", + f"docker inspect {svc} --format '{{{{json .State.Health}}}}' 2>/dev/null", + ), + ( + "dovecot journal", + f"docker exec {svc} journalctl -u dovecot --no-pager -n 30 2>&1", + ), + ( + "postfix journal", + f"docker exec {svc} journalctl -u postfix --no-pager -n 30 2>&1", + ), + ( + "failed systemd units", + f"docker exec {svc} systemctl --failed --no-pager 2>&1", + ), + ( + "dovecot -n (effective config)", + f"docker exec {svc} dovecot -n 2>&1 | tail -40", + ), + ( + "mail TLS material", + f"docker exec {svc} ls -la /etc/ssl/certs/mailserver.pem" + " /etc/ssl/private/mailserver.key 2>&1", + ), + ] + for label, cmd in sections: + out.red(f" --- {label} ---") + output = ct.bash_get(cmd) + if output: + for line in output.strip().splitlines(): + out.print(f" {line}") + + +def logs_docker_cmd(args, out): + """Show Docker Compose logs from a deployed relay container.""" + ix = Incus(out) + ct = _require_docker_relay(ix, args.relay, out, running=not args.diagnostics) + if ct is None: + return 1 + + if args.diagnostics: + dump_docker_diagnostics(ct, out) + return 0 + + follow = "-f " if args.follow else "" + cmd = f"incus exec {ct.name} -- docker compose -f /opt/chatmail-docker/docker-compose.yaml logs {follow}--tail=100" + return out.shell(cmd) + + +def _get_docker_services(ix, name): + """Query running Docker Compose service names from a relay container.""" + raw = ix.run_output( + [ + "exec", + name, + "--", + "docker", + "compose", + "-f", + "/opt/chatmail-docker/docker-compose.yaml", + "ps", + "--services", + "--status", + "running", + ], + check=False, + ) + if not raw: + return [] + return [s.strip() for s in raw.splitlines() if s.strip()] + + +def ps_docker_cmd(args, out): + """Show running Docker Compose services in a deployed relay.""" + ix = Incus(out) + ct = _require_docker_relay(ix, args.relay, out) + if ct is None: + return 1 + for svc in _get_docker_services(ix, ct.name): + out.print(svc) + return 0 + + +def shell_docker_cmd(args, out): + """Open an interactive shell (or run a command) in a Docker container.""" + ix = Incus(out) + ct = _require_docker_relay(ix, args.relay, out) + if ct is None: + return 1 + svc = args.service + if args.command: + cmd_str = " ".join(shlex.quote(c) for c in args.command) + cmd = [ + "incus", + "exec", + ct.name, + "--", + "docker", + "exec", + "-i", + svc, + "bash", + "-c", + cmd_str, + ] + else: + cmd = [ + "incus", + "exec", + ct.name, + "--", + "docker", + "exec", + "-it", + svc, + "bash", + "-l", + ] + return subprocess.call(cmd) + + +def pull_docker_cmd_options(parser, completer=None): + _add_relay_arg( + parser, completer, help="Relay container name to pull the image into." + ) + parser.add_argument( + "--tag", + default="main", + metavar="TAG", + help="GHCR image tag to pull (default: main).", + ) + + +def pull_docker_cmd(args, out): + """Pull a chatmail Docker image from GHCR into a relay container.""" + ix = Incus(out) + ct = ix.get_running_relay(args.relay) + with out.section(f"Pulling {GHCR_IMAGE}:{args.tag}"): + sha = pull_image(ct, args.tag, out) + if sha is None: + out.red(f"Pull failed for {GHCR_IMAGE}:{args.tag}") + return 1 + out.green(f"Done. Image: {DOCKER_IMAGE_TAG}:{sha[:12] if sha else 'latest'}") + return 0 + + +# ------------------------------------------------------------------- +# Deployment driver +# ------------------------------------------------------------------- + + +class DockerDriver(Driver): + """Deploys chatmail relays via Docker Compose in LXC containers.""" + + CLI_NAME = "docker" + CLI_DOC = "Docker relay management (deploy, pull, logs, ps, shell)." + DEFAULT_SOURCE_URL = CmdeployDriver.DEFAULT_SOURCE_URL + REPO_NAME = CmdeployDriver.REPO_NAME + # --source names a prebuilt image, not a git ref, so deploy does no + # checkout. DEFAULT_SOURCE_URL is only used by run_tests to check out the + # relay repo to match the deployed image. + SOURCE_IS_GIT_REF = False + + # Overrides the relay git ref used for the run_tests checkout; set by + # `test-cmdeploy --relay-ref`. Default: the SHA from the image label. + relay_ref = None + + NESTING_CONFIG = { + "security.nesting": "true", + "security.syscalls.intercept.mknod": "true", + "security.syscalls.intercept.setxattr": "true", + } + # CI runners have AppArmor enforcing, which blocks systemd inside + # Docker-in-LXC. These overrides must not be used outside disposable CI. + _CI_NESTING_EXTRA = { + "security.privileged": "true", + "raw.lxc": "lxc.apparmor.profile=unconfined", + } + + @classmethod + def get_nesting_config(cls, out=None): + cfg = dict(cls.NESTING_CONFIG) + # Exact match, not truthiness: os.environ.get("CI") is truthy for + # "false" and "0", so a stray env var would silently switch on + # privileged containers and unconfined apparmor. Matches the + # RUNNER_DEBUG == "1" check in cli.py. + if os.environ.get("CI", "").lower() in ("true", "1"): + if out is not None: + out.red( + " CI=1: launching PRIVILEGED container with apparmor" + " unconfined. Never do this outside disposable CI." + ) + cfg.update(cls._CI_NESTING_EXTRA) + return cfg + + @classmethod + def source_arg_kwargs(cls): + """Docker deploys from a prebuilt image, not a git ref. + + The inherited text advertises @ref, @latest, /path, ./path and + URL@ref, none of which this driver implements. + """ + return { + "default": "", + "help": ( + "Image to deploy: ghcr:TAG (pull from" + f" {GHCR_IMAGE}) or docker:TAG (inject a locally built image" + " from the host Docker daemon). Required unless --image is given." + ), + } + + @classmethod + def add_cli_options(cls, parser, completer=None): + super().add_cli_options(parser, completer=completer) + parser.add_argument( + "--image", + metavar="PATH", + help="Load a pre-exported image tarball into the relay.", + ) + parser.add_argument( + "--compose", + default=DEFAULT_COMPOSE_URL, + help="docker-compose.yaml URL to fetch (inject path only; default: chatmail/docker main).", + ) + + def configure_from_args(self, args): + """Resolve --source/--image into the image the deploy should use. + + Runs inside the base make_cmd template, so argparse has already + guaranteed that source, image and compose exist on *args*. + """ + self.image_path = args.image + self.compose_url = args.compose + self.ghcr_tag = None + self.inject_tag = None + + source_str = args.source or "" + if source_str.startswith("ghcr:"): + if self.image_path: + raise SetupError( + "--image and --source ghcr:TAG are mutually exclusive." + ) + self.ghcr_tag = source_str[len("ghcr:") :] or "main" + return + + # raises ValueError on a malformed tag, which main() reports + self.inject_tag = _parse_inject_tag(source_str) + if self.inject_tag and self.image_path: + raise SetupError("--image and --source docker:TAG are mutually exclusive.") + if not self.inject_tag and not self.image_path: + raise SetupError( + "Specify an image: --source docker:TAG, --source ghcr:TAG," + " or --image PATH." + ) + + @classmethod + def logs_add_cli_options(cls, parser, completer=None): + _add_relay_arg(parser, completer) + parser.add_argument( + "-f", + "--follow", + action="store_true", + help="Follow log output (like tail -f).", + ) + parser.add_argument( + "--diagnostics", + action="store_true", + help="Dump the full post-mortem log set instead of compose logs.", + ) + + @classmethod + def ps_add_cli_options(cls, parser, completer=None): + _add_relay_arg(parser, completer) + + @classmethod + def shell_add_cli_options(cls, parser, completer=None): + _add_relay_arg(parser, completer) + parser.add_argument( + "service", + nargs="?", + default=DOCKER_COMPOSE_SERVICE, + help=f"Docker Compose service (default: {DOCKER_COMPOSE_SERVICE}).", + ) + parser.add_argument( + "command", + nargs="*", + default=[], + metavar="CMD", + help="Command to run (default: interactive bash).", + ) + + # (name, help, func, options_func) -- options_func may accept completer kwarg; + # logs/ps/shell use {name}_add_cli_options classmethods instead (options_func=None) + _DOCKER_SUBCOMMANDS = [ + ( + "logs", + "Show Docker Compose logs from a deployed relay", + logs_docker_cmd, + None, + ), + ("ps", "Show running Docker Compose services", ps_docker_cmd, None), + ("shell", "Open a shell in a Docker container", shell_docker_cmd, None), + ( + "pull", + "Pull a Docker image from GHCR into a relay", + pull_docker_cmd, + pull_docker_cmd_options, + ), + ] + + @classmethod + def add_subcommand(cls, subparsers, shared, *, completer=None): + """Register 'docker' with deploy/build/list/prune sub-subcommands.""" + docker_parser = subparsers.add_parser( + cls.CLI_NAME, + description=cls.CLI_DOC, + help=cls.CLI_DOC.split(".")[0], + parents=[shared], + ) + docker_parser.set_defaults(func=lambda args, out: docker_parser.print_help()) + docker_subs = docker_parser.add_subparsers(title="docker subcommands") + + # docker deploy (special: uses driver make_cmd + add_cli_options) + deploy_p = docker_subs.add_parser( + "deploy", + description="Deploy a chatmail relay via Docker Compose.", + help="Deploy a chatmail relay via Docker Compose", + parents=[shared], + ) + deploy_p.set_defaults(func=cls.make_cmd()) + cls.add_cli_options(deploy_p, completer=completer) + + for name, help_text, func, addopts in cls._DOCKER_SUBCOMMANDS: + p = docker_subs.add_parser( + name, + description=func.__doc__, + help=help_text, + parents=[shared], + ) + p.set_defaults(func=func) + classmethod_opts = getattr(cls, f"{name}_add_cli_options", None) + if classmethod_opts is not None: + classmethod_opts(p, completer=completer) + elif addopts is not None: + addopts(p, completer=completer) + + def run_deploy(self, *, source, ipv4_only=False): + """Deploy Docker Compose relay into an LXC container. + + *source* is always None here (SOURCE_IS_GIT_REF is False); the image + to deploy was resolved by configure_from_args. + """ + with self.out.section(f"Preparing container: {self.ct.shortname}"): + self.ct.ensure( + ipv4_only=ipv4_only, + image_candidates=["localchat-docker", "localchat-base"], + extra_config=self.get_nesting_config(self.out), + ) + + t_total = time.time() + self.deploy() + elapsed = time.time() - t_total + self.out.section_line(f"deploy docker complete ({elapsed:.1f}s)") + + def deploy(self): + """Deploy chatmail via Docker Compose.""" + self.ct.check_deploy_lock(DOCKER) + if not re.fullmatch(r"[a-zA-Z0-9._-]+", self.ct.domain): + raise SetupError(f"Unsafe domain value: {self.ct.domain!r}") + self.ix.write_ssh_config() + + dns_ct = self.get_dns_container() + # The relay resolves *.localchat through the DNS container, same as a + # cmdeploy relay. DHCP's resolver knows nothing about that zone. + self.ct.setup_resolvconf_localchat_nameserver(dns_ct.ipv4) + + with self.out.section("Installing Docker in relay"): + ensure_docker(self.ct) + + if self.image_path: + self._load_local_image() + elif self.ghcr_tag: + with self.out.section(f"Pulling image from GHCR ({self.ghcr_tag})"): + sha = pull_image(self.ct, self.ghcr_tag, self.out) + if sha is None: + raise SetupError(f"Failed to pull {GHCR_IMAGE}:{self.ghcr_tag}") + elif self.inject_tag: + with self.out.section(f"Injecting image ({self.inject_tag})"): + inject_image_from_host(self.ct, self.inject_tag, self.out) + + with self.out.section("Fetching compose file"): + self._fetch_compose_file() + + # Register domain after the pull so set_dns_records()'s recursor cache + # wipe doesn't break public DNS resolution during the image pull. + ensure_ipv6_known(self.ct) + dns_ct.set_dns_records(self.ct.domain, address_records(self.ct)) + + with self.out.section("Preparing chatmail.ini"): + self._write_host_ini() + + with self.out.section("Starting Docker Compose"): + self._start_compose() + + with self.out.section("Waiting for healthcheck"): + self._wait_healthy() + + with self.out.section("Loading DNS zone"): + self._load_dns(dns_ct) + + desc = "" + if self.ghcr_tag: + desc = f"ghcr:{self.ghcr_tag}" + elif self.inject_tag: + desc = f"docker:{self.inject_tag}" + elif self.image_path: + desc = f"image:{self.image_path}" + sha = get_image_label_sha(self.ct, f"{DOCKER_IMAGE_TAG}:latest") + if sha: + desc += f" (relay {sha[:12]})" + source = SimpleNamespace(description=desc) if desc else None + # "dns", not "ipv4": this deploy does full DNS (both set_dns_records + # calls above plus zone extraction). Driver.__init__ reads the label + # back into self.type, and test_cmdeploy_cmd skips cross-relay MX + # verification for any relay whose type is not "dns". + self.ct.write_deploy_state(DOCKER, source=source, deploy_type="dns") + + def _load_local_image(self): + """Load a pre-exported image tarball into the relay.""" + with self.out.section(f"Loading image from {self.image_path}"): + path = shlex.quote(str(self.image_path)) + cmd = f"cat {path} | incus exec {self.ct.name} -- docker load" + ret = self.out.shell(cmd) + if ret: + raise SetupError(f"Failed to load image from {self.image_path}") + loaded = self.ct.bash( + f"docker images {DOCKER_IMAGE_TAG} --format '{{{{.Tag}}}}' | head -1" + ) + if loaded and loaded.strip() != "latest": + self.ct.bash( + f"docker tag {DOCKER_IMAGE_TAG}:{loaded.strip()}" + f" {DOCKER_IMAGE_TAG}:latest" + ) + + def _fetch_compose_file(self): + """Download docker-compose.yaml into the relay; raise SetupError on failure.""" + dest = "/opt/chatmail-docker/docker-compose.yaml" + self.ct.bash("mkdir -p /opt/chatmail-docker") + # apt-helper is always present (part of apt); avoids installing curl/wget. + result = self.ct.bash_get( + f"/usr/lib/apt/apt-helper download-file" + f" {shlex.quote(self.compose_url)} {dest} 2>&1" + ) + if result is None: + raise SetupError(f"Failed to fetch compose file from {self.compose_url}") + + def _write_host_ini(self): + """Write chatmail.ini into the relay via the Docker image's Python.""" + ini_path = "/srv/chatmail/chatmail.ini" + overrides = dict(TEST_INI_OVERRIDES) + if self.ct.is_ipv6_disabled: + overrides["disable_ipv6"] = "True" + script = make_ini_script(self.ct.domain, ini_path, overrides) + self.ct.bash(f""" + mkdir -p /srv/chatmail + docker run --rm \\ + --entrypoint /opt/cmdeploy/bin/python3 \\ + -v /srv/chatmail:/srv/chatmail \\ + {DOCKER_IMAGE_TAG}:latest \\ + -c " +{script} +" + """) + + def _start_compose(self): + """Write .env, compose override, copy compose file, and start.""" + self.ct.bash(f""" + mkdir -p /opt/chatmail-docker + cd /opt/chatmail-docker + cat > .env <<'DOTENV' +MAIL_DOMAIN={self.ct.domain} +CHATMAIL_IMAGE=chatmail-relay:latest +DOTENV + """) + # NOTE: do NOT add `privileged: true` here as it causes Docker to mount a + # fresh devtmpfs and request `a *:* rwm` in the sub-cgroup, which cgroup v2's + # hierarchical eBPF filter on the parent LXC container denies, breaking + # /dev/null access for Dovecot. + self.ct.bash(""" + cat > /opt/chatmail-docker/docker-compose.override.yaml <<'OVERRIDE' +services: + chatmail: + volumes: + - /srv/chatmail/chatmail.ini:/etc/chatmail/chatmail.ini +OVERRIDE + """) + + self.ct.bash(""" + cd /opt/chatmail-docker + docker compose down -v 2>/dev/null || true + docker compose up -d --no-build + """) + + def _wait_healthy(self, timeout=180, interval=5): + """Poll Docker healthcheck until healthy or timeout.""" + since = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + deadline = time.time() + timeout + while time.time() < deadline: + status = self.ct.bash_get( + f"docker inspect {DOCKER_COMPOSE_SERVICE}" + " --format '{{.State.Health.Status}}' 2>/dev/null" + ) + s = status.strip() if status else "" + if s == "healthy": + self.out.print(" Container healthy.") + return + if self.out.verbosity >= 1: + new_logs = self.ct.bash_get( + f"docker logs {DOCKER_COMPOSE_SERVICE} --since {since} 2>&1" + ) + if new_logs: + lines = new_logs.splitlines() + if len(lines) > 20: + self.out.print( + f" [docker] ... ({len(lines) - 20} lines skipped)" + ) + lines = lines[-20:] + for line in lines: + self.out.print(f" [docker] {line}") + since = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + time.sleep(interval) + dump_docker_diagnostics(self.ct, self.out, tail=80) + raise SetupError(f"Docker container not healthy after {timeout}s") + + def _load_dns(self, dns_ct): + """Extract DNS zone from Docker container and load into PowerDNS.""" + tmp = "/tmp/localchat-forward.conf" + self.ct.push_file_content( + tmp, + f""" + server: + domain-insecure: "localchat" + + forward-zone: + name: "localchat" + forward-addr: {dns_ct.ipv4} + """, + ) + svc = DOCKER_COMPOSE_SERVICE + self.ct.bash( + f"docker cp {tmp} {svc}:/etc/unbound/unbound.conf.d/localchat-forward.conf" + f" && docker exec {svc} systemctl restart unbound" + ) + zone_content = self.ct.bash_do( + f"docker exec {svc} cmdeploy dns --ssh-host @local --zonefile /dev/stdout" + ) + if zone_content: + verify_dual_stack_zone(self.ct, zone_content) + dns_ct.set_dns_records(self.ct.domain, zone_content) + else: + # Minimal address record fallback + dns_ct.set_dns_records(self.ct.domain, address_records(self.ct)) + + def _setup_docker_ssh_forwarding(self): + """Rewrite authorized_keys on the LXC host to forward SSH into Docker. + + Tests use SSHExec (execnet over SSH) which lands on the LXC host. + Services (dovecot, opendkim, postfix) run inside the Docker container. + By wrapping the builder key with command="docker exec ...", every SSH + session transparently enters the container. The LXC host itself is + managed via incus exec, so losing direct SSH access is fine. + + A wrapper script is needed because $SSH_ORIGINAL_COMMAND contains + shell metacharacters (quotes, parens) from execnet's python bootstrap. + Bare $SSH_ORIGINAL_COMMAND expansion would mangle them; bash -c with + double-quoted expansion preserves the command correctly. + """ + self.ct.push_file_content( + "/usr/local/bin/docker-ssh-forward", + f'#!/bin/bash\nexec docker exec -i {DOCKER_COMPOSE_SERVICE} bash -c "$SSH_ORIGINAL_COMMAND"', + mode="755", + ) + pub_key = self.ct.incus.ssh_key_path.with_suffix(".pub").read_text().strip() + self.ct.bash("mkdir -p /root/.ssh && chmod 700 /root/.ssh") + self.ct.push_file_content( + "/root/.ssh/authorized_keys", + f'command="/usr/local/bin/docker-ssh-forward" {pub_key}', + mode="600", + ) + + def run_tests(self, second_domain=None): + """Execute the cmdeploy test suite against the Docker relay. + + The builder checkout must match the relay image so that + ``test_deployed_state`` (which compares local ``git rev-parse HEAD`` + against ``/etc/chatmail-version``) passes. When the venv already + exists from a prior deploy, re-checkout if the current SHA differs + from the image SHA. + + Set ``self.relay_ref`` to override the relay git ref used for the + test checkout (default: SHA from the running image). + """ + with self.out.section("cmdeploytest"): + self._setup_docker_ssh_forwarding() + self.bld_ct.write_relay_ssh_config(self.ct) + + ref = ( + self.relay_ref + or get_image_label_sha(self.ct, f"{DOCKER_IMAGE_TAG}:latest") + or "main" + ) + venv_exists = self.bld_ct.bash_get(f"test -d {self.venv_path}") is not None + if not venv_exists: + self.out.print( + f" Venv missing, initializing builder for {self.ct.shortname} ..." + ) + source = parse_source(f"@{ref}", self.DEFAULT_SOURCE_URL) + self.init_builder(source) + + self.out.print("Preparing chatmail.ini on builder ...") + write_ini( + self.bld_ct, + self.ct, + self.ct.domain, + disable_ipv6=self.ct.is_ipv6_disabled, + ) + return run_test_cmdeploy(self, self.get_test_domain_or_ip(), second_domain) diff --git a/tests/test_docker.py b/tests/test_docker.py new file mode 100644 index 0000000..2a3f3d0 --- /dev/null +++ b/tests/test_docker.py @@ -0,0 +1,89 @@ +"""Unit tests for Docker driver helpers.""" + +from inspect import signature + +import pytest + +from cmlxc.cli import DRIVER_BY_NAME, get_parser +from cmlxc.container import SetupError +from cmlxc.driver_base import Driver +from cmlxc.driver_docker import DockerDriver, _parse_inject_tag + + +@pytest.mark.parametrize("hook", ["on_init_relay", "run_deploy", "init_builder"]) +def test_driver_hooks_match_base(hook): + """Every driver's hooks must be callable the way the base class calls them.""" + base = signature(getattr(Driver, hook)) + for name, cls in DRIVER_BY_NAME.items(): + assert signature(getattr(cls, hook)) == base, f"{name}.{hook}" + + +@pytest.mark.parametrize( + "sub, argv", + [ + ("deploy", ["docker", "deploy", "dk0", "--source", "ghcr:main"]), + ("pull", ["docker", "pull", "dk0", "--tag", "main"]), + ("logs", ["docker", "logs", "dk0", "-f"]), + ("ps", ["docker", "ps", "dk0"]), + ("shell", ["docker", "shell", "dk0", "chatmail", "ls"]), + ], +) +def test_docker_subcommand_tree_builds(sub, argv): + args = get_parser().parse_args(argv) + assert callable(args.func) + + +def test_docker_deploy_without_source_is_rejected(): + """A bare `docker deploy NAME` must not silently deploy nothing. + + As argparse accepts it the rejection must happens in configure_from_args + """ + args = get_parser().parse_args(["docker", "deploy", "dk0"]) + assert args.source == "" + driver = DockerDriver.__new__(DockerDriver) + with pytest.raises(SetupError, match="Specify an image"): + driver.configure_from_args(args) + + +@pytest.mark.parametrize( + "argv", + [ + ["docker", "deploy", "dk0", "--source", "ghcr:main", "--image", "/tmp/i.tar"], + ["docker", "deploy", "dk0", "--source", "docker:tag", "--image", "/tmp/i.tar"], + ], +) +def test_docker_deploy_image_and_source_are_exclusive(argv): + args = get_parser().parse_args(argv) + driver = DockerDriver.__new__(DockerDriver) + with pytest.raises(SetupError, match="mutually exclusive"): + driver.configure_from_args(args) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("docker:main", "main"), + ("docker:image:version", "image:version"), + ("docker:registry/repo:tag", "registry/repo:tag"), + ("docker:sha256:abc123", "sha256:abc123"), + ], +) +def test_parse_inject_tag_accepts_valid(value, expected): + assert _parse_inject_tag(value) == expected + + +@pytest.mark.parametrize( + "value", + ["ghcr:main", "@main", "", "main"], +) +def test_parse_inject_tag_returns_none_for_non_docker(value): + assert _parse_inject_tag(value) is None + + +@pytest.mark.parametrize( + "value", + ["docker:", "docker: ", "docker:tag with spaces", "docker:tag!bad"], +) +def test_parse_inject_tag_rejects_invalid_tag(value): + with pytest.raises(ValueError, match="Invalid Docker tag"): + _parse_inject_tag(value) From 2f2400d69bcca756e1da4f180e81f3969c069dbb Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 22 Apr 2026 17:19:22 +0200 Subject: [PATCH 13/17] feat(cli): integrate Docker driver - Register DockerDriver in DRIVER_BY_NAME - test-cmdeploy: dispatch to driver class from container metadata - Fix _print_builder_repos to use driver REPO_NAME (avoids dupes) --- src/cmlxc/cli.py | 27 ++++++++++++++++++++++----- src/cmlxc/driver_base.py | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index ba2f8b3..18141b5 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -1,7 +1,7 @@ """cmlxc -- Manage local chatmail relay containers via Incus. Standard workflow: -init -> deploy-cmdeploy/deploy-madmail -> test-cmdeploy/test-madmail/test-mini. +init -> deploy-cmdeploy/deploy-madmail/docker deploy -> test-*/test-mini. """ import argparse @@ -22,6 +22,7 @@ ) from cmlxc.driver_base import __version__ from cmlxc.driver_cmdeploy import CmdeployDriver +from cmlxc.driver_docker import DockerDriver from cmlxc.driver_madmail import MadmailDriver, print_admin_info from cmlxc.incus import Incus, _is_ip_address, check_cgroup_compat from cmlxc.output import Out @@ -259,6 +260,11 @@ def _add_test_relay_args(parser): def test_cmdeploy_cmd_options(parser): _add_test_relay_args(parser) + parser.add_argument( + "--relay-ref", + default=None, + help="Override relay git ref for tests (default: SHA from deployed image label).", + ) def test_cmdeploy_cmd(args, out): @@ -302,6 +308,8 @@ def test_cmdeploy_cmd(args, out): drv2 = DRIVER_BY_NAME[ct2.driver_name](ct2, out) second_domain = drv2.get_test_domain_or_ip() + if args.relay_ref is not None: + driver.relay_ref = args.relay_ref return driver.run_tests(second_domain=second_domain) @@ -527,11 +535,16 @@ def _print_container_status(out, c, ix): def _print_builder_repos(out, ct): try: - for name in DRIVER_BY_NAME: - path = f"/root/{name}-git-main" + seen = set() + for name, drv_cls in DRIVER_BY_NAME.items(): + repo = drv_cls.REPO_NAME + if repo in seen: + continue + seen.add(repo) + path = f"/root/{repo}-git-main" status = ct.get_repo_status(path) if status: - out.print(f"{name}: {status}") + out.print(f"{repo}: {status}") except Exception: out.print("repos: (unavailable)") @@ -612,7 +625,11 @@ def _print_dns_forwarding_status(out, dns_ip, *, host=False): ("destroy", destroy_cmd, destroy_cmd_options), ] -DRIVER_BY_NAME = {"cmdeploy": CmdeployDriver, "madmail": MadmailDriver} +DRIVER_BY_NAME = { + "cmdeploy": CmdeployDriver, + "docker": DockerDriver, + "madmail": MadmailDriver, +} def _add_subcommand(subparsers, name, func, addopts, shared): diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index 68961a1..85e9ef7 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -112,6 +112,10 @@ class Driver: REQUIRED_SOURCE_PATHS: list[str] = [] DEFAULT_REF: str = "main" type: str = "dns" + # False for drivers whose --source names a prebuilt artifact rather than + # a git ref (DockerDriver) and get source=None in run_deploy and does no + # deploy-time checkout; configure_from_args parses --source. + SOURCE_IS_GIT_REF: bool = True def __init__(self, ct, out): self.ct = ct @@ -149,14 +153,28 @@ def get_builder(self): # CLI registration # ------------------------------------------------------------------ + @classmethod + def source_arg_kwargs(cls): + """Return argparse kwargs for ``--source``. + + Override in drivers that accept a different set of source forms, so + that ``--help`` advertises what the driver actually implements. + """ + return { + "default": f"@{cls.DEFAULT_REF}", + "help": ( + "Driver source: @ref, /path, ./path, or URL@ref" + f" (default: @{cls.DEFAULT_REF})." + ), + } + @classmethod def add_cli_options(cls, parser, completer=None): """Register ``deploy-*`` CLI options on *parser*.""" parser.add_argument( "--source", - default=f"@{cls.DEFAULT_REF}", metavar="SOURCE", - help=f"Driver source: @ref, /path, ./path, or URL@ref (default: @{cls.DEFAULT_REF}).", + **cls.source_arg_kwargs(), ) action = parser.add_argument( "name", @@ -305,7 +323,7 @@ def init_builder(self, source): tag = source.ref if source.kind == "remote" else None self.on_init_relay(repo_path, tag) - def run_deploy(self, *, source, ipv4_only): + def run_deploy(self, *, source, ipv4_only=False): """Perform the driver-specific deployment. Subclasses must implement. @@ -347,16 +365,19 @@ def cmd(args, out): if not driver.get_builder(): return 1 - source = parse_source(args.source, cls.DEFAULT_SOURCE_URL) - if not driver.check_local_source(source): - return 1 + source = None + if cls.SOURCE_IS_GIT_REF: + source = parse_source(args.source, cls.DEFAULT_SOURCE_URL) + if not driver.check_local_source(source): + return 1 driver.configure_from_args(args) out.print(f"cmlxc {__version__}") - with out.section(f"Preparing {cls.CLI_NAME} source in builder"): - out.print(f" Source: {source.description}") - driver.init_builder(source=source) + if source is not None: + with out.section(f"Preparing {cls.CLI_NAME} source in builder"): + out.print(f" Source: {source.description}") + driver.init_builder(source=source) driver.run_deploy( source=source, From cfe49b422e702216b283de9236e23f5bad09345a Mon Sep 17 00:00:00 2001 From: j4n Date: Thu, 6 Aug 2026 12:26:43 +0200 Subject: [PATCH 14/17] ci: add docker-in-lxc support to lxc-test workflow Cache a localchat-docker image (Docker images stripped before export) and dump per-service Docker diagnostics when a run fails. AppArmor is not touched here: callers that need Docker-in-LXC systemd disable it from their own cmlxc_commands, so the shared workflow stays neutral for every other consumer. --- .github/workflows/lxc-test.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lxc-test.yml b/.github/workflows/lxc-test.yml index 5efce1b..5133a55 100644 --- a/.github/workflows/lxc-test.yml +++ b/.github/workflows/lxc-test.yml @@ -114,7 +114,7 @@ jobs: - name: Import cached images run: | mkdir -p /tmp/incus-cache - for alias in localchat-base localchat-builder; do + for alias in localchat-base localchat-builder localchat-docker; do if [ -f /tmp/incus-cache/$alias.tar.gz ]; then echo "Importing: $alias" incus image import /tmp/incus-cache/$alias.tar.gz --alias $alias || true @@ -191,7 +191,13 @@ jobs: run: | for c in $(incus list -c n --format csv); do echo "::group::Logs for $c" - incus exec "$c" -- journalctl -p warning --no-pager -n 100 || true + # Exits 1 on relays that are not Docker deployments. + if cmlxc docker logs "$c" --diagnostics; then + # dockerd and containerd log at info, so drop the severity filter. + incus exec "$c" -- journalctl --no-pager -n 200 || true + else + incus exec "$c" -- journalctl -p warning --no-pager -n 100 || true + fi echo "::endgroup::" done @@ -205,8 +211,21 @@ jobs: echo "Publishing builder container as image ..." incus publish builder-localchat --alias localchat-builder --force || true fi + # Publish Docker relay container with engine only (strip images) + for ct in $(incus list -c n --format csv | grep -v builder); do + if incus exec "$ct" -- docker info >/dev/null 2>&1; then + echo "Stripping Docker images and deploy state from $ct ..." + incus exec "$ct" -- docker system prune -af --volumes 2>/dev/null || true + # Everything below is per-deploy state. + incus exec "$ct" -- rm -rf /opt/chatmail-docker /srv/chatmail \ + /root/.ssh/authorized_keys /etc/resolv.conf 2>/dev/null || true + echo "Publishing $ct as localchat-docker ..." + incus publish "$ct" --alias localchat-docker --force || true + break + fi + done exported=0 - for alias in localchat-base localchat-builder; do + for alias in localchat-base localchat-builder localchat-docker; do if incus image list --format csv -c l | grep -q "^$alias$"; then echo "Exporting: $alias" incus image export $alias /tmp/incus-cache/$alias || true From 9cc4c5ebfd378b67d33dbe15bdf7ee10065239eb Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 5 Aug 2026 17:14:12 +0200 Subject: [PATCH 15/17] ci: remove docker containers before publishing the cache image --- .github/workflows/lxc-test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/lxc-test.yml b/.github/workflows/lxc-test.yml index 5133a55..e1fd91d 100644 --- a/.github/workflows/lxc-test.yml +++ b/.github/workflows/lxc-test.yml @@ -215,6 +215,11 @@ jobs: for ct in $(incus list -c n --format csv | grep -v builder); do if incus exec "$ct" -- docker info >/dev/null 2>&1; then echo "Stripping Docker images and deploy state from $ct ..." + # prune leaves RUNNING containers alone, and the relay has + # restart: unless-stopped, so remove them or the image carries + # a container that respawns on the next run. + incus exec "$ct" -- bash -c \ + 'docker ps -aq | xargs -r docker rm -f' 2>/dev/null || true incus exec "$ct" -- docker system prune -af --volumes 2>/dev/null || true # Everything below is per-deploy state. incus exec "$ct" -- rm -rf /opt/chatmail-docker /srv/chatmail \ From 6b2c2adef67d8a2fcfe43b7af363d4efd222d2dd Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 12 Aug 2026 11:39:57 +0200 Subject: [PATCH 16/17] ci(nightly): add the docker driver and disable AppArmor --- .github/workflows/nightly.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 77d12da..8362bd7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,6 +10,9 @@ jobs: uses: ./.github/workflows/lxc-test.yml with: cmlxc_commands: | + # Docker-in-LXC needs unconfined systemd to reach the cgroup socket. + sudo systemctl stop apparmor || true + sudo apparmor_parser -R /etc/apparmor.d/* 2>/dev/null || true cmlxc init cmlxc deploy-cmdeploy --source @main fulltest0 cmlxc deploy-cmdeploy --source @main fulltest1 @@ -29,3 +32,8 @@ jobs: cmlxc test-mini fulltest-mad0 fulltest-ip0 cmlxc test-mini fulltest-ip0 fulltest0 cmlxc test-mini fulltest0 fulltest-ip0 + cmlxc docker deploy fulltest-dock0 --source ghcr:main + cmlxc docker ps fulltest-dock0 + cmlxc docker logs fulltest-dock0 + cmlxc test-cmdeploy fulltest-dock0 + cmlxc destroy fulltest-dock0 From fd72725995da229e8ce399a9d936ebd148675ef7 Mon Sep 17 00:00:00 2001 From: j4n Date: Mon, 31 Aug 2026 18:16:30 +0200 Subject: [PATCH 17/17] fix(docker): let Docker manage its own iptables We previously set iptables:false which was fine for host-mode networking Docker containers, bridge-mode docker-compose.yaml needs Docker's NAT, so remove the config. --- src/cmlxc/driver_docker.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cmlxc/driver_docker.py b/src/cmlxc/driver_docker.py index e5bc738..4f5c050 100644 --- a/src/cmlxc/driver_docker.py +++ b/src/cmlxc/driver_docker.py @@ -87,9 +87,6 @@ def ensure_docker(ct): apt-get update -qq apt-get install -y -qq \ docker-ce docker-ce-cli containerd.io docker-compose-plugin - mkdir -p /etc/docker - # Docker iptables rules conflict with LXC-managed networking. - printf '{"iptables": false}\\n' > /etc/docker/daemon.json systemctl enable --now docker """)