Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
30 changes: 27 additions & 3 deletions .github/workflows/lxc-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -205,8 +211,26 @@ 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 ..."
# 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 \
/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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
80 changes: 77 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -179,6 +208,51 @@ builder init, and deploy orchestration.
pushes it via SCP and runs `madmail install --simple --ip <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

Expand Down
46 changes: 31 additions & 15 deletions src/cmlxc/cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -42,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 @@ -267,13 +260,25 @@ 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):
"""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

Expand Down Expand Up @@ -303,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)


Expand Down Expand Up @@ -528,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)")

Expand Down Expand Up @@ -613,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):
Expand Down
Loading