-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Docker Compose deployment driver and image management #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9bffa89
225461e
bc9f098
52309ab
edab7f8
c35df2f
0e90f91
98f5127
2c0d4f1
e3bb9c0
20d2fe9
20a246d
589f990
5c932ed
c45c656
a105e70
a999e91
81f80d6
6efa309
d5f6b04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -62,6 +67,7 @@ jobs: | |
| uses: actions/checkout@v6 | ||
| with: | ||
| repository: chatmail/cmlxc | ||
| ref: ${{ inputs.cmlxc_ref }} | ||
| path: cmlxc | ||
|
|
||
| - name: Install Incus (Zabbly) | ||
|
|
@@ -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 | ||
| sudo incus admin init --auto | ||
| sudo chmod 666 /var/lib/incus/unix.socket | ||
|
|
||
|
|
@@ -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') }} | ||
|
|
@@ -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 | ||
|
|
@@ -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::" | ||
|
|
@@ -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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') }} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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. | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
| 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. | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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): | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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(): | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)") | ||
|
|
||
|
|
@@ -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): | ||
|
|
@@ -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) | ||
|
|
||
There was a problem hiding this comment.
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