From 97613b01ceaeea84b5191781388f844f3e969f95 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 20 May 2026 15:28:55 +0200 Subject: [PATCH 1/3] ci: add nightly workflow .github/workflows/nightly.yml: - Scheduled at 02:17 UTC daily (also workflow_dispatch for manual runs). - based on reusable workflow --- .github/workflows/nightly.yml | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..45b585f --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,37 @@ +name: Nightly relay integration + +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + # TEMPORARY: smoke-test on feature branch + push: + branches: [ j4n/nightly-matrix ] + +jobs: + nightly: + uses: ./.github/workflows/lxc-test.yml + with: + # TEMPORARY: lxc-test defaults to installing cmlxc from main, take our + # branch version instead until it's merged + cmlxc_version: ${{ github.sha }} + cmlxc_commands: | + cmlxc init + cmlxc deploy-cmdeploy --source @main fulltest0 + cmlxc deploy-cmdeploy --source @main fulltest1 + cmlxc test-mini fulltest0 + cmlxc test-cmdeploy fulltest0 fulltest1 + cmlxc stop fulltest0 fulltest1 + cmlxc destroy fulltest1 + cmlxc deploy-cmdeploy --type ipv4 --source @main fulltest-ip0 + cmlxc test-mini fulltest-ip0 + cmlxc test-cmdeploy fulltest-ip0 + cmlxc deploy-madmail fulltest-mad0 + cmlxc test-mini fulltest-mad0 + cmlxc test-madmail fulltest-mad0 + cmlxc test-mini fulltest0 fulltest-mad0 + cmlxc test-mini fulltest-mad0 fulltest0 + cmlxc test-mini fulltest-ip0 fulltest-mad0 + cmlxc test-mini fulltest-mad0 fulltest-ip0 + cmlxc test-mini fulltest-ip0 fulltest0 + cmlxc test-mini fulltest0 fulltest-ip0 From de4d32b4f2c08e3e2772ef627d4dc79d16455fb7 Mon Sep 17 00:00:00 2001 From: j4n Date: Tue, 11 Aug 2026 14:36:37 +0200 Subject: [PATCH 2/3] fix(cli): wait for container readiness before writing ssh-config start_cmd wrote ssh-config immediately after ct.start(), which returns before DHCP hands out a lease; match the other start() paths that pair it with wait_ready(). --- src/cmlxc/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index 02dff4c..4304e9c 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -176,6 +176,7 @@ def start_cmd(args, out): return 1 out.green(f"Starting container {ct.name!r} ...") ct.start() + ct.wait_ready() ix.write_ssh_config() out.green("LXC containers started.") From 9451f90e9b9910a11edde3a5caccba31eab8ab3b Mon Sep 17 00:00:00 2001 From: j4n Date: Tue, 11 Aug 2026 14:38:49 +0200 Subject: [PATCH 3/3] feat(dist-upgrade): in-place Debian 12 to 13 relay upgrades Adds a driver-agnostic verb on RelayContainer plus a nightly scenario that upgrades, redeploys, reboots and runs cmdeploy to redeploy the correct dovecot version. Named dist-upgrade after apt, so it reads as a Debian release upgrade rather than a relay-release one. Needs relay#1002 for Trixie dovecot; switch --source to @main once merged. --- .github/workflows/nightly.yml | 14 +++++++ README.md | 1 + src/cmlxc/cli.py | 31 ++++++++++++++ src/cmlxc/container.py | 76 +++++++++++++++++++++++++++++++++++ tests/test_cli.py | 7 ++++ 5 files changed, 129 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 45b585f..e5a8c3e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -35,3 +35,17 @@ jobs: cmlxc test-mini fulltest-mad0 fulltest-ip0 cmlxc test-mini fulltest-ip0 fulltest0 cmlxc test-mini fulltest0 fulltest-ip0 + + # Simulate in-place Debian 12 -> 13 dist-upgrade, separate parallel job; + # use @main once relay#1002 merges. + upgrade: + uses: ./.github/workflows/lxc-test.yml + with: + cmlxc_version: ${{ github.sha }} + cmlxc_commands: | + cmlxc init + cmlxc deploy-cmdeploy --source @j4n/dovecot-multidist upgrade0 + cmlxc dist-upgrade upgrade0 + cmlxc deploy-cmdeploy --source @j4n/dovecot-multidist upgrade0 + cmlxc stop upgrade0 && cmlxc start upgrade0 + cmlxc test-cmdeploy upgrade0 diff --git a/README.md b/README.md index 5586000..5bc6589 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ builder (wipe-and-reclone). cmlxc status cm0 mad1 # show multiple containers cmlxc status --host # show DNS/SSH setup instructions cmlxc start cm0 # restart a stopped relay + cmlxc dist-upgrade cm0 # in-place Debian upgrade and redeploy cmlxc stop cm0 cm1 # stop relays cmlxc destroy cm0 # stop + delete cmlxc destroy --all # destroy relays, keep DNS/builder diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index 4304e9c..88c36c1 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -181,6 +181,36 @@ def start_cmd(args, out): out.green("LXC containers started.") +# ------------------------------------------------------------------- +# upgrade +# ------------------------------------------------------------------- + + +def upgrade_cmd_options(parser): + parser.add_argument( + "names", + nargs="+", + metavar="NAME", + help="One or more relay containers to upgrade.", + ).completer = _container_completer + + +def upgrade_cmd(args, out): + """Upgrade relay containers from Debian 12 to Debian 13. + + Refuse containers without a deploy state (dns, builder). + """ + ix = Incus(out) + for name in args.names: + ct = ix.get_running_relay(name) + if not ct.get_deploy_state(): + raise SetupError( + f"{name!r} has no deploy state; only deployed relays can be upgraded." + ) + with out.section(f"upgrade: {ct.shortname}"): + ct.upgrade_debian() + + # ------------------------------------------------------------------- # stop # ------------------------------------------------------------------- @@ -609,6 +639,7 @@ def _print_dns_forwarding_status(out, dns_ip, *, host=False): ("test-mini", test_mini_cmd, test_mini_cmd_options), ("status", status_cmd, status_cmd_options), ("start", start_cmd, start_cmd_options), + ("dist-upgrade", upgrade_cmd, upgrade_cmd_options), ("stop", stop_cmd, stop_cmd_options), ("destroy", destroy_cmd, destroy_cmd_options), ] diff --git a/src/cmlxc/container.py b/src/cmlxc/container.py index 9301d18..c17b8f0 100644 --- a/src/cmlxc/container.py +++ b/src/cmlxc/container.py @@ -40,6 +40,57 @@ DNS_NS = "ns.localchat" DNS_CONTAINER_NAME = "ns-localchat" +# In-place Debian 12 -> 13 upgrade, run inside a relay container. +# --force-conf*: DEBIAN_FRONTEND silences debconf but not dpkg conffile +# prompts, and we hand-edit resolv.conf and unbound config. +UPGRADE_SCRIPT = r""" +#!/bin/bash +set -eux +export DEBIAN_FRONTEND=noninteractive +APT="apt-get -y -o DPkg::Lock::Timeout=300 \ +-o Dpkg::Options::=--force-confold \ +-o Dpkg::Options::=--force-confdef" +# --allow-releaseinfo-change is an update-only option, apt errors out if it +# is passed to install or full-upgrade +APT_UPDATE="$APT update --allow-releaseinfo-change" + +# Ensure we have the dpkg lock +systemctl disable --now unattended-upgrades \ + apt-daily.timer apt-daily-upgrade.timer || true +$APT purge unattended-upgrades || true + +# Backup our resolv.conf +systemctl disable --now systemd-resolved || true +systemctl mask systemd-resolved || true +cp /etc/resolv.conf /root/resolv.conf.pre-upgrade + +# Allow libc6 restarts +echo 'libraries/restart-without-asking boolean true' | debconf-set-selections + +# Upgrade to latest bookworm +$APT_UPDATE +$APT install debian-archive-keyring +$APT full-upgrade + +# Rewrite both sources.list and .sources +shopt -s nullglob +for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list \ + /etc/apt/sources.list.d/*.sources; do + sed -i 's/bookworm/trixie/g' "$f" +done +grep -rq trixie /etc/apt/sources.list /etc/apt/sources.list.d/ + +$APT_UPDATE +$APT full-upgrade +$APT --purge autoremove +apt-get clean + +cp /root/resolv.conf.pre-upgrade /etc/resolv.conf + +. /etc/os-release +test "$VERSION_ID" = "13" +""" + class DNSConfigurationError(Exception): """Raised on DNS reachability or response failure.""" @@ -425,6 +476,31 @@ def check(): f" Warning: Services on ports {ports} not ready after {timeout}s" ) + def upgrade_debian(self): + """In-place dist-upgrade this relay from Debian 12 to 13. + + Our apt-pin freezes dovecot at its bookworm build while the trixie + time_t transition removes libssl3/libtirpc3 underneath it, so dovecot + survives as an unrunnable package. Redeploy afterwards. + """ + path = "/root/cmlxc-upgrade.sh" + self.push_file_content(path, UPGRADE_SCRIPT, mode="755") + ret = self.out.shell(f"incus exec {self.name} -- {path}") + if ret: + raise SetupError(f"Debian upgrade failed on {self.shortname} (exit {ret})") + + self.out.print("Restarting container after upgrade ...") + restart = self.incus.run(["restart", self.name, "--timeout=120"], check=False) + if restart.returncode: + self.out.red("Graceful restart timed out; forcing") + self.stop(force=True) + self.start() + self.wait_ready() + if not self.verify_ssh(self.incus.ssh_config_path): + raise SetupError(f"{self.shortname}: no SSH after upgrade reboot") + release = self.bash('. /etc/os-release; echo "$PRETTY_NAME"') + self.out.green(f"{self.shortname} upgraded: {release}") + def verify_ssh(self, ssh_config): cmd = ["ssh", "-F", str(ssh_config), "-o", "ConnectTimeout=60"] cmd += [f"root@{self.domain}", "hostname"] diff --git a/tests/test_cli.py b/tests/test_cli.py index d245c14..de7ae45 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import pytest +from cmlxc.cli import get_parser, upgrade_cmd from cmlxc.driver_base import SourceSpec, parse_source, validate_relay_name from cmlxc.driver_cmdeploy import get_ini_overrides @@ -61,3 +62,9 @@ def test_ini_overrides_disable_ipv6(): assert "disable_ipv6" not in get_ini_overrides("cm0.localchat") overrides = get_ini_overrides("cm0.localchat", disable_ipv6=True) assert overrides["disable_ipv6"] == "True" + + +def test_upgrade_parses_multiple_names(): + args = get_parser().parse_args(["dist-upgrade", "cm0", "cm1"]) + assert args.func is upgrade_cmd + assert args.names == ["cm0", "cm1"]