Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9bffa89
fix: filter container IPs by Incus bridge subnet
j4n Apr 20, 2026
225461e
refactor: extract check_init() to Incus class
j4n Apr 20, 2026
bc9f098
feat: add extra_config parameter to container launch()/ensure()
j4n Apr 20, 2026
52309ab
fix: apt-get update before installing dnsutils
j4n Apr 20, 2026
edab7f8
refactor: extract shared pytest runner and on_init_relay default
j4n Apr 20, 2026
c35df2f
feat(builder): install uv in prep_builder for faster venv setup
j4n Apr 21, 2026
0e90f91
feat(driver_base): support SHA-based source refs in init_builder
j4n Apr 22, 2026
98f5127
feat(incus): add _get_docker_services helper
j4n Apr 22, 2026
2c0d4f1
feat(cli): auto-detect RUNNER_DEBUG for verbose output
j4n Apr 22, 2026
e3bb9c0
feat(cli): integrate Docker driver, --relay-ref
j4n Apr 22, 2026
20d2fe9
ci: Docker-in-LXC support for lxc-test workflow
j4n Apr 22, 2026
20a246d
feat(docker): add Docker relay driver
j4n Apr 22, 2026
589f990
docker: always rebuild test venv
j4n Apr 28, 2026
5c932ed
docker: set env CHATMAIL_IMAGE for docker compose, doc
j4n Apr 28, 2026
c45c656
docker: replace --relay-ref with $RELAY_REF
j4n Apr 28, 2026
a105e70
refactor(init_builder): skip git reset --hard for SHA refs
j4n Apr 28, 2026
a999e91
refactor(cmdeploy): extract TEST_INI_OVERRIDES
j4n Apr 28, 2026
81f80d6
refactor(cmlxc,docker): rename run_cmdeploy_pytest run_test_cmdeploy
j4n Apr 28, 2026
6efa309
refactor(docker): use TEST_INI_OVERRIDES from driver_cmdeploy
j4n Apr 28, 2026
d5f6b04
refactor(docker): cleanup
j4n Apr 28, 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
69 changes: 62 additions & 7 deletions .github/workflows/lxc-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ on:
The caller repository is checked out into ./repo.
required: true
type: string
cmlxc_ref:
description: 'cmlxc branch/tag to checkout and install (default: main)'
required: false
type: string
default: 'main'

jobs:
plan:
Expand Down Expand Up @@ -62,6 +67,7 @@ jobs:
uses: actions/checkout@v6
with:
repository: chatmail/cmlxc
ref: ${{ inputs.cmlxc_ref }}
path: cmlxc

- name: Install Incus (Zabbly)
Expand All @@ -70,14 +76,18 @@ jobs:
sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list
sudo apt-get update
sudo apt-get install -y incus
sudo apt-get install -y incus-base

- name: Initialise Incus
run: |
sudo systemctl stop docker.socket docker || true
sudo iptables -P FORWARD ACCEPT
sudo sysctl -w fs.inotify.max_user_instances=65535
sudo sysctl -w fs.inotify.max_user_watches=65535
# Disable AppArmor restrictions so Docker-in-LXC containers
# can run systemd (needs cgroup notification socket access).
sudo systemctl stop apparmor || true
sudo apparmor_parser -R /etc/apparmor.d/* 2>/dev/null || true
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not entirely sure if this is needed or if we can just run lxc with unbound profile

sudo incus admin init --auto
sudo chmod 666 /var/lib/incus/unix.socket

Expand All @@ -91,9 +101,9 @@ jobs:
python -m pip install --upgrade pip
pip install ./cmlxc

- name: Cache Incus images
- name: Restore Incus image cache
id: cache-images
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: /tmp/incus-cache
key: incus-v3-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py') }}
Expand All @@ -103,7 +113,7 @@ jobs:
- name: Import cached images
run: |
mkdir -p /tmp/incus-cache
for alias in localchat-base localchat-builder localchat-cmdeploy; do
for alias in localchat-base localchat-builder localchat-cmdeploy 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
Expand Down Expand Up @@ -170,7 +180,6 @@ jobs:
[[ -z "$trimmed" || "$trimmed" == "#"* ]] && continue
i=$((i+1))
if [ $i -le 12 ]; then continue; fi

echo "::group::Run: $trimmed"
eval "$trimmed" || { echo "::endgroup::"; exit 1; }
echo "::endgroup::"
Expand All @@ -181,27 +190,73 @@ 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
incus exec "$c" -- journalctl --no-pager -n 200 || true
# Dump Docker container logs if present
svc=chatmail
if incus exec "$c" -- docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "$svc"; then
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could consider making this a helper script in the dockerized build to facilitate log extraction for debugging.

echo "--- docker logs $svc ---"
incus exec "$c" -- docker logs "$svc" --tail 200 2>&1 || true
echo "--- dovecot journal ---"
incus exec "$c" -- docker exec "$svc" journalctl -u dovecot --no-pager -n 50 2>&1 || true
echo "--- postfix journal ---"
incus exec "$c" -- docker exec "$svc" journalctl -u postfix --no-pager -n 50 2>&1 || true
echo "--- failed units ---"
incus exec "$c" -- docker exec "$svc" systemctl --failed --no-pager 2>&1 || true
echo "--- dovecot -n (effective config) ---"
incus exec "$c" -- docker exec "$svc" dovecot -n 2>&1 | tail -40 || true
echo "--- ssl cert check ---"
incus exec "$c" -- docker exec "$svc" ls -la /etc/ssl/certs/mailserver.pem /etc/ssl/private/mailserver.key 2>&1 || true
fi
echo "::endgroup::"
done

- name: Export images for cache
if: always() && steps.cache-images.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/incus-cache
# Publish the builder LXC container as a cached image (the Docker
# container inside gets recreated on compose up, so the LXC is clean).
# Only skip localchat-cmdeploy on failure -- it bakes deploy state
# directly into the LXC and would carry broken config into the next run.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that we can cache the docker image container as well because its not containing state for this case - maybe clean up /opt completely

if incus list -c n --format csv | grep -q builder-localchat; then
echo "Cleaning up builder container before publishing ..."
incus exec builder-localchat -- bash -c 'rm -rf /root/relays/* /root/.cache/* /root/.npm /root/.bun'
echo "Publishing builder container as image ..."
incus publish builder-localchat --alias localchat-builder --force || true
fi
for alias in localchat-base localchat-builder localchat-cmdeploy; do
# Publish Docker relay container with engine only (strip images to keep cache small)
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 from $ct ..."
incus exec "$ct" -- docker system prune -af --volumes 2>/dev/null || true
echo "Publishing $ct as localchat-docker ..."
incus publish "$ct" --alias localchat-docker --force || true
break
fi
done
exported=0
if [ "${{ job.status }}" = "success" ]; then
aliases="localchat-base localchat-builder localchat-cmdeploy localchat-docker"
else
aliases="localchat-base localchat-builder localchat-docker"
fi
for alias in $aliases; 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
if [ -f /tmp/incus-cache/$alias ] && [ ! -f /tmp/incus-cache/$alias.tar.gz ]; then
mv /tmp/incus-cache/$alias /tmp/incus-cache/$alias.tar.gz
fi
exported=$((exported+1))
fi
done
echo "exported=$exported" >> "$GITHUB_OUTPUT"
id: export-images

- name: Save Incus image cache
if: always() && steps.export-images.outputs.exported > 0
uses: actions/cache/save@v5
with:
path: /tmp/incus-cache
key: incus-v3-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py') }}

2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list
sudo apt-get update
sudo apt-get install -y incus
sudo apt-get install -y incus-base

- name: Initialise Incus
run: |
Expand Down
82 changes: 79 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ Each `deploy-*` invocation initialises the driver's source in the
builder (wipe-and-reclone).


**Deploy via Docker Compose** (builds and runs chatmail inside Docker-in-LXC):

cmlxc docker deploy --source @main dk0
cmlxc docker deploy --source ../relay dk0
cmlxc docker deploy --image ./chatmail.tar.zst dk1

Pre-build images or manage the image cache in the builder:

cmlxc docker build --source @main
cmlxc docker build --source @main --output ./chatmail.tar.zst
cmlxc docker list
cmlxc docker prune
cmlxc docker prune --all

Inspect running services and logs:

cmlxc docker ps dk0
cmlxc docker logs dk0
cmlxc docker logs dk0 -f

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe detail image cleanup as well

SSH into a Docker service (auto-configured by ``cmlxc``):

ssh chatmail@dk0.localchat


**Run integration tests** inside the builder:

cmlxc test-mini cm0
Expand Down Expand Up @@ -167,13 +192,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.
Expand All @@ -189,6 +215,56 @@ builder init, and deploy orchestration.
pushes it via SCP and runs `madmail install --simple --ip <IP>`.
No DNS entries are needed.

- **docker** -- builds a Docker image in the builder container,
transfers it to the relay, and starts it with `docker compose`.
The relay container is launched with `security.nesting=true` to
allow Docker-in-LXC. DNS zones are extracted from the running
container and loaded into PowerDNS.
Use `--image` to skip the build and load a pre-exported tarball.
Docker is installed inside the containers automatically; no Docker
installation is needed on the host. The Dockerfile and compose
files are cloned from
[chatmail/docker](https://github.com/chatmail/docker) into the
relay checkout automatically.
If `zstd` is installed on the host, `--output` produces compressed
tarballs and `--image` decompresses them; otherwise plain tar is used.

#### Docker image management

`docker build`, `docker list`, and `docker prune` operate on the
builder's Docker image cache independently of any relay deployment.

- `docker build` -- builds the chatmail Docker image from a relay source
and caches it in the builder. Use `--output PATH` to export a tarball (zstd-compressed if
available). Old images are
auto-pruned (configurable with `--keep N`, default 3).
Images are cached by relay git SHA. If only the `docker/` files
changed (Dockerfile, compose, init scripts) without a new relay
commit, pass `--force-rebuild` to bypass the cache.

- `docker list` -- shows cached images with tag, ref, SHA, and build date.

- `docker prune` -- removes stale images and dangling Docker resources.
Three levels: default (containers + dangling images), `--deep`
(adds build cache + volumes), `--all` (everything unused).
Use `--dry-run` to preview disk usage without pruning.

- `docker ps RELAY` -- lists running Docker Compose services in a relay.

- `docker logs RELAY` -- shows Docker Compose logs from a deployed relay
container (last 100 lines). Pass `-f` to follow output in real time.

#### SSH into Docker services

For Docker-deployed relays, `cmlxc` auto-generates SSH config entries for
each running Compose service. After any deploy or `cmlxc status`, you can:

ssh chatmail@dk0.localchat

This uses `ProxyCommand` to run `docker exec` inside the LXC container.
As the compose setup evolves to multiple services, each service gets its
own entry (e.g. `ssh dovecot@dk0.localchat`).


## Releasing

Expand Down
40 changes: 24 additions & 16 deletions src/cmlxc/cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""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.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that does not make sense

"""

import argparse
import os
import subprocess
from pathlib import Path

Expand All @@ -21,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
Expand All @@ -41,15 +43,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):
Expand Down Expand Up @@ -278,8 +272,9 @@ 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)
driver.no_dns = bool(args.no_dns)
drv_cls = DRIVER_BY_NAME.get(ct.driver_name, CmdeployDriver)
driver = drv_cls(ct, out)
driver.no_dns = bool(getattr(args, "no_dns", False))
if not driver.check_init():
return 1

Expand Down Expand Up @@ -529,11 +524,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():
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify change

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)")

Expand Down Expand Up @@ -614,7 +614,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):
Expand Down Expand Up @@ -679,6 +683,10 @@ def main(args=None):
if args.func is None:
return parser.parse_args(["-h"])

# GitHub Actions: auto-enable max verbosity when debug logging is on
if not args.verbose and os.environ.get("RUNNER_DEBUG") == "1":
args.verbose = 3

out = Out(verbosity=args.verbose)
try:
res = args.func(args, out)
Expand Down
Loading