diff --git a/.gitignore b/.gitignore index c0f40b9b..a542cf46 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.swp *qr-*.png chatmail*.ini +lxconfigs/ # C extensions diff --git a/cmdeploy/src/cmdeploy/lxc/__init__.py b/cmdeploy/src/cmdeploy/lxc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cmdeploy/src/cmdeploy/lxc/cli.py b/cmdeploy/src/cmdeploy/lxc/cli.py new file mode 100644 index 00000000..eddd3b5e --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/cli.py @@ -0,0 +1,475 @@ +"""lxc-start/stop/status/test subcommands for testing with local containers.""" + +import os +import time + +from ..util import get_git_hash, get_version_string, shell +from .incus import RELAY_IMAGE_ALIAS, Incus, RelayContainer + +RELAY_NAMES = ("test0", "test1") + + +# ------------------------------------------------------------------- +# lxc-start +# ------------------------------------------------------------------- + + +def lxc_start_cmd_options(parser): + _add_name_args( + parser, + help_text="User relay name(s) to create (default: test0).", + ) + parser.add_argument( + "--ipv4-only", + dest="ipv4_only", + action="store_true", + help="Create an IPv4-only container.", + ) + parser.add_argument( + "--run", + action="store_true", + help="Run 'cmdeploy run' on each container after starting it.", + ) + + +def lxc_start_cmd(args, out): + """Create/Ensure and start LXC relay and DNS containers.""" + + with out.section("Preparing container setup"): + _lxc_start_cmd(args, out) + + +def _lxc_start_cmd(args, out): + ix = Incus(out) + sub = out.new_prefixed_out() + out.green("Ensuring base image ...") + ix.ensure_base_image() + out.green("Ensuring DNS container (ns-localchat) ...") + dns_ct = ix.get_dns_container() + dns_ct.ensure() + sub.print(f"DNS container IP: {dns_ct.ipv4}") + + names = args.names if args.names else RELAY_NAMES + relays = list(ix.get_container(n) for n in names) + for ct in relays: + out.green(f"Ensuring container {ct.name!r} ({ct.domain}) ...") + ct.ensure() + ip = ct.ipv4 + + sub.print("Configuring container hostname ...") + ct.configure_hosts(ip) + + sub.print(f"Writing {ct.ini.name} ...") + ct.write_ini(disable_ipv6=args.ipv4_only) + sub.print(f"Config: {ct.ini}") + if args.ipv4_only: + ct.disable_ipv6() + ipv6 = None + else: + output = ct.bash( + "ip -6 addr show scope global -deprecated" + " | grep -oP '(?<=inet6 )[^/]+'", + check=False, + ) + ipv6 = output.strip() if output else None + sub.print(f"{_format_addrs(ip, ipv6)}") + + sub.green(f"Container {ct.name!r} ready: {ct.domain} -> {ip}") + out.print() + + # Reset DNS zones only for the containers we just started + started_cnames = {ct.name for ct in relays} + managed = ix.list_managed() + started = [c for c in managed if c["name"] in started_cnames] + + if started: + out.print( + f"Resetting DNS zones for {len(started)} domain(s) (A + AAAA records) ..." + ) + dns_ct.reset_dns_records(dns_ct.ipv4, started) + + for ct in relays: + if ct.name in started_cnames: + sub.print(f"Configuring DNS in {ct.name} ...") + ct.configure_dns(dns_ct.ipv4) + + # Generate the unified SSH config + out.green("Writing ssh-config ...") + ssh_cfg = ix.write_ssh_config() + sub.print(f"{ssh_cfg}") + + # Verify SSH via the generated config + for ct in relays: + sub.print(f"Verifying SSH to {ct.name} via ssh-config ...") + if ct.verify_ssh(ssh_cfg): + sub.print(f"SSH OK: ssh -F lxconfigs/ssh-config {ct.domain}") + else: + sub.red(f"WARNING: SSH verification failed for {ct.name}") + + # Print integration suggestions + ssh_cfg = ix.ssh_config_path + if not ix.check_ssh_include(): + sub.green( + "\n(Optional) To use containers from any SSH client, add to ~/.ssh/config:" + ) + sub.green(f" Include {ssh_cfg}") + + # Optionally run cmdeploy run + dns on each relay + if args.run: + for ct in relays: + with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {ct.sname} failed (exit {ret})") + return ret + + with out.section("loading DNS zones"): + for ct in relays: + ret = _run_cmdeploy( + "dns", ct, ix, out, + extra=["--zonefile", str(ct.zone)], + ) + if ret: + out.red(f"DNS for {ct.sname} failed (exit {ret})") + return ret + if ct.zone.exists(): + dns_ct.set_dns_records(ct.zone.read_text()) + out.print(f"Restarting filtermail-incoming on {ct.name}") + ct.bash("systemctl restart filtermail-incoming") + + +# ------------------------------------------------------------------- +# lxc-stop +# ------------------------------------------------------------------- + + +def lxc_stop_cmd_options(parser): + parser.add_argument( + "--destroy", + action="store_true", + help="Delete containers and their config files after stopping.", + ) + parser.add_argument( + "--destroy-all", + dest="destroy_all", + action="store_true", + help="Like --destroy, but also remove the ns-localchat DNS container.", + ) + _add_name_args( + parser, + help_text="Container name(s) to stop (default: test0 + test1).", + ) + + +def lxc_stop_cmd(args, out): + """Stop (and optionally destroy) local LXC relay containers.""" + ix = Incus(out) + names = args.names or RELAY_NAMES + destroy = args.destroy or args.destroy_all + + for ct in map(ix.get_container, names): + if destroy: + out.green(f"Destroying container {ct.name!r} ...") + ct.destroy() + else: + out.green(f"Stopping container {ct.name!r} ...") + ct.stop(force=True) + + if args.destroy_all: + dns_ct = ix.get_dns_container() + out.green(f"Destroying DNS container {dns_ct.name!r} ...") + dns_ct.destroy() + ix.delete_images() + + if destroy: + ix.write_ssh_config() + out.green("LXC containers destroyed.") + else: + out.green("LXC containers stopped.") + + +# ------------------------------------------------------------------- +# lxc-test +# ------------------------------------------------------------------- + + +def lxc_test_cmd_options(parser): + parser.add_argument( + "--one", + action="store_true", + help="Only deploy and test against test0 (skip test1).", + ) + + +def lxc_test_cmd(args, out): + """Run full LXC pipeline: start, deploy, DNS, zone files, and tests. + + All commands run directly on the host using + ``--ssh-config lxconfigs/ssh-config`` for SSH access. + """ + ix = Incus(out) + t_total = time.time() + relay_names = list(RELAY_NAMES) + if args.one: + relay_names = relay_names[:1] + + local_hash = get_git_hash() + + # Per-relay: start, deploy, then snapshot the first relay as a + # reusable image so the second relay launches pre-deployed. + ipv4_only_flags = {RELAY_NAMES[0]: False, RELAY_NAMES[1]: True} + + for ct in map(ix.get_container, relay_names): + name = ct.sname + ipv4_only = ipv4_only_flags.get(name, False) + v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else "" + start_cmd = f"cmdeploy lxc-start{v_flag} {name}" + if ipv4_only: + start_cmd += " --ipv4-only" + with out.section(f"cmdeploy lxc-start: {name}"): + ret = out.shell(start_cmd, cwd=str(ix.project_root)) + if ret: + return ret + + status = _deploy_status(ct, local_hash, ix) + with out.section(f"cmdeploy run: {name}"): + if "IN-SYNC" in status: + out.print(f"{name} is {status}, skipping") + else: + ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {name} failed (exit {ret})") + return ret + + # Snapshot the first relay so subsequent ones launch pre-deployed + if not ix.find_image([RELAY_IMAGE_ALIAS]): + with out.section("lxc-test: caching relay image"): + ct.publish_as_relay_image() + + for ct in map(ix.get_container, relay_names): + with out.section(f"cmdeploy dns: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("dns", ct, ix, out, extra=["--zonefile", str(ct.zone)]) + if ret: + out.red(f"DNS for {ct.sname} failed (exit {ret})") + return ret + + with out.section(f"lxc-test: loading DNS zones {' & '.join(relay_names)}"): + dns_ct = ix.get_dns_container() + for ct in map(ix.get_container, relay_names): + if ct.zone.exists(): + zone_data = ct.zone.read_text() + out.print(f"Loading {ct.zone} into PowerDNS ...") + dns_ct.set_dns_records(zone_data) + + # Restart filtermail so its in-process DNS cache + # does not hold stale negative DKIM responses + # from before the zones were loaded. + for ct in map(ix.get_container, relay_names): + out.print(f"Restarting filtermail-incoming on {ct.name} ...") + ct.bash("systemctl restart filtermail-incoming") + + with out.section("cmdeploy test"): + first = ix.get_container(relay_names[0]) + env = None + if len(relay_names) > 1: + env = os.environ.copy() + env["CHATMAIL_DOMAIN2"] = ix.get_container(relay_names[1]).domain + ret = _run_cmdeploy("test", first, ix, out, **({"env": env} if env else {})) + if ret: + out.red(f"Tests failed (exit {ret})") + return ret + + elapsed = time.time() - t_total + out.section_line(f"lxc-test complete ({elapsed:.1f}s)") + if out.section_timings: + out.print("Section timings:") + for name, secs in out.section_timings: + out.print(f" {name:.<50s} {secs:5.1f}s") + out.print(f" {'total':.<50s} {elapsed:5.1f}s") + out.section_timings.clear() + return 0 + + +# ------------------------------------------------------------------- +# lxc-status +# ------------------------------------------------------------------- + + +def lxc_status_cmd_options(parser): + pass + + +def lxc_status_cmd(args, out): + """Show status of local LXC chatmail containers.""" + ix = Incus(out) + containers = ix.list_managed() + if not containers: + out.red("No LXC containers found. Run 'cmdeploy lxc-start' first.") + return 1 + + local_hash = get_git_hash() + + # Get storage pool path for display + storage_path = None + data = ix.run_json(["storage", "show", "default"], check=False) + if data: + storage_path = data.get("config", {}).get("source") + msg = "Container status" + if storage_path: + msg += f": {storage_path}" + out.section_line(msg) + + dns_ip = None + for c in containers: + _print_container_status(out, c, ix, local_hash) + if c["name"] == ix.get_dns_container().name: + dns_ip = c["ip"] + + out.section_line("Host ssh and DNS configuration") + _print_ssh_status(out, ix) + _print_dns_forwarding_status(out, dns_ip) + return 0 + + +def _print_container_status(out, c, ix, local_hash): + """Print name/status, domain/IPs, and RAM for one container.""" + cname = c["name"] + is_running = c.get("status") == "Running" + ct = ix.get_container(cname) + + # First line: name + running/STOPPED + deploy status + if not is_running: + tag = "STOPPED" + elif not isinstance(ct, RelayContainer): + tag = "running" + else: + tag = f"running {_deploy_status(ct, local_hash, ix)}" + out.print(f"{cname:20s} {tag}") + + # Second line: domain, IPv4, IPv6 + domain = c.get("domain", "") + ip = c.get("ip") or "?" + ipv6 = c.get("ipv6") + out.print(f"{domain:20s} {_format_addrs(ip, ipv6)}") + + # Third line: RAM (RSS), config + detail_out = out.new_prefixed_out(" " * 21) + try: + used, total = ct.rss_mib() + except Exception: + ram_str = "RSS ?" + else: + ram_str = f"RSS {used}/{total} MiB ({used * 100 // total}%)" + + if isinstance(ct, RelayContainer): + detail = f"{ram_str}, config: {os.path.relpath(ct.ini)}" + else: + detail = ram_str + + detail_out.print(detail) + out.print() + + +def _print_ssh_status(out, ix): + """Print SSH integration status.""" + ssh_cfg = ix.ssh_config_path + if ix.check_ssh_include(): + out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓") + else: + out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config") + sub = out.new_prefixed_out() + sub.print("Add to ~/.ssh/config:") + sub.print(f" Include {ssh_cfg}") + + +def _print_dns_forwarding_status(out, dns_ip): + """Print host DNS forwarding status for .localchat.""" + sub = out.new_prefixed_out() + if not dns_ip: + out.red("DNS: ns-localchat container not found") + return + try: + rv = shell("resolvectl status incusbr0") + dns_ok = dns_ip in rv.stdout and "localchat" in rv.stdout + except Exception: + dns_ok = None + if dns_ok is True: + out.green(f"DNS: .localchat forwarding to {dns_ip} ✓") + elif dns_ok is False: + out.red("DNS: .localchat forwarding NOT configured") + sub.print("Run:") + sub.print(f" sudo resolvectl dns incusbr0 {dns_ip}") + sub.print(" sudo resolvectl domain incusbr0 ~localchat") + else: + sub.print("DNS: .localchat forwarding status UNKNOWN") + + +# ------------------------------------------------------------------- +# Internal helpers +# ------------------------------------------------------------------- + + +def _format_addrs(ip, ipv6=None): + parts = [f"IPv4 {ip}"] + if ipv6: + parts.append(f"IPv6 {ipv6}") + return ", ".join(parts) + + +def _deploy_status(ct, local_hash, ix): + """Return a human-readable deploy status string. + + Compares the full deployed version (hash + diff) against + the local state built by :func:`~cmdeploy.util.get_version_string`. + """ + deployed = ct.deployed_version() + if deployed is None: + return "NOT DEPLOYED" + + # A container launched from the relay image has the same + # git hash but a different domain — always redeploy. + deployed_domain = ct.deployed_domain() + if deployed_domain and deployed_domain != ct.domain: + return f"DOMAIN-MISMATCH (deployed: {deployed_domain})" + + deployed_lines = deployed.splitlines() + deployed_hash = deployed_lines[0] if deployed_lines else "" + short = deployed_hash[:12] + + if not local_hash: + return f"UNKNOWN (deployed: {short})" + + local_short = local_hash[:12] + if deployed_hash != local_hash: + return f"STALE (deployed: {short}, local: {local_short})" + + # Hash matches — check for uncommitted diffs + local_version = get_version_string() + if deployed != local_version: + return f"DIRTY ({local_short}, undeployed changes)" + + return f"IN-SYNC ({short})" + + +def _add_name_args(parser, help_text): + parser.add_argument("names", nargs="*", metavar="NAME", help=help_text) + + +def _run_cmdeploy(subcmd, ct, ix, out, extra=None, **kwargs): + """Run ``cmdeploy `` with standard --config/--ssh flags. + + *ct* is a Container (uses ``ct.ini`` and ``ct.domain``). + Returns the subprocess exit code. + """ + extra_str = " ".join(extra) if extra else "" + v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else "" + cmd = f""" + cmdeploy {subcmd}{v_flag} + --config {ct.ini} + --ssh-config {ix.ssh_config_path} + --ssh-host {ct.domain} + {extra_str} + """ + if "cwd" not in kwargs: + kwargs["cwd"] = str(ix.project_root) + return out.shell(cmd, **kwargs) diff --git a/cmdeploy/src/cmdeploy/lxc/incus.py b/cmdeploy/src/cmdeploy/lxc/incus.py new file mode 100644 index 00000000..36700c1d --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/incus.py @@ -0,0 +1,768 @@ +"""Core Incus operations for local chatmail LXC containers.""" + +import json +import subprocess +import textwrap +import time +from pathlib import Path + +from ..util import shell + +LABEL_KEY = "user.localchat-managed" +SSH_KEY_NAME = "id_localchat" +DOMAIN_SUFFIX = ".localchat" +UPSTREAM_IMAGE = "images:debian/12" +BASE_IMAGE_ALIAS = "localchat-base" +BASE_SETUP_NAME = "localchat-base-setup" +RELAY_IMAGE_ALIAS = "localchat-relay" + +DNS_CONTAINER_NAME = "ns-localchat" +DNS_DOMAIN = "ns.localchat" + + +class DNSConfigurationError(Exception): + """Raised when the DNS container is not reachable or not answering.""" + + +def _extract_ip(net_data, family="inet"): + """Extract the first global-scope IP of *family* from network state data. + + *net_data* is the ``state.network`` dict from ``incus list --format=json``. + *family* is ``"inet"`` for IPv4 or ``"inet6"`` for IPv6. + Returns the address string, or 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": + return addr["address"] + return None + + +class Incus: + """Gateway for all Incus container operations. + + Instantiated once per CLI command and passed around so that + all modules share a single entry point for Incus interactions. + """ + + def __init__(self, out): + self.out = out + self.project_root = Path(__file__).resolve().parent.parent.parent.parent.parent + self.lxconfigs_dir = self.project_root / "lxconfigs" + self.lxconfigs_dir.mkdir(exist_ok=True) + self.ssh_key_path = self.lxconfigs_dir / SSH_KEY_NAME + if not self.ssh_key_path.exists(): + shell( + f"ssh-keygen -t ed25519 -f {self.ssh_key_path} -N '' -C localchat", + check=True, + ) + self.ssh_config_path = self.lxconfigs_dir / "ssh-config" + + def write_ssh_config(self): + """Write ``lxconfigs/ssh-config`` mapping all containers to their IPs. + + Each Host block maps the container name, the domain name, and the + short relay name (e.g. ``_test0``) to the container's IP, using the + shared localchat SSH key. Returns the path to the file. + """ + containers = self.list_managed() + key_path = self.ssh_key_path + lines = ["# Auto-generated by cmdeploy lxc-start — do not edit\n"] + for c in containers: + hosts = [c["name"]] + domain = c.get("domain", "") + if domain and domain != c["name"]: + hosts.append(domain) + short = domain.split(".")[0] + if short and short not in hosts: + hosts.append(short) + lines.append(f"\nHost {' '.join(hosts)}\n") + lines.append(f" Hostname {c['ip']}\n") + lines.append(" User root\n") + lines.append(f" IdentityFile {key_path}\n") + lines.append(" IdentitiesOnly yes\n") + lines.append(" StrictHostKeyChecking accept-new\n") + lines.append(" UserKnownHostsFile /dev/null\n") + lines.append(" LogLevel ERROR\n") + path = self.ssh_config_path + path.write_text("".join(lines)) + return path + + def check_ssh_include(self): + """Check if the user's ~/.ssh/config already includes our ssh-config.""" + user_ssh_config = Path.home() / ".ssh" / "config" + if not user_ssh_config.exists(): + return False + lines = user_ssh_config.read_text().splitlines() + target = f"include {self.ssh_config_path}".lower() + return any(line.strip().lower() == target for line in lines) + + def get_host_nameservers(self): + """Return upstream nameservers found on the host.""" + ns = [] + for path in ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"]: + p = Path(path) + if p.exists(): + for line in p.read_text().splitlines(): + if line.strip().startswith("nameserver "): + addr = line.split()[1] + if addr not in ("127.0.0.1", "127.0.0.53", "::1"): + if addr not in ns: + ns.append(addr) + if ns: + break + return ns + + def run(self, args, check=True, capture=True, input=None): + """Run an incus command. + + When *capture* is True and *verbosity* >= 1, output is streamed + to the terminal line-by-line while also being captured for + later return via result.stdout. + """ + cmd = ["incus", "--quiet"] + list(args) + sub = self.out.new_prefixed_out(" ") + + if not capture: + # Simple case: let subprocess handle streams (no capture) + if self.out.verbosity >= 1: + sub.print(f"$ {' '.join(cmd)}") + return subprocess.run( + cmd, text=True, input=input, check=check, stdout=None, stderr=None + ) + + # Capture case: we may need to stream while capturing + if sub.verbosity >= 1: + cmd_lines = " ".join(cmd).splitlines() + sub.print(f"$ {cmd_lines.pop(0)}") + if sub.verbosity >= 2: + for line in cmd_lines: + sub.print(f" {line}") + + proc = subprocess.Popen( + cmd, + text=True, + stdin=subprocess.PIPE if input else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout_lines = [] + if input: + proc.stdin.write(input) + proc.stdin.close() + + for line in proc.stdout: + stdout_lines.append(line) + if sub.verbosity >= 2: + sub.print(f" > {line.rstrip()}") + + stderr = proc.stderr.read() + ret = proc.wait() + stdout = "".join(stdout_lines) + if check and ret != 0: + full_output = stdout + stderr + for line in full_output.splitlines(): + if sub.verbosity < 1: # and we haven't printed it yet + sub.red(line) + raise subprocess.CalledProcessError(ret, cmd, output=stdout, stderr=stderr) + + return subprocess.CompletedProcess(cmd, ret, stdout=stdout, stderr=stderr) + + def run_json(self, args, check=True): + """Run an incus command with ``--format=json``. + + Returns the parsed JSON on success. + When *check* is True raises ``subprocess.CalledProcessError`` + on non-zero exit; when False returns *None* instead. + """ + result = self.run( + list(args) + ["--format=json"], + check=check, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + def run_output(self, args, check=True): + """Run an incus command and return its stripped stdout. + + When *check* is False, returns *None* on non-zero exit + instead of raising. + """ + result = self.run(args, check=check) + if result.returncode != 0: + return None + return result.stdout.strip() + + def find_image(self, aliases): + """Return the first alias from *aliases* that exists, else None.""" + images = self.run_json(["image", "list"], check=False) or [] + existing = {a.get("name") for img in images for a in img.get("aliases", [])} + for alias in aliases: + if alias in existing: + return alias + return None + + def delete_images(self): + """Delete the cached base and relay images.""" + for alias in (RELAY_IMAGE_ALIAS, BASE_IMAGE_ALIAS): + self.run(["image", "delete", alias], check=False) # ok if absent + + def list_managed(self): + """Return list of dicts with name, ip, ipv6, domain, status, memory_usage.""" + containers = [] + for ct in self.run_json(["list"]): + config = ct.get("config", {}) + if config.get(LABEL_KEY) != "true": + continue + name = ct["name"] + state = ct.get("state", {}) + net = state.get("network") or {} + containers.append( + { + "name": name, + "ip": _extract_ip(net, "inet"), + "ipv6": _extract_ip(net, "inet6"), + "domain": config.get( + "user.localchat-domain", f"{name}{DOMAIN_SUFFIX}" + ), + "status": ct.get("status", "Unknown"), + "memory_usage": state.get("memory", {}).get("usage", 0), + } + ) + return containers + + def ensure_base_image(self): + """Build and cache a base image with openssh and the SSH key. + + The image is published as a local incus image with alias + 'localchat-base'. Subsequent container launches use this + image instead of the upstream Debian 12, skipping the + slow apt-get install step. + Returns the image alias. + """ + if self.find_image([BASE_IMAGE_ALIAS]): + self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' already cached.") + return BASE_IMAGE_ALIAS + + self.out.print(" Building base image (one-time setup) ...") + + self.run(["delete", BASE_SETUP_NAME, "--force"], check=False) + self.run(["image", "delete", BASE_IMAGE_ALIAS], check=False) + self.run(["launch", UPSTREAM_IMAGE, BASE_SETUP_NAME]) + + ct = Container(self, BASE_SETUP_NAME) + ct.wait_ready() + + key_path = self.ssh_key_path + pub_key = key_path.with_suffix(".pub").read_text().strip() + host_ns = self.get_host_nameservers() + ns_lines = "\n".join(f"nameserver {n}" for n in host_ns) + ct.bash(f""" + printf '{ns_lines}\n' > /etc/resolv.conf + apt-get -o DPkg::Lock::Timeout=60 update + DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3 + systemctl enable ssh + apt-get clean + mkdir -p /root/.ssh + chmod 700 /root/.ssh + echo '{pub_key}' > /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + """) + + self.run(["stop", BASE_SETUP_NAME]) + self.run(["publish", BASE_SETUP_NAME, f"--alias={BASE_IMAGE_ALIAS}"]) + self.run(["delete", BASE_SETUP_NAME, "--force"]) + self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' ready.") + return BASE_IMAGE_ALIAS + + def get_container(self, name): + """Return a container handle for the given name. + + Accepts both short relay names (``test0``) and full Incus + container names (``test0-localchat``). Returns + ``DNSContainer`` for the DNS container and + ``RelayContainer`` for everything else. + """ + if name == DNS_CONTAINER_NAME: + return DNSContainer(self) + return RelayContainer(self, name.removesuffix("-localchat")) + + def get_dns_container(self): + """Return a DNSContainer handle.""" + return DNSContainer(self) + + +class Container: + """The base container handle wraps all interactions with incus.""" + + def __init__(self, incus, name, domain=None): + self.incus = incus + self.out = incus.out + self.name = name + self.domain = domain or f"{name}{DOMAIN_SUFFIX}" + self.ipv4 = None + self.ipv6 = None + + def bash(self, script, check=True): + """Returns stdout from executing ``bash -ec